You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Broker process not cleaned up on session exit — no idle timeout #108
The Codex companion app-server broker process (app-server-broker.mjs) persists indefinitely after the Claude Code session that spawned it exits. Found a stale broker (3 processes, ~185 MB) running 5+ hours after the originating session ended.
Root Cause
The plugin has a well-implemented session-lifecycle-hook.mjs with handleSessionEnd() that sends broker/shutdown — but this hook doesn't appear to fire reliably. The broker itself has no idle timeout — it only shuts down upon receiving an explicit broker/shutdown RPC or SIGTERM/SIGINT.
When the session-end hook fails to fire (or the session crashes/is killed), the broker runs forever.
Suggested Fix
Add an idle timeout to app-server-broker.mjs as a defensive fallback. If no RPC messages are received for N minutes (e.g., 15-30), the broker should self-terminate. This complements the existing session-end hook rather than replacing it.
The lifecycle hook approach is correct, but defense-in-depth with an idle timeout would prevent orphaned brokers when the hook doesn't fire.
Environment
Ubuntu 22.04, Claude Code CLI
@openai/codex-plugin-cc v1.0.2
Broker was spawned for intake-portal project, outlived the session by 5+ hours
Workaround
Manually killing the broker processes after session exit.
Added a 15-minute idle timeout to app-server-broker.mjs. The timer resets on every incoming socket data (any RPC activity) and calls the existing shutdown() for clean teardown.
+const IDLE_TIMEOUT_MS = Number(process.env.CODEX_BROKER_IDLE_TIMEOUT_MS) || 15 * 60 * 1000;+let idleTimer = null;++function resetIdleTimer(shutdownFn) {+ if (idleTimer) clearTimeout(idleTimer);+ idleTimer = setTimeout(async () => {+ process.stderr.write(`Broker idle for ${IDLE_TIMEOUT_MS / 1000}s with no RPC activity, self-terminating.\n`);+ await shutdownFn();+ process.exit(0);+ }, IDLE_TIMEOUT_MS);+ idleTimer.unref();+}
// Before server creation:
+ resetIdleTimer(() => shutdown(server));
// First line inside socket "data" handler:
+ resetIdleTimer(() => shutdown(server));
Configurable via CODEX_BROKER_IDLE_TIMEOUT_MS env var
.unref() ensures the timer doesn't prevent normal exit
Repro: prior session's codex:rescue skill call did not complete cleanly. Next session's first codex:rescue call hung indefinitely.
State at hang time:
Detached app-server-broker.mjs serve process from a prior session still running (started ~30 min earlier, parent gone, PPID 1).
Its Unix socket at /tmp/cxc-XXXXXX/broker.sock was accepting connections.
~/.claude/plugins/data/codex-openai-codex/state/<project-key>/broker.json recorded the orphan broker's PID + endpoint.
Next session's loadBrokerSession returned the stale entry. isBrokerEndpointReady connected successfully (the 150 ms TCP-connect check passes). runAppServerTurn then hung at the first client.request(...) with no response.
Root cause seems narrower than "no idle timeout": even when the orphan broker is alive, it can be wedged at the application layer — accepts socket connections, doesn't answer JSON-RPC. scripts/lib/broker-lifecycle.mjs:isBrokerEndpointReady only opens a TCP connection, it never sends a JSON-RPC message. A connection-accepting-but-RPC-unresponsive broker passes the check, then the real call hangs forever.
I confirmed the wedged state by hand: echo '{}' | nc -U /tmp/cxc-XXXXXX/broker.sock connected fine but never got a response back — only timeout killed it.
The Codex CLI itself was healthy throughout — codex mcp-server (registered as a Claude Code MCP server) returned a session reply in <30 s with the same auth/config. So the wedge is plugin-side broker state, not Codex.
Workaround in client config — Claude Code SessionStart hook that kills orphan brokers and clears stale state before the plugin reuses it:
Tradeoff: kills brokers shared by concurrent sessions on the same host, but the next call respawns one cleanly. Acceptable for single-session users.
Suggested upstream fix (in addition to the idle-timeout proposal in this issue):
isBrokerEndpointReady should send a real JSON-RPC ping and require a valid response within a short timeout, not just succeed on socket-connect. Sketch:
(Requires the broker to expose a cheap broker/ping method — broker/shutdown already exists, a ping should be straightforward to add alongside it.)
Combined with the idle timeout this issue proposes, the failure mode becomes self-recovering: stale broker.json on disk → ping times out → teardown + respawn happens automatically inside ensureBrokerSession.
loadBrokerSession(cwd) looks up the broker registered for the SessionEnd hook's cwd — i.e., a single workspace root. The teardown call below it then tears down that one broker.
In a Claude Code session where the agent issues /codex:review (or any companion call) against multiple worktrees over the session's lifetime, each call to ensureBrokerSession(cwd, …) in scripts/lib/app-server.mjs:340 spawns a fresh broker keyed to a different cwd. The state file at ~/.claude/plugins/data/codex-openai-codex/state/<workspace-key>/broker.json is per-workspace, so N reviews across N worktrees produce N independent broker triplets (broker.mjs + node wrapper + native codex app-server).
When SessionEnd eventually fires, only one of those N brokers is torn down. The remaining N-1 leak even when the hook fires reliably.
Observed in practice
After ~1 week of normal usage (multi-worktree workflow, agent-driven reviews across ~6 active worktrees, Claude Code session kept alive in a terminal multiplexer):
Memory: ~6 MB per process × 177 processes ≈ ~1 GB resident
59 corresponding stale /var/folders/.../T/cxc-*/ session directories with leftover broker.log files
SIGTERM to all 90 currently-active brokers + their children terminated cleanly without SIGKILL, and teardownBrokerSession's socket/pidfile unlink ran correctly per-broker (the cxc-* directories were left containing only a 0-byte broker.log afterwards). So the existing teardown function is sound — the gap is just that it's never invoked for non-current-cwd brokers.
Why the idle-timeout fix in this issue would resolve the multi-worktree variant too
The idle-timeout fallback proposed in the first comment self-terminates each broker after N minutes of socket inactivity, independent of which cwd the SessionEnd hook is looking at. So a single idle-timeout PR would absorb both:
The original "SessionEnd hook didn't fire" failure mode
This "SessionEnd hook fired but missed N-1 brokers" failure mode
Without idle-timeout, an alternative complementary fix would be to have handleSessionEnd enumerate all broker state files under the plugin's state directory (~/.claude/plugins/data/codex-openai-codex/state/*/broker.json) and tear down each, rather than only the one matching input.cwd. But idle-timeout is the simpler defense-in-depth choice — the per-cwd lookup can stay as the happy-path optimization.
Workaround (mass cleanup)
For others hitting this before the upstream fix lands, terminating all brokers older than N days is safe (existing shutdown() cleans up sockets + pidfiles correctly on SIGTERM):
# Kill all broker triplets older than 2 days
ps -axo pid,etime,command \
| grep -E 'codex app-server|app-server-broker\.mjs' \
| grep -v grep \
| awk '{ if ($2 ~ /-/) { split($2, a, "-"); if (a[1]+0 >= 2) print $1 } }' \
| xargs -n1 kill -TERM
# Clean up the leftover empty session directories
rm -rf "${TMPDIR}cxc-"*
Happy to file a separate issue for the per-cwd handler if it's worth tracking independently, but flagging here in case it changes the framing of the proposed fix.
Reproducing this on macOS (Claude Code with codex@openai-codex v1.0.4): same symptom: stale brokers (app-server-broker.mjs plus node codex app-server plus Rust codex app-server) outlive SessionEnd, reparent to launchd (PPID 1), and accumulate across sessions until killed manually. Tracking PR #343 as the candidate cleanup fix.
Corroborating this with a much more severe real-world data point, plus a code-level confirmation of the root cause and a consumer-side workaround.
Severity is worse than "a stale broker for a few hours"
On a single dev box I found 112 orphaned processes across 14 dead sessions, the oldest 12 days old. Every leaked group is a full app-server-broker.mjs → codex app-server (node + rust) → MCP sidecars (context7-mcp, chrome-devtools-mcp) tree, each holding a /tmp/cxc-* socket dir. They accumulate one group per session that didn't exit gracefully, indefinitely.
The reliable signature of an orphan is exactly what #193 notes: app-server-broker.mjs with ppid==1 (reparented to init = its session is gone). A live session's broker still has a live parent, so this cleanly distinguishes leaked from active.
Code-level confirmation of the root cause
The broker's lifetime depends entirely on the SessionEnd hook firing and succeeding:
lib/broker-lifecycle.mjs spawns it detached: true + child.unref(), so it is deliberately decoupled from the session process tree.
app-server-broker.mjs only self-terminates on an explicit broker/shutdown RPC or SIGTERM/SIGINT (the process.on(...) handlers near the end). Its socket.on("close") handler only removes that one socket from the set — the last client disconnecting does not shut the broker down.
Teardown is driven solely by session-lifecycle-hook.mjs → handleSessionEnd() → teardownBrokerSession().
So any non-graceful session exit — crash, kill -9, SSH/terminal hangup, OOM, or the hook simply not firing — leaves a detached, unref'd broker with no fallback path to ever die. This is why #108's idle-timeout suggestion is the right fix: the fallback has to live in the broker itself, because by definition the leak happens precisely when the hook-based teardown didn't run.
Worth combining a couple of the suggested fallbacks for full defense-in-depth:
Until the broker self-terminates, a Claude Code SessionStart hook that reaps any app-server-broker.mjs with ppid==1 (killing its whole subtree and removing the /tmp/cxc-* dir) cleans these up automatically at the start of each session. It's safe because the kill set is always rooted at an orphaned broker, so live sessions and unrelated processes are never touched.
Corroborating on macOS, v1.0.6 — and importantly, this reproduces in normal runtime use, not just the test suite (#163 is the test-suite variant).
Cleaning up an active dev session just now: ~260 orphaned app-server-broker.mjs processes, all PPID=1, accumulated over ~1 hour of ordinary Claude Code use (not npm test). Each ~30 MB RSS → ~7.5 GB held, plus their codex app-server children. SIGTERM to the PPID=1 cohort reaped them cleanly and free RAM jumped 88 MB → 4.4 GB.
All pointed at test-style socket dirs (/var/folders/.../T/cxc-*/broker.sock), consistent with the detached-broker-reparents-to-init path in the original report — SessionEnd never fires for an unclean exit, and with no idle timeout the broker is never reaped.
PR #490 (idle-timeout, armed when sockets.size === 0 && !activeRequestSocket && !activeStreamSocket) is the exact fix for this class — the 15-min self-exit would have prevented all 260 from accumulating. Strong +1 on landing it; the directory/env-inheritance axis (#487) is orthogonal and separate.
Cross-linking the map: this is scenario 1 in the breakdown I posted in #163 — same root, three scenarios, #490 covers this one.
Still present in v1.0.5. Fresh data point from a Linux/systemd dev box: 6 orphaned broker trees = 18 processes (app-server-broker.mjs → node …/bin/codex app-server → codex musl binary), 2.8–19.6 h old, ~1.5 GB RSS combined, plus 6 leaked /tmp/cxc-* dirs and 106 stale broker.json. Three of the six were still bound to --cwd git worktrees that had already been deleted from disk. All were reparented to systemd --user (unclean session exits — the detached+unref()'d broker survives its parent).
I implemented and verified the idle timeout proposed here; sharing in case it's useful. Two notes that matter for the implementation:
Key it on connected sockets, not on "messages received."BrokerCodexAppServerClient.initialize() opens one connection and close()s it per operation, so between operations zero sockets are connected. An idle timer must therefore arm only when the connection set is empty (disarm on connect, re-arm on last disconnect), and use a generous interval (I used 30 min, env-overridable) so a live session that pauses between calls doesn't lose its warm app-server + thread state. Reaping is self-healing regardless — ensureBrokerSession() respawns on the next request.
Minimal patch against the existing sockets Set + shutdown(server):
constIDLE_MS=Number(process.env.CODEX_COMPANION_BROKER_IDLE_MS)||30*60*1000;letidleTimer=null;functiondisarmIdle(){if(idleTimer){clearTimeout(idleTimer);idleTimer=null;}}functionarmIdle(){disarmIdle();idleTimer=setTimeout(()=>{if(sockets.size===0)shutdown(server).then(()=>process.exit(0),()=>process.exit(0));},IDLE_MS);idleTimer.unref?.();}// in the connection handler: disarmIdle();// in socket 'close' + 'error': if (sockets.size === 0) armIdle();// after server.listen(...): armIdle();
Verified three behaviors: exits when never used; stays alive while a client is connected (idle disarmed); exits after disconnect + idle. This also covers the dead-co-owner facet in #450 with no PID/liveness signal.
Interim workaround for Linux users hitting this now: a systemd --user timer that reaps a broker only when its --cwd no longer exists or no claude process is running (neither can be true for an in-use broker) — happy to share the unit if useful.
Corroborating on macOS (Darwin 25.5), v1.0.5, with a severity data point an order of magnitude above what's been posted — but first a correction that matters for everyone's workaround, because I shipped the wrong one before catching it.
ppid==1 does not distinguish a leaked broker from an active one
Worth separating two process types, because the signature holds for one and not the other.
#193 observes PPID=1 on an orphaned codex Rust binary, and there the inference is sound: that process's parent is the app-server, which is long-lived, so its reparenting really does mean the parent died. The same goes for any codex app-server child.
The generalization to app-server-broker.mjs — "a live session's broker still has a live parent, so ppid==1 cleanly distinguishes leaked from active" — does not hold, at least on macOS. For the broker specifically, ppid==1 is the normal state of a perfectly healthy process.
Measured just now, on a box with 12 live Claude sessions:
broker pid=21066 PPID=1 age=00:07:17 cwd EXISTS --cwd .../bugfix-transcoding-r2-read child: 21073 (codex app-server)
broker pid=11070 PPID=1 age=00:44:36 cwd EXISTS --cwd .../harness-gitar-graphite child: 11071
broker pid=22918 PPID=1 age=02:14:53 cwd EXISTS --cwd .../audit66-e4b-routes child: 22919
... 9 / 9 live brokers at PPID=1, every one with a live `codex app-server` child.
A broker 7 minutes old, serving an active session, in a worktree that exists, with a live child — already reparented to launchd.
Why
It's structural, not a race. spawnBrokerProcess (lib/broker-lifecycle.mjs) uses detached: true + child.unref(), and the spawner is codex-companion.mjs — a one-shot CLI that exits immediately after spawning (every time I sampled ps, including while reviews were running, the live codex-companion.mjs count was 0). So the broker is reparented at birth, whether or not its session is alive. Verified with a minimal repro:
This doesn't invalidate anyone's orphan counts — those orphans are real, and provable by other means. It invalidates ppid as the discriminator. Concretely: SIGTERM to the "PPID=1 cohort" also kills the brokers of every live session. That's usually invisible, because ensureBrokerSession() probes the endpoint and respawns transparently — but it will kill an in-flight review. I did exactly this before understanding the mechanism, on 619 processes at once.
(On Linux/systemd the reparent target is systemd --user rather than 1, so the observed signature may differ there — but the reparent-at-birth mechanism is the same, since it follows from detached+unref plus a transient spawner. Worth re-checking on those boxes before trusting the signature.)
A signal that is sound
We reap on a single criterion: the broker's --cwd no longer exists on disk. A deleted worktree cannot be under review by anyone, so false positives are impossible. It reads --cwd straight from the process command line (broker.json doesn't carry the cwd — only endpoint/pidFile/logFile/sessionDir/pid).
It also sidesteps the trap documented in #450 — process.kill(pid, 0) being unsound under PID reuse, with no cross-platform sessionId→process mapping. "Does this directory exist?" depends on no PID, no /proc, and no ownership model.
It's a partial fix by construction: it only catches brokers whose worktree was deleted (33 of our 55). It is not a substitute for #490 — just a workaround that cannot misfire.
Severity data point
Single macOS box, ~3 days of normal multi-worktree agent use (no test suite involved):
That last row is the interesting one. clearBrokerSession() runs only at the end of handleSessionEnd, so 222 uncleaned state files means the teardown path essentially never completes here — across months of lots, not days. Same story for job state: 39 jobs still marked running in state.json, 0 of them with a live PID, because cleanupSessionJobs() shares the same never-reached code path.
Why so much worse than the 6-14 trees reported elsewhere: this box runs sessions from the main repo while reviews target git worktrees (7 of 9 sessions had cwd = main repo; all 55 brokers had worktree cwds). That's precisely the mismatch in #380 / @devseunggwan's analysis above — so the leak fires on every review, including graceful /quit, not just on unclean exits. The crash path (#108) and the cwd-mismatch path (#380) compound: worktree-heavy workflows hit both on every single review.
On the fix
Strong +1 on #490. The idle timeout is the only design here that needs neither an ownership model nor a liveness probe nor a cwd match — the three things that have each proven unreliable in this thread. @lfarkas's point about arming on sockets.size === 0 rather than on message activity matches what we see: the client opens and closes a connection per operation, so "no messages" is the steady state between calls, not an idle signal.
Still present in 1.0.6 on macOS 15.7.7, four months after this was filed, and with a second symptom this issue does not cover: the broker's codex app-server child accumulates unreaped node_repl grandchildren for as long as the broker lives. On this machine a 16-hour-old orphan held 106 of them, 3.1 GB resident, individually up to 10 h 41 m old. The idle timeout you propose would bound that too, since the children only accumulate while the broker survives — but if reaping is a separate concern, it may deserve splitting out.
Full evidence:
Summary
With the shared session runtime enabled, scripts/app-server-broker.mjs outlives the Claude Code
session that launched it. The broker reparents to PID 1 and keeps serving its unix socket
indefinitely. Its codex app-server child accumulates node_repl grandchildren that are never
reaped, so resident memory grows for as long as the broker lives.
Environment
openai-codex Claude Code plugin 1.0.6 (installed from the marketplace, codex@openai-codex)
Codex CLI 0.153.0, auth method chatgpt
Claude Code 2.1.236
Node v26.8.1, npm 11.19.0
macOS 15.7.7 (Darwin 24.6.0), Apple silicon
Session runtime: shared ("This Claude session is configured to reuse one shared Codex runtime")
What was observed
After roughly 16 hours of intermittent use across two projects:
Process
PPID
Age
Children
RSS
app-server-broker.mjs (cwd project-a), pid 18068
1
16 h 08 m
—
32 MB
codex app-server, pid 18161
18068
16 h 08 m
106
161 MB
106 × Codex.app/.../node_repl
18161
up to 10 h 41 m each
—
3,085 MB total
app-server-broker.mjs (cwd project-b), pid 52931
1
52 m
—
28 MB
codex app-server, pid 53156
52931
20
—
76 MB
Both brokers had PPID 1. The Claude Code sessions that launched them had exited; the brokers
were reparented rather than terminated. The older broker had started at 14:52 the previous day and
was still serving at 06:20 the next morning. 128 processes and about 3.1 GB resident were
reclaimed by killing the two trees, on a machine that was at 8.0 GB of 9.2 GB swap in use.
Individual node_repl children lived up to 10 h 41 m. Nothing appears to reap them when the turn
that spawned them completes.
Steps to reproduce
Enable the shared session runtime.
Open Claude Code in a project, run any Codex-backed command (/codex:review, /codex:rescue,
or a stop-time review), and let it finish.
The broker is still running with PPID 1. Repeat over a working day and the node_repl count under
its codex app-server child climbs monotonically.
Expected
The broker exits when the last session using it exits, or it self-terminates after an idle
timeout. node_repl children are reaped when their turn ends.
Actual
The broker runs until killed by hand, and its grandchildren accumulate for its whole lifetime.
Impact
Unbounded memory growth on long-lived workstations. On the machine above it contributed to swap
exhaustion. A second, subtler consequence: the surviving broker holds a socket for a session that
no longer exists, which is how the stop-review gate ends up citing job IDs its own slash commands
cannot find (filed separately).
Workaround
Kill by PPID, not by command name — the VS Code extension runs its own live codex ... app-server
parented to the editor, and killing that breaks the IDE:
Either tie broker lifetime to its client set (exit when the last client disconnects), or add an
idle timeout plus a SIGTERM handler that tears down the codex app-server child and its
descendants. Reaping node_repl at turn end would bound the growth independently of broker
lifetime.
One more data point, plus a detail on why these trees get so large that I don't think has come up yet.
Setup: plugin 1.0.6, codex-cli 0.159.1, macOS 26 on Apple Silicon. Sessions run from a repo root and dispatch tasks into git worktrees, the same #380 shape @GeoffreyDjof described.
What we hit: 15 brokers held ~38 GB of physical footprint (mostly compressed or swapped) and had pushed 21 GB into swap. 12 of the 15 had worktree --cwds and the oldest was 6.5 days old. None had a running or queued job.
The growth is per thread, and covers every configured MCP server, not only node_repl. Each thread/start on a codex app-server spawns its own copy of every configured MCP server, and those copies live as long as the app-server does. Under these 15 app-servers there were 71 copies each of chrome-devtools-mcp, @playwright/mcp and context7-mcp, 132 node_repl/cua_repl, and 7 copies of a project MCP that weighs 1.0–1.8 GB apiece. Since the companion starts a fresh thread per task, a broker's footprint grows with every task it has ever run.
This reproduces against a bare codex app-server without any model turn:
initialize followed by two thread/start calls gives two full MCP sets (10 processes with our config).
thread/unsubscribe on one of those threads returns {"status":"unsubscribed"}, but all 10 keep running.
SIGTERM to the app-server reaps all of them.
Live brokers are affected too, not just orphans. The largest tree belonged to a session that was still running: a 13-hour-old broker in the session's own cwd, which had run 28 threads, held 255 processes and 10.5 GB. It was never idle for long, so an idle timeout alone would not have bounded it.
Activity
NewAiCoder commentedon Apr 2, 2026
Local fix applied
Added a 15-minute idle timeout to
app-server-broker.mjs. The timer resets on every incoming socket data (any RPC activity) and calls the existingshutdown()for clean teardown.CODEX_BROKER_IDLE_TIMEOUT_MSenv var.unref()ensures the timer doesn't prevent normal exitshutdown()for clean teardown (sockets, pid file, unix socket)Happy to submit a PR.
Wintersta7e commentedon Apr 25, 2026
Hit this with v1.0.2 on Linux/WSL2.
Repro: prior session's
codex:rescueskill call did not complete cleanly. Next session's firstcodex:rescuecall hung indefinitely.State at hang time:
app-server-broker.mjs serveprocess from a prior session still running (started ~30 min earlier, parent gone, PPID 1)./tmp/cxc-XXXXXX/broker.sockwas accepting connections.~/.claude/plugins/data/codex-openai-codex/state/<project-key>/broker.jsonrecorded the orphan broker's PID + endpoint.loadBrokerSessionreturned the stale entry.isBrokerEndpointReadyconnected successfully (the 150 ms TCP-connect check passes).runAppServerTurnthen hung at the firstclient.request(...)with no response.Root cause seems narrower than "no idle timeout": even when the orphan broker is alive, it can be wedged at the application layer — accepts socket connections, doesn't answer JSON-RPC.
scripts/lib/broker-lifecycle.mjs:isBrokerEndpointReadyonly opens a TCP connection, it never sends a JSON-RPC message. A connection-accepting-but-RPC-unresponsive broker passes the check, then the real call hangs forever.I confirmed the wedged state by hand:
echo '{}' | nc -U /tmp/cxc-XXXXXX/broker.sockconnected fine but never got a response back — onlytimeoutkilled it.The Codex CLI itself was healthy throughout —
codex mcp-server(registered as a Claude Code MCP server) returned a session reply in <30 s with the same auth/config. So the wedge is plugin-side broker state, not Codex.Workaround in client config — Claude Code SessionStart hook that kills orphan brokers and clears stale state before the plugin reuses it:
{ "hooks": { "SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "pkill -f 'app-server-broker.mjs serve' 2>/dev/null; find ~/.claude/plugins/data/codex-openai-codex/state -name broker.json -delete 2>/dev/null; exit 0" }] }] } }Tradeoff: kills brokers shared by concurrent sessions on the same host, but the next call respawns one cleanly. Acceptable for single-session users.
Suggested upstream fix (in addition to the idle-timeout proposal in this issue):
isBrokerEndpointReadyshould send a real JSON-RPC ping and require a valid response within a short timeout, not just succeed on socket-connect. Sketch:(Requires the broker to expose a cheap
broker/pingmethod —broker/shutdownalready exists, a ping should be straightforward to add alongside it.)Combined with the idle timeout this issue proposes, the failure mode becomes self-recovering: stale broker.json on disk → ping times out → teardown + respawn happens automatically inside
ensureBrokerSession.devseunggwan commentedon May 13, 2026
Hitting this on macOS with v1.0.4. Wanted to share an additional axis to the root cause that may complement the idle-timeout proposal.
Additional root cause:
handleSessionEndonly handles a singlecwdIn
scripts/session-lifecycle-hook.mjs(v1.0.4):loadBrokerSession(cwd)looks up the broker registered for the SessionEnd hook'scwd— i.e., a single workspace root. The teardown call below it then tears down that one broker.In a Claude Code session where the agent issues
/codex:review(or any companion call) against multiple worktrees over the session's lifetime, each call toensureBrokerSession(cwd, …)inscripts/lib/app-server.mjs:340spawns a fresh broker keyed to a differentcwd. The state file at~/.claude/plugins/data/codex-openai-codex/state/<workspace-key>/broker.jsonis per-workspace, so N reviews across N worktrees produce N independent broker triplets (broker.mjs + node wrapper + nativecodex app-server).When SessionEnd eventually fires, only one of those N brokers is torn down. The remaining N-1 leak even when the hook fires reliably.
Observed in practice
After ~1 week of normal usage (multi-worktree workflow, agent-driven reviews across ~6 active worktrees, Claude Code session kept alive in a terminal multiplexer):
app-server-broker.mjs serve …)codex app-serverprocesses (each broker spawns a node wrapper + native binary)/var/folders/.../T/cxc-*/session directories with leftoverbroker.logfilesSIGTERMto all 90 currently-active brokers + their children terminated cleanly withoutSIGKILL, andteardownBrokerSession's socket/pidfile unlink ran correctly per-broker (thecxc-*directories were left containing only a 0-bytebroker.logafterwards). So the existing teardown function is sound — the gap is just that it's never invoked for non-current-cwd brokers.Why the idle-timeout fix in this issue would resolve the multi-worktree variant too
The idle-timeout fallback proposed in the first comment self-terminates each broker after N minutes of socket inactivity, independent of which
cwdthe SessionEnd hook is looking at. So a single idle-timeout PR would absorb both:Without idle-timeout, an alternative complementary fix would be to have
handleSessionEndenumerate all broker state files under the plugin's state directory (~/.claude/plugins/data/codex-openai-codex/state/*/broker.json) and tear down each, rather than only the one matchinginput.cwd. But idle-timeout is the simpler defense-in-depth choice — the per-cwd lookup can stay as the happy-path optimization.Workaround (mass cleanup)
For others hitting this before the upstream fix lands, terminating all brokers older than N days is safe (existing
shutdown()cleans up sockets + pidfiles correctly on SIGTERM):Happy to file a separate issue for the per-cwd handler if it's worth tracking independently, but flagging here in case it changes the framing of the proposed fix.
Pelton commentedon May 22, 2026
Reproducing this on macOS (Claude Code with
codex@openai-codexv1.0.4): same symptom: stale brokers (app-server-broker.mjsplusnode codex app-serverplus Rustcodex app-server) outliveSessionEnd, reparent to launchd (PPID 1), and accumulate across sessions until killed manually. Tracking PR #343 as the candidate cleanup fix.lfarkas commentedon Jun 26, 2026
Corroborating this with a much more severe real-world data point, plus a code-level confirmation of the root cause and a consumer-side workaround.
Severity is worse than "a stale broker for a few hours"
On a single dev box I found 112 orphaned processes across 14 dead sessions, the oldest 12 days old. Every leaked group is a full
app-server-broker.mjs→codex app-server(node + rust) → MCP sidecars (context7-mcp,chrome-devtools-mcp) tree, each holding a/tmp/cxc-*socket dir. They accumulate one group per session that didn't exit gracefully, indefinitely.The reliable signature of an orphan is exactly what #193 notes:
app-server-broker.mjswithppid==1(reparented to init = its session is gone). A live session's broker still has a live parent, so this cleanly distinguishes leaked from active.Code-level confirmation of the root cause
The broker's lifetime depends entirely on the
SessionEndhook firing and succeeding:lib/broker-lifecycle.mjsspawns itdetached: true+child.unref(), so it is deliberately decoupled from the session process tree.app-server-broker.mjsonly self-terminates on an explicitbroker/shutdownRPC orSIGTERM/SIGINT(theprocess.on(...)handlers near the end). Itssocket.on("close")handler only removes that one socket from the set — the last client disconnecting does not shut the broker down.session-lifecycle-hook.mjs→handleSessionEnd()→teardownBrokerSession().So any non-graceful session exit — crash,
kill -9, SSH/terminal hangup, OOM, or the hook simply not firing — leaves a detached, unref'd broker with no fallback path to ever die. This is why #108's idle-timeout suggestion is the right fix: the fallback has to live in the broker itself, because by definition the leak happens precisely when the hook-based teardown didn't run.Worth combining a couple of the suggested fallbacks for full defense-in-depth:
ppidbecomes 1) — handles crash/SIGKILL/hangup, where an idle timer alone would still leak for N minutes and Orphanedcodexprocess at 100% CPU after Claude Code session ended (PPID=1, main thread spinning inread()) #193's busy-loopingcodexbinary would never be idle at allWorkaround (consumer side)
Until the broker self-terminates, a Claude Code
SessionStarthook that reaps anyapp-server-broker.mjswithppid==1(killing its whole subtree and removing the/tmp/cxc-*dir) cleans these up automatically at the start of each session. It's safe because the kill set is always rooted at an orphaned broker, so live sessions and unrelated processes are never touched.Environment:
@openai/codex-plugin-cc(codex plugin v1.0.4),codex-cli0.142.2, Claude Code 2.1.193, Linux.fix(broker): drop unreliable PID liveness; harden teardown/spawn conc…
axisrow commentedon Jul 16, 2026
Corroborating on macOS, v1.0.6 — and importantly, this reproduces in normal runtime use, not just the test suite (#163 is the test-suite variant).
Cleaning up an active dev session just now: ~260 orphaned
app-server-broker.mjsprocesses, allPPID=1, accumulated over ~1 hour of ordinary Claude Code use (notnpm test). Each ~30 MB RSS → ~7.5 GB held, plus theircodex app-serverchildren. SIGTERM to thePPID=1cohort reaped them cleanly and free RAM jumped 88 MB → 4.4 GB.All pointed at test-style socket dirs (
/var/folders/.../T/cxc-*/broker.sock), consistent with the detached-broker-reparents-to-init path in the original report —SessionEndnever fires for an unclean exit, and with no idle timeout the broker is never reaped.PR #490 (idle-timeout, armed when
sockets.size === 0 && !activeRequestSocket && !activeStreamSocket) is the exact fix for this class — the 15-min self-exit would have prevented all 260 from accumulating. Strong +1 on landing it; the directory/env-inheritance axis (#487) is orthogonal and separate.Cross-linking the map: this is scenario 1 in the breakdown I posted in #163 — same root, three scenarios, #490 covers this one.
lfarkas commentedon Jul 16, 2026
Still present in v1.0.5. Fresh data point from a Linux/systemd dev box: 6 orphaned broker trees = 18 processes (
app-server-broker.mjs→node …/bin/codex app-server→codexmusl binary), 2.8–19.6 h old, ~1.5 GB RSS combined, plus 6 leaked/tmp/cxc-*dirs and 106 stalebroker.json. Three of the six were still bound to--cwdgit worktrees that had already been deleted from disk. All were reparented tosystemd --user(unclean session exits — the detached+unref()'d broker survives its parent).I implemented and verified the idle timeout proposed here; sharing in case it's useful. Two notes that matter for the implementation:
Key it on connected sockets, not on "messages received."
BrokerCodexAppServerClient.initialize()opens one connection andclose()s it per operation, so between operations zero sockets are connected. An idle timer must therefore arm only when the connection set is empty (disarm on connect, re-arm on last disconnect), and use a generous interval (I used 30 min, env-overridable) so a live session that pauses between calls doesn't lose its warm app-server + thread state. Reaping is self-healing regardless —ensureBrokerSession()respawns on the next request.Minimal patch against the existing
socketsSet +shutdown(server):Verified three behaviors: exits when never used; stays alive while a client is connected (idle disarmed); exits after disconnect + idle. This also covers the dead-co-owner facet in #450 with no PID/liveness signal.
Interim workaround for Linux users hitting this now: a
systemd --usertimer that reaps a broker only when its--cwdno longer exists or noclaudeprocess is running (neither can be true for an in-use broker) — happy to share the unit if useful.GeoffreyDjof commentedon Jul 17, 2026
Corroborating on macOS (Darwin 25.5), v1.0.5, with a severity data point an order of magnitude above what's been posted — but first a correction that matters for everyone's workaround, because I shipped the wrong one before catching it.
ppid==1does not distinguish a leaked broker from an active oneWorth separating two process types, because the signature holds for one and not the other.
#193 observes
PPID=1on an orphanedcodexRust binary, and there the inference is sound: that process's parent is theapp-server, which is long-lived, so its reparenting really does mean the parent died. The same goes for anycodex app-serverchild.The generalization to
app-server-broker.mjs— "a live session's broker still has a live parent, soppid==1cleanly distinguishes leaked from active" — does not hold, at least on macOS. For the broker specifically,ppid==1is the normal state of a perfectly healthy process.Measured just now, on a box with 12 live Claude sessions:
A broker 7 minutes old, serving an active session, in a worktree that exists, with a live child — already reparented to
launchd.Why
It's structural, not a race.
spawnBrokerProcess(lib/broker-lifecycle.mjs) usesdetached: true+child.unref(), and the spawner iscodex-companion.mjs— a one-shot CLI that exits immediately after spawning (every time I sampledps, including while reviews were running, the livecodex-companion.mjscount was 0). So the broker is reparented at birth, whether or not its session is alive. Verified with a minimal repro:This doesn't invalidate anyone's orphan counts — those orphans are real, and provable by other means. It invalidates
ppidas the discriminator. Concretely:SIGTERMto the "PPID=1cohort" also kills the brokers of every live session. That's usually invisible, becauseensureBrokerSession()probes the endpoint and respawns transparently — but it will kill an in-flight review. I did exactly this before understanding the mechanism, on 619 processes at once.(On Linux/systemd the reparent target is
systemd --userrather than1, so the observed signature may differ there — but the reparent-at-birth mechanism is the same, since it follows fromdetached+unrefplus a transient spawner. Worth re-checking on those boxes before trusting the signature.)A signal that is sound
We reap on a single criterion: the broker's
--cwdno longer exists on disk. A deleted worktree cannot be under review by anyone, so false positives are impossible. It reads--cwdstraight from the process command line (broker.jsondoesn't carry the cwd — onlyendpoint/pidFile/logFile/sessionDir/pid).It also sidesteps the trap documented in #450 —
process.kill(pid, 0)being unsound under PID reuse, with no cross-platformsessionId→process mapping. "Does this directory exist?" depends on no PID, no/proc, and no ownership model.It's a partial fix by construction: it only catches brokers whose worktree was deleted (33 of our 55). It is not a substitute for #490 — just a workaround that cannot misfire.
Severity data point
Single macOS box, ~3 days of normal multi-worktree agent use (no test suite involved):
app-server-broker.mjs→codex app-server→codex-code-mode-host+ ~14-20node_repl/SkyComputerUseClient mcpeach)/var/folders/**/cxc-*dirsbroker.jsonThat last row is the interesting one.
clearBrokerSession()runs only at the end ofhandleSessionEnd, so 222 uncleaned state files means the teardown path essentially never completes here — across months of lots, not days. Same story for job state: 39 jobs still markedrunninginstate.json, 0 of them with a live PID, becausecleanupSessionJobs()shares the same never-reached code path.Why so much worse than the 6-14 trees reported elsewhere: this box runs sessions from the main repo while reviews target git worktrees (7 of 9 sessions had
cwd= main repo; all 55 brokers had worktree cwds). That's precisely the mismatch in #380 / @devseunggwan's analysis above — so the leak fires on every review, including graceful/quit, not just on unclean exits. The crash path (#108) and the cwd-mismatch path (#380) compound: worktree-heavy workflows hit both on every single review.On the fix
Strong +1 on #490. The idle timeout is the only design here that needs neither an ownership model nor a liveness probe nor a cwd match — the three things that have each proven unreliable in this thread. @lfarkas's point about arming on
sockets.size === 0rather than on message activity matches what we see: the client opens and closes a connection per operation, so "no messages" is the steady state between calls, not an idle signal.Happy to re-run any of the above on request.
stephan-nb commentedon Sep 8, 2026
Still present in 1.0.6 on macOS 15.7.7, four months after this was filed, and with a second symptom this issue does not cover: the broker's
codex app-serverchild accumulates unreapednode_replgrandchildren for as long as the broker lives. On this machine a 16-hour-old orphan held 106 of them, 3.1 GB resident, individually up to 10 h 41 m old. The idle timeout you propose would bound that too, since the children only accumulate while the broker survives — but if reaping is a separate concern, it may deserve splitting out.Full evidence:
Summary
With the shared session runtime enabled,
scripts/app-server-broker.mjsoutlives the Claude Codesession that launched it. The broker reparents to PID 1 and keeps serving its unix socket
indefinitely. Its
codex app-serverchild accumulatesnode_replgrandchildren that are neverreaped, so resident memory grows for as long as the broker lives.
Environment
openai-codexClaude Code plugin 1.0.6 (installed from the marketplace,codex@openai-codex)chatgptshared("This Claude session is configured to reuse one shared Codex runtime")What was observed
After roughly 16 hours of intermittent use across two projects:
app-server-broker.mjs(cwdproject-a), pid 18068codex app-server, pid 18161Codex.app/.../node_replapp-server-broker.mjs(cwdproject-b), pid 52931codex app-server, pid 53156Both brokers had PPID 1. The Claude Code sessions that launched them had exited; the brokers
were reparented rather than terminated. The older broker had started at 14:52 the previous day and
was still serving at 06:20 the next morning. 128 processes and about 3.1 GB resident were
reclaimed by killing the two trees, on a machine that was at 8.0 GB of 9.2 GB swap in use.
Individual
node_replchildren lived up to 10 h 41 m. Nothing appears to reap them when the turnthat spawned them completes.
Steps to reproduce
/codex:review,/codex:rescue,or a stop-time review), and let it finish.
ps -eo pid,ppid,etime,rss,command | grep app-server-brokerThe broker is still running with PPID 1. Repeat over a working day and the
node_replcount underits
codex app-serverchild climbs monotonically.Expected
The broker exits when the last session using it exits, or it self-terminates after an idle
timeout.
node_replchildren are reaped when their turn ends.Actual
The broker runs until killed by hand, and its grandchildren accumulate for its whole lifetime.
Impact
Unbounded memory growth on long-lived workstations. On the machine above it contributed to swap
exhaustion. A second, subtler consequence: the surviving broker holds a socket for a session that
no longer exists, which is how the stop-review gate ends up citing job IDs its own slash commands
cannot find (filed separately).
Workaround
Kill by PPID, not by command name — the VS Code extension runs its own live
codex ... app-serverparented to the editor, and killing that breaks the IDE:
Suggested fix
Either tie broker lifetime to its client set (exit when the last client disconnects), or add an
idle timeout plus a
SIGTERMhandler that tears down thecodex app-serverchild and itsdescendants. Reaping
node_replat turn end would bound the growth independently of brokerlifetime.
fix(broker): self-terminate on idle to reap orphaned shared brokers (o…
Tapppi commentedon Oct 5, 2026
One more data point, plus a detail on why these trees get so large that I don't think has come up yet.
Setup: plugin 1.0.6, codex-cli 0.159.1, macOS 26 on Apple Silicon. Sessions run from a repo root and dispatch tasks into git worktrees, the same #380 shape @GeoffreyDjof described.
What we hit: 15 brokers held ~38 GB of physical footprint (mostly compressed or swapped) and had pushed 21 GB into swap. 12 of the 15 had worktree --cwds and the oldest was 6.5 days old. None had a running or queued job.
The growth is per thread, and covers every configured MCP server, not only node_repl. Each thread/start on a codex app-server spawns its own copy of every configured MCP server, and those copies live as long as the app-server does. Under these 15 app-servers there were 71 copies each of chrome-devtools-mcp, @playwright/mcp and context7-mcp, 132 node_repl/cua_repl, and 7 copies of a project MCP that weighs 1.0–1.8 GB apiece. Since the companion starts a fresh thread per task, a broker's footprint grows with every task it has ever run.
This reproduces against a bare codex app-server without any model turn:
Live brokers are affected too, not just orphans. The largest tree belonged to a session that was still running: a 13-hour-old broker in the session's own cwd, which had run 28 threads, held 255 processes and 10.5 GB. It was never idle for long, so an idle timeout alone would not have bounded it.