Skip to content

fix(appproto): free the passthrough slot before an answer's last frame - #1709

Merged
frahlg merged 1 commit into
masterfrom
fix-api-flaky-race-tests
Oct 17, 2026
Merged

frahlg merged 1 commit into
masterfrom
fix-api-flaky-race-tests

Conversation

@frahlg

@frahlg frahlg commented Oct 17, 2026

Copy link
Copy Markdown
Member

The race job ("core (Go race detector)") sometimes failed two go/internal/api tests on PRs that never touch them. It blocked #1703 (run 39045010631) and failed on claude/box-page-cars (run 39021378697). This fixes both tests and the box-side cause behind the second one.

What failed, and why

TestReadingTheConfigIsAllowedForAnyRole gathered api.chunk frames as soon as api.head arrived and only then waited for api.end. Under load the chunks came after the snapshot, so the body was empty (unexpected end of JSON input ("")). The test now waits for api.end first, gathers the chunks after it, and checks that their size matches what the end reports. No other passthrough test gathers chunks this way. call() in api_app_link_session_test.go and the appproto tests already wait for the end first.

TestAViewerMayStillRead got E_UNAVAILABLE with reason: busy, not a timeout or a size limit. The whole test ran in 0.01 s. The passthrough serves one request per session at a time. The test sent each next read as soon as the head of the last one arrived, while that answer was still being sent. /api/system/info failed most often because it follows /api/modes, the largest of the four answers. I also saw /api/mode and /api/modes fail. The test now waits for each answer's api.end before it asks again, the way the webapp does (session.ts sends one call at a time and waits for each to finish).

That alone would leave a smaller race. The box freed its slot only after it had sent the answer's last frame (defer h.apiBusy.Store(false) around serveAPI). A client that asks again the moment api.end or a refusal arrives could still be told busy. The appproto tests already worked around this with a waitIdle helper that polled the private flag. Its comment said: "The frame lands just before the serving goroutine clears the flag".

The box change

go/internal/appproto/passthrough.go: the slot is freed just before the last frame goes out (api.end, a gate refusal, or the "unavailable" error after a timeout or panic), once the handler has returned. A sync.Once around the release keeps the end of the goroutine as a fallback for other paths: a revoke, a failed send, or a media-type refusal sent while the handler still runs. It cannot free a later request's slot. Depth one is unchanged.

Would a box hit this? Rarely. Over a relay or WebRTC, the network round trip is far longer than the gap between sending api.end and freeing the slot. But the webapp asks again as soon as an answer ends, and losing that race costs a view the 30 s busy backoff that session.ts describes. In process, as in these tests, it happens. So this is a timing slip, not slow work: system/info answered in milliseconds.

The same fix covers the sweeps that send refusal after refusal in one session (TestAViewerIsRefusedAtEveryWrite, TestNoSecretBearingReadCrossesTheSession). They had the same small race.

Tests

  • New TestTheNextRequestIsServedOnceAnAnswerEnds (appproto). A sender with no delay hands the box the next request from inside the Send that carries the last frame, for both an answer and a refusal. It fails every time on master (E_UNAVAILABLE busy) and passes on this branch (300 of 300 with -race).
  • waitIdle is gone. Waiting for the frame is now enough.

Evidence

Same machine and load for both: three full copies of the api package with -race in parallel, and the two tests with GOMAXPROCS=2 -count=2000:

TestAViewerMayStillRead TestReadingTheConfigIsAllowedForAnyRole
master e40ff96 3 failures in 2000 (/api/mode, /api/modes, /api/system/info) 2 failures in 2000 (empty body)
this branch 0 in 2000 0 in 2000
  • go test -race -run <name> -count=200 for each test with the full package running beside it: 200 of 200 passed.
  • go test -race -count=3 ./internal/api/ ./internal/appproto/: ok.
  • make verify: green (76 packages, vet and build).

No change in go/internal/control. Patch changeset included.

Follow-ups (not in this PR)

Other tests that fail now and then in master's race job:

  • TestTheBoxOpensAnExchangeBesideItsUplink
  • TestForecastRetentionLargeOverflowWithinScoringBudget
  • TestAPlugInReachesAStreamingPhoneAsAPlan

🤖 Generated with Claude Code

The race job sometimes failed two api tests on PRs that never touch them.

TestReadingTheConfigIsAllowedForAnyRole gathered chunks after api.head and
before api.end, so under load the body came back empty. It now waits for
api.end first and checks the byte count.

TestAViewerMayStillRead got E_UNAVAILABLE busy: it sent each read while the
last answer was still streaming. It now waits for api.end, as the app does.

Behind that, the box freed its one-request slot only after sending the last
frame, so a client asking again on api.end could still be told busy. The slot
is now freed just before api.end or a refusal goes out. A new test hands the
box the next request from inside that Send; it fails every time on master.
The waitIdle workaround in the appproto tests is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 17, 2026 17:44
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 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-10-17T17:46:45.809832Z adf1c35 Draft marked ready
ℹ️ 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.

@frahlg
frahlg merged commit 8f937e7 into master Oct 17, 2026
22 checks passed

@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: adf1c35b79

ℹ️ 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".

// middle. The app is committed to the status by then, so the only honest
// thing left to say is that this is not the whole of it.
truncated := w.truncated || w.ctx.Err() != nil || w.panicked
w.free()

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 Initialize the writer's release callback in the benchmark

When BenchmarkAPIWriterLargeResponse calls finish() at passthrough_test.go:413, its directly constructed apiWriter never initializes free. This unconditional call therefore panics with a nil-function dereference instead of producing benchmark results; initialize the callback in that literal or make the callback safe when omitted.

Useful? React with 👍 / 👎.

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