Skip to content

fix(codex-bridge): exit when another writer owns the thread (#906 link 1) - #935

Merged
fujibee merged 4 commits into
mainfrom
fix/bridge-die-on-active-writer
Aug 22, 2026
Merged

fix(codex-bridge): exit when another writer owns the thread (#906 link 1)#935
fujibee merged 4 commits into
mainfrom
fix/bridge-die-on-active-writer

Conversation

@fujibee

@fujibee fujibee commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Part of #906. On main.

The link

#906 reports a self-amplifying bridge leak that saturated an HPC login node's per-user TasksMax twice in one day. The trigger it names first: with codex delivery mode monitor, when thread/resume fails with already has an active writer (JSON-RPC -32600 -- e.g. the role's thread is concurrently open in Codex Desktop), the bridge logs proceeding without resume and keeps running. A bridge that cannot own its thread still arms watchers and holds ~10 threads, so the launcher's replacements accumulate instead of one converging.

Why the catch swallowed it

ensureThread's catch was added for a genuinely benign failure (#276): a Codex 0.142+ --remote session may never create a rollout, so thread/resume fails outright, but turn/start needs only the threadId, so the bridge stays alive idle. That was right for that case. It just also caught the deterministic one -- "another writer owns this thread" -- which needs the opposite handling: retrying or proceeding cannot help while the other writer holds the thread.

Two distinguishable things were sharing one branch.

The change (codex-bridge.js)

(1) Both JSON-RPC clients (AppServerClient, WebSocketAppServerClient) rejected with new Error(message.error.message) and dropped the error code. They now attach code to the rejected error.

(2) The ensureThread catch matches the already has an active writer message and die()s (exit 1). It matches the message -- the condition itself -- rather than the bare -32600 code, which JSON-RPC also uses for other invalid requests; the code is carried for diagnostics. Every other resume failure keeps the benign proceeding without resume, unchanged.

This does not by itself stop the launcher from respawning a bridge that keeps dying, or add re-arm backoff, or qualify PIDs across an NFS-shared $HOME -- those are the other links of #906 and are separate.

Verification

  • tests/test_codex_bridge.bats +1: a fake WS app-server answers thread/resume with the -32600 / already has an active writer error; the bridge exits non-zero, says why, and never logs armed or reaches process/spawn -- i.e. it does not become one of the accumulating bridges.
  • The existing fix(codex): grace fallback for thread/resume failure on Codex 0.142+ #276 test ("falls back to idle instead of dying when thread/resume itself fails") still passes, so the benign fallback is intact -- only the active-writer case now exits. Full test_codex_bridge.bats 43/43 local.

…oceed

The thread/resume catch was written to tolerate a benign failure -- a
Codex 0.142+ --remote session that never created a rollout, where
turn/start still works from the threadId alone, so the bridge stays
alive idle. But it swallowed a deterministic one too: "already has an
active writer" (-32600) means another writer -- a co-resident Codex
Desktop, or a second bridge -- owns this thread, and resume cannot
succeed while that holds. A bridge that proceeds anyway still arms
watchers and holds ~10 threads, so on a host with a per-user pid limit
duplicates accumulate until the slice is saturated and every fork of
every process of the user fails (#906, two incidents in one day on an
HPC login node).

Now the two are told apart. The JSON-RPC clients carried only
message.error.message and dropped the code; both now attach it. The
catch matches the "already has an active writer" message -- the
condition itself, not the generic -32600 code, which is also returned
for other invalid requests -- and die()s, so a bridge that cannot own
its thread stops instead of lingering. Every other resume failure keeps
the benign proceed-without-resume, unchanged (the #276 test still
passes).

Part of #906 (link 1 of the reported chain; the launcher orphan scan,
re-arm backoff, and cross-host PID qualification are separate).
Address a review nit: the client comment implied ensureThread needs the
code to distinguish the active-writer case, but it decides on the
message. Say the code is carried for diagnostics and future callers, not
for that branch.
…l on 3.2

The three checks were `[[ ]]` in non-last positions, which report ok with
a false claim inside on macOS bash 3.2 -- the shell CI runs. So on the
macOS shards those assertions were no-ops. Use grep for the positive and
`[ "$(grep -c ...)" -eq 0 ]` for the negatives, which fail on both shells.
No logic change.
Rebasing this branch onto main after #941 landed put #941's two #936
tests just before this branch's #906 test in the same file; resolving
that conflict dropped the closing brace of #941's second test, leaving
the file unparseable. Restore it. Tests only; no logic change.
@fujibee
fujibee force-pushed the fix/bridge-die-on-active-writer branch from a28ef47 to 61dc8fb Compare August 22, 2026 01:34
@fujibee
fujibee merged commit 12480d6 into main Aug 22, 2026
41 of 43 checks passed
fujibee added a commit that referenced this pull request Aug 22, 2026
…e exit

Three safety holes raised in static review, all the wrong-kill class:

(1) The match tested only that PROJECT and each pair value appear
somewhere in the argv as a whole element, not that they follow --project
/ --pair, and it accepted a candidate whose pairs are a SUPERSET of this
role's. So a --workspace-root equal to the project, or an alice+bob
bridge, was a target for an alice reaper. The match now reads the argv in
order (NUL-delimited /proc, else ps), takes the value AFTER --project and
after each --pair, and requires the project to match and the pair SET to
be equal -- not a subset.

(2) The ps fallback bounded tokens with spaces but still killed a
space-containing project, which ps cannot tell from two arguments, so a
project that is a space-prefix of another could match. It now refuses
(and the caller spawns instead) when a value it would match on contains a
space -- ps cannot reconstruct that boundary. A tab inside the pair
separator is not a space and is kept.

(3) The kill did not wait for the target to exit before spawning. The
real bridge shuts down async on SIGTERM and holds its thread as writer
until it does, so spawning immediately makes the new bridge lose
thread/resume to the dying one (active-writer, exit 1, #935) and the old
then exits too, leaving zero. The reaper now waits (bounded) for kill -0
to go false and, if a target will not exit, does NOT spawn this tick.

Tests add the fixtures these need and assert SURVIVAL: a --workspace-root
equal to the project, a pair-superset (alice+bob) bridge, a space-
containing project on the ps path, and no duplicate spawned beside a
bridge still shutting down. Full launcher suite green.

Part of #906 (link 2). Follows review on #943.
fujibee added a commit that referenced this pull request Aug 22, 2026
… -- wait on positive proof only (#906 link 2)

The previous fix over-corrected: it read a failed `_start_token` as "pid dead" and
ended the wait. But that helper returns non-zero for a transient /proc or ps read
failure too, not only for a gone pid, so a single bad observation would let the
launcher spawn a replacement while the killed bridge still held the thread as
writer -- reopening the #935 race the wait was added to close. Same shape as the
bare kill -0 it replaced, just inverted: an observation FAILURE was read as STATE.

Encode the rule and follow it: a failed observation proves nothing, and kill and
spawn each need their own positive proof (on any failure, do nothing; the timeout
bounds the wait). The wait now ends only on proof of EXIT -- either
_agmsg_pid_alive_local reporting the pid ABSENT (the sanctioned helper: EPERM-aware,
ps-cross-checked, erring to "alive" on ambiguity, so a false return is real
absence; and the one liveness path allowed kill -0), or a start token that reads
and now names a DIFFERENT process. A token that merely could not be read keeps the
wait going, and no proof within the budget returns 1 (do not spawn).
fujibee added a commit that referenced this pull request Aug 22, 2026
…the reap wait; token replacement is the only exit proof (#906 link 2)

_agmsg_pid_alive_local's false return is not a trustworthy absence proof: its ps
cross-check pipeline has no pipefail, so a transient ps failure yields an empty
stat and the helper returns "gone" (tracked separately as #954 -- it is a defect
in the shared helper, 31 call sites, not something to change from inside this PR).
Consulting it in the wait let a single flaky observation read as exit and spawn a
replacement while the killed bridge still held the thread as writer (#935).

Remove that consult. The only exit proof the reap wait trusts is a start token
that reads and now names a DIFFERENT process (a replacement). A token that reads
unchanged keeps waiting; a token that cannot be read proves nothing and also keeps
waiting. A normal exit therefore falls through to the timeout and returns 1 -- we
do not spawn this pass. The cost is one cycle, not a stall: the next pass no
longer sees the exited pid in pgrep and spawns then. Fail-closed, as ruled.
fujibee added a commit that referenced this pull request Aug 22, 2026
… reuse-safe reaper do it and refuse to spawn beside a live one (#906 link 2)

The deferred rebind kill still could not satisfy the fail-closed rule on the spawn
side. Even token-guarded, an unreadable token skipped the kill but let the rm +
spawn proceed, double-starting an old writer that was merely unobservable, not
gone; and a token-matched kill did not wait for proof of exit before spawning,
racing a bridge that holds the thread as writer through its async shutdown (#935).
The deeper problem: the pidfile stores a bare pid with no identity, so no kill
built on it can be made reuse-safe.

So stop killing from the pidfile entirely. The lease-based reaper (which runs just
above, matches on the identity hashes, re-checks the start token, and returns
non-zero when it cannot prove a target exited) is the only reuse-safe path allowed
to signal a same-identity bridge. If a mismatched-live bridge is somehow still
alive after it -- the reaper spared a lease-less legacy bridge, or one is
mid-shutdown -- do nothing this tick: keep the binding and retry, never spawning
beside it. A lease-less bridge that never dies just lingers; orphan survival is
acceptable, a wrong-kill or a double-start is not.
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