Files
silo-server/internal/api/middleware/stream_deadline_chain_test.go
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

66 lines
2.1 KiB
Go

package middleware_test
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
chimw "github.com/go-chi/chi/v5/middleware"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/httpstream"
)
// TestStreamingDeadlineSurvivesMiddlewareChain guards the Unwrap chain: the
// streaming handlers' rolling write deadline only works if every
// ResponseWriter wrapper between the server and the handler implements
// Unwrap, so http.ResponseController can reach the connection. This assembles
// the real wrapping middlewares from the main API chain (RequestLogger,
// Metrics, Compress) around a streaming handler and proves a slow response
// outlives the server's WriteTimeout. If a new middleware wraps the writer
// without Unwrap, this fails with a truncated body.
func TestStreamingDeadlineSurvivesMiddlewareChain(t *testing.T) {
const (
writeEvery = 50 * time.Millisecond
writes = 60 // ~3s total, 3x the server WriteTimeout
chunk = "0123456789abcdef"
)
var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := httpstream.NewRollingDeadlineWriter(w)
sw.WriteHeader(http.StatusOK)
for i := 0; i < writes; i++ {
if _, err := sw.Write([]byte(chunk)); err != nil {
return
}
sw.Flush()
time.Sleep(writeEvery)
}
})
// Same order as internal/api/router.go: RequestLogger, Metrics, Compress.
handler = chimw.Compress(5)(handler)
handler = apimw.Metrics(handler)
handler = apimw.RequestLogger("test-node")(handler)
srv := httptest.NewUnstartedServer(handler)
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 through middleware chain at %d bytes: %v (a wrapper without Unwrap?)", len(body), err)
}
if want := writes * len(chunk); len(body) != want {
t.Fatalf("short body through middleware chain: got %d bytes, want %d", len(body), want)
}
}