Repository navigation
Description
Bug: app-server brokers never self-terminate → orphaned process/RAM leak
Repo: openai/codex-plugin-cc
Version: codex plugin 1.0.6
File: scripts/app-server-broker.mjs (+ context in scripts/lib/broker-lifecycle.mjs, scripts/session-lifecycle-hook.mjs)
Summary
The shared app-server broker has no idle timeout. It exits only on an explicit
broker/shutdown RPC, SIGTERM, or SIGINT. Its only automatic reaper is the
SessionEnd hook, which is fragile. When that hook fails to run, the detached
broker (child.unref()) leaks forever, holding a Codex app-server child plus
its MCP/worker subprocesses.
Observed in the wild: 34 orphaned broker chains, 272 processes, ~2.2 GB RSS,
0 clients connected. 16 chains pointed at already-deleted git worktrees.
Root cause
Two gaps compound:
-
Broker never self-exits. In
app-server-broker.mjs,socket.on("close")
just doessockets.delete(socket)+clearSocketOwnership(socket). There is
no check for "no clients left" and no idle timer. A broker with zero clients
lives indefinitely. -
The reaper is best-effort and misses common cases.
SessionEndin
session-lifecycle-hook.mjsis the intended reaper, keyed per-cwd via
loadBrokerSession(cwd). It fails to reap when:- Claude is hard-killed / crashes → hook never runs.
- The 5s hook
timeout(hooks.json) is exceeded on a busy machine → hook killed. - The worktree is deleted before SessionEnd →
loadBrokerSession(cwd)
resolves to gone state, returnsnull, no shutdown is sent → orphan.
(This matches the 16 deleted-worktree chains.)
Because brokers are spawned detached and keyed to a cwd, any hook miss orphans a
broker that nothing will ever clean up.
Fix
Give the broker an idle self-exit. It is safe: the companion opens one socket
per CodexAppServerClient.connect() and closes it when the job ends
(BrokerCodexAppServerClient.initialize/close in lib/app-server.mjs), so a
broker at zero sockets with no active request/stream is genuinely idle. Killing
an idle broker only costs a cold respawn on the next job — ensureBrokerSession
already checks endpoint readiness and respawns if dead. This makes the broker
self-healing regardless of whether any hook fires, covering hard-kills and
deleted worktrees alike.
Threshold defaults to 15 min, overridable via CODEX_COMPANION_BROKER_IDLE_MS.
Patch (scripts/app-server-broker.mjs)
Add idle state after const sockets = new Set();:
const IDLE_TIMEOUT_MS = Number(process.env.CODEX_COMPANION_BROKER_IDLE_MS ?? 15 * 60 * 1000);
let idleTimer = null;
let serverRef = null;
function isIdle() {
return sockets.size === 0 && !activeRequestSocket && !activeStreamSocket;
}
function clearIdleTimer() {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
}
function armIdleTimer() {
clearIdleTimer();
if (!(IDLE_TIMEOUT_MS > 0) || !serverRef || !isIdle()) {
return;
}
idleTimer = setTimeout(async () => {
if (!isIdle()) {
return;
}
await shutdown(serverRef);
process.exit(0);
}, IDLE_TIMEOUT_MS);
if (typeof idleTimer.unref === "function") {
idleTimer.unref();
}
}Cancel the timer on connect (in net.createServer callback, after sockets.add(socket)):
clearIdleTimer();Re-arm on disconnect (in both socket.on("close") and socket.on("error"),
after clearSocketOwnership(socket)):
armIdleTimer();Arm at startup and hold the server ref (replace server.listen(listenTarget.path);):
serverRef = server;
server.listen(listenTarget.path, () => {
armIdleTimer();
});Optional hardening (not required if idle-exit lands)
- Make the
SessionEndhook timeout longer than 5s, or move broker teardown off
the hook's critical path, so slow machines still reap on clean exit. - On
SessionStart, sweepos.tmpdir()for stalecxc-*/broker.pidwhose pid is
dead (leftover session dirs). Redundant once idle-exit ships, but cleans state.
Activity
HoneyTyagii commentedon Jul 23, 2026
Can I work on this?
ghbaud commentedon Jul 24, 2026
Confirming the issue.
maximsht commentedon Jul 25, 2026
Additional downstream impact observed on plugin 1.0.4 (Linux, codex-cli 0.145.0): the leaked chains don't just cost RAM — they can wedge fresh
codexinvocations system-wide.Setup: one evening of agent-driven review calls left 9 orphaned
node → codex app-serverchains, all reparented tosystemd --user(one chain per plugin invocation; spawn times matched our agent calls minute-for-minute). The orphans stayed active: CPU time kept accruing and the shared~/.codexsqlite stores (logs_2.sqlitehad grown to 245 MB) were being written continuously.Impact 1 —
codex execstartup deadlock. While the orphans were churning, a freshcodex exec --sandbox read-only ...hung indefinitely (17+ min): single thread parked infutex_do_wait, 0 CPU total, no TCP sockets, and — notably — zero rows written tologs_2.sqlite, i.e. it blocked at/before store initialization. Not reproducible after killing the orphans; three control runs with identical flags/prompt/env completed in seconds. Smells like contention on the shared sqlite stores rather than anything network-side.Impact 2 — review results never delivered. An adversarial review dispatched through the plugin ran to apparent completion inside an app-server session (visible in the log DB), but the wrapper agent never surfaced a verdict — consistent with requests landing on a stale broker no live session drains.
Workaround we use: kill orphan chains (node wrappers whose PPID is systemd/1 — never interactive TUIs), and wrap direct CLI calls in
timeout. An idle-timeout self-exit in the broker (as proposed here) would eliminate both impacts at the source.ccheng555 commentedon Jul 28, 2026
#563 is an open PR against this: the broker releases its idle
codex app-serverchild and restarts it on demand, and Unix cleanup is changed from the current single-pidSIGTERMto identity-verified process-tree termination, so the detached helpers that outlive the app-server are reaped rather than reparented.Worth flagging for anyone else working this issue: the second half matters independently of the idle timeout.
close()inlib/app-server.mjstree-kills only on win32; the Unix path signals one pid, so a broker that shuts down cleanly can still leave the helper subprocesses behind. The PR write-up documents where the guarantee stops, including thesetsid-after-scan case it does not cover.Sanexio commentedon Jul 30, 2026
Independent confirmation on macOS, plus two details that affect the proposed fixes.
Environment
openai-codex/codex1.0.4 (marketplace commit 807e03a); the two relevantfiles are byte-identical on
main(db52e28), so this is still open at HEAD.task --cwd <repo>delegation from Claude Code sessions.
Observed (one-time measurement, 2026-07-30 ~13:00 local, aggregated over 5 hosts
via
ps -Ao pid=,ppid=,pgid=,etime=,rss=,args=filtered in Python — notpgrep -f, see below):23 broker processes, 56 processes in total including their
codex app-serverchildren,~2.1 GB summed RSS, zero clients connected. Oldest broker: 4 d 15 h elapsed
(corroborated by the birth time of its leftover
$TMPDIR/cxc-*session dir).The largest single tree summed to ~3.9 GB RSS on one host (RSS summing overcounts
shared pages, so treat that as an upper bound). All 23 were idle: no TCP/UDP sockets,
no CPU movement beyond timer ticks (10 ms per 8 s), no queued/running jobs in the
matching
state.json.Detail 1 — the
SessionEndhook is not the missing kill.session-lifecycle-hook.mjs:109does passkillProcess: terminateProcessTree.The gap is that it is keyed by cwd (
loadBrokerSession(cwd), :82-84), so it only everreaps the broker of the session workspace root. Every broker started via
--cwd <other repo>is registered under a different state dir and is never visited.That is #380; worth stating explicitly here because "SessionEnd doesn't kill" is
the wrong summary and sends fixes in the wrong direction.
Detail 2 — the pid-file is not a usable handle for any reaper.
teardownBrokerSession(broker-lifecycle.mjs:174) only killsif (Number.isFinite(pid) && killProcess), but unlinks the pid-file unconditionallyright after (:182-184). Since the production caller
app-server.mjs:340→ensureBrokerSession(cwd, { env: options.env })passes nokillProcess(this is exactly PR #566), the normal outcome is a live broker whosepid-file is already gone. Consequence for the fixes discussed in this thread: a
pid-file- or session-dir-based reaper systematically misses the oldest orphans.
The reliable identity is
broker.jsonunder~/.claude/plugins/data/codex-openai-codex/state/<slug>-<hash>/, plus argv fromps.Two measurement traps that cost us a day, in case others script around this:
pgrep -f app-server-brokerexcludes its own ancestors on BSD/macOS, so a checkrun from inside a codex process tree silently misses the very broker that spawned it;
ps | grepovercounts by the pipeline. Readps -Ao …args=and filter in-process.ppid=1is not an orphan indicator: brokers are starteddetached: true+unref()(
broker-lifecycle.mjs:59-70), so even a 5-minute-old healthy broker has ppid 1.Workaround we run now (external, so plugin updates don't clobber it): a reaper
every 30 min that terminates only brokers which are (a) missing their socket file,
(b) whose
--cwdno longer exists, or (c) idle beyond a threshold — guarded againstlive jobs (checking that the recorded worker pid is actually alive; we found
status: "running"records whose workers had been dead for a month), open sockets,real CPU movement, and linked worktrees. Shutdown via the
broker/shutdownRPC overthe unix socket succeeded for all 22 brokers we terminated — SIGTERM was never needed,
which suggests a self-exit path would be equally clean.
Happy to provide the anonymized process dumps if useful.
ccheng555 commentedon Jul 30, 2026
Thank you. This is strong independent confirmation, and both implementation details match the root cause addressed by #563 (now at 4e8884b).
The fix does not depend on cwd-scoped SessionEnd enumeration, pid files, session directories, or PPID 1. Each broker, stable session owner, and app-server child is published with PID + process-start identity in a plugin-global immutable registry before activation. SessionStart scans every registered broker across cwd boundaries; a dead owner makes the registered tree eligible, while live owners or malformed evidence remain report-only.
The latest commit also durably appends post-activation child observations before request results are exposed, so independently regrouped helpers remain identity-backed cleanup targets, and it writes a terminal record after verified zero-residual cleanup so completed registries are not retried forever. The end-to-end regression creates a helper after activation in its own process group, crashes the app-server, verifies the helper is reclaimed before replacement, and the full suite passes 192/192.
Your PPID and pgrep cautions are especially useful. The implementation does not classify PPID 1 as abandonment; eligibility comes from registered owner identity/liveness. A retest of #563 against the multi-cwd workload you described would be very valuable.
aaronmeza commentedon Aug 3, 2026
Another independent confirmation on macOS at HEAD, plus a root-cause detail I haven't seen stated in this thread: there are two separate mechanisms that break the state-file lookup, and only one of them involves symlinks. Both make SessionEnd resolve a different state directory than the one the spawn wrote, so the broker is never found to be torn down.
Environment
openai-codex/codex1.0.4, marketplace commit807e03aMeasurement
Taken just now, roughly one week after a manual sweep cleared this machine to zero, so this is pure re-accumulation over ~7 days:
25 brokers across 12 distinct working directories, so roughly 2 per directory - consistent with the single-slot state file being overwritten when a directory spawns more than one broker over its life. 25 matching stale
cxc-*session directories under$TMPDIR. Every one reparented to pid 1.Enumerated from
ps -Ao pid=,ppid=,etime=,rss=,args=filtered in Python rather thanpgrep -f, and elapsed times parsed as[[DD-]HH:]MM:SS- a naive string sort over that field silently reports a 3-day-old process as younger than a 34-minute-old one.The earlier sweep on the same machine found 235 brokers at 2.06 GB, oldest 28 days, spread over ~90 distinct working directories, 226 of which pointed at a directory that no longer existed.
The two divergence mechanisms
resolveStateDir(cwd)inlib/state.mjskeys the state directory on<basename>-<sha256(canonicalWorkspaceRoot)[:16]>, where the root comes fromresolveWorkspaceRoot(cwd)and is then canonicalised withfs.realpathSync.native. Both of those steps fall back silently when the directory is gone, and they fail in different ways:A. The git-toplevel fallback. Symlink-independent, fires on any cwd that is not itself the repository root.
resolveWorkspaceRootcallsensureGitRepository(cwd)and, on throw, returnscwdunchanged. While the directory exists that call returns the toplevel; once it is gone the call throws and you get the cwd instead. Both the slug and the hash change.B. The realpath fallback. Fires only when the path has a symlinked component.
fs.realpathSync.native(workspaceRoot)throws on a missing path and the code falls back to the non-canonical string, so the sha256 input differs from the one used at spawn time. On macOS this catches anything under$TMPDIR, since/var/folders/...is a symlink to/private/var/folders/....Isolating experiment - same script, one run under
$TMPDIRand one under a path with no symlink component, resolving the state dir before and after deleting the directory:So a worktree at a non-symlinked path is the only configuration that still resolves correctly after deletion. A cwd one level below the repository root never does, deleted or not, regardless of symlinks - mechanism A alone is enough. That is worth knowing for anyone testing a fix: a repro that only ever uses the repository root as cwd on a non-symlinked path will look like it passes.
This also seems to me like an argument for the shape #563 already takes. Publishing PID plus process-start identity to a plugin-global registry and scanning it across cwd boundaries sidesteps both mechanisms, because neither the path nor its canonical form is load-bearing any more. I've read the description of that PR rather than its diff, so treat that as a reading of the approach and not a review of it.
Interim mitigation
For anyone hitting this before a fix lands, the rule that worked here was: kill only brokers whose
--cwdno longer exists, and skip any whose cwd cannot be parsed rather than killing it. A live session's directory still exists, so the rule cannot touch one. On the 235-process sweep that killed 229 and spared 6, reclaiming 1.94 GB, with no session disrupted.One gotcha worth repeating: capture the pid list up front and iterate that static list. Running the loop inside a shell pipeline truncated it, and only 16 of 229 were killed on the first attempt.
This is a mitigation, not a fix - and it stays necessary even after one, since a crash, a
kill -9, or a reboot skips session-end teardown entirely no matter how correct that teardown becomes.scotthamilton77 commentedon Aug 4, 2026
Confirming this on macOS 26.6 (arm64, Node 24), plugin 1.0.3 and current
main(db52e28) — the two broker files are byte-identical between them, so this reproduces on every released version.I want to add a second, independent mechanism that I don't think this thread has named yet. The framing here is "no idle timeout + fragile SessionEnd reaper", and both are real. But there is also a spawn race that creates orphans no reaper of any design can reach, because their pids are never written down.
The race
ensureBrokerSession(plugins/codex/scripts/lib/broker-lifecycle.mjs:113) is check-then-act with no mutual exclusion:N concurrent invocations from one workspace all observe "no ready broker", all spawn one, and all but the last writer of
broker.jsonbecome unreachable. Their pids exist in no file on disk.handleSessionEndreads a single record vialoadBrokerSession, so even a perfectly reliable SessionEnd hook only ever reaps the one winner.Reproduction — deterministic, no credentials, no
codexbinaryA stub stands in for
app-server-broker.mjs, so this needs nothing but the repo:(The last two lines matter — without them the reproduction leaks the eight stubs it just created, which is rather the point.)
Eight callers, eight brokers, one record. Reproduces on the first run, every run.
Two smaller findings from chasing this
A plain
SIGTERMto a broker does not reliably stop it. Its handler awaitsappClient.close(), which awaits thecodex app-serverchild's exit; signalling the broker alone leaves it hung. Signalling the process group works — brokers are spawneddetached: true, so each leads its own group, andterminateProcessTreealready does the right thing. Worth knowing for anyone scripting a cleanup:kill <pid>will look like it did nothing.A broker carries no record of the environment it was born under. The
codex app-serverchild inherits its environment at spawn and holds it for life, while later callers reuse it over a socket and never have their own environment consulted.broker.jsonhas no environment fingerprint, so a caller cannot tell whether the broker it is about to reuse was started under an environment compatible with its own. This turned a deterministic credential fault into two days of ghost hunting for me: same command, same second, callers that reused a healthy broker succeeded while callers that spawned their own failed. It is adjacent to #382 but not the same claim, so I have not filed it separately — say the word if it deserves its own issue.Note on ordering, for anyone fixing this
The leak currently masks environment faults. Reap the orphans first and every invocation spawns fresh, so a bad environment reaches
codexevery time and an intermittent failure becomes a constant one. Nothing has broken at that point — the mask has come off.Happy to open a PR for the lock if that's wanted; I have one written with regression tests that fail on
mainand pass with it. I held off because #563, #566 and #580 are already in flight over the same three files and I would rather not add a fifth.wolverin0 commentedon Aug 6, 2026
Independent confirmation on Windows — the first in this thread, I think, and the platform detail matters for #563. Also adding a magnitude measurement ~4x the original report, and the mechanism that explains the difference.
Environment
openai-codex/codex1.0.4 (marketplace commit807e03ac).scripts/app-server-broker.mjsis byte-identical between 1.0.4 andmain(db52e28).git log 80c31f9..db52e28 -- plugins/codex/scripts/app-server-broker.mjsreturns 0 commits, so neither 1.0.5 nor 1.0.6 touches it.Measurement
Killing only the orphaned broker chains (nothing else):
node.exeprocesses~9.5 GB reclaimed on one workstation, against the ~2.2 GB / 272 procs in the original report.
Why it is that much larger: the cost is the app-server child's MCP fleet, not the broker
One cwd's chain had spawned its 9-server MCP fleet 11 times in ~15 hours — 00:37, 01:22, 01:57, 02:28, 11:03, 11:54, 12:21, 12:22, 12:34, 15:11, 15:52 — 99 MCP servers, roughly 9 GB. The 11 fleets were statistically indistinguishable from one another: 13-18 s CPU, ~805 MB, ~2200 handles each. Each one initialized fully and was then abandoned.
Two consequences:
Idleness confirmed rather than assumed: the two surviving orphan brokers (started 00:37 and 12:25, serving pipes
cxc-bvUlfZ-codex-app-serverandcxc-Wf5FrG-codex-app-server) showed CPU deltas of 0.00-0.02 s across an 8 s sample — no work at all, consistent with "0 clients connected".Identifying orphans safely on Windows
Worth stating because a blanket sweep is not safe here — brokers are shared between concurrent sessions (#540, #526), so killing every broker kills live work.
The reliable discriminator was ancestry, not name or age: a broker is an orphan iff its own immediate parent is dead. On this setup a live pane's CLI roots at
sh.exe, whereas a plugin-spawned chain roots atnode.exe, so parent-liveness separates the two cleanly. Sweeping on that rule produced no false positives — all 6 live pane CLIs survived.Windows and #563
Reading the PR, several of its mechanisms are explicitly gated off on
win32:ownershipSnapshot/procIdentityare set tonullon win32, so a Windows broker publishes no process identity into the registry;refreshOwnership()andstartUnexpectedExitCleanup()early-return on win32;gatedBrokerChild(the idle app-server child release) is disabled on win32.What Windows keeps is the
terminateProcessTreecall on the normal-close path — which is precisely the path that does not run in the hard-kill / crash / deleted-cwd case that produces these orphans. The newbroker-ownership.mjsandregistered-broker-reaper.mjsthemselves look platform-neutral, but on Windows they would be reasoning over registrations with no identity attached.Is Windows intended to be in scope for #563, or is it better tracked as a follow-up? Happy to test a Windows path against this workload if it helps — the reproduction here is reliable and the signal is large.
scotthamilton77 commentedon Aug 15, 2026
Follow-up to my earlier comment: we investigated the "silent empty result" failure mentioned there (an invocation at 6-way parallelism that printed "Turn started", then exited 0 with empty stdout) and traced it to a different mechanism than the spawn race. Two findings, both reproduced against plugin 1.0.3 with stubs only (no credentials, no real codex binary):
1. A turn's completion promise is never rejected on transport loss — this is the silent empty result.
captureTurnawaitsstate.completion(scripts/lib/codex.mjs:600). The promise'srejectCompletionis captured (codex.mjs:299-315, typed at line 20) but never called from anywhere in the plugin — the rejection path was designed and left unwired.handleExit(scripts/lib/app-server.mjs:162-175) rejects only in-flight JSON-RPC requests; once a turn is streaming,pendingis empty, so a broker socket or app-server child dying mid-turn settles nothing. The event loop drains (the inferred-completion timer is unref'd), node exits 0, and stdout is empty — a lost result indistinguishable from success.Reproduced 20/20 deterministically on both transports: broker socket destroyed mid-turn, and a direct-spawn app-server exiting mid-turn. No concurrency required — parallelism just raises the odds of a transport death (e.g. via the orphan accumulation this issue tracks). A turn that reaches any terminal event always writes stdout (the answer, JSON, or "Codex did not return a final message."), so exit 0 + zero bytes is an exact signature of this path.
Fix shape: wire the already-captured
rejectCompletion— e.g. racestate.completionagainst the client'sexitPromise(app-server.mjs:70-72) — so transport loss becomes a loud error instead of a clean empty exit.2. Single-target notification routing can deliver one client's answer to another — and this interacts with the spawn-lock fix.
routeNotification(scripts/app-server-broker.mjs:84-100) sends every notification to one global target (activeRequestSocket ?? activeStreamSocket), and the guard at line 92 releases stream ownership on anyturn/completedcarrying nothreadId, regardless of whose stream is running. Demonstrated against the genuine broker with two clients: client A'sturn/completedand final answer were delivered to client B, which discarded them; a real launcher in A's seat hangs indefinitely. Caveat: the trigger (aturn/completedwithoutthreadId) was injected by a stub app-server — I have not measured whether real codex-cli traffic emits one.The interaction worth flagging: today the spawn race gives concurrent callers mostly-private brokers, so this routing path is rarely exercised. The per-workspace spawn lock (in the fix I offered earlier, and in the open PRs here) makes broker sharing the norm — which is correct, but it turns this defect from accidentally-rare into routinely reachable. I'd suggest landing per-client (thread-keyed) notification routing in the same change as the lock, or the lock trades a process leak for lost answers.
Happy to share the stub harnesses for either reproduction if useful.
Polsemaker commentedon Aug 20, 2026
Another data point for this, from a different machine — and one dimension that I think is missing from the current framing.
The orphaned brokers are not idle. They spin.
Every report here (including #652) frames this as a RAM leak. On my machine the CPU cost was the larger problem:
Sampling method, in case it's useful for reproducing:
On a laptop that is continuous battery drain from sessions that ended days ago, which I think argues for a shorter default than 15 minutes — or at least makes the idle timeout worth prioritising above a pure-RAM reading of the bug.
Environment: Fedora 44, plugin 1.0.6.
Confirming the deleted-worktree correlation. Same as the sixteen chains you saw: my leaked brokers'
--cwdpointed at git worktrees I had removed after merging. That is a reliable, cheap signal — a broker whose--cwdno longer exists provably has no session to return to.Reproduces immediately. A single
/codex:reviewrun left a fresh orphaned broker (60 MB) that was still running 30 minutes later, after the review had completed.Two traps I hit writing a local stopgap reaper, in case they're useful for the fix design:
1.
ppid=1is not evidence a broker is abandoned. The broker is spawneddetached: true+child.unref()inlib/broker-lifecycle.mjs, so being reparented to init is the normal state for a healthy broker, and it is reused across sessions viabroker.json. I built the first version of my reaper onppid=1+ an age threshold, and measured a broker that was actively serving an in-flight review while havingppid=1and being several hours old. That reaper would have killed it. Anything that garbage-collects on "orphaned" needs a stronger signal than parentage.2. Careful if the idle check is based on connected clients. #652 proposes self-exit "after ten minutes with no connected client". When I measured a live, in-use broker,
ss -xshowed zero peers on itsbroker.sock— while an unrelated idle broker showed one. I don't claim that proves the broker had no client; more likely my socket-level inspection is the wrong lens. But it does suggest the liveness signal should come from the broker's own protocol state (last request timestamp, in-flight turn count) rather than from OS-level socket inspection, or the timeout risks killing brokers mid-work.The two signals I ended up trusting, both cheap and both false-positive-free in testing:
--cwdno longer exists → the workspace is gonebroker.jsonfor that workspace records a different pid → this one is a superseded predecessorThat is deliberately conservative: it leaves a leaked broker running when its workspace still exists. A proper idle timeout in the broker itself is the real fix, which is why I'd rather see #543 land than keep maintaining a reaper outside the plugin.
t3ta commentedon Sep 7, 2026
Additional confirmation (macOS, plugin 1.0.4)
This reproduces the broker leak described here in a real Claude Code workload.
Observed locally:
app-server-broker.mjsprocesses, allPPID=1, with--cwdpointing at per-task git worktrees.codex app-serverprocesses and 128 Notion MCP processes; summed matching RSS was approximately 3.3 GB (RSS sum, so shared pages may be counted more than once).lsofshowed only the listening broker for each socket and no connected client.mainbranch currently reports plugin version 1.0.6 and still declares a 5-secondSessionEndtimeout.The local Claude transcripts also contain at least five occurrences of:
This matches the failure chain in the issue:
SessionEndsendsbroker/shutdown, which waits for app-server cleanup; when Claude cancels the 5-second hook, the detached broker and its child tree remain alive. The absence of broker-side idle self-termination then makes the leak permanent.The relevant upstream files are:
plugins/codex/hooks/hooks.jsonplugins/codex/scripts/session-lifecycle-hook.mjsplugins/codex/scripts/app-server-broker.mjsThis is additional field evidence for #543 rather than a separate issue. #474 covers the related request to increase the
SessionEndtimeout.francocapra commentedon Sep 16, 2026
Another data point, plus a trigger that I don't think is called out above: Claude Code subagents running with
isolation: "worktree".Environment: macOS 26 (Darwin 25.6), Apple Silicon, plugin
codex1.0.6 at marketplace commitdb52e28(currentmain), Node 23.9, Claude Code 2.1.273.Observed on 2026-09-16: 39 orphaned broker chains, all reparented to launchd (ppid 1), 0 clients on every
broker.sock. Each chain heldcodex app-server+ the computer-use plugin +node_repl+SkyComputerUseClient+ a@playwright/mcpserver, for a total of 258nodeprocesses and ~4.8 GB RSS; 1-minute load average was 18–24 on an otherwise idle machine. 12 of the 39 pointed at git worktrees that had already been deleted (2–7 days old). The other 27 pointed at.claude/worktrees/agent-*directories and were all under 1 hour old, spawned by a single Claude Code session.Why subagents make this worse: when the main session dispatches a subagent with
isolation: "worktree", the subagent's cwd is the new worktree, soensureBrokerSession(cwd)spawns a fresh broker keyed to that worktree.SessionEndnever fires for a subagent, and when the main session ends the hook looks upbroker.jsonfor the main cwd only (#380). Net effect: one leaked chain per worktree subagent, so a session that fans out a dozen subagents leaks a dozen chains in an afternoon, with no crash or hard-kill involved.Workaround we deployed (until an idle timeout lands, cf. #457 / #580 / #680): a user-level hook on
SessionStart,SubagentStopandSessionEndthat listsapp-server-broker.mjs serveprocesses and sendsSIGTERMto the ones that are (a) older than 5 minutes and (b) have zero clients on their unix socket (lsof -U, ignoring the broker's own fd). The broker'sSIGTERMhandler already tears down the app-server tree cleanly, andensureBrokerSessionrespawns on the next call, so an idle kill costs one cold start and nothing else. It also removescxc-*temp dirs whosebroker.pidis dead. Happy to share the script if useful.Confirms the diagnosis in the issue body: an idle self-exit inside the broker is the fix that survives every hook-miss path, including this one.
tehfiend commentedon Sep 19, 2026
Same thing here on Windows 11, and it has a second consequence beyond the RAM. The broker makes a git worktree undeletable.
I'm on Claude Code 2.1.278 with plugin 1.0.4 and now 1.0.6, and the broker script is byte for byte the same in both. Here's what I measured on 2026-09-19.
A Claude Code worktree session (claude -w tldr-skill) calls Codex once. ensureBrokerSession spawns node app-server-broker.mjs serve --cwd C:/.../.claude/worktrees/tldr-skill, detached and unref'd. The session exits and Claude Code removes the clean worktree at exit with git worktree remove. On Windows that folder is the broker's working directory, so git deletes the checkout and the .git file and then fails on the folder itself. The registration gets pruned but the branch and the empty folder stay behind.
The SessionEnd hook never reaped the broker. Its broker.json under CLAUDE_PLUGIN_DATA\state\tldr-skill-\ is still there, so teardownBrokerSession and clearBrokerSession never ran. Four hours later the broker (pid 60652, parent gone) was still alive with zero clients, holding the empty folder. I had a second one from three days earlier holding another worktree's folder the same way.
Looking at app-server-broker.mjs, there's no exit path of its own. socket.on("close") just clears ownership, there's no check for the last client leaving, no idle timer, and nothing watching the process that started it. So any miss by the SessionEnd hook leaves it forever, and a Claude Code worktree session removing its cwd at exit is a miss every time.
A few things would fix this at the source. Exit when the last client disconnects and nothing reconnects within a few minutes, which is the patch proposed above. Or watch the owning Claude process and exit when it's gone. And either way, don't spawn the broker with cwd set to the workspace. It already gets --cwd as an argument, so spawning it from os.tmpdir() means a leaked broker no longer pins the folder.
For now our worktree removal script ends any process whose command line names the folder before it runs git worktree remove, which works but shouldn't be needed.
drakulavich commentedon Sep 20, 2026
Another data point, plugin 1.0.6 on macOS 26.6 (Darwin 25.6), Claude Code driving
codex-companion.mjs task …through thecodex-rescuesubagent, one Claude Code session:taskinvocations over a day (each a separate review job),ps -Axo pid,ppid,command | awk '$2==1'showed 20 orphanednode …/codex/1.0.6/scripts/app-server-broker.mjsprocesses, ages 1 h – 20 h, 192 MB RSS total, none attached to a live job.SessionEndhook never had a chance to run; nothing else reaps them.kill(SIGTERM) cleared all 20 cleanly, so the shutdown path itself works — it is just never invoked.An idle timeout in the broker (exit after N minutes without a client) would have covered this case without relying on the hook.
Fairmont77 commentedon Sep 22, 2026
Same here on macOS 26.6, plugin 1.0.6 (Claude Code 2.1.278): 269 orphaned processes in 47 broker chains (app-server-broker.mjs + codex app-server + cua-repl + codex-code-mode-host), all reparented to PID 1, 11.9 GB RSS total, oldest alive for 1d16h, with only 4 Claude sessions open. Killed them by hand. Interim workaround until an idle-timeout PR (#680, #580) lands: a Claude Code SessionEnd hook that kills those four process names older than 3 h. +1 for merging an idle timeout in the broker itself.
gus752 commentedon Sep 27, 2026
Another data point, plugin 1.0.6 on macOS 26.6 (Darwin 25.6), Claude Code 2.1.280/2.1.283, several Claude Code sessions each calling
codex-companion.mjs taskin the background.After about 3 days: 121 brokers, all reparented to launchd, with about 3,400 processes under them and about 32 GB RSS on a 48 GB machine. Each chain holds
codex app-server,codex-code-mode-host, two ChatGPTcua_nodeprocesses and one copy of every MCP server in the user's Codex config. That is 368 copies of each Python MCP server, plus 163 open ssh sessions from an ssh-launched MCP server.30 of the 34 brokers still running at the time of checking had a throwaway working directory (a
mkdtempdir holding task.md/output.md). SessionEnd only stops the broker for the session's own cwd (#767), so a broker keyed to a temp dir is never stopped. #753 (ensureBrokerSessionclearing a live broker's state without killing it) fits the rest.Workaround we run: an hourly job that sends SIGTERM to every broker older than 2 hours, and to every process under it, skipping any broker whose cwd matches a running
codex-companion.mjsjob. An idle timeout inapp-server-broker.mjs, so the broker exits after N minutes with no connected clients, would fix all of these cases.Stener1 commentedon Oct 15, 2026
Another data point, and a case the
SessionEnd-based fixes (#605, #660) can't help with: the owning sessions never ended.Setup: plugin 1.0.6, codex-cli 0.161.0, Claude Code 2.1.300, Linux VM with 8 vCPUs. Claude Code sessions run in tmux under an agent orchestrator. They stay open for days, and many of them run the review gate via
codex-companion.mjs review/adversarial-review.What we found: 36 broker chains (35
codex app-serverprocesses plus theirnodewrappers, about 5.2 GB RSS in thecodexprocesses alone), all reparented tosystemd --user.BERTH_SESSION/CLAUDE_CODE_SESSION_IDenvironment to a runningclaudeprocess.cwdit had reviewed in.CPU: every idle
codex app-serverdrew about 4% of a core, constantly, with no client or job attached. Measured from/proc/<pid>/statutime+stime deltas over 10 s. Together that was about 240% of one core, or 30% of the machine. That was more than all 46 Claude Code sessions combined, five of which were actively working.Why
SessionEnddoesn't cover it: the hook works fine here; it just never fires, because the sessions are long-lived by design. The broker's lifetime is tied to the session's, and a session holds one broker percwd, so brokers pile up over its life. The plugin also has no way to tell an open-but-idle session from an active one. An idle timeout in the broker itself (#108, and PRs #680, #580, #457) is the only one of the proposed fixes that addresses this case.ensureBrokerSession()already starts a new broker when the endpoint is dead, so a short timeout costs little: one cold start on the next review.Workaround: stopping every idle broker with
SIGTERMwas clean. I sentSIGTERMto each broker and itsnode/codexchildren together, and nothing neededSIGKILL. I haven't yet run a review in one of those sessions afterwards; going byensureBrokerSession(), it should simply start a new broker. Afterwards: 35 → 3codexprocesses, 5.2 GB → 0.7 GB, and the box went from about 11% to 67% idle.kevvykevwin commentedon Oct 24, 2026
Same leak on 1.0.4, macOS: 48 broker chains up to 19 days old, 309 processes, ~2.25 GB RSS, with no live Claude session older than a day. One thing I didn't see called out above: each orphaned app-server also keeps its Codex Computer Use client (
SkyComputerUseClient) alive — 117 of them here. On a 24 GB machine this pushed swap to 15.5/16 GB; stopping the orphans freed ~5.7 GB of swap. +1 for landing an idle timeout (#680 / #580).adoosh-afk commentedon Oct 24, 2026
cx-4 [6c32f3] (codex): independently reproduced the idle broker leak on plugin 1.0.6 at
db52e28f4d9ded852ab3942cea316258ae4ef346using a fake Codex executable, then ran the existing PR #814 candidate4ab2ee43fc10e90f9d60a8d4a129d2cd3044ad21on macOS/Node 22.23.3. Installed source: 1 test, 0 pass, 1 fail, 0 skip. Candidate broker/process suites: 28 tests, 28 pass, 0 fail, 0 skip. No live model requests or signals to existing sessions.Baseline log SHA-256:
6979fdf02610aed4b5c93a61e64b2922fa74738549fd0c7cd14a445864cc653b. Candidate log SHA-256:514acfc24d9608ce90583f5de2df033ec023bff4838b5f5878172df96ee04a14.A safety constraint for external cleanup: brokers are intentionally detached, so age and PPID=1 cannot establish abandonment. The current broker has no atomic idle-only shutdown RPC, and the state loader converts unreadable/malformed job state into an empty default. The upstream candidate checks connected clients, pending requests, active turns and queued/running jobs inside the broker, and preserves unreadable job state as a refusal. These local tests support evaluating that existing candidate without duplicating its owner's work. They do not establish production readiness or fix shared-session shutdown (#612).
Agent-Tool: codex
rampagege commentedon Nov 2, 2026
Same on macOS, plugin 1.0.6. Found 55 orphaned
app-server-broker.mjs(ppid=1, oldest 17 days). The leak is amplified by MCP servers configured in Codex: each orphaned broker kept itscodex app-serverplus its MCP children (playwright-mcp, computer-use node processes, node_repl) alive — 1,491 processes / ~18 GB RSS total. Killing the broker trees dropped node memory from 21.5 GB to 2.9 GB. +1 for an idle timeout that tears down the whole process tree (#680 / #814).Morghot42 commentedon Nov 3, 2026
Data point for the live broker side of this issue, plus a small mitigation I haven't seen proposed here.
Idle timeouts don't cover it. The idle-timeout PRs (#680, #814, #660) only fire when a broker has no clients. A long-lived Claude Code session that calls Codex more often than the timeout keeps its broker alive, and every review/task leaves its thread loaded: the broker never sends
thread/unsubscribe, so the app-server keeps one full MCP server set per thread. That is #706, and #707 fixes it. On plugin 1.0.6 / macOS, one broker alive for 1 d 17 h held 35 idle threads (thread/loaded/list, allidle): 281 processes, ~14 GB.Verification. Sending
thread/unsubscribefor those 35 threads through the running broker (no restart) unloaded them about 60 s later (thread/closed). The app-server went from 281 processes to 1, and node/python processes on the machine went from 422/130 to 199/51. With a local broker patch equivalent to the core of #707 (thread owners per socket, unsubscribe on last-owner close, subagent children found viaparentThreadId), a finished task now leaves 0 loaded threads and 0 MCP processes about 70 s after it ends.Complementary mitigation: start review threads without MCP servers. Reviews rarely need the user's MCP servers, and
thread/startaccepts a per-threadconfig, so the shared app-server can keep them for tasks while review threads skip them:Verified on a real
adversarial-review: the disabled server never starts for that thread, the others do, and the review still finds the bug planted in the test repo. It is the per-thread variant of the app-server-wide override suggested in #509, and it limits what any remaining leak can hold.vaskockorovski commentedon Nov 6, 2026
Plugin 1.0.6, Linux. One workstation running a fleet of Claude Code lanes, each in its own git worktree, each running
codex-companion.mjs reviewonce per candidate commit.One datum I don't see stated above: the leak does not need an unclean exit. A review pass that completes and returns its verdict normally still leaves its broker running, and because each worktree is a new workspace key, each lane's pass spawns a new broker. The count therefore grows with the number of passes, not with crashes.
SessionEndonly visits the broker for the session's own cwd (Sanexio and francocapra describe this above), so it would not reach these either; on this workstation the plugin was not enabled in Claude Code as of 6 Oct, so nothing sentbroker/shutdownat all.Measured here:
free -mbefore and after. A review pass run during that diagnosis returned its verdict and left its own broker alive afterwards.I agree with the idle timeout proposed in #108 as the fix that covers these cases, and have nothing to add on the design.
Morghot42 commentedon Nov 6, 2026
Reproduced @vaskockorovski's per-worktree case on macOS, and measured it with both fixes applied locally: thread release on client disconnect (as in #707) plus a 15-minute no-client idle shutdown (as in #680 / #814).
Test: throwaway repo, two worktrees, one
adversarial-review --waitper worktree, each completing normally (verdict returned, exit 0), thengit worktree removeon the second one.So the three observations hold on 1.0.6: a clean exit leaves the broker alive, every worktree gets its own broker, and removing the worktree does not stop it. With the two changes together, the count is bounded by the worktrees used in the last 15 minutes:
Neither change alone is enough. Without thread release, a broker reused more often than the timeout keeps piling up threads (my previous comment). Without the idle timer, the per-worktree brokers from this comment never exit.