An SSE Test Exposed a Real ResponseWriter Data Race

Signalbin's SSE handler was fine, but its test read an httptest.ResponseRecorder while another goroutine wrote to it. A synchronized wrapper fixed the test contract.

Signalbin's race detector found concurrent access to an SSE response body. The race was real, but it lived in the test harness: one goroutine ran the streaming handler while the test goroutine polled httptest.ResponseRecorder.Body.

The fix in commit 61feaa5 did not add a lock to production. It made the test's fake ResponseWriter safe for the concurrency the test itself introduced.

Streaming breaks the usual recorder pattern

Most HTTP handler tests call ServeHTTP synchronously, then inspect the recorder after the handler returns. An SSE handler is designed to stay open, so this test started it in a goroutine, waited until the ready event appeared, canceled the request context, and then waited for shutdown.

The waiting loop repeatedly called response.Body.String(). At the same time, the handler could call Write and Flush. httptest.ResponseRecorder embeds a bytes.Buffer, and that buffer is not safe for concurrent reading and writing.

Go's HTTP contract also makes the lifecycle boundary explicit: a 0 may not be used after 1 returns. Here the accesses happened before return, but from two goroutines with no synchronization. The test had created a concurrent observer that a real HTTP client would implement through a socket, not by reading the server's buffer directly.

The wrapper had to synchronize both sides

The replacement syncRecorder holds a mutex around Write, WriteHeader, Flush, body reads, and status reads. It still delegates behavior to httptest.ResponseRecorder, so the test preserves its useful semantics while adding the missing happens-before relationship.

It is not enough to lock only the polling method. Synchronization works when the writer and reader coordinate on the same lock. Wrapping reads while leaving Write unguarded would make the code look defensive without removing the race.

The handler also checks for http.Flusher, because streaming depends on the concrete writer supporting it. The standard HTTP/1.x and HTTP/2 writers do, while arbitrary wrappers may not. A response wrapper that hides Flush can therefore break SSE even when its Write method works.

The test harness was the component to fix

It would have been easy to dismiss this as a testing artifact. Instead, the race clarified the contract of the test double and kept synchronization out of the production hot path.

When a handler test runs ServeHTTP concurrently, ResponseRecorder is no longer a passive value inspected after completion. It is shared mutable state. Either observe the stream through a real HTTP connection, or wrap every concurrent access with one synchronization mechanism.