Skip to content

Commit 878790f

Browse files
cansofgreaseclaude
andcommitted
Make the loopback-test switch safe to flip while a cancelled run winds down
The very first CI run of the expanded pipeline caught a data race. When a speed test run is cancelled, its upload workers take a moment to wind down, and during that moment they still consult a package-wide switch - the dial guard that decides which destinations are allowed. The test suite flips that switch (tests serve their fake servers on loopback, which the guard exists to refuse), and one flip happened while a straggler from a just- cancelled run was still reading it. The switch now lives behind an atomic slot, so a reader always sees one coherent value - either the real guard or the test relaxation, both safe - no matter when a straggler looks. Production behavior is unchanged: the daemon never flips the switch after startup; only tests do. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4b2adaf commit 878790f

5 files changed

Lines changed: 58 additions & 30 deletions

File tree

internal/speedtest/fallback_classify_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ func TestStarvationCeilingTracksWorkers(t *testing.T) {
370370
// probeDialGuard, so this cannot mask a regression in it.
371371
func allowLoopbackProbes(t *testing.T) {
372372
t.Helper()
373-
old := probeDialControl
374-
probeDialControl = nil
375-
t.Cleanup(func() { probeDialControl = old })
373+
old := probeDialControl()
374+
setProbeDialControl(nil)
375+
t.Cleanup(func() { setProbeDialControl(old) })
376376
}

internal/speedtest/guardfix_proxy_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,9 @@ func TestGuardProxiedDestination(t *testing.T) {
175175

176176
// The relaxation the loopback-served suites rely on disables the pre-check
177177
// the same way it disables the dial guard.
178-
old := probeDialControl
179-
probeDialControl = nil
180-
t.Cleanup(func() { probeDialControl = old })
178+
old := probeDialControl()
179+
setProbeDialControl(nil)
180+
t.Cleanup(func() { setProbeDialControl(old) })
181181
if err := guardProxiedDestination(ctx, "127.0.0.1:9000"); err != nil {
182182
t.Errorf("pre-check fired despite the loopback relaxation: %v", err)
183183
}
@@ -254,9 +254,9 @@ func TestProbeEndpointRefusesProxiedInternalRedirect(t *testing.T) {
254254
// destination pre-check stands between the redirect and adoption, and it
255255
// needs a proxy configured to arm itself. probeDialControl stays NON-nil so
256256
// the pre-check does not read it as the loopback relaxation.
257-
old := probeDialControl
258-
probeDialControl = func(string, string, syscall.RawConn) error { return nil }
259-
t.Cleanup(func() { probeDialControl = old })
257+
old := probeDialControl()
258+
setProbeDialControl(func(string, string, syscall.RawConn) error { return nil })
259+
t.Cleanup(func() { setProbeDialControl(old) })
260260
t.Setenv("HTTP_PROXY", "http://192.168.1.10:3128") // never dialed; probeClient goes direct
261261

262262
srv := &ookla.Server{ID: "proxied-poison", URL: first.URL + "/speedtest/upload.php"}

internal/speedtest/guardfix_proxydirect_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ func TestProxyAddressIsNotReachableAsADirectDestination(t *testing.T) {
4343
flushDestResolveCache()
4444

4545
// The real guard, as production runs it - not the loopback relaxation.
46-
if probeDialControl == nil {
46+
if probeDialControl() == nil {
4747
t.Skip("dial guard disarmed in this build")
4848
}
4949

5050
tr := &http.Transport{
5151
Proxy: guardedEnvProxy,
52-
DialContext: (&net.Dialer{Timeout: 2 * time.Second, Control: probeDialControl}).DialContext,
52+
DialContext: (&net.Dialer{Timeout: 2 * time.Second, Control: probeDialControl()}).DialContext,
5353
}
5454
defer tr.CloseIdleConnections()
5555
c := &http.Client{Transport: tr, Timeout: 3 * time.Second}
@@ -94,7 +94,7 @@ func TestOrdinaryDestinationStillRoutesThroughTheProxy(t *testing.T) {
9494

9595
tr := &http.Transport{
9696
Proxy: guardedEnvProxy,
97-
DialContext: (&net.Dialer{Timeout: 2 * time.Second, Control: probeDialControl}).DialContext,
97+
DialContext: (&net.Dialer{Timeout: 2 * time.Second, Control: probeDialControl()}).DialContext,
9898
}
9999
defer tr.CloseIdleConnections()
100100
c := &http.Client{Transport: tr, Timeout: 3 * time.Second}

internal/speedtest/ookla.go

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ func newOoklaClientRec(uc *ookla.UserConfig) (*ookla.Speedtest, *uploadRecorder)
188188
// unset, so a caller-supplied control (e.g. a future source-interface bind)
189189
// still wins.
190190
if uc != nil && uc.DialerControl == nil {
191-
uc.DialerControl = probeDialControl
191+
uc.DialerControl = probeDialControl()
192192
}
193193
doer := &http.Client{}
194194
client := ookla.New(ookla.WithDoer(doer), ookla.WithUserConfig(uc))
@@ -1206,7 +1206,7 @@ func resolveProxiedDest(ctx context.Context, host string) ([]net.IP, error) {
12061206
// and under the same relaxation the dial guard honours (probeDialControl set
12071207
// nil by allowLoopbackProbes), so loopback-served tests keep working.
12081208
func guardProxiedDestination(ctx context.Context, hostport string) error {
1209-
if probeDialControl == nil || len(proxyAddrs()) == 0 {
1209+
if probeDialControl() == nil || len(proxyAddrs()) == 0 {
12101210
return nil
12111211
}
12121212
host := hostport
@@ -1270,7 +1270,7 @@ func serverDestination(s *ookla.Server) string {
12701270
// makes URL and Host name the same endpoint on the fetchServerList path, so a
12711271
// benign Host paired with a hostile URL would otherwise keep its ping.
12721272
func guardedServers(ctx context.Context, servers ookla.Servers) ookla.Servers {
1273-
if probeDialControl == nil || len(proxyAddrs()) == 0 {
1273+
if probeDialControl() == nil || len(proxyAddrs()) == 0 {
12741274
return servers
12751275
}
12761276
out := make(ookla.Servers, 0, len(servers))
@@ -1318,14 +1318,42 @@ func init() {
13181318
}
13191319

13201320
// probeDialControl is the dial guard the probes AND the measurement client
1321-
// install (see probeClient and newOoklaClientRec). A package var for the same
1322-
// reason as ooklaPing and fetchServerList: the offline tests serve their fakes
1323-
// on loopback, which the guard exists to refuse, so allowLoopbackProbes relaxes
1324-
// this one var to cover both the probes and the real transfer. Production never
1325-
// reassigns it, and TestProbeRefusesInternalDestinations exercises the real
1326-
// probeDialGuard directly so relaxing this in a test cannot hide a regression
1327-
// in the guard itself.
1328-
var probeDialControl = probeDialGuard
1321+
// install (see probeClient and newOoklaClientRec). A package-level slot for the
1322+
// same reason as ooklaPing and fetchServerList: the offline tests serve their
1323+
// fakes on loopback, which the guard exists to refuse, so allowLoopbackProbes
1324+
// relaxes this one slot to cover both the probes and the real transfer.
1325+
// Production never reassigns it, and TestProbeRefusesInternalDestinations
1326+
// exercises the real probeDialGuard directly so relaxing this in a test cannot
1327+
// hide a regression in the guard itself.
1328+
//
1329+
// An atomic slot, not a plain var: the test swap is not the only traffic. An
1330+
// abandoned run's upload workers keep draining briefly after cancellation and
1331+
// re-enter the transport - and so guardedEnvProxy and this slot - while the
1332+
// next test's helper swaps it back; a plain var there is a data race (caught
1333+
// by the race detector, Aug 2026). Readers capture one coherent value; whether
1334+
// a straggler sees the old guard or the new one, both are safe.
1335+
type probeDialControlFunc = func(network, address string, c syscall.RawConn) error
1336+
1337+
var probeDialControlSlot atomic.Pointer[probeDialControlFunc]
1338+
1339+
func init() { setProbeDialControl(probeDialGuard) }
1340+
1341+
// probeDialControl returns the guard currently installed; nil means the
1342+
// loopback relaxation is in effect.
1343+
func probeDialControl() probeDialControlFunc {
1344+
if p := probeDialControlSlot.Load(); p != nil {
1345+
return *p
1346+
}
1347+
return nil
1348+
}
1349+
1350+
func setProbeDialControl(f probeDialControlFunc) {
1351+
if f == nil {
1352+
probeDialControlSlot.Store(nil)
1353+
return
1354+
}
1355+
probeDialControlSlot.Store(&f)
1356+
}
13291357

13301358
// probeClient is the only client the endpoint probes use. Its dialer refuses
13311359
// internal destinations (see probeDialGuard) on every hop, redirects included -
@@ -1344,7 +1372,7 @@ func probeClient(timeout time.Duration) *http.Client {
13441372
// URL and the proxied upload still hit the non-replayable 307 -
13451373
// issues #17/#18, alive behind proxies.
13461374
Proxy: guardedEnvProxy,
1347-
DialContext: (&net.Dialer{Timeout: timeout, Control: probeDialControl}).DialContext,
1375+
DialContext: (&net.Dialer{Timeout: timeout, Control: probeDialControl()}).DialContext,
13481376
// Every caller builds this client for a single request and drops it,
13491377
// so a kept-alive socket can never be reused - it would only sit in
13501378
// the abandoned transport's idle pool (zero IdleConnTimeout: forever)
@@ -3415,8 +3443,8 @@ func measurePacketLoss(ctx context.Context, srv *ookla.Server) *float64 {
34153443
// mirrors the library's own default (PacketSendingTimeout).
34163444
analyzer := ookla.NewPacketLossAnalyzer(&ookla.PacketLossAnalyzerOptions{
34173445
SamplingDuration: packetLossSampleDuration,
3418-
TCPDialer: &net.Dialer{Timeout: 5 * time.Second, Control: probeDialControl},
3419-
UDPDialer: &net.Dialer{Timeout: 5 * time.Second, Control: probeDialControl},
3446+
TCPDialer: &net.Dialer{Timeout: 5 * time.Second, Control: probeDialControl()},
3447+
UDPDialer: &net.Dialer{Timeout: 5 * time.Second, Control: probeDialControl()},
34203448
})
34213449
var loss *float64
34223450
// Upstream leak (speedtest-go v1.7.11): RunWithContext opens a TCP sampler conn

internal/speedtest/ssrf_guard_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,14 @@ func TestProbeEndpointDoesNotPoisonURLOnBlockedRedirect(t *testing.T) {
7373
defer first.Close()
7474

7575
_, internalPort, _ := net.SplitHostPort(internal.Listener.Addr().String())
76-
old := probeDialControl
77-
probeDialControl = func(_, address string, _ syscall.RawConn) error {
76+
old := probeDialControl()
77+
setProbeDialControl(func(_, address string, _ syscall.RawConn) error {
7878
if _, p, _ := net.SplitHostPort(address); p == internalPort {
7979
return fmt.Errorf("blocked internal port %s", p)
8080
}
8181
return nil // the "public" first hop is allowed
82-
}
83-
t.Cleanup(func() { probeDialControl = old })
82+
})
83+
t.Cleanup(func() { setProbeDialControl(old) })
8484

8585
srv := &ookla.Server{ID: "poison-me", URL: first.URL + "/speedtest/upload.php"}
8686
orig := srv.URL

0 commit comments

Comments
 (0)