Skip to content

fix(sio): send the buffered packets to polling clients when closing the server - #5602

Open
prakhar-tiwari wants to merge 2 commits into
socketio:mainfrom
prakhar-tiwari:fix/graceful-close
Open

prakhar-tiwari wants to merge 2 commits into
socketio:mainfrom
prakhar-tiwari:fix/graceful-close

Conversation

@prakhar-tiwari

@prakhar-tiwari prakhar-tiwari commented Nov 12, 2026 •

Copy link
Copy Markdown

The kind of change this PR does introduce

  • a bug fix
  • a new feature
  • an update to the documentation
  • a code change that improves performance
  • other

Current behavior

With HTTP long-polling, calling io.close() right after socket.disconnect(true) drops the DISCONNECT packet. engine.close() discards the write buffers (on purpose since 923a12e), and io.close() then closes the HTTP server, so the client's next GET is refused. The client gets transport error instead of io server disconnect and keeps trying to reconnect.

New behavior

The PR has two commits, one per package:

  • feat(eio): new gracefulClose(timeout = 1000): Promise<void> on BaseServer. It waits until every client with a non-empty write buffer has emitted drain (or close), or until timeout expires, and then calls the existing close(). New sessions are rejected in the meantime (BAD_REQUEST with name: "SERVER_CLOSING"). close() itself is unchanged, so the tests added in 923a12e still pass.
  • fix(sio): io.close() awaits engine.gracefulClose() before closing the HTTP server. Custom engines passed to io.bind() that don't have this method are closed with close(), as before.

If no client has buffered packets, gracefulClose() doesn't wait at all. Otherwise io.close() takes at most 1 second longer.

Tests:

  • socket.io test/close.ts: polling client, disconnect(true) and then io.close(). The client now gets io server disconnect (this test fails on main). Also covers a custom engine without gracefulClose().
  • engine.io test/server.js: the buffered packets are delivered with gracefulClose(), they are discarded once the timeout expires, and new sessions are rejected while waiting.

Other information (e.g. related issues)

Related: #5088

Open questions:

  1. Is 1 s a reasonable default timeout?
  2. Should io.close() always use the graceful path (current PR), or should it be opt-in, e.g. io.close({ timeout })?

Unlike close(), which discards the packets that are still buffered (see
923a12e), gracefulClose() waits for the write buffers to be drained, or
for the given delay to expire, before closing the clients. New sessions
are rejected in the meantime.

With HTTP long-polling, the buffered packets can only be sent when the
client sends its next GET request.

Related: socketio#5088
…he server

Previously, calling io.close() right after socket.disconnect(true) would
discard the DISCONNECT packet for clients using HTTP long-polling, since
engine.close() discards the write buffers and the HTTP server stops
listening right away. The client would then receive a "transport error"
and try to reconnect.

io.close() now waits for engine.gracefulClose() before closing the HTTP
server. Custom engines without this method are closed with close().

Related: socketio#5088
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.

1 participant