Files
buzz/desktop/src/shared/api/relayEventPublisher.ts
Will Pfleger 52c4c08c9e fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805)
Adds bounded-backoff recovery reads to all four sidebar sync hooks
(sections, sort, stars and mutes). A stale relay read at app open now
converges without a user edit or a reconnect.

## What this does

**Shared recovery hook (`useStaleReaderRecovery`).** All four hooks use
one retry effect. It reads immediately on mount, then at 5, 10, 30 and
60 s, staying at 60 s after that. A change of `visibilityState` to
`visible` triggers an immediate read. Reads are single-flight. The
effect restarts whenever `pubkey` or `relayUrl` changes, so a hook that
stays mounted while switching relays drops the old relay's in-flight
reads.

**Guards that stop a recovery read from rolling back a local edit:**

1. **Skip the read while an edit is pending.** If an edit is pending
when a tick starts, the tick skips its read and just schedules the next
one. Without this, the read could return the pre-edit blob after the
edit published and pending cleared. At that point neither apply-time
guard could reject it.
2. **Pending check inside the state updater.** `hasPending()` and the
effect lifetime are re-read inside `setStore(prev => ...)`. That way the
check runs when React executes the updater, not when the response
arrives.
3. **Local-mutation revision.** Each hook counts its local edits. The
count is snapshotted when a read starts and checked again at apply time.
If an edit landed while the read was in flight, the response is
discarded.
4. **Head advanced only after a successful cache write.** A failed
`write*Store()` leaves the same head retryable on the next tick.

**Ownership-aware pending clear.** Each sync manager's
`doPublish(store)` now clears `pendingStore` only while it still holds
that same `store`, on both the identical-payload early return and the
success path. Before, an older publish that finished after a newer edit
had replaced `pendingStore` wiped the newer edit's pending marker, so a
recovery read before the newer debounce could apply the older relay head
over it. `destroy()` still clears unconditionally.

## Scope

Reader-side recovery plus the ownership-aware pending clear. No
outboxes, arbitration or replay changes. It applies to
`useChannelSections`, `useChannelSortPreference`, `useChannelStars` and
`useChannelMutes`.

Related: [#7806](https://github.com/block/buzz/pull/7806)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-28 12:48:16 -06:00

105 lines
3.9 KiB
TypeScript

import type { RelayEvent } from "@/shared/api/types";
import type { PendingEvent } from "@/shared/api/relayClientShared";
import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
import { PUBLISH_TIMEOUT_MS } from "@/shared/api/relayClientTimings";
type PublishSession = {
generation: () => number;
ownership: () => number;
pendingEvents: Map<string, PendingEvent>;
send: (payload: unknown[], generation: number) => Promise<void>;
reconnect: () => Promise<number>;
normalizeError: (error: unknown, fallback: string) => Error;
recoverSocketFailure: (error: unknown, fallback: string) => Error;
};
/** Typed so no relay rejection text can be mistaken for a caller's cancel. */
export class PublishCanceledError extends Error {
constructor() {
super("Relay publish canceled by its caller.");
this.name = "PublishCanceledError";
}
}
/**
* Publish once, with one reconnect retry, without crossing session ownership.
* `isCurrent`, when given, is checked immediately before every socket send; a
* false result rejects with `PublishCanceledError` and sends nothing.
*/
export async function publishSessionEvent(
session: PublishSession,
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
isCurrent?: () => boolean,
): Promise<RelayEvent> {
const publishOwnership = session.ownership();
await waitForRateLimit();
if (publishOwnership !== session.ownership()) {
throw new Error("Relay disconnected for community switch.");
}
if (isCurrent?.() === false) throw new PublishCanceledError();
const publishGeneration = session.generation();
return new Promise<RelayEvent>((resolve, reject) => {
const timeout = window.setTimeout(() => {
session.pendingEvents.delete(event.id);
reject(new Error(timeoutMessage));
}, PUBLISH_TIMEOUT_MS);
const pendingEvent = { event, resolve, reject, timeout };
session.pendingEvents.set(event.id, pendingEvent);
void session
.send(["EVENT", event], publishGeneration)
.catch(async (error) => {
// A disconnect may already have rejected this operation while the send
// was in flight. Its late failure must not reset the replacement session.
if (
publishOwnership !== session.ownership() ||
publishGeneration !== session.generation() ||
session.pendingEvents.get(event.id) !== pendingEvent
) {
return;
}
// Expected socket recovery must not reject the operation being retried.
session.pendingEvents.delete(event.id);
const sendError = session.recoverSocketFailure(error, sendErrorMessage);
session.pendingEvents.set(event.id, pendingEvent);
let retryGeneration: number | null = null;
try {
retryGeneration = await session.reconnect();
if (
publishOwnership !== session.ownership() ||
session.generation() !== retryGeneration ||
session.pendingEvents.get(event.id) !== pendingEvent
) {
throw new Error(
"Relay publish was superseded by a session change.",
);
}
if (isCurrent?.() === false) {
window.clearTimeout(timeout);
session.pendingEvents.delete(event.id);
reject(new PublishCanceledError());
return;
}
await session.send(["EVENT", event], retryGeneration);
} catch (retryError) {
if (session.pendingEvents.get(event.id) !== pendingEvent) return;
window.clearTimeout(timeout);
session.pendingEvents.delete(event.id);
reject(
publishOwnership === session.ownership() &&
retryGeneration !== null &&
session.generation() === retryGeneration
? session.recoverSocketFailure(retryError, sendError.message)
: session.normalizeError(retryError, sendError.message),
);
}
});
});
}