Skip to content

Broker process not cleaned up on session exit — no idle timeout #108

Open

Description

@NewAiCoder

Bug

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.

Activity

  1. NewAiCoder commented on Apr 2, 2026

    @NewAiCoder
    Author

    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 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
    • Uses existing shutdown() for clean teardown (sockets, pid file, unix socket)

    Happy to submit a PR.

  2. Wintersta7e commented on Apr 25, 2026

    @Wintersta7e

    Hit this with v1.0.2 on Linux/WSL2.

    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:

    {
      "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):

    isBrokerEndpointReady should send a real JSON-RPC ping and require a valid response within a short timeout, not just succeed on socket-connect. Sketch:

    async function isBrokerEndpointReady(endpoint, timeoutMs = 2000) {
      return new Promise((resolve) => {
        const socket = connectToEndpoint(endpoint);
        const deadline = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
        socket.setEncoding("utf8");
        let buf = "";
        socket.on("connect", () => {
          socket.write(`${JSON.stringify({ id: 1, method: "broker/ping", params: {} })}\n`);
        });
        socket.on("data", (chunk) => {
          buf += chunk;
          const line = buf.split("\n")[0];
          try {
            const msg = JSON.parse(line);
            if (msg.id === 1) { clearTimeout(deadline); socket.end(); resolve(true); }
          } catch { /* keep buffering */ }
        });
        socket.on("error", () => { clearTimeout(deadline); resolve(false); });
      });
    }

    (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.

  3. devseunggwan commented on May 13, 2026

    @devseunggwan

    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: handleSessionEnd only handles a single cwd

    In scripts/session-lifecycle-hook.mjs (v1.0.4):

    async function handleSessionEnd(input) {
      const cwd = input.cwd || process.cwd();
      const brokerSession =
        loadBrokerSession(cwd) ??
        (process.env[BROKER_ENDPOINT_ENV] ? { ... } : null);
      ...
      teardownBrokerSession({ endpoint, pidFile, logFile, sessionDir, pid, ... });
    }

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

    • 59 broker processes (app-server-broker.mjs serve …)
    • 118 codex app-server processes (each broker spawns a node wrapper + native binary)
    • Oldest broker: 7 days
    • 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:

    1. The original "SessionEnd hook didn't fire" failure mode
    2. 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.

  4. Pelton commented on May 22, 2026

    @Pelton

    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.

  5. lfarkas commented on Jun 26, 2026

    @lfarkas

    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:

    Workaround (consumer side)

    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.

    Environment: @openai/codex-plugin-cc (codex plugin v1.0.4), codex-cli 0.142.2, Claude Code 2.1.193, Linux.

  6. axisrow commented on Jul 16, 2026

    @axisrow

    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.

  7. lfarkas commented on Jul 16, 2026

    @lfarkas

    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:

    1. 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.

    2. Minimal patch against the existing sockets Set + shutdown(server):

    const IDLE_MS = Number(process.env.CODEX_COMPANION_BROKER_IDLE_MS) || 30 * 60 * 1000;
    let idleTimer = null;
    function disarmIdle() { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } }
    function armIdle() {
      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.

  8. GeoffreyDjof commented on Jul 17, 2026

    @GeoffreyDjof

    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:

    const c = spawn(process.execPath, ["-e", "setTimeout(()=>{},15000)"], { detached: true, stdio: "ignore" });
    c.unref();   // spawner exits -> child PPID becomes 1 within ~1s

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

    Orphaned broker trees 55
    Total processes 619 (app-server-broker.mjs → codex app-server → codex-code-mode-host + ~14-20 node_repl / SkyComputerUseClient mcp each)
    RSS held 16.8 GB
    Oldest 3 days 7 h
    Leaked /var/folders/**/cxc-* dirs 146
    Stale broker.json 222

    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.

    Happy to re-run any of the above on request.

  9. stephan-nb commented on Sep 8, 2026

    @stephan-nb

    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

    1. Enable the shared session runtime.
    2. Open Claude Code in a project, run any Codex-backed command (/codex:review, /codex:rescue,
      or a stop-time review), and let it finish.
    3. Quit Claude Code.
    4. ps -eo pid,ppid,etime,rss,command | grep app-server-broker

    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:

    ps -eo pid,ppid,etime,rss,command | awk '$2==1 && /app-server-broker/'
    pkill -TERM -P <app_server_pid>; kill -TERM <app_server_pid> <broker_pid>

    Suggested fix

    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.

  10. Tapppi commented on Oct 5, 2026

    @Tapppi

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions