fix(app): distrust windows loaded across a connection gap (#2574)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-08 13:06:53 +02:00
1 parent 24b84c419c
commit fd6ae4abd7
15 files changed
+186 -32

No files matched your search

@@ -8,6 +8,7 @@ import {Endpoints} from '@app/features/app/constants/Endpoints';
import Authentication from '@app/features/auth/state/Authentication';
import Channels from '@app/features/channel/state/Channels';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
import {DELETE_MESSAGE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import GuildMembers from '@app/features/member/state/GuildMembers';
@@ -15,6 +16,7 @@ import {
type MessageFetchCacheHit,
resolveMessageFetchExecutionDecision,
resolveMessageFetchPreflightDecision,
resolveMessageFetchWindowCached,
} from '@app/features/messaging/commands/MessageFetchStateMachine';
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
@@ -258,6 +260,7 @@ function handleMessageFetchSuccess(
channelId: string,
messages: Array<WireMessage>,
pageState: MessagePageState,
cached: boolean,
jump?: JumpOptions,
tailProbe?: TailProbeContext,
): void {
@@ -268,7 +271,7 @@ function handleMessageFetchSuccess(
isAfter: pageState.isAfter,
hasMoreBefore: pageState.hasMoreBefore,
hasMoreAfter: pageState.hasMoreAfter,
cached: false,
cached,
jump,
tailProbe: tailProbe != null,
});
@@ -466,17 +469,25 @@ export async function fetchMessages(
const started = Messages.getMessages(channelId);
probeEpoch = {loadGeneration: started.loadGeneration, jumpTicket: started.jumpTicket};
}
const connectedAtRequest = GatewayConnection.isConnected;
const epochAtRequest = GatewayConnection.connectionEpoch;
try {
const timeStart = Date.now();
logger.debug(`Fetching messages for channel ${channelId}`);
const messages = await requestChannelMessages(channelId, before, after, limit, jump);
const cached = resolveMessageFetchWindowCached({
connectedAtRequest,
connectedAtResponse: GatewayConnection.isConnected,
epochAtRequest,
epochAtResponse: GatewayConnection.connectionEpoch,
});
if (probeEpoch != null && !isTailProbeApplicable(channelId, probeEpoch, after)) {
settleTailProbe(options, 'retry');
return [];
}
const pageState = calculateMessagePageState(channelId, before, after, limit, messages, jump);
logger.info(`Fetched ${messages.length} messages for channel ${channelId}, took ${Date.now() - timeStart}ms`);
handleMessageFetchSuccess(channelId, messages, pageState, jump, options?.tailProbe);
handleMessageFetchSuccess(channelId, messages, pageState, cached, jump, options?.tailProbe);
settleTailProbe(options, 'applied');
return messages;
} catch (error) {
@@ -8,6 +8,7 @@ import {
type MessageFetchPreflightInput,
resolveMessageFetchExecutionDecision,
resolveMessageFetchPreflightDecision,
resolveMessageFetchWindowCached,
selectMessageFetchExecutionDecision,
selectMessageFetchPreflightDecision,
transitionMessageFetchExecutionSnapshot,
@@ -104,3 +105,31 @@ describe('messageFetchExecutionMachine', () => {
expect(selectMessageFetchExecutionDecision(networkSnapshot)).toEqual({type: 'requestNetwork'});
});
});
describe('resolveMessageFetchWindowCached', () => {
function trust(overrides: Partial<Parameters<typeof resolveMessageFetchWindowCached>[0]> = {}) {
return {
connectedAtRequest: true,
connectedAtResponse: true,
epochAtRequest: 7,
epochAtResponse: 7,
...overrides,
};
}
it('trusts a window loaded inside one uninterrupted connection', () => {
expect(resolveMessageFetchWindowCached(trust())).toBe(false);
});
it('distrusts a window whose request left while the socket was down', () => {
expect(resolveMessageFetchWindowCached(trust({connectedAtRequest: false}))).toBe(true);
});
it('distrusts a window whose response landed while the socket was down', () => {
expect(resolveMessageFetchWindowCached(trust({connectedAtResponse: false}))).toBe(true);
});
it('distrusts a window whose connection epoch moved under it', () => {
expect(resolveMessageFetchWindowCached(trust({epochAtResponse: 8}))).toBe(true);
});
});
@@ -197,3 +197,15 @@ export function selectMessageFetchExecutionDecision(
export function resolveMessageFetchExecutionDecision(input: MessageFetchExecutionInput): MessageFetchExecutionDecision {
return buildExecutionDecision(input);
}
export interface MessageFetchWindowTrustInput {
connectedAtRequest: boolean;
connectedAtResponse: boolean;
epochAtRequest: number;
epochAtResponse: number;
}
export function resolveMessageFetchWindowCached(input: MessageFetchWindowTrustInput): boolean {
if (!input.connectedAtRequest || !input.connectedAtResponse) return true;
return input.epochAtRequest !== input.epochAtResponse;
}
@@ -3,7 +3,10 @@
import {Message} from '@app/features/messaging/models/MessagingMessage';
import {UploadingAttachment} from '@app/features/messaging/models/UploadingAttachment';
import {resolveChannelIncomingMessageDecision} from '@app/features/messaging/state/ChannelIncomingMessageStateMachine';
import {resolveChannelMessagesLoadDecision} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
import {
resolveChannelMessagesLoadDecision,
selectChannelMessagesLoadRestoresTrust,
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
import MessageReactions from '@app/features/messaging/state/MessageReactions';
import {mergeAscendingById} from '@app/features/messaging/utils/MessagePaginationUtils';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
@@ -358,6 +361,10 @@ export class ChannelMessages {
ChannelMessages.retainedChannelIds.delete(channelId);
}
static isRetained(channelId: string): boolean {
return ChannelMessages.retainedChannelIds.has(channelId);
}
static dropBuffers(channelId: string): void {
const instance = ChannelMessages.channelCache.get(channelId);
if (!instance) return;
@@ -900,6 +907,11 @@ export class ChannelMessages {
jumpReturnChannelId: jump?.returnToMessageId ? (jump.returnChannelId ?? this.channelId) : null,
jumpReturnGuildId: jump?.returnToMessageId ? (jump.returnGuildId ?? null) : null,
};
const reachesLiveEdge = selectChannelMessagesLoadRestoresTrust({
mode: loadDecision.mode,
isAfter,
hasMoreAfter,
});
next = next.cloneAnd({
ready: true,
loadingMore: false,
@@ -907,7 +919,7 @@ export class ChannelMessages {
...jumpPatch,
hasMoreBefore: loadDecision.preserveHasMoreBefore ? next.hasMoreBefore : hasMoreBefore,
hasMoreAfter: loadDecision.preserveHasMoreAfter ? next.hasMoreAfter : hasMoreAfter,
cached,
cached: reachesLiveEdge ? cached : next.cached || cached,
error: false,
});
return next;
@@ -11,6 +11,7 @@ import {
resolveChannelMessagesWindowStatus,
selectChannelMessagesFillerVisible,
selectChannelMessagesLoadDecision,
selectChannelMessagesLoadRestoresTrust,
selectChannelMessagesSpacerHeight,
selectChannelMessagesTailGapId,
selectChannelMessagesTailProbeId,
@@ -518,3 +519,23 @@ describe('selectChannelMessagesTailProbeOutcome', () => {
expect(selectChannelMessagesTailProbeOutcome(outcomeInput({currentJumpTicket: 3}))).toBe('discard');
});
});
describe('selectChannelMessagesLoadRestoresTrust', () => {
it('trusts a window a replacement load rebuilt from scratch', () => {
expect(selectChannelMessagesLoadRestoresTrust({mode: 'replace', isAfter: false, hasMoreAfter: false})).toBe(true);
});
it('trusts a window whose tail merge caught up to the live edge', () => {
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeAfter', isAfter: true, hasMoreAfter: false})).toBe(true);
});
it('keeps distrusting a tail merge that stopped short of the live edge', () => {
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeAfter', isAfter: true, hasMoreAfter: true})).toBe(false);
});
it('keeps distrusting a page of older history', () => {
expect(selectChannelMessagesLoadRestoresTrust({mode: 'mergeBefore', isAfter: false, hasMoreAfter: false})).toBe(
false,
);
});
});
@@ -301,6 +301,15 @@ export function selectChannelMessagesTailProbeId(input: ChannelMessagesTailInput
return selectChannelMessagesTailGapId(input);
}
export function selectChannelMessagesLoadRestoresTrust(input: {
mode: ChannelMessagesLoadMode;
isAfter: boolean;
hasMoreAfter: boolean;
}): boolean {
if (input.mode === 'replace') return true;
return input.isAfter && !input.hasMoreAfter;
}
export interface ChannelMessagesTailProbeResultInput {
probeGeneration: number;
currentGeneration: number;
@@ -201,7 +201,7 @@ class Messages {
}
private hasLoadedPage(messages: ChannelMessages): boolean {
return messages.ready && messages.length > 0;
return messages.ready && messages.length > 0 && !messages.cached;
}
shouldPreloadLatestPage(channelId: string): boolean {
@@ -209,7 +209,9 @@ class Messages {
return false;
}
const messages = ChannelMessages.get(channelId);
return !messages || (messages.length === 0 && !messages.loadingMore && !messages.ready);
if (!messages) return true;
if (messages.loadingMore || ChannelMessages.isRetained(channelId)) return false;
return messages.length === 0 ? !messages.ready : messages.cached;
}
@action
@@ -286,15 +288,6 @@ class Messages {
return true;
}
@action
handleResumed(): boolean {
ChannelMessages.forEach((messages) => {
this.commitMessages(messages.withPatch({ready: true}));
});
this.notifyChange();
return true;
}
@action
handleGatewayReady(): boolean {
const selectedChannelId = SelectedChannel.currentChannelId;
@@ -386,9 +379,10 @@ class Messages {
if (!isNonGuildChannel && !guildExists) {
return false;
}
const distrustedTailId = messages.ready && messages.cached ? (messages.last()?.id ?? null) : null;
this.commitMessages(messages.withPatch({loadingMore: true}));
this.notifyChange();
MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL);
MessageCommands.fetchMessages(channelId, null, distrustedTailId, MAX_MESSAGES_PER_CHANNEL);
return false;
}