diff --git a/packages/engine.io-client/lib/transport.ts b/packages/engine.io-client/lib/transport.ts index 9bcd30d90..00c8e33c2 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 2f09e8021..32509a8b0 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 d89057c00..8005051f7 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 = {