Skip to content

Commit 3b37f99

Browse files
committed
Address lifespan shutdown review feedback
1 parent 0febbad commit 3b37f99

3 files changed

Lines changed: 95 additions & 20 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ All notable changes to this project will be documented in this file.
1414

1515
### Changed
1616

17-
- **FastAPI lifespan cleanup** — Controller shutdown now cancels and awaits the scheduled-step background loop, then closes the shared async HTTP event client.
17+
- **FastAPI lifespan cleanup** — Controller shutdown now cancels the scheduled-step background loop and waits briefly for it to stop.
1818

1919
### Tests
2020

21-
- `tests/test_server.py` — scheduler task cancellation and HTTP client cleanup during FastAPI lifespan shutdown.
21+
- `tests/test_server.py` — scheduler task cancellation and bounded shutdown waiting during FastAPI lifespan shutdown.
2222

2323
## [1.1.1] - 2026-05-20
2424

src/supervaizer/server.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737

3838
from supervaizer.__version__ import VERSION
3939
from supervaizer.account import Account
40-
from supervaizer.account_service import close_httpx_client
4140
from supervaizer.agent import (
4241
Agent,
4342
) # <-- MODIFIED: removed AdminIPAllowlistMiddleware, create_admin_routes imports
@@ -77,6 +76,7 @@
7776
insp = inspect
7877

7978
T = TypeVar("T")
79+
SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS = 5.0
8080

8181
# Additional imports for server persistence
8282

@@ -530,15 +530,20 @@ async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
530530
try:
531531
yield
532532
finally:
533-
# Let the scheduler observe cancellation instead of leaving a
534-
# pending task attached to the event loop.
533+
# Give the scheduler a bounded chance to observe cancellation.
535534
scheduled_step_task.cancel()
536-
with suppress(asyncio.CancelledError):
537-
await scheduled_step_task
538-
539-
# The event client is process-wide; close it once the app has
540-
# stopped accepting controller work.
541-
await close_httpx_client()
535+
done, pending = await asyncio.wait(
536+
{scheduled_step_task},
537+
timeout=SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS,
538+
)
539+
if pending:
540+
log.warning(
541+
"[Scheduled step] Shutdown timed out while waiting for "
542+
"the scheduler task to stop"
543+
)
544+
if done:
545+
with suppress(asyncio.CancelledError):
546+
await scheduled_step_task
542547

543548
app = FastAPI(
544549
lifespan=_lifespan,

tests/test_server.py

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from fastapi.testclient import TestClient
2626
from rich import inspect
2727

28+
import supervaizer.server as server_module
2829
from supervaizer import Server
2930
from supervaizer.__version__ import VERSION
3031
from supervaizer.agent import Agent
@@ -407,19 +408,22 @@ def fake_create_task(
407408
created_task_names.append(name)
408409
return scheduled_step_task
409410

410-
close_httpx_client = mocker.patch(
411-
"supervaizer.server.close_httpx_client",
412-
new=mocker.AsyncMock(),
413-
)
411+
async def fake_wait(
412+
tasks: set[object], *, timeout: float | None = None
413+
) -> tuple[set[object], set[object]]:
414+
waited_tasks.append(set(tasks))
415+
waited_timeouts.append(timeout)
416+
return set(tasks), set()
417+
414418
monkeypatch.setattr(
415419
Server.__init__.__globals__["asyncio"],
416420
"create_task",
417421
fake_create_task,
418422
)
419-
monkeypatch.setitem(
420-
Server.__init__.__globals__,
421-
"close_httpx_client",
422-
close_httpx_client,
423+
monkeypatch.setattr(
424+
Server.__init__.__globals__["asyncio"],
425+
"wait",
426+
fake_wait,
423427
)
424428

425429
server = Server(
@@ -438,7 +442,73 @@ def fake_create_task(
438442

439443
assert scheduled_step_task.cancelled is True
440444
assert scheduled_step_task.awaited is True
441-
close_httpx_client.assert_awaited_once_with()
445+
assert waited_tasks == [{scheduled_step_task}]
446+
assert waited_timeouts == [server_module.SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS]
447+
448+
449+
@pytest.mark.asyncio
450+
async def test_server_lifespan_leaves_shutdown_after_scheduler_timeout(
451+
agent_fixture: Agent,
452+
monkeypatch: pytest.MonkeyPatch,
453+
) -> None:
454+
monkeypatch.setenv("SUPERVAIZER_LOCAL_MODE", "false")
455+
456+
class PendingScheduledStepTask:
457+
def __init__(self) -> None:
458+
self.cancelled = False
459+
self.awaited = False
460+
461+
def cancel(self) -> None:
462+
self.cancelled = True
463+
464+
def __await__(self) -> Any:
465+
async def _unexpected() -> None:
466+
self.awaited = True
467+
raise AssertionError("pending scheduler task should not be awaited")
468+
469+
return _unexpected().__await__()
470+
471+
pending_task = PendingScheduledStepTask()
472+
473+
def fake_create_task(
474+
coro: object, *, name: str | None = None, **_kwargs: Any
475+
) -> PendingScheduledStepTask:
476+
if hasattr(coro, "close"):
477+
coro.close()
478+
return pending_task
479+
480+
async def fake_wait(
481+
tasks: set[object], *, timeout: float | None = None
482+
) -> tuple[set[object], set[object]]:
483+
assert timeout == server_module.SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS
484+
return set(), set(tasks)
485+
486+
monkeypatch.setattr(
487+
Server.__init__.__globals__["asyncio"],
488+
"create_task",
489+
fake_create_task,
490+
)
491+
monkeypatch.setattr(
492+
Server.__init__.__globals__["asyncio"],
493+
"wait",
494+
fake_wait,
495+
)
496+
497+
server = Server(
498+
agents=[agent_fixture],
499+
supervisor_account=None,
500+
admin_interface=False,
501+
host="localhost",
502+
port=8001,
503+
environment="test",
504+
api_key="test-key",
505+
)
506+
507+
async with server.app.router.lifespan_context(server.app):
508+
assert pending_task.cancelled is False
509+
510+
assert pending_task.cancelled is True
511+
assert pending_task.awaited is False
442512

443513

444514
def test_server_decrypt(server_fixture: Server) -> None:

0 commit comments

Comments
 (0)