fix(agent-runtime): exit when the host does, instead of running forever - #307
Conversation
The runtime intended to shut down with its host — `transport.onclose` stopped the stream, the heartbeat and the lease renewal, then exited. It never fired. The MCP SDK's stdio transport binds only 'data' and 'error' on stdin, so `onclose` is reached only by an orderly `close()`, never by a host that was killed, crashed or force-quit. That exit path was dead code in exactly the case it existed for. Nothing else stopped the process either: the hook listener's TCP server held the event loop open, the 2s heartbeat and lease timers kept running against a Switch that was gone, and `uncaughtException` swallowed the resulting EPIPE once `serving` was true rather than letting it escalate. Measured on one developer machine: 486 processes, 3.5 GB resident, ~198% CPU, 242 stale loopback listeners, across five runtime versions, the oldest 18 days. The two longest-lived had grown to 470 MB and 367 MB — consistent with writes queueing against a stderr pipe whose reader is gone but which was never closed. Four independent triggers now, because each covers a case the others miss: stdin 'end'/'close' for a dropped pipe, the termination signals for an orderly kill, a stdout/stderr 'error' for a peer that is gone but not closed, and a ppid watchdog as the backstop — reparenting is the one signal that survives every way a host can vanish, and SESSION_PPID was already recorded for the session directory. Any one of them would have prevented all 486. The hook listener is also unref'd, so the stdin reader is what holds the loop open: alive now means a host is attached rather than a port is bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shutdown fix stops new orphans. It does nothing about the ones already running: every published version up to 0.3.2 lacks it, and those processes sit there until the machine reboots — 486 of them, 3.5 GB, on the machine that prompted this. A new runtime now clears them out on its way up, which reaches a user through the connector pin without waiting for an app release. The signal is the process tree, not anything on disk. When the host dies its `npm exec` wrapper is reparented to init while the runtime carries on pointing at a wrapper that is alive but orphaned — so "is the parent dead?" is the wrong question, and would have missed both of the worst offenders (15765 was very much alive; only its parent was gone). Reaching init without passing through a live host is the right one, and it needs no port file, no session directory and no hook. The match is anchored on argv[0] rather than a substring of the command line. A substring test also matches any shell that merely mentions the package — installing it, grepping for it — and an orphaned one of those would have been killed. An early draft did exactly that; the cases are pinned in the tests. The reaper excludes its own chain outright rather than relying on the walk to spare it. The walk does spare it, since a freshly spawned runtime has a live host by construction, but a reaper that can reach itself is one bad predicate away from killing the session it serves. Stale session directories go too — pure litter from runtimes that were killed outright and never ran `unpublishPort`, 8071 of them here. Verified end to end against a real orphan built the way the bug builds one (host killed with its stdin held open by a third party, so the runtime never sees EOF): a fresh runtime reported `reaping 2 runtime process(es) whose host is gone` and `removing 8016 stale session director(ies)`, and left itself running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Added: clearing up the orphans that already existThe shutdown fix is prospective — every published version up to
The signal is the process tree, not the session directoryI first tried keying off
So "is the parent dead?" would have missed both of the worst offenders. "Does the chain reach init without passing a live host?" catches them. No port file, no session directory, no hook involved. Two things worth reviewing closely1. The match is anchored on argv[0], not a substring. A substring test on 2. The reaper excludes its own chain outright. The walk would spare it anyway — a freshly spawned runtime has a live host by construction — but a reaper that can reach itself is one bad predicate away from killing the session it was started to serve. Three tests cover it, including the case where its own chain looks orphaned. It runs fire-and-forget after Stale session directories are swept too: pure litter from runtimes killed outright that never ran Verified against a real orphanNot a mock. I built one the way the bug builds one — host killed with its stdin held open by a third party, so the runtime never sees EOF — confirmed the wrapper had reparented to pid 1, then started a fresh runtime: Both orphans gone, and the new runtime left itself running. Worth noting the first attempt at this test failed to reproduce — when the host holds the only pipe end, stdin closes properly and even Unrelated bug found on the way, not fixed hereChasing the session directory turned up a separate problem: the runtime writes to Confirmed by probe: spawner What that costs, given
Context that reframes it: only the Claude connector ships hooks at all — no Deliberately out of scope here. I have not confirmed the lease symptom against a live session, only the pid mismatch by construction and probe — worth verifying before acting on it. |
agent-runtime 0.3.2 → 0.3.3 (patch): - fix(#307): the runtime exits when its host does instead of running forever. Its onclose-based shutdown never fired for a killed/crashed/force-quit host, and the hook listener + heartbeat/lease timers kept the process alive, leaving stale processes and loopback listeners accumulating. Shutdown is now driven by four independent triggers (stdin end/close, termination signals, stdout/stderr write error, parent-pid watchdog). Phase 1 of a two-phase runtime release: this publishes 0.3.3 to npm via the switch-agent-runtime-v0.3.3 tag. The connector runtime pins (claude/codex .mcp.json, opencode opencode.json, SWITCH_AGENT_RUNTIME_PIN), the plugin versions, and the sidecar are DELIBERATELY left at their current versions — a pin must never name a version npm does not have yet. Phase 2 (re-pin + plugin/sidecar bumps) follows once 0.3.3 is live, on request. artifacts.yaml + generated modules regenerated; artifacts-check passes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 of the agent-runtime 0.3.3 release (0.3.3 is live on npm). Moves every runtime pin from 0.3.2 → 0.3.3 and bumps each consumer so the host-exit fix (#307) reaches users: - Runtime pin → 0.3.3 in all four places: claude & codex .mcp.json, opencode opencode.json, and SWITCH_AGENT_RUNTIME_PIN in console distribution.ts (the embedded opencode config derives from that constant, so it follows). - Plugin versions bumped so installs re-download: switch-connector 0.9.9 → 0.9.10, switch-connector-codex 0.3.10 → 0.3.11, switch-connector-opencode 0.1.5 → 0.1.6. - sidecar 1.9.4 → 1.9.5 (runs the new runtime; major stays 1, wire unchanged). claude/codex reach users on next marketplace Update; opencode + sidecar ride the next Switch Console release. artifacts.yaml + generated modules regenerated; artifacts-check passes; pins agree with each other (runtime-pin/connector-assets tests green). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What prompted this
Investigating node processes eating memory on a dev machine. They were all
switch-agent-runtime:npm exec+ runtime pairs)0.3.2,0.3.1,0.3.0,0.1.6,0.1.5)Every parent was orphaned to
launchd— the Claude Code / Switch Console session that spawned them was long gone.Why they never exited
bin.tshad the right intent:But the MCP SDK's stdio transport binds only two stdin events:
No
'end', no'close'.oncloseis reached only via an explicitclose()— an orderly client shutdown. A host that is killed, crashes or is force-quit leaves stdin at EOF and nothing listening. That exit path was dead code in precisely the scenario it existed for.Three things then kept each process alive and busy:
bin.ts:1587) is ref'd, so the event loop never drained — the 242 stale listeners.uncaughtExceptionswallowed the resulting EPIPE onceservingwas true, so nothing escalated.Observed directly on two of them:
->(none)— peer already gone, still didn't exit.PIPEwhose write end nobody closed, so no EOF ever arrived.On the 470 MB
Not confirmed by reading alone —
readSseis clean, no listener or buffer accumulation. The mechanism that fits is stderr backpressure: writes to a pipe whose reader is gone queue in memory unbounded, and at one line per 2s over 14 days that is ~600k queued strings. Consistent with the oldest process being the largest, and with pid 76532 (unix socket → writes fail fast with EPIPE rather than queueing) sitting lower. Worth a heap snapshot before treating as settled; the shutdown fix removes the conditions either way.The fix
Four independent triggers, because each covers a case the others miss:
transport.onclose'end'/'close'SIGTERM/SIGINT/SIGHUP'error'Any one of them would have prevented all 486.
SESSION_PPIDwas already recorded atbin.ts:86for the session directory; the watchdog just reads it.Two supporting changes:
server.unref()on the hook listener. With stdin's'data'listener holding the loop ref'd, alive now means a host is attached rather than a port is bound. This is the change most worth a careful look — if any path leaves stdin unref'd, an idle runtime could exit early. Thedoes not stay alive on the hook listener alonetest pins it.uncaughtExceptionno longer swallows EPIPE. A broken stdio pipe means the host is gone; carrying on is what left these running for weeks. This restores the repo's "fail loud, never fake" rule — it was the quietest possible degradation.Tests
bin.shutdown.test.ts— 15 tests, following the real-subprocess pattern frombin.handshake.test.ts(spawn the built artifact, handshake, then kill it the way a host would).Four behavioural tests over a real child process, plus source-level guards for the watchdog and each trigger (the watchdog's 30s cadence is longer than a test should wait; source assertions catch its removal, the regression that matters).
Verified the tests fail without the fix — reverted
bin.ts, rebuilt, and all four behavioural tests failed (the child never exits). Then restored and confirmed green.format,lint,typecheckall clean.Open point for the reviewer
No version bump, at the author's request.
console/AGENTS.mdrequires the runtime version to move in the same commit as any change, so this deliberately departs from that. It also means the connector.mcp.jsonpins stay at0.3.2and no session gets this fix until a version is cut, tagged (switch-agent-runtime-v<version>) and the pins follow. Worth deciding before merge.Cleanup is separate
The fix is prospective only — the processes already running have no shutdown path and need killing by hand:
🤖 Generated with Claude Code