1
0

Add the sse-websocket module

This commit is contained in:
2026-09-04 00:52:18 +05:30
parent e1f8aa7402
commit 5224afdad2
51 changed files with 2761 additions and 0 deletions

View 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 &mdash; 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) &middot; Next: [3. The payload](03-the-payload.md)