From fc62a30384deccd17c488d33867414ca15b4a82b Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 10 Jun 2026 21:52:48 +0200 Subject: [PATCH] fix(app): cancel readiness loop on lifespan shutdown (review round 2) _readiness_refresh_loop is started with tg.start_soon and loops forever with no shutdown_event check. anyio waits for start_soon tasks on normal task-group exit rather than cancelling them, so graceful shutdown hung until uvicorn's timeout. Cancel the task group's scope after teardown() to stop the loop and any stragglers, mirroring _maybe_login_flow_cleanup. Also document the cache ttl_seconds startup-override and the inclusive is_stale boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- nextcloud_mcp_server/app.py | 8 ++++++++ nextcloud_mcp_server/observability/readiness.py | 1 + 2 files changed, 9 insertions(+) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index db03c2dd..bbbdf599 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -434,6 +434,8 @@ def _clear_vector_sync_state() -> None: # Fallback public URL when NEXTCLOUD_MCP_SERVER_URL is unset (dev/local). _DEFAULT_MCP_SERVER_URL = "http://localhost:8000" +# Pre-loop default; _readiness_refresh_loop overrides ttl_seconds at startup to +# 2x the configured refresh interval, so bumping this value alone has no effect. _readiness_cache = ReadinessCache(ttl_seconds=30.0) @@ -2186,6 +2188,12 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # Request path must not spawn into a cancelling group. _vector_sync_state.eviction_task_group = None await teardown() + # teardown() signals the sync tasks to drain, but the readiness loop + # runs forever and has no shutdown_event to observe. anyio waits for + # start_soon tasks on normal exit rather than cancelling them, so + # without this the lifespan shutdown hangs until uvicorn's graceful + # timeout. Cancel the group to stop the loop (and any stragglers). + tg.cancel_scope.cancel() # Health check endpoints for Kubernetes probes def health_live(request): diff --git a/nextcloud_mcp_server/observability/readiness.py b/nextcloud_mcp_server/observability/readiness.py index dd081ccf..4d138dbc 100644 --- a/nextcloud_mcp_server/observability/readiness.py +++ b/nextcloud_mcp_server/observability/readiness.py @@ -70,6 +70,7 @@ class ReadinessCache(BaseModel): if not self.statuses: return True current = time.monotonic() if now is None else now + # Inclusive boundary: exactly ttl_seconds old counts as stale. return any( current - status.checked_at >= self.ttl_seconds for status in self.statuses.values()