package api import ( "bufio" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" ) // TestEventStreamOutlivesWriteTimeout is the regression test for #78. // // http.Server.WriteTimeout is an ABSOLUTE deadline measured from when the // request header was read — not an idle timeout — so a streaming response is cut // once it passes, however recently the handler wrote. pansy sets it to 30s while // an agent turn may run for minutes. // // This has to be driven through a real http.Server and asserted from the CLIENT // side, because the failure cannot be observed from the handler: writes made // after the deadline return err == nil and their bytes are silently discarded. // A test that checked the return of io.WriteString would pass against the bug. func TestEventStreamOutlivesWriteTimeout(t *testing.T) { // tick > writeTimeout on purpose, so EVERY frame lands after the deadline has // already passed and the first one clears it by a full 100ms. The margin only // ever grows: a slow or loaded CI runner delays frames further past a deadline // that has already expired, so scheduling jitter pushes this test toward // passing rather than toward flaking. Sizing it the other way — frames landing // just before the deadline — would flake exactly when CI is busiest. const writeTimeout = 200 * time.Millisecond const tick = 300 * time.Millisecond const frames = 3 // ~900ms total; the last lands 700ms past the deadline gin.SetMode(gin.TestMode) r := gin.New() r.GET("/stream", func(c *gin.Context) { s := openEventStream(c) for i := 0; i < frames; i++ { time.Sleep(tick) s.send(chatEvent{Error: "frame"}) } }) srv := httptest.NewUnstartedServer(r) srv.Config.WriteTimeout = writeTimeout srv.Start() defer srv.Close() resp, err := srv.Client().Get(srv.URL + "/stream") if err != nil { t.Fatalf("get: %v", err) } defer resp.Body.Close() got := 0 sc := bufio.NewScanner(resp.Body) for sc.Scan() { if strings.HasPrefix(sc.Text(), "data: ") { got++ } } // Read errors are the symptom, not an aside: against the bug the client gets // an unexpected EOF partway through. Report it rather than only the count. if err := sc.Err(); err != nil { t.Errorf("client read error after %d/%d frames: %v", got, frames, err) } if got != frames { t.Errorf("client received %d frames, want %d — the stream was cut at WriteTimeout", got, frames) } }