Files
pad/internal/server/sse_stream.go
T
xarmian 8a1aaf61a8 fix(server): a failed flush reaches the handler through chi's wrappers (BUG-3827) (#2186)
* fix(server): a failed flush reaches the handler through chi's wrappers (BUG-3827)

chi's WrapResponseWriter (logger, metrics, MCP audit) and its compressor
implement Flush but not FlushError, so http.ResponseController.Flush called
Flush and returned nil. A flush to a client that had stopped reading
"succeeded", and sseStream's Dead/Err never saw it; slots were released only
because the request context ended.

flushErrorWriter wraps each of the four chi writers. Its FlushError runs
chi's own Flush (status bookkeeping, the compressor's encoder) and then
flushes the writer beneath through a ResponseController, which returns
net/http's sticky write error. It unwraps to the chi writer, so write
deadlines still reach the connection.

Pins: a stalled flush through the outer chain (identity and gzip), the
activity stream seeing its failed keepalive flush (test-only seam
Server.sseStreamFailedForTesting), and MCPAuditLog with its audit writer
started (it is a passthrough without one). With FlushError renamed away,
all four fail.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): the flush-error wrapper keeps exactly the chi writer's interfaces (BUG-3827 codex r1)

The r0 wrapper hid io.ReaderFrom (io.Copy's sendfile path) and http.Pusher,
and added Hijacker and Flusher where chi had none. withFlushError now leaves
a non-Flusher alone and returns a variant with exactly the chi writer's
Hijacker / ReaderFrom / Pusher set; a shape chi never produces is left
unwrapped. TestBUG3827_WrapperKeepsTheChiWritersInterfaces pins all seven
NewWrapResponseWriter shapes plus the compressor; against the r0 wrapper 7
of 8 fail.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(server): each chain leg proves its encoder, and deflate gets a leg (BUG-3827 codex r2)

openStalledStream returns the response head; every leg of the stalled-flush
chain test asserts its Content-Encoding, and a deflate leg joins identity and
gzip. With compression bypassed, gzip and deflate fail on that assertion.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 01:02:42 -04:00

141 lines
4.4 KiB
Go

package server
import (
"errors"
"fmt"
"net/http"
"sync"
"time"
)
// sseWriteTimeout bounds one frame on a long-lived event stream: a single
// Write or Flush that has not completed in this long ends the stream
// (BUG-3637). The server sets no WriteTimeout, because a stream lives for
// hours, so without this a client that stops reading leaves its handler
// blocked in a write once the socket buffers fill. That handler is the one
// goroutine that would notice cancellation, revalidate access and release the
// admission slot, so all three stop with it.
//
// Generous on purpose: a keepalive is a few bytes and a replay frame a few
// kilobytes, so a reader that is merely slow drains one in far less. Pinned
// at no less than twice the keepalive cadence on both streams (the test
// TestSSE_BUG3637_WriteTimeoutOutlastsTheKeepaliveCadence), so a reader that
// can keep up with keepalives is never cut off by this bound.
const sseWriteTimeout = 2 * sseKeepaliveInterval
// sseStream is the ResponseWriter an event-stream handler writes through once
// it has decided to stream. Every Write and Flush arms a fresh write deadline
// first, and the first failure is sticky: later writes return it at once, and
// dead is closed so the handler's select loop can leave without waiting for
// its next write to fail.
type sseStream struct {
http.ResponseWriter
rc *http.ResponseController
timeout time.Duration
mu sync.Mutex
err error
dead chan struct{}
failedForTesting func(err error) // Server.sseStreamFailedForTesting
}
// sseFrameWriteTimeout is sseWriteTimeout, or a test's override.
func (s *Server) sseFrameWriteTimeout() time.Duration {
if s.sseWriteTimeoutOverride > 0 {
return s.sseWriteTimeoutOverride
}
return sseWriteTimeout
}
// errSSENoWriteDeadline is openSSEStream's refusal: the writer cannot take a
// write deadline, so nothing could bound a frame.
var errSSENoWriteDeadline = errors.New("event stream: the response writer does not support write deadlines")
// openSSEStream wraps w, or refuses when w cannot take a write deadline. The
// bound is always sseFrameWriteTimeout: a method taking no timeout, so a
// handler has no way to pass a different one (codex r4). It
// FAILS CLOSED (codex r1 High): streaming without the deadline is the pinned
// handler this type exists to prevent, so a writer that cannot be bounded is
// not streamed to at all. The probe clears any deadline, which is the state
// net/http leaves a connection in when it has no WriteTimeout. net/http's own
// HTTP/1 and HTTP/2 writers support it; a wrapper without Unwrap does not.
func (s *Server) openSSEStream(w http.ResponseWriter) (*sseStream, error) {
rc := http.NewResponseController(w)
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
return nil, fmt.Errorf("%w: %w", errSSENoWriteDeadline, err)
}
return &sseStream{
ResponseWriter: w,
rc: rc,
timeout: s.sseFrameWriteTimeout(),
dead: make(chan struct{}),
failedForTesting: s.sseStreamFailedForTesting,
}, nil
}
// Unwrap lets http.ResponseController and middleware see the writer beneath.
func (s *sseStream) Unwrap() http.ResponseWriter { return s.ResponseWriter }
// Dead is closed on the stream's first write or flush failure.
func (s *sseStream) Dead() <-chan struct{} { return s.dead }
// Err is the stream's first write or flush failure, or nil.
func (s *sseStream) Err() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.err
}
func (s *sseStream) Write(p []byte) (int, error) {
if err := s.Err(); err != nil {
return 0, err
}
if err := s.arm(); err != nil {
return 0, err
}
n, err := s.ResponseWriter.Write(p)
if err != nil {
s.fail(err)
}
return n, err
}
// Flush satisfies http.Flusher, which has no error return; a failure is
// recorded and reported through Err, Dead and the next Write.
func (s *sseStream) Flush() {
if s.Err() != nil {
return
}
if s.arm() != nil {
return
}
if err := s.rc.Flush(); err != nil {
s.fail(err)
}
}
// arm sets a fresh deadline for the write about to happen. A failure here
// fails the stream: the write would otherwise be unbounded.
func (s *sseStream) arm() error {
if err := s.rc.SetWriteDeadline(time.Now().Add(s.timeout)); err != nil {
s.fail(err)
return err
}
return nil
}
func (s *sseStream) fail(err error) {
s.mu.Lock()
first := s.err == nil
if first {
s.err = err
close(s.dead)
}
s.mu.Unlock()
if first && s.failedForTesting != nil {
s.failedForTesting(err)
}
}