From bc561107c2c24b8219fbed08b1d1ab733199788e Mon Sep 17 00:00:00 2001 From: Cipher208 <269750686+Cipher208@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:14:42 +0100 Subject: [PATCH] fix(lifespan): bound the startup gate below the client handshake budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stdio server blocked in maintenance_lock.acquire() waited the full 120 s default, while the opencode v2 client gives up on initialize after ~6 s and logs 'mcp transport error ... process exited'. The process was alive and polling the lock the whole time, so the client's message was simply wrong — but the effect was real: for every session that started during the wedge there was no memory server at all. The gate's own docstring already promised the fallback is 'better a noisy success than a server that never comes up'. A 120 s wait broke that promise: the ungated fallback only ran long after the client had stopped listening. Measured 2026-11-13 (holding the lock with a probe process): uncontended handshake ~2.7 s gate 4 s + work ~6.24 s (still late, right on the cliff) gate 2 s + work ~4.27 s (inside the budget) So: gate 2 s, with CLIENT_HANDSHAKE_BUDGET_S recording the constraint, and ARIEL_MIGRATION_GATE_TIMEOUT_S to override when a long migration must be waited out. Regression test asserts gate < budget. Verified: contended handshake 4.27 s, uncontended 2.7 s, 5 gate tests pass, full suite 2870 passed, mypy clean. --- mcp_server/lifespan.py | 23 ++++++++++++++++++++- tests/test_mcp_server/test_lifespan_gate.py | 23 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/mcp_server/lifespan.py b/mcp_server/lifespan.py index e360246c..0ee3be74 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 daa1321d..2f20ada1 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" + )