Skip to content

Commit efbc758

Browse files
fix: await background sync task cancellation in lifespan shutdown (#456)
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
1 parent a0f20eb commit efbc758

2 files changed

Lines changed: 75 additions & 0 deletions

File tree

src/basic_memory/api/app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
5959
app.state.sync_task = asyncio.create_task(initialize_file_sync(app_config))
6060
else:
6161
logger.info("Sync changes disabled. Skipping file sync service.")
62+
app.state.sync_task = None
6263

6364
# proceed with startup
6465
yield
@@ -67,6 +68,10 @@ async def lifespan(app: FastAPI): # pragma: no cover
6768
if app.state.sync_task:
6869
logger.info("Stopping sync...")
6970
app.state.sync_task.cancel() # pyright: ignore
71+
try:
72+
await app.state.sync_task
73+
except asyncio.CancelledError:
74+
logger.info("Sync task cancelled successfully")
7075

7176
await db.shutdown_db()
7277

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Integration test for FastAPI lifespan shutdown behavior.
3+
4+
This test verifies the asyncio cancellation pattern used by the API lifespan:
5+
when the background sync task is cancelled during shutdown, it must be *awaited*
6+
before database shutdown begins. This prevents "hang on exit" scenarios in
7+
`asyncio.run(...)` callers (e.g. CLI/MCP clients using httpx ASGITransport).
8+
"""
9+
10+
import asyncio
11+
12+
from httpx import ASGITransport, AsyncClient
13+
14+
15+
def test_lifespan_shutdown_awaits_sync_task_cancellation(app, monkeypatch):
16+
"""
17+
Ensure lifespan shutdown awaits the cancelled background sync task.
18+
19+
Why this is deterministic:
20+
- Cancelling a task does not make it "done" immediately; it becomes done only
21+
once the event loop schedules it and it processes the CancelledError.
22+
- In the buggy version, shutdown proceeded directly to db.shutdown_db()
23+
immediately after calling cancel(), so at *entry* to shutdown_db the task
24+
is still not done.
25+
- In the fixed version, lifespan does `await sync_task` before shutdown_db,
26+
so by the time shutdown_db is called, the task is done (cancelled).
27+
"""
28+
29+
# Import the *module* (not the package-level FastAPI `basic_memory.api.app` export)
30+
# so monkeypatching affects the exact symbols referenced inside lifespan().
31+
#
32+
# Note: `basic_memory/api/__init__.py` re-exports `app`, so `import basic_memory.api.app`
33+
# can resolve to the FastAPI instance rather than the `basic_memory.api.app` module.
34+
import importlib
35+
36+
api_app_module = importlib.import_module("basic_memory.api.app")
37+
38+
# Keep startup cheap: we don't need real DB init for this ordering test.
39+
async def _noop_initialize_app(_app_config):
40+
return None
41+
42+
async def _fake_get_or_create_db(*_args, **_kwargs):
43+
return object(), object()
44+
45+
monkeypatch.setattr(api_app_module, "initialize_app", _noop_initialize_app)
46+
monkeypatch.setattr(api_app_module.db, "get_or_create_db", _fake_get_or_create_db)
47+
48+
# Make the sync task long-lived so it must be cancelled on shutdown.
49+
async def _fake_initialize_file_sync(_app_config):
50+
await asyncio.Event().wait()
51+
52+
monkeypatch.setattr(api_app_module, "initialize_file_sync", _fake_initialize_file_sync)
53+
54+
# Assert ordering: shutdown_db must be called only after the sync_task is done.
55+
async def _assert_sync_task_done_before_db_shutdown():
56+
assert api_app_module.app.state.sync_task is not None
57+
assert api_app_module.app.state.sync_task.done()
58+
59+
monkeypatch.setattr(api_app_module.db, "shutdown_db", _assert_sync_task_done_before_db_shutdown)
60+
61+
async def _run_client_once():
62+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
63+
# Any request is sufficient to trigger lifespan startup/shutdown.
64+
await client.get("/__nonexistent__")
65+
66+
# Use asyncio.run to match the CLI/MCP execution model where loop teardown
67+
# would hang if a background task is left running.
68+
asyncio.run(_run_client_once())
69+
70+

0 commit comments

Comments
 (0)