fix(proxy): reset memory backend to None on init timeout

If asyncio.wait_for fires while LocalBackend._ensure_initialized() is
running, _init_backend_locked may have already assigned self._backend
(the LocalBackend constructor) before its own await raised or was
cancelled. Callers doing if self.memory_handler._backend: then see
a truthy-but-broken backend.

Null self._backend in the TimeoutError handler so the post-timeout
state is consistent: _initialized=False AND _backend=None.
This commit is contained in:
Adryan Eka Vandra 2026-04-18 01:57:16 +07:00
parent 0e166c60d4
commit 3f1f82d7e2
No known key found for this signature in database
GPG key ID: A46A577A26A97682
2 changed files with 47 additions and 0 deletions

View file

@ -185,6 +185,12 @@ class MemoryHandler:
await asyncio.wait_for(_do_init(), timeout=STARTUP_INIT_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
# Fail-open: leave _initialized=False so subsequent calls retry.
# CRITICAL: also null the backend — _init_backend_locked may have
# already assigned ``self._backend`` before its own await raised /
# was cancelled by wait_for. Callers that do
# ``if self.memory_handler._backend:`` must not see a
# truthy-but-broken backend.
self._backend = None
logger.error(
"Memory: backend initialization timed out after "
f"{STARTUP_INIT_TIMEOUT_SECONDS}s "

View file

@ -149,6 +149,47 @@ async def test_ensure_initialized_timeout_leaves_handler_unready(
assert STARTUP_INIT_TIMEOUT_SECONDS == 30.0
@pytest.mark.asyncio
async def test_ensure_initialized_timeout_nulls_partially_initialized_backend(
tmp_path, monkeypatch
):
"""If wait_for fires while _init_backend_locked has already set
``self._backend`` but before ``self._initialized = True``, the timeout
handler must null ``_backend``. Otherwise callers doing
``if self.memory_handler._backend:`` see a truthy-but-broken backend.
"""
class SlowBackend:
def __init__(self, config):
self.config = config
async def _ensure_initialized(self) -> None:
# Hang long enough to blow the 0.01s timeout below.
await asyncio.sleep(5.0)
async def close(self) -> None:
pass
import headroom.memory.backends.local as local_mod
monkeypatch.setattr(local_mod, "LocalBackend", SlowBackend)
handler = MemoryHandler(
MemoryConfig(
enabled=True, backend="local", db_path=str(tmp_path / "mem.db")
)
)
with patch(
"headroom.proxy.memory_handler.STARTUP_INIT_TIMEOUT_SECONDS", 0.01
):
await handler._ensure_initialized()
# Both must be consistent after timeout.
assert handler._initialized is False
assert handler._backend is None
# -------------------------------------------------------------------
# Real backend init (no monkeypatching) — integration smoke test
# -------------------------------------------------------------------