133 lines
5.4 KiB
Markdown
133 lines
5.4 KiB
Markdown
# 2. The SSE lifecycle, and the three ways a stream ends
|
|
|
|
Previous: [1. Two protocols](01-two-protocols.md) · Next: [3. The payload](03-the-payload.md)
|
|
|
|
---
|
|
|
|
An `SseEmitter` has exactly one interesting property: **nothing owns it.** Spring does not keep a
|
|
list, does not notify you when one dies, and does not clean up after you. Every SSE bug in
|
|
production is a variation on that sentence.
|
|
|
|
## The wire format
|
|
|
|
[`docs/output/sse-wire-format.txt`](output/sse-wire-format.txt), captured from a raw socket by
|
|
[`SseWireFormatTest`](../src/test/java/com/ankurm/ssews/SseWireFormatTest.java):
|
|
|
|
```
|
|
HTTP/1.1 200
|
|
Content-Type: text/event-stream
|
|
Transfer-Encoding: chunked
|
|
|
|
:stream open
|
|
|
|
id:1
|
|
event:metric
|
|
retry:3000
|
|
data:{"seq":1,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
|
|
|
id:2
|
|
event:note
|
|
data:line one
|
|
data:line two
|
|
```
|
|
|
|
Points that matter:
|
|
|
|
- **No `Content-Length`**, so the response is chunked. Any intermediary that buffers a chunked
|
|
response defeats SSE entirely; that is why `X-Accel-Buffering: no` exists for nginx.
|
|
- **A blank line terminates an event.** Two `data:` lines in one event are rejoined by the client
|
|
with `\n` — a multi-line string does not become two events.
|
|
- **A line starting with `:` is a comment** and is discarded. Sending one periodically is the
|
|
standard trick for keeping an idle stream alive through a proxy that reaps quiet connections.
|
|
- **`retry:` sets the client's reconnect delay** in milliseconds, and it is sticky.
|
|
- **`id:` is what comes back as `Last-Event-ID`** on the browser's automatic reconnect. See
|
|
`/sse/resume` for the replay shape; almost no server implements it, which is why "SSE
|
|
guarantees delivery" is false in practice.
|
|
|
|
## Ending 1: the client goes away
|
|
|
|
The server does not find out until it writes. `SseDisconnectTest` closes a socket, then
|
|
broadcasts, and counts:
|
|
|
|
```
|
|
broadcasts before the write failed : 2
|
|
registry.sendsOk : 1
|
|
registry.sendsFailed : 0 -> 1
|
|
```
|
|
|
|
The **first** write after the client vanished succeeded. TCP accepted the bytes into the socket
|
|
buffer and only surfaced the reset on the write after that. So on any dashboard, at least one full
|
|
serialise-and-write happens into a dead connection, and on a slow-to-reset path it can be several.
|
|
|
|
The consequence for your code: **the broadcast loop must catch and drop**, and it must catch two
|
|
things. `IOException` is the socket. `IllegalStateException` is the emitter having already been
|
|
completed or timed out on a different thread — and if that escapes onto a `@Scheduled`
|
|
method, the schedule stops and every client's dashboard freezes at once.
|
|
|
|
## Ending 2: the async request times out
|
|
|
|
`SseTimeoutTest` opens a stream with a 1.2-second emitter timeout, sends one event, then waits.
|
|
[`docs/output/sse-lifecycle.txt`](output/sse-lifecycle.txt):
|
|
|
|
```
|
|
HTTP/1.1 200
|
|
Content-Type: text/event-stream
|
|
Transfer-Encoding: chunked
|
|
|
|
event:hello
|
|
data:then silence
|
|
|
|
0
|
|
=== stream ended after ~2165 ms ===
|
|
```
|
|
|
|
**Nothing marks the timeout.** No 503, no error event, no trailer. The response was committed with
|
|
200 when the first byte went out, so closing the socket is the only thing left to do, and the
|
|
browser's `EventSource` treats that as an ordinary disconnect and reconnects. Server-side it is
|
|
visible: `onTimeout` ran and the registry's counter moved.
|
|
|
|
Note the ~2.1 s for a 1.2 s timeout. Tomcat checks async timeouts from its background
|
|
processor, so expiry is granular to about a second rather than exact.
|
|
|
|
### The number that decides how long a stream lives
|
|
|
|
Three levels, checked in order:
|
|
|
|
1. `new SseEmitter(millis)` — per handler
|
|
2. `spring.mvc.async.request-timeout` — per application
|
|
3. whatever the servlet container defaults to — invisible
|
|
|
|
`AsyncTimeoutUnsetTest` reads the third off the live `AsyncContext` rather than quoting it
|
|
([`async-timeout.txt`](output/async-timeout.txt)):
|
|
|
|
```
|
|
{"spring.mvc.async.request-timeout":"<unset>","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":30000}
|
|
{"spring.mvc.async.request-timeout":"5s","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":5000}
|
|
```
|
|
|
|
**Thirty seconds.** A dashboard that silently reconnects every thirty seconds, with nothing in the
|
|
log, is this and nothing else. `new SseEmitter(Long.MAX_VALUE)` is the usual fix, and it is also
|
|
what turns a leaked emitter into a permanent one.
|
|
|
|
Reading it took some care: inside the handler method `request.isAsyncStarted()` is still `false`,
|
|
because Spring starts async processing *after* the method returns and it has seen the return type.
|
|
The first place the real `AsyncContext` exists is
|
|
`AsyncHandlerInterceptor.afterConcurrentHandlingStarted`.
|
|
|
|
## Ending 3: you complete it
|
|
|
|
`emitter.complete()`, or `completeWithError(ex)`. The second writes nothing useful to a stream
|
|
that is already committed — if you want the client to know something went wrong, send an
|
|
`event:error` of your own *first*, then complete.
|
|
|
|
## Therefore: a registry
|
|
|
|
[`EmitterRegistry`](../src/main/java/com/ankurm/ssews/EmitterRegistry.java) exists because of the
|
|
three endings above. It removes on **all three** callbacks, not just `onCompletion`. Registering
|
|
only `onCompletion` is the common version of this bug: a timed-out emitter is never completed by
|
|
the client, so it stays in the map for ever and every subsequent broadcast pays for it.
|
|
|
|
---
|
|
|
|
Previous: [1. Two protocols](01-two-protocols.md) · Next: [3. The payload](03-the-payload.md)
|