Skip to content

Commit 796816c

Browse files
cansofgreaseclaude
andcommitted
Stop bufferbloat readings being fabricated by dropped handshakes
Bufferbloat is measured by timing TCP handshakes to a fixed target, once while the link is quiet and again while a speedtest saturates it; the difference is the queueing delay. When a handshake's first packet is lost, the system waits about a second and tries again, so the sample comes back reading roughly 1000ms whatever the link is really doing. That was poisoning the baseline. A quiet-link burst that collected enough of those retransmits produced a baseline near a second, and because the reported figure is the loaded time minus the baseline, floored at zero, every honest loaded reading then came out as exactly zero bufferbloat - not missing, but confidently wrong. Retransmit-shaped samples are now discarded before the baseline is taken, and a burst with no honest sample left in it reports nothing at all rather than a fabricated number. The test cares about slow links specifically: a satellite connection whose real samples sit near 600ms keeps every one of them, because a sample is only judged against the fastest one in its own burst. The reason so many handshakes were being lost is that the target is reached over whichever address family answers first, and on a connection where one family is losing packets that family still wins the race about half the time - then stays cached for as long as the daemon runs. It is now measured with a short burst before it is trusted, and if it looks lossy the other family is measured too and the cleaner one is used. On the connection this was found on, the IPv6 path was dropping about 44% of handshakes while IPv4 was clean. The measuring cannot be cancelled once started, since a half-finished burst would judge a link on evidence its own caller destroyed - so stopping a speedtest now abandons it instead of waiting for it, and nothing is remembered from an abandoned one. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5f6af1d commit 796816c

9 files changed

Lines changed: 1471 additions & 121 deletions

File tree

