Files
pansy/internal/api/sse_deadline_test.go
T
steveandClaude Opus 4.8 04cdf815df
Build image / build-and-push (push) Successful in 8s
Bound each SSE write instead of removing the deadline entirely
Gadfly, security lens: clearing the write deadline outright traded the
truncation bug for an unbounded one. With no deadline, a client that stops
reading fills the socket buffer and blocks Write forever — pinning the agent
run goroutine and this stream's mutex, which also takes down the keep-alive
since it needs the same lock. The old 30s WriteTimeout at least bounded that.

Refresh the deadline per frame instead: the stream as a whole is unbounded,
but no single write is. That is the only shape that satisfies both ends, since
an absolute deadline cuts long turns and no deadline cannot be recovered from.

The probe stays in openEventStream so a writer that cannot take deadlines is
reported once rather than once per frame; the per-write call deliberately
ignores its error for the same reason.

Also widened the test's timing margins, per the second finding. tick is now
GREATER than writeTimeout, so every frame lands after the deadline has already
expired and the margin only ever grows — a loaded CI runner pushes this toward
passing rather than toward flaking. Sized the other way it would flake exactly
when CI is busiest. Passes 3/3 with -count=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 18:23:31 -04:00

72 lines
2.4 KiB
Go

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)
}
}