fix(app): retry hydrating message authors after a reconnect (#1849)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:30:50 +02:00
1 parent 3e267b8104
commit 8c431c7562
1 file changed
+120 -32
@@ -17,8 +17,9 @@ interface PendingMemberRequest {
reject: (error: Error) => void; reject: (error: Error) => void;
members: Array<GuildMember>; members: Array<GuildMember>;
receivedChunks: number; receivedChunks: number;
expectedChunks: number;
requestedUserIds?: Set<string>; requestedUserIds?: Set<string>;
markMissingAsNonMembers: boolean;
timeoutId: ReturnType<typeof setTimeout>;
} }
const MEMBER_REQUEST_TIMEOUT = 30000; const MEMBER_REQUEST_TIMEOUT = 30000;
@@ -83,12 +84,19 @@ class GuildMembers {
pendingRequests: Map<string, PendingMemberRequest> = new Map(); pendingRequests: Map<string, PendingMemberRequest> = new Map();
loadedGuilds: Set<string> = new Set(); loadedGuilds: Set<string> = new Set();
private pendingMessageMemberHydration: Map<string, Set<string>> = new Map(); private pendingMessageMemberHydration: Map<string, Set<string>> = new Map();
private inFlightMessageMembers: Map<string, Set<string>> = new Map();
private messageMemberRequestGeneration: number = 0;
constructor() { constructor() {
makeAutoObservable<this, 'pendingMessageMemberHydration'>( makeAutoObservable<
this,
'pendingMessageMemberHydration' | 'inFlightMessageMembers' | 'messageMemberRequestGeneration'
>(
this, this,
{ {
pendingMessageMemberHydration: false, pendingMessageMemberHydration: false,
inFlightMessageMembers: false,
messageMemberRequestGeneration: false,
}, },
{autoBind: true}, {autoBind: true},
); );
@@ -127,8 +135,14 @@ class GuildMembers {
handleConnectionOpen(guilds: Array<GuildReadyData>): void { handleConnectionOpen(guilds: Array<GuildReadyData>): void {
this.members = {}; this.members = {};
this.nonMembers = {}; this.nonMembers = {};
this.pendingMessageMemberHydration.clear();
this.loadedGuilds.clear(); this.loadedGuilds.clear();
this.resetMessageMemberRequests();
const availableGuildIds = new Set(guilds.map((guild) => guild.id));
for (const guildId of Array.from(this.pendingMessageMemberHydration.keys())) {
if (!availableGuildIds.has(guildId)) {
this.pendingMessageMemberHydration.delete(guildId);
}
}
for (const guild of guilds) { for (const guild of guilds) {
this.handleGuildCreate(guild); this.handleGuildCreate(guild);
} }
@@ -138,14 +152,16 @@ class GuildMembers {
if (guild.unavailable) { if (guild.unavailable) {
return; return;
} }
const members: Members = {}; if (!this.members[guild.id]) {
this.members[guild.id] = {};
}
const members = this.members[guild.id];
cacheGuildMemberUsers(guild.members); cacheGuildMemberUsers(guild.members);
for (const member of guild.members) { for (const member of guild.members) {
members[member.user.id] = new GuildMember(guild.id, member, {cacheUser: false}); members[member.user.id] = new GuildMember(guild.id, member, {cacheUser: false});
} }
addVoiceStateMembers(guild, members); addVoiceStateMembers(guild, members);
const missingVoiceStateMemberUserIds = getMissingVoiceStateMemberUserIds(guild, members); const missingVoiceStateMemberUserIds = getMissingVoiceStateMemberUserIds(guild, members);
this.members[guild.id] = members;
if (missingVoiceStateMemberUserIds.length > 0 && GatewayConnection.socket) { if (missingVoiceStateMemberUserIds.length > 0 && GatewayConnection.socket) {
void this.ensureMembersLoaded(guild.id, missingVoiceStateMemberUserIds).catch((error: unknown) => { void this.ensureMembersLoaded(guild.id, missingVoiceStateMemberUserIds).catch((error: unknown) => {
logger.warn('Failed to fetch missing voice members after guild create', { logger.warn('Failed to fetch missing voice members after guild create', {
@@ -157,7 +173,7 @@ class GuildMembers {
} }
if (options?.synced || GatewayConnection.hasCompletedGuildSync(guild.id)) { if (options?.synced || GatewayConnection.hasCompletedGuildSync(guild.id)) {
this.loadedGuilds.add(guild.id); this.loadedGuilds.add(guild.id);
this.flushPendingMessageMemberHydration(guild.id); void this.requestPendingMessageMembers(guild.id);
} }
} }
@@ -165,6 +181,7 @@ class GuildMembers {
delete this.members[guildId]; delete this.members[guildId];
delete this.nonMembers[guildId]; delete this.nonMembers[guildId];
this.pendingMessageMemberHydration.delete(guildId); this.pendingMessageMemberHydration.delete(guildId);
this.inFlightMessageMembers.delete(guildId);
this.loadedGuilds.delete(guildId); this.loadedGuilds.delete(guildId);
} }
@@ -242,6 +259,7 @@ class GuildMembers {
pending.members.push(...newMembers); pending.members.push(...newMembers);
pending.receivedChunks++; pending.receivedChunks++;
if (pending.receivedChunks >= chunkCount) { if (pending.receivedChunks >= chunkCount) {
clearTimeout(pending.timeoutId);
this.markNotFoundAsNonMembers(pending); this.markNotFoundAsNonMembers(pending);
pending.resolve(pending.members); pending.resolve(pending.members);
this.pendingRequests.delete(nonce); this.pendingRequests.delete(nonce);
@@ -251,6 +269,9 @@ class GuildMembers {
} }
private markNotFoundAsNonMembers(pending: PendingMemberRequest): void { private markNotFoundAsNonMembers(pending: PendingMemberRequest): void {
if (!pending.markMissingAsNonMembers) {
return;
}
const requested = pending.requestedUserIds; const requested = pending.requestedUserIds;
if (!requested || requested.size === 0) { if (!requested || requested.size === 0) {
return; return;
@@ -281,28 +302,36 @@ class GuildMembers {
limit?: number; limit?: number;
userIds?: Array<string>; userIds?: Array<string>;
presences?: boolean; presences?: boolean;
markMissingAsNonMembers?: boolean;
}, },
): Promise<Array<GuildMember>> { ): Promise<Array<GuildMember>> {
const userIds = options?.userIds; const userIds = options?.userIds;
if (userIds && userIds.length > MAX_USER_IDS_PER_REQUEST) { if (userIds && userIds.length > MAX_USER_IDS_PER_REQUEST) {
const all: Array<GuildMember> = []; const batches: Array<Promise<Array<GuildMember>>> = [];
for (let i = 0; i < userIds.length; i += MAX_USER_IDS_PER_REQUEST) { for (let i = 0; i < userIds.length; i += MAX_USER_IDS_PER_REQUEST) {
const slice = userIds.slice(i, i + MAX_USER_IDS_PER_REQUEST); const slice = userIds.slice(i, i + MAX_USER_IDS_PER_REQUEST);
const batch = await this.fetchMembers(guildId, {...options, userIds: slice}); batches.push(this.fetchMembers(guildId, {...options, userIds: slice}));
all.push(...batch);
} }
return all; const results = await Promise.all(batches);
return results.flat();
} }
const nonce = generateMemberNonce(); const nonce = generateMemberNonce();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
if (this.pendingRequests.has(nonce)) {
this.pendingRequests.delete(nonce);
reject(new Error('Request timed out'));
}
}, MEMBER_REQUEST_TIMEOUT);
this.pendingRequests.set(nonce, { this.pendingRequests.set(nonce, {
guildId, guildId,
resolve, resolve,
reject, reject,
members: [], members: [],
receivedChunks: 0, receivedChunks: 0,
expectedChunks: 1,
requestedUserIds: userIds && userIds.length > 0 ? new Set(userIds) : undefined, requestedUserIds: userIds && userIds.length > 0 ? new Set(userIds) : undefined,
markMissingAsNonMembers: options?.markMissingAsNonMembers ?? false,
timeoutId,
}); });
const socket = GatewayConnection.socket; const socket = GatewayConnection.socket;
const requestOptions: { const requestOptions: {
@@ -327,12 +356,6 @@ class GuildMembers {
requestOptions.userIds = userIds; requestOptions.userIds = userIds;
} }
socket?.requestGuildMembers(requestOptions); socket?.requestGuildMembers(requestOptions);
setTimeout(() => {
if (this.pendingRequests.has(nonce)) {
this.pendingRequests.delete(nonce);
reject(new Error('Request timed out'));
}
}, MEMBER_REQUEST_TIMEOUT);
}); });
} }
@@ -340,7 +363,6 @@ class GuildMembers {
guildIds: Array<string>; guildIds: Array<string>;
query?: string; query?: string;
limit?: number; limit?: number;
userIds?: Array<string>;
presences?: boolean; presences?: boolean;
}): void { }): void {
const socket = GatewayConnection.socket; const socket = GatewayConnection.socket;
@@ -355,7 +377,6 @@ class GuildMembers {
guildIds, guildIds,
...(options.query !== undefined && {query: options.query}), ...(options.query !== undefined && {query: options.query}),
...(options.limit !== undefined && {limit: options.limit}), ...(options.limit !== undefined && {limit: options.limit}),
...(options.userIds !== undefined && {userIds: options.userIds}),
...(options.presences !== undefined && {presences: options.presences}), ...(options.presences !== undefined && {presences: options.presences}),
}); });
} }
@@ -365,7 +386,7 @@ class GuildMembers {
if (missingIds.length === 0) { if (missingIds.length === 0) {
return; return;
} }
await this.fetchMembers(guildId, {userIds: missingIds}); await this.fetchMembers(guildId, {userIds: missingIds, markMissingAsNonMembers: true});
} }
async ensureMembersLoadedForMessages(guildId: string, userIds: Array<string>): Promise<void> { async ensureMembersLoadedForMessages(guildId: string, userIds: Array<string>): Promise<void> {
@@ -373,14 +394,23 @@ class GuildMembers {
if (missingIds.length === 0) { if (missingIds.length === 0) {
return; return;
} }
if (GatewayConnection.hasCompletedGuildSync(guildId)) {
await this.ensureMembersLoaded(guildId, missingIds);
return;
}
this.queuePendingMessageMemberHydration(guildId, missingIds); this.queuePendingMessageMemberHydration(guildId, missingIds);
if (SelectedGuild.selectedGuildId === guildId) { if (!GatewayConnection.hasCompletedGuildSync(guildId) && SelectedGuild.selectedGuildId === guildId) {
GatewayConnection.syncGuildIfNeeded(guildId, 'message-member-hydration'); GatewayConnection.syncGuildIfNeeded(guildId, 'message-member-hydration');
} }
await this.requestPendingMessageMembers(guildId);
}
handleConnectionResumed(): void {
this.resetMessageMemberRequests();
for (const guildId of Array.from(this.pendingMessageMemberHydration.keys())) {
void this.requestPendingMessageMembers(guildId);
}
}
private resetMessageMemberRequests(): void {
this.messageMemberRequestGeneration += 1;
this.inFlightMessageMembers.clear();
} }
private getMissingMemberIds(guildId: string, userIds: Array<string>): Array<string> { private getMissingMemberIds(guildId: string, userIds: Array<string>): Array<string> {
@@ -400,21 +430,79 @@ class GuildMembers {
} }
} }
private flushPendingMessageMemberHydration(guildId: string): void { private async requestPendingMessageMembers(guildId: string): Promise<void> {
if (!GatewayConnection.hasCompletedGuildSync(guildId)) {
return;
}
const pending = this.pendingMessageMemberHydration.get(guildId); const pending = this.pendingMessageMemberHydration.get(guildId);
if (!pending || pending.size === 0) { if (!pending || pending.size === 0) {
return; return;
} }
const stillMissing = new Set(this.getMissingMemberIds(guildId, Array.from(pending)));
for (const userId of Array.from(pending)) {
if (!stillMissing.has(userId)) {
pending.delete(userId);
}
}
if (pending.size === 0) {
this.pendingMessageMemberHydration.delete(guildId); this.pendingMessageMemberHydration.delete(guildId);
const missingIds = this.getMissingMemberIds(guildId, Array.from(pending));
if (missingIds.length === 0) {
return; return;
} }
this.requestMembersInBackground({ let inFlight = this.inFlightMessageMembers.get(guildId);
guildIds: [guildId], if (!inFlight) {
userIds: missingIds, inFlight = new Set();
presences: true, this.inFlightMessageMembers.set(guildId, inFlight);
}
const userIds = Array.from(pending).filter((userId) => !inFlight.has(userId));
if (userIds.length === 0) {
return;
}
for (const userId of userIds) {
inFlight.add(userId);
}
const generation = this.messageMemberRequestGeneration;
try {
await this.fetchMembers(guildId, {userIds, markMissingAsNonMembers: false});
if (generation === this.messageMemberRequestGeneration) {
this.forgetPendingMessageMembers(guildId, userIds);
}
} catch (error) {
logger.warn('Failed to hydrate message author members, will retry on the next load or reconnect', {
guildId,
userIds,
error,
}); });
} finally {
if (generation === this.messageMemberRequestGeneration) {
this.releaseInFlightMessageMembers(guildId, userIds);
}
}
}
private forgetPendingMessageMembers(guildId: string, userIds: Array<string>): void {
const pending = this.pendingMessageMemberHydration.get(guildId);
if (!pending) {
return;
}
for (const userId of userIds) {
pending.delete(userId);
}
if (pending.size === 0) {
this.pendingMessageMemberHydration.delete(guildId);
}
}
private releaseInFlightMessageMembers(guildId: string, userIds: Array<string>): void {
const inFlight = this.inFlightMessageMembers.get(guildId);
if (!inFlight) {
return;
}
for (const userId of userIds) {
inFlight.delete(userId);
}
if (inFlight.size === 0) {
this.inFlightMessageMembers.delete(guildId);
}
} }
isGuildFullyLoaded(guildId: string): boolean { isGuildFullyLoaded(guildId: string): boolean {