Skip to content

test: synchronise every fixture-upstream capture a test goroutine reads - #191

Merged
amiddavid merged 2 commits into
mainfrom
fix/test-race-captures
Sep 3, 2026
Merged

test: synchronise every fixture-upstream capture a test goroutine reads#191
amiddavid merged 2 commits into
mainfrom
fix/test-race-captures

Conversation

@amiddavid

@amiddavid amiddavid commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

The defect class

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 recognises:

var forwarded []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    forwarded, _ = io.ReadAll(r.Body)   // SERVER goroutine
    ...
}))
resp, _ := http.Post(srv.URL+"/...", ...)
resp.Body.Close()
gjson.GetBytes(forwarded, "tools")       // TEST goroutine — no edge between the two

make cover runs the suite with -race, so these are latent.

Audit

_test.go files swept 281 (every one in the repo)
closures running on another goroutine (handler bodies + go func) 95
of those, writing a variable the enclosing test also reads 46
qualifying — no mutex / channel / WaitGroup / atomic between the accesses 31 (in 11 files)
already correct, left alone 16

Method: an AST-ish scanner over every _test.go — locate each func literal whose parameters
include http.ResponseWriter or which is launched with go func, brace-match its body, collect
plain assignments and ++/-- to identifiers not declared inside it, then check whether the
enclosing 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's hostedFixture (guards seen/body with f.mu),
proxy/modes_test.go's captureUpstream (mu.Lock + a copying accessor), the
wg.Wait()-joined per-index slice fills in internal/extract/diag_test.go and
modes/modes_test.go, dash/sse_test.go's drained (never read outside its goroutine), and
the atomic.Int64 counters proxy/expandsplice_test.go already uses in four places.

Fixed here (29 writes, 11 files)

file writes shape used
internal/cheapmodel/cheapmodel_test.go 5 buffered channel
proxy/proxy_test.go 13 upstreamCapture (9) + atomic.Int64 (4)
proxy/adjudicatetool_test.go 5 upstreamCapture (3) + atomic.Int64 (2)
proxy/agentcompaction_test.go 2 upstreamCapture
proxy/expandsplice_test.go 5 upstreamCapture (3) + atomic.Int64 (2)
proxy/expandgate_test.go 1 upstreamCapture
proxy/dashexpand_test.go 1 atomic.Int64
proxy/keepalive_test.go 1 buffered channel
proxy/keepalive_wire_test.go 1 buffered channel (non-blocking receive)
proxy/prefixask_test.go 1 mutex + forwarded() accessor
proxy/tenancy_test.go 1 buffered channel (non-blocking receive)

Not fixed, deliberately

  • proxy/conformance_test.go (×2) and proxy/counttokens_test.go (×1) — the three the
    reviewer found; owned by feat(dist): pure-Go releases, a cache preset, --idle-exit, and gateway conformance #141. Untouched here.
    - proxy/expandsplice_test.go:140calls is written in the handler but read only inside
    it, 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:

  1. upstreamCapture (new, proxy/proxy_test.go, package proxy_test) — a mutex-guarded
    []upstreamRound recording method, path, cloned header and body per round, modelled directly
    on hostedFixture. record() returns the 1-based round number, so a handler that must
    answer 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").
  2. atomic.Int64 for counter-only fixtures — carries its own edge, one-line change at the
    call site, and already the established shape in expandsplice_test.go.
  3. Buffered channel for one-shot struct captures — cheapmodel, keepalive,
    keepalive_wire, tenancy. In the two tests where "no request arrived" is itself the
    assertion (keepalive_wire, tenancy), the receive is a non-blocking select, so that case
    stays observable as the zero value instead of becoming a deadlock.

Naming: the helper is upstreamCapture, deliberately not recordedRequest#141 adds a
recordedRequest to proxy/ccbody_test.go in the same package. The two cannot collide at merge
in either ordering. The five internal-package (package proxy) files are a separate namespace
and use local fixes.

Detector output — honestly, it did not fire

-race does not report this shape, before or after the fix. Concretely, on the unmodified
tree:

$ go test -race -count=20 -run 'TestAnthropicBearerAuth|TestAnthropicDefaultAuthUsesAPIKey' ./internal/cheapmodel/
ok  	github.com/rossoctl/context-guru/internal/cheapmodel	1.139s

$ go test -race -count=10 -run '<the 15 affected proxy tests>' ./proxy/
ok  	github.com/rossoctl/context-guru/proxy	13.075s

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:

  • the canonical pattern (handler assigns, test reads after the round trip): clean at
    -count=50;
  • a harsher variant that assigns after the response is written and flushed, so the client can
    plausibly return first: clean at -count=200 with GORACE=history_size=7;
  • a control in the same package — two concurrent requests, so two server goroutines write
    one variable — reported immediately:
