From 996151804fe7f1a1285dd46da98cbde2cd52ebe9 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sat, 17 Oct 2026 11:40:16 +0200 Subject: [PATCH 1/2] fix(plugin-gateway): forward the guildDelete of a guild READY drops dropUnlistedGuilds now takes the made-up GUILD_DELETE through the steps handleDispatch takes for a real one: the action's before, the cache write, dispatch, then the action's handle. forwardGatewayDispatches only listens to dispatch, so broker workers never saw these deletions. Refs #243 --- .../gateway-forward-ready-guild-delete.md | 6 +++ packages/plugin-broker/README.md | 3 +- .../tests/gateway-replay.test.ts | 37 ++++++++++++++++ packages/plugin-gateway/README.md | 5 ++- packages/plugin-gateway/src/GatewayClient.ts | 21 +++++++--- packages/plugin-gateway/src/util/events.ts | 3 ++ .../plugin-gateway/tests/hardening.test.ts | 42 +++++++++++++++++++ 7 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 .changeset/gateway-forward-ready-guild-delete.md diff --git a/.changeset/gateway-forward-ready-guild-delete.md b/.changeset/gateway-forward-ready-guild-delete.md new file mode 100644 index 00000000..08dcc56f --- /dev/null +++ b/.changeset/gateway-forward-ready-guild-delete.md @@ -0,0 +1,6 @@ +--- +"@wolfstar/plugin-gateway": patch +"@wolfstar/plugin-broker": patch +--- + +Forward the `guildDelete` of a guild a `READY` drops: when a `READY` no longer lists a cached guild (the bot left it while offline), `GatewayClient` now emits `dispatch` for the `GUILD_DELETE` it applies, after the cache write and with the cached guild as its state, before `guildDelete`. `forwardGatewayDispatches` publishes it like any other dispatch, so a `plugin-broker` worker's `guildDelete` listeners clean these guilds up too. The made-up dispatch carries `s: 0` and is not emitted as `raw`. diff --git a/packages/plugin-broker/README.md b/packages/plugin-broker/README.md index 01459b40..17082732 100644 --- a/packages/plugin-broker/README.md +++ b/packages/plugin-broker/README.md @@ -160,7 +160,8 @@ destroy a `GatewayClient`), exiting is left to your handler. receives, under its type (e.g. `MESSAGE_CREATE`) with its data as payload. It forwards them on the client's `dispatch` event, once they are written to the client's cache, so workers sharing a [`@wolfstar/plugin-cache`](https://www.npmjs.com/package/@wolfstar/plugin-cache) Redis cache see the -state each dispatch left when they receive it: +state each dispatch left when they receive it. That includes the `GUILD_DELETE` the client emits on `dispatch` +for a cached guild a `READY` no longer lists (one the bot left while offline), so workers clean those up too: ```ts import { createBroker, forwardGatewayDispatches } from "@wolfstar/plugin-broker"; diff --git a/packages/plugin-broker/tests/gateway-replay.test.ts b/packages/plugin-broker/tests/gateway-replay.test.ts index e9df0571..aef72fb3 100644 --- a/packages/plugin-broker/tests/gateway-replay.test.ts +++ b/packages/plugin-broker/tests/gateway-replay.test.ts @@ -107,6 +107,43 @@ describe("replayGatewayDispatches", () => { expect(dispatchedOnWorker).not.toHaveBeenCalled(); }); + test("GIVEN a READY that no longer lists a cached guild THEN the worker's guildDelete gets the dropped guild", async () => { + const { broker, consumer, producer, worker } = setup(); + forwardGatewayDispatches(producer, broker); + replayGatewayDispatches(consumer, worker); + + const deleted: unknown[][] = []; + worker.on("guildDelete", (...args) => void deleted.push(args)); + + await consumer.start(); + await feed(producer, "GUILD_CREATE", { + id: guildId, + name: "Wolf Den", + roles: [], + emojis: [], + stickers: [], + features: [], + channels: [], + threads: [], + members: [], + }); + await feed(producer, "READY", { + v: 10, + user: author, + guilds: [], + session_id: "s", + resume_gateway_url: "wss://gateway.discord.gg", + shard: [0, 1], + application: { id: "266624760782258186", flags: 0 }, + }); + + await vi.waitFor(() => expect(deleted).toHaveLength(1)); + const [guild, data] = deleted[0] as [{ id: string; name: string } | null, { id: string }]; + expect(guild?.id).toBe(guildId); + expect(guild?.name).toBe("Wolf Den"); + expect(data).toEqual({ id: guildId }); + }); + test("GIVEN events THEN only those dispatch types are replayed", async () => { const { broker, consumer, producer, worker } = setup(); forwardGatewayDispatches(producer, broker); diff --git a/packages/plugin-gateway/README.md b/packages/plugin-gateway/README.md index 93a5ee33..a51d5597 100644 --- a/packages/plugin-gateway/README.md +++ b/packages/plugin-gateway/README.md @@ -202,8 +202,9 @@ exception: it is always emitted, since it sets `client.user` from the payload al On `READY`, the cached guilds of that shard which `READY` no longer lists are dropped and emitted as `guildDelete`: the bot left them while disconnected, or while the process was down with a -persistent cache, and Discord does not replay those removals. This is best effort: a failure (cache unreachable, -unknown shard count) is reported through `error` and keeps the remaining guilds. +persistent cache, and Discord does not replay those removals. Each drop also emits `dispatch` as a `GUILD_DELETE` +(with `s: 0`, and no `raw`), so a process replaying this client's dispatches sees these deletions too. This is best +effort: a failure (cache unreachable, unknown shard count) is reported through `error` and keeps the remaining guilds. ### Replaying dispatches on another process diff --git a/packages/plugin-gateway/src/GatewayClient.ts b/packages/plugin-gateway/src/GatewayClient.ts index c647b9ac..9df34869 100644 --- a/packages/plugin-gateway/src/GatewayClient.ts +++ b/packages/plugin-gateway/src/GatewayClient.ts @@ -1169,12 +1169,17 @@ export class GatewayClient extends Client { } /** - * Drops the cached guilds of a shard its `READY` no longer lists, and emits `guildDelete` for each of them. + * Drops the cached guilds of a shard its `READY` no longer lists, and emits `dispatch` and `guildDelete` for each of + * them. * * @remarks * They are the guilds the bot left while it was disconnected, or while the process was down with a persistent * cache. Discord does not replay those removals on a new session, so the cache would otherwise keep them forever. * + * Each drop is handled as a `GUILD_DELETE` (with `s: 0`) Discord would have sent, `raw` aside: `dispatch` fires once + * the guild is gone from the cache, with the cached guild as its state, so forwarding `dispatch` to other processes + * (`@wolfstar/plugin-broker`'s `forwardGatewayDispatches`) carries these deletions too. + * * It needs a guilds cache able to enumerate its entries, and is skipped without one. * * It is best effort: any failure (cache unreachable, unknown shard count) is reported through `error` and stops the @@ -1204,17 +1209,23 @@ export class GatewayClient extends Client { const count = data.shard?.[1] ?? this.#shardCount ?? (await this.gateway.getShardCount()); const listed = new Set(data.guilds.map((guild) => guild.id)); const shardCount = BigInt(count); + const action = this.actions.get(GatewayDispatchEvents.GuildDelete); for (const id of cached) { if (listed.has(id) || Number((BigInt(id) >> 22n) % shardCount) !== shardId) continue; - const guild = (await this.guilds.cache.get(id)) ?? null; - await applyGatewayDispatch(cache, { + // Taken through the steps `handleDispatch` takes for a `GUILD_DELETE` Discord sends, `raw` aside (Discord did + // not send this one): `dispatch` is what forwards it to the processes sharing the cache, such as a broker's + // workers, which would otherwise never see this guild go. + const payload: GatewayDispatchPayload = { op: GatewayOpcodes.Dispatch, s: 0, t: GatewayDispatchEvents.GuildDelete, d: { id }, - }); - this.emit("guildDelete", guild, { id }); + }; + const state = await action?.before(payload.d); + await applyGatewayDispatch(cache, payload); + this.emit("dispatch", payload, shardId, state); + await action?.handle(payload.d, state, shardId); } } diff --git a/packages/plugin-gateway/src/util/events.ts b/packages/plugin-gateway/src/util/events.ts index 3c65b91e..9820d0c4 100644 --- a/packages/plugin-gateway/src/util/events.ts +++ b/packages/plugin-gateway/src/util/events.ts @@ -119,6 +119,9 @@ export interface GatewayEventMap { * Not emitted for `INTERACTION_CREATE`, served by the HTTP endpoint, nor for a dispatch dropped because its cache * write failed with {@link GatewayClientOptions.cacheFailure} set to `"skip"`. * + * Also emitted, with `s: 0` and no `raw` before it, for the `GUILD_DELETE` the client makes up for a cached guild a + * `READY` no longer lists, see {@link GatewayClient.reconcileGuilds}. + * * `state` is what the dispatch's handler read before the cache write, e.g. the cached message a `MESSAGE_UPDATE` * replaces, or `undefined`. It is what {@link GatewayClient.serializeDispatchState} serializes for other processes. */ diff --git a/packages/plugin-gateway/tests/hardening.test.ts b/packages/plugin-gateway/tests/hardening.test.ts index f5c70bae..0e9989c9 100644 --- a/packages/plugin-gateway/tests/hardening.test.ts +++ b/packages/plugin-gateway/tests/hardening.test.ts @@ -344,6 +344,48 @@ describe("READY reconciliation", () => { expect(await client.guilds.cache.get(otherShardGuild)).toBeDefined(); }); + test("GIVEN a cached guild READY no longer lists THEN dispatch is emitted for it once, after the cache write", async () => { + const client = createClient(createInMemoryCache(), { shardCount: 2 }); + send(client, GatewayDispatchEvents.GuildCreate, guild("10")); + send(client, GatewayDispatchEvents.GuildCreate, guild("11")); + send(client, GatewayDispatchEvents.GuildCreate, guild(otherShardGuild), 1); + await client.idle(); + + const deletes: { payload: GatewayDispatchPayload; shardId: number; state: unknown }[] = []; + const cachedOnDispatch: unknown[] = []; + const raws: GatewayDispatchPayload[] = []; + const order: string[] = []; + client.on("raw", (payload) => void raws.push(payload)); + client.on("dispatch", (payload, shardId, state) => { + if (payload.t !== GatewayDispatchEvents.GuildDelete) return; + order.push("dispatch"); + deletes.push({ payload, shardId, state }); + cachedOnDispatch.push(client.cache!.guilds!.get(payload.d.id)); + }); + client.on("guildDelete", () => void order.push("guildDelete")); + + send(client, GatewayDispatchEvents.Ready, { + user, + guilds: [{ id: "10", unavailable: true }], + session_id: "s", + }); + await client.idle(); + + expect(deletes).toHaveLength(1); + expect(deletes[0]!.payload).toEqual({ + op: GatewayOpcodes.Dispatch, + s: 0, + t: GatewayDispatchEvents.GuildDelete, + d: { id: "11" }, + }); + expect(deletes[0]!.shardId).toBe(0); + expect((deletes[0]!.state as { name: string } | undefined)?.name).toBe("Guild 11"); + expect(await cachedOnDispatch[0]).toBeUndefined(); + expect(order).toEqual(["dispatch", "guildDelete"]); + // The made-up GUILD_DELETE is not something Discord sent. + expect(raws.map((raw) => raw.t)).toEqual([GatewayDispatchEvents.Ready]); + }); + test("GIVEN an unreachable cache THEN READY is still emitted under the default skip policy", async () => { const cache = createInMemoryCache(); // `createInMemoryCache` builds MemoryEntityCaches, which can enumerate their entries. From 306aa95bb536511ea23a62d299bc6c410712ac3e Mon Sep 17 00:00:00 2001 From: RedStar Date: Sat, 17 Oct 2026 11:41:18 +0200 Subject: [PATCH 2/2] fix(plugin-gateway): track the guilds clientReady waits for by shard A shard's new READY replaced nothing in the global expected guilds set, so a guild the bot left between two READYs kept clientReady waiting until waitGuildTimeout, and READYs after clientReady filled a set never emptied. Mirrors discordjs/discord.js#11547. Refs #243 --- .../gateway-expected-guilds-by-shard.md | 5 +++ packages/plugin-gateway/src/GatewayClient.ts | 24 +++++++++--- .../plugin-gateway/tests/client-ready.test.ts | 39 +++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 .changeset/gateway-expected-guilds-by-shard.md diff --git a/.changeset/gateway-expected-guilds-by-shard.md b/.changeset/gateway-expected-guilds-by-shard.md new file mode 100644 index 00000000..adde23b0 --- /dev/null +++ b/.changeset/gateway-expected-guilds-by-shard.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": patch +--- + +Track the guilds `clientReady` waits for by shard, like discord.js: a shard's new `READY` now replaces the unavailable guilds its previous one listed instead of adding to them, so a guild the bot left between two `READY`s of a shard (a session invalidated during startup) no longer holds `clientReady` back until `waitGuildTimeout`. The client also stops collecting these guild IDs once `clientReady` fired, where every reconnect's `READY` used to add them to a set that was never emptied. diff --git a/packages/plugin-gateway/src/GatewayClient.ts b/packages/plugin-gateway/src/GatewayClient.ts index 9df34869..22f26ec1 100644 --- a/packages/plugin-gateway/src/GatewayClient.ts +++ b/packages/plugin-gateway/src/GatewayClient.ts @@ -489,8 +489,9 @@ export class GatewayClient extends Client { readonly #token: string; // The guilds `READY` listed as initially unavailable, whose `GUILD_CREATE` or `GUILD_DELETE` the client waits for - // before emitting `clientReady`, like discord.js's `Client#expectedGuilds`. - readonly #expectedGuilds = new Set(); + // before emitting `clientReady`, like discord.js's `Client#expectedGuilds`, by shard: a shard's new `READY` replaces + // its own, so a guild the bot left between two of them is not waited for until `waitGuildTimeout`. + readonly #expectedGuilds = new Map>(); // The fallback timer emitting `clientReady` even with guilds still unavailable, like discord.js's own // `readyTimeout`. Cleared and rescheduled every time `#checkClientReady` runs short of triggering it. @@ -1046,8 +1047,13 @@ export class GatewayClient extends Client { const isReady = payload.t === GatewayDispatchEvents.Ready; if (isReady) { await this.reconcileGuilds(payload.d, shardId); - // The guilds this shard's `READY` listed as initially unavailable, awaited by `clientReady`. - for (const guild of payload.d.guilds) this.#expectedGuilds.add(guild.id); + // The guilds this shard's `READY` listed as initially unavailable, awaited by `clientReady`. Once it fired, + // nothing waits for them any more. + if (this.clientReadyTimestamp === null) { + const expected = new Set(payload.d.guilds.map((guild) => guild.id)); + if (expected.size > 0) this.#expectedGuilds.set(shardId, expected); + else this.#expectedGuilds.delete(shardId); + } } let state: unknown; @@ -1071,13 +1077,21 @@ export class GatewayClient extends Client { } else if ( (payload.t === GatewayDispatchEvents.GuildCreate || payload.t === GatewayDispatchEvents.GuildDelete) && - this.#expectedGuilds.delete(payload.d.id) + this.#forgetExpectedGuild(shardId, payload.d.id) ) { await this.#checkClientReady(); } } } + // Stops waiting for a guild of a shard, returning whether it was waited for. + #forgetExpectedGuild(shardId: number, guildId: string): boolean { + const expected = this.#expectedGuilds.get(shardId); + if (!expected?.delete(guildId)) return false; + if (expected.size === 0) this.#expectedGuilds.delete(shardId); + return true; + } + // A shard resuming a stored session gets no `READY`, which is what sets `client.user` and runs the ready check. It has // no guilds to wait for either: the cache already holds them. async #readyAfterResume(shardId: number): Promise { diff --git a/packages/plugin-gateway/tests/client-ready.test.ts b/packages/plugin-gateway/tests/client-ready.test.ts index 9f3e5761..3a46c71a 100644 --- a/packages/plugin-gateway/tests/client-ready.test.ts +++ b/packages/plugin-gateway/tests/client-ready.test.ts @@ -126,6 +126,45 @@ describe("GatewayClient#clientReady", () => { expect(calls).toHaveLength(1); }); + test("GIVEN a shard's new READY no longer lists a pending guild THEN it stops waiting for it", async () => { + const client = createClient({ waitGuildTimeout: 15_000 }); + const calls: unknown[][] = []; + client.on(GatewayEvents.ClientReady, (...args) => calls.push(args)); + + await dispatch(client, GatewayDispatchEvents.Ready, ready([{ id: "10", unavailable: true }])); + expect(calls).toHaveLength(0); + + // The shard identifies again (its session was invalidated) and the bot left guild 10 in between. + await dispatch(client, GatewayDispatchEvents.Ready, ready()); + + expect(calls).toHaveLength(1); + }); + + test("GIVEN a READY on one shard THEN it only replaces the pending guilds of that shard", async () => { + const client = createClient({ waitGuildTimeout: 15_000 }); + const calls: unknown[][] = []; + client.on(GatewayEvents.ClientReady, (...args) => calls.push(args)); + + await dispatch( + client, + GatewayDispatchEvents.Ready, + ready([{ id: "10", unavailable: true }]), + 0, + ); + await dispatch( + client, + GatewayDispatchEvents.Ready, + ready([{ id: "11", unavailable: true }]), + 1, + ); + await dispatch(client, GatewayDispatchEvents.Ready, ready(), 1); + // Shard 0 still waits for guild 10. + expect(calls).toHaveLength(0); + + await dispatch(client, GatewayDispatchEvents.GuildDelete, { id: "10" }, 0); + expect(calls).toHaveLength(1); + }); + test("GIVEN it already fired THEN a later GUILD_CREATE does not fire it again", async () => { const client = createClient(); const calls: unknown[][] = [];