Skip to content

Ending any Claude session kills the shared broker mid-turn: concurrent sessions' tasks die silently (exit 0) and stay "running" forever #540

Description

@leitxio

Environment

  • Plugin: codex@openai-codex v1.0.6 (repo openai/codex-plugin-cc)
  • Claude Code on Linux (Fedora, Node 24)
  • Multiple concurrent Claude Code sessions in the same workspace (interactive sessions plus occasional short-lived/headless ones)

Summary

When any Claude session in a workspace ends, the SessionEnd hook unconditionally shuts down the workspace's shared broker. If another session's rescue task is streaming a turn through that broker at that moment, its companion process exits silently with code 0 mid-turn: no error, no failed state — the job log just stops after an ordinary event line. The job file stays status: "running" forever (nothing ever pid-checks it), and the stale record then blocks all further tasks on that session ("Task … is still running") until a manual /codex:cancel.

Three defects compose into this:

  1. SessionEnd assumes one session per workspace. scripts/session-lifecycle-hook.mjs:101 (handleSessionEnd) calls sendBrokerShutdown() on the broker recorded in the shared per-workspace broker.json, with no check whether other live sessions (or in-flight jobs) are using it. The broker's shutdown() then calls socket.end() on every connected client (scripts/app-server-broker.mjs:104), including another session's mid-stream turn.

  2. A transport drop mid-turn is a silent exit, not an error. captureTurn awaits state.completion (scripts/lib/codex.mjs:606), a bare promise resolved only by a turn/completed notification (resolveCompletion, lib/codex.mjs:304-310). It is never raced against the client's exit. On socket close, handleExit (scripts/lib/app-server.mjs:163) rejects only pending request promises — during streaming there are none (turn/start already resolved) — so nothing rejects, no catch runs, runTrackedJob never records a failure. The closed socket was the last ref'd handle (the completion timer is unref()d, lib/codex.mjs:393), so the event loop drains and Node exits 0 mid-await. The same silent death occurs for any transport loss: direct-mode app-server child crash, broker crash, or the client being SIGTERMed (no signal handler updates job state either).

  3. Nothing ever detects the corpse. status/result (scripts/lib/job-control.mjs) render whatever the job file says without checking pid liveness, so the job shows running with growing elapsed indefinitely. Bonus: when result <job-id> is called for a running/dead job, matchJobReference throws No job found for "<id>" before the intended Job … is still running branch in resolveResultJob is reached (the predicate filters to finished jobs and matchJobReference throws on no match), which makes diagnosing this even more confusing.

Evidence (observed corpse, twice, different sessions)

  • Job A (session 1): created 06:52:50, log ends 06:52:57 right after Command completed: … (exit 0); recorded pid dead; job file frozen at status: running, phase: verifying; status still showed it "running / 12m" long after.
  • Job B (session 2): created 07:18:04, log ends 07:19:13 the same way. The Bash call that ran the foreground companion returned normally after ~94 s with a clean exit (no timeout, no error output beyond relayed progress lines) — i.e., the process self-exited mid-turn.
  • Every /tmp/cxc-* broker session dir created during that window was erased completely (socket + pid + log + dir + broker.json) — the full-erase combination only handleSessionEnd produces — while jobs were in flight; sessions were being opened/closed concurrently in this workspace throughout.
  • A later failure ("Selected model is at capacity") failed loudly with Turn failed — showing the error path works when a rejection actually reaches the turn; the silent deaths never produce any error line.

Related but distinct: #526 (idle-timeout shutdown race, broker-side), #509 (stale broker reuse → hang at Turn started), #450/#380 (orphan brokers on missing/failed SessionEnd), #286 (same-cwd state races). None of them covers the SessionEnd-of-a-concurrent-session → mid-turn client kill → silent exit-0 → permanently-running job chain.

Expected behavior

  • Ending one Claude session must not kill in-flight work of other sessions sharing the workspace broker (refcount sessions, or refuse/defer shutdown while a foreign turn is streaming, or scope brokers per session).
  • A transport loss mid-turn must surface as a job failure (race state.completion against the client's exit/close promise) — never a silent exit 0 that leaves status: "running".
  • status/result should detect a dead worker (pid liveness check) and report the job as failed/orphaned instead of running forever; a dead job must not block subsequent tasks until manual cancel.

Acceptance criteria

  • With two sessions in one workspace, ending session X while session Y streams a rescue turn: Y's task either completes normally (broker survives) or fails fast with an explicit transport error — job state transitions to failed with an error line in the log.
  • Killing the companion process mid-turn (SIGTERM/SIGKILL) leaves a job that status reports as dead/failed after the fact, not running.
  • result <job-id> for a running job says "still running", not "No job found".
  • Regression test: broker shutdown() with a client mid-stream → client's captureTurn rejects (no silent event-loop-drain exit).

Workaround

Setting CODEX_COMPANION_APP_SERVER_ENDPOINT to an unreachable socket forces the ECONNREFUSED → direct-mode fallback in withAppServer, giving each task a private app-server that other sessions' SessionEnd cannot kill (at the cost of per-task startup).

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