Skip to content

fix(agent-runtime): exit when the host does, instead of running forever - #307

Merged
amaudruz merged 2 commits into
mainfrom
fix/runtime-orphan-shutdown
Aug 27, 2026
Merged

fix(agent-runtime): exit when the host does, instead of running forever#307
amaudruz merged 2 commits into
mainfrom
fix/runtime-orphan-shutdown

Conversation

@amaudruz

Copy link
Copy Markdown
Collaborator

What prompted this

Investigating node processes eating memory on a dev machine. They were all switch-agent-runtime:

Processes 486 (243 npm exec + runtime pairs)
Resident 3.5 GB
CPU ~198% (two full cores)
Stale loopback listeners 242
Oldest 18 days
Versions resident at once 5 (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.ts had the right intent:

transport.onclose = () => { stopStream(); stopHeartbeat(); stopLeaseRenew(); unpublishPort(); process.exit(0); };

But the MCP SDK's stdio transport binds only two stdin events:

server/stdio.js:37:  this._stdin.on('data', this._ondata);
server/stdio.js:38:  this._stdin.on('error', this._onerror);

No 'end', no 'close'. onclose is reached only via an explicit close() — 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:

  1. The hook listener (bin.ts:1587) is ref'd, so the event loop never drained — the 242 stale listeners.
  2. The heartbeat (2s), lease renewal (2s) and stream reconnect (30s cap) kept running against an unreachable Switch.
  3. uncaughtException swallowed the resulting EPIPE once serving was true, so nothing escalated.

Observed directly on two of them:

  • pid 76532 (8d, 367 MB): fds 0/1/2 are unix sockets reading ->(none)peer already gone, still didn't exit.
  • pid 15860 (14d, 470 MB): stdin is a PIPE whose write end nobody closed, so no EOF ever arrived.

On the 470 MB

Not confirmed by reading alone — readSse is 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:

Trigger Covers
transport.onclose orderly client shutdown (unchanged)
stdin 'end' / 'close' host killed, crashed, force-quit
SIGTERM / SIGINT / SIGHUP orderly kill — buys the cleanup, since node would terminate anyway
stdout/stderr 'error' peer gone but pipe never closed
ppid watchdog (30s, unref'd) the backstop — reparenting survives every way a host can vanish

Any one of them would have prevented all 486. SESSION_PPID was already recorded at bin.ts:86 for 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. The does not stay alive on the hook listener alone test pins it.
  • uncaughtException no 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 from bin.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.

Test Files  8 passed (8)
     Tests  72 passed (72)

format, lint, typecheck all clean.

Open point for the reviewer

No version bump, at the author's request. console/AGENTS.md requires the runtime version to move in the same commit as any change, so this deliberately departs from that. It also means the connector .mcp.json pins stay at 0.3.2 and 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:

pkill -f "npm exec @sandbox.*switch-agent-runtime"
pkill -f "\.bin/switch-agent-runtime"

🤖 Generated with Claude Code

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>
@amaudruz

Copy link
Copy Markdown
Collaborator Author

Added: clearing up the orphans that already exist

The shutdown fix is prospective — every published version up to 0.3.2 lacks it, and those processes sit there until reboot. A new runtime now reaps them on its way up, which reaches users through the connector pin without waiting for an app release.

src/reap.ts + src/reap.test.ts (22 tests). 94 passing in the package overall; format, lint, typecheck clean.

The signal is the process tree, not the session directory

I first tried keying off ~/.switch/sessions/<pid>/. That would not have worked. 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:

runtime parent parent's parent
15860 (470 MB, 14d) 15765 npm execalive 1
76532 (367 MB, 8d) 76464 npm execalive 1

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 closely

1. The match is anchored on argv[0], not a substring. A substring test on switch-agent-runtime also matches any shell whose command line merely mentions the package — a zsh -c installing it, a pkill -f for it, an editor with the path open. An orphaned one of those would have been SIGTERMed. An early draft of this did exactly that, and I only caught it because my own test harness showed up in ps as a candidate. The false-positive cases are pinned in reap.test.ts.

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 serving = true, so a host waiting on initialize never pays for the ps scan or the directory removals. One consequence: a very short-lived session can exit mid-sweep. That's harmless — both halves are idempotent and resume on the next start.

Stale session directories are swept too: pure litter from runtimes killed outright that never ran unpublishPort. There were 8071 on this machine.

Verified against a real orphan

Not 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:

switch: reaping 2 runtime process(es) whose host is gone
switch: removing 8016 stale session director(ies)

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 0.3.2 exits. That narrows the real-world trigger: these survive specifically when something else keeps the stdio open, which matches what I saw on the two long-lived processes (one held a pipe whose write end nobody closed, the other a unix socket whose peer was gone).

Unrelated bug found on the way, not fixed here

Chasing the session directory turned up a separate problem: the runtime writes to ~/.switch/sessions/<npm exec pid>/, but switch_hook.py reads ~/.switch/sessions/<os.getppid()>/, and the hook is a direct child of the agent CLI. npx forks rather than execs, so those are never the same number — the lookup fails silently.

Confirmed by probe: spawner 18404npm exec 18410 → runtime 18432, and the directory created was 18410.

What that costs, given /connect is already covered by the tool-call path at bin.ts:1000:

  • role leases are never renewedstartLeaseRenew() is only reachable from the hook, so an exclusive seat silently expires (the one with correctness consequences)
  • the missed-message counter never resets, so missed_count grows forever
  • a gap warning can repeat
  • the Slack "thinking" indicator lingers after a no-reply turn

Context that reframes it: only the Claude connector ships hooks at all — no hooks/ under codex-plugin or opencode-plugin — so all four are already the normal state on two of three hosts. That suggests the better fix is moving those three signals into the tool-call path beside the existing connect_to_room case, where they work everywhere and need no Python change, leaving /turn-end as the only thing that genuinely needs the port.

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.

@amaudruz
amaudruz merged commit 52ed043 into main Aug 27, 2026
10 checks passed
amaudruz added a commit that referenced this pull request Aug 27, 2026
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>
amaudruz added a commit that referenced this pull request Aug 27, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant