mirror of
https://github.com/discordeno/discordeno.git
synced 2026-09-17 08:47:22 +00:00
fix(gateway): shard.resume() ending in a loop of constant resumes (#4017)
* fix(gateway): shard.resume() ending in a loop of constant resumes * refactor(gateway): change shard.close() to async and move resolve logic to shard.close() * refactor: wait for shard.close() promise in gateway.shutdown() * Fix integration test --------- Co-authored-by: Fleny <fleny113@outlook.com>
This commit is contained in:
@@ -20,13 +20,13 @@ const ZLIB_SYNC_FLUSH = new Uint8Array([0x0, 0x0, 0xff, 0xff])
|
|||||||
|
|
||||||
let fzstd: typeof import('fzstd')
|
let fzstd: typeof import('fzstd')
|
||||||
|
|
||||||
/** Since fzstd is an optional dependency, we need to import it lazily */
|
/** Since fzstd is an optional dependency, we need to import it lazily. */
|
||||||
async function getFZStd() {
|
async function getFZStd() {
|
||||||
return (fzstd ??= await import('fzstd'))
|
return (fzstd ??= await import('fzstd'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DiscordenoShard {
|
export class DiscordenoShard {
|
||||||
/** The id of the shard */
|
/** The id of the shard. */
|
||||||
id: number
|
id: number
|
||||||
/** The connection config details that this shard will used to connect to discord. */
|
/** The connection config details that this shard will used to connect to discord. */
|
||||||
connection: ShardGatewayConfig
|
connection: ShardGatewayConfig
|
||||||
@@ -54,18 +54,25 @@ export class DiscordenoShard {
|
|||||||
resolves = new Map<'READY' | 'RESUMED' | 'INVALID_SESSION', (payload: DiscordGatewayPayload) => void>()
|
resolves = new Map<'READY' | 'RESUMED' | 'INVALID_SESSION', (payload: DiscordGatewayPayload) => void>()
|
||||||
/** Shard bucket. Only access this if you know what you are doing. Bucket for handling shard request rate limits. */
|
/** Shard bucket. Only access this if you know what you are doing. Bucket for handling shard request rate limits. */
|
||||||
bucket: LeakyBucket
|
bucket: LeakyBucket
|
||||||
/** Logger for the bucket */
|
/** Logger for the bucket. */
|
||||||
logger: Pick<typeof logger, 'debug' | 'info' | 'warn' | 'error' | 'fatal'>
|
logger: Pick<typeof logger, 'debug' | 'info' | 'warn' | 'error' | 'fatal'>
|
||||||
/** Text decoder used for compressed payloads */
|
/** Text decoder used for compressed payloads. */
|
||||||
textDecoder = new TextDecoder()
|
textDecoder = new TextDecoder()
|
||||||
/** ZLib Inflate instance for ZLib-stream transport payloads */
|
/** ZLib Inflate instance for ZLib-stream transport payloads. */
|
||||||
inflate?: Inflate
|
inflate?: Inflate
|
||||||
/** ZLib inflate buffer */
|
/** ZLib inflate buffer. */
|
||||||
inflateBuffer: Uint8Array | null = null
|
inflateBuffer: Uint8Array | null = null
|
||||||
/** ZStd Decompress instance for ZStd-stream transport payloads */
|
/** ZStd Decompress instance for ZStd-stream transport payloads. */
|
||||||
zstdDecompress?: ZstdDecompress
|
zstdDecompress?: ZstdDecompress
|
||||||
/** Queue for compressed payloads for Zstd Decompress */
|
/** Queue for compressed payloads for Zstd Decompress */
|
||||||
decompressionPromisesQueue: ((data: DiscordGatewayPayload) => void)[] = []
|
decompressionPromisesQueue: ((data: DiscordGatewayPayload) => void)[] = []
|
||||||
|
/**
|
||||||
|
* A function that will be called once the socket is closed and handleClose() has finished updating internal states.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* This is for internal purposes only, and subject to breaking changes.
|
||||||
|
*/
|
||||||
|
resolveAfterClose?: (close: CloseEvent) => void
|
||||||
|
|
||||||
constructor(options: ShardCreateOptions) {
|
constructor(options: ShardCreateOptions) {
|
||||||
this.id = options.id
|
this.id = options.id
|
||||||
@@ -120,10 +127,18 @@ export class DiscordenoShard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Close the socket connection to discord if present. */
|
/** Close the socket connection to discord if present. */
|
||||||
close(code: number, reason: string): void {
|
async close(code: number, reason: string): Promise<void> {
|
||||||
if (this.socket?.readyState !== NodeWebSocket.OPEN) return
|
if (this.socket?.readyState !== NodeWebSocket.OPEN) return
|
||||||
|
|
||||||
this.socket?.close(code, reason)
|
this.socket?.close(code, reason)
|
||||||
|
|
||||||
|
// We need to wait for the socket to be fully closed, otherwise there'll be race condition issues if we try to connect again, resulting in unexpected behavior.
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
this.resolveAfterClose = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset the resolveAfterClose function after it has been resolved.
|
||||||
|
this.resolveAfterClose = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Connect the shard with the gateway and start heartbeating. This will not identify the shard to the gateway. */
|
/** Connect the shard with the gateway and start heartbeating. This will not identify the shard to the gateway. */
|
||||||
@@ -228,7 +243,7 @@ export class DiscordenoShard {
|
|||||||
// Therefore we need to close the old connection and heartbeating before creating a new one.
|
// Therefore we need to close the old connection and heartbeating before creating a new one.
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
this.logger.debug(`[Shard] Identifying open Shard #${this.id}, closing the connection`)
|
this.logger.debug(`[Shard] Identifying open Shard #${this.id}, closing the connection`)
|
||||||
this.close(ShardSocketCloseCodes.ReIdentifying, 'Re-identifying closure of old connection.')
|
await this.close(ShardSocketCloseCodes.ReIdentifying, 'Re-identifying closure of old connection.')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state = ShardState.Identifying
|
this.state = ShardState.Identifying
|
||||||
@@ -285,7 +300,7 @@ export class DiscordenoShard {
|
|||||||
// It's possible that the shard is still connected with Discord's gateway therefore we need to forcefully close it.
|
// It's possible that the shard is still connected with Discord's gateway therefore we need to forcefully close it.
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
this.logger.debug(`[Shard] Resuming open Shard #${this.id}, closing the connection`)
|
this.logger.debug(`[Shard] Resuming open Shard #${this.id}, closing the connection`)
|
||||||
this.close(ShardSocketCloseCodes.ResumeClosingOldConnection, 'Reconnecting the shard, closing old connection.')
|
await this.close(ShardSocketCloseCodes.ResumeClosingOldConnection, 'Reconnecting the shard, closing old connection.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shard has never identified, so we cannot resume.
|
// Shard has never identified, so we cannot resume.
|
||||||
@@ -346,7 +361,7 @@ export class DiscordenoShard {
|
|||||||
|
|
||||||
/** Shutdown the this. Forcefully disconnect the shard from Discord. The shard may not attempt to reconnect with Discord. */
|
/** Shutdown the this. Forcefully disconnect the shard from Discord. The shard may not attempt to reconnect with Discord. */
|
||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
this.close(ShardSocketCloseCodes.Shutdown, 'Shard shutting down.')
|
await this.close(ShardSocketCloseCodes.Shutdown, 'Shard shutting down.')
|
||||||
this.state = ShardState.Offline
|
this.state = ShardState.Offline
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +383,9 @@ export class DiscordenoShard {
|
|||||||
|
|
||||||
this.logger.debug(`[Shard] Shard #${this.id} closed with code ${close.code}${close.reason ? `, and reason: ${close.reason}` : ''}.`)
|
this.logger.debug(`[Shard] Shard #${this.id} closed with code ${close.code}${close.reason ? `, and reason: ${close.reason}` : ''}.`)
|
||||||
|
|
||||||
|
// Resolve the close promise if it exists
|
||||||
|
this.resolveAfterClose?.(close)
|
||||||
|
|
||||||
switch (close.code) {
|
switch (close.code) {
|
||||||
case ShardSocketCloseCodes.TestingFinished: {
|
case ShardSocketCloseCodes.TestingFinished: {
|
||||||
this.state = ShardState.Offline
|
this.state = ShardState.Offline
|
||||||
@@ -723,7 +741,7 @@ export class DiscordenoShard {
|
|||||||
// Reference: https://discord.com/developers/docs/topics/gateway#heartbeating-example-gateway-heartbeat-ack
|
// Reference: https://discord.com/developers/docs/topics/gateway#heartbeating-example-gateway-heartbeat-ack
|
||||||
if (!this.heart.acknowledged) {
|
if (!this.heart.acknowledged) {
|
||||||
this.logger.debug(`[Shard] Heartbeat not acknowledged for Shard #${this.id}. Assuming zombied connection.`)
|
this.logger.debug(`[Shard] Heartbeat not acknowledged for Shard #${this.id}. Assuming zombied connection.`)
|
||||||
this.close(ShardSocketCloseCodes.ZombiedConnection, 'Zombied connection, did not receive an heartbeat ACK in time.')
|
await this.close(ShardSocketCloseCodes.ZombiedConnection, 'Zombied connection, did not receive an heartbeat ACK in time.')
|
||||||
|
|
||||||
await this.resume()
|
await this.resume()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -355,11 +355,9 @@ export function createGatewayManager(options: CreateGatewayManagerOptions): Gate
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async shutdown(code, reason, clearReshardingInterval = true) {
|
async shutdown(code, reason, clearReshardingInterval = true) {
|
||||||
gateway.shards.forEach((shard) => shard.close(code, reason))
|
|
||||||
|
|
||||||
if (clearReshardingInterval) clearInterval(gateway.resharding.checkIntervalId)
|
if (clearReshardingInterval) clearInterval(gateway.resharding.checkIntervalId)
|
||||||
|
|
||||||
await delay(5000)
|
await Promise.all(Array.from(gateway.shards.values()).map((shard) => shard.close(code, reason)))
|
||||||
},
|
},
|
||||||
async sendPayload(shardId, payload) {
|
async sendPayload(shardId, payload) {
|
||||||
const shard = gateway.shards.get(shardId)
|
const shard = gateway.shards.get(shardId)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type DiscordGatewayPayload, Intents } from '@discordeno/types'
|
import { type DiscordGatewayPayload, GatewayCloseEventCodes, GatewayOpcodes, Intents } from '@discordeno/types'
|
||||||
import uWS from 'uWebSockets.js'
|
import uWS from 'uWebSockets.js'
|
||||||
import { type GatewayManager, ShardSocketCloseCodes, createGatewayManager } from '../../src/index.js'
|
import { type GatewayManager, createGatewayManager } from '../../src/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This value needs to be AT LEAST `1017`
|
* This value needs to be AT LEAST `1017`
|
||||||
@@ -30,10 +30,6 @@ function createGatewayManagerWithPort(port: number): GatewayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createUws(options: CreateUwsOptions) {
|
async function createUws(options: CreateUwsOptions) {
|
||||||
options.onOpen ??= () => {}
|
|
||||||
options.onMessage ??= (_message: any) => {}
|
|
||||||
options.onClose ??= (_code: number, _message: string) => {}
|
|
||||||
options.closing ??= false
|
|
||||||
let port: number
|
let port: number
|
||||||
|
|
||||||
const { promise, resolve, reject } = promiseWithResolvers<{ port: number; uwsToken: uWS.us_listen_socket }>()
|
const { promise, resolve, reject } = promiseWithResolvers<{ port: number; uwsToken: uWS.us_listen_socket }>()
|
||||||
@@ -41,44 +37,36 @@ async function createUws(options: CreateUwsOptions) {
|
|||||||
const app = uWS.App()
|
const app = uWS.App()
|
||||||
|
|
||||||
app.ws('/*', {
|
app.ws('/*', {
|
||||||
compression: uWS.SHARED_COMPRESSOR,
|
|
||||||
maxPayloadLength: 16 * 1024 * 1024,
|
|
||||||
idleTimeout: 10,
|
|
||||||
open: async (ws) => {
|
open: async (ws) => {
|
||||||
if (options.closing) {
|
|
||||||
ws.end(ShardSocketCloseCodes.Shutdown)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
op: 10,
|
op: GatewayOpcodes.Hello,
|
||||||
d: {
|
d: {
|
||||||
heartbeat_interval: heartbeatInterval,
|
heartbeat_interval: heartbeatInterval,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
options.onOpen!()
|
options.onOpen?.()
|
||||||
},
|
},
|
||||||
message: async (ws, message, _isBinary) => {
|
message: async (ws, message, _isBinary) => {
|
||||||
const msg = JSON.parse(Buffer.from(message).toString())
|
const msg = JSON.parse(Buffer.from(message).toString())
|
||||||
options.onMessage!(msg)
|
options.onMessage?.(msg)
|
||||||
|
|
||||||
if (msg.op === 1) {
|
if (msg.op === GatewayOpcodes.Heartbeat) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
op: 11,
|
op: GatewayOpcodes.HeartbeatACK,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.op === 2) {
|
if (msg.op === GatewayOpcodes.Identify) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
t: 'READY',
|
t: 'READY',
|
||||||
s: 1,
|
s: 1,
|
||||||
op: 0,
|
op: GatewayOpcodes.Dispatch,
|
||||||
d: {
|
d: {
|
||||||
v: 10,
|
v: 10,
|
||||||
user_settings: {},
|
user_settings: {},
|
||||||
@@ -110,13 +98,6 @@ async function createUws(options: CreateUwsOptions) {
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (msg.op === 6) {
|
|
||||||
// resume
|
|
||||||
}
|
|
||||||
},
|
|
||||||
close: (_ws, code, message) => {
|
|
||||||
const msg = Buffer.from(message).toString()
|
|
||||||
options.onClose!(code, msg)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -144,7 +125,6 @@ describe('gateway', () => {
|
|||||||
|
|
||||||
const uwsOptions: CreateUwsOptions = {
|
const uwsOptions: CreateUwsOptions = {
|
||||||
onOpen: resolveConnected,
|
onOpen: resolveConnected,
|
||||||
closing: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { port, uwsToken } = await createUws(uwsOptions)
|
const { port, uwsToken } = await createUws(uwsOptions)
|
||||||
@@ -153,8 +133,8 @@ describe('gateway', () => {
|
|||||||
await gateway.spawnShards()
|
await gateway.spawnShards()
|
||||||
await connected
|
await connected
|
||||||
|
|
||||||
uwsOptions.closing = true
|
// TODO: We should use ShardSocketCloseCodes.TestingFinished but there is an issue with sending 3xxx codes to uWS
|
||||||
await gateway.shutdown(ShardSocketCloseCodes.Shutdown, 'User requested bot stop', true)
|
await gateway.shutdown(GatewayCloseEventCodes.InvalidShard, 'User requested bot stop', true)
|
||||||
|
|
||||||
uWS.us_listen_socket_close(uwsToken)
|
uWS.us_listen_socket_close(uwsToken)
|
||||||
})
|
})
|
||||||
@@ -172,7 +152,6 @@ describe('gateway', () => {
|
|||||||
|
|
||||||
resolveHeartbeat()
|
resolveHeartbeat()
|
||||||
},
|
},
|
||||||
closing: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { port, uwsToken } = await createUws(uwsOptions)
|
const { port, uwsToken } = await createUws(uwsOptions)
|
||||||
@@ -189,8 +168,8 @@ describe('gateway', () => {
|
|||||||
|
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
|
|
||||||
uwsOptions.closing = true
|
// TODO: We should use ShardSocketCloseCodes.TestingFinished but there is an issue with sending 3xxx codes to uWS
|
||||||
await gateway.shutdown(ShardSocketCloseCodes.Shutdown, 'User requested bot stop', true)
|
await gateway.shutdown(GatewayCloseEventCodes.InvalidShard, 'User requested bot stop', true)
|
||||||
|
|
||||||
uWS.us_listen_socket_close(uwsToken)
|
uWS.us_listen_socket_close(uwsToken)
|
||||||
})
|
})
|
||||||
@@ -216,6 +195,4 @@ function promiseWithResolvers<T>() {
|
|||||||
interface CreateUwsOptions {
|
interface CreateUwsOptions {
|
||||||
onOpen?: () => any
|
onOpen?: () => any
|
||||||
onMessage?: (message: DiscordGatewayPayload) => any
|
onMessage?: (message: DiscordGatewayPayload) => any
|
||||||
onClose?: (code: number, message: string) => any
|
|
||||||
closing?: boolean
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user