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) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-10 21:52:48 +02:00
co-authored by Claude Opus 4.8
parent cc2ce6e853
commit fc62a30384
2 changed files with 9 additions and 0 deletions
+8
View File
@@ -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):
@@ -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()