Files
buzz/desktop/src/features/messages/lib/messageMerge.ts
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d 6c3d472ffb fix(desktop): make echo reconciliation ownership-aware so concurrent identical sends both survive
Wren's redteam blocker on #3329: two in-flight replies with the same
author/content/root are semantically indistinguishable to
isMatchingPendingMessage, so under the ordering
echo1 -> success1 -> echo2 -> success2 the second echo claimed the
FIRST send's already-consumed localKey, and the localKey-keyed dedupe
then evicted the first confirmed reply — an accepted user message
silently deleted from the pane.

reconcileIncomingMessage now resolves the incoming event's render key
ownership-aware (resolveIncomingLocalKey):

1. a confirmed row with the same id already owns a key -> inherit it,
   never re-match a pending;
2. a caller-supplied localKey (onSuccess binding) is honored only if no
   OTHER confirmed row owns it — if an echo claimed that pending first,
   fall through and claim a remaining pending instead;
3. otherwise claim the first semantically matching pending row.

Pending and confirmed rows never share a key (claiming a pending
removes it in the same merge), so a claimed key can never evict a
confirmed row.

Regression: concurrent-identical-replies ordering matrix over all 4!
echo/success interleavings, asserting both accepted events survive with
distinct render keys and no pending residue. Proven red against the
previous merge logic (stash-verified) and green with the fix. Full
desktop unit suite 3728/3728; both thread-liveness Playwright smoke
specs pass.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-28 12:29:29 -04:00

127 lines
4.0 KiB
TypeScript

import type { RelayEvent } from "@/shared/api/types";
import {
dedupeMessagesById,
normalizeTimelineMessages,
sortMessages,
} from "./messageQueryKeys";
import { getChannelIdFromTags, getThreadReference } from "./threading";
function getLocalRenderKey(message: RelayEvent) {
return message.localKey ?? message.id;
}
function isMatchingPendingMessage(pending: RelayEvent, incoming: RelayEvent) {
if (
!pending.pending ||
incoming.pending ||
pending.content !== incoming.content ||
pending.kind !== incoming.kind ||
pending.pubkey.toLowerCase() !== incoming.pubkey.toLowerCase() ||
getChannelIdFromTags(pending.tags) !== getChannelIdFromTags(incoming.tags)
) {
return false;
}
const pendingThread = getThreadReference(pending.tags);
const incomingThread = getThreadReference(incoming.tags);
return (
pendingThread.parentId === incomingThread.parentId &&
pendingThread.rootId === incomingThread.rootId
);
}
/**
* Resolve the render key (localKey) the incoming event should carry, without
* ever colliding with a confirmed row's key. Concurrent identical sends make
* pending rows semantically indistinguishable, so key assignment must be
* ownership-aware or a later echo/success evicts the other send's confirmed
* row (the concurrent-identical-replies blocker):
*
* 1. A confirmed row with the same id already exists → the event was
* reconciled once (its echo or REST success won the race); inherit that
* row's key and never re-match a pending.
* 2. The caller supplied a localKey (onSuccess binding) and no *other*
* confirmed row owns it → honor it. If a different confirmed row owns
* it (an echo claimed that pending first), fall through and claim a
* remaining pending instead.
* 3. Otherwise claim the first semantically matching pending row.
*
* Pending and confirmed rows never share a key (claiming a pending removes
* it in the same merge), so a claimed pending key cannot evict a confirmed
* row.
*/
function resolveIncomingLocalKey(
normalizedCurrent: RelayEvent[],
incoming: RelayEvent,
): string | undefined {
const confirmedExisting = normalizedCurrent.find(
(message) => message.id === incoming.id && !message.pending,
);
if (confirmedExisting) {
return getLocalRenderKey(confirmedExisting);
}
const supplied = incoming.localKey;
if (supplied != null) {
const ownedByOtherConfirmed = normalizedCurrent.some(
(message) =>
!message.pending &&
message.id !== incoming.id &&
getLocalRenderKey(message) === supplied,
);
if (!ownedByOtherConfirmed) {
return supplied;
}
}
const replacedPending = normalizedCurrent.find((message) =>
isMatchingPendingMessage(message, incoming),
);
return replacedPending ? getLocalRenderKey(replacedPending) : undefined;
}
export function reconcileIncomingMessage(
current: RelayEvent[],
incoming: RelayEvent,
): RelayEvent[] {
const normalizedCurrent = dedupeMessagesById(current);
const localKey = resolveIncomingLocalKey(normalizedCurrent, incoming);
const incomingWithLocalKey =
localKey === incoming.localKey ? incoming : { ...incoming, localKey };
const incomingLocalKey = getLocalRenderKey(incomingWithLocalKey);
const deduped = normalizedCurrent.filter(
(message) =>
message.id !== incoming.id &&
getLocalRenderKey(message) !== incomingLocalKey,
);
return [...deduped, incomingWithLocalKey];
}
function mergeMessagesWithNormalizer(
current: RelayEvent[],
incoming: RelayEvent,
normalize: (messages: RelayEvent[]) => RelayEvent[],
): RelayEvent[] {
return normalize(reconcileIncomingMessage(current, incoming));
}
export function mergeMessages(
current: RelayEvent[],
incoming: RelayEvent,
): RelayEvent[] {
return mergeMessagesWithNormalizer(current, incoming, sortMessages);
}
export function mergeTimelineCacheMessages(
current: RelayEvent[],
incoming: RelayEvent,
): RelayEvent[] {
return mergeMessagesWithNormalizer(
current,
incoming,
normalizeTimelineMessages,
);
}