Skip to content

Wait out a broker that is refusing connections, and stop three tests racing the runner - #10

Merged
Edo771977 merged 2 commits into
mainfrom
claude/focused-carson-khonz0
Sep 18, 2026
Merged

Edo771977 merged 2 commits into
mainfrom
claude/focused-carson-khonz0

Conversation

@Edo771977

@Edo771977 Edo771977 commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Two commits: the broker change this PR started as, and three pre-existing flaky tests the review work surfaced.

335/335 idle (run twice), 334/335 under load average 36 — the one failure there is a fourth pre-existing flake, unrelated and reproduced identically on main (see the end). tsc clean.

1. Wait out a broker that is refusing connections, not one that is missing

ensureBrokerSession() probes the persisted broker for 150ms and, when that fails, replaces it — spawning a second broker with its own app-server and every MCP server under it. What the failed probe meant was never asked, and the two answers deserve opposite treatment: "nothing is there" and "something is there but would not take this connection right now" are the same boolean.

probeBrokerEndpoint() now reports the outcome, not just a verdict:

probe outcome meaning what happens
EAGAIN (full accept backlog), EBUSY (Windows named pipe with no free instance), timeout listening, refusing right now second window (1500ms, overridable), reused if it starts accepting
ECONNREFUSED, ENOENT, unparsable nothing is there straight to the replacement, as before — no wait

Liveness at that gate is now pinned to the identity recorded at spawn time, like the shutdown path, so a reused pid reads as gone instead of keeping an abandoned record alive.

What the first version of this PR got wrong

It waited whenever the pid was alive, on the theory that a broker mid-turn keeps its event loop busy and misses the probe. That theory is wrong on POSIX and two adversarial reviews caught it. connect() to a Unix socket is completed by the kernel as soon as the peer is listening — the server never has to call accept(). A server blocked in a 5-second spin loop still answers:

probe while the server's event loop is blocked: {"outcome":"connect","ms":1}
probe while the server's event loop is blocked: {"outcome":"connect","ms":0}
after the server is gone:                       {"outcome":"ECONNREFUSED","ms":1}

So the old gate fired only when nothing was listening — the "broker is gone" case it claimed to be free for. What it actually caught was the broker's own graceful shutdown: runShutdown() closes the listener before its first await and the process then lives through two 5s graces, so for up to ten seconds the pid is alive and nothing listens. Measured, same outcome either way:

before: {"elapsedMs":368,  "replacementSpawned":true}
after:  {"elapsedMs":2033, "replacementSpawned":true}

1500ms of that was spent holding the broker lock. It was also, for any live-pid record, exactly the cost this PR cited as its reason for not taking upstream #768's blanket 3s probe. Gating on the outcome removes it. (openai#768's other two fixes — never tearing down a live broker, and serializing the check-then-create window — were already in this fork.)

Tests

Four, deciding on injected probe outcomes rather than wall-clock races — a Unix connect() cannot be made to report EAGAIN on demand without a saturated backlog, and EBUSY needs Windows:

sabotage which test fails
no busy window at all refusing-broker reuse, and keeps-refusing replacement
outcome gate removed (anything alive waits) gone-broker never pays
shipped default 1500 → 15 refusing-broker reuse
liveness gate removed dead record is reclaimed, not waited on

2. Three pre-existing tests that raced the runner

All three pass idle and fail under load; all three were reproduced here in a full-suite run at load average 36, and each asserted something other than what its name promises.

shutdown request always uses a finite deadline failed on unreachable: true, not on time. It called sendBrokerShutdown() with timeoutMs: 0, clamped to 1ms — and a loaded machine does not complete connect() in 1ms, so the deadline fires before the connection and the outcome correctly reports a broker it never reached. One loop was covering two properties, so they are now separate: timeoutMs: 0 asserts only that the call settles, raced against a watchdog (socket.setTimeout(0) disables the timer outright, and the clamp is the only thing between that and waiting forever); the accepted-but-silent case gets a budget the connect can win, plus an assertion that the server did accept — otherwise the test was not measuring its own scenario.

The two interrupt-starvation tests failed because the SessionEnd hook splits one 2200ms budget between the reaper's dead-turn interrupts and the session's own, and a hung interrupt consumes its whole slice. Under load nothing was left for the second job, and the test reported a starvation that had not happened. The budget is now tunable through CODEX_TURN_INTERRUPT_BUDGET_MS, like the other lifecycle limits, default unchanged; the two tests raise it to 8000ms. The sharing math — the property under test — is identical at any budget, and the knob stands on its own for a workspace with many active jobs.

Both fixes still fail on the defect they guard: removing the zero-timeout clamp makes the deadline test report "never settled", and removing the per-job share makes both starvation tests fail.

