Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/engine.io-client/lib/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ export abstract class Transport extends Emitter<
public query: Record<string, string>;
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;
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 28 additions & 3 deletions packages/engine.io-client/lib/transports/polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}

Expand Down
80 changes: 80 additions & 0 deletions packages/engine.io-client/test/transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down