From ad64368ece063c3fc14ac1120189bb28090da18d Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 28 Aug 2026 17:19:03 -0700 Subject: [PATCH 1/3] tailcat: reproduce active connection leak on close --- lifecycle_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 lifecycle_test.go diff --git a/lifecycle_test.go b/lifecycle_test.go new file mode 100644 index 000000000..f1819de30 --- /dev/null +++ b/lifecycle_test.go @@ -0,0 +1,80 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package tailcat + +import ( + "context" + "net" + "testing" + "time" + + "tailscale.com/tstest/integration" + "tailscale.com/types/key" + "tailscale.com/wgengine/filter" +) + +func TestServerCloseClosesActiveConnections(t *testing.T) { + t.Parallel() + + dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") + reg := dm.Regions[1] + if reg == nil { + t.Fatal("no region 1 in derpmap") + } + + clientKey := key.NewNode() + accepted := make(chan net.Conn, 1) + handlerDone := make(chan struct{}) + s := &Server{ + Logf: mkLogger(t, "server"), + Region: reg, + AllowedClients: []key.NodePublic{clientKey.Public()}, + ServedTCPPorts: []filter.PortRange{{First: 80, Last: 80}}, + OnTCP: func(port uint16) func(net.Conn) { + if port != 80 { + return nil + } + return func(conn net.Conn) { + accepted <- conn + defer close(handlerDone) + var buf [1]byte + _, _ = conn.Read(buf[:]) + } + }, + } + if err := s.Start(); err != nil { + t.Fatalf("server Start: %v", err) + } + t.Cleanup(func() { s.Close() }) + + c := &Client{Server: s.ConnBlob(), Key: clientKey, Logf: mkLogger(t, "client")} + t.Cleanup(func() { c.Close() }) + PingForTest(t, s, c) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + clientConn, err := c.DialTCPPort(ctx, 80) + if err != nil { + t.Fatalf("DialTCPPort: %v", err) + } + t.Cleanup(func() { clientConn.Close() }) + + var serverConn net.Conn + select { + case serverConn = <-accepted: + t.Cleanup(func() { serverConn.Close() }) + case <-ctx.Done(): + t.Fatalf("waiting for accepted connection: %v", ctx.Err()) + } + + if err := s.Close(); err != nil { + t.Fatalf("server Close: %v", err) + } + + select { + case <-handlerDone: + case <-time.After(5 * time.Second): + t.Fatal("server Close left an active netstack connection open") + } +} From a8a05980f392cec5953293029929cd83dba36984 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 28 Aug 2026 17:25:31 -0700 Subject: [PATCH 2/3] tailcat: keep lifecycle test with package tests --- lifecycle_test.go | 80 ----------------------------------------------- tailcat_test.go | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 80 deletions(-) delete mode 100644 lifecycle_test.go diff --git a/lifecycle_test.go b/lifecycle_test.go deleted file mode 100644 index f1819de30..000000000 --- a/lifecycle_test.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -package tailcat - -import ( - "context" - "net" - "testing" - "time" - - "tailscale.com/tstest/integration" - "tailscale.com/types/key" - "tailscale.com/wgengine/filter" -) - -func TestServerCloseClosesActiveConnections(t *testing.T) { - t.Parallel() - - dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") - reg := dm.Regions[1] - if reg == nil { - t.Fatal("no region 1 in derpmap") - } - - clientKey := key.NewNode() - accepted := make(chan net.Conn, 1) - handlerDone := make(chan struct{}) - s := &Server{ - Logf: mkLogger(t, "server"), - Region: reg, - AllowedClients: []key.NodePublic{clientKey.Public()}, - ServedTCPPorts: []filter.PortRange{{First: 80, Last: 80}}, - OnTCP: func(port uint16) func(net.Conn) { - if port != 80 { - return nil - } - return func(conn net.Conn) { - accepted <- conn - defer close(handlerDone) - var buf [1]byte - _, _ = conn.Read(buf[:]) - } - }, - } - if err := s.Start(); err != nil { - t.Fatalf("server Start: %v", err) - } - t.Cleanup(func() { s.Close() }) - - c := &Client{Server: s.ConnBlob(), Key: clientKey, Logf: mkLogger(t, "client")} - t.Cleanup(func() { c.Close() }) - PingForTest(t, s, c) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - clientConn, err := c.DialTCPPort(ctx, 80) - if err != nil { - t.Fatalf("DialTCPPort: %v", err) - } - t.Cleanup(func() { clientConn.Close() }) - - var serverConn net.Conn - select { - case serverConn = <-accepted: - t.Cleanup(func() { serverConn.Close() }) - case <-ctx.Done(): - t.Fatalf("waiting for accepted connection: %v", ctx.Err()) - } - - if err := s.Close(); err != nil { - t.Fatalf("server Close: %v", err) - } - - select { - case <-handlerDone: - case <-time.After(5 * time.Second): - t.Fatal("server Close left an active netstack connection open") - } -} diff --git a/tailcat_test.go b/tailcat_test.go index f6089a855..39fd0551c 100644 --- a/tailcat_test.go +++ b/tailcat_test.go @@ -216,6 +216,71 @@ func TestHalfClose(t *testing.T) { } } +func TestServerCloseClosesActiveConnections(t *testing.T) { + t.Parallel() + + dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") + reg := dm.Regions[1] + if reg == nil { + t.Fatal("no region 1 in derpmap") + } + + clientKey := key.NewNode() + accepted := make(chan net.Conn, 1) + handlerDone := make(chan struct{}) + s := &Server{ + Logf: mkLogger(t, "server"), + Region: reg, + AllowedClients: []key.NodePublic{clientKey.Public()}, + ServedTCPPorts: []filter.PortRange{{First: 80, Last: 80}}, + OnTCP: func(port uint16) func(net.Conn) { + if port != 80 { + return nil + } + return func(conn net.Conn) { + accepted <- conn + defer close(handlerDone) + var buf [1]byte + _, _ = conn.Read(buf[:]) + } + }, + } + if err := s.Start(); err != nil { + t.Fatalf("server Start: %v", err) + } + t.Cleanup(func() { s.Close() }) + + c := &Client{Server: s.ConnBlob(), Key: clientKey, Logf: mkLogger(t, "client")} + t.Cleanup(func() { c.Close() }) + PingForTest(t, s, c) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + clientConn, err := c.DialTCPPort(ctx, 80) + if err != nil { + t.Fatalf("DialTCPPort: %v", err) + } + t.Cleanup(func() { clientConn.Close() }) + + var serverConn net.Conn + select { + case serverConn = <-accepted: + t.Cleanup(func() { serverConn.Close() }) + case <-ctx.Done(): + t.Fatalf("waiting for accepted connection: %v", ctx.Err()) + } + + if err := s.Close(); err != nil { + t.Fatalf("server Close: %v", err) + } + + select { + case <-handlerDone: + case <-time.After(5 * time.Second): + t.Fatal("server Close left an active netstack connection open") + } +} + func TestConnBlob(t *testing.T) { akey := func(a [32]byte) NodePublic { return NodePublic{key.NodePublicFromRaw32(mem.B(a[:]))} From 32d7dd5b382691fdaff993468bd83af894d5a4e7 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 28 Aug 2026 17:30:04 -0700 Subject: [PATCH 3/3] tailcat: close all backend resources --- tailcat.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tailcat.go b/tailcat.go index a0eba13c4..9a4837ee6 100644 --- a/tailcat.go +++ b/tailcat.go @@ -232,6 +232,7 @@ type locoBackend struct { nm *netmap.NetworkMap allowedClients map[key.NodePublic]bool // or nil map for all eps []netip.AddrPort // our current local UDP endpoints, sorted + closeOnce sync.Once } func (b *locoBackend) derpRegionID() int { @@ -245,12 +246,23 @@ func (b *locoBackend) derpRegionID() int { } func (b *locoBackend) Close() error { - if e, ok := b.sys.Engine.GetOK(); ok { - e.Close() - } - if m, ok := b.sys.NetMon.GetOK(); ok { - m.Close() - } + b.closeOnce.Do(func() { + if b.ns != nil { + b.ns.Close() + } + if e, ok := b.sys.Engine.GetOK(); ok { + e.Close() + } + if m, ok := b.sys.NetMon.GetOK(); ok { + m.Close() + } + if d, ok := b.sys.Dialer.GetOK(); ok { + d.Close() + } + if bus, ok := b.sys.Bus.GetOK(); ok { + bus.Close() + } + }) return nil }