diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index bf5ee38..ff00ab5 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -255,6 +255,15 @@ shutdown semantics or TCP CONNECT handling, and is not included in v1.0.1. Cancellation requires context-aware resolvers/dialers; custom implementations that ignore cancellation cannot be forcibly interrupted by the proxy. +Source builds also cancel an HTTP connection's request contexts when that +tracked connection is closed. This lets forced shutdown cancel a pending +upstream request even when HTTP/1.1 pipelining has stopped the background +reader from noticing socket closure. Graceful shutdown still allows active +requests and CONNECT tunnels to finish until its deadline. Expiry returns the +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. + For SOCKS5 TCP and HTTP CONNECT tunnels, `--idle-timeout` (default `5m`) measures inactivity across both directions: an active download or upload does not need reverse-direction application traffic to stay open. A blocked write diff --git a/internal/proxy/http.go b/internal/proxy/http.go index 8851edd..9af86d8 100644 --- a/internal/proxy/http.go +++ b/internal/proxy/http.go @@ -61,7 +61,7 @@ func NewHTTPServer(cfg Config) (*HTTPServer, error) { MaxHeaderBytes: 64 << 10, ConnContext: func(ctx context.Context, conn net.Conn) context.Context { if tracked, ok := conn.(*trackedConn); ok { - return context.WithValue(ctx, httpConnContextKey{}, tracked) + return context.WithValue(tracked.connectionContext(ctx), httpConnContextKey{}, tracked) } return ctx }, diff --git a/internal/proxy/http_shutdown_cancel_test.go b/internal/proxy/http_shutdown_cancel_test.go new file mode 100644 index 0000000..6b843d8 --- /dev/null +++ b/internal/proxy/http_shutdown_cancel_test.go @@ -0,0 +1,390 @@ +package proxy + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/cppla/autocar/internal/transport" +) + +// The listener observes the standard library's one-byte background read. It +// never performs an additional read or changes deadlines, data, or errors. +type httpShutdownReadListener struct { + net.Listener + byteRead chan struct{} + closed chan struct{} + readOnce sync.Once + closeOnce sync.Once +} + +func (l *httpShutdownReadListener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return &httpShutdownReadConn{Conn: conn, listener: l}, nil +} + +func (l *httpShutdownReadListener) Close() error { + err := l.Listener.Close() + l.closeOnce.Do(func() { close(l.closed) }) + return err +} + +type httpShutdownReadConn struct { + net.Conn + listener *httpShutdownReadListener +} + +func (c *httpShutdownReadConn) Read(p []byte) (int, error) { + n, err := c.Conn.Read(p) + if len(p) == 1 && n == 1 { + c.listener.readOnce.Do(func() { close(c.listener.byteRead) }) + } + return n, err +} + +type httpShutdownHarness struct { + server *HTTPServer + client net.Conn + listener *httpShutdownReadListener + requestContext chan context.Context + handlerDone chan struct{} + serveDone chan error +} + +func newHTTPShutdownHarness(t *testing.T, cfg Config) *httpShutdownHarness { + t.Helper() + server, err := NewHTTPServer(cfg) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + h := &httpShutdownHarness{ + server: server, + listener: &httpShutdownReadListener{Listener: listener, byteRead: make(chan struct{}), closed: make(chan struct{})}, + requestContext: make(chan context.Context, 1), + handlerDone: make(chan struct{}), + serveDone: make(chan error, 1), + } + var first sync.Once + server.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + isFirst := false + first.Do(func() { + isFirst = true + h.requestContext <- r.Context() + }) + if isFirst { + defer close(h.handlerDone) + } + server.ServeHTTP(w, r) + }) + go func() { h.serveDone <- server.Serve(h.listener) }() + t.Cleanup(func() { + if h.client != nil { + _ = h.client.Close() + } + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + _ = h.server.Shutdown(ctx) + cancel() + select { + case err := <-h.serveDone: + if err != nil { + t.Errorf("proxy Serve cleanup: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("proxy Serve cleanup did not finish") + } + }) + h.client, err = net.DialTimeout("tcp", listener.Addr().String(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + if err := h.client.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + return h +} + +func httpShutdownWait(t *testing.T, done <-chan struct{}, label string) bool { + t.Helper() + select { + case <-done: + return true + case <-time.After(2 * time.Second): + t.Errorf("%s did not finish", label) + return false + } +} + +func httpShutdownRequestContext(t *testing.T, h *httpShutdownHarness) context.Context { + t.Helper() + select { + case ctx := <-h.requestContext: + return ctx + case <-time.After(2 * time.Second): + t.Fatal("proxy did not enter the request handler") + return nil + } +} + +// Header receipt and cancellation are tested through real loopback TCP at both +// ends. With a pipelined byte already consumed, net/http has no background read +// left to observe a later forced close. Closing the tracked connection must +// still cancel the actual inbound context and the waiting upstream request. +func TestHTTPForcedShutdownCancelsWaitingResponse(t *testing.T) { + for _, mode := range []string{"ordinary", "pipelined", "concurrent-pipelined"} { + t.Run(mode, func(t *testing.T) { + originEntered, originDone, originCanceled := make(chan struct{}), make(chan struct{}), make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/first" { + w.WriteHeader(http.StatusNoContent) + return + } + defer close(originDone) + close(originEntered) + select { + case <-r.Context().Done(): + close(originCanceled) + case <-release: + w.WriteHeader(http.StatusNoContent) + } + })) + h := newHTTPShutdownHarness(t, Config{Dialer: directDialer(), IdleTimeout: 10 * time.Second, MaxConnections: 1}) + // This cleanup runs before the harness cleanup and releases the origin + // even if the cancellation assertion fails on a regressed build. + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + _ = h.client.Close() + origin.CloseClientConnections() + httpShutdownWait(t, h.handlerDone, "proxy handler cleanup") + httpShutdownWait(t, originDone, "origin handler cleanup") + origin.Close() + }) + mustWrite(t, h.client, []byte(fmt.Sprintf("GET %s/first HTTP/1.1\r\nHost: ignored.invalid\r\n\r\n", origin.URL))) + requestCtx := httpShutdownRequestContext(t, h) + if !httpShutdownWait(t, originEntered, "origin request") { + return + } + if mode != "ordinary" { + // Send only after the first request reaches the origin; otherwise + // both requests could be buffered by the initial header read. + mustWrite(t, h.client, []byte(fmt.Sprintf("GET %s/next HTTP/1.1\r\nHost: ignored.invalid\r\n\r\n", origin.URL))) + if !httpShutdownWait(t, h.listener.byteRead, "net/http pipelined-byte read") { + return + } + } + if err := requestCtx.Err(); err != nil { + t.Fatalf("request was canceled before Shutdown: %v", err) + } + + callers := 1 + if mode == "concurrent-pipelined" { + callers = 3 + } + start := make(chan struct{}) + results := make(chan error, callers) + for range callers { + go func() { + <-start + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + results <- h.server.Shutdown(ctx) + }() + } + close(start) + deadlineSeen := false + for range callers { + select { + case err := <-results: + if errors.Is(err, context.DeadlineExceeded) { + deadlineSeen = true + } else if err != nil { + t.Errorf("forced Shutdown: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("concurrent Shutdown did not finish") + } + } + if !deadlineSeen { + t.Error("all Shutdown calls completed before their deadline despite the blocked request") + } + httpShutdownWait(t, requestCtx.Done(), "inbound request cancellation") + httpShutdownWait(t, originCanceled, "waiting origin request cancellation") + httpShutdownWait(t, h.handlerDone, "proxy handler after forced Shutdown") + httpShutdownWait(t, originDone, "origin handler after forced Shutdown") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := h.server.lifecycle.tracker.wait(ctx); err != nil { + t.Errorf("forced Shutdown retained an accepted connection slot: %v", err) + } + if err := h.server.Shutdown(ctx); err != nil { + t.Errorf("repeated Shutdown after cleanup: %v", err) + } + }) + } +} + +func TestHTTPGracefulShutdownPreservesActiveRequest(t *testing.T) { + originEntered, originDone := make(chan struct{}), make(chan struct{}) + originContext := make(chan context.Context, 1) + release := make(chan struct{}) + var releaseOnce sync.Once + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(originDone) + originContext <- r.Context() + close(originEntered) + select { + case <-r.Context().Done(): + return + case <-release: + _, _ = io.WriteString(w, "completed during graceful shutdown") + } + })) + h := newHTTPShutdownHarness(t, Config{Dialer: directDialer(), IdleTimeout: 10 * time.Second}) + shutdownDone := make(chan error, 1) + shutdownStarted := false + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + _ = h.client.Close() + origin.CloseClientConnections() + httpShutdownWait(t, h.handlerDone, "proxy handler cleanup") + httpShutdownWait(t, originDone, "origin handler cleanup") + origin.Close() + if shutdownStarted { + select { + case <-shutdownDone: + case <-time.After(4 * time.Second): + t.Error("graceful Shutdown cleanup did not finish") + } + } + }) + mustWrite(t, h.client, []byte(fmt.Sprintf("GET %s/ HTTP/1.1\r\nHost: ignored.invalid\r\n\r\n", origin.URL))) + requestCtx := httpShutdownRequestContext(t, h) + if !httpShutdownWait(t, originEntered, "origin request") { + return + } + originCtx := <-originContext + shutdownStarted = true + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + shutdownDone <- h.server.Shutdown(ctx) + }() + if !httpShutdownWait(t, h.listener.closed, "listener close at Shutdown start") { + return + } + select { + case <-requestCtx.Done(): + t.Fatalf("graceful Shutdown canceled the active request: %v", requestCtx.Err()) + case <-originCtx.Done(): + t.Fatalf("graceful Shutdown canceled the origin request: %v", originCtx.Err()) + case err := <-shutdownDone: + shutdownStarted = false + t.Fatalf("Shutdown returned before origin release: %v", err) + case <-time.After(100 * time.Millisecond): + } + releaseOnce.Do(func() { close(release) }) + response, err := http.ReadResponse(bufio.NewReader(h.client), &http.Request{Method: http.MethodGet}) + if err != nil { + t.Fatal(err) + } + body, readErr := io.ReadAll(response.Body) + closeErr := response.Body.Close() + if readErr != nil || closeErr != nil || response.StatusCode != http.StatusOK || string(body) != "completed during graceful shutdown" { + t.Fatalf("graceful response = %d/%q, read=%v close=%v", response.StatusCode, body, readErr, closeErr) + } + httpShutdownWait(t, h.handlerDone, "completed proxy handler") + httpShutdownWait(t, originDone, "completed origin handler") + select { + case err := <-shutdownDone: + shutdownStarted = false + if err != nil { + t.Errorf("graceful Shutdown: %v", err) + } + case <-time.After(4 * time.Second): + t.Error("graceful Shutdown did not finish after origin release") + } +} + +func TestHTTPForcedShutdownCancelsPendingConnect(t *testing.T) { + dialEntered, dialDone := make(chan struct{}), make(chan struct{}) + dialContext := make(chan context.Context, 1) + dialRelease := make(chan struct{}) + dialer := transport.DialFunc(func(ctx context.Context, _, _ string) (net.Conn, error) { + defer close(dialDone) + dialContext <- ctx + close(dialEntered) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-dialRelease: + return nil, errors.New("test cleanup released pending CONNECT dial") + } + }) + h := newHTTPShutdownHarness(t, Config{Dialer: dialer, DialTimeout: 10 * time.Second, IdleTimeout: 10 * time.Second}) + t.Cleanup(func() { + // Force cleanup independently if an assertion fails before Shutdown. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = h.server.Shutdown(ctx) + close(dialRelease) + httpShutdownWait(t, dialDone, "CONNECT dial cleanup") + httpShutdownWait(t, h.handlerDone, "CONNECT handler cleanup") + }) + mustWrite(t, h.client, []byte("CONNECT 127.0.0.1:12345 HTTP/1.1\r\nHost: 127.0.0.1:12345\r\n\r\n")) + requestCtx := httpShutdownRequestContext(t, h) + if !httpShutdownWait(t, dialEntered, "CONNECT dial") { + return + } + dialCtx := <-dialContext + // Early tunnel bytes arrive while CONNECT is still awaiting its dial. The + // passive observer only sees net/http's existing one-byte read; no new + // reader is allowed to steal these bytes to obtain cancellation. + mustWrite(t, h.client, []byte("early CONNECT payload")) + if !httpShutdownWait(t, h.listener.byteRead, "net/http early CONNECT byte") { + return + } + if err := requestCtx.Err(); err != nil { + t.Fatalf("early CONNECT bytes canceled the request: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + err := h.server.Shutdown(ctx) + cancel() + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("forced Shutdown = %v, want deadline exceeded", err) + } + httpShutdownWait(t, requestCtx.Done(), "pending CONNECT request cancellation") + httpShutdownWait(t, dialCtx.Done(), "pending CONNECT dial context cancellation") + httpShutdownWait(t, dialDone, "pending CONNECT dial after forced Shutdown") + httpShutdownWait(t, h.handlerDone, "pending CONNECT handler after forced Shutdown") + if !errors.Is(requestCtx.Err(), context.Canceled) || !errors.Is(dialCtx.Err(), context.Canceled) { + t.Errorf("CONNECT cancellation errors = request %v, dial %v", requestCtx.Err(), dialCtx.Err()) + } + _ = h.client.SetReadDeadline(time.Now().Add(2 * time.Second)) + response, readErr := io.ReadAll(h.client) + if strings.Contains(string(response), "200 Connection Established") { + t.Errorf("forced canceled CONNECT reported success: %q", response) + } + if readErr != nil && !errors.Is(readErr, net.ErrClosed) { + var netErr net.Error + if errors.As(readErr, &netErr) && netErr.Timeout() { + t.Errorf("downstream CONNECT connection remained open: %v", readErr) + } + } +} diff --git a/internal/proxy/lifecycle.go b/internal/proxy/lifecycle.go index 5e010b3..d0cffad 100644 --- a/internal/proxy/lifecycle.go +++ b/internal/proxy/lifecycle.go @@ -88,12 +88,33 @@ func (t *connTracker) closeAll() { type trackedConn struct { net.Conn - once sync.Once - tracker *connTracker - activityTimeout atomic.Int64 - deadlineMu sync.Mutex - externalRead time.Time - externalWrite time.Time + once sync.Once + tracker *connTracker + activityTimeout atomic.Int64 + deadlineMu sync.Mutex + externalRead time.Time + externalWrite time.Time + closeMu sync.Mutex + closed bool + connectionCancel context.CancelFunc +} + +// connectionContext binds net/http's connection context to explicit socket +// closure. Its background reader can stop after caching a pipelined byte, so +// closing the socket alone need not produce a read error that cancels a request. +// ConnContext calls this once per accepted connection. Registration and Close +// may race during shutdown; either ordering must return a canceled context. +func (c *trackedConn) connectionContext(parent context.Context) context.Context { + ctx, cancel := context.WithCancel(parent) + c.closeMu.Lock() + if c.closed { + c.closeMu.Unlock() + cancel() + return ctx + } + c.connectionCancel = cancel + c.closeMu.Unlock() + return ctx } func (c *trackedConn) Read(p []byte) (int, error) { @@ -193,7 +214,17 @@ func (c *trackedConn) setActivityTimeout(timeout time.Duration) { func (c *trackedConn) Close() error { err := c.Conn.Close() - c.once.Do(func() { c.tracker.remove(c) }) + c.once.Do(func() { + c.closeMu.Lock() + c.closed = true + cancel := c.connectionCancel + c.connectionCancel = nil + c.closeMu.Unlock() + if cancel != nil { + cancel() + } + c.tracker.remove(c) + }) return err } diff --git a/internal/proxy/lifecycle_context_test.go b/internal/proxy/lifecycle_context_test.go new file mode 100644 index 0000000..3ab60a9 --- /dev/null +++ b/internal/proxy/lifecycle_context_test.go @@ -0,0 +1,290 @@ +package proxy + +import ( + "context" + "errors" + "io" + "net" + "sync" + "testing" + "time" +) + +func trackedContextPipe(t *testing.T) (*trackedConn, net.Conn) { + t.Helper() + local, peer := net.Pipe() + tracker := newConnTracker(1) + tracked := &trackedConn{Conn: local, tracker: tracker} + if !tracker.add(tracked) { + t.Fatal("could not register pipe") + } + t.Cleanup(func() { + _ = peer.Close() + _ = tracked.Close() + }) + return tracked, peer +} + +func TestTrackedConnectionContextCloseBeforeOrAfterBinding(t *testing.T) { + for _, closeBeforeBinding := range []bool{false, true} { + name := "bind-then-close" + if closeBeforeBinding { + name = "close-then-bind" + } + t.Run(name, func(t *testing.T) { + tracked, _ := trackedContextPipe(t) + type valueKey struct{} + parent := context.WithValue(context.Background(), valueKey{}, "parent-value") + if closeBeforeBinding { + if err := tracked.Close(); err != nil { + t.Fatal(err) + } + } + ctx := tracked.connectionContext(parent) + if got := ctx.Value(valueKey{}); got != "parent-value" { + t.Fatalf("connection context value = %v", got) + } + if !closeBeforeBinding { + if err := ctx.Err(); err != nil { + t.Fatalf("open connection context is canceled: %v", err) + } + if err := tracked.Close(); err != nil { + t.Fatal(err) + } + } + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("closed connection context error = %v", ctx.Err()) + } + if err := parent.Err(); err != nil { + t.Fatalf("closing one connection canceled its parent: %v", err) + } + select { + case <-tracked.tracker.zero: + default: + t.Fatal("closed connection kept its tracker slot") + } + if err := tracked.Close(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestTrackedConnectionContextConcurrentBindAndClose(t *testing.T) { + for range 100 { + tracked, _ := trackedContextPipe(t) + parent, cancelParent := context.WithCancel(context.Background()) + start := make(chan struct{}) + bound := make(chan context.Context, 1) + closed := make(chan error, 1) + go func() { + <-start + bound <- tracked.connectionContext(parent) + }() + go func() { + <-start + closed <- tracked.Close() + }() + close(start) + ctx := <-bound + err := <-closed + // Both workers are joined before any assertion can exit this test. + parentErr := parent.Err() + connectionErr := ctx.Err() + cancelParent() + if err != nil { + t.Fatal(err) + } + if parentErr != nil { + t.Fatalf("connection close canceled parent: %v", parentErr) + } + if !errors.Is(connectionErr, context.Canceled) { + t.Fatalf("concurrent close missed cancellation: %v", connectionErr) + } + select { + case <-tracked.tracker.zero: + default: + t.Fatal("concurrent close kept its tracker slot") + } + } +} + +func TestTrackedConnectionContextParentCancellationKeepsSocketOpen(t *testing.T) { + tracked, peer := trackedContextPipe(t) + parent, cancelParent := context.WithCancelCause(context.Background()) + t.Cleanup(func() { cancelParent(nil) }) + ctx := tracked.connectionContext(parent) + parentCause := errors.New("parent canceled") + cancelParent(parentCause) + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("parent cancellation did not reach connection: %v", ctx.Err()) + } + if !errors.Is(context.Cause(ctx), parentCause) { + t.Fatalf("connection context lost parent cancellation cause: %v", context.Cause(ctx)) + } + select { + case <-tracked.tracker.zero: + t.Fatal("context cancellation released a still-open socket") + default: + } + if err := tracked.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if err := peer.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + wrote := make(chan error, 1) + go func() { + _, err := peer.Write([]byte("socket-still-open")) + wrote <- err + }() + body := make([]byte, len("socket-still-open")) + _, readErr := io.ReadFull(tracked, body) + writeErr := <-wrote + if readErr != nil || writeErr != nil || string(body) != "socket-still-open" { + t.Fatalf("socket after context cancellation: body=%q read=%v write=%v", body, readErr, writeErr) + } +} + +func TestTrackedConnectionContextCancellationPrecedesTrackerRelease(t *testing.T) { + tracked, _ := trackedContextPipe(t) + ctx := tracked.connectionContext(context.Background()) + cancelStarted := make(chan struct{}) + releaseCancel := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseCancel) }) } + t.Cleanup(release) + cancelResult := make(chan error, 1) + // Instrument the registered cancellation callback so the ordering check is + // deterministic even if tracker removal and cancellation are adjacent. + // Registration is complete and no worker has started yet. + originalCancel := tracked.connectionCancel + tracked.connectionCancel = func() { + var err error + if !tracked.closeMu.TryLock() { + err = errors.New("context canceled under connection close mutex") + } else { + tracked.closeMu.Unlock() + } + if !tracked.tracker.mu.TryLock() { + err = errors.Join(err, errors.New("context canceled under tracker mutex")) + } else { + tracked.tracker.mu.Unlock() + } + cancelResult <- err + close(cancelStarted) + <-releaseCancel + originalCancel() + } + closed := make(chan error, 1) + go func() { closed <- tracked.Close() }() + cancelObserved, closeObserved := false, false + var closeErr error + select { + case <-cancelStarted: + cancelObserved = true + case closeErr = <-closed: + closeObserved = true + case <-time.After(2 * time.Second): + } + releasedEarly := false + select { + case <-tracked.tracker.zero: + releasedEarly = true + default: + } + var cancelErr error + if cancelObserved { + cancelErr = <-cancelResult + } + release() + if !closeObserved { + select { + case closeErr = <-closed: + case <-time.After(2 * time.Second): + t.Fatal("Close worker did not finish after releasing cancellation callback") + } + } + // The callback and Close worker are joined before assertions/cleanup. + if !cancelObserved { + t.Fatal("Close did not invoke the registered context cancellation callback") + } + if cancelErr != nil { + t.Fatal(cancelErr) + } + if closeErr != nil { + t.Fatal(closeErr) + } + if releasedEarly { + t.Fatal("tracker reached zero before connection cancellation returned") + } + select { + case <-tracked.tracker.zero: + default: + t.Fatal("tracker did not reach zero after close") + } + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("tracker reached zero with a live connection context: %v", ctx.Err()) + } +} + +func TestTrackedConnectionContextCloseWriteKeepsContextAndReplyAlive(t *testing.T) { + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + if err := listener.SetDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + peer, err := net.DialTCP("tcp", nil, listener.Addr().(*net.TCPAddr)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = peer.Close() }) + local, err := listener.AcceptTCP() + if err != nil { + t.Fatal(err) + } + tracker := newConnTracker(1) + tracked := &trackedConn{Conn: local, tracker: tracker} + t.Cleanup(func() { _ = tracked.Close() }) + if !tracker.add(tracked) { + t.Fatal("could not register TCP connection") + } + ctx := tracked.connectionContext(context.Background()) + if err := tracked.SetDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if err := peer.SetDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if err := tracked.CloseWrite(); err != nil { + t.Fatal(err) + } + if err := ctx.Err(); err != nil { + t.Fatalf("TCP write half-close canceled connection context: %v", err) + } + select { + case <-tracker.zero: + t.Fatal("TCP write half-close released the connection slot") + default: + } + if n, err := peer.Read(make([]byte, 1)); n != 0 || !errors.Is(err, io.EOF) { + t.Fatalf("peer read after write half-close = %d, %v", n, err) + } + const response = "response-after-half-close" + if _, err := peer.Write([]byte(response)); err != nil { + t.Fatal(err) + } + body := make([]byte, len(response)) + if _, err := io.ReadFull(tracked, body); err != nil || string(body) != response { + t.Fatalf("reply after half-close = %q, %v", body, err) + } + if err := tracked.Close(); err != nil { + t.Fatal(err) + } + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("full close after half-close did not cancel context: %v", ctx.Err()) + } +}