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>
66 lines
2.1 KiB
Go
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)
|
|
}
|
|
}
|