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) { // Long enough that the frames below straddle it, short enough to keep the // test quick. Everything else is scaled off it. const writeTimeout = 300 * time.Millisecond const tick = 200 * time.Millisecond const frames = 4 // last one lands at ~800ms, well 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) } }