Skip to content

test(router): wrap the clock stub once so a failing test cannot race the live engine - #621

Merged
FumingPower3925 merged 1 commit into
mainfrom
fix/620-rig-clock-race
Sep 14, 2026
Merged

FumingPower3925 merged 1 commit into
mainfrom
fix/620-rig-clock-race

Conversation

@FumingPower3925

Copy link
Copy Markdown
Contributor

Fixes #620. A failure of TestAdaptiveSettledRouteRetime592 no longer reports a phantom data race on top of the real reason.

The class fix is structural, not an ordering rule

The issue asked for an ordering fix — register the server shutdown after the stub so it runs before the restore. That works, but only until the next test forgets it. Instead nowNano is now wrapped exactly once, from an init(), before any test can have started a server; stubbing only flips atomics. The variable is never assigned again for the life of the test binary, so no present or future call site can reintroduce the racing write. Cost: one atomic load per call, test binary only, on a path that already runs only for promoted routes.

All four stubNowNano call sites were audited. Three drive the router in-process and had no hazard today; they are converted anyway, because the hazard is one New(...) away. The ordering fix is in as well, because the race was also telling us the rig leaked a live engine: the shutdown now genuinely joins Listen.

The discriminating control

Same deliberate t.Fatalf injected into the settled arm, engine live, on both trees, same container.

tree deliberate failures WARNING: DATA RACE
main 6 4
this branch 6 0

Twenty runs per engine, under load

Eight spinners plus a cold-cache go build ./... loop in the same cgroup, peak load average 14.7. The load bites: /ping max goes from 2 ms quiet to 72 ms, and the sampler collects about 460 samples per window instead of 900.

120 subtests, zero skips, zero data races, zero precondition failures, two workers in all 120. The #592 state assertion held 20/20 on both engines: promoted within the bound, at least eight promoted connections, worst promotion latency 5,489 ms against an 8,000 ms bound. Both controls inverted 40/40.

Fault 2 fired during the run, and it was worse than filed

One io_uring run shows warm_reqs=425 warm_depromotions=1: a warm-up run overran the blocking threshold, promotion cleared the fast streak, and the rig put the route back on the timed path so the streak could re-accumulate. The issue found this in negctrl_learning; it is in fact worse in the settled arm, where the clock is frozen, so on main a promotion there never expires and the route is off the timed path forever — that run could never have settled and would have burned the whole warm bound before failing. Both adaptive warm loops are fixed.

One fault deliberately not fixed here

The epoll settled arm failed 7 of 20 on this branch under that load, all on the same absolute 5 ms /ping bar. It is pre-existing and it is not measuring the engine: main under identical load fails the same way 2 of 10; both trees are 0 of 5 quiet; and decisively, negctrl_async — where every connection is async-dispatched and the worker is provably free — reaches a stalled fraction of 0.094 on the same box, passing only because the controls are judged on the median. A bar that a provably-free worker exceeds is measuring the scheduler, not the fix. Re-basing it changes what the test asserts, which is outside this issue, so it is filed separately.

…blish the controls' preconditions (celeris#620)

Two faults in the celeris#592 rig, both test-only; no engine or router
behaviour changes.

1. The clock stub was restored while the server was still serving, so
   EVERY failure of TestAdaptiveSettledRouteRetime592 also reported a
   data race. stubNowNano assigned the package var nowNano, and its
   restore ran from a t.Fatalf's runtime.Goexit while the event loops
   were still reading the same var through isPromoted -> adaptivePromoted
   -> routeAsync.

   Fixed structurally rather than by a cleanup-ordering rule, because a
   rule only holds until the next test forgets it: nowNano is wrapped
   exactly once, from a test-file init, before any test can have started
   a server, and stubbing now only flips atomics. nowNano is never
   written again for the life of the test binary, so no present or
   future call site can reintroduce the racing write. stubNowNano also
   takes *testing.T and releases via t.Cleanup, which runs after every
   defer in the test body.

   The rig additionally JOINS the engine on every exit path instead of
   only cancelling its context: stopEngine (registered after the stub,
   so LIFO runs it first) waits for StartWithListenerAndContext to
   return, which both native engines do only after wg.Wait() on their
   loops. A t.Fatalf no longer leaves two live workers running into the
   next subtest.

   Discriminating experiment: with a deliberate t.Fatalf injected into
   the settled arm, origin/main reports the failure AND "WARNING: DATA
   RACE" (stubNowNano.func2 at router_async_test.go:286, against
   isPromoted at router.go:340); this tree reports the failure and no
   race. 6 injected failures per tree, 4 race reports on main (the
   detector dedupes by stack), 0 here.

2. The controls' preconditions were jitter-sensitive. Every warm-up in
   this rig runs against a FAST store, so a promotion during warm-up is
   runner jitter -- handler.go calls promoteRouteImmediate on a SINGLE
   inline run over adaptiveBlockingThreshold, so one slow request under
   -race on a loaded box is enough, and because the rig freezes the
   promotion clock the TTL that would undo it never fires. That is the
   "precondition: /kv must still be learning after 100 runs
   (settled=false promoted=true)" failure in run 34863046623, and it is
   worse in the settled arm, where the same promotion also clears the
   fast streak and takes the route off the timed path so it can never
   settle.

   Both adaptive warm loops now drive such a promotion back out through
   the engine's own de-promotion transition (depromote589, the body of
   isPromoted's expiry branch) and count it on the result line as
   warm_depromotions. The assertion is not loosened: the preconditions
   are still asserted afterwards -- reaching one now means the
   de-promotion itself did not take -- and the controls' claims are
   untouched, so they still have to invert.

Verified in golang:1.27, --cpus 4, seccomp=unconfined, memlock 128 MiB.
20 runs per engine of the full rig under -race on a deliberately loaded
box (eight `yes > /dev/null` plus a cold-cache `go build ./...` loop):
120 subtests, workers=2 throughout, 0 skips, 0 data races, 0 precondition
failures, both controls CONTROL_OK 20/20 on both engines with the #589
claim failing in all 120, and the settled arm reporting FIXED. One
warm-up jitter promotion was caught and undone (iouring/settled,
warm_depromotions=1) -- the fault-2 trigger, now handled.

Still open, and NOT introduced here: the epoll settled arm's absolute
5 ms /ping bar measures the runner as much as the engine at that load
level. It failed 7/20 on this tree (stalled_frac 0.053-0.083) and 2/10
on origin/main under the same load, 0/5 on both when quiet, and the
sample count halves (~460 vs ~900) because the test's own client loop is
descheduled -- and the async control, whose worker is provably free,
reaches 0.094 on the same box and passes only because it is judged on
the median.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The #592 rig restores its clock stub while the server is live, so every failure also reports a data race

1 participant