openai / openai/codex-plugin-cc
Shared per-workspace broker is torn down by any session's SessionEnd, killing other sessions' in-flight jobs
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 33.3k
- Forks
- 2.3k
- PR merge metrics
- No merged PRs in 30d
Description
Summary
The companion broker is keyed on the workspace, but its teardown is triggered by an individual session ending, with no check for other sessions' in-flight work. When several Claude Code sessions run in the same checkout — normal on a shared repo — any session closing kills whatever job the others are running. The victim sees codex app-server connection closed., and for the stop-review gate this surfaces as its session-stop being blocked by a failure it did not cause.
Observed as an intermittent stop-gate failure: 3 of 7 runs died over one day, with no correlation to run duration — a 6-minute run survived while ~5-minute runs died. The variable was never the run; it was whether another session happened to close during it.
Environment
- plugin 1.0.6, Node v24, Linux
- 7 Claude Code sessions live in one git checkout (verified: every one of their
/proc/<pid>/cwdresolves to the same repo root) - one broker serving them all:
app-server-broker.mjs serve --endpoint unix:/tmp/cxc-XXXXXX/broker.sock --cwd <repo>, reparented to PID 1
Bug 1 (primary) — unguarded workspace-wide teardown
scripts/session-lifecycle-hook.mjs → handleSessionEnd() resolves the broker via loadBrokerSession(cwd), which reads broker.json from the workspace-keyed state dir (lib/state.mjs resolveStateDir). It then unconditionally calls sendBrokerShutdown(endpoint) and teardownBrokerSession({... killProcess: terminateProcessTree}), which kills the pid, unlinks socket/pid/log files, and rmdirs the session dir.
lib/broker-lifecycle.mjs teardownBrokerSession has no refcount, no owning-session check, and no in-flight-job check. cleanupSessionJobs() correctly scopes itself to the ending session's own job rows — but the broker kill beside it is workspace-wide.
Victim path: BrokerCodexAppServerClient's socket fires close → handleExit(this.exitError) with exitError === null → every pending request rejects with new Error("codex app-server connection closed.") (lib/app-server.mjs). No reconnect, no retry.
Expected: a session ending does not disturb work owned by another session.
Actual: it terminates it, surfacing as an opaque connection-closed.
Bug 2 — the broker honours broker/shutdown mid-turn
Independently of Bug 1: scripts/app-server-broker.mjs answers broker/shutdown and immediately runs shutdown(server), which socket.end()s every connected client, closes the upstream app-server, and exits. It consults neither activeRequestSocket nor activeStreamSocket.
So the graceful RPC alone is enough to kill a live turn — the SIGTERM in Bug 1 is not even required. A complete fix needs both a guard at the teardown call site and a refusal inside the broker.
Bug 3 — the shared broker is single-flight, so concurrent sessions collide
scripts/app-server-broker.mjs rejects a request with BROKER_BUSY_RPC_CODE (-32001) "Shared Codex broker is busy." whenever activeRequestSocket/activeStreamSocket is held by a different socket. A review turn is turn/start, which is in STREAMING_METHODS, so it holds activeStreamSocket for the whole multi-minute turn.
With N sessions sharing one broker, any two overlapping turns mean one fails, and unlike Bug 1 this scales with turn duration. Arguably a design limit of sharing one app-server rather than an oversight, but it makes the shared broker unsuitable as-is for multi-session workspaces.
Bug 4 (minor, two parts)
- Orphaned job rows. When Bug 1 kills a job, the victim's row keeps
status: "running"forever — only the ending session's rows are reaped, so nothing reconciles the victim's. The next session's stop hook then reports a phantom running job. - Diagnostics are destroyed by the next state write.
saveStateunlinks the log file of any job dropped fromstate.json, and jobs are dropped both by theSessionEndfilter and bypruneJobs()atMAX_JOBS = 50. A failure's log is routinely gone before anyone can investigate — which is why this report leans on code reading rather than logs.
Reproduction
- Open two Claude Code sessions in the same git checkout.
- In session A, start a long-running Codex job (
/codex:review, or let the stop gate run). - While it runs, exit session B.
- Session A's job fails with
codex app-server connection closed.and itsstate.jsonrow is left atstatus: "running".
Deterministic variant, no Codex turns needed: seed the workspace state dir with a broker.json plus a foreign status: "running" job row, run session-lifecycle-hook.mjs SessionEnd with a different session_id, and observe broker.json cleared and the broker pid killed.
Suggested fix
handleSessionEnd— before shutting the broker down, check whether any job belonging to a different session isqueued/running; if so, reap only your own rows and leave the broker alone. Bound the check by staleness (e.g. the gate's own 15-minute timeout) so an orphaned row from a crashed session cannot wedge teardown permanently. An idle broker is already reclaimed byensureBrokerSession's liveness check on next use, so leaking it is safe.app-server-broker.mjsbroker/shutdown— refuse while another client's request or stream is active, replying with the existingBROKER_BUSY_RPC_CODEinstead of shutting down.
Longer term, reference-counting the broker across sessions, or giving each session its own app-server, would also remove Bug 3.
I've applied both guards locally and verified them with a mutation test in an isolated workspace: it passes with the guard, fails on exactly the guard's assertion when the predicate is neutralised to false, and the stale-row and no-foreign-job controls tear down normally in both states. Happy to open a PR if that's useful.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with handleSessionEnd() in scripts/session-lifecycle-hook.mjs, then trace resolveStateDir in lib/state.mjs and teardownSessionBroker in lib/broker-lifecycle.mjs. Inspect broker/shutdown and active request or stream handling in scripts/app-server-broker.mjs, then run the two-session reproduction and the described isolated-workspace mutation test. Done means foreign active work survives SessionEnd and shutdown refuses while another client is active.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- backend, devtools
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100