mirror of
https://github.com/infinite-iroha/touka.git
synced 2026-06-13 15:47:38 +08:00
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
143 lines
3.6 KiB
Go
143 lines
3.6 KiB
Go
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")
|
|
}
|
|
}
|