Skip to content

Fix the two intermittent failures, both real bugs - #10

Merged
packetloss404 merged 1 commit into
mainfrom
fix/flaky-mcp-acp
Sep 5, 2026
Merged

Fix the two intermittent failures, both real bugs#10
packetloss404 merged 1 commit into
mainfrom
fix/flaky-mcp-acp

Conversation

@packetloss404

Copy link
Copy Markdown
Owner

The two tests that had been failing at random on CI. Neither turned out to be a test defect — both are production bugs that the tests were correctly catching, intermittently.

internal/mcp — a healthy server reported as a failed shutdown

Observed as:

mcp.Shutdown: b: mcp: server exited: read |0: file already closed

reaperLoop calls cmd.Wait(), and os/exec closes the pipes it created once Wait sees the child exit. StdoutPipe's own documentation says as much: "Wait will close the pipe after seeing the command exit, so most callers need not close it themselves; it is thus incorrect to call Wait before all reads from the pipe have completed."

So a read still in flight fails with os.ErrClosed instead of reporting EOF. The reader had simply lost a race with the reaper. That error was then recorded as the cause of death — and because markDead is first-writer-wins:

func (c *Client) markDead(err error) {
	if c.dead.CompareAndSwap(false, true) {

the reaper's clean exit path could never replace it. closeExitErr surfaced it, and Close reported a shutdown failure for a server that had stopped perfectly normally. This is not test-only: on quit, a user would see mcp: server exited: read |0: file already closed for a healthy server.

os.ErrClosed is now classified exactly like EOF — provisional, leaving the reaper's status to win. The existing code already had the right instinct for EOF, and its comment says why: EOF on stdout is not a cause of death, it is the first symptom. ErrClosed is the same symptom wearing a different coat.

The decision moved into exitReasonFromRead, a pure function, so it can be tested directly instead of by losing the race on purpose.

internal/acp — a legitimate session/load rejected as busy

Observed as:

Expected nil, but got: {"code":"-32000","message":"session already has an active prompt"}

The session's active flag was cleared in a deferred function:

defer func() {
	state.mu.Lock()
	state.active = false
	state.mu.Unlock()
}()

A defer runs after the prompt response has already been written to the wire. That leaves a window in which the client has the response — from its point of view the prompt is over — but the server still considers the session busy. A client that reads the response and immediately sends session/load gets rejected.

That is not a hypothetical client. It is what switching between sessions looks like, and it is exactly the sequence in the failing test: prompt → cancel → await the prompt response → re-load.

active is now cleared immediately before each of the four terminal responses, with the defer kept as a safety net for any future path that returns without sending one (clearing twice is harmless). The ordering the defer existed for is preserved — its comment worried about a replay interleaving with the prompt's final updates, and every trailing update is already written by the time each terminal response is sent.

Evidence

Both directions, because "it stopped failing" is not evidence on its own:

check result
acp test, 300 iterations, fix reverted to defer-only reproduces the exact CI failure: re-load of an idle session should succeed
acp test, 300 iterations, with the fix pass
acp TestServerLoadSession*, 100 iterations, -race pass
TestManager_Shutdown_AllClients, 40 consecutive runs pass
whole internal/mcp package, 3 runs, -race pass
new classifier test with the ErrClosed handling removed 2 subtests fail

Plus the usual gates: golangci-lint 0 findings under GOOS=linux, darwin and windows; go vet clean.

One note on the full-suite run

A full go test ./... on this branch also failed TestManager_ReadOnlyJobWithoutVerifyRootCannotSeeWorktree (internal/jobs, 88s) and TestEngine_RetryCapIsHard (internal/workflow). Both pass in isolation in 3.4s and 2.7s respectively. They were starved by three concurrent golangci-lint passes I was running at the same time, and neither package is touched by this change — but it is worth recording that internal/jobs in particular degrades badly under CPU pressure.

🤖 Generated with Claude Code

Neither was a test defect.

internal/mcp: reaperLoop calls cmd.Wait, and os/exec closes the pipes it
created once Wait sees the child exit. A read still in flight then fails
with "read |0: file already closed" instead of reporting EOF -- the reader
simply lost a race. That error was recorded as the cause of death, and
because markDead is first-writer-wins the reaper's clean exit status could
never replace it, so Close reported a shutdown failure for a server that
had stopped perfectly normally. On quit a user would see
"mcp: server exited: read |0: file already closed" for a healthy server.

os.ErrClosed is now classified exactly like EOF: provisional, leaving the
reaper's status to win. The decision moves into exitReasonFromRead, a pure
function, so it is tested directly rather than by losing the race on
purpose.

internal/acp: the session's active flag was cleared in a deferred function,
which runs after the prompt response is already on the wire. A client that
reads the response and immediately sends session/load -- which is what
switching sessions looks like, and what the test does -- could be rejected
with "session already has an active prompt". It is now cleared immediately
before each terminal response, with the defer kept as a safety net for any
future path that returns without sending one. The ordering the defer
existed for is preserved: every trailing update is already written by then,
so a replay still cannot interleave with this prompt's final updates.

Evidence in both directions. With the acp clear reverted to defer-only, 300
iterations reproduce the exact CI failure; with the fix, 300 iterations pass
and 100 more under -race. The mcp shutdown test passes 40 consecutive runs
and the package passes three times under -race, and two subtests of the new
classifier test fail when the ErrClosed handling is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T19:25:32.251979Z 933dd92 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 933dd92c50

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/acp/server.go
Comment on lines +1340 to 1343
clearActive := func() {
state.mu.Lock()
state.active = false
state.mu.Unlock()

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 Serialize the terminal response before exposing the session as idle

If another session/prompt or session/load is already queued when a turn enters any terminal path, this clears active before sendResult/sendError acquires writeMu. The request loop can consequently accept the next operation, whose worker or history replay may write updates before the preceding prompt's terminal response, violating the one-active-prompt ordering guarantee and potentially replacing the runtime before the old worker returns. Clear the flag and serialize the terminal response as one synchronized transition rather than exposing an idle session between these calls.

Useful? React with 👍 / 👎.

@packetloss404
packetloss404 merged commit 733d286 into main Sep 5, 2026
16 checks passed
@packetloss404
packetloss404 deleted the fix/flaky-mcp-acp branch September 5, 2026 19:27
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