mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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>
105 lines
3.9 KiB
TypeScript
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),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|