fix(broker): terminate the broker on teardown instead of only unlinking its pid file - #566
fix(broker): terminate the broker on teardown instead of only unlinking its pid file#566srdrkr wants to merge 1 commit into
Conversation
…ng its pid file
The broker is spawned detached and unref'd so it can outlive the process
that started it. teardownBrokerSession unlinks the pid file, the log and
the endpoint socket, but only terminated the process when a caller
injected killProcess. Its default was null.
session-lifecycle-hook passes terminateProcessTree, so session end was
fine. ensureBrokerSession did not: both of its teardown paths passed
options.killProcess ?? null, and its only caller (app-server.mjs) passes
just { env }. So every stale-session replacement and every spawn that
misses its readiness window unlinked the pid file and left the broker
running, unreachable, because nothing else knows the pid.
Observed on one workstation: 206 orphaned app-server-broker.mjs
processes, PPID 1, oldest cohort 7 to 9 days, one per invocation. They
accumulate until process and pty exhaustion.
Default killProcess to terminateProcessTree in both places. Injection
still works, so existing tests and the session hook are unaffected.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1b3264f9ac
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| sessionDir: existing.sessionDir ?? null, | ||
| pid: existing.pid ?? null, | ||
| killProcess: options.killProcess ?? null | ||
| killProcess: options.killProcess ?? terminateProcessTree |
There was a problem hiding this comment.
Verify stale broker PID before terminating it
When broker.json outlives the broker (for example after a crash, SIGKILL, or stale state under CLAUDE_PLUGIN_DATA) and the OS later reuses existing.pid, this new default terminator runs solely because the old endpoint is not ready. terminateProcessTree sends SIGTERM to -pid/pid, so the next plugin command can kill an unrelated process or process group before clearing the stale session; validate that the pid file still exists and matches the stored pid (or otherwise verify the process) before killing, and just clear stale state when it cannot be validated.
Useful? React with 👍 / 👎.
|
I'm a lowly pm, hire me please! |
|
Bumping this — we're independently affected and would like to see it land. On one macOS machine the missing default kill (plus the cwd-keyed lookup in #380) had accumulated 25 orphaned brokers, 1.31 GB RSS, oldest 15 days, 15 of them owned by no live session. On the bot's P2, "Verify stale broker PID before terminating it" — that risk is real, but the suggested remedy ("validate that the pid file still exists and matches the stored") is unreliable in practice, at least on macOS.
An alternative that doesn't depend on the pidfile: match the Two details that turned out to matter:
// "match" | "mismatch" | "unknown" — callers must treat "unknown" as fail-closed.
export function verifyBrokerProcess(pid, endpoint, platform = process.platform) {
if (!Number.isFinite(pid)) return "mismatch";
if (platform === "win32") return "unknown-permissive";
const result = runCommand("ps", ["-p", String(pid), "-o", "command="]);
if (result.error) return "unknown"; // ps unavailable: do not guess
if (result.status !== 0) return "mismatch"; // no such process: definitively gone
const command = result.stdout;
if (!command.includes("app-server-broker.mjs")) return "mismatch";
if (endpoint) {
const socketPath = endpoint.startsWith("unix:") ? endpoint.slice("unix:".length) : endpoint;
return command.includes(socketPath) ? "match" : "mismatch"; // not just *a* broker
}
return "match";
}Then in We've been running this together with idle self-termination (#450/#457/#490) and session-id teardown (#380/#381) locally for a day, with a regression harness covering the PID-identity case specifically: two live brokers, teardown called with broker A's pid and broker B's endpoint, asserting A survives. Happy to open a PR against this branch with the guard and that test if useful. Written by Claude (Claude Code) on behalf of @afspies, who reviewed and sent it. The measurements and file paths above are from a real machine, not inferred. |
The leak
The broker is spawned
detached: trueandunref()'d so it can outlive the process that started it. That is correct for a broker, but it makes explicit termination mandatory.teardownBrokerSessionunlinks the pid file, the log file and the endpoint socket. It only terminates the process when a caller injectskillProcess, whose default wasnull:session-lifecycle-hook.mjsdoes passterminateProcessTree, so session end was fine.ensureBrokerSessiondoes not. Both of its teardown paths passoptions.killProcess ?? null, and its only caller (app-server.mjs:344) passes just{ env: options.env }.So two paths leaked:
nullis returned. The broker it just spawned survives.In both cases the pid file is deleted while the process keeps running, so nothing can ever find it again. There is no reaper and no age-based sweep.
Observed impact
On one workstation, after roughly a week of ordinary use:
app-server-broker.mjsprocesses, allPPID 1, oldest cohort 7 to 9 days old, one per invocationnodefork failed: Device not configuredTerminating just those 206 dropped the machine from 1047 processes to 565, since each broker also held child processes. Likely a contributor to battery drain as well.
The fix
Default
killProcesstoterminateProcessTreeinteardownBrokerSession, and stop coercing an absent injection tonullat bothensureBrokerSessioncall sites. Injection still works unchanged, so the session hook and existing callers are unaffected.Eight lines changed in one file.
Tests
tests/broker-lifecycle.test.mjs, three cases. The behavioural one spawns a real stub process that never opens its endpoint, lets readiness time out, and asserts nothing survives afterwards. It deliberately injects no killer, because injecting one exercises the path that always worked.Verified A/B/A against the unfixed tree:
Full suite on this branch: 90 pass, 4 fail. Clean
mainatdb52e28: 88 pass, 6 fail. The same four failures are present onmainand are unrelated to this change (status shows phases…,status preserves adversarial review kind labels,result returns the stored output…,resolveStateDir uses a temp-backed per-workspace directory). This branch fixes two and breaks none.Note on scope
This stops new orphans. It does not reap ones already on disk from earlier versions, which have to be cleared by hand. If you would like a startup sweep for pre-existing strays, I am happy to add it in a separate PR rather than widen this one.