test: synchronise every fixture-upstream capture a test goroutine reads - #191
Conversation
A fixture handler runs on its httptest.Server's own goroutine. The HTTP round trip that
follows LOOKS like it orders the handler's writes before the test goroutine's reads, but
it is not a happens-before edge the memory model (or the race detector) recognises: a
`var got []byte` written in the handler and read after `resp.Body.Close()` is an
unsynchronised access, and `make cover` runs the suite with -race.
Swept all 281 _test.go files: 95 closures run on another goroutine (handler bodies and
`go func`), 46 write a variable the enclosing test reads, and 30 of those writes had no
mutex, channel, WaitGroup or atomic between the two accesses. The other 16 were already
correct -- tenancy_test.go's hostedFixture (f.mu), modes_test.go's captureUpstream
(mu.Lock), the wg.Wait()-joined slice fills in diag_test.go and modes_test.go, and the
atomic.Int64 counters expandsplice_test.go already uses.
Three shapes, chosen so the edge is visible at the call site:
- Bodies, headers and per-round captures in package proxy_test go through a new
mutex-guarded `upstreamCapture` (proxy_test.go), modelled on hostedFixture.
`record()` returns the 1-based round number, so a handler that answers differently
per round no longer needs a captured counter either.
- Counter-only fixtures become `atomic.Int64`, which carries its own edge and is the
smaller change -- and the shape expandsplice_test.go already used.
- Single-shot struct captures (cheapmodel, keepalive, keepalive_wire, tenancy) come
back over a buffered channel; prefixask's fixture gets a `forwarded()` accessor
behind its own mutex.
Behaviour is unchanged: every assertion still reads the same bytes, and the two tests
where "no request arrived" is itself the assertion (keepalive_wire, tenancy) use a
non-blocking receive so that case stays observable instead of deadlocking.
Honest note on verification: -race does NOT report this shape, before or after. A
standalone reproducer of the canonical pattern is clean at -count=200 (including a
variant that assigns after the response is flushed), while a control with two concurrent
handlers writing one variable is reported immediately -- so the detector was live and the
loopback path is manufacturing a happens-before edge through net/http's shared internals.
These are therefore findings by inspection: unsynchronised by the memory model, not
currently observable by TSan.
proxy/conformance_test.go and proxy/counttokens_test.go are deliberately untouched; they
are fixed separately in #141, which adds its own `recordedRequest` helper. The helper
added here is named `upstreamCapture` so the two cannot collide at merge.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid
left a comment
There was a problem hiding this comment.
Reviewed in a dedicated worktree at the PR head (999e1bf, parent == current origin/main, so no drift). Two passes: a hand review plus an independent automated sweep for the same defect class. Sound change and an unusually honest write-up — particular credit for saying plainly that -race does not fire on this shape rather than manufacturing a revert-and-fail story, and for proving the detector was live with a control. Nothing below blocks merge.
Claims I checked independently
My own scan of every _test.go for closures on another goroutine writing a variable the enclosing test reads agrees with the audit table:
dash/sse_test.go:56drained— declared outside, but genuinely read only inside the goroutine. Correctly left alone.proxy/counttokens_test.go:21gotPath— real defect, deferred to #141. Verified #141 actually fixes it (gotPath, gotBody := rec.requestPath(), rec.forwarded()), so the deferral is covered rather than dropped on the floor.modes_test.go'scaptureUpstream,tenancy_test.go'shostedFixture,prefixask_test.go— guarded, correct.- The only extra hit my scanner produced (
adjudicatetool_test.gostream) is a false positive: the"stream": truemap key.
Deadlock risk on the new blocking receives: there is no retry logic in internal/cheapmodel and none on the proxy's upstream path, so no size-1 channel can ever take a second send and wedge a handler goroutine behind srv.Close(). keepalive_test.go:723 is gated by an asserted status == 200 from a synchronous sendPing; the two cheapmodel receives by an asserted err == nil. keepalive_wire's non-blocking select is sound (k.dispatch = k.fire is inline, so the value is queued before sweep returns) and degrades to a clean failure rather than a false pass if that ever changes.
CI: build-test runs make cover → go test -race ./..., green at 10m22s, so all 11 files compile and pass under -race (and vet's copylocks would have caught a by-value upstreamCapture).
Findings that fall outside the diff hunks
1. proxy/expandsplice_test.go:136 — the "not fixed, deliberately" reasoning does not hold on this PR's own terms. The body says calls is "written in the handler but read only inside it, on rounds that the proxy issues sequentially. No cross-goroutine read." But TestTheStreamedPrefixReachesTheClientBeforeTheExpandCall drives two upstream rounds (round 1 serves head+tail; round 2 serves round2Answer, the source of the asserted ANSWERED), and each round is served from a httptest.Server goroutine. Handler-goroutine-1's calls++ and handler-goroutine-2's calls++ / if calls > 1 are therefore accesses to one variable from two goroutines — cross-goroutine, just never concurrent. By the memory-model criterion this PR applies everywhere else, the site qualifies.
One caveat against overstating it: TSan orders accesses by vector clock rather than by luck, so if there were genuinely no edge here -race would report it, and CI is green. That points at keep-alive connection reuse putting both rounds on the same conn goroutine — which is not a guarantee anyone gives you. So: either convert it (one line, atomic.Int64, already precedented ~200 lines below) or restate the justification as "same connection, therefore same goroutine", which is at least the true reason.
2. proxy/expandsplice_test.go:279-281 — this PR makes a pre-existing comment stale. It reads "atomic, unlike the counters in the tests above: the second round's connection is closed without a response, so there is no happens-before edge…". One of the two counters "above" is now an atomic too, so the contrast it draws is no longer true — and the remaining plain int above it is exactly the site in finding 1. Worth rewording here, since this PR is what invalidates it.
| // | ||
| // Counter-only fixtures do not need this: an atomic.Int64 carries its own edge and is a smaller | ||
| // change at the call site. | ||
| type upstreamCapture struct { |
There was a problem hiding this comment.
Duplicate capture helper in one package (quality, not correctness). proxy/modes_test.go:54 already has captureUpstream(t) (*httptest.Server, func() [][]byte) — mutex-guarded, 15 call sites, same package proxy_test. upstreamCapture is a strict superset of it (method / path / header / body, plus the round number), so the package now offers two overlapping ways to record an upstream and the next test picks arbitrarily.
The PR body cites captureUpstream as the in-repo precedent but does not say why it is not extended or reused. Either reimplement captureUpstream over upstreamCapture (its accessor becomes a one-liner over rounds), or leave a comment saying why both shapes exist.
There was a problem hiding this comment.
Fixed in 90b602b — captureUpstream is now implemented over upstreamCapture rather than beside it:
func captureUpstream(t *testing.T) (*httptest.Server, func() [][]byte) {
t.Helper()
var up upstreamCapture
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
up.record(r)
...
}))
t.Cleanup(srv.Close)
return srv, up.bodies
}with a new bodies() accessor on upstreamCapture. I kept the narrow func() [][]byte signature rather than exposing upstreamCapture to those call sites: all fifteen only ever want the bodies in order, and widening them would be churn for no reader's benefit. What mattered was the duplication you pointed at — the mutex, and the paragraph explaining why it is there, now exist in exactly one place, and sync drops out of modes_test.go's imports as a result.
There was a problem hiding this comment.
Verified on 90b602b. captureUpstream is now the bodies-only view over upstreamCapture (return srv, up.bodies), sync drops out of modes_test.go, and the 15 call sites keep their signature. Aliasing is unchanged from the old accessor: both build a fresh outer slice and share the inner ones, and the handler allocates a fresh body per round, so no call site can observe a mutation it could not before. up.bodies as a method value on an addressable local binds &up correctly. Settled from my side.
| t.Fatalf("gateway should inject the real key, upstream saw %q", gotAuth) | ||
| } | ||
| _ = gotXAPI | ||
| _ = hdr.Get("x-api-key") |
There was a problem hiding this comment.
Dead statement — it carries over the equally dead _ = gotXAPI, but the diff already rewrites this line, so it may as well go. Nothing reads an x-api-key assertion here; if the intent was "the gateway must not also send x-api-key", that is worth writing as a real assertion instead.
There was a problem hiding this comment.
Fixed in 90b602b. Your reading of the intent was right, so it is written as a real assertion instead of deleted:
// And only that one slot: an OpenAI upstream has no business receiving Anthropic's header,
// least of all a copy of the key.
if gotXAPI := hdr.Get("x-api-key"); gotXAPI != "" {
t.Fatalf("gateway sent x-api-key to an OpenAI upstream: %q", gotXAPI)
}Revert-verified rather than assumed — mutating the OpenAI route's setKey to also set the header:
proxy_test.go:377: gateway sent x-api-key to an OpenAI upstream: "real-openai-key"
Restored, passes.
There was a problem hiding this comment.
Verified on 90b602b. The dead statement is now a real assertion (x-api-key must be empty on an OpenAI upstream), which is strictly more than I asked for — the line went from asserting nothing to asserting the thing its presence hinted at. Settled from my side.
| if len(hits) != 2 { | ||
| t.Fatalf("want 2 upstream hits, got %d: %+v", len(hits), hits) | ||
| if n := up.hits(); n != 2 { | ||
| t.Fatalf("want 2 upstream hits, got %d", n) |
There was a problem hiding this comment.
Minor diagnostic regression: the old message dumped the hits (got %d: %+v), so a failure told you what actually arrived. Now it prints only the count, which is the least informative part of the failure. A small accessor returning the recorded rounds (or %+v on up.round(1)/up.round(2)) would restore it.
There was a problem hiding this comment.
Fixed in 90b602b via a served() accessor returning a copy of the rounds.
One thing worth recording, because the obvious fix was not enough: %+v over []upstreamRound renders the body []byte field as a list of decimal byte values, so the restored dump was ~2 KB of [123 34 109 101 ...] — arguably less useful than the bare count. So upstreamRound also gets a String():
func (r upstreamRound) String() string {
return fmt.Sprintf("%s %s header=%v body=%s", r.method, r.path, r.header, r.body)
}Checked by forcing the branch (n != 3), which now prints POST /inference/v1/chat/completions header=map[...] body={"messages":[...]} — readable, and it fixes %+v for every other site that dumps a round too.
There was a problem hiding this comment.
Verified on 90b602b. served() plus String() on upstreamRound is the right shape — you are correct that a naive %+v restore would have been worse than the count, since %+v on the []byte body prints decimal byte values. Checked that the element-wise String() actually gets used: %+v on a []upstreamRound calls String() per element because the value receiver satisfies fmt.Stringer, so the dump renders as text. Settled from my side.
| } | ||
| // With no key configured the client's own auth passes through, as documented. | ||
| var got *http.Request | ||
| select { |
There was a problem hiding this comment.
The select's stated purpose is to keep "nothing arrived" observable rather than turning it into a deadlock — but the assertion that consumes the zero value dereferences it:
if got == nil || got.Header.Get("Authorization") != "Bearer client-own-key" {
t.Errorf("single-tenant pass-through changed: %v", got.Header.Get("Authorization"))
}The got == nil || guard says nil is considered reachable, yet the t.Errorf argument is evaluated unconditionally, and got.Header on a nil *http.Request panics — taking down the whole test binary instead of reporting the failure. So on the one path the new select exists to preserve, the outcome is still not a clean failure.
To be fair: the t.Errorf line is pre-existing and unchanged — this is not introduced here. But the new select is what makes that path a designed-for outcome rather than a can't-happen, so it is the right patch to fix it in: hoist a nil-safe auth local, or t.Fatal on nil before the header check.
(The sibling non-blocking site, keepalive_wire_test.go:94, is fine — seen is a struct value, so the zero case is a nil body field and t.Fatals cleanly.)
There was a problem hiding this comment.
Fixed in 90b602b, and you were right that this is the patch to fix it in — t.Fatal on nil before the header is read:
select {
case got = <-seen:
default:
t.Fatal("single-tenant request never reached the upstream")
}
if auth := got.Header.Get("Authorization"); auth != "Bearer client-own-key" {
t.Errorf("single-tenant pass-through changed: %v", auth)
}Verified on the path in question, by dropping the handler's seen <- r.Clone(...) so nothing ever arrives. Before:
panic: runtime error: invalid memory address or nil pointer dereference
github.com/rossoctl/context-guru/proxy.TestSingleTenantUnchanged(...)
/tmp/cg-src-pr191-verify/proxy/tenancy_test.go:519
FAIL github.com/rossoctl/context-guru/proxy 1.768s
After:
--- FAIL: TestSingleTenantUnchanged (0.25s)
tenancy_test.go:517: single-tenant request never reached the upstream
So the outcome the select exists to preserve is now actually a reportable failure rather than a dead test binary.
There was a problem hiding this comment.
Verified on 90b602b. t.Fatal in the default branch is the fix I wanted, and it does more than remove the panic: it makes the non-blocking select mean what its comment claims. "Nothing arrived" is now a named failure at the point of arrival rather than a zero value that has to survive being dereferenced two lines later. The got == nil || guard correctly goes away with it, since nil is unreachable past the select. Settled from my side.
Six findings from the review of #191, in the two files the reviewer named. **expandsplice_test.go's remaining plain counter (review finding 1).** The body claimed `calls` at :136 was "read only inside the handler ... no cross-goroutine read". That does not hold: TestTheStreamedPrefixReachesTheClientBeforeTheExpandCall drives two upstream rounds and each is served from a goroutine of the httptest.Server's own, so the `calls++` of round 1 and the `calls++` / `if calls > 1` of round 2 touch one variable from two goroutines — sequential, but not ordered by anything the memory model gives you. Whether keep-alive reuse puts both rounds on the same conn goroutine is net/http's business, not an invariant a test may lean on. Converted to atomic.Int64, the shape already used five times in the same file, and the comment now states the actual reason rather than the false one. **The comment this PR made stale (finding 2).** :279 read "atomic, unlike the counters in the tests above" — the contrast died when those counters became atomics, and the last plain int among them was the site above. Reworded to stand on its own: the second round's connection is closed without a response, so not even a completed round trip sits between the write and the read. **captureUpstream duplicated upstreamCapture (inline, proxy_test.go:40).** modes_test.go's captureUpstream is a mutex-guarded body recorder in the same package, and upstreamCapture is a strict superset of it, so the package offered two overlapping ways to record an upstream. captureUpstream is now implemented over upstreamCapture via a new bodies() accessor; its narrow signature stays because its fifteen call sites only want the bodies in order. `sync` drops out of modes_test.go's imports. The reasoning for the synchronisation now lives in exactly one place. **The dead x-api-key statement (inline, proxy_test.go:355).** `_ = hdr.Get("x-api-key")` carried over from an equally dead `_ = gotXAPI` and asserted nothing. Written as the assertion it was presumably reaching for: an OpenAI upstream must not receive Anthropic's header, least of all a copy of the key. **The lost failure diagnostic (inline, proxy_test.go:279).** The pre-PR message dumped the hits; the PR reduced it to the count, which is the least informative part. Restored via a served() accessor — plus a String() on upstreamRound, because %+v on a []byte field renders the body as a list of decimal byte values and defeats the point of dumping it. **The nil dereference on the path the select exists to preserve (inline, tenancy_test.go:514).** TestSingleTenantUnchanged's non-blocking receive is there so "nothing arrived" stays a reportable failure instead of a deadlock — but the t.Errorf argument was evaluated unconditionally, so got.Header on a nil *http.Request panicked and took the whole test binary down. Pre-existing and unchanged by #191, but #191 is what turned that path from can't-happen into designed-for, so it is fixed here: t.Fatal on nil before the header is read. Verification (eval box, go1.26.4, CGO_ENABLED=1): - `gofmt -l proxy` clean, `go vet ./proxy/` clean, `go build ./...` clean. - `go test -race ./proxy/ ./internal/cheapmodel/` passing. - The two new assertions were revert-verified rather than assumed: - x-api-key: mutating the OpenAI route's setKey to also set the header (`hd.Set("x-api-key", h.opts.OpenAIKey)`) fails as `proxy_test.go:377: gateway sent x-api-key to an OpenAI upstream: "real-openai-key"`. Restored, passes. - the nil guard: dropping the handler's `seen <- r.Clone(...)` to simulate a request that never arrives fails cleanly as `tenancy_test.go:517: single-tenant request never reached the upstream`, where the pre-fix assertion panics at tenancy_test.go:519 inside t.Errorf's argument. Restored, passes. - The atomic conversion is not revert-verifiable, for the reason #191 already documents at length: -race does not fire on this shape before or after, so it is a finding by inspection. Unrelated pre-existing failure found while re-running the suite at `-count=2` and reported as #192 rather than patched here: TestExtractEconomicsAreExported and TestExpandUnresolvedSeriesRender assert on process-global metrics counters they increment themselves, so the proxy package cannot pass at -count>1. Reproduces identically on unmodified origin/main, so it belongs on its own branch. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
|
Addressed in Finding 1 — Finding 2 — the stale comment at The four inline findings are answered on their own threads. Two of them (the One thing you will want to know before re-running the suiteRe-running at It is not cosmetic in context: Full Assisted-By: Claude Opus 5 (1M context) noreply@anthropic.com |
CI green on
|
| check | result |
|---|---|
build-test (make cover → go test -race ./...) |
pass, 12m38s |
purego |
pass, 1m5s |
trivy |
pass, 13s |
| DCO | pass |
So all eleven touched files compile and pass under -race in CI, including the reimplemented captureUpstream across its fifteen call sites.
The six findings, and where each one landed
| # | finding | where | what changed |
|---|---|---|---|
| 1 | expandsplice_test.go:136 — the "not fixed, deliberately" reasoning does not hold |
review body | calls → atomic.Int64; comment restates the real reason. Audit table corrected to 31 qualifying / 29 fixed, withdrawn entry struck through rather than deleted |
| 2 | expandsplice_test.go:279-281 — this PR made the comment stale |
review body | reworded to stand alone (it was stale in both halves — the counters above are atomics now, and the one plain int among them was finding 1) |
| 3 | duplicate capture helper in one package | proxy_test.go:40 |
captureUpstream reimplemented over upstreamCapture via a new bodies(); sync drops out of modes_test.go |
| 4 | dead _ = hdr.Get("x-api-key") |
proxy_test.go:355 |
written as the assertion it was reaching for; revert-verified |
| 5 | diagnostic regression — only the count survived | proxy_test.go:279 |
served() accessor plus a String() on upstreamRound — %+v on the []byte body rendered ~2 KB of decimal byte values, so the naive restore was less useful than the count it replaced |
| 6 | nil deref on the path the select exists to preserve |
tenancy_test.go:514 |
t.Fatal on nil before the header read; revert-verified, and the pre-fix form demonstrated panicking |
Detail and the revert-verification output are on the four inline threads and in the comment above; the PR body carries the corrected audit numbers.
Still open, deliberately
- feat(dist): pure-Go releases, a
cachepreset, --idle-exit, and gateway conformance #141's three sites (conformance_test.go×2,counttokens_test.go×1) — untouched, as before. You verified feat(dist): pure-Go releases, acachepreset, --idle-exit, and gateway conformance #141 actually fixescounttokens_test.go:21, so the deferral holds. - test: proxy package cannot run at -count>1 — two tests assert on process-global metrics counters #192 — the
-count>1failures inTestExtractEconomicsAreExported/TestExpandUnresolvedSeriesRender. Reproduces on unmodifiedorigin/main, so it is filed rather than carried here. Flagging it again only because-count=Nis the instrument a reviewer reaches for on a change like this one, and on theproxypackage it currently fails for reasons that have nothing to do with the change under review.
Nothing else from the review is outstanding. Ready for another look.
Assisted-By: Claude Opus 5 (1M context) noreply@anthropic.com
|
Re-reviewed at Body finding 1 —
|
|
Your correction on the base is right, and my error was worse than a stale SHA — worth stating precisely, because it changes what #192 says rather than just how it is worded. The remote's default branch is The sharper consequence is for #192. Both tests it names were added after The reproduction itself is unaffected, and I checked rather than assumed. The tree I ran against was built from the remote default-branch ref, which resolved to Noted on the two substantive points:
Nothing outstanding from my side. Thanks for catching the base — it was the one claim in the handoff I had taken from a local ref without checking it against the remote. Assisted-By: Claude Opus 5 (1M context) noreply@anthropic.com |
|
Correcting my own base note above, because it was wrong about the mechanism. I wrote that The operative conclusion is unchanged and now rests on the right fact: Two figures in the handoff that reached #192 are also off, and since the range is what makes that argument work, it is worth having them exact:
Neither changes the conclusion — both files are absent at |
|
Both corrections are mine to own, and both are right — I verified them rather than just accepting them:
#192's body is amended inline with the right figures, and the amendment records that both were wrong and why, so the issue's own record does not rely on a reader finding your comment. One figure in the correction does not reconcile"62 of them first-parent PR merges" does not match any measure I can find on that range:
The repo squash-merges, so "PR merges" here are non-merge first-parent commits ending in Raising it only because it is the same class of error as mine and it is now in #192's permanent record next to figures I have just corrected — by your own standard that the range is what makes the argument work. It changes nothing: 78 is the number that carries the point, and the point survives at 57, 60 or 62. Net stateEverything material is agreed and unchanged: the defect, its cause, the reproduction against Assisted-By: Claude Opus 5 (1M context) noreply@anthropic.com |
|
My |
#191 landed a mutex-guarded `upstreamCapture` in package proxy_test while this branch carried `recordedRequest`, added for the same reason on the same review. Two near-identical helpers in one package is duplication a reader has to reconcile, so this drops mine. Theirs is the better of the two: it records every round with method, path, headers and body — so a test needing the second round's headers has somewhere to read them — and `record` returns the 1-based round number, which removes the captured counter a per-round handler would otherwise need. Mapping: rec.forwarded() -> up.body(1), rec.requestPath() -> up.round(1).path. The reason both existed is unchanged and worth keeping in view: a fixture handler runs on the test server's goroutine, and the HTTP round trip that follows is not a happens-before edge. Note that `-race` reports none of these, before or after — #191 established that with a control, so this is synchronisation by the memory model rather than by anything the detector demanded. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
#191 landed a mutex-guarded `upstreamCapture` in package proxy_test while this branch carried `recordedRequest`, added for the same reason on the same review. Two near-identical helpers in one package is duplication a reader has to reconcile, so this drops mine. Theirs is the better of the two: it records every round with method, path, headers and body — so a test needing the second round's headers has somewhere to read them — and `record` returns the 1-based round number, which removes the captured counter a per-round handler would otherwise need. Mapping: rec.forwarded() -> up.body(1), rec.requestPath() -> up.round(1).path. The reason both existed is unchanged and worth keeping in view: a fixture handler runs on the test server's goroutine, and the HTTP round trip that follows is not a happens-before edge. Note that `-race` reports none of these, before or after — #191 established that with a control, so this is synchronisation by the memory model rather than by anything the detector demanded. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…way conformance (#141) * feat(dist): pure-Go releases, a `cache` preset, --idle-exit, and gateway conformance Implements the local-distribution proposal (#130) minus the Claude Code plugin, which ships as its own PR because all six blocking findings from the review of #141 live in it. Nothing here is held behind that. `docs/get-started/quickstart-proxy.md`, `docs/setup.md` and `docs/hosted.md` all told evaluators to install a C toolchain and set `CGO_ENABLED=1`. That is true only for a `cg_skeleton` build. setup.md went further and named bifrost's tokenizer as a cgo dependency, which it is not — o200k_base is embedded (`internal/tokens/tokens.go`). Verified directly on go 1.26.4 rather than taken from the proposal: `CGO_ENABLED=0` with default tags builds all four release targets (linux/darwin x amd64/arm64, 27.1-33.5 MB stripped), `file` reports "statically linked", `ldd` reports "not a dynamic executable", the binary starts and answers /healthz, and `-tags cg_skeleton` fails under CGO_ENABLED=0 with the build-constraints signature — confirming tree-sitter is the only C dependency. - `.goreleaser.yaml`: a plain GOOS/GOARCH matrix, no cross-toolchains, no `brews:` block (the tap repo and release signing are an unowned question, and nothing may depend on a repo that does not exist). - `.github/workflows/release.yaml`: a tag publishes, `workflow_dispatch` builds the same matrix as a snapshot. It asserts the pure-Go claim with `CC=/nonexistent-c-compiler`. - `make build-static`. The Makefile keeps `CGO_ENABLED=1` because `go test -race` needs it, and the comment now says that is a test-time requirement — reading it as a shipping requirement is how the wrong claim reached the docs. The funnel's default, chosen so a stranger can verify the claim by reading one line rather than trusting four components. Not `safe`, whose extra components are lossless in meaning but still rewrite the JSON. Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the watchdog converge on the SAME teardown, so the self-killing path cannot drift from the one known to work. Two properties are load-bearing: - **The keep-alive inverts "idle."** Pinging is what the proxy does precisely while no client traffic arrives — the quiet gap after `end_turn`, where 83.7% of the recoverable dollars sit. A pending ping both vetoes the exit and RESETS the clock, so retiring the last ping buys a full fresh threshold rather than exiting moments later. - **Exit wipes the in-memory store.** `store.ValidateIdleExit` refuses anything below `max(2 x store.ttl_seconds, 1h)` at startup — ~5h34m at the default. 2x because the TTL is a sliding window. `NewMemory` now calls the same `Options.EffectiveTTL` the floor is computed from, so the two cannot drift. All five items from the proposal, under the `cache` preset. Four were already correct and are now pinned by tests; `POST /anthropic/v1/messages/count_tokens` was missing entirely — without it a client counts context by issuing INFERENCE requests, billed calls added by a proxy sold on removing them. Five places promised it did not: `config/config.go`, `docs/reference/presets.md`, `docs/how-to/choose-a-preset.md`, the plugin doc, and the install skill. Verified before fixing — `[Read Bash]` in, `[Read Bash context_guru_expand]` out on the real gateway route. Root cause was a code-vs-comment contradiction. `Options.InjectExpand` documented the gate as requiring "an expandable marker"; `expand/inject.go` says "No marker condition, deliberately" and the real conditions were mode, store-persists and has-tools. Nothing asked whether the pipeline could produce a marker at all. `components.Pipeline.HasOffload()` answers that by type assertion rather than a name list (a name list is a second copy of "which components are lossy" and drifts the moment somebody adds one). Under `auto`, injection now requires it. `always` still injects unconditionally — an operator who asks for it by name gets it. This also fixes `off`, the A/B control arm, which was carrying an extra tool declaration. Marker independence is preserved, which is the invariant that matters for cache stability: a pipeline does not change turn to turn, so the tools array stays byte-stable across a session. **Ten existing expand tests changed fixture, and that is worth reading.** They hand-seed the Store to simulate an offload, but built their handler with `pipeline: []` — a pipeline that cannot offload anything. That was harmless only while injection ignored the pipeline. They now use `offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies), so each fixture matches its own premise. No assertion was weakened. - **`--idle-exit` was defeated by any health probe** (finding 7). `/healthz` and `/metrics` no longer count as activity: a probe on a schedule shorter than the threshold meant the exit NEVER fired and logged nothing to say so — measured, a 1h-threshold proxy reporting "idle for 1h3m0s" after 2h03m. A dashboard poll still counts, deliberately: a probe is not a viewer, and exiting under somebody who is watching is the worse failure. - **A gateway may no longer self-terminate.** `--idle-exit` with `--upstreams` is refused at startup. That safety was previously accidental — it held only because hosted deployments run a liveness probe, which the change above stops counting. - **The floor's refusal was logged after "listening"**, so a rejected configuration read as a crash. Both refusals moved earlier and into one testable `checkIdleExit`. - **`--listen` and `--version` flags** (findings 2 and 12, which are the plugin's, but the flags are the core's). The address reached the process only through the environment, so no supervisor or `ps` could tell which port an instance held; and `buildinfo.Version` was reachable only via `/stats` on a running proxy, so an installer asking `--help` recorded "Usage of context-guru-proxy:" as the installed version. - **Nothing tested the shipped configuration** (finding 9). A tag published without running any tests, and CI runs the suite only with `CGO_ENABLED=1` — so `TestEveryPresetBuilds`, which guards exactly the CGO-free artifact, was never executed in that configuration. The release workflow now runs a CGO-off suite over the packages whose behaviour depends on which components are compiled in, plus the full suite, before publishing. It also asserts `--version` answers. - **`scripts/gate-a-purego.sh` was cited as proof in four places and is not in this PR** (finding 8). Those now cite the release workflow's own assert step, which exists here and fails the release if a cgo dependency escapes the `cg_skeleton` tag. - **The savings claim was measured in the wrong regime.** −34.1% / 96.7% comes from a harness running tasks back-to-back inside the provider's 5-minute TTL, and is one task measured three times; this project's own interactive figure is $0.0298 across 1,127 sessions, with 1,105 of 1,127 session starts reading zero from cache. Both are now stated, with the zero cases (outside a git repo, under the 1,024-token floor, non-Anthropic backend). The old citation pointed at `docs/results/context-guru.md`, which contains neither number. - **`count_tokens` behaviour kept, consequence documented** (finding 10). It answers about the ORIGINAL body — over-reporting is recoverable, under-reporting costs a failed turn when a fail-open component reverts. What was undocumented is the cost: the client self-compacts earlier than needed (115,933 reported vs 32,802 forwarded). Now in `docs/reference/routes.md`, where the route was absent entirely. Its **hosted branch was wholly untested** — that branch is all that stands between the multi-tenant service and an unmetered open forwarder — so it now has one. - `--idle-exit` and `--version` added to both flag tables; `bytes.NewReader` in counttokens. Six mutations, each proven to have landed in the source before its result was allowed to count: expand injection ungated (the defect) -> TestCachePresetAdvertisesNoExtraTool FAIL cache: sent [Read Bash], forwarded [Read Bash context_guru_expand] off: sent [Read Bash], forwarded [Read Bash context_guru_expand] HasOffload always true -> same test FAIL, same two subcases HasOffload always false -> FAIL on the offloader subcase: "mints markers but no longer advertises the expand tool, so a model cannot recover what it offloaded" probes count as activity again -> TestProbesDoNotDeferIdleExit FAIL ("two hours of nothing but liveness probes: idle past the threshold, but watchIdle never exited") gateway guard disabled -> TestCheckIdleExitRefusesAGatewaySelfTerminating FAIL count_tokens hosted auth removed -> TestCountTokensHostedRequiresAuth FAIL (502, want 401) The third mutation is the one worth noting: it proves the fix did not trade a silent defect for its mirror image, an offloader whose output nothing can expand. `go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> * ci: read the Go toolchain from go.mod everywhere (#152) CI validated on Go 1.25 while `go.mod` declared 1.26.4 and this PR's release workflow built on 1.26 — three numbers that have to agree, with nothing making them. That is a distribution bug, not a housekeeping one. `purego` asserts that the SHIPPED artifact builds with cgo off, and `release.yaml` builds the artifact people download; when those run different toolchains from each other and from the module, the assertion describes a build nobody ships. The `purego` job inherited the wrong pin from the job it was copied from, which is exactly how the drift spread in the first place. Fixed as a class rather than an instance: `go-version-file: go.mod` in all three places, so the module file is the single source of truth and the next toolchain bump moves CI, the release build and the module together or not at all. Typing `1.26` in three files would have fixed today's symptom and left tomorrow's. `check-latest` is dropped with the literals — it existed to pick up patch releases of a pinned minor, and go.mod names an exact version. Both workflows re-validated as YAML. The change can only really be proven by CI itself, which is where the previous mismatch was invisible. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> * fix(dist): act on the #141 review — monotonic idle clock, earlier refusal, honest comments Ten findings, all addressed. The pattern across most of them is the one this branch keeps rediscovering: a comment that describes something the code does not do. ## Behaviour **The idle clock lost its monotonic reading.** It stored `now.UnixNano()` and rebuilt the instant with `time.Unix(0, ns)`, which carries no monotonic reading — so `now.Sub(act.last())` was wall-clock arithmetic. A laptop suspend/resume or an NTP step counted as idleness, and the watchdog could fire on its first tick after a lid-open, racing the user's first request. On the laptop this feature exists for, suspend is the normal case. Now stores the `time.Time` itself. Fixing it broke the unstamped-clock backstop, and the existing test caught that: `time.Time{}`'s `UnixNano()` is a large NEGATIVE number, not zero, so the guard stopped firing and the watchdog exited immediately reporting "idle for 2562047h47m16s". The predicate is `IsZero()` now. **The refusal ran too late to be free.** `checkIdleExit` sat after the dashboard and control SQLite files are opened, and `log.Fatalf` calls `os.Exit`, which runs no defers — so `--idle-exit 30m` created and migrated both databases and then exited with WAL/-shm left behind. Moved to immediately after the config resolves, where everything it reads is already known. **The floor protected a store that need not exist.** `--store=false` resolves to `store.Nop`, which holds no frozen decisions, yet a short threshold was refused with a message about dropping them. Skipped when `Enabled` is explicitly false; `nil` (unconfigured) still means on. **`IDLE_EXIT=86400` silently meant "never".** `envDuration` discarded the parse error, and the `idle-exit armed` line is only logged above zero — so the operator's evidence was the ABSENCE of a line. A non-empty unparseable duration is now fatal, for every caller: each one is a timeout, a retention window or a process lifetime, and a typo in any of them changes behaviour nobody chose. **Requests are stamped on completion as well as entry.** Entry-only meant a long request looked like a gap in use the moment it finished. **A clamp that could never fire is gone.** `checkIdleExit` refuses anything under an hour, so `threshold/20` is always at least three minutes and the `< 30s` branch was unreachable — while the comment above it claimed the clamps prevented "a 1h floor meaning a check every three minutes", which is exactly what an hour yields. ## Comments that were not true - `stampActivity` claimed a long streaming response "cannot age out while it is still being served". It can: the clock is not refreshed DURING a request, so a lone SSE consumer past the threshold is severed by the shutdown `armShutdown` performs. Now states the residual and why it is not worth machinery (the dashboard polls every 30s, and the floor is an hour). - The `--idle-exit` flag comment said it and the resurrection hook "ship together". They do not: the hook is in the plugin PR. Anyone setting this by hand today gets a proxy that exits and stays exited, and the comment says so. - `.goreleaser.yaml` cited `scripts/install.sh` as the consumer of `checksums.txt`. That installer ships with the plugin; today the file is what a human curling a release should check by hand. ## Tests `recordedRequest` (mutex-guarded) replaces three unsynchronised captures the reviewer flagged in `conformance_test.go` and `counttokens_test.go`: a handler goroutine wrote them, the test goroutine read them, and an HTTP round trip is not a happens-before edge the memory model guarantees. Worth stating precisely: `-race` is clean before AND after, so this is a fix by INSPECTION, not one the detector demonstrated. A parallel sweep of the whole suite (28 more instances, its own PR) built a control proving the detector was live and that this shape is invisible to it — an in-process loopback round trip manufactures an edge through net/http's internals that the spec does not promise and a Go release can remove. The `10m -> 30s` interval case was labelled "clamped low" and asserted exactly `10m/20`, so it passed whether or not the clamp existed. Replaced with cases that pin the rule and the cap. New tests, each revert-verified against the pre-fix file with the mutation proven to have landed: Unix-nanos clock -> TestActivityClockKeepsItsMonotonicReading FAIL "the stored instant has no monotonic reading, so idleness is measured against the wall clock" entry-only stamp -> TestStampActivityRefreshesOnCompletion FAIL clock reads the moment the request STARTED, 20 minutes behind its completion floor always enforced -> TestCheckIdleExitSkipsTheFloorWithNoStore FAIL refused 30m with the store disabled, citing frozen decisions that cannot exist silent duration parse -> TestParseEnvDurationRefusesAUnitlessValue FAIL parseEnvDuration("86400") returned the default and no error `parseEnvDuration` is split out of `envDuration` so the decision is testable without a process that calls `os.Exit`. Two findings are verified by inspection only, and neither is testable without a subprocess harness: the refusal's new POSITION (it precedes every `Close`-deferring construction in main) and the three comment corrections. `go build ./...`, `go vet ./...`, `gofmt -l`, the full `go test ./...`, and `go test -race` over ./proxy/ and ./cmd/context-guru-proxy/ are all clean. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> * test: use #191's upstreamCapture instead of a second capture helper #191 landed a mutex-guarded `upstreamCapture` in package proxy_test while this branch carried `recordedRequest`, added for the same reason on the same review. Two near-identical helpers in one package is duplication a reader has to reconcile, so this drops mine. Theirs is the better of the two: it records every round with method, path, headers and body — so a test needing the second round's headers has somewhere to read them — and `record` returns the 1-based round number, which removes the captured counter a per-round handler would otherwise need. Mapping: rec.forwarded() -> up.body(1), rec.requestPath() -> up.round(1).path. The reason both existed is unchanged and worth keeping in view: a fixture handler runs on the test server's goroutine, and the HTTP round trip that follows is not a happens-before edge. Note that `-race` reports none of these, before or after — #191 established that with a control, so this is synchronisation by the memory model rather than by anything the detector demanded. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> * fix(dist): act on the second #141 review — three of these were my own fixes' blast radius Five findings plus two notes. The reviewer's framing is the useful one: three are consequences of the FIRST review's fixes, so they read as follow-ups rather than fresh ground. All three were mine, and in each case the fix was right and its blast radius was not measured. ## The `envDuration` fatal broke `--version` and `--help` Every call site is a default expression in main's `var (...)` block, which Go evaluates BEFORE `flag.Parse` — so exiting from inside that helper ran before the `--version` short-circuit existed to be reached. `IDLE_EXIT=86400 context-guru-proxy --version` exited 1 with a parse error: precisely the unitless mistake the fatal was added to catch, defeating the reason `--version` was added in the same round ("an installer must be able to ask what it is"), and failing the release workflow's own `--version | grep` gate in any shell exporting a bad value. It also died before `logging.Setup()`, so the message never reached CG_LOG_FILE. The blast radius was wider than IDLE_EXIT: the helper also backs UPSTREAM_HEADER_TIMEOUT, DASHBOARD_RETENTION and the three ARCHIVE_* windows, so a deployment carrying a malformed value for any of them refused to start where it had previously fallen back. Keep the loudness, move the moment: `envDuration` records the bad value and returns the default; `checkEnvDurations` refuses after `flag.Parse`, past the `--version` return, once the log sink exists and before anything is opened. All of them at once — an operator fixing one typo should not restart to discover the next. ## `/healthz/` was not exempt, so a trailing-slash probe kept the process alive forever An exact `r.URL.Path` match. `http.ServeMux` answers `/healthz/` with a 301 to `/healthz`, which a Kubernetes httpGet probe and most monitoring loops read as healthy — and because the stamp is taken before the mux sees the request, such a probe refreshed the activity clock indefinitely. Silently, with `idle-exit armed` as the only log line: the exact failure the previous round's fix was written for, reachable by adding one character to a probe URL. `stampActivity` now asks the mux which pattern matches rather than comparing the path. `ServeMux.Handler` reports, for an internally-generated redirect, the pattern that will match after following it — so `/healthz/` resolves to `/healthz` — and reports the EMPTY pattern for an unmatched path, so a 404 (a port scanner, a stray `/health`, a typo) is no longer mistaken for use. One question to the same matcher that will route the request, instead of a second copy of the rules. ## Skipping the floor with the store off removed it entirely, and could panic `ValidateIdleExit` was not called at all with `--store=false`, so no floor applied — not even the bare 1h term. `STORE=false --idle-exit=10ns` then reached `time.NewTicker` with `threshold/20 == 0`, which PANICS: an intended startup refusal became a crash. It also broke the invariant README and docs/reference/config.md state unconditionally. Only the `2 x store.ttl_seconds` term is about the store. The 1h minimum now always applies, and `idleCheckInterval` cannot return a non-positive value regardless of caller — a crash is the wrong failure mode for a helper, and the previous version of that function reasoned "checkIdleExit refuses anything under an hour" and was then reached with 10ns through the path I had just opened. ## And - `release.yaml` was missing `-p 1`, which `ci.yaml` one file over documents as the mitigation for a real flake (#163) on a 2-core runner. This workflow runs the full suite too, so the contention is at least as bad, and a tag push must not fail to publish for a diagnosed cause. - A stale duplicate doc line above `envDuration`, left by the previous edit. - `pendingPings`'s doc said "a live entry is by construction one we intend to ping". Nearly true: `sweep` also drops entries whose `pingable()` has gone false and only looks once `Idle` has elapsed, so inside that window this counts an entry that will never be pinged. Narrowed to "outside that window", with why erring toward counting is still right — over-counting delays an exit by minutes, under-counting kills the process in the gap the keep-alive exists to work in. - `docs/how-to/use-with-claude-code.md` now says what the no-API-key path actually hands over: the proxy receives the claude.ai OAuth credential on every request, retains it in memory for a tracked session when keep-alive is on, and those pings spend the same usage limits. The section below it is titled "Keep the API key out of Claude Code", so a reader could otherwise conclude the subscription path gives the proxy less. ## A test of mine argued for the defect The first attempt at this round failed on `TestCheckIdleExitSkipsTheFloorWithNoStore` — my own test from the previous round, whose NAME asserts the behaviour this review showed was wrong. It was deleted rather than edited into shape, and its still-valid cases folded into the test named for the corrected behaviour. A test named for a defect is worse than no test: it argues for the defect on every future read. ## Verification Three new tests, each revert-verified against the pre-fix file: exact-path probe match -> TestProbeExemptionSurvivesATrailingSlash FAIL on all five: /healthz/, /metrics/, //healthz, /health (404), /nope (404) all counted as activity floor skipped entirely -> TestCheckIdleExitKeepsTheOneHourMinimumWithNoStore FAIL accepted --idle-exit=10ns, 1ms and 30m with the store disabled no panic guard -> TestIdleCheckIntervalIsAlwaysPositive FAIL idleCheckInterval(0s) = 0s; time.NewTicker would panic Finding 1 is verified by reading rather than by test: asserting it needs a subprocess that runs the built binary with a bad env var and `--version`, which felt disproportionate — the fix is that the refusal now sits after `flag.Parse` and after the `--version` return, which is a position, not a behaviour a unit test can observe. `go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> * fix(dist): act on the third #141 review — a live hole, and two claims of mine that were false Five findings. One is a hole I left open, two are corrections to statements I wrote, and one would have destroyed a user's files. ## Bob mode defeated the probe exemption entirely `proxy.Mux` registers a `/` catch-all whenever `BobUpstream` is set, and `--bob-upstream` did NOT trigger the gateway refusal — only `--upstreams` did. So `--bob-upstream=… --idle-exit=24h` was an accepted configuration, and in it `mux.Handler` answers pattern `"/"` for every unmatched path: `/healthz/`, `/nope`, every port-scan path. `"/"` is not a probe route, so the clock was stamped and `--idle-exit` never fired — the same silent failure the previous round's `mux.Handler` fix was written to close, reachable through a different flag. Closed by construction: the gateway refusal now covers `--bob-upstream`, so a proxy with a catch-all cannot also have a watchdog, and the message names whichever flag the operator actually passed. Plus a catch-all exclusion in the stamp decision as belt to that braces — the two rules live in different files, and if they drift, over-counting `/` as "not use" errs toward exiting a laptop proxy rather than toward a gateway that never exits. ## The mechanism I documented is not what ServeMux does I wrote that ServeMux answers `/healthz/` with a 301 to `/healthz`, and that `Handler` reports "the pattern that will match after following the redirect". Both false: `cleanPath` re-appends a trailing slash and `matchOrRedirect` only ever ADDS one, so with this route table `/healthz/` is a plain 404 and `Handler` reports the EMPTY pattern — which is what exempts it, through the same branch that exempts `/nope`. The redirect Go does generate, for a subtree root, also reports an empty pattern. The behaviour was right; the justification was invented, and it was the justification for choosing `mux.Handler` over a path compare — so anything built on it later would have been wrong. Corrected in the comment and in three test case labels that were passing for a reason they did not document. ## The refusal's advice was backwards `ValidateIdleExit` told the operator to "raise store.ttl_seconds if the short lifetime is deliberate". The floor is `max(2*ttl, 1h)`, so raising the TTL RAISES the floor: follow the advice, get the same refusal with a larger number. It also credited the floor to `2x the store's %s entry lifetime` unconditionally — with `ttl_seconds: 30` it announced `floor of 1h0m0s (2x the store's 30s entry lifetime)`, and 2x30s is 1m. The single number the operator must act on was attributed to arithmetic that does not produce it. The message now says which of the two terms binds, offers only levers that lower the floor, and when the absolute term binds it says what the 2x-TTL floor would have been so the arithmetic is checkable. This path is startup-fatal, so the message is the only evidence anyone gathers. ## The documented untar would overwrite the user's own files `archives[0].files` puts LICENSE, README.md and THIRD-PARTY-NOTICES at the archive ROOT (GoReleaser defaults `wrap_in_directory` to false), and both the release footer and the quickstart tell the evaluator to run `tar xzf …` with no `-C`. In a project directory — where somebody evaluating a proxy for their coding agent is standing — that silently overwrites their README.md and LICENSE. `wrap_in_directory: true`, and both documented commands updated to `install -m 755 context-guru_*/context-guru-proxy`. Note for the plugin PR: its install.sh locates the binary at the archive root, so it needs the same change; handled there rather than left to fail at a stranger's install. ## And The README flag table never got the `--listen` row that landed in `docs/reference/config.md`, in the same table where `--idle-exit` and `--version` were added — so a README reader could not discover the flag whose stated reason for existing is discoverability. ## Verification Two new tests, plus label corrections: TestCheckIdleExitRefusesBobModeToo — the four flag combinations, and that the message names the flag actually passed TestCatchAllRouteIsNotActivity — with a `/` route registered, /healthz/ and /nope must still not count, while the explicit Bob and Anthropic routes must TestValidateIdleExitMessageNamesTheBindingTerm — asserts the message says "2x the store's" only when that term binds, offers LOWER rather than raise, and shows the 1m0s figure when the absolute term is doing the work `go test ./...` and `gofmt -l` clean. Findings 4 and 5 are documentation and packaging, verified by reading the rendered command and the table. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com> --------- Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
The defect class
A fixture handler runs on its
httptest.Server's own goroutine. The HTTP round trip thatfollows looks like it orders the handler's writes before the test goroutine's reads, but it is
not a happens-before edge the memory model recognises:
make coverruns the suite with-race, so these are latent.Audit
_test.gofiles sweptgo func)Method: an AST-ish scanner over every
_test.go— locate each func literal whose parametersinclude
http.ResponseWriteror which is launched withgo func, brace-match its body, collectplain assignments and
++/--to identifiers not declared inside it, then check whether theenclosing test function reads that identifier outside the closure. Every hit was then read by
hand; the scanner's false positives (declaration-only "reads", writes that never leave the
handler) were discarded rather than fixed.
The 16 already-correct ones are worth naming, because they are the in-repo precedent:
proxy/tenancy_test.go'shostedFixture(guardsseen/bodywithf.mu),proxy/modes_test.go'scaptureUpstream(mu.Lock+ a copying accessor), thewg.Wait()-joined per-index slice fills ininternal/extract/diag_test.goandmodes/modes_test.go,dash/sse_test.go'sdrained(never read outside its goroutine), andthe
atomic.Int64countersproxy/expandsplice_test.goalready uses in four places.Fixed here (29 writes, 11 files)
internal/cheapmodel/cheapmodel_test.goproxy/proxy_test.goupstreamCapture(9) +atomic.Int64(4)proxy/adjudicatetool_test.goupstreamCapture(3) +atomic.Int64(2)proxy/agentcompaction_test.goupstreamCaptureproxy/expandsplice_test.goupstreamCapture(3) +atomic.Int64(2)proxy/expandgate_test.goupstreamCaptureproxy/dashexpand_test.goatomic.Int64proxy/keepalive_test.goproxy/keepalive_wire_test.goproxy/prefixask_test.goforwarded()accessorproxy/tenancy_test.goNot fixed, deliberately
proxy/conformance_test.go(×2) andproxy/counttokens_test.go(×1) — the three thereviewer found; owned by feat(dist): pure-Go releases, a
cachepreset, --idle-exit, and gateway conformance #141. Untouched here.-proxy/expandsplice_test.go:140—callsis written in the handler but read only insideit, on rounds that the proxy issues sequentially. No cross-goroutine read, so not a finding.
Withdrawn — this was wrong, and is fixed in the review round below.
Fix shapes, and why
Three, picked so the edge is visible where a reader is already looking:
upstreamCapture(new,proxy/proxy_test.go, packageproxy_test) — a mutex-guarded[]upstreamRoundrecording method, path, cloned header and body per round, modelled directlyon
hostedFixture.record()returns the 1-based round number, so a handler that mustanswer differently per round reads its own count from the return value and the captured
counter disappears along with the captured body. This is where most of the sites are (bodies,
headers, "what did round 2 carry").
atomic.Int64for counter-only fixtures — carries its own edge, one-line change at thecall site, and already the established shape in
expandsplice_test.go.cheapmodel,keepalive,keepalive_wire,tenancy. In the two tests where "no request arrived" is itself theassertion (
keepalive_wire,tenancy), the receive is a non-blockingselect, so that casestays observable as the zero value instead of becoming a deadlock.
Naming: the helper is
upstreamCapture, deliberately notrecordedRequest— #141 adds arecordedRequesttoproxy/ccbody_test.goin the same package. The two cannot collide at mergein either ordering. The five internal-package (
package proxy) files are a separate namespaceand use local fixes.
Detector output — honestly, it did not fire
-racedoes not report this shape, before or after the fix. Concretely, on the unmodifiedtree:
So a revert-and-watch-it-fail verification is not available here, and I am not going to claim
one. What I did instead was establish that the detector was live and that the shape, not the
tests, is what it cannot see — with a standalone reproducer:
-count=50;plausibly return first: clean at
-count=200withGORACE=history_size=7;one variable — reported immediately:
The reproducer was scratch scaffolding and is not part of this branch.
Read-out: an in-process loopback round trip manufactures a happens-before edge through net/http's
shared internals (pooled bufio readers/writers and the atomics under them), which is enough to
blind TSan even though it is not a guarantee the memory model gives you. These are therefore
findings by inspection — unsynchronised per the memory model, not currently observable by the
detector. Practically that means low flake risk today and no promise about tomorrow: the edge is
an artefact of net/http's internals, not of anything the tests do, and it can change under us
with any Go release. The mutex, channel and atomic put the ordering in the test where it belongs.
Verification
go vet ./proxy/ ./internal/cheapmodel/— clean.gofmt -l proxy internal/cheapmodel— clean (empty).go build ./...— clean.go test -race ./proxy/ ./internal/cheapmodel/— passing.No behavioural change: every assertion reads the same bytes it read before, and the two
"nothing arrived" assertions are preserved by construction rather than by luck.
🤖 Generated with Claude Code
Review round (
90b602b)Six findings from the review, all addressed. Two changed a claim this PR was making rather than
just its code.
1.
expandsplice_test.go:136was a finding after all. The "not fixed, deliberately" entryabove said
callswas read only inside the handler. It is not:TestTheStreamedPrefixReachesTheClientBeforeTheExpandCalldrives two upstream rounds and each isserved from a goroutine of the
httptest.Server's own, so round 1'scalls++and round 2'scalls++/if calls > 1touch one variable from two goroutines — sequential, but ordered bynothing the memory model promises. That
-racestays quiet points at keep-alive reuse puttingboth rounds on the same conn goroutine, which is net/http's business and not an invariant a test
may lean on. Converted to
atomic.Int64and the comment now gives the real reason. The auditnumbers above are corrected accordingly (31 qualifying, 29 fixed).
2. The stale comparative comment at
:279. It read "atomic, unlike the counters in the testsabove" — a contrast this PR killed by making those counters atomics, and whose last plain
intwas finding 1. Reworded to stand alone.
3.
captureUpstreamduplicatedupstreamCapture. It is now implemented over it, via a newbodies()accessor; the narrow signature stays because its fifteen call sites only want thebodies in order, and
syncdrops out ofmodes_test.go. One place now holds thesynchronisation and the reasoning for it — which was the point of the helper.
4. The dead
_ = hdr.Get("x-api-key"). Written as the assertion it was reaching for: anOpenAI upstream must not receive Anthropic's header, least of all a copy of the key.
5. The lost failure diagnostic.
served()restores the dump, and aString()onupstreamRoundmakes it readable —%+von a[]bytefield prints decimal byte values, whichdefeats the point of dumping the body at all.
6. The nil dereference on the path the
selectexists to preserve.t.Errorf's argument wasevaluated unconditionally, so
got.Headeron a nil*http.Requestpanicked and took the testbinary down on exactly the "nothing arrived" outcome the non-blocking receive was added to keep
reportable. Pre-existing, but this PR is what made that path designed-for, so it is fixed here:
t.Fatalon nil before the header is read.Verification of the round
The two findings that added real assertions were revert-verified, not assumed:
Both restored and passing afterwards. The atomic conversion is not revert-verifiable, for the
reason this PR already documents at length:
-racedoes not fire on the shape before or after,so it stays a finding by inspection.
gofmt -l proxyclean,go vet ./proxy/clean,go build ./...clean,go test -race ./proxy/ ./internal/cheapmodel/passing.One unrelated defect found, and filed not patched
Re-running the suite at
-count=2fails inTestExtractEconomicsAreExportedandTestExpandUnresolvedSeriesRender— both assert on process-global metrics counters they incrementthemselves, so the
proxypackage cannot pass at-count>1. It reproduces identically onunmodified
origin/main, so it is not this branch's: filed as #192 with the cause and fix optionsrather than carried here. Worth knowing while reviewing a concurrency change, since
-count=Nisthe instrument you would otherwise reach for.