From ce6b4a41749ce9d8980a78bc50fc1b4c477bf7ab Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 24 Apr 2026 17:11:44 -0400 Subject: [PATCH] fix(web/api): scope DB-retry sleep so tests don't starve background tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_lifespan_db_retry patched decnet.web.api.asyncio.sleep to skip the DB-retry backoff. Problem: asyncio is a shared module — the patch leaks to every caller that looked up asyncio.sleep via `import asyncio`, including run_health_heartbeat's own sleep loop. That heartbeat task spawns inside the same lifespan; with its sleep mocked, the while-loop spins tight, starves cancellation, and leaves an orphan task that pytest-timeout eventually signals — surfacing as the 'Task exception was never retrieved' warnings the user saw when running the suite. Fix: give decnet.web.api a local binding `_retry_sleep = asyncio.sleep` for the DB-retry wait, and have the test patch that instead. Narrowly scoped, no impact on asyncio.sleep callers elsewhere. Test timing before: 12s with --timeout=10 (interrupted by signal). Test timing after: 0.58s. Full tests/web slice: 27s → 7.1s with the spurious warnings gone. --- decnet/web/api.py | 6 +++++- tests/web/test_web_api.py | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/decnet/web/api.py b/decnet/web/api.py index a15c63d0..b4689f6c 100644 --- a/decnet/web/api.py +++ b/decnet/web/api.py @@ -1,4 +1,8 @@ import asyncio +# Local binding for the DB-retry sleep so tests can patch it without +# affecting `asyncio.sleep` globally (which would otherwise starve the +# heartbeat / worker loops that share the interpreter's asyncio module). +from asyncio import sleep as _retry_sleep import os import traceback import uuid @@ -75,7 +79,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: log.warning("DB init attempt %d/5 failed: %s", attempt, exc) if attempt == 5: log.error("DB failed to initialize after 5 attempts — startup may be degraded") - await asyncio.sleep(0.5) + await _retry_sleep(0.5) # Conditionally enable OpenTelemetry tracing from decnet.telemetry import setup_tracing diff --git a/tests/web/test_web_api.py b/tests/web/test_web_api.py index b07a1f8d..988a9d4f 100644 --- a/tests/web/test_web_api.py +++ b/tests/web/test_web_api.py @@ -148,7 +148,10 @@ class TestLifespan: mock_repo.initialize = _failing_init with patch("decnet.web.api.repo", mock_repo): - with patch("decnet.web.api.asyncio.sleep", new_callable=AsyncMock): + # Patch only the local _retry_sleep binding — patching + # `asyncio.sleep` globally would starve the heartbeat loop's + # own sleep and leak the task past the test's lifetime. + with patch("decnet.web.api._retry_sleep", new_callable=AsyncMock): with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)): with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)): with patch("decnet.web.api.attacker_profile_worker", return_value=asyncio.sleep(0)):