fix(web/api): scope DB-retry sleep so tests don't starve background tasks
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.
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
import asyncio
|
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 os
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
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)
|
log.warning("DB init attempt %d/5 failed: %s", attempt, exc)
|
||||||
if attempt == 5:
|
if attempt == 5:
|
||||||
log.error("DB failed to initialize after 5 attempts — startup may be degraded")
|
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
|
# Conditionally enable OpenTelemetry tracing
|
||||||
from decnet.telemetry import setup_tracing
|
from decnet.telemetry import setup_tracing
|
||||||
|
|||||||
@@ -148,7 +148,10 @@ class TestLifespan:
|
|||||||
mock_repo.initialize = _failing_init
|
mock_repo.initialize = _failing_init
|
||||||
|
|
||||||
with patch("decnet.web.api.repo", mock_repo):
|
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_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.log_collector_worker", return_value=asyncio.sleep(0)):
|
||||||
with patch("decnet.web.api.attacker_profile_worker", return_value=asyncio.sleep(0)):
|
with patch("decnet.web.api.attacker_profile_worker", return_value=asyncio.sleep(0)):
|
||||||
|
|||||||
Reference in New Issue
Block a user