diff --git a/mcp_server/lifespan.py b/mcp_server/lifespan.py index e360246..0ee3be7 100644 --- a/mcp_server/lifespan.py +++ b/mcp_server/lifespan.py @@ -1,5 +1,6 @@ import asyncio import logging +import os from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager, suppress from dataclasses import dataclass @@ -77,10 +78,30 @@ async def _storage_pass() -> None: await _connection_health() +# How long the MCP client waits for `initialize` before it declares the server +# dead. opencode v2 gives up after ~6 s and logs +# `mcp transport error ... process exited` — while the process is still alive. +# Any startup wait longer than this makes a healthy server unreachable. +CLIENT_HANDSHAKE_BUDGET_S: float = 6.0 + # How long startup waits its turn on the maintenance turnstile before running # migrations ungated (spec S3). Bounded: the normal wait is milliseconds — # only a genuinely long migration makes a follower wait at all. -MIGRATION_GATE_TIMEOUT_S: float = 120.0 +# +# 2026-11-13: this was 120 s, i.e. 20x the client's handshake budget. A server +# blocked in `maintenance_lock.acquire()` therefore stayed alive but invisible: +# the client had already given up, so every session that started during the +# wedge had no memory server at all. The gate's own fallback ("better a noisy +# success than a server that never comes up") never ran, because the wait +# outlasted the client. Keep it under the handshake budget so the ungated +# fallback happens while the client is still listening. +# 2 s, not 4: an uncontended startup already spends ~2.7 s before it can +# answer `initialize` (imports + migrations), so the gate has to leave room +# inside the 6 s budget or the contended case lands exactly on the cliff. +# Measured 2026-11-13: gate 4 s + 2.2 s of work = 6.24 s, i.e. still late. +# Override with ARIEL_MIGRATION_GATE_TIMEOUT_S when a deliberately long +# migration has to be waited out. +MIGRATION_GATE_TIMEOUT_S: float = float(os.environ.get("ARIEL_MIGRATION_GATE_TIMEOUT_S", "2.0")) async def run_startup_gated( diff --git a/tests/test_mcp_server/test_lifespan_gate.py b/tests/test_mcp_server/test_lifespan_gate.py index daa1321..2f20ada 100644 --- a/tests/test_mcp_server/test_lifespan_gate.py +++ b/tests/test_mcp_server/test_lifespan_gate.py @@ -102,3 +102,26 @@ async def migrate() -> str: from shared.maintenance_lock import release release(fd) + + +def test_startup_gate_is_bounded_below_the_client_handshake_budget() -> None: + """The gate must never outlast the client's handshake wait. + + 2026-11-13: a stdio server blocked in `maintenance_lock.acquire()` for the + full 120 s default while the opencode v2 client gives up on `initialize` + after ~6 s. The server was alive and polling the lock, but the client had + already declared `mcp transport error ... process exited` — so the memory + server was invisible for every session that started during the wedge. + + The gate's own docstring already says the fallback is "better a noisy + success than a server that never comes up". A 120 s wait contradicts that: + the server is up, yet unreachable. Bound the wait to less than the client + budget so the ungated fallback actually runs while the client is listening. + """ + from mcp_server import lifespan + + assert lifespan.MIGRATION_GATE_TIMEOUT_S < lifespan.CLIENT_HANDSHAKE_BUDGET_S, ( + f"startup gate ({lifespan.MIGRATION_GATE_TIMEOUT_S}s) must be shorter than " + f"the client handshake budget ({lifespan.CLIENT_HANDSHAKE_BUDGET_S}s), " + "or a held lock makes the server unreachable" + )