Files
silo-server/internal/httpstream/rolling_deadline_test.go
T
b7292a9473 fix(streaming): stop killing healthy streams at the server WriteTimeout (#361)
The main API server's WriteTimeout (120s) is an absolute deadline from
request start, so every streaming response still being written at T+120s
was cut mid-body with a clean close. Clients saw multi-GB direct streams
truncate every two minutes; the Apple client's cursor-resume reconnect
absorbed most kills silently, but one landing during backpressure or a
demuxer resync exhausted its retry budget and forced a full player
teardown (visible stop + historical audio desync seeding).

Fix: internal/httpstream.RollingDeadlineWriter pushes the connection's
write deadline forward with progress via http.ResponseController — a
response that keeps moving lives indefinitely, a stalled one is still
reaped within the window (180s default, SILO_STREAM_WRITE_STALL_TIMEOUT
to override). ReadFrom delegates in bounded slices so http.ServeContent
keeps its sendfile fast path. Wired into direct play, remux, downloads,
the transcode-node proxy, and ebook serving; the server-level 120s guard
stays for every other route.

The metrics and request-logger response writers now implement Unwrap —
without it http.ResponseController cannot traverse to the connection and
SetWriteDeadline fails, silently disabling the fix (exactly what the
first dev deploy showed). A middleware-chain integration test locks the
whole path down against future wrappers missing Unwrap.

Validated on dev: 200s/512MB direct and 300s/768MB via CDN sustained
range-GETs (previously dying at 120s), zero duration_ms=120000 stream
entries since deploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:07:16 -04:00

183 lines
5.1 KiB
Go

package httpstream
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestStreamSurvivesServerWriteTimeout is the regression test for the 120s
// stream-truncation bug: a response that keeps making progress must outlive
// the server's absolute WriteTimeout when wrapped.
func TestStreamSurvivesServerWriteTimeout(t *testing.T) {
const (
writeEvery = 50 * time.Millisecond
writes = 60 // ~3s total, 3x the server WriteTimeout
chunk = "0123456789abcdef"
)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := newRollingDeadlineWriter(w, 2*time.Second, 0 /* bump every write */)
sw.WriteHeader(http.StatusOK)
for i := 0; i < writes; i++ {
if _, err := sw.Write([]byte(chunk)); err != nil {
return
}
sw.Flush()
time.Sleep(writeEvery)
}
}))
srv.Config.WriteTimeout = 1 * time.Second
srv.Start()
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("stream died before completion (got %d bytes): %v", len(body), err)
}
if want := writes * len(chunk); len(body) != want {
t.Fatalf("short body: got %d bytes, want %d", len(body), want)
}
}
// TestUnwrappedStreamStillKilledAtWriteTimeout proves the server-level guard
// is unchanged for handlers that do not opt in.
func TestUnwrappedStreamStillKilledAtWriteTimeout(t *testing.T) {
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
f := w.(http.Flusher)
for i := 0; i < 60; i++ {
if _, err := w.Write([]byte("0123456789abcdef")); err != nil {
return
}
f.Flush()
time.Sleep(50 * time.Millisecond)
}
}))
srv.Config.WriteTimeout = 500 * time.Millisecond
srv.Start()
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
return // connection died before headers: also a kill, test passes
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err == nil && len(body) == 60*16 {
t.Fatal("unwrapped stream survived the server WriteTimeout; guard is gone")
}
}
// TestStalledClientReaped proves the wrapper still bounds a genuinely stalled
// connection: a client that stops reading must cause a write error within
// roughly the stall window, not never.
func TestStalledClientReaped(t *testing.T) {
handlerDone := make(chan error, 1)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := newRollingDeadlineWriter(w, 1*time.Second, 0)
sw.WriteHeader(http.StatusOK)
buf := make([]byte, 1<<20)
var err error
for i := 0; i < 256; i++ { // up to 256 MB >> any socket buffer
if _, err = sw.Write(buf); err != nil {
break
}
sw.Flush()
}
handlerDone <- err
}))
srv.Config.WriteTimeout = 0 // isolate: only the rolling deadline may reap
srv.Start()
defer srv.Close()
conn, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: x\r\n\r\n")
// Read just the status line, then stop reading entirely.
if _, err := bufio.NewReader(io.LimitReader(conn, 32)).ReadString('\n'); err != nil {
t.Fatalf("status line: %v", err)
}
select {
case err := <-handlerDone:
if err == nil {
t.Fatal("handler finished 256MB into a non-reading client without error")
}
case <-time.After(30 * time.Second):
t.Fatal("stalled client was never reaped by the rolling deadline")
}
}
// TestReadFromPreservesCompletion exercises the io.ReaderFrom path used by
// http.ServeContent (sendfile) under a server WriteTimeout shorter than the
// transfer, with a source large enough to require multiple bounded slices.
func TestReadFromPreservesCompletion(t *testing.T) {
const totalSize = 8 << 20
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := newRollingDeadlineWriter(w, 2*time.Second, 0)
sw.WriteHeader(http.StatusOK)
src := &slowReader{r: io.LimitReader(neverEnding('x'), totalSize), delay: 200 * time.Microsecond}
// io.Copy must take sw's ReadFrom path, as http.ServeContent does.
if _, err := io.Copy(sw, src); err != nil {
return
}
}))
srv.Config.WriteTimeout = 1 * time.Second
srv.Start()
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
n, err := io.Copy(io.Discard, resp.Body)
if err != nil {
t.Fatalf("stream died at %d bytes: %v", n, err)
}
if n != totalSize {
t.Fatalf("short body: got %d, want %d", n, totalSize)
}
}
type neverEnding byte
func (b neverEnding) Read(p []byte) (int, error) {
for i := range p {
p[i] = byte(b)
}
return len(p), nil
}
// slowReader throttles reads so the transfer outlives the server WriteTimeout.
type slowReader struct {
r io.Reader
delay time.Duration
}
func (s *slowReader) Read(p []byte) (int, error) {
time.Sleep(s.delay)
if len(p) > 32<<10 {
p = p[:32<<10]
}
return s.r.Read(p)
}