Fix SSE streams stalling by using dedicated OTP actors#62
Merged
dcrockwell merged 3 commits intodevelopfrom Mar 11, 2026
Merged
Fix SSE streams stalling by using dedicated OTP actors#62dcrockwell merged 3 commits intodevelopfrom
dcrockwell merged 3 commits intodevelopfrom
Conversation
## Why This Change Was Made - The existing `sse_response` function used chunked transfer encoding (`Stream(yielder)`) which Mist converted to `Chunked(yielder)`. The yielder blocked in the mist handler process's mailbox, competing with TCP messages (ACKs, window updates), causing SSE streams to stall after 2-3 events. - A proper SSE implementation requires a dedicated OTP actor per connection with its own mailbox, matching how Mist handles WebSockets. ## What Was Changed - Added `src/dream/servers/mist/sse.gleam` module with `upgrade_to_sse`, `send_event`, event builders (`event`, `event_name`, `event_id`, `event_retry`), and action helpers (`continue_connection`, `continue_connection_with_selector`, `stop_connection`) - Follows the same stash-and-upgrade pattern as `websocket.gleam` — retrieves the raw Mist request from the process dictionary, calls `mist.server_sent_events`, stashes the resulting response - Deprecated `response.sse_response` with doc comment directing users to the new module - Added unit tests, tested documentation snippets, and a full `examples/sse/` example app with Cucumber integration tests - Added `docs/guides/sse.md` guide, updated `docs/reference/streaming-api.md` and `docs/guides/streaming.md` - Bumped version to 2.4.0, updated CHANGELOG.md, created release notes ## Note to Future Engineer - Dream's `SSEConnection` wraps Mist's opaque `SSEConnection` which wraps an internal `Connection(body, socket, transport)`. If you ever need raw socket access (e.g., for `send_raw`), capture socket/transport from `mist_request.body` at upgrade time rather than cracking open the opaque type later. Your future self will thank you when Mist bumps a minor version. - The old `sse_response` is deprecated but not removed. If you're reading this in 2028 wondering why it's still here — congratulations, you found the tech debt. The `data:` prefix adds itself, like a clingy coworker who cc's themselves on every email.
## Why This Change Was Made - CI was hanging indefinitely during SSE integration tests because the readiness check curled the `/events` SSE endpoint, which is a long-lived stream that never closes - `curl -s http://localhost:8081/events` connects, receives the 200 + headers, then blocks forever waiting for the stream to end — so the readiness loop never advances ## What Was Changed - Added a `/health` endpoint to the SSE example router that returns a plain 200 "ok" response - Changed the Makefile readiness check from `/events` to `/health` ## Note to Future Engineer - Every other example curls a normal HTTP endpoint for readiness. SSE and WebSocket endpoints are long-lived — never use them for health checks unless you enjoy watching CI spin like a loading screen from 2005.
## Why This Change Was Made
- CI was hanging because the "SSE endpoint returns correct headers" scenario did a synchronous `HTTPoison.get` to the `/events` SSE endpoint
- SSE streams never end, and the server sends events every second, so `recv_timeout` never fires (data IS being received) — the call hangs forever
## What Was Changed
- Merged the header assertions into the "SSE endpoint streams events" scenario, which already uses async streaming
- The SSE connect step now captures response headers from `AsyncHeaders` instead of discarding them
- Added a new step definition `the SSE response header {string} should contain {string}` for header assertions on async SSE connections
- Removed the standalone headers scenario that used synchronous HTTP
## Note to Future Engineer
- Never use synchronous HTTP requests to test SSE or WebSocket endpoints. They stream forever. Use async connections and check headers from the handshake phase.
- If you're tempted to add `recv_timeout: 1_000` as a "fix" — it won't work. The server keeps sending data, so recv_timeout resets with every event. It's not a timeout problem, it's a "this stream literally never ends" problem.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The existing
sse_responsefunction used chunked transfer encoding under the hood. Mist convertedStream(yielder)toChunked(yielder), which meant the yielder iterated inside the mist handler process's mailbox. That mailbox also receives TCP messages (ACKs, window updates), so after 2–3 events the two would contend and the SSE stream would stall indefinitely.This is the same class of problem Mist solved for WebSockets — long-lived connections need their own OTP actor with a dedicated mailbox.
What
New module:
dream/servers/mist/sseA complete SSE API that mirrors the WebSocket module's design:
upgrade_to_sse— upgrades an HTTP request to an SSE connection backed by a dedicated OTP actor (uses the same stash-and-upgrade pattern aswebsocket.gleam)send_event— sends structured events to the clientevent,event_name,event_id,event_retryfor constructing SSE events with a pipeline syntaxcontinue_connection,continue_connection_with_selector,stop_connectionfor controlling the actor lifecycleSSEConnection,Event,Action) that hide Mist internals from user codeDeprecation
response.sse_responseis deprecated with a doc comment explaining the stalling bug and directing users to the new module. It is not removed — existing code continues to compile.Testing
examples/sse/) with Cucumber integration tests that verify events stream without stallingDocumentation
docs/guides/sse.md— comprehensive guide covering concepts, lifecycle, event builders, broadcasting, client-side EventSource, and testingdocs/reference/streaming-api.mdwith the new API referencedocs/guides/streaming.mdto point to the new SSE guideVersion bump to 2.4.0 with CHANGELOG and release notes.
How
The new module follows the established stash-and-upgrade pattern from
websocket.gleam:upgrade_to_sse, which retrieves the stashed Mist requestupgrade_to_ssecallsmist.server_sent_eventswith wrappedon_initandon_messagecallbacksUser-facing callbacks (
on_init,on_message) follow Dream's no-closures pattern — dependencies are passed explicitly rather than captured.Test plan
gleam test)make test-integrationinexamples/sse/)EventSourceand confirm events stream continuously