fix(desktop): harden profile batch and thread-reply fetches against relay slowness - #7188
fix(desktop): harden profile batch and thread-reply fetches against relay slowness#7188wpfleger96 wants to merge 1 commit into
Conversation
28a8eaa to
46f545b
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes on three P2 correctness issues at 46f545bb1a35d098f7ba79c0369c4958b2743879 (PR merge-base bd73490418266f267d9bb3bdf13e64582adc8e80; current base tip e17a0d44c379b2ae40b91b8ba43f9a9c5bede90f).
The required fixes are bounded: preserve successfully fetched replies when expected-target validation exhausts, validate a target arriving during an existing cold fetch, and close the relay subscription when a retried history request times out. Add regressions through the real hook/transport lifecycle; the copied retry callback and pre-seeded exhausted-set tests do not cover these transitions.
Validation: mounted the production thread hook with real React/TanStack and a stubbed Tauri response; reproduced both thread failures. Exercised the production history request/CLOSED/EOSE handlers with a fake clock and verified the missing CLOSE. Independently traced UI consumers and relay subscription ownership. No new profile-batch blocker found. Broad CI was still running at the snapshot checked; no full-suite rerun or live relay/UI claim. Focus refetch remains disabled despite the PR description, so please align that description with the actual retry/remount behavior.
46f545b to
b4f65f1
Compare
🔐 Codex Security Review
|
b4f65f1 to
e5adb15
Compare
e5adb15 to
01a6a19
Compare
01a6a19 to
2539566
Compare
2539566 to
71f999b
Compare
…elay slowness Under a slow or temporarily rate-limited relay the desktop turns transient relay failures into permanent degradation: a cold channel's profile batch exhausts its one retry and leaves raw npubs and broken mention chips forever; a thread opened from a notification trusts a successful-empty reply read as authoritative; and a rate-limited CLOSED on a history subscription rejects immediately rather than retrying after the window clears. Fix 1 — cold profile batch resilience (useUsersBatchQuery): Override retry to 3 with exponential backoff, scoped to this query only. The global defaults (retry: 1, refetchOnWindowFocus: false) are unchanged so other queries keep their deliberate semantics. refetchOnWindowFocus is NOT overridden — errored queries auto-refetch on component remount (channel-switch), which covers the recovery path without firing on every focus event (which breaks the E2E hover-surface test at message-feedback-snapshots.spec.ts:97). Fix 2 — stale-empty thread reads (useThreadReplies): When a completed paged fetch does not contain the expected event (e.g. a reply opened from a notification), throw ThreadExpectedEventMissingError to engage React Query's retry-with-backoff rather than caching an authoritative empty. Add a useEffect to invalidate the query when expectedEventId changes on an already-mounted thread (same query key — closure does not re-run otherwise). Track per-target retry exhaustion: after 3 ThreadExpectedEventMissingError throws for the same target, add it to exhaustedTargets and return the fetched replies instead of throwing — preventing deleted/moderated targets from locking the thread in a terminal error state that discards valid replies. Export loadThreadReplies with an injectable fetcher parameter so behavioral tests can exercise the real validation logic without a Tauri bridge. Fix 3 — CLOSED recovery for history subscriptions (relayClosedRecovery): For rate-limited CLOSED on history subs the relay session previously rejected the caller's promise immediately. Add store-and-retry: record filter and timeoutMs on HistorySubscription, then on a rate-limited CLOSED re-register under a fresh subId and defer sendReq until the rate-limit window clears. Bounded to 3 attempts; exhausted retries fall through to immediate reject. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
71f999b to
0ee208a
Compare
Three client gaps turn transient relay failures into permanent UI degradation. Under a slow or rate-limited relay:
CLOSEDon a history subscription immediately rejects the caller rather than retrying after the rate-limit window.All three are addressed without changing global query defaults or the happy-path behavior.
Changes
Fix 1 — cold profile batch resilience (
useUsersBatchQuery,desktop/src/features/profile/hooks.ts)Override
retry: 3with exponential backoff and error-gatedrefetchOnWindowFocus: (query) => query.state.status === "error", scoped to this query only. The global defaults (retry: 1,refetchOnWindowFocus: false) are intentional for other queries and are unchanged. After the retry budget exhausts, a window-focus event (e.g. channel-switch) recovers the query automatically — but only when it is already in an error state, preventing unnecessary refetches for successful batches.Fix 2 — stale-empty thread reads (
useThreadReplies.ts,ChannelScreen.tsx)Add optional
expectedEventIdparameter. When a completed paged fetch does not contain the expected event, throwThreadExpectedEventMissingErrorso React Query's built-in retry machinery handles it rather than caching an authoritative empty.ChannelScreenpassesthreadScrollTargetId(the notification-linked reply ID) asexpectedEventId.When notification routing changes
expectedEventIdwhile the same thread root is already mounted (same query key), an explicitinvalidateQueriesin auseEffecttriggers a fresh validation pass. For the cold-start race (target arrives before the first page returns), the effect detectsfetchStatus === "fetching" && status === "pending"and callscancelQueries().then(invalidateQueries)so the obsolete in-flight response cannot settle as authoritative before the new target's validation closure is active.The query-fn tracks consecutive fetch attempts per target. On attempt 3, it adds the target to
exhaustedTargetsRefbefore callingloadThreadReplies.loadThreadRepliessees the target in the exhausted set and returns the fetched replies directly rather than throwing — the terminal attempt always resolves to success. No re-entrant scheduling: the resolution is synchronous inside the query function itself. Deleted/moderated targets never lock the thread in a terminal error surface.Fix 3 — CLOSED recovery for history subscriptions (
relayClosedRecovery.ts,relayClientSession.ts,relayClientShared.ts,relayGateBoundary.ts)On a rate-limited
CLOSEDthe subscription previously rejected the caller immediately. StorefilterandtimeoutMsonHistorySubscription, then on rate-limitedCLOSEDre-register under a freshsubIdand defersendRequntil the rate-limit window clears — matching the live-sub recovery design already present inrelayClosedRecovery.ts. Bounded to 3 attempts; exhausted retries reject immediately so callers are never left waiting indefinitely. A new op-timeout guards the retry REQ against a non-responding relay; when the op-timeout fires it sendsCLOSEfor the rotatedsubId(matching the behavior of the original timeout path) so the relay releases the slot rather than counting it against the per-connection cap.Tests
relayClosedRecovery.test.mjs: behavioral fake-clock tests for history-sub retry, 3-attempt exhaustion, op-timeout CLOSE send + late-EOSE non-regression, rejecting-closeSubscriptionswallowed without unhandled rejection, wiring source assertion (fails ifrelayClientSession.tsdrops thecloseSubscriptioncallback) — 18 testsuseThreadReplies.test.mjs:loadThreadRepliesunit tests (throw/exhaustion-guard); behavioral hook tests via realQueryClientProvider+renderHook: exhaustion-resolves-to-data, mounted-thread target-change triggers invalidation, cold-fetch cancel-then-invalidate (gated fetcher — released after rerender, stale empty discarded, replacement fetch settles with target); ChannelScreen wiring source assertion — 9 testsprofileBatchResilience.test.mjs: source assertions forretry: 3,retryDelay, error-gatedrefetchOnWindowFocus, and unchanged global defaults — 2 tests