From 1f4cb92d11e2f42ad24a53aeee0825a8d5d104e9 Mon Sep 17 00:00:00 2001 From: Jeremiah-D Date: Wed, 14 Oct 2026 09:21:27 +0000 Subject: [PATCH] fix(engine.io-client): send the close packet when closing mid-handshake If close() is called on a polling transport while the handshake is still in flight, doClose() defers writing the close packet until the transport opens, but Transport#close() then calls onClose() synchronously, flipping the transport to "closed" so the deferred write never happens. Defer onClose() along with the close packet instead, and drive the deferred write off the handshake completion in onData rather than a one-off "open" listener (the Socket clears all transport listeners when it closes, which would silently drop that listener). The sid is read from the open packet itself, so the close packet stays routable even when the Socket has already dropped its listeners. --- packages/engine.io-client/lib/transport.ts | 18 ++++- .../lib/transports/polling.ts | 31 ++++++- packages/engine.io-client/test/transport.js | 80 +++++++++++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/packages/engine.io-client/lib/transport.ts b/packages/engine.io-client/lib/transport.ts index 9bcd30d90c..00c8e33c23 100644 --- a/packages/engine.io-client/lib/transport.ts +++ b/packages/engine.io-client/lib/transport.ts @@ -45,6 +45,16 @@ export abstract class Transport extends Emitter< public query: Record; public writable: boolean = false; + /** + * Whether doClose() deferred the actual close until the transport is open. + * The polling transport sets this when close() is called mid-handshake, as + * the close packet can only be written once the handshake completes (see + * Polling#doClose). While set, close() skips its synchronous onClose() + * call -- the transport calls onClose() itself once the deferred close + * runs. + */ + protected closeDeferred: boolean = false; + protected opts: SocketOptions; protected supportsBinary: boolean; protected readyState: TransportState; @@ -100,7 +110,13 @@ export abstract class Transport extends Emitter< public close() { if (this.readyState === "opening" || this.readyState === "open") { this.doClose(); - this.onClose(); + // doClose() may defer the close until the transport is open (the + // polling transport waits for the handshake to complete so the close + // packet can be sent), in which case onClose() runs once the deferred + // close completes + if (!this.closeDeferred) { + this.onClose(); + } } return this; diff --git a/packages/engine.io-client/lib/transports/polling.ts b/packages/engine.io-client/lib/transports/polling.ts index 2f09e8021d..32509a8b01 100644 --- a/packages/engine.io-client/lib/transports/polling.ts +++ b/packages/engine.io-client/lib/transports/polling.ts @@ -95,6 +95,27 @@ export abstract class Polling extends Transport { // otherwise bypass onData and handle the message this.onPacket(packet); + + if (this.closeDeferred && "open" === packet.type) { + // close() was called while the handshake was still in flight and it + // just completed, so the deferred close packet can now be written. + // The sid is read from the open packet itself, as the Socket may have + // already dropped its listeners (see Socket#_onClose). + this.closeDeferred = false; + if (!this.query.sid && typeof packet.data === "string") { + try { + const sid = JSON.parse(packet.data).sid; + if (sid) { + this.query.sid = sid; + } + } catch (e) { + debug("could not parse sid from open packet"); + } + } + debug("writing deferred close packet"); + this.write([{ type: "close" }]); + this.onClose(); + } }; // decode payload @@ -127,12 +148,16 @@ export abstract class Polling extends Transport { if ("open" === this.readyState) { debug("transport open - closing"); + this.closeDeferred = false; close(); } else { - // in case we're trying to close while - // handshaking is in progress (GH-164) + // in case we're trying to close while handshaking is in progress + // (GH-164), wait for the handshake to complete before writing the close + // packet. This intentionally does not rely on a one-off "open" + // listener: the Socket clears all transport listeners when it closes + // (see Socket#_onClose), which would silently drop the deferred close. debug("transport not open - deferring close"); - this.once("open", close); + this.closeDeferred = true; } } diff --git a/packages/engine.io-client/test/transport.js b/packages/engine.io-client/test/transport.js index d89057c000..8005051f75 100644 --- a/packages/engine.io-client/test/transport.js +++ b/packages/engine.io-client/test/transport.js @@ -344,6 +344,86 @@ describe("Transport", () => { }); } + describe("close during handshake", () => { + const OPEN_PACKET = + '0{"sid":"close-during-handshake","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}'; + + let originalFetch; + let fetchCalls; + let releaseHandshake; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchCalls = []; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + releaseHandshake = release; + const okResponse = (text) => ({ + ok: true, + status: 200, + headers: { getSetCookie: () => [] }, + text: () => Promise.resolve(text), + }); + globalThis.fetch = (url, opts = {}) => { + const method = (opts.method || "GET").toUpperCase(); + fetchCalls.push({ method, url, body: opts.body ?? null }); + if (method === "GET") { + // hang the handshake poll until the test releases it + return gate.then(() => okResponse(OPEN_PACKET)); + } + return Promise.resolve(okResponse("ok")); + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const createSocket = () => + new eio.Socket({ transportImplementations: [eio.Fetch] }); + + const closePackets = () => + fetchCalls.filter((call) => call.method === "POST" && call.body === "1"); + + it("should send the close packet when the transport is closed mid-handshake", (done) => { + const socket = createSocket(); + setTimeout(() => { + socket.transport.close(); + releaseHandshake(); + setTimeout(() => { + const packets = closePackets(); + expect(packets.length).to.be(1); + expect(packets[0].url).to.contain("sid=close-during-handshake"); + // the transport must not poll again once closed + expect( + fetchCalls.filter((call) => call.method === "GET").length, + ).to.be(1); + socket.close(); + done(); + }, 50); + }, 20); + }); + + it("should send the close packet when the socket is closed mid-handshake", (done) => { + const socket = createSocket(); + setTimeout(() => { + socket.close(); + releaseHandshake(); + setTimeout(() => { + const packets = closePackets(); + expect(packets.length).to.be(1); + expect(packets[0].url).to.contain("sid=close-during-handshake"); + expect( + fetchCalls.filter((call) => call.method === "GET").length, + ).to.be(1); + done(); + }, 50); + }, 20); + }); + }); + describe("options", () => { it("should accept an `extraHeaders` option for XMLHttpRequest in browser", () => { const headers = {