Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion mcp_server/lifespan.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions tests/test_mcp_server/test_lifespan_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Loading