From 18d935bd50a7e17b0171ea86595a7f6fe55c8766 Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Sun, 19 Jul 2026 10:24:56 -0700 Subject: [PATCH 1/6] fix(huddle): unify lifecycle reconstruction Share huddle lifecycle semantics between the channel card and header indicator so stale or drained huddles cannot retain a phantom participant. Co-authored-by: Ian Oberst Signed-off-by: Ian Oberst Co-authored-by: Codex Ai-assisted: true --- .../huddle/components/HuddleAttachment.tsx | 140 ++++++------------ .../huddle/components/HuddleIndicator.tsx | 138 ++++++----------- .../huddle/lib/huddleLifecycleState.test.mjs | 109 ++++++++++++++ .../huddle/lib/huddleLifecycleState.ts | 112 ++++++++++++++ 4 files changed, 314 insertions(+), 185 deletions(-) create mode 100644 desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs create mode 100644 desktop/src/features/huddle/lib/huddleLifecycleState.ts diff --git a/desktop/src/features/huddle/components/HuddleAttachment.tsx b/desktop/src/features/huddle/components/HuddleAttachment.tsx index 80e446f798..54ab3728ec 100644 --- a/desktop/src/features/huddle/components/HuddleAttachment.tsx +++ b/desktop/src/features/huddle/components/HuddleAttachment.tsx @@ -6,12 +6,7 @@ import { toast } from "sonner"; import type { TimelineMessage } from "@/features/messages/types"; import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; -import { - KIND_HUDDLE_ENDED, - KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, - KIND_HUDDLE_STARTED, -} from "@/shared/constants/kinds"; +import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { Attachment, @@ -23,7 +18,12 @@ import { AttachmentTitle, } from "@/shared/ui/attachment"; import { useHuddle } from "../HuddleContext"; -import { isHuddleStartStale } from "../lib/huddleCardState"; +import { + huddleEventChannelId, + type HuddleLifecycleState, + huddleStalenessDelayMs, + reconstructHuddleState, +} from "../lib/huddleLifecycleState"; type HuddleAttachmentProps = { channelId: string | null; @@ -32,11 +32,6 @@ type HuddleAttachmentProps = { onOpenThread?: (message: TimelineMessage) => void; }; -type HuddleLifecycleState = { - ended: boolean; - participants: Set; -}; - function parseEphemeralChannelId(content: string): string | null { try { const parsed = JSON.parse(content) as { ephemeral_channel_id?: unknown }; @@ -48,71 +43,22 @@ function parseEphemeralChannelId(content: string): string | null { } } -function lifecycleEventChannelId(event: RelayEvent): string | null { - return parseEphemeralChannelId(event.content); -} - -function lifecycleParticipant(event: RelayEvent): string | null { - return ( - event.tags.find( - (tag) => tag[0] === "p" && typeof tag[1] === "string", - )?.[1] ?? - event.pubkey ?? - null - ); -} - -function reconstructHuddleLifecycle( - events: Iterable, - fallbackCreatorPubkey: string | undefined, - ephemeralChannelId: string, -): HuddleLifecycleState { - const sorted = [...events] - .filter((event) => lifecycleEventChannelId(event) === ephemeralChannelId) - .sort( - (left, right) => - left.created_at - right.created_at || - left.kind - right.kind || - left.id.localeCompare(right.id), - ); - const participants = new Set(); - let ended = false; - - if (fallbackCreatorPubkey) { - participants.add(fallbackCreatorPubkey); - } - - for (const event of sorted) { - switch (event.kind) { - case KIND_HUDDLE_STARTED: - ended = false; - if (event.pubkey) participants.add(event.pubkey); - break; - case KIND_HUDDLE_PARTICIPANT_JOINED: { - if (ended) break; - const pubkey = lifecycleParticipant(event); - if (pubkey) participants.add(pubkey); - break; - } - case KIND_HUDDLE_PARTICIPANT_LEFT: { - if (ended) break; - const pubkey = lifecycleParticipant(event); - if (pubkey) participants.delete(pubkey); - break; - } - case KIND_HUDDLE_ENDED: - ended = true; - break; - } - } - - return { ended, participants }; -} - function participantLabel(count: number) { return `${count} participant${count === 1 ? "" : "s"}`; } +function messageLifecycleEvent(message: TimelineMessage): RelayEvent { + return { + id: message.id, + pubkey: message.pubkey ?? "", + kind: message.kind ?? KIND_HUDDLE_STARTED, + created_at: message.createdAt, + content: message.body, + tags: message.tags ?? [], + sig: "", + }; +} + export function HuddleAttachment({ channelId, className, @@ -127,10 +73,18 @@ export function HuddleAttachment({ const queryClient = useQueryClient(); const [isJoining, setIsJoining] = React.useState(false); const [lifecycleState, setLifecycleState] = - React.useState(() => ({ - ended: false, - participants: new Set(message.pubkey ? [message.pubkey] : []), - })); + React.useState(() => + ephemeralChannelId + ? reconstructHuddleState( + [messageLifecycleEvent(message)], + ephemeralChannelId, + ) + : { + ended: true, + participants: new Set(), + startCreatedAt: null, + }, + ); React.useEffect(() => { if (!channelId || !ephemeralChannelId) return; @@ -138,6 +92,7 @@ export function HuddleAttachment({ const huddleChannelId = ephemeralChannelId; let disposed = false; let cleanup: (() => void) | null = null; + let staleTimeout: ReturnType | null = null; const seenEvents = new Map([ [ message.id, @@ -155,20 +110,24 @@ export function HuddleAttachment({ function updateState() { if (disposed) return; - setLifecycleState( - reconstructHuddleLifecycle( - seenEvents.values(), - message.pubkey, - huddleChannelId, - ), + if (staleTimeout) clearTimeout(staleTimeout); + const state = reconstructHuddleState( + seenEvents.values(), + huddleChannelId, ); + setLifecycleState(state); + const staleDelay = state.ended + ? null + : huddleStalenessDelayMs(state.startCreatedAt); + if (staleDelay !== null) + staleTimeout = setTimeout(updateState, staleDelay); } updateState(); relayClient .subscribeToHuddleEvents(channelId, (event) => { if (disposed || seenEvents.has(event.id)) return; - if (lifecycleEventChannelId(event) !== huddleChannelId) return; + if (huddleEventChannelId(event) !== huddleChannelId) return; seenEvents.set(event.id, event); updateState(); }) @@ -185,6 +144,7 @@ export function HuddleAttachment({ return () => { disposed = true; + if (staleTimeout) clearTimeout(staleTimeout); cleanup?.(); }; }, [ @@ -198,21 +158,15 @@ export function HuddleAttachment({ message.tags, ]); - const participantCount = Math.max(1, lifecycleState.participants.size); + const participantCount = lifecycleState.participants.size; const isEnded = lifecycleState.ended; const isCurrentHuddle = Boolean(ephemeralChannelId) && activeEphemeralChannelId === ephemeralChannelId; - const isStaleUnconfirmedHuddle = - !isCurrentHuddle && isHuddleStartStale(message.createdAt); const canJoin = Boolean( - channelId && - ephemeralChannelId && - !isEnded && - !isCurrentHuddle && - !isStaleUnconfirmedHuddle, + channelId && ephemeralChannelId && !isEnded && !isCurrentHuddle, ); - const displayEnded = isEnded || isStaleUnconfirmedHuddle; + const displayEnded = isEnded; async function handleJoin() { if (!channelId || !ephemeralChannelId || isJoining || isStarting) return; diff --git a/desktop/src/features/huddle/components/HuddleIndicator.tsx b/desktop/src/features/huddle/components/HuddleIndicator.tsx index ad5baea581..e71046fca0 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -10,16 +10,16 @@ import { Button } from "@/shared/ui/button"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle } from "../HuddleContext"; - -/** Huddle lifecycle event kinds */ -const KIND_HUDDLE_STARTED = 48100; -const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; -const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; -const KIND_HUDDLE_ENDED = 48103; +import { + huddleEventChannelId, + huddleStalenessDelayMs, + reconstructHuddleState, +} from "../lib/huddleLifecycleState"; type ActiveHuddle = { ephemeralChannelId: string; participants: Set; + startCreatedAt: number | null; }; type HuddleIndicatorProps = { @@ -56,98 +56,54 @@ export function HuddleIndicator({ let disposed = false; let cleanup: (() => void) | null = null; + let staleTimeout: ReturnType | null = null; // Track all seen events for reconstruction. Keyed by event.id for dedup. const seenEvents = new Map(); - /** Reconstruct huddle state from the full set of seen events. - * Sort by created_at, then kind (causal: start < join < left < end), - * then event id for final tiebreak. This handles out-of-order delivery, - * reconnect replay, late mounts, and same-second event batches. - * - * Resilient to missing start event: if we see join/left events for an - * ephemeral channel without a prior start, we infer the huddle exists. - * This covers the edge case where >100 lifecycle events push the start - * event out of the subscription window. */ function reconstruct() { - const sorted = [...seenEvents.values()].sort( - (a, b) => - a.created_at - b.created_at || - a.kind - b.kind || - a.id.localeCompare(b.id), - ); - - let huddle: ActiveHuddle | null = null; - // Track ended ephemeral channels so late-arriving join/left events - // (e.g. relay-emitted 48102 that lands 1s after a client-emitted 48103) - // don't resurrect a phantom huddle via the "infer huddle exists" fallback. - const endedChannels = new Set(); - - for (const ev of sorted) { - let ephId: string | null = null; - try { - const content = JSON.parse(ev.content); - ephId = content.ephemeral_channel_id ?? null; - } catch { - continue; // Malformed — skip - } + if (staleTimeout) clearTimeout(staleTimeout); + const eventsByHuddle = new Map(); + for (const event of seenEvents.values()) { + const ephemeralChannelId = huddleEventChannelId(event); + if (!ephemeralChannelId) continue; + const events = eventsByHuddle.get(ephemeralChannelId) ?? []; + events.push(event); + eventsByHuddle.set(ephemeralChannelId, events); + } - switch (ev.kind) { - case KIND_HUDDLE_STARTED: { - if (!ephId) break; - // A new start supersedes any previous ended state for this channel. - endedChannels.delete(ephId); - huddle = { - ephemeralChannelId: ephId, - participants: new Set([ev.pubkey]), - }; - break; + const activeHuddles = [...eventsByHuddle.entries()] + .map(([ephemeralChannelId, events]) => ({ + ephemeralChannelId, + events, + state: reconstructHuddleState(events, ephemeralChannelId), + lastEventCreatedAt: Math.max( + ...events.map((event) => event.created_at), + ), + })) + .filter(({ state }) => !state.ended) + .sort( + (left, right) => + right.lastEventCreatedAt - left.lastEventCreatedAt || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + ); + const latest = activeHuddles[0]; + const huddle: ActiveHuddle | null = latest + ? { + ephemeralChannelId: latest.ephemeralChannelId, + participants: latest.state.participants, + startCreatedAt: latest.state.startCreatedAt, } - case KIND_HUDDLE_PARTICIPANT_JOINED: { - if (!ephId) break; - // Skip if this ephemeral channel has already ended — don't - // resurrect a phantom huddle from a late-arriving relay event. - if (endedChannels.has(ephId)) break; - // 48101 events are relay-signed — the actual participant is in the "p" tag. - const joinedPk = - ev.tags.find((t) => t[0] === "p")?.[1] ?? ev.pubkey; - if (!huddle || ephId !== huddle.ephemeralChannelId) { - huddle = { - ephemeralChannelId: ephId, - participants: new Set(), - }; - } - huddle.participants.add(joinedPk); - break; - } - case KIND_HUDDLE_PARTICIPANT_LEFT: { - if (!ephId) break; - // Skip if this ephemeral channel has already ended. - if (endedChannels.has(ephId)) break; - // 48102 events are relay-signed — the actual participant is in the "p" tag. - const leftPk = ev.tags.find((t) => t[0] === "p")?.[1] ?? ev.pubkey; - if (!huddle || ephId !== huddle.ephemeralChannelId) { - huddle = { - ephemeralChannelId: ephId, - participants: new Set(), - }; - } - huddle.participants.delete(leftPk); - break; - } - case KIND_HUDDLE_ENDED: { - if (!ephId) break; - endedChannels.add(ephId); - if (huddle && ephId === huddle.ephemeralChannelId) { - huddle = null; - } - break; - } - } - } + : null; if (!disposed) { setActiveHuddle(huddle); + const staleDelay = huddle + ? huddleStalenessDelayMs(huddle.startCreatedAt) + : null; + if (staleDelay !== null) { + staleTimeout = setTimeout(reconstruct, staleDelay); + } } } @@ -178,6 +134,7 @@ export function HuddleIndicator({ return () => { disposed = true; + if (staleTimeout) clearTimeout(staleTimeout); cleanup?.(); setActiveHuddle(null); }; @@ -249,10 +206,7 @@ export function HuddleIndicator({ ); } - // At least 1 participant must exist for the huddle to be active. - // When START fell out of the event window, the creator isn't in the - // reconstructed set — floor at 1 to avoid showing "0 participants". - const participantCount = Math.max(1, activeHuddle.participants.size); + const participantCount = activeHuddle.participants.size; async function doJoin() { if (!activeHuddle || isJoining) return; diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs new file mode 100644 index 0000000000..e5f0328e8f --- /dev/null +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + huddleStalenessDelayMs, + reconstructHuddleState, +} from "./huddleLifecycleState.ts"; + +const HUDDLE_ID = "huddle-id"; +const CREATOR = "a".repeat(64); +const PARTICIPANT = "b".repeat(64); +const NOW_SECONDS = 2_000_000; + +function lifecycleEvent(kind, overrides = {}) { + return { + id: `${kind}-${overrides.created_at ?? NOW_SECONDS}`, + pubkey: CREATOR, + created_at: NOW_SECONDS, + kind, + tags: [], + content: JSON.stringify({ ephemeral_channel_id: HUDDLE_ID }), + sig: "", + ...overrides, + }; +} + +test("reconstructHuddleState ends an explicitly ended huddle", () => { + const state = reconstructHuddleState( + [lifecycleEvent(48100), lifecycleEvent(48103)], + HUDDLE_ID, + NOW_SECONDS * 1000, + ); + + assert.equal(state.ended, true); + assert.equal(state.startCreatedAt, NOW_SECONDS); +}); + +test("reconstructHuddleState ends a fully drained huddle", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100), + lifecycleEvent(48101, { tags: [["p", PARTICIPANT]] }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS + 1, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS + 1, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + (NOW_SECONDS + 1) * 1000, + ); + + assert.equal(state.ended, true); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState ends a stale huddle and retains its start time", () => { + const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; + const state = reconstructHuddleState( + [lifecycleEvent(48100, { created_at: startCreatedAt })], + HUDDLE_ID, + NOW_SECONDS * 1000, + ); + + assert.equal(state.ended, true); + assert.equal(state.startCreatedAt, startCreatedAt); + assert.deepEqual([...state.participants], [CREATOR]); +}); + +test("reconstructHuddleState keeps real joins active when START aged out", () => { + const state = reconstructHuddleState( + [lifecycleEvent(48101, { tags: [["p", PARTICIPANT]] })], + HUDDLE_ID, + NOW_SECONDS * 1000, + ); + + assert.equal(state.ended, false); + assert.equal(state.startCreatedAt, null); + assert.deepEqual([...state.participants], [PARTICIPANT]); +}); + +test("reconstructHuddleState does not resurrect after an end event", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100), + lifecycleEvent(48103, { created_at: NOW_SECONDS + 1 }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS + 2, + tags: [["p", PARTICIPANT]], + }), + ], + HUDDLE_ID, + (NOW_SECONDS + 2) * 1000, + ); + + assert.equal(state.ended, true); + assert.deepEqual([...state.participants], [CREATOR]); +}); + +test("huddleStalenessDelayMs schedules just past the stale boundary", () => { + assert.equal( + huddleStalenessDelayMs(NOW_SECONDS - 60 * 60 + 10, NOW_SECONDS * 1000), + 10_001, + ); + assert.equal(huddleStalenessDelayMs(null, NOW_SECONDS * 1000), null); +}); diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.ts b/desktop/src/features/huddle/lib/huddleLifecycleState.ts new file mode 100644 index 0000000000..f45c7dbe10 --- /dev/null +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.ts @@ -0,0 +1,112 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_HUDDLE_ENDED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_STARTED, +} from "@/shared/constants/kinds"; +import { + HUDDLE_JOINABLE_WINDOW_SECONDS, + isHuddleStartStale, +} from "./huddleCardState"; + +export type HuddleLifecycleState = { + ended: boolean; + participants: Set; + startCreatedAt: number | null; +}; + +export function huddleEventChannelId(event: RelayEvent): string | null { + try { + const parsed = JSON.parse(event.content) as { + ephemeral_channel_id?: unknown; + }; + return typeof parsed.ephemeral_channel_id === "string" + ? parsed.ephemeral_channel_id + : null; + } catch { + return null; + } +} + +function lifecycleParticipant(event: RelayEvent): string | null { + return ( + event.tags.find( + (tag) => tag[0] === "p" && typeof tag[1] === "string", + )?.[1] ?? + event.pubkey ?? + null + ); +} + +/** + * Reconstruct one huddle from its lifecycle events. + * + * An inferred huddle with no START event stays active while at least one JOIN + * remains in the window. This preserves late-mount recovery after START ages + * out of the relay subscription without inventing a participant for a fully + * drained huddle. + */ +export function reconstructHuddleState( + events: Iterable, + ephemeralChannelId: string, + nowMs = Date.now(), +): HuddleLifecycleState { + const sorted = [...events] + .filter((event) => huddleEventChannelId(event) === ephemeralChannelId) + .sort( + (left, right) => + left.created_at - right.created_at || + left.kind - right.kind || + left.id.localeCompare(right.id), + ); + let participants = new Set(); + let explicitlyEnded = false; + let startCreatedAt: number | null = null; + + for (const event of sorted) { + switch (event.kind) { + case KIND_HUDDLE_STARTED: + if (explicitlyEnded) break; + startCreatedAt = event.created_at; + participants = new Set(event.pubkey ? [event.pubkey] : []); + break; + case KIND_HUDDLE_PARTICIPANT_JOINED: { + if (explicitlyEnded) break; + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.add(pubkey); + break; + } + case KIND_HUDDLE_PARTICIPANT_LEFT: { + if (explicitlyEnded) break; + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.delete(pubkey); + break; + } + case KIND_HUDDLE_ENDED: + explicitlyEnded = true; + break; + } + } + + return { + ended: + explicitlyEnded || + participants.size === 0 || + (startCreatedAt !== null && isHuddleStartStale(startCreatedAt, nowMs)), + participants, + startCreatedAt, + }; +} + +/** Delay until a fresh START crosses the shared joinable-window boundary. */ +export function huddleStalenessDelayMs( + startCreatedAt: number | null, + nowMs = Date.now(), +): number | null { + if (startCreatedAt === null) return null; + return Math.max( + 0, + (startCreatedAt + HUDDLE_JOINABLE_WINDOW_SECONDS) * 1000 - nowMs + 1, + ); +} From 7187ba2142f5c5aae09de5d6bb2ed5e8f4f16548 Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Sun, 19 Jul 2026 11:45:15 -0700 Subject: [PATCH 2/6] fix(huddle): preserve live lifecycle evidence Treat empty participant state as conclusive only when START remains in the subscription window. Keep long-running and locally current huddles active when newer lifecycle evidence supersedes the start-age fallback. Co-authored-by: Ian Oberst Signed-off-by: Ian Oberst Co-authored-by: Codex Ai-assisted: true --- .../huddle/components/HuddleAttachment.tsx | 12 ++-- .../huddle/components/HuddleIndicator.tsx | 14 ++-- .../huddle/lib/huddleLifecycleState.test.mjs | 72 +++++++++++++++++-- .../huddle/lib/huddleLifecycleState.ts | 56 ++++++++++----- 4 files changed, 120 insertions(+), 34 deletions(-) diff --git a/desktop/src/features/huddle/components/HuddleAttachment.tsx b/desktop/src/features/huddle/components/HuddleAttachment.tsx index 54ab3728ec..8bf2d35489 100644 --- a/desktop/src/features/huddle/components/HuddleAttachment.tsx +++ b/desktop/src/features/huddle/components/HuddleAttachment.tsx @@ -70,6 +70,9 @@ export function HuddleAttachment({ [message.body], ); const { activeEphemeralChannelId, isStarting, joinHuddle } = useHuddle(); + const isCurrentHuddle = + Boolean(ephemeralChannelId) && + activeEphemeralChannelId === ephemeralChannelId; const queryClient = useQueryClient(); const [isJoining, setIsJoining] = React.useState(false); const [lifecycleState, setLifecycleState] = @@ -78,11 +81,13 @@ export function HuddleAttachment({ ? reconstructHuddleState( [messageLifecycleEvent(message)], ephemeralChannelId, + { isCurrentHuddle }, ) : { ended: true, participants: new Set(), startCreatedAt: null, + staleDeadlineMs: null, }, ); @@ -114,11 +119,12 @@ export function HuddleAttachment({ const state = reconstructHuddleState( seenEvents.values(), huddleChannelId, + { isCurrentHuddle }, ); setLifecycleState(state); const staleDelay = state.ended ? null - : huddleStalenessDelayMs(state.startCreatedAt); + : huddleStalenessDelayMs(state.staleDeadlineMs); if (staleDelay !== null) staleTimeout = setTimeout(updateState, staleDelay); } @@ -150,6 +156,7 @@ export function HuddleAttachment({ }, [ channelId, ephemeralChannelId, + isCurrentHuddle, message.body, message.createdAt, message.id, @@ -160,9 +167,6 @@ export function HuddleAttachment({ const participantCount = lifecycleState.participants.size; const isEnded = lifecycleState.ended; - const isCurrentHuddle = - Boolean(ephemeralChannelId) && - activeEphemeralChannelId === ephemeralChannelId; const canJoin = Boolean( channelId && ephemeralChannelId && !isEnded && !isCurrentHuddle, ); diff --git a/desktop/src/features/huddle/components/HuddleIndicator.tsx b/desktop/src/features/huddle/components/HuddleIndicator.tsx index e71046fca0..78af1ed2d8 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -19,7 +19,7 @@ import { type ActiveHuddle = { ephemeralChannelId: string; participants: Set; - startCreatedAt: number | null; + staleDeadlineMs: number | null; }; type HuddleIndicatorProps = { @@ -44,7 +44,7 @@ export function HuddleIndicator({ onStart, startDisabled, }: HuddleIndicatorProps) { - const { joinHuddle, isStarting } = useHuddle(); + const { activeEphemeralChannelId, joinHuddle, isStarting } = useHuddle(); const queryClient = useQueryClient(); const [activeHuddle, setActiveHuddle] = React.useState( null, @@ -76,7 +76,9 @@ export function HuddleIndicator({ .map(([ephemeralChannelId, events]) => ({ ephemeralChannelId, events, - state: reconstructHuddleState(events, ephemeralChannelId), + state: reconstructHuddleState(events, ephemeralChannelId, { + isCurrentHuddle: activeEphemeralChannelId === ephemeralChannelId, + }), lastEventCreatedAt: Math.max( ...events.map((event) => event.created_at), ), @@ -92,14 +94,14 @@ export function HuddleIndicator({ ? { ephemeralChannelId: latest.ephemeralChannelId, participants: latest.state.participants, - startCreatedAt: latest.state.startCreatedAt, + staleDeadlineMs: latest.state.staleDeadlineMs, } : null; if (!disposed) { setActiveHuddle(huddle); const staleDelay = huddle - ? huddleStalenessDelayMs(huddle.startCreatedAt) + ? huddleStalenessDelayMs(huddle.staleDeadlineMs) : null; if (staleDelay !== null) { staleTimeout = setTimeout(reconstruct, staleDelay); @@ -138,7 +140,7 @@ export function HuddleIndicator({ cleanup?.(); setActiveHuddle(null); }; - }, [channelId]); + }, [activeEphemeralChannelId, channelId]); // When the local user ends/leaves a huddle, the backend transitions to idle // and emits huddle-state-changed. Clear the indicator immediately rather than diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs index e5f0328e8f..35016ba576 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs @@ -28,7 +28,7 @@ test("reconstructHuddleState ends an explicitly ended huddle", () => { const state = reconstructHuddleState( [lifecycleEvent(48100), lifecycleEvent(48103)], HUDDLE_ID, - NOW_SECONDS * 1000, + { nowMs: NOW_SECONDS * 1000 }, ); assert.equal(state.ended, true); @@ -50,7 +50,7 @@ test("reconstructHuddleState ends a fully drained huddle", () => { }), ], HUDDLE_ID, - (NOW_SECONDS + 1) * 1000, + { nowMs: (NOW_SECONDS + 1) * 1000 }, ); assert.equal(state.ended, true); @@ -62,7 +62,7 @@ test("reconstructHuddleState ends a stale huddle and retains its start time", () const state = reconstructHuddleState( [lifecycleEvent(48100, { created_at: startCreatedAt })], HUDDLE_ID, - NOW_SECONDS * 1000, + { nowMs: NOW_SECONDS * 1000 }, ); assert.equal(state.ended, true); @@ -70,11 +70,40 @@ test("reconstructHuddleState ends a stale huddle and retains its start time", () assert.deepEqual([...state.participants], [CREATOR]); }); +test("reconstructHuddleState keeps an old START active after a recent JOIN", () => { + const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: startCreatedAt }), + lifecycleEvent(48101, { tags: [["p", PARTICIPANT]] }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.staleDeadlineMs, null); + assert.deepEqual([...state.participants], [CREATOR, PARTICIPANT]); +}); + +test("reconstructHuddleState keeps the current huddle active past START age", () => { + const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; + const state = reconstructHuddleState( + [lifecycleEvent(48100, { created_at: startCreatedAt })], + HUDDLE_ID, + { isCurrentHuddle: true, nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.staleDeadlineMs, null); + assert.deepEqual([...state.participants], [CREATOR]); +}); + test("reconstructHuddleState keeps real joins active when START aged out", () => { const state = reconstructHuddleState( [lifecycleEvent(48101, { tags: [["p", PARTICIPANT]] })], HUDDLE_ID, - NOW_SECONDS * 1000, + { nowMs: NOW_SECONDS * 1000 }, ); assert.equal(state.ended, false); @@ -82,6 +111,37 @@ test("reconstructHuddleState keeps real joins active when START aged out", () => assert.deepEqual([...state.participants], [PARTICIPANT]); }); +test("reconstructHuddleState treats empty truncated history as inconclusive", () => { + const events = [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 100 }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 99, + tags: [["p", CREATOR]], + }), + ]; + for (let index = 0; index < 50; index += 1) { + const participant = `participant-${index}`; + events.push( + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 50 + index, + tags: [["p", participant]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 50 + index, + tags: [["p", participant]], + }), + ); + } + + const state = reconstructHuddleState(events.slice(-100), HUDDLE_ID, { + nowMs: NOW_SECONDS * 1000, + }); + + assert.equal(state.ended, false); + assert.equal(state.startCreatedAt, null); + assert.equal(state.participants.size, 0); +}); + test("reconstructHuddleState does not resurrect after an end event", () => { const state = reconstructHuddleState( [ @@ -93,7 +153,7 @@ test("reconstructHuddleState does not resurrect after an end event", () => { }), ], HUDDLE_ID, - (NOW_SECONDS + 2) * 1000, + { nowMs: (NOW_SECONDS + 2) * 1000 }, ); assert.equal(state.ended, true); @@ -102,7 +162,7 @@ test("reconstructHuddleState does not resurrect after an end event", () => { test("huddleStalenessDelayMs schedules just past the stale boundary", () => { assert.equal( - huddleStalenessDelayMs(NOW_SECONDS - 60 * 60 + 10, NOW_SECONDS * 1000), + huddleStalenessDelayMs((NOW_SECONDS + 10) * 1000 + 1, NOW_SECONDS * 1000), 10_001, ); assert.equal(huddleStalenessDelayMs(null, NOW_SECONDS * 1000), null); diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.ts b/desktop/src/features/huddle/lib/huddleLifecycleState.ts index f45c7dbe10..03f47ad12d 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.ts +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.ts @@ -5,15 +5,18 @@ import { KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, } from "@/shared/constants/kinds"; -import { - HUDDLE_JOINABLE_WINDOW_SECONDS, - isHuddleStartStale, -} from "./huddleCardState"; +import { HUDDLE_JOINABLE_WINDOW_SECONDS } from "./huddleCardState"; export type HuddleLifecycleState = { ended: boolean; participants: Set; startCreatedAt: number | null; + staleDeadlineMs: number | null; +}; + +type ReconstructHuddleOptions = { + isCurrentHuddle?: boolean; + nowMs?: number; }; export function huddleEventChannelId(event: RelayEvent): string | null { @@ -42,15 +45,14 @@ function lifecycleParticipant(event: RelayEvent): string | null { /** * Reconstruct one huddle from its lifecycle events. * - * An inferred huddle with no START event stays active while at least one JOIN - * remains in the window. This preserves late-mount recovery after START ages - * out of the relay subscription without inventing a participant for a fully - * drained huddle. + * An inferred huddle with no START event stays non-terminal because the + * subscription window may have truncated an older participant JOIN. A retained + * START makes an empty reconstructed participant set conclusive. */ export function reconstructHuddleState( events: Iterable, ephemeralChannelId: string, - nowMs = Date.now(), + options: ReconstructHuddleOptions = {}, ): HuddleLifecycleState { const sorted = [...events] .filter((event) => huddleEventChannelId(event) === ephemeralChannelId) @@ -63,6 +65,7 @@ export function reconstructHuddleState( let participants = new Set(); let explicitlyEnded = false; let startCreatedAt: number | null = null; + let sawParticipantEventAfterStart = false; for (const event of sorted) { switch (event.kind) { @@ -70,15 +73,18 @@ export function reconstructHuddleState( if (explicitlyEnded) break; startCreatedAt = event.created_at; participants = new Set(event.pubkey ? [event.pubkey] : []); + sawParticipantEventAfterStart = false; break; case KIND_HUDDLE_PARTICIPANT_JOINED: { if (explicitlyEnded) break; + if (startCreatedAt !== null) sawParticipantEventAfterStart = true; const pubkey = lifecycleParticipant(event); if (pubkey) participants.add(pubkey); break; } case KIND_HUDDLE_PARTICIPANT_LEFT: { if (explicitlyEnded) break; + if (startCreatedAt !== null) sawParticipantEventAfterStart = true; const pubkey = lifecycleParticipant(event); if (pubkey) participants.delete(pubkey); break; @@ -89,24 +95,38 @@ export function reconstructHuddleState( } } + // An empty set is conclusive only when START is retained: without START, + // the subscription's 100-event window may have truncated an older JOIN. + const drained = startCreatedAt !== null && participants.size === 0; + // START age is only a fallback for a huddle that never produced newer + // lifecycle evidence. The relay TTL is renewable, so a later JOIN/LEFT or + // the locally current huddle must not be expired from START time alone. + const staleDeadlineMs = + startCreatedAt !== null && + !sawParticipantEventAfterStart && + !options.isCurrentHuddle && + !explicitlyEnded && + !drained + ? (startCreatedAt + HUDDLE_JOINABLE_WINDOW_SECONDS) * 1000 + 1 + : null; + const nowMs = options.nowMs ?? Date.now(); + return { ended: explicitlyEnded || - participants.size === 0 || - (startCreatedAt !== null && isHuddleStartStale(startCreatedAt, nowMs)), + drained || + (staleDeadlineMs !== null && nowMs >= staleDeadlineMs), participants, startCreatedAt, + staleDeadlineMs, }; } -/** Delay until a fresh START crosses the shared joinable-window boundary. */ +/** Delay until an unconfirmed START crosses the shared stale boundary. */ export function huddleStalenessDelayMs( - startCreatedAt: number | null, + staleDeadlineMs: number | null, nowMs = Date.now(), ): number | null { - if (startCreatedAt === null) return null; - return Math.max( - 0, - (startCreatedAt + HUDDLE_JOINABLE_WINDOW_SECONDS) * 1000 - nowMs + 1, - ); + if (staleDeadlineMs === null) return null; + return Math.max(0, staleDeadlineMs - nowMs); } From 831ccdf336e4736213d9e0fe30557868c50fac85 Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Sun, 19 Jul 2026 12:18:06 -0700 Subject: [PATCH 3/6] fix(huddle): make lifecycle ordering clock-safe Fold relay participant transitions independently from client START timestamps, preserve uncertainty for truncated history, and stop the header from falling back to older sessions after a newer huddle ends. Co-authored-by: Ian Oberst Signed-off-by: Ian Oberst Co-authored-by: Codex Ai-assisted: true --- .../huddle/components/HuddleAttachment.tsx | 6 +- .../huddle/components/HuddleIndicator.tsx | 43 ++--- .../huddle/lib/huddleLifecycleState.test.mjs | 131 ++++++++++++++ .../huddle/lib/huddleLifecycleState.ts | 169 ++++++++++++++---- 4 files changed, 280 insertions(+), 69 deletions(-) diff --git a/desktop/src/features/huddle/components/HuddleAttachment.tsx b/desktop/src/features/huddle/components/HuddleAttachment.tsx index 8bf2d35489..b70accae53 100644 --- a/desktop/src/features/huddle/components/HuddleAttachment.tsx +++ b/desktop/src/features/huddle/components/HuddleAttachment.tsx @@ -19,6 +19,7 @@ import { } from "@/shared/ui/attachment"; import { useHuddle } from "../HuddleContext"; import { + HUDDLE_EVENT_HISTORY_LIMIT, huddleEventChannelId, type HuddleLifecycleState, huddleStalenessDelayMs, @@ -119,7 +120,10 @@ export function HuddleAttachment({ const state = reconstructHuddleState( seenEvents.values(), huddleChannelId, - { isCurrentHuddle }, + { + historyMayBeTruncated: seenEvents.size >= HUDDLE_EVENT_HISTORY_LIMIT, + isCurrentHuddle, + }, ); setLifecycleState(state); const staleDelay = state.ended diff --git a/desktop/src/features/huddle/components/HuddleIndicator.tsx b/desktop/src/features/huddle/components/HuddleIndicator.tsx index 78af1ed2d8..646584abd0 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -11,9 +11,9 @@ import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle } from "../HuddleContext"; import { - huddleEventChannelId, + HUDDLE_EVENT_HISTORY_LIMIT, huddleStalenessDelayMs, - reconstructHuddleState, + selectActiveHuddleState, } from "../lib/huddleLifecycleState"; type ActiveHuddle = { @@ -63,38 +63,15 @@ export function HuddleIndicator({ function reconstruct() { if (staleTimeout) clearTimeout(staleTimeout); - const eventsByHuddle = new Map(); - for (const event of seenEvents.values()) { - const ephemeralChannelId = huddleEventChannelId(event); - if (!ephemeralChannelId) continue; - const events = eventsByHuddle.get(ephemeralChannelId) ?? []; - events.push(event); - eventsByHuddle.set(ephemeralChannelId, events); - } - - const activeHuddles = [...eventsByHuddle.entries()] - .map(([ephemeralChannelId, events]) => ({ - ephemeralChannelId, - events, - state: reconstructHuddleState(events, ephemeralChannelId, { - isCurrentHuddle: activeEphemeralChannelId === ephemeralChannelId, - }), - lastEventCreatedAt: Math.max( - ...events.map((event) => event.created_at), - ), - })) - .filter(({ state }) => !state.ended) - .sort( - (left, right) => - right.lastEventCreatedAt - left.lastEventCreatedAt || - right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), - ); - const latest = activeHuddles[0]; - const huddle: ActiveHuddle | null = latest + const selected = selectActiveHuddleState(seenEvents.values(), { + activeEphemeralChannelId, + historyMayBeTruncated: seenEvents.size >= HUDDLE_EVENT_HISTORY_LIMIT, + }); + const huddle: ActiveHuddle | null = selected ? { - ephemeralChannelId: latest.ephemeralChannelId, - participants: latest.state.participants, - staleDeadlineMs: latest.state.staleDeadlineMs, + ephemeralChannelId: selected.ephemeralChannelId, + participants: selected.state.participants, + staleDeadlineMs: selected.state.staleDeadlineMs, } : null; diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs index 35016ba576..17d9454148 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { huddleStalenessDelayMs, reconstructHuddleState, + selectActiveHuddleState, } from "./huddleLifecycleState.ts"; const HUDDLE_ID = "huddle-id"; @@ -86,6 +87,67 @@ test("reconstructHuddleState keeps an old START active after a recent JOIN", () assert.deepEqual([...state.participants], [CREATOR, PARTICIPANT]); }); +test("reconstructHuddleState preserves a JOIN timestamped before START", () => { + const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: startCreatedAt }), + lifecycleEvent(48101, { + created_at: startCreatedAt - 5, + tags: [["p", PARTICIPANT]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.staleDeadlineMs, null); + assert.deepEqual([...state.participants], [CREATOR, PARTICIPANT]); +}); + +test("reconstructHuddleState applies a LEFT timestamped before START", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 5, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, true); + assert.equal(state.participants.size, 0); +}); + +test("reconstructHuddleState drains participants under START clock skew", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 4, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 3, + tags: [["p", CREATOR]], + }), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, true); + assert.equal(state.participants.size, 0); +}); + test("reconstructHuddleState keeps the current huddle active past START age", () => { const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; const state = reconstructHuddleState( @@ -142,6 +204,75 @@ test("reconstructHuddleState treats empty truncated history as inconclusive", () assert.equal(state.participants.size, 0); }); +test("reconstructHuddleState keeps a skew-retained START inconclusive when history is truncated", () => { + const events = [lifecycleEvent(48100)]; + for (let index = 0; index < 49; index += 1) { + const participant = `participant-${index}`; + events.push( + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 100 + index * 2, + tags: [["p", participant]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 99 + index * 2, + tags: [["p", participant]], + }), + ); + } + events.push( + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }), + ); + + const state = reconstructHuddleState(events, HUDDLE_ID, { + historyMayBeTruncated: true, + nowMs: NOW_SECONDS * 1000, + }); + + assert.equal(events.length, 100); + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + +test("selectActiveHuddleState does not resurrect an older incomplete huddle", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + const eventForHuddle = (kind, ephemeralChannelId, overrides = {}) => + lifecycleEvent(kind, { + content: JSON.stringify({ + ephemeral_channel_id: ephemeralChannelId, + }), + ...overrides, + }); + + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, newerHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, newerHuddleId, { + created_at: NOW_SECONDS - 8, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + test("reconstructHuddleState does not resurrect after an end event", () => { const state = reconstructHuddleState( [ diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.ts b/desktop/src/features/huddle/lib/huddleLifecycleState.ts index 03f47ad12d..d90d0cd587 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.ts +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.ts @@ -15,10 +15,24 @@ export type HuddleLifecycleState = { }; type ReconstructHuddleOptions = { + historyMayBeTruncated?: boolean; isCurrentHuddle?: boolean; nowMs?: number; }; +type SelectActiveHuddleOptions = { + activeEphemeralChannelId?: string | null; + historyMayBeTruncated?: boolean; + nowMs?: number; +}; + +export const HUDDLE_EVENT_HISTORY_LIMIT = 100; + +export type ActiveHuddleLifecycleState = { + ephemeralChannelId: string; + state: HuddleLifecycleState; +}; + export function huddleEventChannelId(event: RelayEvent): string | null { try { const parsed = JSON.parse(event.content) as { @@ -54,56 +68,69 @@ export function reconstructHuddleState( ephemeralChannelId: string, options: ReconstructHuddleOptions = {}, ): HuddleLifecycleState { - const sorted = [...events] - .filter((event) => huddleEventChannelId(event) === ephemeralChannelId) + const matchingEvents = [...events].filter( + (event) => huddleEventChannelId(event) === ephemeralChannelId, + ); + const startEvent = matchingEvents + .filter((event) => event.kind === KIND_HUDDLE_STARTED) + .sort( + (left, right) => + left.created_at - right.created_at || left.id.localeCompare(right.id), + ) + .at(-1); + const participantEvents = matchingEvents + .filter( + (event) => + event.kind === KIND_HUDDLE_PARTICIPANT_JOINED || + event.kind === KIND_HUDDLE_PARTICIPANT_LEFT, + ) .sort( (left, right) => left.created_at - right.created_at || left.kind - right.kind || left.id.localeCompare(right.id), ); - let participants = new Set(); - let explicitlyEnded = false; - let startCreatedAt: number | null = null; - let sawParticipantEventAfterStart = false; - - for (const event of sorted) { - switch (event.kind) { - case KIND_HUDDLE_STARTED: - if (explicitlyEnded) break; - startCreatedAt = event.created_at; - participants = new Set(event.pubkey ? [event.pubkey] : []); - sawParticipantEventAfterStart = false; - break; - case KIND_HUDDLE_PARTICIPANT_JOINED: { - if (explicitlyEnded) break; - if (startCreatedAt !== null) sawParticipantEventAfterStart = true; - const pubkey = lifecycleParticipant(event); - if (pubkey) participants.add(pubkey); - break; - } - case KIND_HUDDLE_PARTICIPANT_LEFT: { - if (explicitlyEnded) break; - if (startCreatedAt !== null) sawParticipantEventAfterStart = true; - const pubkey = lifecycleParticipant(event); - if (pubkey) participants.delete(pubkey); - break; + const participants = new Set( + startEvent?.pubkey ? [startEvent.pubkey] : [], + ); + const explicitlyEnded = matchingEvents.some( + (event) => event.kind === KIND_HUDDLE_ENDED, + ); + const startCreatedAt = startEvent?.created_at ?? null; + + // START is client-signed while participant transitions are relay-signed, so + // their created_at values are not one causal clock. Seed the creator from + // START, then fold only relay participant transitions in their own order. + if (!explicitlyEnded) { + for (const event of participantEvents) { + switch (event.kind) { + case KIND_HUDDLE_PARTICIPANT_JOINED: { + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.add(pubkey); + break; + } + case KIND_HUDDLE_PARTICIPANT_LEFT: { + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.delete(pubkey); + break; + } } - case KIND_HUDDLE_ENDED: - explicitlyEnded = true; - break; } } - // An empty set is conclusive only when START is retained: without START, - // the subscription's 100-event window may have truncated an older JOIN. - const drained = startCreatedAt !== null && participants.size === 0; + // An empty set is conclusive only when START is retained and the replay did + // not hit its limit. Under clock skew, START can survive in a truncated + // window even when an earlier relay-signed JOIN did not. + const drained = + startCreatedAt !== null && + !options.historyMayBeTruncated && + participants.size === 0; // START age is only a fallback for a huddle that never produced newer // lifecycle evidence. The relay TTL is renewable, so a later JOIN/LEFT or // the locally current huddle must not be expired from START time alone. const staleDeadlineMs = startCreatedAt !== null && - !sawParticipantEventAfterStart && + participantEvents.length === 0 && !options.isCurrentHuddle && !explicitlyEnded && !drained @@ -122,6 +149,78 @@ export function reconstructHuddleState( }; } +/** + * Select the channel header's huddle without falling back past a newer ended + * session. Retained START events are the session boundaries; participant and + * END timestamps never compete with a different client's START timestamp. + */ +export function selectActiveHuddleState( + events: Iterable, + options: SelectActiveHuddleOptions = {}, +): ActiveHuddleLifecycleState | null { + const allEvents = [...events]; + const historyMayBeTruncated = + options.historyMayBeTruncated ?? + allEvents.length >= HUDDLE_EVENT_HISTORY_LIMIT; + const eventsByHuddle = new Map(); + for (const event of allEvents) { + const ephemeralChannelId = huddleEventChannelId(event); + if (!ephemeralChannelId) continue; + const huddleEvents = eventsByHuddle.get(ephemeralChannelId) ?? []; + huddleEvents.push(event); + eventsByHuddle.set(ephemeralChannelId, huddleEvents); + } + + const candidates = [...eventsByHuddle.entries()].map( + ([ephemeralChannelId, huddleEvents]) => ({ + ephemeralChannelId, + state: reconstructHuddleState(huddleEvents, ephemeralChannelId, { + historyMayBeTruncated, + isCurrentHuddle: + options.activeEphemeralChannelId === ephemeralChannelId, + nowMs: options.nowMs, + }), + lastEventCreatedAt: Math.max( + ...huddleEvents.map((event) => event.created_at), + ), + }), + ); + + const current = candidates.find( + ({ ephemeralChannelId, state }) => + ephemeralChannelId === options.activeEphemeralChannelId && !state.ended, + ); + if (current) { + return { + ephemeralChannelId: current.ephemeralChannelId, + state: current.state, + }; + } + + // A retained START is a session boundary. Choose the newest session before + // applying terminal state so an older incomplete history cannot reappear + // after a newer huddle ends. Only use relay activity as a fallback when the + // subscription window contains no START at all. + const startedCandidates = candidates.filter( + ({ state }) => state.startCreatedAt !== null, + ); + const candidatePool = + startedCandidates.length > 0 ? startedCandidates : candidates; + candidatePool.sort( + (left, right) => + (right.state.startCreatedAt ?? right.lastEventCreatedAt) - + (left.state.startCreatedAt ?? left.lastEventCreatedAt) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + ); + + const selected = candidatePool[0]; + if (!selected || selected.state.ended) return null; + return { + ephemeralChannelId: selected.ephemeralChannelId, + state: selected.state, + }; +} + /** Delay until an unconfirmed START crosses the shared stale boundary. */ export function huddleStalenessDelayMs( staleDeadlineMs: number | null, From 804b48f9dcdf982297b075064e57ae331e41b70a Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Sun, 19 Jul 2026 12:43:12 -0700 Subject: [PATCH 4/6] fix(huddle): prioritize relay-backed activity Co-authored-by: Ian Oberst Signed-off-by: Ian Oberst Co-authored-by: Codex Ai-assisted: true --- .../huddle/lib/huddleLifecycleState.test.mjs | 160 +++++++++++++++++- .../huddle/lib/huddleLifecycleState.ts | 85 +++++++--- 2 files changed, 213 insertions(+), 32 deletions(-) diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs index 17d9454148..d6769357c1 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs @@ -25,6 +25,13 @@ function lifecycleEvent(kind, overrides = {}) { }; } +function eventForHuddle(kind, ephemeralChannelId, overrides = {}) { + return lifecycleEvent(kind, { + content: JSON.stringify({ ephemeral_channel_id: ephemeralChannelId }), + ...overrides, + }); +} + test("reconstructHuddleState ends an explicitly ended huddle", () => { const state = reconstructHuddleState( [lifecycleEvent(48100), lifecycleEvent(48103)], @@ -71,6 +78,19 @@ test("reconstructHuddleState ends a stale huddle and retains its start time", () assert.deepEqual([...state.participants], [CREATOR]); }); +test("reconstructHuddleState documents bounded staleness extension under maximum future skew", () => { + const maxClientClockSkewSeconds = 15 * 60; + const startCreatedAt = NOW_SECONDS + maxClientClockSkewSeconds; + const state = reconstructHuddleState( + [lifecycleEvent(48100, { created_at: startCreatedAt })], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, false); + assert.equal(state.staleDeadlineMs, (startCreatedAt + 60 * 60) * 1000 + 1); +}); + test("reconstructHuddleState keeps an old START active after a recent JOIN", () => { const startCreatedAt = NOW_SECONDS - 60 * 60 - 1; const state = reconstructHuddleState( @@ -239,13 +259,6 @@ test("reconstructHuddleState keeps a skew-retained START inconclusive when histo test("selectActiveHuddleState does not resurrect an older incomplete huddle", () => { const olderHuddleId = "older-huddle"; const newerHuddleId = "newer-huddle"; - const eventForHuddle = (kind, ephemeralChannelId, overrides = {}) => - lifecycleEvent(kind, { - content: JSON.stringify({ - ephemeral_channel_id: ephemeralChannelId, - }), - ...overrides, - }); const selected = selectActiveHuddleState( [ @@ -273,6 +286,139 @@ test("selectActiveHuddleState does not resurrect an older incomplete huddle", () assert.equal(selected, null); }); +test("selectActiveHuddleState orders relay lifecycle evidence across skewed START clocks", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS + 10, + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 20, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, newerHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, newerHuddleId, { + created_at: NOW_SECONDS - 3, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + +test("selectActiveHuddleState prefers live relay evidence over a future-skewed START-only session", () => { + const startOnlyHuddleId = "start-only-huddle"; + const relayActiveHuddleId = "relay-active-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS + 15 * 60, + }), + eventForHuddle(48100, relayActiveHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, relayActiveHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, relayActiveHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState preserves live relay evidence when its START aged out", () => { + const startOnlyHuddleId = "start-only-huddle"; + const relayActiveHuddleId = "relay-active-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS + 15 * 60, + }), + eventForHuddle(48101, relayActiveHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, relayActiveHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState prefers a fresh START-only session over terminal relay history", () => { + const endedHuddleId = "ended-huddle"; + const drainedHuddleId = "drained-huddle"; + const startOnlyHuddleId = "start-only-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS - 18, + }), + eventForHuddle(48100, drainedHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48102, drainedHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", CREATOR]], + }), + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS - 1, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, startOnlyHuddleId); + assert.equal(selected?.state.ended, false); +}); + +test("selectActiveHuddleState does not tier a departed JOIN participant as present", () => { + const relayHistoryHuddleId = "relay-history-huddle"; + const startOnlyHuddleId = "start-only-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, relayHistoryHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, relayHistoryHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, relayHistoryHuddleId, { + created_at: NOW_SECONDS - 18, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48100, startOnlyHuddleId, { + created_at: NOW_SECONDS - 1, + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, startOnlyHuddleId); + assert.equal(selected?.state.ended, false); +}); + test("reconstructHuddleState does not resurrect after an end event", () => { const state = reconstructHuddleState( [ diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.ts b/desktop/src/features/huddle/lib/huddleLifecycleState.ts index d90d0cd587..aa90678f0a 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.ts +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.ts @@ -172,18 +172,35 @@ export function selectActiveHuddleState( } const candidates = [...eventsByHuddle.entries()].map( - ([ephemeralChannelId, huddleEvents]) => ({ - ephemeralChannelId, - state: reconstructHuddleState(huddleEvents, ephemeralChannelId, { + ([ephemeralChannelId, huddleEvents]) => { + const relayLifecycleEvents = huddleEvents.filter( + (event) => + event.kind === KIND_HUDDLE_PARTICIPANT_JOINED || + event.kind === KIND_HUDDLE_PARTICIPANT_LEFT || + event.kind === KIND_HUDDLE_ENDED, + ); + const state = reconstructHuddleState(huddleEvents, ephemeralChannelId, { historyMayBeTruncated, isCurrentHuddle: options.activeEphemeralChannelId === ephemeralChannelId, nowMs: options.nowMs, - }), - lastEventCreatedAt: Math.max( - ...huddleEvents.map((event) => event.created_at), - ), - }), + }); + return { + ephemeralChannelId, + state, + hasPresentRelayParticipant: + !state.ended && + relayLifecycleEvents.some( + (event) => + event.kind === KIND_HUDDLE_PARTICIPANT_JOINED && + state.participants.has(lifecycleParticipant(event) ?? ""), + ), + latestRelayCreatedAt: + relayLifecycleEvents.length > 0 + ? Math.max(...relayLifecycleEvents.map((event) => event.created_at)) + : null, + }; + }, ); const current = candidates.find( @@ -197,24 +214,42 @@ export function selectActiveHuddleState( }; } - // A retained START is a session boundary. Choose the newest session before - // applying terminal state so an older incomplete history cannot reappear - // after a newer huddle ends. Only use relay activity as a fallback when the - // subscription window contains no START at all. - const startedCandidates = candidates.filter( - ({ state }) => state.startCreatedAt !== null, - ); - const candidatePool = - startedCandidates.length > 0 ? startedCandidates : candidates; - candidatePool.sort( - (left, right) => - (right.state.startCreatedAt ?? right.lastEventCreatedAt) - - (left.state.startCreatedAt ?? left.lastEventCreatedAt) || - right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), - ); + // Relay-signed JOIN/LEFT/END events share one clock across huddles, so only + // the newest relay-backed session may be shown. If it is terminal, do not + // resurrect an older relay-backed session. A currently present participant + // gives that newest relay-backed session priority over every START-only + // candidate without comparing relay and client clocks numerically. + const newestRelayCandidate = candidates + .filter(({ latestRelayCreatedAt }) => latestRelayCreatedAt !== null) + .sort( + (left, right) => + (right.latestRelayCreatedAt ?? 0) - (left.latestRelayCreatedAt ?? 0) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + )[0]; + if (newestRelayCandidate?.hasPresentRelayParticipant) { + return { + ephemeralChannelId: newestRelayCandidate.ephemeralChannelId, + state: newestRelayCandidate.state, + }; + } + + // A START-only session has no relay-clock evidence to compare with a + // terminal relay-backed session. Prefer the newest non-terminal START-only + // candidate instead of letting END/LEFT-only history displace a fresh room. + const selected = candidates + .filter( + ({ latestRelayCreatedAt, state }) => + latestRelayCreatedAt === null && + state.startCreatedAt !== null && + !state.ended, + ) + .sort( + (left, right) => + (right.state.startCreatedAt ?? 0) - (left.state.startCreatedAt ?? 0) || + right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), + )[0]; - const selected = candidatePool[0]; - if (!selected || selected.state.ended) return null; + if (!selected) return null; return { ephemeralChannelId: selected.ephemeralChannelId, state: selected.state, From c404939dcd32d93ce23b05cda039cdc1d1c26bfe Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Sun, 19 Jul 2026 13:07:01 -0700 Subject: [PATCH 5/6] fix(huddle): preserve authoritative lifecycle evidence Co-authored-by: Ian Oberst Co-authored-by: Codex Ai-assisted: true Signed-off-by: Ian Oberst --- .../huddle/components/HuddleAttachment.tsx | 19 +++-- .../huddle/lib/huddleLifecycleState.test.mjs | 77 +++++++++++++++++++ .../huddle/lib/huddleLifecycleState.ts | 44 ++++++++--- 3 files changed, 124 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/huddle/components/HuddleAttachment.tsx b/desktop/src/features/huddle/components/HuddleAttachment.tsx index b70accae53..40f2c213ba 100644 --- a/desktop/src/features/huddle/components/HuddleAttachment.tsx +++ b/desktop/src/features/huddle/components/HuddleAttachment.tsx @@ -20,9 +20,9 @@ import { import { useHuddle } from "../HuddleContext"; import { HUDDLE_EVENT_HISTORY_LIMIT, - huddleEventChannelId, type HuddleLifecycleState, huddleStalenessDelayMs, + recordHuddleSubscriptionEvent, reconstructHuddleState, } from "../lib/huddleLifecycleState"; @@ -99,6 +99,7 @@ export function HuddleAttachment({ let disposed = false; let cleanup: (() => void) | null = null; let staleTimeout: ReturnType | null = null; + const seenChannelEventIds = new Set(); const seenEvents = new Map([ [ message.id, @@ -121,7 +122,8 @@ export function HuddleAttachment({ seenEvents.values(), huddleChannelId, { - historyMayBeTruncated: seenEvents.size >= HUDDLE_EVENT_HISTORY_LIMIT, + historyMayBeTruncated: + seenChannelEventIds.size >= HUDDLE_EVENT_HISTORY_LIMIT, isCurrentHuddle, }, ); @@ -136,9 +138,16 @@ export function HuddleAttachment({ updateState(); relayClient .subscribeToHuddleEvents(channelId, (event) => { - if (disposed || seenEvents.has(event.id)) return; - if (huddleEventChannelId(event) !== huddleChannelId) return; - seenEvents.set(event.id, event); + if ( + disposed || + !recordHuddleSubscriptionEvent( + seenChannelEventIds, + seenEvents, + huddleChannelId, + event, + ) + ) + return; updateState(); }) .then((dispose) => { diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs index d6769357c1..a26db3d599 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { huddleStalenessDelayMs, + recordHuddleSubscriptionEvent, reconstructHuddleState, selectActiveHuddleState, } from "./huddleLifecycleState.ts"; @@ -256,6 +257,52 @@ test("reconstructHuddleState keeps a skew-retained START inconclusive when histo assert.equal(state.participants.size, 0); }); +test("recordHuddleSubscriptionEvent preserves channel-wide truncation before huddle filtering", () => { + const seenChannelEventIds = new Set(); + const seenHuddleEvents = new Map(); + const start = lifecycleEvent(48100); + seenHuddleEvents.set(start.id, start); + + for (let index = 0; index < 99; index += 1) { + const event = eventForHuddle(48101, `unrelated-huddle-${index}`, { + id: `unrelated-event-${index}`, + created_at: NOW_SECONDS - 100 + index, + tags: [["p", `participant-${index}`]], + }); + assert.equal( + recordHuddleSubscriptionEvent( + seenChannelEventIds, + seenHuddleEvents, + HUDDLE_ID, + event, + ), + true, + ); + } + + const retainedLeft = lifecycleEvent(48102, { + id: "retained-left", + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }); + recordHuddleSubscriptionEvent( + seenChannelEventIds, + seenHuddleEvents, + HUDDLE_ID, + retainedLeft, + ); + + const state = reconstructHuddleState(seenHuddleEvents.values(), HUDDLE_ID, { + historyMayBeTruncated: seenChannelEventIds.size >= 100, + nowMs: NOW_SECONDS * 1000, + }); + + assert.equal(seenChannelEventIds.size, 100); + assert.equal(seenHuddleEvents.size, 2); + assert.equal(state.ended, false); + assert.equal(state.participants.size, 0); +}); + test("selectActiveHuddleState does not resurrect an older incomplete huddle", () => { const olderHuddleId = "older-huddle"; const newerHuddleId = "newer-huddle"; @@ -315,6 +362,36 @@ test("selectActiveHuddleState orders relay lifecycle evidence across skewed STAR assert.equal(selected, null); }); +test("selectActiveHuddleState ignores a future-skewed END when ordering rooms", () => { + const endedHuddleId = "ended-huddle"; + const liveHuddleId = "live-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS - 30, + }), + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS - 20, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS + 15 * 60, + }), + eventForHuddle(48100, liveHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, liveHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, liveHuddleId); + assert.equal(selected?.state.ended, false); +}); + test("selectActiveHuddleState prefers live relay evidence over a future-skewed START-only session", () => { const startOnlyHuddleId = "start-only-huddle"; const relayActiveHuddleId = "relay-active-huddle"; diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.ts b/desktop/src/features/huddle/lib/huddleLifecycleState.ts index aa90678f0a..9565aaccdf 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.ts +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.ts @@ -46,6 +46,25 @@ export function huddleEventChannelId(event: RelayEvent): string | null { } } +/** + * Record one channel-wide subscription event while retaining only the target + * huddle's events for reconstruction. The channel-wide IDs preserve whether + * the relay history query reached its limit before per-huddle filtering. + */ +export function recordHuddleSubscriptionEvent( + seenChannelEventIds: Set, + seenHuddleEvents: Map, + ephemeralChannelId: string, + event: RelayEvent, +): boolean { + if (seenChannelEventIds.has(event.id)) return false; + seenChannelEventIds.add(event.id); + if (huddleEventChannelId(event) === ephemeralChannelId) { + seenHuddleEvents.set(event.id, event); + } + return true; +} + function lifecycleParticipant(event: RelayEvent): string | null { return ( event.tags.find( @@ -173,11 +192,10 @@ export function selectActiveHuddleState( const candidates = [...eventsByHuddle.entries()].map( ([ephemeralChannelId, huddleEvents]) => { - const relayLifecycleEvents = huddleEvents.filter( + const relayParticipantEvents = huddleEvents.filter( (event) => event.kind === KIND_HUDDLE_PARTICIPANT_JOINED || - event.kind === KIND_HUDDLE_PARTICIPANT_LEFT || - event.kind === KIND_HUDDLE_ENDED, + event.kind === KIND_HUDDLE_PARTICIPANT_LEFT, ); const state = reconstructHuddleState(huddleEvents, ephemeralChannelId, { historyMayBeTruncated, @@ -190,14 +208,16 @@ export function selectActiveHuddleState( state, hasPresentRelayParticipant: !state.ended && - relayLifecycleEvents.some( + relayParticipantEvents.some( (event) => event.kind === KIND_HUDDLE_PARTICIPANT_JOINED && state.participants.has(lifecycleParticipant(event) ?? ""), ), latestRelayCreatedAt: - relayLifecycleEvents.length > 0 - ? Math.max(...relayLifecycleEvents.map((event) => event.created_at)) + relayParticipantEvents.length > 0 + ? Math.max( + ...relayParticipantEvents.map((event) => event.created_at), + ) : null, }; }, @@ -214,11 +234,13 @@ export function selectActiveHuddleState( }; } - // Relay-signed JOIN/LEFT/END events share one clock across huddles, so only - // the newest relay-backed session may be shown. If it is terminal, do not - // resurrect an older relay-backed session. A currently present participant - // gives that newest relay-backed session priority over every START-only - // candidate without comparing relay and client clocks numerically. + // Relay-signed JOIN/LEFT events share one clock across huddles, so only the + // newest relay-backed session may be shown. END is also client-emitted and + // stays room-local terminal evidence; it must not order different rooms. If + // the newest relay-backed session is terminal, do not resurrect an older + // relay-backed session. A currently present participant gives that newest + // relay-backed session priority over every START-only candidate without + // comparing relay and client clocks numerically. const newestRelayCandidate = candidates .filter(({ latestRelayCreatedAt }) => latestRelayCreatedAt !== null) .sort( From 0002b212e0baed0bd9cea36d8ed06ef565b74f65 Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Sun, 19 Jul 2026 13:39:45 -0700 Subject: [PATCH 6/6] fix(huddle): keep terminal transitions room-local Co-authored-by: Ian Oberst Co-authored-by: Codex Ai-assisted: true Signed-off-by: Ian Oberst --- .../huddle/lib/huddleLifecycleState.test.mjs | 99 ++++++++++++++++++- .../huddle/lib/huddleLifecycleState.ts | 64 ++++++------ 2 files changed, 129 insertions(+), 34 deletions(-) diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs index a26db3d599..76b59ad5ee 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.test.mjs @@ -44,6 +44,32 @@ test("reconstructHuddleState ends an explicitly ended huddle", () => { assert.equal(state.startCreatedAt, NOW_SECONDS); }); +test("reconstructHuddleState folds the participant roster for an ended huddle", () => { + const state = reconstructHuddleState( + [ + lifecycleEvent(48100, { created_at: NOW_SECONDS - 4 }), + lifecycleEvent(48101, { + created_at: NOW_SECONDS - 3, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 2, + tags: [["p", PARTICIPANT]], + }), + lifecycleEvent(48102, { + created_at: NOW_SECONDS - 1, + tags: [["p", CREATOR]], + }), + lifecycleEvent(48103), + ], + HUDDLE_ID, + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(state.ended, true); + assert.equal(state.participants.size, 0); +}); + test("reconstructHuddleState ends a fully drained huddle", () => { const state = reconstructHuddleState( [ @@ -333,6 +359,43 @@ test("selectActiveHuddleState does not resurrect an older incomplete huddle", () assert.equal(selected, null); }); +test("selectActiveHuddleState keeps a newer ended-room barrier after an older room LEFT", () => { + const olderHuddleId = "older-huddle"; + const newerHuddleId = "newer-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, olderHuddleId, { + created_at: NOW_SECONDS - 20, + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 19, + tags: [["p", CREATOR]], + }), + eventForHuddle(48101, olderHuddleId, { + created_at: NOW_SECONDS - 18, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48100, newerHuddleId, { + created_at: NOW_SECONDS - 10, + }), + eventForHuddle(48101, newerHuddleId, { + created_at: NOW_SECONDS - 9, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, newerHuddleId, { + created_at: NOW_SECONDS - 8, + }), + eventForHuddle(48102, olderHuddleId, { + created_at: NOW_SECONDS - 1, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected, null); +}); + test("selectActiveHuddleState orders relay lifecycle evidence across skewed START clocks", () => { const olderHuddleId = "older-huddle"; const newerHuddleId = "newer-huddle"; @@ -392,6 +455,40 @@ test("selectActiveHuddleState ignores a future-skewed END when ordering rooms", assert.equal(selected?.state.ended, false); }); +test("selectActiveHuddleState ignores a delayed LEFT from an ended room", () => { + const endedHuddleId = "ended-huddle"; + const liveHuddleId = "live-huddle"; + const selected = selectActiveHuddleState( + [ + eventForHuddle(48100, endedHuddleId, { + created_at: NOW_SECONDS - 6, + }), + eventForHuddle(48101, endedHuddleId, { + created_at: NOW_SECONDS - 5, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48103, endedHuddleId, { + created_at: NOW_SECONDS - 4, + }), + eventForHuddle(48100, liveHuddleId, { + created_at: NOW_SECONDS - 3, + }), + eventForHuddle(48101, liveHuddleId, { + created_at: NOW_SECONDS - 2, + tags: [["p", PARTICIPANT]], + }), + eventForHuddle(48102, endedHuddleId, { + created_at: NOW_SECONDS - 1, + tags: [["p", PARTICIPANT]], + }), + ], + { nowMs: NOW_SECONDS * 1000 }, + ); + + assert.equal(selected?.ephemeralChannelId, liveHuddleId); + assert.equal(selected?.state.ended, false); +}); + test("selectActiveHuddleState prefers live relay evidence over a future-skewed START-only session", () => { const startOnlyHuddleId = "start-only-huddle"; const relayActiveHuddleId = "relay-active-huddle"; @@ -511,7 +608,7 @@ test("reconstructHuddleState does not resurrect after an end event", () => { ); assert.equal(state.ended, true); - assert.deepEqual([...state.participants], [CREATOR]); + assert.deepEqual([...state.participants], [CREATOR, PARTICIPANT]); }); test("huddleStalenessDelayMs schedules just past the stale boundary", () => { diff --git a/desktop/src/features/huddle/lib/huddleLifecycleState.ts b/desktop/src/features/huddle/lib/huddleLifecycleState.ts index 9565aaccdf..7b5919575c 100644 --- a/desktop/src/features/huddle/lib/huddleLifecycleState.ts +++ b/desktop/src/features/huddle/lib/huddleLifecycleState.ts @@ -120,19 +120,17 @@ export function reconstructHuddleState( // START is client-signed while participant transitions are relay-signed, so // their created_at values are not one causal clock. Seed the creator from // START, then fold only relay participant transitions in their own order. - if (!explicitlyEnded) { - for (const event of participantEvents) { - switch (event.kind) { - case KIND_HUDDLE_PARTICIPANT_JOINED: { - const pubkey = lifecycleParticipant(event); - if (pubkey) participants.add(pubkey); - break; - } - case KIND_HUDDLE_PARTICIPANT_LEFT: { - const pubkey = lifecycleParticipant(event); - if (pubkey) participants.delete(pubkey); - break; - } + for (const event of participantEvents) { + switch (event.kind) { + case KIND_HUDDLE_PARTICIPANT_JOINED: { + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.add(pubkey); + break; + } + case KIND_HUDDLE_PARTICIPANT_LEFT: { + const pubkey = lifecycleParticipant(event); + if (pubkey) participants.delete(pubkey); + break; } } } @@ -197,6 +195,9 @@ export function selectActiveHuddleState( event.kind === KIND_HUDDLE_PARTICIPANT_JOINED || event.kind === KIND_HUDDLE_PARTICIPANT_LEFT, ); + const relayJoinEvents = relayParticipantEvents.filter( + (event) => event.kind === KIND_HUDDLE_PARTICIPANT_JOINED, + ); const state = reconstructHuddleState(huddleEvents, ephemeralChannelId, { historyMayBeTruncated, isCurrentHuddle: @@ -208,16 +209,12 @@ export function selectActiveHuddleState( state, hasPresentRelayParticipant: !state.ended && - relayParticipantEvents.some( - (event) => - event.kind === KIND_HUDDLE_PARTICIPANT_JOINED && - state.participants.has(lifecycleParticipant(event) ?? ""), + relayJoinEvents.some((event) => + state.participants.has(lifecycleParticipant(event) ?? ""), ), - latestRelayCreatedAt: - relayParticipantEvents.length > 0 - ? Math.max( - ...relayParticipantEvents.map((event) => event.created_at), - ) + latestRelayJoinCreatedAt: + relayJoinEvents.length > 0 + ? Math.max(...relayJoinEvents.map((event) => event.created_at)) : null, }; }, @@ -234,18 +231,19 @@ export function selectActiveHuddleState( }; } - // Relay-signed JOIN/LEFT events share one clock across huddles, so only the - // newest relay-backed session may be shown. END is also client-emitted and - // stays room-local terminal evidence; it must not order different rooms. If - // the newest relay-backed session is terminal, do not resurrect an older - // relay-backed session. A currently present participant gives that newest - // relay-backed session priority over every START-only candidate without - // comparing relay and client clocks numerically. + // Relay-signed JOIN events share one clock across huddles, so only the newest + // relay-backed session may be shown. LEFT is a departure transition within a + // session, while END is client-emitted room-local evidence; neither may make + // an older room outrank a newer session. If the newest relay-backed session + // is terminal, do not resurrect an older relay-backed session. A currently + // present participant gives that newest relay-backed session priority over + // every START-only candidate without comparing relay and client clocks. const newestRelayCandidate = candidates - .filter(({ latestRelayCreatedAt }) => latestRelayCreatedAt !== null) + .filter(({ latestRelayJoinCreatedAt }) => latestRelayJoinCreatedAt !== null) .sort( (left, right) => - (right.latestRelayCreatedAt ?? 0) - (left.latestRelayCreatedAt ?? 0) || + (right.latestRelayJoinCreatedAt ?? 0) - + (left.latestRelayJoinCreatedAt ?? 0) || right.ephemeralChannelId.localeCompare(left.ephemeralChannelId), )[0]; if (newestRelayCandidate?.hasPresentRelayParticipant) { @@ -260,8 +258,8 @@ export function selectActiveHuddleState( // candidate instead of letting END/LEFT-only history displace a fresh room. const selected = candidates .filter( - ({ latestRelayCreatedAt, state }) => - latestRelayCreatedAt === null && + ({ latestRelayJoinCreatedAt, state }) => + latestRelayJoinCreatedAt === null && state.startCreatedAt !== null && !state.ended, )