mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(orbit): cleanup regex, state-blob budget, radio top-up lockout (#1155)
* fix(orbit): stop cleanup sweep from deleting live sessions on other devices The orphan-cleanup regex anchored the trailing `__` inside the optional `_from_…` group, so the canonical session playlist name (`__psyorbit_<sid>__`) never matched. It then fell into the "unrecognised → prune" branch, which deletes unconditionally and bypasses the orphan-TTL grace that exists precisely to protect a session running on the user's other device. Opening Psysonic on a second device wiped the first device's live session out from under its guests. Move the trailing `__` outside the optional group so both the session and outbox names match; the existing sid/TTL/ended logic then applies as intended. Add a behavioural test covering keep-fresh / prune-stale / prune-ended / skip-current / outbox / corrupt / foreign-owner cases. * fix(orbit): keep host publishing when the state blob grows past budget OrbitState.queue (the suggestion/attribution history) was append-only at the sweep-fold step and never bounded. On a long session it grew until the serialised blob exceeded ORBIT_STATE_MAX_BYTES; serialiseOrbitState then threw, writeOrbitState's caller swallowed the error and retried the same over-budget state every tick, so the host silently stopped publishing and guests froze into a host-timeout. Two layers: - Cap queue at ORBIT_QUEUE_HISTORY_LIMIT in applyOutboxSnapshotsToState, evicting oldest-by-addedAt (robust to the periodic queue shuffle). The dropped tracks have long since played, so their attribution/dedupe entry is dead weight. - Add serialiseOrbitStateForWire: a budget-aware serialiser that sheds oldest history, then the play-queue tail, on a copy before giving up. writeOrbitState now uses it, so a transient over-budget tick degrades the published blob instead of taking the host offline. Local store state is untouched. Tested: history cap eviction order, wire-trim byte budget + oldest-first drop, play-queue fallback, and within-budget passthrough. * fix(orbit): lock out the proactive radio top-up during a session The ≤2-remaining radio top-up in runNext lacked the isInOrbitSession() guard that its infinite-queue sibling has at both entry and resolution. A guest who joined with residual radioAdded tracks (before the first syncToHost replaces the queue) could fire it, appending up to 10 unrelated radio tracks and trimming queue history — drifting the guest off the host's playlist. Add the guard at entry and re-check inside the resolution .then(), mirroring the infinite-queue branch; the existing finally() still clears the fetching flag. The queue-exhausted radio fallback already returns early in Orbit, so only this mid-queue path was unguarded. * docs(changelog): add Orbit cleanup / state-budget / radio fixes (#1155) * docs(changelog): move Orbit fixes to bottom of Fixed section (#1155)
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../store/orbitStore', () => ({
|
||||
useOrbitStore: { getState: () => ({ state: null, setState: vi.fn() }) },
|
||||
}));
|
||||
|
||||
import { makeInitialOrbitState, type OrbitQueueItem, type OrbitState } from '../../api/orbit';
|
||||
import { ORBIT_QUEUE_HISTORY_LIMIT } from './constants';
|
||||
import { applyOutboxSnapshotsToState, type OutboxSnapshot } from './stateMath';
|
||||
|
||||
function stateWithQueue(queue: OrbitQueueItem[]): OrbitState {
|
||||
return { ...makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' }), queue };
|
||||
}
|
||||
|
||||
describe('applyOutboxSnapshotsToState — queue history cap', () => {
|
||||
it('drops the oldest entries when new suggestions push past the limit', () => {
|
||||
// A full history (addedAt 0…limit-1) plus 10 brand-new suggestions.
|
||||
const existing: OrbitQueueItem[] = Array.from({ length: ORBIT_QUEUE_HISTORY_LIMIT }, (_, i) => ({
|
||||
trackId: `old-${i}`,
|
||||
addedBy: 'old',
|
||||
addedAt: i,
|
||||
}));
|
||||
const state = stateWithQueue(existing);
|
||||
const now = 1_000_000;
|
||||
const snapshots: OutboxSnapshot[] = [
|
||||
{
|
||||
user: 'bob',
|
||||
outboxPlaylistId: 'ob',
|
||||
trackIds: Array.from({ length: 10 }, (_, i) => `new-${i}`),
|
||||
lastHeartbeat: now,
|
||||
},
|
||||
];
|
||||
|
||||
const next = applyOutboxSnapshotsToState(state, snapshots, now);
|
||||
|
||||
expect(next.queue.length).toBe(ORBIT_QUEUE_HISTORY_LIMIT);
|
||||
// All 10 new suggestions survive…
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(next.queue.some(q => q.trackId === `new-${i}`)).toBe(true);
|
||||
}
|
||||
// …and the 10 oldest were evicted.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(next.queue.some(q => q.trackId === `old-${i}`)).toBe(false);
|
||||
}
|
||||
// The youngest retained "old" entries are still present.
|
||||
expect(next.queue.some(q => q.trackId === `old-${ORBIT_QUEUE_HISTORY_LIMIT - 1}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a sub-limit queue untouched', () => {
|
||||
const state = stateWithQueue([{ trackId: 't0', addedBy: 'old', addedAt: 0 }]);
|
||||
const now = 1_000_000;
|
||||
const next = applyOutboxSnapshotsToState(
|
||||
state,
|
||||
[{ user: 'bob', outboxPlaylistId: 'ob', trackIds: ['t1'], lastHeartbeat: now }],
|
||||
now,
|
||||
);
|
||||
expect(next.queue.map(q => q.trackId)).toEqual(['t0', 't1']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user