Skip to content

tailcat: don't crash the server when a client resets before it's served - #167

Open
ex3ndr wants to merge 1 commit into
tailscale:mainfrom
slopus:fix-peerenv-reset-before-served
Open

ex3ndr wants to merge 1 commit into
tailscale:mainfrom
slopus:fix-peerenv-reset-before-served

Conversation

@ex3ndr

@ex3ndr ex3ndr commented Nov 13, 2026

Copy link
Copy Markdown

This PR was opened by an AI agent at the direct request of @bra1nDump.

Happy (slopus/happy-agent) ships tailcat as its computer-side transport: the desktop agent runs tailcat in serve mode with -e for each connection, and a phone connects to it. We hit this crash while testing that setup.

The bug

If a client resets a TCP flow right after the handshake, before netstack calls the handler, gonet's RemoteAddr() and LocalAddr() return nil. ExecConnHandler passes them to PeerEnv, and remote.String() panics at tailcat_exec.go:49. The panic kills the whole tailcat process, along with every other flow and session on it.

Two crashes in one evening, from our daemon's log of tailcat's stderr (v0.7.0, darwin/arm64; the second has the same stack):

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x20 pc=0x103253700]

goroutine 100943 [running]:
github.com/tailscale/tailcat.(*Server).PeerEnv(0x5fc0a522cea0, {0x103bb0f40, 0x5fc0ab4c6b70}, {0x0, 0x0})
	github.com/tailscale/tailcat/tailcat_exec.go:49 +0x30
github.com/tailscale/tailcat.(*Server).ExecConnHandler.func1({0x103bba640, 0x5fc0ac32faa0})
	github.com/tailscale/tailcat/tailcat_exec.go:29 +0xb8
tailscale.com/wgengine/netstack.(*Impl).acceptTCP(0x5fc0a522d320, 0x5fc0ac2f4f40)
	tailscale.com@v1.103.0-pre.0.20260916030321-a2263542f260/wgengine/netstack/netstack.go:1715 +0x828
created by gvisor.dev/gvisor/pkg/tcpip/transport/tcp.(*Forwarder).HandlePacket in goroutine 80459
	gvisor.dev/gvisor@v0.0.0-20260915211658-a6f909f08a72/pkg/tcpip/transport/tcp/forwarder.go:97 +0x28c

Repro

TestExecConnHandlerResetBeforeServed does a real gVisor handshake, has the client Abort() (RST), waits until the server side reports a nil RemoteAddr(), then calls ExecConnHandler. TestServerSurvivesHandlerPanic starts a real Server whose port-81 handler panics, dials 81, then checks that port 80 still answers.

go test -run 'TestExecConnHandlerResetBeforeServed|TestServerSurvivesHandlerPanic' -count=1 -v .

Before the fix (main e00cdde with only the new tests), each test kills the test binary:

=== RUN   TestExecConnHandlerResetBeforeServed
--- FAIL: TestExecConnHandlerResetBeforeServed (0.01s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]
[signal SIGSEGV: segmentation violation code=0x1 addr=0x20 pc=0x1727362]
...
github.com/tailscale/tailcat.(*Server).PeerEnv(0x289c1caf3dd0, {0x2d69958, 0x289c1cafa3f0}, {0x0, 0x0})
	tailcat/tailcat_exec.go:49 +0x42
github.com/tailscale/tailcat.(*Server).ExecConnHandler.func1(...)
	tailcat/tailcat_exec.go:29
github.com/tailscale/tailcat.TestExecConnHandlerResetBeforeServed(0x289c1c8458c8)
	tailcat/tailcat_exec_test.go:66 +0x8cc
FAIL	github.com/tailscale/tailcat	0.024s
=== RUN   TestServerSurvivesHandlerPanic
...
panic: handler bug

goroutine 647 [running]:
github.com/tailscale/tailcat.TestServerSurvivesHandlerPanic.func2.1({0x0?, 0x0?})
	tailcat/tailcat_exec_test.go:87 +0x25
tailscale.com/wgengine/netstack.(*Impl).acceptTCP(0x34329c090480, 0x34329c077a40)
	tailscale.com@v1.105.0-pre.0.20261013225543-fb79793d99f5/wgengine/netstack/netstack.go:1750 +0xbc2
created by gvisor.dev/gvisor/pkg/tcpip/transport/tcp.(*Forwarder).HandlePacket in goroutine 615
	gvisor.dev/gvisor@v0.0.0-20260915211658-a6f909f08a72/pkg/tcpip/transport/tcp/forwarder.go:97 +0x2ed
FAIL	github.com/tailscale/tailcat	0.042s

After:

=== RUN   TestExecConnHandlerResetBeforeServed
--- PASS: TestExecConnHandlerResetBeforeServed (0.01s)
=== RUN   TestServerSurvivesHandlerPanic
    tailcat.go:2449:         [server] panic serving TCP connection: handler bug
--- PASS: TestServerSurvivesHandlerPanic (0.01s)
PASS
ok  	github.com/tailscale/tailcat	0.035s

go test ./... passes on this branch (linux/amd64).

Impact

A crash isn't contained to the flow that triggered it. Our desktop agent restarts tailcat, which comes back on a new UDP port and without its sessions, so every connected client has to notice and rejoin. In our test harness (the server process killed and restarted while the phone app was suspended, the same effect as this panic), the phone's requests on the old session hung until they timed out after ~35 s, in 7 of 7 trials (36.6 s median). Once our client re-invites after a restart, recovery takes about 0.3 s (205–348 ms, 8 of 8). Without the crash, neither happens.

gantt
    title tailcat restarted while the phone is suspended (ms from phone start)
    dateFormat x
    axisFormat %M:%S
    section Server
    killed, back on a new UDP port      :done, 1704, 2735
    section Phone, old session
    suspended                           :done, 527, 30532
    5 requests hang, then time out      :crit, 30532, 65896
    retry, new session, done            :66905, 67461
    section Phone that re-invites
    suspended                           :done, 585, 30585
    re-invited, direct, done            :30585, 30913
Loading

The fix

  • ExecConnHandler closes a connection that no longer has addresses without running the command.
  • PeerEnv leaves out a nil address instead of calling String() on it (an SSH session's connection can be reset at any time too).
  • As net/http does per connection, a panic in an OnTCP or OnTCPForward handler is recovered, logged with its stack, and closes only that connection.

gonet's LocalAddr and RemoteAddr return nil once the connection is no
longer connected. If the client resets a connection right after the
handshake, before netstack calls the OnTCP handler, ExecConnHandler
passed those nil addresses to PeerEnv, whose remote.String() panicked
and took the whole server down with every other connection on it:

    panic: runtime error: invalid memory address or nil pointer dereference
    tailcat.(*Server).PeerEnv            tailcat_exec.go:49
    tailcat.(*Server).ExecConnHandler.func1  tailcat_exec.go:29
    netstack.(*Impl).acceptTCP

ExecConnHandler now closes such a connection without running the
command, and PeerEnv leaves out an address it doesn't have (an SSH
session's connection can be reset at any time too).

Also, as net/http does per connection, recover a panic in an OnTCP or
OnTCPForward handler: log it with its stack and close that connection,
so one bad flow can't kill the server.

Signed-off-by: Kirill <kirill2003de@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants