fix(handlers): use net.ListenTCP + close conn immediately after response

- Explicitly bind to IPv4 only with net.ListenTCP("tcp4", ...) to
  avoid IPv6 (::1) vs IPv4 (127.0.0.1) mismatch on macOS where
  Listen("tcp", "127.0.0.1:0") might bind ::1.
- Close the connection immediately after writing the response.
  If we keep it open, the client's request-body writer goroutine
  blocks on the socket (waiting for server to drain the body).
  Closing immediately unblocks it; the client already received
  the response so the write error is harmless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Molecule AI · core-be 2026-05-12 14:31:22 +00:00
parent c9fea76bc8
commit 42ec6f5cfa

View File

@ -60,13 +60,14 @@ const testTargetID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
// returning the server URL. The server URL (e.g. "http://127.0.0.1:<port>/")
// is suitable for caching in Redis and passing to executeDelegation.
//
// The server reads HTTP headers (which carry Content-Length) using a short
// deadline, then immediately sends the response. This prevents deadlock where
// io.Copy(io.Discard, conn) would wait for EOF (client waiting for headers
// before sending body → server waiting for body before sending response).
// The server reads HTTP headers using a deadline, then immediately sends the
// response. This prevents the classic TCP deadlock: server blocked reading
// body while client blocked waiting for response.
func rawHTTPServer(t *testing.T, statusCode int, body string) (serverURL string, closeFn func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
// Use ListenTCP with explicit IPv4 to avoid IPv6 mismatch on macOS
// (Listen("tcp", "127.0.0.1:0") might bind ::1 on some systems).
ln, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
if err != nil {
t.Fatalf("rawHTTPServer listen: %v", err)
}
@ -87,39 +88,37 @@ func rawHTTPServer(t *testing.T, statusCode int, body string) (serverURL string,
}
// Handle in background so we don't block test execution.
// Strategy: read HTTP headers using a 2-second deadline (enough for the
// client to send headers + a small body). After deadline fires, send
// the response. The kernel discards any unread buffered body bytes
// when the connection closes — harmless.
// Strategy: read available bytes with a deadline (enough for headers).
// After deadline fires, send the response immediately.
// The kernel discards any unread buffered body bytes when the
// connection closes — harmless.
go func() {
conn := <-connCh
if conn == nil {
t.Log("SERVER: accept goroutine got nil conn")
return
}
t.Logf("SERVER: connection accepted from %v", conn.RemoteAddr())
defer conn.Close()
// Read headers with deadline. After 2s, Read returns with whatever
// bytes have arrived (headers are always sent first by the HTTP client).
// Read what we can with a 2s deadline. Headers always arrive first.
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
headerBuf := make([]byte, 4096)
for {
n, err := conn.Read(headerBuf)
if n > 0 {
t.Logf("SERVER: read %d bytes", n)
_ = headerBuf[:n]
}
if err != nil {
t.Logf("SERVER: read done, err=%v", err)
break
}
}
// Send response immediately — don't wait for remaining body bytes.
// Send response and IMMEDIATELY close the connection.
// If we keep it open, the client's request-body writer goroutine
// might block on the socket (waiting for the server to drain the
// body). Closing immediately unblocks it. The client already
// received the response, so the write error is harmless.
resp := buildHTTPResponse(statusCode, body)
t.Logf("SERVER: sending response (%d bytes)", len(resp))
conn.Write(resp) //nolint:errcheck
t.Log("SERVER: response sent")
conn.Close()
}()
return serverURL, closeFn