TestArriveCancelsAndReports waits on a counter that is published ~90 lines before the state it asserts, so it fails under load.
Seen on CI for #160 (a plugin-only PR that touches no file under proxy/):
--- FAIL: TestArriveCancelsAndReports (0.03s)
keepalive_test.go:413: refreshed = 0, want the ping's own cache_read of 48576
FAIL github.com/rossoctl/context-guru/proxy 220.284s
Mechanism
waitPings polls k.pings:
// keepalive_test.go:742
for time.Now().Before(deadline) {
if k.pings.Load() >= n { return }
...
}
k.pings.Add(1) happens at keepalive.go:899, immediately after k.send() returns:
u, status, err := k.send(j, body)
ms := float64(k.now().Sub(start).Microseconds()) / 1000.0
k.pings.Add(1) // <-- the wait is satisfied HERE
But the value the test asserts is written ~90 lines later in the same flow, at keepalive.go:989, after the error check, the dash.Event construction and ev.Price():
k.mu.Lock()
e.spent += cost
e.refreshed = u.CacheRead // <-- the state under test appears HERE
k.mu.Unlock()
So the wait returns strictly before the state exists, and arrive() reads the zero value. refreshed = 0 in the failure is exactly the pre-write value — the assertion is sound, the wait is wrong.
Demonstrated, not inferred
It does not reproduce on demand locally: 60 runs under -race, then 200 more at -cpu=1, all green. Rather than assert the mechanism from reading, I widened the suspected window — a 200ms sleep inserted between those two points:
PROBE LANDED: 200ms inserted between pings.Add(1) and the e.refreshed store
--- FAIL: TestArriveCancelsAndReports (0.00s)
keepalive_test.go:413: refreshed = 0, want the ping's own cache_read of 48576
VERDICT: gap CONFIRMED — waitPings returns before e.refreshed is written
restored byte-identical: True
Same failure, same line, same value. (proxy/keepalive.go was restored and hash-verified; nothing was committed.)
Why it surfaced on a plugin PR
Contention, not a code dependency. make cover runs go test -race ./..., scheduling packages concurrently on a 2-core runner, and #160 adds a deliberately wall-clock-heavy package (~21s, mostly two tests holding a stalling socket ~11s each while curl waits out its timeouts). That widens an existing window; it does not create one. main is green on its recent runs, so this is a latent defect on main that #160 makes easier to hit — it will recur on unrelated PRs.
Scope
Small: 7 waitPings call sites, 2 followed by arrive(). Only keepalive_test.go:406 asserts late-block state. The other (:441) asserts strategyID, written at keepalive.go:661 at entry creation, so it is unaffected. One test, one wait.
The fix belongs on the test side — wait for the state actually under test rather than for a counter that merely precedes it. Moving k.pings.Add(1) to the end of the flow would fix the symptom and break its meaning: it counts pings sent, which is what the failure and skip branches below it depend on.
One property worth carrying into the fix
-race cannot catch this. Both accesses are properly mutex-guarded, so the bug is ordering, not unsynchronised memory — the intermittent failure is the only signal there will ever be. Adding -count or -race to CI would not have surfaced it earlier.
Is there a production consequence? arrive() is on the real request path, so a request landing inside that window reports refreshed = 0 in its compact row — an understated audit figure rather than a correctness problem, and narrow. Worth noting on the fix, not worth widening the issue for.
Happy to take this; it is out of scope for #160 by design, so it wants its own branch off main.
TestArriveCancelsAndReportswaits on a counter that is published ~90 lines before the state it asserts, so it fails under load.Seen on CI for #160 (a plugin-only PR that touches no file under
proxy/):Mechanism
waitPingspollsk.pings:k.pings.Add(1)happens atkeepalive.go:899, immediately afterk.send()returns:But the value the test asserts is written ~90 lines later in the same flow, at
keepalive.go:989, after the error check, thedash.Eventconstruction andev.Price():So the wait returns strictly before the state exists, and
arrive()reads the zero value.refreshed = 0in the failure is exactly the pre-write value — the assertion is sound, the wait is wrong.Demonstrated, not inferred
It does not reproduce on demand locally: 60 runs under
-race, then 200 more at-cpu=1, all green. Rather than assert the mechanism from reading, I widened the suspected window — a 200ms sleep inserted between those two points:Same failure, same line, same value. (
proxy/keepalive.gowas restored and hash-verified; nothing was committed.)Why it surfaced on a plugin PR
Contention, not a code dependency.
make coverrunsgo test -race ./..., scheduling packages concurrently on a 2-core runner, and#160adds a deliberately wall-clock-heavy package (~21s, mostly two tests holding a stalling socket ~11s each whilecurlwaits out its timeouts). That widens an existing window; it does not create one.mainis green on its recent runs, so this is a latent defect on main that #160 makes easier to hit — it will recur on unrelated PRs.Scope
Small: 7
waitPingscall sites, 2 followed byarrive(). Onlykeepalive_test.go:406asserts late-block state. The other (:441) assertsstrategyID, written atkeepalive.go:661at entry creation, so it is unaffected. One test, one wait.The fix belongs on the test side — wait for the state actually under test rather than for a counter that merely precedes it. Moving
k.pings.Add(1)to the end of the flow would fix the symptom and break its meaning: it counts pings sent, which is what the failure and skip branches below it depend on.One property worth carrying into the fix
-racecannot catch this. Both accesses are properly mutex-guarded, so the bug is ordering, not unsynchronised memory — the intermittent failure is the only signal there will ever be. Adding-countor-raceto CI would not have surfaced it earlier.Is there a production consequence?
arrive()is on the real request path, so a request landing inside that window reportsrefreshed = 0in its compact row — an understated audit figure rather than a correctness problem, and narrow. Worth noting on the fix, not worth widening the issue for.Happy to take this; it is out of scope for #160 by design, so it wants its own branch off
main.