Skip to content

fix(mcp): destroy stdin on idle timeout and arm it where hosts respawn - #7525

Merged
sisyphus-dev-ai merged 9 commits into
devfrom
fix/mem-mcp-idle-teardown
Aug 31, 2026
Merged

sisyphus-dev-ai merged 9 commits into
devfrom
fix/mem-mcp-idle-teardown

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

Advances #6547. Supersedes #6548 — the two mcp-stdio-core commits are @lifrary's work cherry-picked verbatim (authorship preserved); this PR rebases them onto dev (resolving the onPoll/watchdog conflict in server.test.ts by keeping dev's per-poll harness inside the refactored child-spawn helpers) and then closes the remaining teardown gaps only where host respawn is evidenced.

Summary

  • mcp-stdio-core (from fix(mcp-stdio-core): destroy stdin on idle timeout so abandoned servers exit #6548, by @lifrary): the idle callback now calls config.input.destroy(). Previously the timer only set isClosed and fired onIdleTimeout, but the read loop observes isClosed only after a message arrives — an idle server is idle precisely because none does. A live parent that abandons the pipe kept the child parked on for await forever. With the destroy, the idle timeout becomes a real exit path (same shape as the parent watchdog).
  • ast-grep-mcp: arms the idle timeout on the senpi-hosted stdio server — the one consumer whose host provably respawns. Default 10 minutes (matches the mcp-stdio-core default already shipped for the senpi-hosted memory MCP; an idle exit costs one backoff-delayed respawn, not a broken session). Configurable via OMO_AST_GREP_IDLE_TIMEOUT_MS (mirrors OMO_AST_GREP_PROJECT_CWD and the git-bash *_TIMEOUT_MS env convention) with an explicit option seam; <= 0 disables.
  • No-respawn sites stay disabled, now with the reasoning pinned in comments so the idleTimeoutMs: 0 call sites are not "finished" later without respawn evidence.
  • Adjacent regression fixed (found by sweep, commit 158d5dcaf): the codegraph unavailable stub inherited the now-live 10-min default and would die mid-session on codex; pinned to idleTimeoutMs: 0 with a regression test, and the component's committed dist/ regenerated via the plugin-level build (it had also gone stale for fix(mcp-stdio-core): destroy stdin on idle timeout so abandoned servers exit #6548's destroy line — nothing in CI verifies it, which is how that slipped).

Per-site verdict table

Respawn evidence gathered from host sources (upstream trees fetched via GitHub API, 2026-08-31):

Site Host Respawn evidence Decision
mcp-stdio-core/src/server.ts idle callback n/a (library) n/a Fixed (via #6548 cherry-pick)
lsp-daemon/src/proxy.ts:93 opencode v1.18.18 No. sst/opencode mcp/index.ts watch(): client.onclose removes the client and sets status: failed "Connection closed"; reconnect only via user-driven MCP.add/MCP.connect (/mcp command) Keep idleTimeoutMs: 0; comment added
lsp-core/src/mcp.ts:77 (lsp-tools-mcp CLI) legacy opencode configs, standalone CLI users Not evidenced. No in-repo client respawns this CLI; opencode (a known host) marks failed without retry Keep idleTimeoutMs: 0; comment added
ast-grep-mcp/src/mcp.ts:284 senpi (registerMcpServer, lifecycle: "lazy") Yes. senpi mcp/connection.ts: transport.onclose -> markDegraded; reconnect.ts configureMcpReconnect respawns via renew() -> connect() with backoff 500ms-8s (breaker 5/30s). Sibling senpi-hosted memory MCP already ships the 10-min default Enabled: 10-min default, env + option override
git-bash-mcp/src/mcp.ts:76 codex (win32) No. openai/codex codex-mcp/src/rmcp_client.rs: reconnect-with-backoff only for CODEX_APPS_MCP_SERVER_NAME; user-configured stdio servers get no respawn Keep idleTimeoutMs: 0; comment added
omo-codex/.../mcp-unavailable.ts (unavailable stub, inherited the 10-min default) codex No (same codex evidence) Pinned to idleTimeoutMs: 0 — post-#6548 the default became a real stdin destroy, which would silently kill this stub after 10 idle minutes on a no-respawn host; regression test pins idle_timeout_ms: 0, component dist regenerated
omo-codex/.../mcp-bridge.ts:84-88 (watchdog only, no idle timer) codex No (same codex evidence) Not a runJsonRpcStdioServer caller — it drives createParentWatchdog + manual forward loops, so there is no idle timeout to configure; verified by sweep, no edit needed
omo-senpi/.../memory-server.ts (inherits the 10-min default) senpi Yes (row 4 evidence) Left on the default — exactly where #6548 was intended to start working, and respawn is evidenced

Why not enable idle everywhere

An unproven idle-kill that breaks live sessions is worse than the leak. On opencode, a closed stdio MCP server is failed until the user manually reconnects via /mcp; on codex, only OpenAI's own codex_apps server auto-reconnects. Killing an idle lsp proxy or git_bash child under those hosts removes tools from live sessions permanently.

Residual (why this advances rather than fixes #6547)

The OpenCode-path leak — an opencode host that abandons the lsp proxy's stdin while staying alive — is still open, by design of this PR: closing it requires an opencode-side respawn or per-session stdin close, which is out of this repo's control. Also unchanged: onIdleTimeout remains un-awaited and the idle clock arms on message arrival rather than handler completion (pre-existing shape).

Verification

TDD, all runs on a remote CI clone (bun /tmp/omo-mac-test.mjs), never locally:

  • RED (test-only commit): 4 new ast-grep idle tests fail for the right reason — 331 pass / 4 fail — while mcp-stdio-core (including fix(mcp-stdio-core): destroy stdin on idle timeout so abandoned servers exit #6548's two idle tests) is green in the same run
  • GREEN after fix: 335 pass / 0 fail (ast-grep + mcp-stdio-core)
  • Final all touched packages (mcp-stdio-core, ast-grep-mcp, lsp-daemon, lsp-core, git-bash-mcp): 507 pass / 0 fail (2 pre-existing Windows-gated skips)
  • Unavailable-stub regression: RED idle_timeout_ms: 600000 vs expected 0 at test-only commit, GREEN 1 pass / 0 fail after fix; component suite delta parent→tip is exactly +1 passing test (47/31/78 → 48/31/79, the 31 reproduce identically at parent and are clone-env artifacts; CI's codex-compatibility runs them green); component tsc --noEmit clean; mcp-stdio-core + ast-grep after the new pin: 336 pass / 0 fail
  • tsgo --noEmit clean on all five touched packages

Full evidence: .omo/evidence/20260831-mem-mcp-idle/REPORT.md (RED/GREEN logs + verdict table).

lifrary and others added 5 commits August 31, 2026 10:24
…rs exit

The idle timer fired but nothing tore the server down. `isClosed` is only
consulted after a message arrives (`for await` ... `if (isClosed) break`), and
an idle stdio server is idle precisely because no message arrives, so the read
loop parked forever on an open pipe.

This only shows up when the parent stays alive while abandoning the pipe: the
parent watchdog needs the parent to die, and the stdin `close`/`end` paths need
the parent to release the write end. A host that keeps the fd open and simply
forgets the child hits none of them, and the child lives forever.

Destroy the input in the idle callback, mirroring what the parent-exit callback
already does six lines below. The resulting ERR_STREAM_PREMATURE_CLOSE is
already swallowed by the existing catch because `isClosed` is set.

Adds the first two tests that exercise the idle path: one in-process, and one
that spawns a real child whose parent holds stdin open, because a settled read
loop still leaves a live process if anything keeps the event loop alive. Every
pre-existing test sets `idleTimeoutMs: 0`, which is why the gap survived.

Note this only takes effect where an idle timeout is enabled. codegraph's two
servers take the 10-minute default; lsp-core, lsp-daemon and git-bash-mcp all
pass `idleTimeoutMs: 0` and so still have no teardown for a live parent that
abandons the pipe.
…setup

The idle-timeout test's handler declared a `message` parameter, which
`McpRequestHandler` types as `unknown`, so `message.id` failed typecheck on
all three CI runners. That handler is never invoked in the test: its premise
is a live parent that abandons stdin without writing, so no message ever
arrives. Dropping the parameter matches the ten other handler call sites in
the file and clears the error without a cast.

Also extract `buildServerScript` and `spawnServerChild` so the watchdog and
idle child processes share one spawn setup, addressing the review comment
about the two helpers drifting. Each caller still passes its own config lines
verbatim, so what the child actually does still reads at the call site.

Verified: `tsgo --noEmit` clean for the package and repo-wide, `bun test`
22 pass / 0 fail. A temporary probe confirmed the refactored generator still
produces a child that answers a real JSON-RPC request with no stderr, because
the idle test asserts on process exit and would pass just as happily against
a child that crashed on a malformed script.

Co-Authored-By: Claude <[email protected]>
The senpi host auto-reconnects a stdio MCP server that exits: transport
close marks the connection degraded and the reconnect scheduler respawns
the child with backoff (senpi connection.ts + reconnect.ts), so an idle
exit costs one backoff-delayed respawn instead of a broken session.

Arm the idle timeout at the mcp-stdio-core default (10 min, matching the
senpi-hosted memory MCP) with an OMO_AST_GREP_IDLE_TIMEOUT_MS override
and an explicit option seam. Values <= 0 still disable the timer.

Now that #6548 makes the idle callback destroy stdin, an idle ast-grep
child exits instead of parking forever on a pipe held by a live parent.
opencode marks a closed stdio MCP server failed until a user reconnects
via /mcp, and codex only auto-reconnects its own codex_apps server, so
arming the idle timer on those hosts would trade a leak for broken
live sessions. The comments pin that analysis at each idleTimeoutMs: 0
call site so it is not 'finished' later without respawn evidence.
@github-actions github-actions Bot added ast-grep-mcp Changes under packages/ast-grep-mcp mcp-stdio-core Changes under packages/mcp-stdio-core lsp-core Changes under packages/lsp-core lsp-daemon Changes under packages/lsp-daemon git-bash-mcp Changes under packages/git-bash-mcp labels Aug 31, 2026
The committed extensions bundles embed inputs touched by this branch:
mcp-stdio-core's server.ts (idle destroy from #6548) reaches omo.js and
omo-memory-mcp.js transitively, and ast-grep-mcp's mcp.ts feeds the
staged runtime that build-extension.mjs --check rebuilds. Regenerated
via bun run build:senpi-plugin under the CI-pinned bun 1.4.0; verified
with node packages/omo-senpi/plugin/scripts/build-extension.mjs --check
(exit 0).
@github-actions github-actions Bot added omo-senpi Changes under packages/omo-senpi lazycodex Codex (LazyCodex) edition: packages/omo-codex labels Aug 31, 2026
@code-yeongyu
code-yeongyu force-pushed the fix/mem-mcp-idle-teardown branch 3 times, most recently from 28e8173 to 38154b9 Compare August 31, 2026 03:46
…x host

The stub called runJsonRpcStdioServer without idleTimeoutMs, inheriting
the 10-minute default. That default was inert before #6548; with the
idle callback now destroying stdin, the stub would silently die after
10 idle minutes on codex — a host that never respawns an exited stdio
MCP server (codex-rs auto-reconnects only its own codex_apps server) —
turning later codegraph tool calls into dead-connection errors instead
of the skip reason.

Pin idleTimeoutMs: 0 with the no-respawn rationale, add a lifecycleLog
seam plus a test pinning idle_timeout_ms: 0, and pin the same contract
in mcp-stdio-core: an explicitly zero idle timeout creates no timer at
all. Regenerate the component's committed dist via the plugin-level
build (bun run --cwd packages/omo-codex/plugin build), which also syncs
the previously stale #6548 idle-destroy delta into it. The log option
uses a conditional spread to stay assignable under the component's
exactOptionalPropertyTypes tsconfig.
@sisyphus-dev-ai
sisyphus-dev-ai merged commit 7603a66 into dev Aug 31, 2026
26 checks passed
@sisyphus-dev-ai
sisyphus-dev-ai deleted the fix/mem-mcp-idle-teardown branch August 31, 2026 04:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ast-grep-mcp Changes under packages/ast-grep-mcp git-bash-mcp Changes under packages/git-bash-mcp lazycodex Codex (LazyCodex) edition: packages/omo-codex lsp-core Changes under packages/lsp-core lsp-daemon Changes under packages/lsp-daemon mcp-stdio-core Changes under packages/mcp-stdio-core omo-senpi Changes under packages/omo-senpi

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: stdio MCP servers never exit when a live parent abandons stdin — the idle timeout fires but tears nothing down (follow-up to #5560)

3 participants