From 5278dd8356754f719ac60f5dadc8616aa41c7b90 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 12:34:49 +0100 Subject: [PATCH] test(cloudlink): QUIC fails after its dial in the demotion test TestAQUICSessionThatEndsBeforeHelloOKDemotesQUIC closed each QUIC session right after the handshake. When that close beat the end of the box's dial, the dial failed as on a path that blocks UDP, the WebSocket won the race, nothing was demoted, and the later QUIC win moved QUIC first. The service now closes once the box has opened its control stream, so the dial has succeeded and the session ends before hello_ok. Under load the QUIC handshake can also outlast the 250 ms race delay, so the WebSocket won before QUIC was tried. A test-only hook holds the fake service's WebSocket until QUIC is demoted on this network. Co-Authored-By: Claude Opus 5.5 --- go/internal/cloudlink/link_test.go | 8 ++++++++ go/internal/cloudlink/quic_test.go | 31 ++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index d0ed40611..1a2c3bd8f 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -198,6 +198,8 @@ type fakeService struct { // that is stopping. wrongShard string restartBeforeOK bool + // beforeWebSocket, when set, runs before each WebSocket upgrade. + beforeWebSocket func() wmu sync.Mutex mu sync.Mutex @@ -266,6 +268,12 @@ func (s *fakeService) write(conn uplinkConn, key *ecdsa.PrivateKey, home, t stri } func (s *fakeService) handle(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + hold := s.beforeWebSocket + s.mu.Unlock() + if hold != nil { + hold() + } conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) if err != nil { return diff --git a/go/internal/cloudlink/quic_test.go b/go/internal/cloudlink/quic_test.go index f23d97a74..d7f6d3996 100644 --- a/go/internal/cloudlink/quic_test.go +++ b/go/internal/cloudlink/quic_test.go @@ -459,11 +459,39 @@ func TestAQUICSessionThatEndsBeforeHelloOKDemotesQUIC(t *testing.T) { return } refused.Add(1) - _ = qc.CloseWithError(0x110, "") + // Close once the box has opened its control stream: its dial + // has then succeeded, and the session ends before hello_ok. A + // close straight after the handshake can beat the end of the + // dial, which then fails as on a path that blocks UDP and + // demotes nothing. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = qc.AcceptStream(ctx) + _ = qc.CloseWithError(0x110, "") + }() } }() + // Under load the QUIC handshake can outlast cloudwire.RaceDelay, and the + // WebSocket would then win before QUIC was tried. Hold the WebSocket + // until QUIC has failed here, which puts it first on this network. + network := localNetwork(addr) + var link atomic.Pointer[Link] + svc.mu.Lock() + svc.beforeWebSocket = func() { + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + if l := link.Load(); l != nil && l.preferred(network) == cloudwire.TransportWebSocket { + return + } + time.Sleep(5 * time.Millisecond) + } + } + svc.mu.Unlock() + h := newQUICHarness(t, svc) + link.Store(h.link) h.grant(t, protocol.ScopeLiveRead) waitWithin(t, 20*time.Second, "the uplink up", func() bool { return h.link.Status().Connected }) if got := h.link.Status().Transport; got != cloudwire.TransportWebSocket { @@ -472,7 +500,6 @@ func TestAQUICSessionThatEndsBeforeHelloOKDemotesQUIC(t *testing.T) { if refused.Load() == 0 { t.Fatal("QUIC was never tried") } - network := localNetwork(addr) if got := h.link.preferred(network); got != cloudwire.TransportWebSocket { t.Fatalf("after QUIC failed, %q goes first", got) }