From d71d30a4b05ea1865baf9b8aa4a1d77341a891d4 Mon Sep 17 00:00:00 2001 From: cppla Date: Sat, 3 Oct 2026 01:12:37 +0800 Subject: [PATCH] fix: preserve shutdown errors and complete tracked cleanup --- docs/DEPLOYMENT.md | 7 + internal/proxy/http.go | 5 +- .../proxy/http_shutdown_close_result_test.go | 423 ++++++++++++++++++ internal/proxy/lifecycle.go | 34 +- internal/proxy/listener_close_error_test.go | 183 ++++++++ 5 files changed, 648 insertions(+), 4 deletions(-) create mode 100644 internal/proxy/http_shutdown_close_result_test.go create mode 100644 internal/proxy/listener_close_error_test.go diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 3b054a6..469fe69 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -264,6 +264,13 @@ shutdown context error and initiates request cancellation and socket cleanup; it does not wait indefinitely for custom handlers that ignore cancellation. This change is not included in v1.0.1. +Source builds treat an entirely already-closed listener error as benign during +shutdown, including net/http's duplicate close of a stopped HTTP listener. +Mixed error chains retain unrelated close failures and context errors. A real +listener-close failure no longer skips tracked-connection cleanup: shutdown +still waits for the caller's deadline and closes remaining tracked connections +on expiry, reporting both failures. This is not included in v1.0.1. + Source builds also cancel pending SOCKS5 TCP CONNECT setup when its tracked client connection is forcibly closed, without waiting for the full dial timeout. Successful setup still cancels the temporary dialing context without diff --git a/internal/proxy/http.go b/internal/proxy/http.go index 15db51f..54a40a1 100644 --- a/internal/proxy/http.go +++ b/internal/proxy/http.go @@ -103,10 +103,13 @@ func (s *HTTPServer) Serve(listener net.Listener) error { // forcibly closed when ctx expires. func (s *HTTPServer) Shutdown(ctx context.Context) error { closeErr := s.lifecycle.stopAccepting() - if errors.Is(closeErr, net.ErrClosed) { + if isListenerClosedError(closeErr) { closeErr = nil } httpErr := s.server.Shutdown(ctx) + if isListenerClosedError(httpErr) { + httpErr = nil + } trackErr := s.lifecycle.shutdown(ctx) s.transport.CloseIdleConnections() return errors.Join(closeErr, httpErr, trackErr) diff --git a/internal/proxy/http_shutdown_close_result_test.go b/internal/proxy/http_shutdown_close_result_test.go new file mode 100644 index 0000000..4f8ccbb --- /dev/null +++ b/internal/proxy/http_shutdown_close_result_test.go @@ -0,0 +1,423 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/transport" +) + +type shutdownCloseResultEvent struct { + phase int64 + raw error + returned error +} + +type shutdownCloseResultInjection struct { + phase int64 + other error + wrapped bool + preclosed bool +} + +// Accept gates only the return of the real closed-listener error until the +// second real Close. Thus net/http still owns a registered managed listener +// when Shutdown closes it, independently of the scheduler. The ordinary case +// never manufactures a Close error. Error controls explicitly add a separate +// fixture sentinel to the actual raw Close result at exactly one phase. +type shutdownCloseResultListener struct { + *net.TCPListener + injection shutdownCloseResultInjection + acceptStarted chan struct{} + rawAcceptDone chan struct{} + acceptReturned chan struct{} + secondClose chan struct{} + releaseAccept chan struct{} + startOnce sync.Once + releaseOnce sync.Once + closeCalls atomic.Int64 + mu sync.Mutex + closes []shutdownCloseResultEvent + acceptErr error +} + +func (l *shutdownCloseResultListener) Accept() (net.Conn, error) { + l.startOnce.Do(func() { close(l.acceptStarted) }) + conn, err := l.TCPListener.Accept() + l.mu.Lock() + l.acceptErr = err + l.mu.Unlock() + close(l.rawAcceptDone) + if conn == nil && errors.Is(err, net.ErrClosed) { + <-l.releaseAccept + } + close(l.acceptReturned) + return conn, err +} + +func (l *shutdownCloseResultListener) Close() error { + phase := l.closeCalls.Add(1) + rawErr := l.TCPListener.Close() + err := rawErr + if phase == l.injection.phase { + err = errors.Join(rawErr, l.injection.other) + if l.injection.wrapped { + err = fmt.Errorf("fixture listener close phase %d: %w", phase, err) + } + } + l.mu.Lock() + l.closes = append(l.closes, shutdownCloseResultEvent{phase: phase, raw: rawErr, returned: err}) + l.mu.Unlock() + if phase == 2 { + close(l.secondClose) + l.release() + } + return err +} + +func (l *shutdownCloseResultListener) release() { + l.releaseOnce.Do(func() { close(l.releaseAccept) }) +} + +func TestHTTPIdleShutdownIgnoresRealDuplicateListenerClose(t *testing.T) { + err, _ := shutdownCloseResultExercise(t, shutdownCloseResultInjection{}) + if err != nil { + t.Errorf("clean idle Shutdown = %v, want nil", err) + } +} + +func TestHTTPShutdownPreservesOtherListenerCloseErrors(t *testing.T) { + for phase, name := range []string{"initial", "http", "final_lifecycle"} { + for _, wrapped := range []bool{false, true} { + form := "joined" + if wrapped { + form = "wrapped" + } + t.Run(name+"/"+form, func(t *testing.T) { + other := errors.New("fixture " + name + " listener close failure") + injection := shutdownCloseResultInjection{ + phase: int64(phase + 1), other: other, wrapped: wrapped, + // An independently preclosed raw endpoint makes the first + // phase's real Close return net.ErrClosed too. This case + // proves a joined other error must not be swallowed by an + // any-match closed-error check. The wrapped first-phase + // control instead preserves a genuinely non-closed error. + preclosed: phase == 0 && !wrapped, + } + err, events := shutdownCloseResultExercise(t, injection) + if !errors.Is(err, other) { + t.Errorf("Shutdown = %v, want original other-error identity %v", err, other) + } + for _, event := range events { + if event.phase != injection.phase { + continue + } + if !errors.Is(err, event.returned) { + t.Errorf("Shutdown lost selected Close error identity: %v / %v", err, event.returned) + } + if errors.Is(err, net.ErrClosed) != errors.Is(event.returned, net.ErrClosed) { + t.Errorf("Shutdown closed-error membership = %v, want selected error membership %v", errors.Is(err, net.ErrClosed), errors.Is(event.returned, net.ErrClosed)) + } + if event.raw != nil && !errors.Is(event.returned, event.raw) { + t.Errorf("fixture control lost its actual raw Close error: %v / %v", event.returned, event.raw) + } + } + }) + } + } +} + +// This is a direct production lifecycle control, not an HTTP handler fixture. +// A genuine listener-close error must survive alongside the caller deadline +// without bypassing forced closure of the already accepted tracked TCP socket. +func TestLifecycleShutdownCloseErrorStillForcesTrackedTCP(t *testing.T) { + for _, preclosed := range []bool{true, false} { + name := "closed_and_other" + if !preclosed { + name = "other_only" + } + t.Run(name, func(t *testing.T) { + shutdownCloseResultForcedTCP(t, preclosed) + }) + } +} + +func shutdownCloseResultForcedTCP(t *testing.T, preclosed bool) { + t.Helper() + raw, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + other := errors.New("fixture lifecycle listener close failure") + listener := &shutdownCloseResultListener{ + TCPListener: raw, injection: shutdownCloseResultInjection{phase: 1, other: other}, + acceptStarted: make(chan struct{}), rawAcceptDone: make(chan struct{}), + acceptReturned: make(chan struct{}), secondClose: make(chan struct{}), releaseAccept: make(chan struct{}), + } + lifecycle := newServerLifecycle(1) + managed, err := lifecycle.manage(listener) + if err != nil { + _ = raw.Close() + t.Fatal(err) + } + type acceptResult struct { + conn net.Conn + err error + } + accepted := make(chan acceptResult, 1) + acceptExited := make(chan struct{}) + go func() { + defer close(acceptExited) + conn, err := managed.Accept() + accepted <- acceptResult{conn, err} + }() + var client *net.TCPConn + var tracked *trackedConn + ctx := context.Background() + cancel := func() {} + shutdownResult := make(chan error, 1) + shutdownExited := make(chan struct{}) + shutdownStarted := false + t.Cleanup(func() { + listener.release() + _ = raw.Close() + cancel() + if tracked != nil { + _ = tracked.Close() + cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), time.Second) + if err := lifecycle.tracker.wait(cleanupCtx); err != nil { + t.Errorf("independent tracked TCP cleanup retained a socket: %v", err) + } + cancelCleanup() + } + if client != nil { + _ = client.Close() + } + shutdownCloseResultWait(t, acceptExited, "tracked TCP Accept cleanup") + select { + case result := <-accepted: + if result.conn != nil { + _ = result.conn.Close() + } + default: + } + if shutdownStarted { + shutdownCloseResultWait(t, shutdownExited, "lifecycle Shutdown cleanup") + } + }) + client, err = net.DialTCP("tcp", nil, raw.Addr().(*net.TCPAddr)) + if err != nil { + t.Fatal(err) + } + if !shutdownCloseResultWait(t, acceptExited, "tracked TCP Accept") { + t.Fatal("managed Accept did not complete") + } + result := <-accepted + if result.err != nil { + t.Fatal(result.err) + } + var ok bool + tracked, ok = result.conn.(*trackedConn) + if !ok { + _ = result.conn.Close() + t.Fatalf("managed Accept result = %T, want trackedConn", result.conn) + } + connectionCtx := tracked.connectionContext(context.Background()) + if err := connectionCtx.Err(); err != nil { + t.Fatalf("tracked socket context canceled before Shutdown: %v", err) + } + if preclosed { + // Closing only the listener keeps the accepted socket alive, but + // makes the lifecycle raw Close yield net.ErrClosed plus other. + if err := raw.Close(); err != nil { + t.Fatal(err) + } + } + ctx, cancel = context.WithTimeout(context.Background(), 60*time.Millisecond) + defer cancel() + shutdownStarted = true + go func() { + defer close(shutdownExited) + shutdownResult <- lifecycle.shutdown(ctx) + }() + if !shutdownCloseResultWait(t, shutdownExited, "lifecycle forced Shutdown") { + t.Fatal("lifecycle Shutdown did not return") + } + shutdownErr := <-shutdownResult + if !errors.Is(shutdownErr, other) || !errors.Is(shutdownErr, context.DeadlineExceeded) { + t.Errorf("forced lifecycle Shutdown = %v, want both original close-error identity and caller deadline", shutdownErr) + } + if !errors.Is(connectionCtx.Err(), context.Canceled) { + t.Errorf("forced tracked socket context = %v, want canceled", connectionCtx.Err()) + } + lifecycle.tracker.mu.Lock() + held := len(lifecycle.tracker.conns) + lifecycle.tracker.mu.Unlock() + if held != 0 { + t.Errorf("forced lifecycle retained %d tracked sockets", held) + } + if err := client.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { + t.Fatal(err) + } + var b [1]byte + n, readErr := client.Read(b[:]) + if n != 0 || !errors.Is(readErr, io.EOF) { + t.Errorf("client after forced Shutdown = %d/%v, want real TCP EOF before cleanup", n, readErr) + } + listener.mu.Lock() + events := append([]shutdownCloseResultEvent(nil), listener.closes...) + listener.mu.Unlock() + if len(events) != 1 || !errors.Is(events[0].returned, other) || !errors.Is(shutdownErr, events[0].returned) { + t.Errorf("forced Close observation = %+v, want one original other error retained in Shutdown", events) + } + if len(events) == 1 { + if preclosed && !errors.Is(events[0].raw, net.ErrClosed) { + t.Errorf("preclosed raw Close = %v, want net.ErrClosed", events[0].raw) + } + if !preclosed && events[0].raw != nil { + t.Errorf("first raw Close = %v, want nil before adding other-only error", events[0].raw) + } + } + t.Logf("direct lifecycle preclosed=%v: Shutdown=%v; tracked context=%v; slots=%d; peer=%d/%v; Accept/Shutdown joined", preclosed, shutdownErr, connectionCtx.Err(), held, n, readErr) +} + +func shutdownCloseResultExercise(t *testing.T, injection shutdownCloseResultInjection) (error, []shutdownCloseResultEvent) { + t.Helper() + raw, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + listener := &shutdownCloseResultListener{ + TCPListener: raw, injection: injection, + acceptStarted: make(chan struct{}), rawAcceptDone: make(chan struct{}), + acceptReturned: make(chan struct{}), secondClose: make(chan struct{}), releaseAccept: make(chan struct{}), + } + var dialCalls, handlerCalls atomic.Int64 + server, err := NewHTTPServer(Config{Dialer: transport.DialFunc(func(context.Context, string, string) (net.Conn, error) { + dialCalls.Add(1) + return nil, errors.New("unused fixture destination") + })}) + if err != nil { + _ = raw.Close() + t.Fatal(err) + } + server.server.Handler = http.HandlerFunc(func(http.ResponseWriter, *http.Request) { handlerCalls.Add(1) }) + serveResult := make(chan error, 1) + serveExited := make(chan struct{}) + go func() { + defer close(serveExited) + serveResult <- server.Serve(listener) + }() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + shutdownResult := make(chan error, 1) + shutdownExited := make(chan struct{}) + shutdownStarted := false + t.Cleanup(func() { + // This independent release also works if production never performs + // its second Close, keeping every failure/negative control joinable. + listener.release() + _ = raw.Close() + cancel() + if shutdownStarted { + shutdownCloseResultWait(t, shutdownExited, "Shutdown cleanup") + } + shutdownCloseResultWait(t, serveExited, "Serve cleanup") + if closeErr := server.server.Close(); closeErr != nil { + t.Errorf("net/http final cleanup: %v", closeErr) + } + }) + if server.cfg.handshakeTimeout != defaultHandshakeTimeout || server.cfg.dialTimeout != defaultDialTimeout || server.cfg.idleTimeout != defaultIdleTimeout { + t.Fatal("listener close fixture changed production default budgets") + } + if !shutdownCloseResultWait(t, listener.acceptStarted, "registered listener Accept entry") { + t.Fatal("Serve did not enter Accept") + } + shutdownCloseResultAssertEmpty(t, server, &dialCalls, &handlerCalls) + if injection.preclosed { + // Separate fixture preparation, not a counted production Close or + // synthetic raw error: Accept now really sees a closed TCP listener. + if err := raw.Close(); err != nil { + t.Fatal(err) + } + if !shutdownCloseResultWait(t, listener.rawAcceptDone, "preclosed real Accept") { + t.Fatal("preclosed Accept did not return") + } + } + shutdownStarted = true + go func() { + defer close(shutdownExited) + shutdownResult <- server.Shutdown(ctx) + }() + if !shutdownCloseResultWait(t, listener.rawAcceptDone, "real Accept returning closed") || + !shutdownCloseResultWait(t, listener.secondClose, "second real listener Close") || + !shutdownCloseResultWait(t, listener.acceptReturned, "Accept gate return") || + !shutdownCloseResultWait(t, shutdownExited, "Shutdown return") || + !shutdownCloseResultWait(t, serveExited, "Serve return") { + t.Fatal("idle shutdown fixture did not complete") + } + shutdownErr := <-shutdownResult + if serveErr := <-serveResult; serveErr != nil { + t.Errorf("Serve = %v, want nil", serveErr) + } + if err := ctx.Err(); err != nil { + t.Errorf("Shutdown context is not live: %v", err) + } + if errors.Is(shutdownErr, context.DeadlineExceeded) || errors.Is(shutdownErr, context.Canceled) { + t.Errorf("idle Shutdown contains an unexpected context failure: %v", shutdownErr) + } + listener.mu.Lock() + events := append([]shutdownCloseResultEvent(nil), listener.closes...) + acceptErr := listener.acceptErr + listener.mu.Unlock() + if !errors.Is(acceptErr, net.ErrClosed) { + t.Errorf("underlying Accept error = %v, want real net.ErrClosed", acceptErr) + } + if len(events) != 3 || listener.closeCalls.Load() != 3 { + t.Errorf("Close events=%+v/count %d, want three production phases", events, listener.closeCalls.Load()) + } + for _, event := range events { + if event.phase == 1 && !injection.preclosed { + if event.raw != nil { + t.Errorf("first actual Close = %v, want nil", event.raw) + } + } else if !errors.Is(event.raw, net.ErrClosed) { + t.Errorf("duplicate actual Close phase %d = %v, want net.ErrClosed", event.phase, event.raw) + } + if event.phase != injection.phase && event.raw != event.returned { + t.Errorf("non-injected phase %d changed the real Close result", event.phase) + } + t.Logf("Close phase %d: actual=%v; returned=%v", event.phase, event.raw, event.returned) + } + shutdownCloseResultAssertEmpty(t, server, &dialCalls, &handlerCalls) + t.Logf("Shutdown=%v; context live; Accept/Serve/Shutdown joined; connections/handlers/dials all zero", shutdownErr) + return shutdownErr, events +} + +func shutdownCloseResultWait(t *testing.T, done <-chan struct{}, label string) bool { + t.Helper() + select { + case <-done: + return true + case <-time.After(time.Second): + t.Errorf("%s did not join/complete", label) + return false + } +} + +func shutdownCloseResultAssertEmpty(t *testing.T, server *HTTPServer, dials, handlers *atomic.Int64) { + t.Helper() + server.lifecycle.tracker.mu.Lock() + held := len(server.lifecycle.tracker.conns) + server.lifecycle.tracker.mu.Unlock() + if held != 0 || dials.Load() != 0 || handlers.Load() != 0 { + t.Errorf("idle held=%d/dials=%d/handlers=%d, want all zero", held, dials.Load(), handlers.Load()) + } +} diff --git a/internal/proxy/lifecycle.go b/internal/proxy/lifecycle.go index 82db4f6..9fa863a 100644 --- a/internal/proxy/lifecycle.go +++ b/internal/proxy/lifecycle.go @@ -311,12 +311,40 @@ func (s *serverLifecycle) stopAccepting() error { func (s *serverLifecycle) shutdown(ctx context.Context) error { closeErr := s.stopAccepting() - if closeErr != nil && !errors.Is(closeErr, net.ErrClosed) { - return closeErr + if isListenerClosedError(closeErr) { + closeErr = nil } if err := s.tracker.wait(ctx); err != nil { s.tracker.closeAll() + if closeErr != nil { + return errors.Join(closeErr, err) + } return err } - return nil + return closeErr +} + +// isListenerClosedError accepts only an entirely benign closed-listener tree. +// An errors.Is match alone can hide a different error in another joined branch. +// Keep mixed/unknown trees intact, including their original error identities. +func isListenerClosedError(err error) bool { + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + children := joined.Unwrap() + if len(children) == 0 { + return false + } + for _, child := range children { + if !isListenerClosedError(child) { + return false + } + } + return true + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return isListenerClosedError(wrapped.Unwrap()) + } + return errors.Is(err, net.ErrClosed) } diff --git a/internal/proxy/listener_close_error_test.go b/internal/proxy/listener_close_error_test.go new file mode 100644 index 0000000..38822a7 --- /dev/null +++ b/internal/proxy/listener_close_error_test.go @@ -0,0 +1,183 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "net" + "testing" + "time" +) + +// These deliberately expose malformed or opaque error trees. A custom Is +// match on a wrapper must not hide an unknown, empty, or nil child. +type listenerCloseSingleError struct { + child error + claimsClose bool + contextMatch error +} + +func (*listenerCloseSingleError) Error() string { return "custom single listener close error" } +func (e *listenerCloseSingleError) Unwrap() error { return e.child } +func (e *listenerCloseSingleError) Is(target error) bool { + return e.claimsClose && target == net.ErrClosed || e.contextMatch != nil && target == e.contextMatch +} + +type listenerCloseClaimsError struct{ contextMatch error } + +func (*listenerCloseClaimsError) Error() string { return "custom classified listener close error" } +func (e *listenerCloseClaimsError) Is(target error) bool { + return target == net.ErrClosed || e.contextMatch != nil && target == e.contextMatch +} + +type listenerCloseManyError struct { + children []error + claimsClose bool +} + +func (*listenerCloseManyError) Error() string { return "custom multi listener close error" } +func (e *listenerCloseManyError) Unwrap() []error { return e.children } +func (e *listenerCloseManyError) Is(target error) bool { + return e.claimsClose && target == net.ErrClosed +} + +func TestIsListenerClosedErrorRequiresEveryLeaf(t *testing.T) { + other := errors.New("listener close failed for a real unrelated reason") + wrappedClosed := fmt.Errorf("close listener: %w", net.ErrClosed) + cases := []struct { + name string + err error + want bool + }{ + {name: "nil"}, + {name: "closed sentinel", err: net.ErrClosed, want: true}, + {name: "wrapped closed", err: wrappedClosed, want: true}, + {name: "nested wrapped closed", err: fmt.Errorf("outer: %w", wrappedClosed), want: true}, + {name: "network operation closed", err: &net.OpError{Op: "close", Net: "tcp", Err: net.ErrClosed}, want: true}, + {name: "all closed join", err: errors.Join(net.ErrClosed, wrappedClosed), want: true}, + {name: "nested all closed joins", err: errors.Join(wrappedClosed, errors.Join(net.ErrClosed, &net.OpError{Op: "close", Err: wrappedClosed})), want: true}, + {name: "wrapped all closed join", err: fmt.Errorf("outer: %w", errors.Join(net.ErrClosed, wrappedClosed)), want: true}, + {name: "custom closed single", err: &listenerCloseSingleError{child: wrappedClosed}, want: true}, + {name: "custom all closed multi", err: &listenerCloseManyError{children: []error{net.ErrClosed, wrappedClosed}}, want: true}, + {name: "custom closed-only leaf", err: &listenerCloseClaimsError{}, want: true}, + {name: "leaf closed and canceled", err: &listenerCloseClaimsError{contextMatch: context.Canceled}}, + {name: "leaf closed and deadline", err: &listenerCloseClaimsError{contextMatch: context.DeadlineExceeded}}, + {name: "closed child with canceled wrapper", err: &listenerCloseSingleError{child: net.ErrClosed, contextMatch: context.Canceled}}, + {name: "closed child with deadline wrapper", err: &listenerCloseSingleError{child: net.ErrClosed, contextMatch: context.DeadlineExceeded}}, + {name: "closed join with dual-classified leaf", err: errors.Join(net.ErrClosed, &listenerCloseClaimsError{contextMatch: context.Canceled})}, + {name: "opaque unknown", err: other}, + {name: "opaque closed text", err: errors.New(net.ErrClosed.Error())}, + {name: "wrapped unknown", err: fmt.Errorf("close listener: %w", other)}, + {name: "operation unknown", err: &net.OpError{Op: "close", Net: "tcp", Err: other}}, + {name: "operation nil child", err: &net.OpError{Op: "close", Net: "tcp"}}, + {name: "nil single unwrap", err: &listenerCloseSingleError{}}, + {name: "nil single claiming closed", err: &listenerCloseSingleError{claimsClose: true}}, + {name: "nil multi unwrap", err: &listenerCloseManyError{}}, + {name: "empty multi unwrap", err: &listenerCloseManyError{children: []error{}}}, + {name: "empty multi claiming closed", err: &listenerCloseManyError{children: []error{}, claimsClose: true}}, + {name: "multi nil child only", err: &listenerCloseManyError{children: []error{nil}}}, + {name: "multi closed then nil", err: &listenerCloseManyError{children: []error{net.ErrClosed, nil}}}, + {name: "multi nil then closed", err: &listenerCloseManyError{children: []error{nil, net.ErrClosed}}}, + {name: "unknown child claiming closed", err: &listenerCloseSingleError{child: other, claimsClose: true}}, + {name: "mixed children claiming closed", err: &listenerCloseManyError{children: []error{net.ErrClosed, other}, claimsClose: true}}, + {name: "nested empty beside closed", err: errors.Join(net.ErrClosed, &listenerCloseManyError{})}, + } + for _, failure := range []struct { + name string + err error + }{ + {name: "generic", err: other}, + {name: "context cancellation", err: context.Canceled}, + {name: "context deadline", err: context.DeadlineExceeded}, + } { + cases = append(cases, + struct { + name string + err error + want bool + }{name: "closed then " + failure.name, err: errors.Join(net.ErrClosed, failure.err)}, + struct { + name string + err error + want bool + }{name: failure.name + " then closed", err: errors.Join(failure.err, net.ErrClosed)}, + struct { + name string + err error + want bool + }{name: "wrapped mixed " + failure.name, err: fmt.Errorf("outer: %w", errors.Join(wrappedClosed, failure.err))}, + ) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // A bool classification must not prune a mixed error or rewrite + // its caller-owned multi-error slice while deciding to preserve it. + var originalChildren []error + joined, isMulti := tc.err.(interface{ Unwrap() []error }) + if isMulti { + originalChildren = append([]error(nil), joined.Unwrap()...) + } + if got := isListenerClosedError(tc.err); got != tc.want { + t.Fatalf("isListenerClosedError(%T) = %t, want %t", tc.err, got, tc.want) + } + if isMulti { + children := joined.Unwrap() + if len(children) != len(originalChildren) { + t.Fatal("classification changed the original error tree") + } + for i := range children { + if children[i] != originalChildren[i] { + t.Fatal("classification replaced an original error child") + } + } + } + }) + } +} + +func TestLifecycleForcedShutdownPreservesContextErrorIdentity(t *testing.T) { + for _, mode := range []string{"canceled", "deadline"} { + t.Run(mode, func(t *testing.T) { + lifecycle := newServerLifecycle(1) + upstream, peer := net.Pipe() + defer upstream.Close() + defer peer.Close() + tracked := &trackedConn{Conn: upstream, tracker: lifecycle.tracker} + if !lifecycle.tracker.add(tracked) { + t.Fatal("test connection was not admitted") + } + connectionCtx := tracked.connectionContext(context.Background()) + if connectionCtx.Err() != nil { + t.Fatal("admitted connection context was already canceled") + } + var ctx context.Context + var cancel context.CancelFunc + if mode == "deadline" { + ctx, cancel = context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + } else { + ctx, cancel = context.WithCancel(context.Background()) + cancel() + } + defer cancel() + // With no listener error to join, preserve the original context + // sentinel while still closing the active tracked connection. + if err := lifecycle.shutdown(ctx); err != ctx.Err() { + t.Fatalf("forced Shutdown error = %T %v, want original %T %v", err, err, ctx.Err(), ctx.Err()) + } + if connectionCtx.Err() != context.Canceled { + t.Fatalf("forced socket close did not cancel its connection context: %v", connectionCtx.Err()) + } + lifecycle.tracker.mu.Lock() + count, zero := len(lifecycle.tracker.conns), lifecycle.tracker.zero + lifecycle.tracker.mu.Unlock() + if count != 0 { + t.Fatalf("forced Shutdown retained %d tracked connections", count) + } + select { + case <-zero: + default: + t.Fatal("forced Shutdown did not release the tracker zero signal") + } + }) + } +}