fix(orbit): outbox lost-update recovery and session hardening (#1159)

* fix(orbit): harden the guest outbox — recover lost suggestions, avoid a duplicate outbox

Two outbox robustness issues on the poll-based, non-atomic playlist
transport:

- Lost-update: a track a guest appends to its outbox can be wiped by a
  concurrent host sweep-clear before the host recorded it, leaving the
  suggestion lost AND stuck on "waiting on host" forever (it only clears
  once it reaches the host's play queue). The playlist read-modify-write
  can't be made atomic, so recover: re-send a pending suggestion the host
  hasn't recorded (absent from state.queue) past a grace window — the host
  dedupes by (user, trackId), so it's idempotent — and give up + toast past
  a 45s window so the row stops hanging. New pendingResend planner module
  with unit tests; suggest + tick share ensureTrackInOutbox.

- Duplicate outbox: a transient getPlaylists failure at join was swallowed
  to [], so the existing-outbox check missed and a second outbox was
  created. Distinguish "lookup failed" from "genuinely absent" and retry
  once before falling back to create.

New i18n key for the give-up toast across all locales.

* fix(orbit): reject non-http(s) schemes in share invites

normalizeShareServerUrl only prepends http:// when the server string doesn't
already start with "http", so an invite carrying a scheme like httpx:// or
https-phish:// slips through unchanged. Reject anything whose parsed protocol
isn't http(s) at parse time, before the known-server match. Add round-trip
tests covering the accepted and rejected schemes.

* fix(orbit): read exit-modal role fresh to avoid a stale keydown closure

The exit modal's Enter/Escape handler binds once per open (deps [isOpen]) and
closed over the role prop, so a role change while the modal was up would act
on a stale value. Read role from the store inside the action instead, and drop
the now-unused reactive subscription.

* docs(changelog): consolidate Orbit fixes into one block, add 1159
This commit is contained in:
Psychotoxical
2026-06-22 17:32:53 +02:00
committed by GitHub
parent 9b03949f34
commit 78a177b1bc
20 changed files with 292 additions and 44 deletions
+27
View File
@@ -9,7 +9,13 @@ import {
applyOrbitTransitionSettings,
saveGuestTransitionsOnce,
restoreGuestTransitions,
ensureTrackInOutbox,
planPendingResends,
forgetPendingSuggestion,
resetPendingResendState,
} from '../utils/orbit';
import { showToast } from '../utils/ui/toast';
import i18n from '../i18n';
import { estimateLivePosition, type OrbitState } from '../api/orbit';
import { pushOrbitEvent } from '../utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat';
@@ -220,6 +226,26 @@ export function useOrbitGuest(): void {
for (const q of (state.playQueue ?? [])) landed.add(q.trackId);
if (state.currentTrack) landed.add(state.currentTrack.trackId);
useOrbitStore.getState().reconcilePendingSuggestions(landed);
landed.forEach(forgetPendingSuggestion);
// Mitigate the outbox lost-update race: a suggestion the host hasn't
// recorded (absent from state.queue, where every *received* submission
// lands) past a grace window was likely wiped by a racing sweep-clear
// — re-send it (the host dedupes, so this is idempotent). Give up +
// toast on ones that never land so the row doesn't hang forever.
const stillPending = useOrbitStore.getState().pendingSuggestions;
if (stillPending.length > 0 && outboxPlaylistId) {
const recorded = new Set(state.queue.map(q => q.trackId));
const plan = planPendingResends(stillPending, recorded);
for (const trackId of plan.resend) {
void ensureTrackInOutbox(outboxPlaylistId, trackId).catch(() => {});
}
if (plan.giveUp.length > 0) {
useOrbitStore.getState().reconcilePendingSuggestions(new Set(plan.giveUp));
plan.giveUp.forEach(forgetPendingSuggestion);
showToast(i18n.t('orbit.toastSuggestLost'), 3500, 'error');
}
}
}
// Host signalled session end: surface via `phase`, let the UI handle
@@ -374,6 +400,7 @@ export function useOrbitGuest(): void {
if (timer !== null) window.clearTimeout(timer);
// Leaving / session ended → give the user their own transition prefs back.
restoreGuestTransitions();
resetPendingResendState();
};
}, [active, sessionPlaylistId]);