diff --git a/dist/defaultWorker.js b/dist/defaultWorker.js index 306d691077f81e96bf977ed13aa1a962e13e5d0c..a4e2a21c12fd2ad2888aec00bd7c66406bc51104 100644 --- a/dist/defaultWorker.js +++ b/dist/defaultWorker.js @@ -366,6 +366,8 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter __name(this, "WebSocketShard"); } connection = null; + connectionEpoch = 0; + destroyPromise = null; useIdentifyCompress = false; inflate = null; textDecoder = new import_node_util.TextDecoder(); @@ -415,6 +417,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter if (this.#status !== 0 /* Idle */) { throw new Error("Tried to connect a shard that wasn't idle"); } + const epoch = this.connectionEpoch; const { version, encoding, compression } = this.strategy.options; const params = new import_node_url.URLSearchParams({ v: version, encoding }); if (compression) { @@ -433,6 +436,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; const url = `${session?.resumeURL ?? this.strategy.options.gatewayInformation.url}?${params.toString()}`; this.debug([`Connecting to ${url}`]); const connection = new WebSocketConstructor(url, [], { @@ -440,21 +444,25 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter }); connection.binaryType = "arraybuffer"; connection.onmessage = (event) => { + if (epoch !== this.connectionEpoch) return; void this.onMessage(event.data, event.data instanceof ArrayBuffer); }; connection.onerror = (event) => { + if (epoch !== this.connectionEpoch) return; this.onError(event.error); }; connection.onclose = (event) => { + if (epoch !== this.connectionEpoch) return; void this.onClose(event.code); }; connection.onopen = () => { + if (epoch !== this.connectionEpoch) return; this.sendRateLimitState = getInitialSendRateLimitState(); }; this.connection = connection; this.#status = 1 /* Connecting */; const { ok } = await this.waitForEvent("hello" /* Hello */, this.strategy.options.helloTimeout); - if (!ok) { + if (!ok || epoch !== this.connectionEpoch) { return; } if (session?.shardCount === this.strategy.options.shardCount) { @@ -464,6 +472,26 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } } async destroy(options = {}) { + // Invalidate pending setup and event waits before cancellation can resume them. + const epoch = ++this.connectionEpoch; + if (this.destroyPromise) { + await this.destroyPromise; + return; + } + if (this.#status === 0 /* Idle */) return; + const cleanup = this.destroyConnection(options); + this.destroyPromise = cleanup; + try { + await cleanup; + } finally { + if (this.destroyPromise === cleanup) this.destroyPromise = null; + } + if (options.recover !== void 0 && epoch === this.connectionEpoch) { + await (0, import_promises2.setTimeout)(500); + if (epoch === this.connectionEpoch) return this.internalConnect(); + } + } + async destroyConnection(options = {}) { if (this.#status === 0 /* Idle */) { this.debug(["Tried to destroy a shard that was idle"]); return; @@ -494,36 +522,27 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter if (options.recover !== 1 /* Resume */) { await this.strategy.updateSessionInfo(this.id, null); } - if (this.connection) { - this.connection.onmessage = null; - this.connection.onclose = null; - const shouldClose = this.connection.readyState === import_ws.WebSocket.OPEN; - this.debug([ - "Connection status during destroy", - `Needs closing: ${shouldClose}`, - `Ready state: ${this.connection.readyState}` - ]); - if (shouldClose) { - let outerResolve; - const promise = new Promise((resolve2) => { - outerResolve = resolve2; - }); - this.connection.onclose = outerResolve; - this.connection.close(options.code, options.reason); - await promise; + const connection = this.connection; + if (connection) { + connection.onmessage = null; + connection.onopen = null; + connection.onclose = null; + // A connecting ws can still emit its handshake error. Keep its epoch-bound + // error handler until terminate/close has actually completed. + if (connection.readyState !== 3 /* CLOSED */) { + const closed = new Promise((resolve) => { connection.onclose = resolve; }); + if (connection.readyState === 0 /* CONNECTING */) connection.terminate(); + else if (connection.readyState === 1 /* OPEN */) connection.close(options.code, options.reason); + await closed; this.emit("closed" /* Closed */, { code: options.code }); } - this.connection.onerror = null; - } else { - this.debug(["Destroying a shard that has no connection; please open an issue on GitHub"]); + connection.onerror = null; + if (this.connection === connection) this.connection = null; } this.#status = 0 /* Idle */; - if (options.recover !== void 0) { - await (0, import_promises2.setTimeout)(500); - return this.internalConnect(); - } } async waitForEvent(event, timeoutDuration) { + const epoch = this.connectionEpoch; this.debug([`Waiting for event ${event} ${timeoutDuration ? `for ${timeoutDuration}ms` : "indefinitely"}`]); const timeoutController = new AbortController(); const timeout = timeoutDuration ? (0, import_node_timers.setTimeout)(() => timeoutController.abort(), timeoutDuration).unref() : null; @@ -536,6 +555,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter ]); return { ok: !closed }; } catch { + if (epoch !== this.connectionEpoch) return { ok: false }; void this.destroy({ code: 1e3 /* Normal */, reason: "Something timed out or went wrong while waiting for an event", @@ -546,7 +566,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter if (timeout) { (0, import_node_timers.clearTimeout)(timeout); } - this.timeoutAbortControllers.delete(event); + if (this.timeoutAbortControllers.get(event) === timeoutController) this.timeoutAbortControllers.delete(event); if (!closeController.signal.aborted) { closeController.abort(); } @@ -593,6 +613,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter this.connection.send(JSON.stringify(payload)); } async identify() { + const epoch = this.connectionEpoch; this.debug(["Waiting for identify throttle"]); const controller = new AbortController(); const closeHandler = /* @__PURE__ */ __name(() => { @@ -602,7 +623,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter try { await this.strategy.waitForIdentify(this.id, controller.signal); } catch { - if (controller.signal.aborted) { + if (controller.signal.aborted || epoch !== this.connectionEpoch) { this.debug(["Was waiting for an identify, but the shard closed in the meantime"]); return; } @@ -618,6 +639,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } finally { this.off("closed" /* Closed */, closeHandler); } + if (epoch !== this.connectionEpoch) return; this.debug([ "Identifying", `shard id: ${this.id.toString()}`, @@ -642,6 +664,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter op: import_v102.GatewayOpcodes.Identify, d }); + if (epoch !== this.connectionEpoch) return; await this.waitForEvent("ready" /* Ready */, this.strategy.options.readyTimeout); } async resume(session) { @@ -663,14 +686,17 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter }); } async heartbeat(requested = false) { + const epoch = this.connectionEpoch; if (!this.isAck && !requested) { return this.destroy({ reason: "Zombie connection", recover: 1 /* Resume */ }); } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; await this.send({ op: import_v102.GatewayOpcodes.Heartbeat, d: session?.sequence ?? null }); + if (epoch !== this.connectionEpoch) return; this.lastHeartbeatAt = Date.now(); this.isAck = false; } @@ -722,8 +748,9 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter return null; } async onMessage(data, isBinary) { + const epoch = this.connectionEpoch; const payload = await this.unpackMessage(data, isBinary); - if (!payload) { + if (!payload || epoch !== this.connectionEpoch) { return; } switch (payload.op) { @@ -733,7 +760,6 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } switch (payload.t) { case import_v102.GatewayDispatchEvents.Ready: { - this.#status = 3 /* Ready */; const session2 = { sequence: payload.s, sessionId: payload.d.session_id, @@ -742,6 +768,8 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter resumeURL: payload.d.resume_gateway_url }; await this.strategy.updateSessionInfo(this.id, session2); + if (epoch !== this.connectionEpoch) return; + this.#status = 3 /* Ready */; this.emit("ready" /* Ready */, { data: payload.d }); break; } @@ -756,9 +784,11 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; if (session) { if (payload.s > session.sequence) { await this.strategy.updateSessionInfo(this.id, { ...session, sequence: payload.s }); + if (epoch !== this.connectionEpoch) return; } } else { this.debug([ @@ -782,6 +812,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter case import_v102.GatewayOpcodes.InvalidSession: { this.debug([`Invalid session; will attempt to resume: ${payload.d.toString()}`]); const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; if (payload.d && session) { await this.resume(session); } else { @@ -797,17 +828,19 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter const jitter = Math.random(); const firstWait = Math.floor(payload.d.heartbeat_interval * jitter); this.debug([`Preparing first heartbeat of the connection with a jitter of ${jitter}; waiting ${firstWait}ms`]); + const controller = new AbortController(); try { - const controller = new AbortController(); this.initialHeartbeatTimeoutController = controller; await (0, import_promises2.setTimeout)(firstWait, void 0, { signal: controller.signal }); } catch { this.debug(["Cancelled initial heartbeat due to #destroy being called"]); return; } finally { - this.initialHeartbeatTimeoutController = null; + if (this.initialHeartbeatTimeoutController === controller) this.initialHeartbeatTimeoutController = null; } + if (epoch !== this.connectionEpoch) return; await this.heartbeat(); + if (epoch !== this.connectionEpoch) return; this.debug([`First heartbeat sent, starting to beat every ${payload.d.heartbeat_interval}ms`]); this.heartbeatInterval = (0, import_node_timers.setInterval)(() => void this.heartbeat(), payload.d.heartbeat_interval); break; diff --git a/dist/defaultWorker.mjs b/dist/defaultWorker.mjs index 1302a02537062b489b0be156eccb60a960f015c5..b8f1aff44c28f5deede48fca0287a6aa860e2e8a 100644 --- a/dist/defaultWorker.mjs +++ b/dist/defaultWorker.mjs @@ -348,6 +348,8 @@ var WebSocketShard = class extends AsyncEventEmitter { __name(this, "WebSocketShard"); } connection = null; + connectionEpoch = 0; + destroyPromise = null; useIdentifyCompress = false; inflate = null; textDecoder = new TextDecoder(); @@ -397,6 +399,7 @@ var WebSocketShard = class extends AsyncEventEmitter { if (this.#status !== 0 /* Idle */) { throw new Error("Tried to connect a shard that wasn't idle"); } + const epoch = this.connectionEpoch; const { version, encoding, compression } = this.strategy.options; const params = new URLSearchParams({ v: version, encoding }); if (compression) { @@ -415,6 +418,7 @@ var WebSocketShard = class extends AsyncEventEmitter { } } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; const url = `${session?.resumeURL ?? this.strategy.options.gatewayInformation.url}?${params.toString()}`; this.debug([`Connecting to ${url}`]); const connection = new WebSocketConstructor(url, [], { @@ -422,21 +426,25 @@ var WebSocketShard = class extends AsyncEventEmitter { }); connection.binaryType = "arraybuffer"; connection.onmessage = (event) => { + if (epoch !== this.connectionEpoch) return; void this.onMessage(event.data, event.data instanceof ArrayBuffer); }; connection.onerror = (event) => { + if (epoch !== this.connectionEpoch) return; this.onError(event.error); }; connection.onclose = (event) => { + if (epoch !== this.connectionEpoch) return; void this.onClose(event.code); }; connection.onopen = () => { + if (epoch !== this.connectionEpoch) return; this.sendRateLimitState = getInitialSendRateLimitState(); }; this.connection = connection; this.#status = 1 /* Connecting */; const { ok } = await this.waitForEvent("hello" /* Hello */, this.strategy.options.helloTimeout); - if (!ok) { + if (!ok || epoch !== this.connectionEpoch) { return; } if (session?.shardCount === this.strategy.options.shardCount) { @@ -446,6 +454,26 @@ var WebSocketShard = class extends AsyncEventEmitter { } } async destroy(options = {}) { + // Invalidate pending setup and event waits before cancellation can resume them. + const epoch = ++this.connectionEpoch; + if (this.destroyPromise) { + await this.destroyPromise; + return; + } + if (this.#status === 0 /* Idle */) return; + const cleanup = this.destroyConnection(options); + this.destroyPromise = cleanup; + try { + await cleanup; + } finally { + if (this.destroyPromise === cleanup) this.destroyPromise = null; + } + if (options.recover !== void 0 && epoch === this.connectionEpoch) { + await sleep2(500); + if (epoch === this.connectionEpoch) return this.internalConnect(); + } + } + async destroyConnection(options = {}) { if (this.#status === 0 /* Idle */) { this.debug(["Tried to destroy a shard that was idle"]); return; @@ -476,36 +504,27 @@ var WebSocketShard = class extends AsyncEventEmitter { if (options.recover !== 1 /* Resume */) { await this.strategy.updateSessionInfo(this.id, null); } - if (this.connection) { - this.connection.onmessage = null; - this.connection.onclose = null; - const shouldClose = this.connection.readyState === WebSocket.OPEN; - this.debug([ - "Connection status during destroy", - `Needs closing: ${shouldClose}`, - `Ready state: ${this.connection.readyState}` - ]); - if (shouldClose) { - let outerResolve; - const promise = new Promise((resolve2) => { - outerResolve = resolve2; - }); - this.connection.onclose = outerResolve; - this.connection.close(options.code, options.reason); - await promise; + const connection = this.connection; + if (connection) { + connection.onmessage = null; + connection.onopen = null; + connection.onclose = null; + // A connecting ws can still emit its handshake error. Keep its epoch-bound + // error handler until terminate/close has actually completed. + if (connection.readyState !== 3 /* CLOSED */) { + const closed = new Promise((resolve) => { connection.onclose = resolve; }); + if (connection.readyState === 0 /* CONNECTING */) connection.terminate(); + else if (connection.readyState === 1 /* OPEN */) connection.close(options.code, options.reason); + await closed; this.emit("closed" /* Closed */, { code: options.code }); } - this.connection.onerror = null; - } else { - this.debug(["Destroying a shard that has no connection; please open an issue on GitHub"]); + connection.onerror = null; + if (this.connection === connection) this.connection = null; } this.#status = 0 /* Idle */; - if (options.recover !== void 0) { - await sleep2(500); - return this.internalConnect(); - } } async waitForEvent(event, timeoutDuration) { + const epoch = this.connectionEpoch; this.debug([`Waiting for event ${event} ${timeoutDuration ? `for ${timeoutDuration}ms` : "indefinitely"}`]); const timeoutController = new AbortController(); const timeout = timeoutDuration ? setTimeout(() => timeoutController.abort(), timeoutDuration).unref() : null; @@ -518,6 +537,7 @@ var WebSocketShard = class extends AsyncEventEmitter { ]); return { ok: !closed }; } catch { + if (epoch !== this.connectionEpoch) return { ok: false }; void this.destroy({ code: 1e3 /* Normal */, reason: "Something timed out or went wrong while waiting for an event", @@ -528,7 +548,7 @@ var WebSocketShard = class extends AsyncEventEmitter { if (timeout) { clearTimeout(timeout); } - this.timeoutAbortControllers.delete(event); + if (this.timeoutAbortControllers.get(event) === timeoutController) this.timeoutAbortControllers.delete(event); if (!closeController.signal.aborted) { closeController.abort(); } @@ -575,6 +595,7 @@ var WebSocketShard = class extends AsyncEventEmitter { this.connection.send(JSON.stringify(payload)); } async identify() { + const epoch = this.connectionEpoch; this.debug(["Waiting for identify throttle"]); const controller = new AbortController(); const closeHandler = /* @__PURE__ */ __name(() => { @@ -584,7 +605,7 @@ var WebSocketShard = class extends AsyncEventEmitter { try { await this.strategy.waitForIdentify(this.id, controller.signal); } catch { - if (controller.signal.aborted) { + if (controller.signal.aborted || epoch !== this.connectionEpoch) { this.debug(["Was waiting for an identify, but the shard closed in the meantime"]); return; } @@ -600,6 +621,7 @@ var WebSocketShard = class extends AsyncEventEmitter { } finally { this.off("closed" /* Closed */, closeHandler); } + if (epoch !== this.connectionEpoch) return; this.debug([ "Identifying", `shard id: ${this.id.toString()}`, @@ -624,6 +646,7 @@ var WebSocketShard = class extends AsyncEventEmitter { op: GatewayOpcodes2.Identify, d }); + if (epoch !== this.connectionEpoch) return; await this.waitForEvent("ready" /* Ready */, this.strategy.options.readyTimeout); } async resume(session) { @@ -645,14 +668,17 @@ var WebSocketShard = class extends AsyncEventEmitter { }); } async heartbeat(requested = false) { + const epoch = this.connectionEpoch; if (!this.isAck && !requested) { return this.destroy({ reason: "Zombie connection", recover: 1 /* Resume */ }); } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; await this.send({ op: GatewayOpcodes2.Heartbeat, d: session?.sequence ?? null }); + if (epoch !== this.connectionEpoch) return; this.lastHeartbeatAt = Date.now(); this.isAck = false; } @@ -704,8 +730,9 @@ var WebSocketShard = class extends AsyncEventEmitter { return null; } async onMessage(data, isBinary) { + const epoch = this.connectionEpoch; const payload = await this.unpackMessage(data, isBinary); - if (!payload) { + if (!payload || epoch !== this.connectionEpoch) { return; } switch (payload.op) { @@ -715,7 +742,6 @@ var WebSocketShard = class extends AsyncEventEmitter { } switch (payload.t) { case GatewayDispatchEvents.Ready: { - this.#status = 3 /* Ready */; const session2 = { sequence: payload.s, sessionId: payload.d.session_id, @@ -724,6 +750,8 @@ var WebSocketShard = class extends AsyncEventEmitter { resumeURL: payload.d.resume_gateway_url }; await this.strategy.updateSessionInfo(this.id, session2); + if (epoch !== this.connectionEpoch) return; + this.#status = 3 /* Ready */; this.emit("ready" /* Ready */, { data: payload.d }); break; } @@ -738,9 +766,11 @@ var WebSocketShard = class extends AsyncEventEmitter { } } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; if (session) { if (payload.s > session.sequence) { await this.strategy.updateSessionInfo(this.id, { ...session, sequence: payload.s }); + if (epoch !== this.connectionEpoch) return; } } else { this.debug([ @@ -764,6 +794,7 @@ var WebSocketShard = class extends AsyncEventEmitter { case GatewayOpcodes2.InvalidSession: { this.debug([`Invalid session; will attempt to resume: ${payload.d.toString()}`]); const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; if (payload.d && session) { await this.resume(session); } else { @@ -779,17 +810,19 @@ var WebSocketShard = class extends AsyncEventEmitter { const jitter = Math.random(); const firstWait = Math.floor(payload.d.heartbeat_interval * jitter); this.debug([`Preparing first heartbeat of the connection with a jitter of ${jitter}; waiting ${firstWait}ms`]); + const controller = new AbortController(); try { - const controller = new AbortController(); this.initialHeartbeatTimeoutController = controller; await sleep2(firstWait, void 0, { signal: controller.signal }); } catch { this.debug(["Cancelled initial heartbeat due to #destroy being called"]); return; } finally { - this.initialHeartbeatTimeoutController = null; + if (this.initialHeartbeatTimeoutController === controller) this.initialHeartbeatTimeoutController = null; } + if (epoch !== this.connectionEpoch) return; await this.heartbeat(); + if (epoch !== this.connectionEpoch) return; this.debug([`First heartbeat sent, starting to beat every ${payload.d.heartbeat_interval}ms`]); this.heartbeatInterval = setInterval(() => void this.heartbeat(), payload.d.heartbeat_interval); break; diff --git a/dist/index.js b/dist/index.js index 106b2ec450a248cd5bd7c36c038215145438eade..80a09fa2a8c953187e26d22e2e4a87bdb0893133 100644 --- a/dist/index.js +++ b/dist/index.js @@ -605,6 +605,8 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter __name(this, "WebSocketShard"); } connection = null; + connectionEpoch = 0; + destroyPromise = null; useIdentifyCompress = false; inflate = null; textDecoder = new import_node_util.TextDecoder(); @@ -654,6 +656,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter if (this.#status !== 0 /* Idle */) { throw new Error("Tried to connect a shard that wasn't idle"); } + const epoch = this.connectionEpoch; const { version: version2, encoding, compression } = this.strategy.options; const params = new import_node_url.URLSearchParams({ v: version2, encoding }); if (compression) { @@ -672,6 +675,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; const url = `${session?.resumeURL ?? this.strategy.options.gatewayInformation.url}?${params.toString()}`; this.debug([`Connecting to ${url}`]); const connection = new WebSocketConstructor(url, [], { @@ -679,21 +683,25 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter }); connection.binaryType = "arraybuffer"; connection.onmessage = (event) => { + if (epoch !== this.connectionEpoch) return; void this.onMessage(event.data, event.data instanceof ArrayBuffer); }; connection.onerror = (event) => { + if (epoch !== this.connectionEpoch) return; this.onError(event.error); }; connection.onclose = (event) => { + if (epoch !== this.connectionEpoch) return; void this.onClose(event.code); }; connection.onopen = () => { + if (epoch !== this.connectionEpoch) return; this.sendRateLimitState = getInitialSendRateLimitState(); }; this.connection = connection; this.#status = 1 /* Connecting */; const { ok } = await this.waitForEvent("hello" /* Hello */, this.strategy.options.helloTimeout); - if (!ok) { + if (!ok || epoch !== this.connectionEpoch) { return; } if (session?.shardCount === this.strategy.options.shardCount) { @@ -703,6 +711,26 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } } async destroy(options = {}) { + // Invalidate pending setup and event waits before cancellation can resume them. + const epoch = ++this.connectionEpoch; + if (this.destroyPromise) { + await this.destroyPromise; + return; + } + if (this.#status === 0 /* Idle */) return; + const cleanup = this.destroyConnection(options); + this.destroyPromise = cleanup; + try { + await cleanup; + } finally { + if (this.destroyPromise === cleanup) this.destroyPromise = null; + } + if (options.recover !== void 0 && epoch === this.connectionEpoch) { + await (0, import_promises2.setTimeout)(500); + if (epoch === this.connectionEpoch) return this.internalConnect(); + } + } + async destroyConnection(options = {}) { if (this.#status === 0 /* Idle */) { this.debug(["Tried to destroy a shard that was idle"]); return; @@ -733,36 +761,27 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter if (options.recover !== 1 /* Resume */) { await this.strategy.updateSessionInfo(this.id, null); } - if (this.connection) { - this.connection.onmessage = null; - this.connection.onclose = null; - const shouldClose = this.connection.readyState === import_ws.WebSocket.OPEN; - this.debug([ - "Connection status during destroy", - `Needs closing: ${shouldClose}`, - `Ready state: ${this.connection.readyState}` - ]); - if (shouldClose) { - let outerResolve; - const promise = new Promise((resolve2) => { - outerResolve = resolve2; - }); - this.connection.onclose = outerResolve; - this.connection.close(options.code, options.reason); - await promise; + const connection = this.connection; + if (connection) { + connection.onmessage = null; + connection.onopen = null; + connection.onclose = null; + // A connecting ws can still emit its handshake error. Keep its epoch-bound + // error handler until terminate/close has actually completed. + if (connection.readyState !== 3 /* CLOSED */) { + const closed = new Promise((resolve) => { connection.onclose = resolve; }); + if (connection.readyState === 0 /* CONNECTING */) connection.terminate(); + else if (connection.readyState === 1 /* OPEN */) connection.close(options.code, options.reason); + await closed; this.emit("closed" /* Closed */, { code: options.code }); } - this.connection.onerror = null; - } else { - this.debug(["Destroying a shard that has no connection; please open an issue on GitHub"]); + connection.onerror = null; + if (this.connection === connection) this.connection = null; } this.#status = 0 /* Idle */; - if (options.recover !== void 0) { - await (0, import_promises2.setTimeout)(500); - return this.internalConnect(); - } } async waitForEvent(event, timeoutDuration) { + const epoch = this.connectionEpoch; this.debug([`Waiting for event ${event} ${timeoutDuration ? `for ${timeoutDuration}ms` : "indefinitely"}`]); const timeoutController = new AbortController(); const timeout = timeoutDuration ? (0, import_node_timers.setTimeout)(() => timeoutController.abort(), timeoutDuration).unref() : null; @@ -775,6 +794,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter ]); return { ok: !closed }; } catch { + if (epoch !== this.connectionEpoch) return { ok: false }; void this.destroy({ code: 1e3 /* Normal */, reason: "Something timed out or went wrong while waiting for an event", @@ -785,7 +805,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter if (timeout) { (0, import_node_timers.clearTimeout)(timeout); } - this.timeoutAbortControllers.delete(event); + if (this.timeoutAbortControllers.get(event) === timeoutController) this.timeoutAbortControllers.delete(event); if (!closeController.signal.aborted) { closeController.abort(); } @@ -832,6 +852,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter this.connection.send(JSON.stringify(payload)); } async identify() { + const epoch = this.connectionEpoch; this.debug(["Waiting for identify throttle"]); const controller = new AbortController(); const closeHandler = /* @__PURE__ */ __name(() => { @@ -841,7 +862,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter try { await this.strategy.waitForIdentify(this.id, controller.signal); } catch { - if (controller.signal.aborted) { + if (controller.signal.aborted || epoch !== this.connectionEpoch) { this.debug(["Was waiting for an identify, but the shard closed in the meantime"]); return; } @@ -857,6 +878,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } finally { this.off("closed" /* Closed */, closeHandler); } + if (epoch !== this.connectionEpoch) return; this.debug([ "Identifying", `shard id: ${this.id.toString()}`, @@ -881,6 +903,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter op: import_v102.GatewayOpcodes.Identify, d }); + if (epoch !== this.connectionEpoch) return; await this.waitForEvent("ready" /* Ready */, this.strategy.options.readyTimeout); } async resume(session) { @@ -902,14 +925,17 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter }); } async heartbeat(requested = false) { + const epoch = this.connectionEpoch; if (!this.isAck && !requested) { return this.destroy({ reason: "Zombie connection", recover: 1 /* Resume */ }); } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; await this.send({ op: import_v102.GatewayOpcodes.Heartbeat, d: session?.sequence ?? null }); + if (epoch !== this.connectionEpoch) return; this.lastHeartbeatAt = Date.now(); this.isAck = false; } @@ -961,8 +987,9 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter return null; } async onMessage(data, isBinary) { + const epoch = this.connectionEpoch; const payload = await this.unpackMessage(data, isBinary); - if (!payload) { + if (!payload || epoch !== this.connectionEpoch) { return; } switch (payload.op) { @@ -972,7 +999,6 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } switch (payload.t) { case import_v102.GatewayDispatchEvents.Ready: { - this.#status = 3 /* Ready */; const session2 = { sequence: payload.s, sessionId: payload.d.session_id, @@ -981,6 +1007,8 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter resumeURL: payload.d.resume_gateway_url }; await this.strategy.updateSessionInfo(this.id, session2); + if (epoch !== this.connectionEpoch) return; + this.#status = 3 /* Ready */; this.emit("ready" /* Ready */, { data: payload.d }); break; } @@ -995,9 +1023,11 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter } } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; if (session) { if (payload.s > session.sequence) { await this.strategy.updateSessionInfo(this.id, { ...session, sequence: payload.s }); + if (epoch !== this.connectionEpoch) return; } } else { this.debug([ @@ -1021,6 +1051,7 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter case import_v102.GatewayOpcodes.InvalidSession: { this.debug([`Invalid session; will attempt to resume: ${payload.d.toString()}`]); const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; if (payload.d && session) { await this.resume(session); } else { @@ -1036,17 +1067,19 @@ var WebSocketShard = class extends import_async_event_emitter.AsyncEventEmitter const jitter = Math.random(); const firstWait = Math.floor(payload.d.heartbeat_interval * jitter); this.debug([`Preparing first heartbeat of the connection with a jitter of ${jitter}; waiting ${firstWait}ms`]); + const controller = new AbortController(); try { - const controller = new AbortController(); this.initialHeartbeatTimeoutController = controller; await (0, import_promises2.setTimeout)(firstWait, void 0, { signal: controller.signal }); } catch { this.debug(["Cancelled initial heartbeat due to #destroy being called"]); return; } finally { - this.initialHeartbeatTimeoutController = null; + if (this.initialHeartbeatTimeoutController === controller) this.initialHeartbeatTimeoutController = null; } + if (epoch !== this.connectionEpoch) return; await this.heartbeat(); + if (epoch !== this.connectionEpoch) return; this.debug([`First heartbeat sent, starting to beat every ${payload.d.heartbeat_interval}ms`]); this.heartbeatInterval = (0, import_node_timers.setInterval)(() => void this.heartbeat(), payload.d.heartbeat_interval); break; @@ -1388,6 +1421,8 @@ var WebSocketManager = class extends import_async_event_emitter2.AsyncEventEmitt this.options = { ...DefaultWebSocketManagerOptions, ...options }; this.strategy = this.options.buildStrategy(this); } + connectionEpoch = 0; + destroyed = false; /** * Fetches the gateway information from Discord - or returns it from cache if available * @@ -1450,10 +1485,19 @@ var WebSocketManager = class extends import_async_event_emitter2.AsyncEventEmitt return shardIds; } async connect() { + if (this.destroyed) return; + const epoch = this.connectionEpoch; const shardCount = await this.getShardCount(); + if (epoch !== this.connectionEpoch) return; await this.updateShardCount(shardCount); + if (epoch !== this.connectionEpoch) { + await this.strategy.destroy(); + return; + } const shardIds = await this.getShardIds(); + if (epoch !== this.connectionEpoch) return; const data = await this.fetchGatewayInformation(); + if (epoch !== this.connectionEpoch) return; if (data.session_start_limit.remaining < shardIds.length) { throw new Error( `Not enough sessions remaining to spawn ${shardIds.length} shards; only ${data.session_start_limit.remaining} remaining; resets at ${new Date(Date.now() + data.session_start_limit.reset_after).toISOString()}` @@ -1462,6 +1506,8 @@ var WebSocketManager = class extends import_async_event_emitter2.AsyncEventEmitt await this.strategy.connect(); } destroy(options) { + this.destroyed = true; + this.connectionEpoch += 1; return this.strategy.destroy(options); } send(shardId, payload) { diff --git a/dist/index.mjs b/dist/index.mjs index 0ebc06d477ac54303c7542261cc5bcc9a9537519..bfb1f0880bf43f70e5c9176b62a1c23ca21596bc 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -566,6 +566,8 @@ var WebSocketShard = class extends AsyncEventEmitter { __name(this, "WebSocketShard"); } connection = null; + connectionEpoch = 0; + destroyPromise = null; useIdentifyCompress = false; inflate = null; textDecoder = new TextDecoder(); @@ -615,6 +617,7 @@ var WebSocketShard = class extends AsyncEventEmitter { if (this.#status !== 0 /* Idle */) { throw new Error("Tried to connect a shard that wasn't idle"); } + const epoch = this.connectionEpoch; const { version: version2, encoding, compression } = this.strategy.options; const params = new URLSearchParams({ v: version2, encoding }); if (compression) { @@ -633,6 +636,7 @@ var WebSocketShard = class extends AsyncEventEmitter { } } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; const url = `${session?.resumeURL ?? this.strategy.options.gatewayInformation.url}?${params.toString()}`; this.debug([`Connecting to ${url}`]); const connection = new WebSocketConstructor(url, [], { @@ -640,21 +644,25 @@ var WebSocketShard = class extends AsyncEventEmitter { }); connection.binaryType = "arraybuffer"; connection.onmessage = (event) => { + if (epoch !== this.connectionEpoch) return; void this.onMessage(event.data, event.data instanceof ArrayBuffer); }; connection.onerror = (event) => { + if (epoch !== this.connectionEpoch) return; this.onError(event.error); }; connection.onclose = (event) => { + if (epoch !== this.connectionEpoch) return; void this.onClose(event.code); }; connection.onopen = () => { + if (epoch !== this.connectionEpoch) return; this.sendRateLimitState = getInitialSendRateLimitState(); }; this.connection = connection; this.#status = 1 /* Connecting */; const { ok } = await this.waitForEvent("hello" /* Hello */, this.strategy.options.helloTimeout); - if (!ok) { + if (!ok || epoch !== this.connectionEpoch) { return; } if (session?.shardCount === this.strategy.options.shardCount) { @@ -664,6 +672,26 @@ var WebSocketShard = class extends AsyncEventEmitter { } } async destroy(options = {}) { + // Invalidate pending setup and event waits before cancellation can resume them. + const epoch = ++this.connectionEpoch; + if (this.destroyPromise) { + await this.destroyPromise; + return; + } + if (this.#status === 0 /* Idle */) return; + const cleanup = this.destroyConnection(options); + this.destroyPromise = cleanup; + try { + await cleanup; + } finally { + if (this.destroyPromise === cleanup) this.destroyPromise = null; + } + if (options.recover !== void 0 && epoch === this.connectionEpoch) { + await sleep2(500); + if (epoch === this.connectionEpoch) return this.internalConnect(); + } + } + async destroyConnection(options = {}) { if (this.#status === 0 /* Idle */) { this.debug(["Tried to destroy a shard that was idle"]); return; @@ -694,36 +722,27 @@ var WebSocketShard = class extends AsyncEventEmitter { if (options.recover !== 1 /* Resume */) { await this.strategy.updateSessionInfo(this.id, null); } - if (this.connection) { - this.connection.onmessage = null; - this.connection.onclose = null; - const shouldClose = this.connection.readyState === WebSocket.OPEN; - this.debug([ - "Connection status during destroy", - `Needs closing: ${shouldClose}`, - `Ready state: ${this.connection.readyState}` - ]); - if (shouldClose) { - let outerResolve; - const promise = new Promise((resolve2) => { - outerResolve = resolve2; - }); - this.connection.onclose = outerResolve; - this.connection.close(options.code, options.reason); - await promise; + const connection = this.connection; + if (connection) { + connection.onmessage = null; + connection.onopen = null; + connection.onclose = null; + // A connecting ws can still emit its handshake error. Keep its epoch-bound + // error handler until terminate/close has actually completed. + if (connection.readyState !== 3 /* CLOSED */) { + const closed = new Promise((resolve) => { connection.onclose = resolve; }); + if (connection.readyState === 0 /* CONNECTING */) connection.terminate(); + else if (connection.readyState === 1 /* OPEN */) connection.close(options.code, options.reason); + await closed; this.emit("closed" /* Closed */, { code: options.code }); } - this.connection.onerror = null; - } else { - this.debug(["Destroying a shard that has no connection; please open an issue on GitHub"]); + connection.onerror = null; + if (this.connection === connection) this.connection = null; } this.#status = 0 /* Idle */; - if (options.recover !== void 0) { - await sleep2(500); - return this.internalConnect(); - } } async waitForEvent(event, timeoutDuration) { + const epoch = this.connectionEpoch; this.debug([`Waiting for event ${event} ${timeoutDuration ? `for ${timeoutDuration}ms` : "indefinitely"}`]); const timeoutController = new AbortController(); const timeout = timeoutDuration ? setTimeout(() => timeoutController.abort(), timeoutDuration).unref() : null; @@ -736,6 +755,7 @@ var WebSocketShard = class extends AsyncEventEmitter { ]); return { ok: !closed }; } catch { + if (epoch !== this.connectionEpoch) return { ok: false }; void this.destroy({ code: 1e3 /* Normal */, reason: "Something timed out or went wrong while waiting for an event", @@ -746,7 +766,7 @@ var WebSocketShard = class extends AsyncEventEmitter { if (timeout) { clearTimeout(timeout); } - this.timeoutAbortControllers.delete(event); + if (this.timeoutAbortControllers.get(event) === timeoutController) this.timeoutAbortControllers.delete(event); if (!closeController.signal.aborted) { closeController.abort(); } @@ -793,6 +813,7 @@ var WebSocketShard = class extends AsyncEventEmitter { this.connection.send(JSON.stringify(payload)); } async identify() { + const epoch = this.connectionEpoch; this.debug(["Waiting for identify throttle"]); const controller = new AbortController(); const closeHandler = /* @__PURE__ */ __name(() => { @@ -802,7 +823,7 @@ var WebSocketShard = class extends AsyncEventEmitter { try { await this.strategy.waitForIdentify(this.id, controller.signal); } catch { - if (controller.signal.aborted) { + if (controller.signal.aborted || epoch !== this.connectionEpoch) { this.debug(["Was waiting for an identify, but the shard closed in the meantime"]); return; } @@ -818,6 +839,7 @@ var WebSocketShard = class extends AsyncEventEmitter { } finally { this.off("closed" /* Closed */, closeHandler); } + if (epoch !== this.connectionEpoch) return; this.debug([ "Identifying", `shard id: ${this.id.toString()}`, @@ -842,6 +864,7 @@ var WebSocketShard = class extends AsyncEventEmitter { op: GatewayOpcodes2.Identify, d }); + if (epoch !== this.connectionEpoch) return; await this.waitForEvent("ready" /* Ready */, this.strategy.options.readyTimeout); } async resume(session) { @@ -863,14 +886,17 @@ var WebSocketShard = class extends AsyncEventEmitter { }); } async heartbeat(requested = false) { + const epoch = this.connectionEpoch; if (!this.isAck && !requested) { return this.destroy({ reason: "Zombie connection", recover: 1 /* Resume */ }); } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; await this.send({ op: GatewayOpcodes2.Heartbeat, d: session?.sequence ?? null }); + if (epoch !== this.connectionEpoch) return; this.lastHeartbeatAt = Date.now(); this.isAck = false; } @@ -922,8 +948,9 @@ var WebSocketShard = class extends AsyncEventEmitter { return null; } async onMessage(data, isBinary) { + const epoch = this.connectionEpoch; const payload = await this.unpackMessage(data, isBinary); - if (!payload) { + if (!payload || epoch !== this.connectionEpoch) { return; } switch (payload.op) { @@ -933,7 +960,6 @@ var WebSocketShard = class extends AsyncEventEmitter { } switch (payload.t) { case GatewayDispatchEvents.Ready: { - this.#status = 3 /* Ready */; const session2 = { sequence: payload.s, sessionId: payload.d.session_id, @@ -942,6 +968,8 @@ var WebSocketShard = class extends AsyncEventEmitter { resumeURL: payload.d.resume_gateway_url }; await this.strategy.updateSessionInfo(this.id, session2); + if (epoch !== this.connectionEpoch) return; + this.#status = 3 /* Ready */; this.emit("ready" /* Ready */, { data: payload.d }); break; } @@ -956,9 +984,11 @@ var WebSocketShard = class extends AsyncEventEmitter { } } const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; if (session) { if (payload.s > session.sequence) { await this.strategy.updateSessionInfo(this.id, { ...session, sequence: payload.s }); + if (epoch !== this.connectionEpoch) return; } } else { this.debug([ @@ -982,6 +1012,7 @@ var WebSocketShard = class extends AsyncEventEmitter { case GatewayOpcodes2.InvalidSession: { this.debug([`Invalid session; will attempt to resume: ${payload.d.toString()}`]); const session = await this.strategy.retrieveSessionInfo(this.id); + if (epoch !== this.connectionEpoch) return; if (payload.d && session) { await this.resume(session); } else { @@ -997,17 +1028,19 @@ var WebSocketShard = class extends AsyncEventEmitter { const jitter = Math.random(); const firstWait = Math.floor(payload.d.heartbeat_interval * jitter); this.debug([`Preparing first heartbeat of the connection with a jitter of ${jitter}; waiting ${firstWait}ms`]); + const controller = new AbortController(); try { - const controller = new AbortController(); this.initialHeartbeatTimeoutController = controller; await sleep2(firstWait, void 0, { signal: controller.signal }); } catch { this.debug(["Cancelled initial heartbeat due to #destroy being called"]); return; } finally { - this.initialHeartbeatTimeoutController = null; + if (this.initialHeartbeatTimeoutController === controller) this.initialHeartbeatTimeoutController = null; } + if (epoch !== this.connectionEpoch) return; await this.heartbeat(); + if (epoch !== this.connectionEpoch) return; this.debug([`First heartbeat sent, starting to beat every ${payload.d.heartbeat_interval}ms`]); this.heartbeatInterval = setInterval(() => void this.heartbeat(), payload.d.heartbeat_interval); break; @@ -1351,6 +1384,8 @@ var WebSocketManager = class extends AsyncEventEmitter2 { this.options = { ...DefaultWebSocketManagerOptions, ...options }; this.strategy = this.options.buildStrategy(this); } + connectionEpoch = 0; + destroyed = false; /** * Fetches the gateway information from Discord - or returns it from cache if available * @@ -1413,10 +1448,19 @@ var WebSocketManager = class extends AsyncEventEmitter2 { return shardIds; } async connect() { + if (this.destroyed) return; + const epoch = this.connectionEpoch; const shardCount = await this.getShardCount(); + if (epoch !== this.connectionEpoch) return; await this.updateShardCount(shardCount); + if (epoch !== this.connectionEpoch) { + await this.strategy.destroy(); + return; + } const shardIds = await this.getShardIds(); + if (epoch !== this.connectionEpoch) return; const data = await this.fetchGatewayInformation(); + if (epoch !== this.connectionEpoch) return; if (data.session_start_limit.remaining < shardIds.length) { throw new Error( `Not enough sessions remaining to spawn ${shardIds.length} shards; only ${data.session_start_limit.remaining} remaining; resets at ${new Date(Date.now() + data.session_start_limit.reset_after).toISOString()}` @@ -1425,6 +1469,8 @@ var WebSocketManager = class extends AsyncEventEmitter2 { await this.strategy.connect(); } destroy(options) { + this.destroyed = true; + this.connectionEpoch += 1; return this.strategy.destroy(options); } send(shardId, payload) {