fix(sse): flush response headers before blocking select loop

Without flusher.Flush() after WriteHeader, the HTTP response headers
were buffered and never sent to the client, causing SSE clients to hang
indefinitely waiting for the initial response.

- Add flusher.Flush() after WriteHeader in HandleSSE
- Add TestSSEStreamReceivesEventOnCapture integration test:
  starts test server, subscribes to SSE, captures a request, and
  verifies the event arrives within 2s

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
SoftwareEngineer 2026-06-22 19:05:50 +02:00
parent 4256d34c27
commit cf5b2f185c
2 changed files with 76 additions and 0 deletions

View File

@ -8,6 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"github.com/elfoundation/hatch/internal/store" "github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -129,6 +130,80 @@ func TestCaptureRecordsAllVerbs(t *testing.T) {
} }
} }
func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
repo := newFakeRepo()
srv := httptest.NewServer(testRouter(repo))
defer srv.Close()
// Use separate clients with separate transports to avoid
// connection-pool contention with the long-lived SSE connection.
sseClient := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
}
captureClient := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
}
// Subscribe to SSE with a cancellable context.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sseReq, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/e/ep/events", nil)
if err != nil {
t.Fatalf("SSE request create failed: %v", err)
}
sseResp, err := sseClient.Do(sseReq)
if err != nil {
t.Fatalf("SSE GET failed: %v", err)
}
defer sseResp.Body.Close()
if sseResp.StatusCode != http.StatusOK {
t.Fatalf("SSE returned %d", sseResp.StatusCode)
}
ct := sseResp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("SSE Content-Type is %q, expected text/event-stream", ct)
}
// Read SSE body in a goroutine; capture in main goroutine.
bodyCh := make(chan string, 1)
go func() {
buf := make([]byte, 4096)
n, _ := sseResp.Body.Read(buf)
bodyCh <- string(buf[:n])
}()
// Small sleep to let the SSE subscription register.
time.Sleep(10 * time.Millisecond)
// Capture a request on the same server.
captureResp, err := captureClient.Post(srv.URL+"/ep", "application/json", strings.NewReader(`{"msg":"hello"}`))
if err != nil {
t.Fatalf("capture POST failed: %v", err)
}
captureResp.Body.Close()
if captureResp.StatusCode != http.StatusOK {
t.Fatalf("capture returned %d", captureResp.StatusCode)
}
// Wait for the SSE event with a timeout.
select {
case body := <-bodyCh:
if !strings.Contains(body, "data:") {
t.Fatalf("SSE stream missing 'data:' prefix: %q", body)
}
if !strings.Contains(body, "\"method\":\"POST\"") {
t.Fatalf("SSE stream missing POST method: %q", body)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for SSE event")
}
// Cancel context to close the SSE connection.
cancel()
}
func TestCaptureReturnsJSON200(t *testing.T) { func TestCaptureReturnsJSON200(t *testing.T) {
r := testRouter(newFakeRepo()) r := testRouter(newFakeRepo())
w := httptest.NewRecorder() w := httptest.NewRecorder()

View File

@ -80,6 +80,7 @@ func HandleSSE(repo store.Repository) http.HandlerFunc {
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := hub.subscribe(endpointID) ch := hub.subscribe(endpointID)
defer hub.unsubscribe(endpointID, ch) defer hub.unsubscribe(endpointID, ch)