Skip to content

fix(sse): register client before attaching close handler to fix race (#1128)#1405

Open
jakebromberg wants to merge 1 commit into
mainfrom
fix/1128-sse-register-race
Open

fix(sse): register client before attaching close handler to fix race (#1128)#1405
jakebromberg wants to merge 1 commit into
mainfrom
fix/1128-sse-register-race

Conversation

@jakebromberg

Copy link
Copy Markdown
Member

Closes #1128.

Problem

ServerEventsManager.registerClient attached client.res.on('close', ...) before the final this.clients.set(client.id, client). If the underlying socket closed in that window (e.g. a TCP RST while the SSE headers were still being flushed), the close handler's unsubAll(client.id) ran against a map that didn't yet contain the client — a silent no-op. The subsequent clients.set then inserted the (already-dead) client, where it lived forever: no inactivity timer, no live socket, and a guaranteed write-to-dead-socket attempt the next time anything broadcast to one of its topics.

Fix

Move the clients.set(client.id, client) ahead of startHeartbeat, ahead of the on('close') attach, and ahead of the writeHead/initial write — eliminating the window in which 'close' can fire against a not-yet-registered entry. No async boundary sits between the set and the on('close') attach, so the close handler is guaranteed to observe the inserted client when it runs.

Test

tests/unit/utils/serverEvents.test.ts adds a registerClient close-before-insert race (BS#1128) block with two cases. Both use a mock Response whose write synchronously emits 'close', mimicking a socket reset arriving mid-writeHead/write:

  1. After the race, subscribe(...) for that client throws a 404-style WxycError (the client was correctly removed from the map by the close handler).
  2. After the race, disconnect(...) for that client does NOT call res.end() (no dead entry leaked into the map for disconnect to act on).

Both tests fail against main and pass after the reorder.

Local CI

  • npm run lint — 0 errors
  • npm run format:check — clean
  • npm run typecheck — clean
  • npm run test:unit — 3153 passed (incl. 17 in serverEvents.test.ts, up from 15)

…1128)

If the underlying socket closed between `client.res.on('close', ...)` and
the final `this.clients.set(client.id, client)`, the close handler's
`unsubAll(client.id)` ran against a map that didn't contain the client —
silent no-op. The client was then inserted into `this.clients` and lived
there forever, leaking memory and producing write-to-dead-socket attempts
on subsequent broadcasts.

Move the `clients.set` ahead of the close handler attach and ahead of
writeHead/write so the synchronous `'close'` window can't observe a
not-yet-inserted entry. No async boundary sits between the set and the
on('close') attach.

Tests cover the close-during-write window via a mock Response whose
`write` synchronously emits 'close'.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSE registerClient races client.id insertion vs close handler

1 participant