Skip to content

Reap leaked broker sessions across working directories - #660

Open
siimvene wants to merge 9 commits into
openai:mainfrom
siimvene:fix/broker-session-leak
Open

Reap leaked broker sessions across working directories#660
siimvene wants to merge 9 commits into
openai:mainfrom
siimvene:fix/broker-session-leak

Conversation

@siimvene

@siimvene siimvene commented Aug 19, 2026

Copy link
Copy Markdown

Problem

Broker sessions leak when the plugin is used across more than one working directory. A broker is spawned per cwd (ensureBrokerSession keys its state by cwd), but teardown only ran for the current cwd at SessionEnd, so brokers for every other cwd were never cleaned up, accumulating as orphaned broker + app-server processes plus leftover os.tmpdir()/cxc-* socket directories (tens of processes / ~1 GB after a few days of multi-repo use). They also contend on the shared codex log store, which can make later app-server startups hang.

Approach

Give the broker ownership of its own lifecycle instead of having sessions guess when it is safe to kill it.

  • app-server-broker.mjs: the broker ends itself, and only itself. It exits after CODEX_BROKER_IDLE_MS (default 30 min, strict-integer parsing, <= 0 disables) with no connections, no in-flight work, and no active stream; it also exits when its codex app-server client exits (a dead child would otherwise leave an unkillable listening zombie) and when a forwarded request exceeds CODEX_BROKER_REQUEST_TIMEOUT_MS (default 10 min, a wedged app server). On every exit path it retires its own broker.json record (guarded on the endpoint still matching) and removes its own session directory; recursive removal is restricted to cxc- dirs directly under the OS temp dir. All shutdown paths share one promise, so a signal cannot abort cleanup mid-flight.
  • broker-lifecycle.mjs: dead-only GC. reapBrokerSessions() removes only directories whose recorded broker PID is provably dead. It never inspects or signals a live PID, ignores directories without a readable valid broker.pid (an empty or torn file may be a broker mid-startup), and never touches non-broker cxc-* dirs.
  • session-lifecycle-hook.mjs: sessions leave shared state alone. SessionStart/SessionEnd run the dead-only GC. SessionEnd no longer kills brokers or clears the shared broker.json; it interrupts the Codex turns owned by its own background jobs (mirroring /codex:cancel, bounded by a 2s budget under the hook's 5s timeout) before terminating their runners.
  • Consumers validate instead of trusting the record. reuseExistingBroker probes the recorded endpoint before use; getSessionRuntimeStatus judges liveness by the recorded broker pid; withAppServer retries direct on any connect-phase failure, so racing an idle shutdown costs a retry rather than a failed command.

Safety

Nothing ever kills or signals a live broker. A broker shared by a concurrent session is never interrupted, no PID is ever signalled by the GC, and a broker that can no longer serve (dead or wedged app server) removes itself rather than waiting to be found.

Testing

tests/broker-lifecycle.test.mjs (new) covers the reaper GC matrix (dead pid removed; live pid, missing/torn/garbage/zero pid file, and non-cxc- dirs all left alone) and pid-based runtime-status liveness; the test env caps broker idle at 30s so suite runs no longer leave broker + fake-codex pairs behind. Functional runs against real brokers verified: idle exit removes process, session dir, and record; SessionEnd leaves a live shared broker and its record untouched while interrupting owned turns; a SIGKILLed app-server child takes its broker, record, and session dir down within ~2s. Full suite unchanged against the pre-change baseline.

Supersedes an earlier owner-tracking revision; see the discussion for the review history (shared-broker ownership, broker.json lifecycle, orphaned turns, hook time-budget, and the idle-shutdown disarm paths found by a multi-lens adversarial review).

@siimvene
siimvene requested a review from a team August 19, 2026 10:52

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0cc9d4ecff

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +291 to +293
const ownsIt = alive && ownerSessionId !== null && readBrokerOwner(sessionDir) === ownerSessionId;
if (alive && !ownsIt) {
continue; // a live broker owned by another (or unknown) session — leave it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid reaping brokers reused by concurrent sessions

When session B uses the same working directory as session A, ensureBrokerSession returns A's already-live broker without changing its owner. If A later ends from another working directory, this owner comparison treats the shared broker as exclusively A's and shuts it down or kills it, potentially interrupting B's in-flight request. Tracking only the creator is therefore insufficient; the reaper must account for other sessions currently reusing the broker.

Useful? React with 👍 / 👎.

Comment on lines +289 to +291
const pid = readBrokerPid(sessionDir); // null when the pid is dead/invalid
const alive = pid !== null && isPidAlive(pid);
const ownsIt = alive && ownerSessionId !== null && readBrokerOwner(sessionDir) === ownerSessionId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify broker identity before killing a live PID

If a broker exits without removing broker.pid and the OS reuses that PID before its owning session ends, process.kill(pid, 0) reports the unrelated process as alive; the matching owner file then causes terminateProcessTree to kill that unrelated PID or process group. Confirm that the endpoint is actually served by the recorded broker, or persist and validate stronger process identity, before passing this PID to killProcess.

Useful? React with 👍 / 👎.

A broker is spawned per working directory and reused across sessions, but
teardownBrokerSession only ran for the current cwd at SessionEnd, so brokers
for every other cwd leaked as orphaned broker + app-server processes plus
os.tmpdir()/cxc-* socket dirs (tens of processes / ~1 GB after a few days of
multi-repo use).

Give the broker ownership of its own lifecycle instead of having sessions
guess when it is safe to kill:

- app-server-broker.mjs: the broker exits itself once it has had no connections
  and no in-flight work for CODEX_BROKER_IDLE_MS (default 30 min; <= 0 disables).
  Callers already respawn a broker on demand, so self-exit when idle is safe.
- broker-lifecycle.mjs: reapBrokerSessions() now only GCs the *directories* of
  brokers that are already gone (dead PID, or a live PID whose broker endpoint is
  unreachable — a reused/stale PID). It never signals a PID and never touches a
  live, reachable broker, so it cannot interrupt a session sharing a broker or
  kill an unrelated process that reused a stale PID.
- session-lifecycle-hook.mjs: SessionStart/SessionEnd run the dead-only GC;
  SessionEnd no longer force-kills or shuts down the current cwd's broker (that
  was unsafe when the broker is shared) — it just drops the session's pointer and
  lets the broker self-exit when idle.

Addresses two review notes on the earlier revision: killing a broker a
concurrent session reuses, and signalling a possibly-reused PID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siimvene
siimvene force-pushed the fix/broker-session-leak branch from 0cc9d4e to fe08482 Compare August 19, 2026 11:14
@siimvene

Copy link
Copy Markdown
Author

Thanks for the review — both points were spot on. I reworked the fix rather than patch around them.

Instead of an external reaper deciding when to kill a broker (which can't know about a concurrent session reusing it, and can mis-signal a reused PID), the broker now owns its own lifecycle:

  • Idle self-shutdown (app-server-broker.mjs): the broker exits itself after CODEX_BROKER_IDLE_MS (default 30 min) with no connections and no in-flight work, and removes its own session directory. In-flight work is tracked with a counter around the app-server request (not activeRequestSocket, which a disconnecting client clears), so idle shutdown can't fire mid-request.
  • Reaper is now dead-only (broker-lifecycle.mjs): it only removes the directory of a broker whose PID is already dead. It never inspects or signals a live PID, so it can't interrupt a shared broker, can't signal a reused PID, and can't race a broker that's still starting up.
  • SessionEnd no longer force-kills the cwd broker (unsafe when shared) — it drops the session's pointer and lets the broker self-exit when idle.

Both original P1s (killing a broker a concurrent session reuses; signalling a possibly-reused PID) are resolved by never killing a live broker at all.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe084821e4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Do not shut down or kill this cwd's broker: it may be shared with a
// concurrent session, and it exits itself once idle (app-server-broker.mjs).
// Just drop this session's pointer to it.
clearBrokerSession(cwd);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the shared broker pointer while peer sessions are active

When two Claude sessions share a working directory and session A ends while session B has a background turn running, this deletes the workspace-wide broker.json, not a session-local pointer. The revised code no longer kills B's broker, but fresh evidence here shows that B's subsequent /codex:cancel uses reuseExistingBroker with no endpoint and sends its interrupt to a new direct app server rather than the broker executing the turn; terminating B's client then leaves the underlying turn running until idle shutdown. A normal subsequent command also starts a second broker while the original is still active, recreating the process contention this change is intended to prevent.

Useful? React with 👍 / 👎.

Comment on lines +162 to +164
idleTimer = setTimeout(() => {
if (isIdle()) {
shutdown(server).finally(() => process.exit(0));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear or validate broker state after idle shutdown

After a session remains open but makes no broker connections for the default 30 minutes, this shutdown removes the socket/session directory while leaving the workspace's broker.json intact. Consumers such as getCodexAuthStatus and getSessionRuntimeStatus load that file without checking endpoint readiness, so /codex:setup connects to the stale socket and can report an authenticated user as logged out, while /codex:status continues claiming a shared runtime is active; either remove the matching state record during shutdown or make these reuse/status paths validate it.

Useful? React with 👍 / 👎.

The state record now lives and dies with the broker it points at:

- SessionEnd no longer clears the workspace-wide broker.json. The record
  is shared by every session on the cwd, so a session ending must not
  delete it out from under a peer whose turn is still running.
- The broker retires its own record at the start of shutdown(), guarded
  on the endpoint still matching, so a clean exit (idle, broker/shutdown,
  SIGTERM/SIGINT) leaves no stale pointer and a record that was already
  replaced is never touched.
- The reuseExistingBroker path validates the recorded endpoint before
  handing it out, so a record left by an uncleanly killed broker degrades
  to a direct app server instead of erroring against a dead socket.
- getSessionRuntimeStatus checks the unix socket file exists before
  reporting a shared runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siimvene

Copy link
Copy Markdown
Author

Both points confirmed and fixed in fa1e404. The root of both was the same: broker.json's lifecycle didn't follow the broker-owns-itself model the rest of the fix moved to.

P1 (SessionEnd deleting the shared record): handleSessionEnd no longer calls clearBrokerSession. The record is workspace-wide state shared by every session on the cwd, so a session ending now leaves it (and the broker) entirely alone. Peer sessions keep routing turn/interrupt and follow-up commands through the existing broker.

P2 (stale record after idle shutdown): three layers, in order of preference:

  1. The broker retires its own record at the start of shutdown(), guarded on the endpoint still matching, so every clean exit path (idle, broker/shutdown, SIGTERM/SIGINT) removes the pointer along with the session dir. Clearing it first means a client probing mid-shutdown finds no record and starts a fresh broker instead of connecting to a dying socket; the guard keeps a record that was already replaced.
  2. The reuseExistingBroker path in CodexAppServerClient.connect validates the recorded endpoint (150ms probe) before using it, so a record left by an uncleanly killed broker behaves like no record: getCodexAuthStatus and the interrupt path fall back to a direct app server instead of failing against a dead socket.
  3. getSessionRuntimeStatus reports "shared" only when the recorded unix socket file still exists (the broker removes it on clean exit; the session-start reaper removes it after an unclean one).

Functionally tested: SessionEnd leaves a live broker and its record untouched; idle shutdown removes process, session dir, and record; a stale record reports direct startup and a reused connect degrades to direct spawn; a newer broker's record survives an older broker's shutdown.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa1e40441b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

await sendBrokerShutdown(brokerEndpoint);
}

cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Interrupt owned turns before killing session jobs

When SessionEnd fires while this session has a running background task, cleanupSessionJobs only terminates the runner and removes its job metadata; unlike handleCancel, it never sends turn/interrupt. Fresh evidence in this revision is that the broker/app-server is now deliberately left alive afterward, so an already-started Codex turn—potentially a --write task—continues without its client and may keep modifying the workspace until completion or idle shutdown, while its notifications can be routed to a later broker client. Interrupt each owned job using its saved threadId and turnId before terminating the runner.

Useful? React with 👍 / 👎.

Now that the shared app server deliberately outlives a session, killing
a background job's runner at SessionEnd left its already-started Codex
turn running clientless in the broker (potentially a --write task
mutating the workspace) until it completed or the broker idled out.

cleanupSessionJobs now mirrors handleCancel: for each still-running job
owned by the ending session it sends turn/interrupt with the threadId
and turnId from the per-job file (falling back to the state entry)
before terminating the runner. The interrupt is skipped when no live
broker endpoint exists, since the turn died with its app server. A
corrupt job file falls back to the state entry instead of failing the
whole hook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siimvene

Copy link
Copy Markdown
Author

Confirmed and fixed in 4cef183. This was a real regression the redesign introduced: previously the SessionEnd broker kill took any in-flight turn down with it, and removing the kill silently removed that guarantee.

cleanupSessionJobs now mirrors handleCancel: for each still-running job owned by the ending session it sends turn/interrupt with the threadId/turnId from the per-job file (falling back to the state-entry fields) and then terminates that job's runner immediately, before moving to the next job. Details:

  • The interrupt goes through the broker's existing allowInterruptDuringActiveStream gate, which admits a second connection interrupting the stream holder's turn.
  • It is skipped when no live broker endpoint exists (150ms probe): with no shared app server, the turn already died with the runner's direct one, and probing first avoids spawning a throwaway direct app server just to deliver a meaningless interrupt.
  • Per-job interleaving (interrupt, then kill, per job) keeps the window between a runner exiting in response to its interrupt and its PID being signalled to a single await, so a reused PID cannot be killed while other jobs' interrupts are still pending.
  • A corrupt or unreadable per-job file falls back to the state entry instead of failing the whole hook, and the final state save re-loads fresh state, since the awaited interrupts widen the old load-to-save window.

Functionally tested: with a live broker, the hook interrupts and exits 0, the job is removed, and the broker plus its record survive; with no broker, the fast path skips the interrupt (~150ms); missing thread/turn ids and corrupt job files degrade gracefully.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4cef1830e0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const { threadId, turnId } = readJobTurn(workspaceRoot, job);
if (threadId && turnId) {
try {
await interruptAppServerTurn(cwd, { threadId, turnId });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound session-end interrupt requests

When a live broker accepts the connection but its app server never answers turn/interrupt, this await never settles because interruptAppServerTurn and the underlying request have no timeout. The SessionEnd hook itself has a five-second timeout in plugins/codex/hooks/hooks.json, so Claude eventually kills the hook before terminateProcessTree or the state cleanup runs, leaving exactly the runner and turn this path is intended to stop. Apply a short deadline to the interrupt attempt and always proceed with terminating the runner.

Useful? React with 👍 / 👎.

Comment thread plugins/codex/scripts/lib/codex.mjs Outdated
export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) {
const endpoint = env?.[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
const recorded = env?.[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
const endpoint = recorded && isBrokerEndpointPresent(recorded) ? recorded : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the --cwd runtime-status test

This new endpoint-existence gate deterministically breaks the existing setup and status honor --cwd when reading shared session runtime test: its saved endpoint is unix:/tmp/fake-broker.sock, so npm test now reports direct startup instead of the asserted shared session. Update that fixture to provide an endpoint satisfying the new validity contract, or preserve the previous contract, so the repository test suite passes.

Useful? React with 👍 / 👎.

function isBrokerEndpointPresent(endpoint) {
try {
const target = parseBrokerEndpoint(endpoint);
return target.kind === "unix" ? fs.existsSync(target.path) : true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Probe broker readiness instead of socket-file presence

When a broker crashes during an active Claude session, its Unix socket and broker.json remain until another SessionStart/SessionEnd reaper runs, so mere filesystem presence makes /codex:status continue claiming a shared runtime is active even though no process is listening; stale Windows pipe endpoints are unconditionally treated as active as well. Fresh evidence beyond the earlier idle-shutdown issue is this unclean-death path, because clean idle shutdown now removes the record. Validate connectivity or recorded process identity rather than treating the endpoint's representation as proof of liveness.

Useful? React with 👍 / 👎.

The SessionEnd hook runs under a 5-second harness timeout, so the new
per-job turn interrupts are raced against a shared 2s budget: a broker
whose app server never answers turn/interrupt can no longer get the
hook killed before runner termination and state cleanup run. main()
exits explicitly, since an abandoned interrupt attempt can hold handles
that would otherwise keep the hook alive until the harness kills it.

getSessionRuntimeStatus now judges a recorded session live by its
recorded broker pid (signal 0, EPERM counts as alive), falling back to
unix-socket presence only when no usable pid is recorded. A broker that
crashed mid-session leaves its socket and record on disk until a reaper
runs, so file presence alone overstated liveness; the pid check also
covers Windows pipe endpoints, which cannot be stat'd meaningfully. Env
overrides keep their previous semantics, including an empty value
masking the recorded session.

The --cwd runtime-status test fixture records a live pid to satisfy the
new validity contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f89e894922

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +87 to +90
const brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null;
brokerAlive = brokerEndpoint
? await waitForBrokerEndpoint(brokerEndpoint, 150).catch(() => false)
: false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor the configured broker endpoint during cleanup

When jobs use CODEX_COMPANION_APP_SERVER_ENDPOINT without a matching local broker.json, this gate sets brokerAlive to false, so SessionEnd kills the runner and removes its metadata without sending turn/interrupt; a write-capable turn can therefore continue in the externally configured broker. Fresh evidence beyond the prior interrupt finding is that CodexAppServerClient.connect prioritizes the environment endpoint, while this new liveness check consults only loadBrokerSession(cwd). Select and probe the endpoint using the same precedence as the client before deciding to skip interruption.

Useful? React with 👍 / 👎.

Findings from an exhaustive multi-lens adversarial review of the
cumulative diff, plus a cross-model review pass on the fixes:

- A client that disconnected during its awaited streaming request could
  be assigned to activeStreamSocket afterwards, stranding a dead socket
  that kept isIdle() false forever and busy-rejected other clients; the
  assignment now checks socket.destroyed, and releasing stream
  ownership on turn/completed reschedules the idle timer.
- The broker now observes its codex app-server client's exit and shuts
  itself down: a dead child previously left a listening zombie that
  probes passed, the reaper skipped, and nothing could kill.
- Forwarded requests are bounded (CODEX_BROKER_REQUEST_TIMEOUT_MS,
  default 10 min); a request unanswered that long means a wedged app
  server, so the broker terminates rather than releasing ownership of a
  possibly still-executing turn to a later client.
- Recursive session-dir removal on shutdown is restricted to cxc- dirs
  directly under the OS temp dir; a manual --pid-file anywhere else has
  only the broker's own files unlinked.
- All shutdown paths share one promise, so a signal landing during an
  idle shutdown cannot process.exit() mid-cleanup.
- withAppServer retries direct whenever connect() itself fails (fn
  never ran, always safe: covers ENOENT/ECONNREFUSED/ECONNRESET and
  initialize-phase closes when racing an idle shutdown); post-connect
  retries remain limited to the broker-busy rejection, since replaying
  fn after a mid-flight failure could repeat side effects.
- The SessionEnd interrupt skips the synchronous availability probe (a
  responding broker proves the runtime), so the 2s budget race actually
  bounds wall-clock; the broker probe honors the env endpoint
  precedence that connect() uses.
- The reaper leaves a directory whose broker.pid is empty or
  unparseable alone (possible torn write from a starting broker).
- CODEX_BROKER_IDLE_MS parsing is strict-integer with a 2^31-1 clamp:
  parseInt truncated "30m" to 30ms, and larger values overflowed
  setTimeout to ~1ms, inverting "effectively never" into instant.
- Connections landing mid-shutdown are destroyed instead of holding
  server.close() open.
- New tests cover the reaper GC matrix and pid-based runtime-status
  liveness; the test env caps broker idle at 30s so suite runs stop
  leaving broker + fake-codex pairs alive for 30 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siimvene

Copy link
Copy Markdown
Author

Following the round-4 fixes we ran an exhaustive multi-lens adversarial review of the cumulative diff (7 independent reviewers over consumer, concurrency, harness-constraint, portability, failure-injection, and maintainer lenses; every finding attacked by 2 skeptics). It confirmed several defects in this PR beyond what has been raised here, all fixed in this push.

The broker's idle self-shutdown could be permanently disarmed, making a wedged broker immortal now that no external path kills brokers:

  • A client that disconnected while its streaming request was awaited could be assigned to activeStreamSocket after the await, stranding a dead socket that kept isIdle() false with no event left to clear it (and busy-rejecting other clients until turn completion). The assignment now checks socket.destroyed, and releasing stream ownership on turn/completed reschedules the idle timer.
  • A codex app-server child that died left the broker listening: endpoint probes passed, the reaper skips live pids, and post-death requests hung forever (a callback-less write to destroyed stdin is a silent no-op), each one pinning inFlightRequests. The broker now observes the app-server client's exit and shuts itself down; callers respawn on demand.
  • A hung (but alive) app server pinned inFlightRequests the same way. Forwarded requests are now bounded by CODEX_BROKER_REQUEST_TIMEOUT_MS (default 10 minutes; requests resolve at acceptance, streams ride notifications). Since the race cannot cancel the underlying request, a timeout terminates the whole broker (taking the wedged app server with it) rather than releasing ownership of a possibly still-executing turn to a later client.

Other fixes in this push:

  • shutdown() recursively removed whatever directory contained the caller-supplied --pid-file; recursive removal is now restricted to cxc- directories directly under the OS temp dir (the plugin's own mkdtemp layout), with individual file unlinks otherwise. All shutdown paths also share one promise now, so a signal landing during an idle shutdown can no longer process.exit() mid-cleanup.
  • The SessionEnd interrupt budget raced a timer against work that began with two synchronous spawnSync availability probes, which block the event loop and cannot be preempted. The hook now skips the availability check (a responding broker already proves the runtime exists), so the raced work is purely async.
  • withAppServer never fell back to a direct app server when connect() itself threw. It now retries direct on any connect-phase failure (nothing ran yet, so a retry is always safe: this covers ENOENT, ECONNREFUSED, ECONNRESET, and initialize-phase closes when racing an idle shutdown). Post-connect retries stay limited to the broker-busy rejection, which the broker raises before any work runs; replaying fn after other mid-flight failures could repeat side effects such as an already-created thread.
  • The reaper treated an empty or unparseable broker.pid as a dead broker and deleted the directory, which could race a broker mid-startup during the pid file's write window. Unparseable now means "leave it alone".
  • CODEX_BROKER_IDLE_MS parsing: parseInt truncated unit suffixes ("30m" became 30ms) and values above 2^31-1 overflow setTimeout to ~1ms, both silently inverting "effectively never" configs into instant shutdown. Parsing is now strict-integer with a clamp.
  • Connections landing between shutdown start and server.close() are destroyed instead of holding the close open; SessionEnd's broker probe now honors the CODEX_COMPANION_APP_SERVER_ENDPOINT precedence that connect() uses.
  • New tests/broker-lifecycle.test.mjs covers the reaper's GC matrix (dead pid, live pid, missing/torn/garbage/zero pid file, non-cxc- dirs) and pid-based runtime-status liveness; the test env caps broker idle at 30s so suite runs no longer leave broker + fake-codex pairs alive for 30 minutes.

Verified: full test suite unchanged against the pre-change baseline; functional runs against real brokers confirm idle exit removes process, dir, and record; SessionEnd leaves live brokers untouched; and a SIGKILLed app-server child now takes its broker, record, and session dir down within ~2s.

Known, intentionally accepted: status/auth reads against a stale record now fall back to spawning a direct app server (correct answer at a cost, where pre-diff they failed fast against a dead socket); a broker reused across sessions holds its spawn-time environment until it idles out; sub-second probe-vs-shutdown windows remain and now cost a single direct retry.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c756492d8a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +219 to +222
return (
path.basename(dir).startsWith("cxc-") &&
fs.realpathSync(path.dirname(dir)) === fs.realpathSync(os.tmpdir())
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid recursively deleting caller-selected temp directories

When the broker is invoked manually with --pid-file /tmp/cxc-work/broker.pid or a Unix endpoint beneath /tmp/cxc-work, this predicate treats that caller-selected directory as plugin-managed solely because of its name and parent. performShutdown then recursively deletes the entire directory, including unrelated user files, despite the preceding comment promising manual directories are preserved. Record whether the directory was actually created by the plugin, or limit manual cleanup to the socket and PID files.

Useful? React with 👍 / 👎.

A cxc- name directly under the OS temp dir does not prove the plugin
created the directory: a manual serve invocation with
--pid-file /tmp/cxc-work/broker.pid would have had /tmp/cxc-work
recursively deleted on shutdown, user files included. spawnBrokerProcess
now passes --managed-session-dir after creating the directory via
mkdtemp, and the broker only removes a directory recursively when that
flag was given AND the path matches the plugin's own layout; any other
invocation has only the broker's pid file and socket unlinked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siimvene

Copy link
Copy Markdown
Author

Confirmed and fixed in 1cc837a. Name plus location was still an inference about ownership, not a record of it. spawnBrokerProcess now passes --managed-session-dir after creating the directory via mkdtemp, and the broker removes a directory recursively only when that flag was given and the path still matches the plugin's own layout. Any other invocation, including --pid-file /tmp/cxc-work/broker.pid, has only the broker's pid file and socket unlinked.

Functionally verified both ways: a manual serve into a user-created cxc- directory under the temp dir keeps the directory and its files on shutdown (pid file and socket unlinked); plugin-spawned brokers still remove their session dir on idle exit and on app-server-child death.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1cc837aa25

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// The broker process is gone but left its directory behind (killed, not a
// clean exit). Remove the leftover; the PID is dead so nothing is signalled.
try {
fs.rmSync(sessionDir, { recursive: true, force: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict reaping to spawner-managed directories

When a broker is manually invoked with a PID file such as /tmp/cxc-work/broker.pid and is then killed uncleanly, the next session hook sees the dead PID and recursively deletes /tmp/cxc-work, including unrelated caller files. The new --managed-session-dir check protects clean broker shutdown, but fresh evidence here is that this reaper never checks or persists that ownership signal; require a spawner-created ownership marker before recursively removing the directory.

Useful? React with 👍 / 👎.

The reaper had the same ownership gap shutdown had one commit earlier:
a manual --pid-file /tmp/cxc-work/broker.pid whose broker died uncleanly
would have /tmp/cxc-work recursively deleted at the next session hook.
createBrokerSessionDir now writes a broker.managed marker into the
mkdtemp directory, and the reaper deletes only marked directories.
teardownBrokerSession unlinks the marker so its non-recursive rmdir
still succeeds. With ownership persisted at creation and checked by
every deleter, no code path removes a directory the plugin did not
provably create.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siimvene

Copy link
Copy Markdown
Author

Confirmed and fixed in 77db721. createBrokerSessionDir now persists a broker.managed marker into the directory it mkdtemps, and the reaper deletes only marked directories; teardownBrokerSession unlinks the marker so its non-recursive rmdir still succeeds. A manual --pid-file location is now safe against every deleter regardless of how it is named or how its broker dies.

Ownership is persisted once at creation and checked by both recursive-removal paths (the broker's own shutdown via --managed-session-dir, the reaper via the marker), so there is no remaining code path that removes a directory the plugin did not provably create. Tests extended: an unmarked cxc- directory containing a dead pid file and a user file survives the reaper.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 77db721fbe

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

for (const socket of sockets) {
socket.end();
}
await appClient.close().catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound app-server close on every shutdown path

When the Codex app-server does not exit after stdin is closed and the subsequent SIGTERM, SpawnedCodexAppServerClient.close() waits on exitPromise indefinitely. Since performShutdown has already cleared broker.json and blocks here before closing the server or removing the session directory, an idle or signal-triggered shutdown can leave both processes running while the next command spawns another broker; the five-second backstop currently exists only for request-timeout shutdowns. Apply a forced-termination deadline to this close path as well.

Useful? React with 👍 / 👎.

SpawnedCodexAppServerClient.close() escalates only to SIGTERM and then
awaits the child's exit unbounded, so an app server ignoring both
stdin-close and SIGTERM wedged performShutdown after broker.json was
already cleared, leaving the old broker and its child alive while the
next command spawned a replacement. The close is now raced against a 5s
deadline that force-kills the child's process tree (its exit settles
the dangling close), and every shutdown path gets the 15s process-exit
backstop that previously existed only for request timeouts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siimvene

Copy link
Copy Markdown
Author

Confirmed and fixed. performShutdown now races appClient.close() against a 5s deadline that force-kills the child's process tree (the child's exit settles the dangling close), and every shutdown path carries a 15s process-exit backstop that previously existed only for the request-timeout path. A SIGTERM-immune app server can no longer wedge a shutdown after the record was cleared.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0fb97bbd27

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +202 to +204
if (appClient.proc?.pid) {
terminateProcessTree(appClient.proc.pid);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send a real force-kill to the app-server child

When the app-server ignores stdin closure and SIGTERM—the exact case this deadline is intended to handle—this does not terminate it on POSIX. SpawnedCodexAppServerClient.initialize() creates a non-detached child, while terminateProcessTree() first signals the process group -pid and returns without signaling the PID when that nonexistent group yields ESRCH; moreover, its signal is still only SIGTERM, not a force-kill. The timeout promise therefore resolves, allowing the broker to exit while leaving the wedged app-server orphaned and recreating the process/log-store contention this change is meant to prevent.

Useful? React with 👍 / 👎.

terminateProcessTree was a verified no-op here on POSIX: the child is
not detached, so the process-group signal hits ESRCH and nothing falls
back to the pid, and the signal was only SIGTERM regardless (which the
wedged child ignores by hypothesis). The deadline now sends SIGKILL to
the child pid on POSIX (empirically verified against a SIGTERM-immune
child) and keeps taskkill /T /F on Windows where the child is a cmd.exe
wrapper needing a tree kill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@siimvene

Copy link
Copy Markdown
Author

Confirmed and fixed in 8f4eec4, with the mechanics verified empirically this time: against a SIGTERM-trapping child, terminateProcessTree reported delivered=false (process-group ESRCH, no pid fallback) and the child stayed alive, while a direct SIGKILL to the pid took it down. The deadline now sends SIGKILL to the child pid on POSIX and keeps taskkill /T /F on Windows, where the child is a cmd.exe wrapper that needs the tree kill.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant