sse-ts: XHR-based SSE client hangs on clean server stream close without terminal event
sse-ts (XHR-based SSE client, maxime-petazzoni sse.js lineage) fires NO callback when the server closes a stream cleanly without a terminal event — silent hang, not an error.
Symptom: an infinite typing/loading indicator when the backend worker serving the stream was gracefully recycled (SIGTERM) mid-response. No error event, no final message; the UI's loading state never cleared.
Root cause, from the library source (sse-ts/lib/sse.ts):
- The
errorevent is dispatched only from_onStreamFailure, wired to XHRonerror,onabort, andstatus >= 400. A clean TCP close of a 200 response is none of these. - A clean close lands in XHR
onload->_onStreamLoaded, which just parses the remaining buffered chunk and dispatches whatever events it contains. If your app protocol ends streams with a terminal event (e.g. acompleteJSON payload) and the server died before sending it, no listener fires at all. The only observable signal is areadystatechangeevent withreadyState === 2(CLOSED), which typical integrations don't listen to. - Latent inverse bug: client-initiated
close()callsxhr.abort(), which firesonabort->_onStreamFailure-> a spuriouserrorevent on your own teardown (component unmount) unless you flag client-initiated closes before callingclose().
This interacts badly with any backend that gracefully drains workers (gunicorn/uvicorn timeout_graceful_shutdown, RSS-based worker recycling, autoscaler scale-in, deploys): graceful shutdown produces exactly the clean-close shape the client cannot distinguish from success.
Wrap the sse-ts integration with three pieces of state: settled (a terminal callback was delivered), closed_by_client, and a stall watchdog.
- Close-without-complete detection: listen for
readystatechange; whenreadyState === 2(CLOSED) and!settled && !closed_by_client, deliver your error callback. This is the graceful-worker-shutdown case and it fires instantly. - Stall watchdog: reset a timer on EVERY
messageevent BEFORE any empty-data guard — sse_starlette-style keep-alive ping comments (: ping) arrive as message events with emptydata(sse-ts parses comment lines byindexOf(':') <= 0and ignores them as fields, but the blank-line split still dispatches a message event withdata: ''), so pings keep the timer alive on healthy-but-slow streams. Size the timeout to ~3 ping intervals (e.g. 45s for sse_starlette's default 15s ping). On expiry:settled = true; source.close(); on_error('stalled'). This covers the hung-upstream case (e.g. an LLM API with a 600s default read timeout) where the TCP connection stays open but nothing flows. - Suppress teardown aborts: set
closed_by_client = truebefore callingsource.close()in your returned close handle, and early-return from theerrorlistener when it's set. - Gate all terminal callbacks on
settledso error/complete never double-fire.