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 does sockets.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. SessionEnd in
session-lifecycle-hook.mjs is 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, returns null, 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)):
Re-arm on disconnect (in both socket.on("close") and socket.on("error"),
after clearSocketOwnership(socket)):
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
SessionEnd hook timeout longer than 5s, or move broker teardown off
the hook's critical path, so slow machines still reap on clean exit.
- On
SessionStart, sweep os.tmpdir() for stale cxc-*/broker.pid whose pid is
dead (leftover session dirs). Redundant once idle-exit ships, but cleans state.
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 inscripts/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/shutdownRPC,SIGTERM, orSIGINT. Its only automatic reaper is theSessionEndhook, which is fragile. When that hook fails to run, the detachedbroker (
child.unref()) leaks forever, holding a Codex app-server child plusits 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 does
sockets.delete(socket)+clearSocketOwnership(socket). There isno 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.
SessionEndinsession-lifecycle-hook.mjsis the intended reaper, keyed per-cwd vialoadBrokerSession(cwd). It fails to reap when:timeout(hooks.json) is exceeded on a busy machine → hook killed.loadBrokerSession(cwd)resolves to gone state, returns
null, 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/closeinlib/app-server.mjs), so abroker at zero sockets with no active request/stream is genuinely idle. Killing
an idle broker only costs a cold respawn on the next job —
ensureBrokerSessionalready 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();:Cancel the timer on connect (in
net.createServercallback, aftersockets.add(socket)):Re-arm on disconnect (in both
socket.on("close")andsocket.on("error"),after
clearSocketOwnership(socket)):Arm at startup and hold the server ref (replace
server.listen(listenTarget.path);):Optional hardening (not required if idle-exit lands)
SessionEndhook timeout longer than 5s, or move broker teardown offthe hook's critical path, so slow machines still reap on clean exit.
SessionStart, sweepos.tmpdir()for stalecxc-*/broker.pidwhose pid isdead (leftover session dirs). Redundant once idle-exit ships, but cleans state.