Add the sse-websocket module
This commit is contained in:
132
sse-websocket/docs/02-sse-lifecycle.md
Normal file
132
sse-websocket/docs/02-sse-lifecycle.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user