mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(orbit): host-side session guards — settings default, maxUsers, push serialisation (#1157)
* fix(orbit): unify session-settings default to the canonical preset
updateOrbitSettings merged patches onto a hand-rolled
{ autoApprove: true, autoShuffle: true } fallback when a legacy session's
blob predated the settings field. That literal disagreed with
ORBIT_DEFAULT_SETTINGS (autoApprove: false), so toggling autoShuffle on
such a session silently flipped autoApprove on, landing guest suggestions
without host approval. Use ORBIT_DEFAULT_SETTINGS as the single source of
truth for the fallback. Add a behavioural test for the settings-less,
preserve-existing, and not-hosting paths.
* fix(orbit): enforce maxUsers host-side in the participant rebuild
The join gate only sees a one-tick-old state blob, so two guests can both
pass the participants.length < maxUsers check and join past the cap at
once; maxUsers was advisory. The host is the single writer of the
canonical state, so enforce the cap there: admit at most maxUsers guests
in the participant rebuild, earliest joiners winning (an established
participant is never displaced by a newcomer) with a deterministic
username tie-break for same-tick joins. Suggestions from an over-cap guest
are ignored, consistent with their absence from the participant list.
Reorders the fold so the admitted set gates the queue-additions loop. Add
tests for the cap, the no-displacement rule, and the ignored over-cap
suggestion.
* fix(orbit): serialise host state pushes to stop overlapping ticks dropping writes
pushState does several awaited round-trips (sweep -> resolve -> write) and
is fired from three sources: the mount, the 2.5 s timer, and a play/pause
subscription. With no in-flight lock these can overlap and race
last-writer-wins: a slow run that already swept and cleared an outbox can
lose its write to a faster run, dropping those suggestions from both the
outbox and the published state. The lastPushedAtRef was only ever written,
never used as a guard.
Add makeCoalescedRunner: a pure helper that serialises an async task and
coalesces any mid-flight request into a single rerun, so no trigger is
lost. The host hook wraps pushState in it; the dead ref is removed. Add a
unit test for the serialise + coalesce semantics.
* docs(changelog): add Orbit host-side session guards (1157)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { getSong } from '../api/subsonicLibrary';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
applyOutboxSnapshotsToState,
|
||||
maybeShuffleQueue,
|
||||
effectiveShuffleIntervalMs,
|
||||
makeCoalescedRunner,
|
||||
suggestionKey,
|
||||
} from '../utils/orbit';
|
||||
import {
|
||||
@@ -49,10 +50,6 @@ export function useOrbitHost(): void {
|
||||
const sessionId = useOrbitStore(s => s.sessionId);
|
||||
const hostName = useOrbitStore(s => s.state?.host);
|
||||
|
||||
// Refs hold the last values we used to build the patch — cheap to
|
||||
// recompute against, no need to subscribe to every playerStore tick.
|
||||
const lastPushedAtRef = useRef(0);
|
||||
|
||||
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,7 +135,6 @@ export function useOrbitHost(): void {
|
||||
useOrbitStore.getState().setState(next);
|
||||
try {
|
||||
await writeOrbitState(sessionPlaylistId, next);
|
||||
lastPushedAtRef.current = Date.now();
|
||||
pushOrbitEvent('host:push', JSON.stringify({
|
||||
track: next.currentTrack?.trackId ?? null,
|
||||
playing: next.isPlaying,
|
||||
@@ -221,11 +217,17 @@ export function useOrbitHost(): void {
|
||||
}
|
||||
};
|
||||
|
||||
// Serialise pushState across its three triggers (mount, timer, play/pause
|
||||
// flip): two bodies must never run concurrently, or a slow run that already
|
||||
// swept+cleared an outbox can lose its write to a faster one. A request
|
||||
// arriving mid-run coalesces into a single rerun so no trigger is lost.
|
||||
const runPush = makeCoalescedRunner(pushState);
|
||||
|
||||
// Immediate push on mount so guests see fresh state without waiting
|
||||
// a full tick after the host comes online.
|
||||
void pushState();
|
||||
void runPush();
|
||||
|
||||
const id = window.setInterval(() => { void pushState(); }, STATE_TICK_MS);
|
||||
const id = window.setInterval(() => { void runPush(); }, STATE_TICK_MS);
|
||||
|
||||
// Event-driven push on play/pause flips. Without this the worst-case
|
||||
// delay between "host hits pause" and "guest stops" is two full polling
|
||||
@@ -239,7 +241,7 @@ export function useOrbitHost(): void {
|
||||
if (state.isPlaying === prevIsPlaying) return;
|
||||
prevIsPlaying = state.isPlaying;
|
||||
pushOrbitEvent('host:event-push', `isPlaying flip → ${state.isPlaying}`);
|
||||
void pushState();
|
||||
void runPush();
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user