Known, not fixed here

Under the same load, task logs subagent reasoning and messages with a subagent prefix fails: the subagent's thread/started name never arrives before the turn ends, so every log line falls back to the thread id (Subagent thr_2 reasoning: instead of Subagent design-challenger reasoning:). Reproduced 4/4 on main and 4/4 on this branch under 32 spinners, so it is pre-existing and untouched by these commits. It looks like a notification-ordering race between the child thread's thread/started and the parent's collabAgentToolCall item, which is a deeper fix than this PR should carry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg

ensureBrokerSession() probes the persisted broker for 150ms and, when that
fails, replaces it — spawning a second broker with its own app-server and
every MCP server under it. The question this leaves unanswered is what a
failed probe meant, and the two answers deserve opposite treatment:
"nothing is there" and "something is there but would not take this
connection right now" are the same boolean.

probeBrokerEndpoint() now reports the outcome, not just a verdict. An
endpoint that is present and refusing — EAGAIN from a full accept backlog,
EBUSY from a Windows named pipe with no free instance, or a timeout — gets
a second window (1500ms, overridable) and is reused if it starts accepting.
ECONNREFUSED, ENOENT and an unparsable endpoint buy nothing: those mean the
broker is gone, which is the common case, and it goes straight to the
replacement as before.

That distinction is the whole point. A first attempt here waited whenever
the pid was alive, on the theory that a broker mid-turn keeps its event
loop busy and misses the probe. That theory is wrong on POSIX: connect() to
a Unix socket is completed by the kernel as soon as the peer is listening,
so a broker blocked in a spin loop still answers in under 2ms (measured).
What the pid-alive gate actually caught was the broker's own graceful
shutdown — listener closed, process alive for up to two 5s graces — where
it added 1500ms inside the broker lock and then did exactly what it did
before (measured: 368ms to 2033ms, same outcome). It also reproduced, for
a live-pid record, the cost this fork rejected upstream's blanket 3s probe
for.

Liveness at that gate is now pinned to the identity recorded at spawn time,
like the shutdown path, so a reused pid reads as gone instead of keeping an
abandoned record alive.

The tests decide on injected probe outcomes rather than on wall-clock
races: a Unix connect() cannot be made to report EAGAIN on demand without a
saturated backlog, and EBUSY needs Windows. Each covers a distinct part of
the decision and fails when only its part is reverted — no busy window, no
outcome gate, no liveness gate, or the shipped 1500ms default changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
@Edo771977
Edo771977 force-pushed the claude/focused-carson-khonz0 branch from 1e09047 to 48e3897 Compare September 18, 2026 12:39
@Edo771977 Edo771977 changed the title Reuse a busy broker instead of starting a second one Wait out a broker that is refusing connections, not one that is missing Sep 18, 2026
All three pass idle and fail under load; all three were reproduced here in
a full-suite run at load average 36, and each turned out to assert
something other than what its name promises.

`shutdown request always uses a finite deadline` failed on
`unreachable: true`, not on time. It called sendBrokerShutdown() with
timeoutMs 0, which is clamped to 1ms — and a loaded machine does not
complete connect() in 1ms, so the deadline fires before the connection and
the outcome correctly reports a broker it never reached. One loop was
covering two properties, so they are now separate: timeoutMs 0 asserts only
that the call settles at all, raced against a watchdog, since
socket.setTimeout(0) disables the timer outright and the clamp is the only
thing between that and waiting forever. The accepted-but-silent case gets a
budget the connect can actually win, plus an assertion that the server did
accept — otherwise the test was not measuring the scenario it describes.

The two interrupt-starvation tests failed because the SessionEnd hook
splits one 2200ms budget between the reaper's dead-turn interrupts and the
session's own, and a hung interrupt consumes its whole slice. Under load
nothing was left for the second job, and the test reported a starvation
that had not happened. The budget is now tunable through
CODEX_TURN_INTERRUPT_BUDGET_MS, like the other lifecycle limits, default
unchanged; the two tests raise it to 8000ms. The sharing math — which is
the property under test — is identical at any budget, and the knob stands
on its own for a workspace with many active jobs, where the default is
thin for the same reason.

Each fix still fails on the defect it guards: removing the zero-timeout
clamp makes the deadline test report "never settled", and removing the
per-job share makes both starvation tests fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
@Edo771977 Edo771977 changed the title Wait out a broker that is refusing connections, not one that is missing Wait out a broker that is refusing connections, and stop three tests racing the runner Sep 18, 2026
@Edo771977
Edo771977 merged commit 07d5f32 into main Sep 18, 2026
1 check passed
@Edo771977
Edo771977 deleted the claude/focused-carson-khonz0 branch September 18, 2026 13:20
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.

2 participants