WARNING: DATA RACE
Read at 0x00c000018468 by goroutine 50:
  ...racerepro.TestSanityTwoHandlersRace.func1()
      /tmp/cg-src-racesweep/racerepro/sanity_test.go:19 +0xce
  net/http.HandlerFunc.ServeHTTP()
      /usr/local/go/src/net/http/server.go:2286 +0x47
Previous write at 0x00c000018468 by goroutine 47:
  ...racerepro.TestSanityTwoHandlersRace.func1()
      /tmp/cg-src-racesweep/racerepro/sanity_test.go:19 +0xe4
Goroutine 50 (running) created at:
  net/http/httptest.(*Server).goServe.func1()
      /usr/local/go/src/net/http/httptest/server.go:341 +0xb2

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:136 was a finding after all. The "not fixed, deliberately" entry
above said calls was read only inside the handler. It is not:
TestTheStreamedPrefixReachesTheClientBeforeTheExpandCall drives two upstream rounds and each is
served from a goroutine of the httptest.Server's own, so round 1's calls++ and round 2's
calls++ / if calls > 1 touch one variable from two goroutines — sequential, but ordered by
nothing the memory model promises. That -race stays quiet points at keep-alive reuse putting
both rounds on the same conn goroutine, which is net/http's business and not an invariant a test
may lean on. Converted to atomic.Int64 and the comment now gives the real reason. The audit
numbers above are corrected accordingly (31 qualifying, 29 fixed).

2. The stale comparative comment at :279. It read "atomic, unlike the counters in the tests
above"
— a contrast this PR killed by making those counters atomics, and whose last plain int
was finding 1. Reworded to stand alone.

3. captureUpstream duplicated upstreamCapture. It is now implemented over it, via a new
bodies() accessor; the narrow signature stays because its fifteen call sites only want the
bodies in order, and sync drops out of modes_test.go. One place now holds the
synchronisation 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: an
OpenAI 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 a String() on
upstreamRound makes it readable — %+v on a []byte field prints decimal byte values, which
defeats the point of dumping the body at all.

6. The nil dereference on the path the select exists to preserve. t.Errorf's argument was
evaluated unconditionally, so got.Header on a nil *http.Request panicked and took the test
binary 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.Fatal on nil before the header is read.

Verification of the round

The two findings that added real assertions were revert-verified, not assumed:

# x-api-key — OpenAI route's setKey mutated to also set the header
proxy_test.go:377: gateway sent x-api-key to an OpenAI upstream: "real-openai-key"

# nil guard — handler's `seen <- r.Clone(...)` dropped to simulate a request that never arrives
tenancy_test.go:517: single-tenant request never reached the upstream
#   ...where the pre-fix assertion instead panics inside t.Errorf's argument at tenancy_test.go:519

Both restored and passing afterwards. The atomic conversion is not revert-verifiable, for the
reason this PR already documents at length: -race does not fire on the shape before or after,
so it stays a finding by inspection.

gofmt -l proxy clean, 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=2 fails in TestExtractEconomicsAreExported and
TestExpandUnresolvedSeriesRender — both assert on process-global metrics counters they increment
themselves, so the proxy package cannot pass at -count>1. It reproduces identically on
unmodified origin/main, so it is not this branch's: filed as #192 with the cause and fix options
rather than carried here. Worth knowing while reviewing a concurrency change, since -count=N is
the instrument you would otherwise reach for.

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 amiddavid left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:56 drained — declared outside, but genuinely read only inside the goroutine. Correctly left alone.
  • proxy/counttokens_test.go:21 gotPath — 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's captureUpstream, tenancy_test.go's hostedFixture, prefixask_test.go — guarded, correct.
  • The only extra hit my scanner produced (adjudicatetool_test.go stream) is a false positive: the "stream": true map 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 covergo 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.

Comment thread proxy/proxy_test.go
//
// 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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 90b602bcaptureUpstream 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread proxy/proxy_test.go Outdated
t.Fatalf("gateway should inject the real key, upstream saw %q", gotAuth)
}
_ = gotXAPI
_ = hdr.Get("x-api-key")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread proxy/proxy_test.go Outdated
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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread proxy/tenancy_test.go
}
// With no key configured the client's own auth passes through, as documented.
var got *http.Request
select {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Addressed in 90b602b. Thanks for the review — the two findings in the body were the valuable ones, because both were about a claim the PR was making rather than about its code.

Finding 1 — expandsplice_test.go:136. You are right and my "not fixed, deliberately" justification was wrong. I wrote that calls was read only inside the handler; it is written by round 1's handler goroutine and read by round 2's, which is a cross-goroutine access under exactly the criterion this PR applies everywhere else. I took the convert option rather than the restate one: -race's silence most likely means keep-alive reuse is putting both rounds on one conn goroutine, and "net/http happens to pool the connection" is not a property a test should depend on — it is the same class of accidental edge the whole PR is about, just arriving by a different route. It is now atomic.Int64 with the honest reason in the comment, and the audit table is corrected (31 qualifying, 29 fixed) with the withdrawn entry struck through rather than deleted, since it is part of the record.

Finding 2 — the stale comment at :279. Reworded to stand on its own terms. Worth noting it was stale in both halves: the counters "above" are now atomics, and the one plain int left among them was finding 1, so after that conversion there was no contrast left to draw at all.

The four inline findings are answered on their own threads. Two of them (the x-api-key assertion, the nil guard) added real assertions, so both were revert-verified rather than asserted to work — output on the threads.

One thing you will want to know before re-running the suite

Re-running at -count=2 gave me two failures that are not this branch's: TestExtractEconomicsAreExported and TestExpandUnresolvedSeriesRender both assert on process-global metrics counters they increment themselves (metrics.xReg, expand.Unresolved()), so the second run in a process reads the first's residue. Reproduces identically on unmodified origin/main, so I filed it as #192 with the cause and fix options rather than carrying it here.

It is not cosmetic in context: -count=N is the instrument for shaking out a race, and it is the one this PR's own write-up leans on. On the proxy package it currently fails for unrelated reasons, so a genuine intermittent failure would be hidden in noise that looks just like it.

Full -race suite at -count=1 is green (proxy 188s, internal/cheapmodel 1.6s), gofmt/vet/build clean.

Assisted-By: Claude Opus 5 (1M context) noreply@anthropic.com

@amiddavid

Copy link
Copy Markdown
Collaborator Author

CI green on 90b602b

check result
build-test (make covergo 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 callsatomic.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

Nothing else from the review is outstanding. Ready for another look.

Assisted-By: Claude Opus 5 (1M context) noreply@anthropic.com

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Re-reviewed at 90b602b. All six findings addressed; nothing new, and I have no remaining objection to merging. Per-thread confirmations are on the four inline threads; the two body findings and three notes follow.

Body finding 1 — expandsplice_test.go:136

Verified: calls is now atomic.Int64, and the replacement comment gives the real reason rather than the one I disputed — "which conn goroutine serves round 2 is net/http's business, not something the test may assume". That is the accurate framing: the point was never that a race is likely, it is that the test was relying on an invariant net/http does not offer it. Correcting the audit table to 31/29 with the withdrawn entry struck through rather than deleted is the right call — the reasoning that was wrong stays legible.

Agreed that this conversion is not revert-verifiable, and saying so beats manufacturing a verification. Worth recording why, since it is the same reason the whole PR is unverifiable that way: TSan orders accesses by vector clock, not by timing luck, so a genuinely unordered pair would be reported deterministically. Green -race therefore says an edge did exist in practice — via connection reuse or net/http's shared internals — not that the detector got lucky. That is exactly the "findings by inspection" claim the PR opened with, and it survives the round trip intact.

Body finding 2 — expandsplice_test.go:279-281

Verified: reworded to stand on its own facts ("not even a completed round trip stands between the handler's write and the assertion's read") with no dependency on what the neighbouring tests happen to use. That also fixes the half of the staleness I had not separated out — the comment was wrong about the counters above it in both directions at once.

On #192

Confirmed independently, by inspection rather than by running it: TestExtractEconomicsAreExported calls metrics.RecordExtractionSuppressed / RecordExtractionCacheLookup against the process-global registry and then asserts cg_extract_calls_total{outcome="suppressed"} 1, so a second iteration in the same binary reads 2 and the substring match fails. proxy/promexport_components_test.go is untouched by this branch (last modified in 72eaee8), so filing it rather than patching it here is correct scoping — it is a main defect found on a feature branch.

It does bound what a concurrency review of this package can claim, and that cuts both ways: -count=N is the natural instrument for this defect class, and it is unavailable on ./proxy/ for reasons unrelated to any of these fixes. Reviewers of #192 should know that repeat-run coverage of the whole package is what it unblocks, not just those two tests.

One correction to the handoff note

The branch is not behind. origin/main is 51fcd91, which is this branch's parent — 3fabf51 is a local-only ref in the primary checkout, not on the remote. So there is nothing to rebase onto and no divergence to reconcile; CI is green against a current base, not a stale one.

Verification state at 90b602b

  • build-test (make covergo test -race ./...) pass, 12m38s; purego, trivy, DCO pass.
  • Re-checked by hand: no reintroduced unsynchronised capture in the four touched files; modes_test.go's import list is consistent with its new body; %+v on []upstreamRound resolves through String(); got cannot be nil past the select in tenancy_test.go.
  • Still no Go on this reviewer's box, so everything here is inspection plus CI, as before.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

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 51fcd91, which is this branch's parent, exactly as you say. 3fabf51 is not a divergent local commit ahead of it, as my note implied — it is a stale ancestor: a local branch in the primary checkout that had not been fetched for ten commits (3fabf51..51fcd91 spans #178, #183, #184, #185 among others). That is what misled me, and it is why the note was framed backwards: there was never anything to rebase onto, and no divergence.

The sharper consequence is for #192. Both tests it names were added after 3fabf51promexport_components_test.go and promexport_coverage_test.go do not exist in that tree at all, having arrived with #184's metrics/grafana work. So "this is on main today (3fabf51)" was not merely imprecise, it named a tree in which the defect cannot be observed.

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 51fcd91 — not from the stale local branch — and independently against this PR's head 999e1bf, whose parent is also 51fcd91. Both failed identically. So the finding and the cause hold; only the SHA quoted in the prose was wrong. #192's body is corrected, with the error and its scope recorded there rather than silently overwritten.

Noted on the two substantive points:

  • Your framing of why green -race is not luck is better than mine and I will use it: TSan orders by vector clock, so a genuinely unordered pair is reported deterministically — green therefore says an edge did exist in practice, via connection reuse or net/http's internals, not that the detector missed it. That is a stronger statement of "findings by inspection" than the PR body currently makes, and it is the reason the atomic conversion is unverifiable by reverting rather than an excuse for it.
  • Agreed on test: proxy package cannot run at -count>1 — two tests assert on process-global metrics counters #192's scope: what it unblocks is repeat-run coverage of the whole proxy package, not those two tests. I have not widened its title to claim more than was demonstrated, but the fix-options section is written against the package-level goal.

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

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Correcting my own base note above, because it was wrong about the mechanism.

I wrote that 3fabf51 is "a local-only ref in the primary checkout, not on the remote." That is false. git merge-base --is-ancestor 3fabf51 51fcd91 succeeds: the commit is on the remote, as an ancestor of the default branch. It is a stale local branch, not an unpushed one — the opposite failure mode from the one I named, and someone reading my sentence later would have gone looking for an unpushed commit that does not exist.

The operative conclusion is unchanged and now rests on the right fact: origin/main is 51fcd91, this branch's parent, so there is nothing to rebase and no divergence.

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 3fabf51, so the tree named in #192's original body genuinely could not exhibit the defect, and the reproduction stands because it was run against the remote ref and against 999e1bf's parent, both 51fcd91. I have put the same two corrections on #192 so the issue's own record carries them.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

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:

measure count
total commits 78
first-parent commits 60
first-parent subjects ending (#N) 57
actual merge commits (--merges) 3

The repo squash-merges, so "PR merges" here are non-merge first-parent commits ending in (#N)57. There are only 3 true merge commits in the range. So the figure is somewhere between 57 and 60 depending on what you meant, and 62 exceeds even the first-parent total.

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 state

Everything material is agreed and unchanged: the defect, its cause, the reproduction against 51fcd91 and 999e1bf, the absence of both files at 3fabf51, and all six review findings on this PR. Two things improved on the way through — your mechanism correction (stale ancestor, not unpushed commit) and your TSan framing, which is a better statement of the PR's own core claim than the body currently makes.

Assisted-By: Claude Opus 5 (1M context) noreply@anthropic.com

@amiddavid

Copy link
Copy Markdown
Collaborator Author

My 62 of them first-parent PR merges two comments up is wrong — it exceeds the first-parent total, so it could not have been a subset of anything. Exact on 3fabf51..51fcd91: 78 commits, 60 first-parent, 57 first-parent subjects ending (#N), 3 true merge commits. The repo squash-merges, so 57 is the right figure for PR landings by subject. Corrected in full on #192, where the same figure sits in the permanent record. Nothing else depends on it: 78 is what carries the stale-ancestor point, and that one is confirmed.

@amiddavid
amiddavid merged commit 69cb2be into main Sep 3, 2026
4 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Sep 3, 2026
amiddavid added a commit that referenced this pull request Sep 3, 2026
#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>
amiddavid added a commit that referenced this pull request Sep 3, 2026
#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>
OsherElhadad pushed a commit that referenced this pull request Sep 3, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants