From d1a4e289156735e6c4d50b9fd722495de1b7c596 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:36:38 +1300 Subject: [PATCH 1/2] =?UTF-8?q?deps:=20go-winio=20v0.6.4=20=E2=80=94=20pip?= =?UTF-8?q?e=20listener=20Close=20no=20longer=20hangs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.6.2's win32PipeListener.Close could wait forever: when Close lands while an Accept is pending, ConnectNamedPipe can race the handle close and report something other than ErrFileClosed (e.g. ERROR_NO_DATA). listenerRoutine then treats the listener as still open, never closes doneCh, and Close blocks on it. ipc.Serve waits for that Close before returning, so TestPipeSecondServerRejected hung the race job until the 10m test timeout. v0.6.4 makes listener closure authoritative on that path. Its ErrPipeListenerClosed is now a deprecated alias of net.ErrClosed, so isListenerClosed matches net.ErrClosed directly, like the unix side. x/sys v0.49.0 comes along as winio's minimum. --- go.mod | 4 ++-- go.sum | 12 ++++++------ internal/ipc/server_windows.go | 4 +++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 165a737..081d2f9 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ module proxyforward go 1.26.10 require ( - github.com/Microsoft/go-winio v0.6.2 + github.com/Microsoft/go-winio v0.6.4 github.com/energye/systray v1.0.3 github.com/hashicorp/yamux v0.1.2 github.com/kardianos/service v1.3.0 @@ -21,7 +21,7 @@ require ( github.com/wailsapp/wails/v2 v2.13.0 go.uber.org/goleak v1.3.0 golang.org/x/crypto v0.57.0 - golang.org/x/sys v0.48.0 + golang.org/x/sys v0.49.0 golang.org/x/time v0.15.0 modernc.org/sqlite v1.53.0 ) diff --git a/go.sum b/go.sum index 89141d8..1f674f6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= +github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -112,8 +112,8 @@ golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= @@ -121,8 +121,8 @@ golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ipc/server_windows.go b/internal/ipc/server_windows.go index f51a0e4..d0d89c4 100644 --- a/internal/ipc/server_windows.go +++ b/internal/ipc/server_windows.go @@ -34,4 +34,6 @@ func listen() (net.Listener, error) { }) } -func isListenerClosed(err error) bool { return errors.Is(err, winio.ErrPipeListenerClosed) } +// winio reports a closed listener as net.ErrClosed (its ErrPipeListenerClosed is +// a deprecated alias of it). +func isListenerClosed(err error) bool { return errors.Is(err, net.ErrClosed) } From bb662f00fdd07b4f894d2ab011bae3b848c2f2c2 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:38:20 +1300 Subject: [PATCH 2/2] e2e: retry the gateway on a fresh port when its QUIC bind fails The harness starts the gateway on an ephemeral TCP control port, and the gateway binds UDP on the same number for QUIC. Nothing makes that number free for UDP: Windows runners reserve UDP ranges and system services hold UDP ports exclusively, so the bind sporadically fails with WSAEACCES. The gateway then serves TCP only, as designed, and TestQUICRoundTrip and TestQUICMultiplexesPlayers wait out their 10s deadline on a tunnel that can never come up. It failed the race job on a dependabot PR in August and again on #35. Gateway.QUICAddr reports the bound UDP address (nil when QUIC is off or its bind failed), and startGateway retries on a fresh port while it is nil, so every harness with QUIC enabled really has it. --- internal/e2e/e2e_test.go | 36 ++++++++++++++++++++++++++++++------ internal/gateway/gateway.go | 10 ++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 171f0b5..1545ce8 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -135,12 +135,7 @@ func newHarnessWith(t *testing.T, localAddr string, opts harnessOpts) *harness { opts.tweakGateway(gwCfg) } - gwCtx, gwCancel := context.WithCancel(context.Background()) - gw := gateway.New(gwCfg, t.TempDir(), logger.With("side", "gateway")) - if err := gw.Start(gwCtx); err != nil { - gwCancel() - t.Fatal(err) - } + gw, gwCancel := startGateway(t, gwCfg, logger.With("side", "gateway")) t.Cleanup(func() { gwCancel() gw.Shutdown() @@ -199,6 +194,35 @@ func newHarnessWith(t *testing.T, localAddr string, opts harnessOpts) *harness { return h } +// startGateway starts a gateway on an ephemeral control port, retrying on a +// fresh port while QUIC is enabled but its UDP bind failed. The gateway binds +// UDP on whatever number the OS handed out for TCP, and nothing makes that +// number free for UDP: Windows runners reserve UDP ranges and system services +// hold UDP ports exclusively, so the bind sporadically fails with WSAEACCES. +// The gateway then (by design) serves TCP only, and every QUIC test would wait +// out its deadline on a tunnel that can never come up. +func startGateway(t *testing.T, cfg *config.Config, logger *slog.Logger) (*gateway.Gateway, context.CancelFunc) { + t.Helper() + dir := t.TempDir() + const attempts = 5 + for i := 1; ; i++ { + ctx, cancel := context.WithCancel(context.Background()) + gw := gateway.New(cfg, dir, logger) + if err := gw.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + if !cfg.Gateway.QUICEnabled || gw.QUICAddr() != nil { + return gw, cancel + } + cancel() + gw.Shutdown() + if i == attempts { + t.Fatalf("gateway QUIC listener failed to bind on %d ephemeral ports", attempts) + } + } +} + func (h *harness) startAgent() { h.t.Helper() ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 214fcc2..1fea89b 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -269,6 +269,16 @@ func (g *Gateway) ControlAddr() net.Addr { } return g.controlLn.Addr() } + +// QUICAddr is the UDP address the QUIC control listener is bound to, or nil +// when QUIC is off or its bind failed (Start logs that and serves TCP only). +func (g *Gateway) QUICAddr() net.Addr { + if g.quicLn == nil { + return nil + } + return g.quicLn.Addr() +} + func (g *Gateway) Fingerprint() string { return g.fingerprint } // GatewayID returns this gateway's derived gw_ display identity (empty before Start).