README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -640,8 +640,12 @@ or upload-only run has no figures for the direction it skipped, packet loss is
640640
optional and not always measurable, family and probe direction are recorded
641641
only when the run really established them (the engine notes below say when
642642
that is), and bufferbloat is absent when a transfer
643-
phase was too short to sample, returned too few samples, or the latency target
644-
was unreachable. Missing is stored as missing rather than as a zero, so charts
643+
phase was too short to sample, returned too few samples, the latency target
644+
was unreachable, or too few idle probes survived the retransmit filter to leave a
645+
baseline. That last case drops the figure on purpose: a single retransmit in the
646+
idle number is worth about a second, enough on its own to cancel real bloat down
647+
to zero, so a polluted baseline would report a clean link instead of an
648+
unmeasurable one. Missing is stored as missing rather than as a zero, so charts
645649
and thresholds can tell "not measured" from "measured, and it was bad". A run
646650
that failed outright isn't a measurement at all: it is kept only as a flagged
647651
data-usage row, which every measurement view filters out (see the data-usage
@@ -678,14 +682,26 @@ flowchart LR
678682
queued --> bloat["bufferbloat = 190 - 24<br/>= +166 ms under load"]
679683
```
680684
681-
Both figures are **medians** of their probes, and the headline bufferbloat number
682-
- the one the tiles show and the one your **max bufferbloat** threshold is
685+
Both figures are **medians** of their probes - the idle one over only the probes
686+
that survive a retransmit filter, since on an unloaded link a sample more than
687+
500 ms above that burst's own minimum is an OS retry rather than latency. A burst
688+
whose own fastest probe is already at or above a second holds no honest sample to
689+
measure the rest against, and yields no baseline at all. The loaded phases keep
690+
theirs, where a near-second sample is the bloat itself. The headline bufferbloat
691+
number - the one the tiles show and the one your **max bufferbloat** threshold is
683692
compared against - is `median(loaded) - median(idle)`. The chart also plots a
684693
**p95** per direction, the sustained bad end of the distribution. p95 is
685694
deliberately not the maximum: these are TCP-connect probes, and a single worst
686695
sample on one is usually a SYN retransmission (a fixed ~1000 ms OS retry, and
687696
~2000 ms for a second one) rather than queue delay, so a max-based number
688-
reports round figures that say more about packet loss than about buffering.
697+
reports round figures that say more about packet loss than about buffering. The
698+
probes go to a fixed dual-stack name, and the address family that wins their
699+
connection race is not taken on trust - a path that drops half its handshakes
700+
still wins races constantly, and its retries would land in the baseline. So the
701+
winner is graded with a short burst first, and only if that burst comes back
702+
lossy is the other family resolved and measured, taking the job only if it grades
703+
cleaner. A host reachable in just one family keeps it however lossy: lossy data
704+
beats none.
689705
690706
There are two engines, picked in the settings drawer:
691707

internal/speedtest/iperf.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ func (i *Iperf) Run(ctx context.Context) (Result, error) {
584584
// reject) - version caveats are surfaced to the UI via IperfVersion, not gated
585585
// here (see iperfArgs).
586586
res := Result{Engine: "iperf3", Server: name}
587-
probeAddr := lulRunEndpoint()
587+
probeAddr := lulRunEndpoint(ctx)
588588
res.IdleMS = measureIdleLatency(ctx, probeAddr)
589589
// Unloaded ping to the server, taken now while the link is idle (see measureServerRTT
590590
// for why iperf3's own min_rtt isn't trusted here).

internal/speedtest/lul.go

Lines changed: 348 additions & 89 deletions
Large diffs are not rendered by default.

internal/speedtest/lul_test.go

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ func TestMedian(t *testing.T) {
2929
// The loaded sampler must return nil when the phase is too short or yields too
3030
// few samples - a number from an unsaturated instant would be misleading.
3131
func TestLoadSamplerGating(t *testing.T) {
32+
origFails := lulFails
33+
t.Cleanup(func() { lulFails = origFails })
3234
// Real network unavailable in tests isn't assumed; we only exercise the
33-
// gating path: stop immediately, far under both thresholds.
35+
// gating path: stop immediately, far under both thresholds. The empty address
36+
// is the offline part - the dial fails without touching a resolver.
3437
stop := startLoadSampler(context.Background(), "")
3538
time.Sleep(50 * time.Millisecond)
3639
if got := stop(); got != nil {
@@ -43,11 +46,23 @@ func TestLoadSamplerGating(t *testing.T) {
4346
// (The old sync.Once cached the miss and then dialed the hostname - paying DNS in
4447
// every timed handshake - for the whole process.)
4548
func TestLulDialAddrRetriesAfterResolveFailure(t *testing.T) {
46-
origTarget, origResolved, origDial := lulTarget, lulResolved, lulResolveDial
47-
t.Cleanup(func() { lulTarget, lulResolved, lulResolveDial = origTarget, origResolved, origDial })
49+
origTarget, origResolved, origFails, origDial := lulTarget, lulResolved, lulFails, lulResolveDial
50+
origBurst, origFam := lulProbeBurst, lulFamilyDial
51+
t.Cleanup(func() {
52+
lulTarget, lulResolved, lulFails, lulResolveDial = origTarget, origResolved, origFails, origDial
53+
lulProbeBurst, lulFamilyDial = origBurst, origFam
54+
})
4855

4956
lulTarget = "lul.invalid:443" // a hostname, so lulDialAddr takes the resolve path
50-
lulResolved = ""
57+
lulResolved, lulFails = "", 0
58+
// Selection is not under test here: report the candidate clean so the
59+
// caching semantics stay the subject, and keep both seams off the network.
60+
lulProbeBurst = func(context.Context, string, int, time.Duration) ([]float64, int) {
61+
return []float64{1, 1, 1, 1, 1}, 0
62+
}
63+
lulFamilyDial = func(context.Context, string, string) (net.Conn, error) {
64+
return nil, errors.New("not under test")
65+
}
5166
calls := 0
5267
lulResolveDial = func(context.Context, string) (net.Conn, error) {
5368
calls++
@@ -58,47 +73,111 @@ func TestLulDialAddrRetriesAfterResolveFailure(t *testing.T) {
5873
}
5974

6075
// 1st: resolve fails -> hostname fallback, nothing cached.
61-
if got := lulDialAddr(); got != lulTarget {
76+
if got := lulDialAddr(context.Background()); got != lulTarget {
6277
t.Fatalf("after failed resolve = %q, want hostname fallback %q", got, lulTarget)
6378
}
6479
// 2nd: the miss was NOT cached, so the resolve retries and now succeeds.
65-
if got, want := lulDialAddr(), "203.0.113.7:443"; got != want {
80+
if got, want := lulDialAddr(context.Background()), "203.0.113.7:443"; got != want {
6681
t.Fatalf("after recovered resolve = %q, want resolved literal %q", got, want)
6782
}
6883
// 3rd: served from cache, no further dial.
69-
if got, want := lulDialAddr(), "203.0.113.7:443"; got != want {
84+
if got, want := lulDialAddr(context.Background()), "203.0.113.7:443"; got != want {
7085
t.Fatalf("cached resolve = %q, want %q", got, want)
7186
}
7287
if calls != 2 {
7388
t.Fatalf("resolve dialed %d times, want 2 (one failed retry, then one cached success)", calls)
7489
}
7590
}
7691

77-
// A cached resolve must not outlive the stack it belongs to: after
78-
// lulFailInvalidate consecutive failed connects the literal is dropped so the
79-
// next sample re-resolves (the first resolve picked IPv6, IPv6 later died while
80-
// IPv4 still works). A successful connect resets the streak.
81-
func TestLulNoteConnectDropsDeadCache(t *testing.T) {
82-
origResolved, origFails := lulResolved, lulFails
83-
t.Cleanup(func() { lulResolved, lulFails = origResolved, origFails })
92+
// A RUN PINS ONE ENDPOINT EVEN WHEN THE RESOLVE FAILS. Handing back "" would
93+
// leave every sample to resolve for itself: on an idle link that is a whole
94+
// family selection per probe, so the idle burst spends its budget on failed
95+
// selections instead of probes and reports no baseline at all; inside a load
96+
// phase it is a DNS lookup in every timed handshake, inflating the bufferbloat
97+
// number being measured. The hostname is the honest fallback - worse than a
98+
// literal, but one name for every sample of the run.
99+
func TestLulRunEndpointPinsHostnameWhenResolveFails(t *testing.T) {
100+
lulSelectSeams(t)
101+
resolves := 0
102+
lulResolveDial = func(context.Context, string) (net.Conn, error) {
103+
resolves++
104+
return nil, errors.New("simulated resolve failure")
105+
}
106+
lulProbeBurst = func(context.Context, string, int, time.Duration) ([]float64, int) {
107+
t.Error("validated a candidate although the resolve failed")
108+
return nil, lulSelectProbes
109+
}
110+
lulFamilyDial = func(context.Context, string, string) (net.Conn, error) {
111+
return nil, errors.New("no candidate: must not be reached")
112+
}
113+
114+
if got := lulRunEndpoint(context.Background()); got != lulTarget {
115+
t.Fatalf("lulRunEndpoint = %q with the resolve failing, want the hostname %q: \"\" "+
116+
"leaves every sample to resolve for itself", got, lulTarget)
117+
}
118+
// And a sample dials what it was handed, whatever the cache holds: no
119+
// selection, no DNS, nothing but the timed handshake.
120+
before := resolves
121+
if _, ok := connectRTT(context.Background(), ""); ok {
122+
t.Fatal("connectRTT reported a successful sample without an address to dial")
123+
}
124+
if resolves != before {
125+
t.Fatalf("one sample ran %d extra resolve dials, want 0: a sample must dial the "+
126+
"endpoint the run pinned, never re-enter the selection", resolves-before)
127+
}
128+
}
129+
130+
// A CACHED LITERAL IS DROPPED AT A RUN BOUNDARY, NOT MID-RUN. The cache must not
131+
// outlive the stack it belongs to: after lulFailInvalidate consecutive failed
132+
// connects the literal is dead and the next run must re-resolve (the first
133+
// resolve picked IPv6, IPv6 later died while IPv4 still works). But dropping it
134+
// the moment the streak lands drops it INSIDE a saturated phase - congestion
135+
// produces those failures too - and the phases after it are then left dialing a
136+
// hostname, paying DNS inside the very handshakes being timed. So lulNoteConnect
137+
// only counts and lulDialAddr, reached once per run on an idle link, does the
138+
// dropping. A successful connect clears the streak: a path that recovered is not
139+
// dead.
140+
func TestLulDeadLiteralDroppedAtRunBoundary(t *testing.T) {
141+
lulSelectSeams(t)
142+
lulResolved = "203.0.113.7:443"
143+
resolves := 0
144+
lulResolveDial = func(context.Context, string) (net.Conn, error) {
145+
resolves++
146+
return stubConn{remote: &net.TCPAddr{IP: net.ParseIP("2001:db8::7"), Port: 443}}, nil
147+
}
148+
lulProbeBurst = func(context.Context, string, int, time.Duration) ([]float64, int) {
149+
return []float64{7.0, 7.2, 6.9, 7.1, 7.0}, 0
150+
}
151+
lulFamilyDial = func(context.Context, string, string) (net.Conn, error) {
152+
return nil, errors.New("clean winner: must not consult the other family")
153+
}
84154

85-
lulResolved, lulFails = "203.0.113.7:443", 0
86155
for i := 0; i < lulFailInvalidate-1; i++ {
87156
lulNoteConnect(false)
88157
}
89-
if lulResolved == "" {
90-
t.Fatalf("cache dropped after %d failures, want it kept below the threshold", lulFailInvalidate-1)
91-
}
92-
lulNoteConnect(true) // success resets the streak
158+
lulNoteConnect(true) // a success clears the streak
93159
for i := 0; i < lulFailInvalidate-1; i++ {
94160
lulNoteConnect(false)
95161
}
162+
if got := lulRunEndpoint(context.Background()); got != "203.0.113.7:443" {
163+
t.Fatalf("lulRunEndpoint = %q below the failure threshold, want the cached literal: "+
164+
"only a streak that reaches lulFailInvalidate=%d means dead", got, lulFailInvalidate)
165+
}
166+
if resolves != 0 {
167+
t.Fatalf("re-resolved %d times below the failure threshold, want 0", resolves)
168+
}
169+
170+
lulNoteConnect(false) // the streak lands, mid-run
96171
if lulResolved == "" {
97-
t.Fatal("a successful connect must reset the failure streak")
172+
t.Fatal("the cached literal was dropped the moment the streak landed; want it held to " +
173+
"the run boundary, so no later phase is left dialing a hostname mid-run")
174+
}
175+
if got := lulRunEndpoint(context.Background()); got != v6Winner {
176+
t.Fatalf("lulRunEndpoint = %q on the next run, want the re-selected literal %q: a dead "+
177+
"literal must not survive a run boundary either", got, v6Winner)
98178
}
99-
lulNoteConnect(false) // streak reaches the threshold
100-
if lulResolved != "" {
101-
t.Fatalf("cache = %q after %d consecutive failures, want dropped for re-resolve", lulResolved, lulFailInvalidate)
179+
if resolves != 1 {
180+
t.Fatalf("re-resolved %d times at the run boundary, want exactly 1", resolves)
102181
}
103182
}
104183

0 commit comments

Comments
 (0)