Skip to content

test(proxy): TestArriveCancelsAndReports waits on a counter published before the state it asserts #206

Description

@amiddavid

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions