fix(SSE)!: redesign EventStreamChan to prevent context pool recycling

BREAKING CHANGE: EventStreamChan signature changed from
  (chan<- Event, <-chan error)
to
  (eventChan <-chan Event)
The caller now creates and passes the channel instead of receiving it.
The errChan return value is removed.

The old non-blocking design allowed the handler to return before the SSE
stream ended, causing ServeHTTP to return the Context to the pool while
the internal goroutine was still writing to the pooled writer — a data
race across requests. The new blocking design keeps the handler inside
EventStreamChan until the event channel is closed or the client
disconnects, ensuring the Context remains bound throughout the stream.

- Caller creates channel, producer goroutine sends events
- EventStreamChan blocks handler until stream ends
- Internal goroutine captures stable references (Flusher, context.Context)
  instead of holding *Context pointer
- Nil guard on Flusher type assertion
- Add sse_test.go covering blocking, disconnect, and event format
- Update docs/sse.md for new API
This commit is contained in:
wjqserver 2026-03-29 15:42:01 +08:00
parent 8031e799d9
commit 2f94763c65
3 changed files with 188 additions and 40 deletions

143
sse_test.go Normal file
View file

@ -0,0 +1,143 @@
package touka
import (
"context"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestEventStreamChanBlocksHandler verifies that EventStreamChan blocks until
// the event channel is closed.
func TestEventStreamChanBlocksHandler(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
c, _ := CreateTestContextWithRequest(rr, req)
handlerReturned := make(chan struct{})
eventChan := make(chan Event)
// Start producer goroutine before EventStreamChan blocks
go func() {
defer close(eventChan)
time.Sleep(30 * time.Millisecond)
eventChan <- Event{Data: "hello"}
time.Sleep(30 * time.Millisecond)
}()
go func() {
c.EventStreamChan(eventChan)
close(handlerReturned)
}()
// Wait for goroutine to start
time.Sleep(10 * time.Millisecond)
// Handler should NOT have returned (eventChan not closed)
select {
case <-handlerReturned:
t.Fatal("Handler returned before eventChan was closed - EventStreamChan is not blocking")
case <-time.After(40 * time.Millisecond):
// good, still blocking
}
// Wait for producer to finish (30+30ms + margin)
select {
case <-handlerReturned:
// good, handler returned
case <-time.After(200 * time.Millisecond):
t.Fatal("Handler did not return after eventChan was closed")
}
}
// TestEventStreamChanUnblocksOnClientDisconnect verifies the handler returns
// when the request context is cancelled, even if eventChan is never closed.
func TestEventStreamChanUnblocksOnClientDisconnect(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil).WithContext(ctx)
c, _ := CreateTestContextWithRequest(rr, req)
eventChan := make(chan Event)
handlerReturned := make(chan struct{})
// Producer never closes eventChan
go func() {
for {
select {
case <-ctx.Done():
return
default:
eventChan <- Event{Data: "tick"}
time.Sleep(10 * time.Millisecond)
}
}
}()
go func() {
c.EventStreamChan(eventChan)
close(handlerReturned)
}()
// Handler should NOT have returned
select {
case <-handlerReturned:
t.Fatal("Handler returned before stream ended")
case <-time.After(60 * time.Millisecond):
// good, still blocked
}
// Cancel context to simulate client disconnect
cancel()
select {
case <-handlerReturned:
// good
case <-time.After(200 * time.Millisecond):
t.Fatal("Handler did not return after client disconnect")
}
}
// TestEventStreamChanWritesEvents verifies the SSE event format is correct.
func TestEventStreamChanWritesEvents(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
c, _ := CreateTestContextWithRequest(rr, req)
eventChan := make(chan Event)
go func() {
defer close(eventChan)
eventChan <- Event{Id: "1", Event: "tick", Data: "hello\nworld"}
eventChan <- Event{Id: "2", Data: "second"}
}()
c.EventStreamChan(eventChan)
body := rr.Body.String()
ct := rr.Header().Get("Content-Type")
if !strings.Contains(ct, "text/event-stream") {
t.Fatalf("expected text/event-stream content type, got %q", ct)
}
if !strings.Contains(body, "id: 1") {
t.Fatal("missing id field in first event")
}
if !strings.Contains(body, "event: tick") {
t.Fatal("missing event field in first event")
}
if !strings.Contains(body, "data: hello") {
t.Fatal("missing data line 1 in first event")
}
if !strings.Contains(body, "data: world") {
t.Fatal("missing data line 2 in first event")
}
if !strings.Contains(body, "id: 2") {
t.Fatal("missing id field in second event")
}
if !strings.Contains(body, "data: second") {
t.Fatal("missing data in second event")
}
}