textmachine/platform/internal/httpapi/serve_test.go

290 lines
10 KiB
Go

package httpapi
import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"strings"
"testing"
"time"
)
// start runs a REAL http.Server, the same one main builds, on a loopback port. Everything below
// needs that: the defects this file pins live in the server's connection handling, and a mux under
// httptest never touches it.
func start(t *testing.T, h http.Handler, to Timeouts) net.Listener {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
srv := serverWithTimeouts("127.0.0.1:0", h, quietLogger(), to)
ln, err := srv.Listen(ctx)
if err != nil {
cancel()
t.Fatalf("listen: %v", err)
}
done := make(chan error, 1)
go func() { done <- srv.Run(ctx, ln) }()
t.Cleanup(func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("run: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("server did not stop")
}
})
return ln
}
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func fastTimeouts() Timeouts {
return Timeouts{ReadHeader: time.Second, Read: 250 * time.Millisecond, Idle: time.Second, Shutdown: 3 * time.Second}
}
// PD-2. A client that announces a body and then stops sending pins the connection: net/http drains
// the unread body inside the response's header write, and that read inherits the connection
// deadline. With no ReadTimeout the server waits forever and the connection is held until the
// CLIENT decides to leave. Mutation caught: delete ReadTimeout from NewServer.
func TestHalfFedRequestIsDroppedByTheServer(t *testing.T) {
t.Parallel()
ln := start(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Reads nothing, exactly like the guard that rejects an unauthenticated POST.
w.WriteHeader(http.StatusUnauthorized)
}), fastTimeouts())
var d net.Dialer
conn, err := d.DialContext(t.Context(), "tcp", ln.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
if _, err := fmt.Fprint(conn, "POST /v0/books HTTP/1.1\r\nHost: x\r\nContent-Length: 4096\r\n\r\nhalf"); err != nil {
t.Fatalf("write: %v", err)
}
// Generous relative to the 250ms ReadTimeout and short relative to "forever": the assertion is
// that the SERVER let go, not that it was fast.
if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
t.Fatalf("deadline: %v", err)
}
start := time.Now()
if _, err := io.ReadAll(conn); err != nil {
t.Fatalf("server never closed the connection after %s: %v (connection pinned — PD-2)", time.Since(start), err)
}
}
// PD-9. A signal must START the drain, not end every in-flight request. With the signal context
// used as BaseContext, a context-aware handler is cancelled the instant the signal arrives and the
// grace period is decorative. Mutation caught: BaseContext returning the ctx passed to Run.
func TestShutdownDrainsInFlightRequests(t *testing.T) {
t.Parallel()
entered := make(chan struct{})
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(entered)
select {
case <-time.After(300 * time.Millisecond):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("finished"))
case <-r.Context().Done():
w.WriteHeader(http.StatusServiceUnavailable)
}
})
ctx, cancel := context.WithCancel(context.Background())
srv := serverWithTimeouts("127.0.0.1:0", h, quietLogger(), fastTimeouts())
ln, err := srv.Listen(ctx)
if err != nil {
cancel()
t.Fatalf("listen: %v", err)
}
runDone := make(chan error, 1)
go func() { runDone <- srv.Run(ctx, ln) }()
type result struct {
status int
body string
}
resp := make(chan result, 1)
go func() {
r, err := get(t.Context(), "http://"+ln.Addr().String()+"/slow")
if err != nil {
resp <- result{-1, err.Error()}
return
}
defer r.Body.Close()
b, _ := io.ReadAll(r.Body)
resp <- result{r.StatusCode, string(b)}
}()
<-entered
cancel() // the signal
select {
case got := <-resp:
if got.status != http.StatusOK || got.body != "finished" {
t.Fatalf("in-flight request was cut by the shutdown: status %d body %q (PD-9)", got.status, got.body)
}
case <-time.After(5 * time.Second):
t.Fatal("no response")
}
select {
case err := <-runDone:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Run did not return")
}
}
// The other half of the PD-2 fix: a response that takes longer than ReadTimeout to write must
// still arrive whole. It does so with no help from the handler — net/http clears the connection's
// read deadline once the request has arrived and nothing re-arms it (server.go:2059-2062) — so what
// is pinned here is that property and the wrappers the response controller reaches through.
// Mutation caught: removing ReadTimeout's harmlessness (any re-arming), or statusRecorder.Unwrap,
// without which Flush cannot find the real writer and the frames sit in the buffer.
func TestStreamOutlivesReadTimeout(t *testing.T) {
t.Parallel()
to := fastTimeouts()
flushed := make(chan error, 1)
streamed := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
flushed <- rc.Flush()
select {
case <-time.After(3 * to.Read): // well past the read deadline the connection started with
case <-r.Context().Done():
return // the stream was cut: the client below will see EOF without the late frame
}
_, _ = fmt.Fprint(w, "data: late\n\n")
_ = rc.Flush()
})
// Wrapped in the same chain a real route gets, because the wrappers are what the response
// controller has to reach through.
ln := start(t, AccessLog(quietLogger())(Recover(quietLogger())(streamed)), to)
resp, err := get(t.Context(), "http://"+ln.Addr().String()+"/stream")
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
select {
case err := <-flushed:
if err != nil {
t.Errorf("flush through the middleware wrappers: %v (statusRecorder.Unwrap)", err)
}
case <-time.After(5 * time.Second):
t.Fatal("handler never flushed")
}
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
if strings.Contains(sc.Text(), "late") {
return
}
}
t.Fatal("stream ended before the late frame: the read deadline cut a long-lived response")
}
// PD-51, the case that decided the fate of the zone's ClearReadDeadline helper. On a request whose
// body was announced and never finished, the drain inside the response header write is the ONLY
// thing bounding the connection, and it is bounded by Timeouts.Read. A handler that clears the read
// deadline first — which the helper's doc comment used to call mandatory for streaming — removes
// that bound and hangs inside WriteHeader for as long as the client keeps the socket: PD-2 again,
// re-created by the fix for it. Measured: handler still stuck 4s after the client had gone.
// Mutation caught: deleting ReadTimeout from NewServer; reintroducing a deadline clear here.
func TestHalfFedStreamingRequestIsCutLoose(t *testing.T) {
t.Parallel()
to := fastTimeouts()
woke := make(chan error, 1)
streamed := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
// The flush is what blocks: WriteHeader only records the status, and the drain of the body
// the client never finished happens when the header is actually put on the wire. A handler
// that never flushes never reaches it — which is why this is the streaming shape, not a
// bare WriteHeader.
_ = http.NewResponseController(w).Flush()
select {
case <-r.Context().Done():
woke <- r.Context().Err()
case <-time.After(2 * time.Second):
woke <- nil
}
})
ln := start(t, streamed, to)
var d net.Dialer
conn, err := d.DialContext(t.Context(), "tcp", ln.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
if _, err := fmt.Fprint(conn, "POST /stream HTTP/1.1\r\nHost: x\r\nContent-Length: 4096\r\n\r\nhalf"); err != nil {
t.Fatalf("write: %v", err)
}
select {
case err := <-woke:
if err == nil {
t.Fatal("a half-fed connection outlived the read timeout: nothing bounds it once the drain does not (PD-51)")
}
case <-time.After(5 * time.Second):
t.Fatal("handler never woke: stuck in WriteHeader draining a body that never arrives")
}
}
// PD-46. The behavioural tests above build their own short deadlines, so a defect in the ones the
// daemon ships is invisible to them — the exact shape in which PD-2 survived P0, a property checked
// on an object that is not the one shipped.
//
// This asserts on NewServer, which is now the ONLY way to build the serving server and takes no
// timeouts, so main cannot pass different ones: the value and the wiring are the same call. An
// earlier version of this test passed DefaultTimeouts() itself and therefore proved only half —
// replacing main's argument left it green (found by review, measured).
// Mutation caught: DefaultTimeouts().Read = 0; dropping any timeout line from serverWithTimeouts.
func TestTheServerTheDaemonRunsHasEveryDeadlineSet(t *testing.T) {
t.Parallel()
srv := NewServer("127.0.0.1:0", http.NotFoundHandler(), quietLogger())
for _, c := range []struct {
name string
got time.Duration
}{
{"ReadHeaderTimeout", srv.http.ReadHeaderTimeout},
{"ReadTimeout", srv.http.ReadTimeout},
{"IdleTimeout", srv.http.IdleTimeout},
} {
if c.got <= 0 {
t.Errorf("%s is %v: a connection with no deadline is held for as long as the client likes (PD-2)",
c.name, c.got)
}
}
// Absent ON PURPOSE, and the absence is as load-bearing as the values above: a write deadline
// set here cuts an SSE response at a fixed age. Setting it "for symmetry" is the regression.
if srv.http.WriteTimeout != 0 {
t.Errorf("WriteTimeout is %v, want unset: it would cut a streaming response", srv.http.WriteTimeout)
}
if srv.grace <= 0 {
t.Errorf("shutdown grace is %v: a drain would give in-flight requests no time at all", srv.grace)
}
if srv.http.ReadHeaderTimeout > srv.http.ReadTimeout {
t.Errorf("ReadHeaderTimeout %v exceeds ReadTimeout %v: the header deadline can never fire",
srv.http.ReadHeaderTimeout, srv.http.ReadTimeout)
}
}
func get(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return http.DefaultClient.Do(req)
}