Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/apps/src/microsoft_teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,16 @@ async def on_http_ready() -> None:
tasks.append(plugin.on_start(event))
await asyncio.gather(*tasks)

except (asyncio.CancelledError, KeyboardInterrupt):
self.log.info("Teams app shutting down")
try:
for plugin in reversed(self.plugins):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, could you run self.stop()?

Copy link
Collaborator Author

@lilyydu lilyydu Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had it as stop initially and then copilot left this comment - #304 (comment)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is our main concern tho:

at the top of stop - we immediately return if the app is not running. if cancellation happens before on_http_ready() fires , self._running is still False, so self.stop() would return without cleaning up anything

it is very similiar logic tho, so I could just extract the common code into a single helper

if hasattr(plugin, "on_stop") and callable(plugin.on_stop):
await plugin.on_stop()
finally:
self._running = False
self._events.emit("stop", StopEvent())

except Exception as error:
self._running = False
self.log.error(f"Failed to start app: {error}")
Expand Down
63 changes: 62 additions & 1 deletion packages/apps/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
TokenProtocol,
TypingActivity,
)
from microsoft_teams.apps import ActivityContext, ActivityEvent, App, AppOptions
from microsoft_teams.apps import ActivityContext, ActivityEvent, App, AppOptions, Plugin, PluginBase, PluginStartEvent


class FakeToken(TokenProtocol):
Expand Down Expand Up @@ -167,6 +167,67 @@ async def mock_on_stop():
await app_with_options.stop()
assert not app_with_options.is_running

@pytest.mark.asyncio
async def test_app_start_with_multiple_plugins_cancelled(self, mock_logger, mock_storage):
@Plugin(name="PluginTwo", version="1.0", description="plugin")
class PluginTwo(PluginBase):
def __init__(self):
super().__init__()
self.stop_called = False

async def on_start(self, event: PluginStartEvent) -> None: # noqa: D102
pass

async def on_stop(self) -> None: # noqa: D102
self.stop_called = True

plugin_two = PluginTwo()

options = AppOptions(
logger=mock_logger,
storage=mock_storage,
client_id="test-client-id",
client_secret="test-secret",
plugins=[plugin_two],
)
app = App(**options)

mock_stream = MagicMock()
mock_stream.events = MagicMock()
mock_stream.events.on = MagicMock()
mock_stream.close = AsyncMock()
app.http.create_stream = MagicMock(return_value=mock_stream)

block = asyncio.Event()

async def mock_on_start_blocking(event):
if app.http.on_ready_callback:
await app.http.on_ready_callback()
await block.wait()

with patch.object(app.http, "on_start", new_callable=AsyncMock, side_effect=mock_on_start_blocking):
app.http.on_stop = AsyncMock()

start_task = asyncio.create_task(app.start(3978))

for _ in range(50):
await asyncio.sleep(0.01)
if app.is_running:
break

assert app.is_running, "App should be running before cancellation"

start_task.cancel()
try:
await start_task
except asyncio.CancelledError:
pass

mock_logger.info.assert_any_call("Teams app shutting down")

assert plugin_two.stop_called, "plugin two on_stop was called."
assert not app.is_running, "App should not be running after cancellation"

# Event Testing - Focus on functional behavior

@pytest.mark.asyncio
Expand Down