diff --git a/pkg/tcpip/transport/tcp/dispatcher.go b/pkg/tcpip/transport/tcp/dispatcher.go index f25aa27..2df3b51 100644 --- a/pkg/tcpip/transport/tcp/dispatcher.go +++ b/pkg/tcpip/transport/tcp/dispatcher.go @@ -149,6 +149,18 @@ func handleConnecting(ep *Endpoint) { ep.mu.Unlock() return } + // lx:begin handshake-nil-guard (sing-box-lx SPECS/TASKS/048) + // listenContext.performHandshake zeroes ep.h and releases ep.mu before + // calling ep.Close(); the state only changes later, inside closeLocked. + // A segment arriving in that window wakes handleConnecting, whose gate + // above checks the state but not h, so every ep.h dereference below — + // processSegments, listenEP in the error branch, and deliverAccepted — + // runs on a nil handshake and takes down the whole process. + if ep.h == nil { + ep.mu.Unlock() + return + } + // lx:end handshake-nil-guard if err := ep.h.processSegments(); err != nil { // +checklocksforce:ep.h.ep.mu // handshake failed. clean up the tcp endpoint and handshake // state. diff --git a/pkg/tcpip/transport/tcp/handshake_nil_guard_lx_test.go b/pkg/tcpip/transport/tcp/handshake_nil_guard_lx_test.go new file mode 100644 index 0000000..86cacf6 --- /dev/null +++ b/pkg/tcpip/transport/tcp/handshake_nil_guard_lx_test.go @@ -0,0 +1,45 @@ +// lx:begin handshake-nil-guard (sing-box-lx SPECS/TASKS/048) + +package tcp + +import "testing" + +// TestHandleConnectingWithNilHandshake pins the guard in handleConnecting. +// +// listenContext.performHandshake, in its failure branch (accept.go), does: +// +// ep.mu.Lock() +// ep.h = nil // handshake destroyed +// ep.mu.Unlock() // mutex released — state is still SynSent/SynRecv +// ep.Close() // state only changes here, inside closeLocked +// +// A segment arriving between the Unlock and Close wakes the dispatcher, which +// dispatches to handleConnecting because connecting() is still true. Without +// the guard, ep.h.processSegments() dereferences a nil handshake and panics +// with a nil receiver at connect.go — taking down the entire process. +// +// This test recreates that window exactly: no stubbing, the state passes the +// real connecting() gate and control reaches the dereference on its own. +// +// Without the guard this test panics; with it, the segment is a silent no-op. +func TestHandleConnectingWithNilHandshake(t *testing.T) { + ep := &Endpoint{} + ep.state.Store(uint32(StateSynRecv)) + ep.h = nil + + if !ep.EndpointState().connecting() { + t.Fatalf("precondition failed: state %v is not connecting", ep.EndpointState()) + } + + // What processor.start does for an endpoint with a queued segment. + handleConnecting(ep) + + // The guard must leave the mutex unlocked for the closing side, which is + // blocked on LockUser inside ep.Close(). + if !ep.TryLock() { + t.Fatal("handleConnecting returned holding ep.mu — Close() would deadlock") + } + ep.mu.Unlock() +} + +// lx:end handshake-nil-guard