|
| 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