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:
Psychotoxical
2026-06-22 13:33:09 +02:00
committed by GitHub
parent bd43ea5240
commit 2c9b2eeb46
11 changed files with 475 additions and 7 deletions
+10 -3
View File
@@ -111,9 +111,12 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
}).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); });
}
}
// Proactively top up radio tracks when ≤ 2 remain — always, regardless
// of infinite queue setting.
if (nextRef.radioAdded && !isRadioFetching()) {
// Proactively top up radio tracks when ≤ 2 remain — independent of the
// infinite-queue setting, but still skipped in Orbit: the radio top-up
// appends unrelated tracks and trims queue history, which would drift a
// guest off the host's playlist (same rationale as the infinite-queue
// branch above).
if (nextRef.radioAdded && !isRadioFetching() && !isInOrbitSession()) {
const remainingRadio = queueItems.slice(nextIdx + 1).filter(r => r.radioAdded).length;
if (remainingRadio <= 2) {
// H2: nextTrack may be a placeholder if its ref is still cold — empty
@@ -133,6 +136,10 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
setRadioFetching(true);
Promise.all([getSimilarSongs2(seedArtistId), getTopSongs(seedArtistName)])
.then(([similar, top]) => {
// Re-check — the user may have joined an Orbit session between
// scheduling this fetch and its resolution (mirrors the
// infinite-queue branch). The finally() still clears the flag.
if (isInOrbitSession()) return;
const existingIds = new Set(get().queueItems.map(r => r.trackId));
// Lead with similar (other artists) for variety; top tracks
// of the upcoming artist are only a fallback when similar
+83
View File
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Control points for the test.
const { inOrbit, getSimilarSongs2, getTopSongs } = vi.hoisted(() => ({
inOrbit: { value: false },
getSimilarSongs2: vi.fn(() => Promise.resolve([])),
getTopSongs: vi.fn(() => Promise.resolve([])),
}));
vi.mock('../api/subsonicArtists', () => ({ getSimilarSongs2, getTopSongs }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(() => Promise.resolve()) }));
vi.mock('./orbitSession', () => ({ isInOrbitSession: () => inOrbit.value }));
vi.mock('./authStore', () => ({
useAuthStore: { getState: () => ({ infiniteQueueEnabled: false }) },
}));
vi.mock('./radioSessionState', () => ({
addRadioSessionSeen: vi.fn(),
getCurrentRadioArtistId: () => null,
hasRadioSessionSeen: () => false,
isRadioFetching: () => false,
setRadioFetching: vi.fn(),
}));
vi.mock('./infiniteQueueState', () => ({
isInfiniteQueueFetching: () => false,
setInfiniteQueueFetching: vi.fn(),
}));
vi.mock('./engineState', () => ({ setIsAudioPaused: vi.fn() }));
vi.mock('./skipStarRating', () => ({ applySkipStarOnManualNext: vi.fn() }));
vi.mock('../utils/library/queueTrackView', () => ({
resolveQueueTrack: (ref: { trackId: string }) => ({
id: ref.trackId,
artistId: 'a1',
artist: 'Artist',
}),
}));
vi.mock('../utils/playback/buildInfiniteQueueCandidates', () => ({
buildInfiniteQueueCandidates: vi.fn(() => Promise.resolve([])),
}));
vi.mock('../utils/playback/songToTrack', () => ({ songToTrack: (s: unknown) => s }));
vi.mock('../utils/playback/playbackServer', () => ({ ensureQueueServerPinned: () => null }));
vi.mock('../utils/library/queueTrackResolver', () => ({ seedQueueResolver: vi.fn() }));
vi.mock('../utils/library/queueItemRef', () => ({ toQueueItemRefs: () => [] }));
import { runNext } from './nextAction';
function fakeGet() {
// index 0 → next is the radioAdded ref at index 1; nothing radio ahead of it,
// so the ≤2-remaining proactive top-up is eligible.
const queueItems = [
{ trackId: 't0', radioAdded: true },
{ trackId: 't1', radioAdded: true },
{ trackId: 't2', radioAdded: false },
];
return {
queueItems,
queueIndex: 0,
repeatMode: 'off' as const,
currentTrack: { id: 't0', artistId: 'a1', artist: 'Artist', radioAdded: true },
playTrack: vi.fn(),
};
}
beforeEach(() => {
inOrbit.value = false;
getSimilarSongs2.mockClear();
getTopSongs.mockClear();
});
describe('runNext — radio proactive top-up Orbit lockout', () => {
it('fires the radio top-up when not in an Orbit session', () => {
const get = fakeGet as unknown as () => never;
runNext(vi.fn(), get, /* manual */ false);
expect(getSimilarSongs2).toHaveBeenCalledTimes(1);
});
it('skips the radio top-up while in an Orbit session', () => {
inOrbit.value = true;
const get = fakeGet as unknown as () => never;
runNext(vi.fn(), get, /* manual */ false);
expect(getSimilarSongs2).not.toHaveBeenCalled();
expect(getTopSongs).not.toHaveBeenCalled();
});
});
+156
View File
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ORBIT_PLAYLIST_PREFIX,
makeInitialOrbitState,
orbitOutboxPlaylistName,
orbitSessionPlaylistName,
type OrbitState,
} from '../../api/orbit';
import { ORBIT_ORPHAN_TTL_MS } from './constants';
const { getPlaylists, deletePlaylist } = vi.hoisted(() => ({
getPlaylists: vi.fn(),
deletePlaylist: vi.fn(),
}));
const { authState, orbitState } = vi.hoisted(() => ({
authState: { username: 'me' as string | undefined },
orbitState: { sessionId: null as string | null },
}));
vi.mock('../../api/subsonicPlaylists', () => ({ getPlaylists, deletePlaylist }));
vi.mock('../../store/authStore', () => ({
useAuthStore: {
getState: () => ({
getActiveServer: () => (authState.username ? { username: authState.username } : undefined),
}),
},
}));
vi.mock('../../store/orbitStore', () => ({
useOrbitStore: { getState: () => ({ sessionId: orbitState.sessionId }) },
}));
import { cleanupOrphanedOrbitPlaylists } from './cleanup';
type FakePlaylist = {
id: string;
name: string;
owner?: string;
comment?: string;
changed?: string;
};
/** A session-playlist comment whose heartbeat (`positionAt`) is `ageMs` old. */
function sessionComment(sid: string, ageMs: number, ended = false): string {
const state: OrbitState = makeInitialOrbitState({ sid, host: 'me', name: 'sesh' });
state.positionAt = Date.now() - ageMs;
if (ended) state.ended = true;
return JSON.stringify(state);
}
/** An outbox comment whose heartbeat `ts` is `ageMs` old. */
function outboxComment(ageMs: number): string {
return JSON.stringify({ ts: Date.now() - ageMs });
}
beforeEach(() => {
authState.username = 'me';
orbitState.sessionId = null;
deletePlaylist.mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
getPlaylists.mockReset();
});
async function runWith(playlists: FakePlaylist[]): Promise<string[]> {
getPlaylists.mockResolvedValue(playlists);
await cleanupOrphanedOrbitPlaylists();
return deletePlaylist.mock.calls.map(c => c[0] as string);
}
describe('cleanupOrphanedOrbitPlaylists', () => {
it('keeps a fresh session playlist from another device (regression for the regex bug)', async () => {
const sid = 'aaaa1111';
const deleted = await runWith([
{ id: 'p1', name: orbitSessionPlaylistName(sid), owner: 'me', comment: sessionComment(sid, 1_000) },
]);
expect(deleted).toEqual([]);
});
it('deletes a stale session playlist past the orphan TTL', async () => {
const sid = 'bbbb2222';
const deleted = await runWith([
{
id: 'p2',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual(['p2']);
});
it('deletes a session the host explicitly ended even when fresh', async () => {
const sid = 'cccc3333';
const deleted = await runWith([
{
id: 'p3',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, 1_000, /* ended */ true),
},
]);
expect(deleted).toEqual(['p3']);
});
it('never touches this device\'s current session', async () => {
const sid = 'dddd4444';
orbitState.sessionId = sid;
const deleted = await runWith([
// Stale heartbeat, but it's *our* live session → must be skipped.
{
id: 'p4',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual([]);
});
it('keeps a fresh outbox but prunes a stale one', async () => {
const sid = 'eeee5555';
const deleted = await runWith([
{ id: 'fresh', name: orbitOutboxPlaylistName(sid, 'bob'), owner: 'me', comment: outboxComment(1_000) },
{
id: 'stale',
name: orbitOutboxPlaylistName(sid, 'eve'),
owner: 'me',
comment: outboxComment(ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual(['stale']);
});
it('prunes an unrecognisable __psyorbit_* playlist', async () => {
const deleted = await runWith([
{ id: 'junk', name: `${ORBIT_PLAYLIST_PREFIX}not-a-real-name`, owner: 'me' },
]);
expect(deleted).toEqual(['junk']);
});
it('ignores playlists owned by another user', async () => {
const sid = 'ffff6666';
const deleted = await runWith([
{
id: 'foreign',
name: orbitSessionPlaylistName(sid),
owner: 'someone-else',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual([]);
});
});
+6 -1
View File
@@ -25,7 +25,12 @@ export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
const TTL = ORBIT_ORPHAN_TTL_MS;
const currentSid = useOrbitStore.getState().sessionId;
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+__)?$`);
// The trailing `__` is part of *both* the session name (`__psyorbit_<sid>__`)
// and the outbox name (`__psyorbit_<sid>_from_<user>__`), so it must sit
// outside the optional `_from_…` group. Keeping it inside (the old bug) meant
// the bare session name never matched and fell into the unconditional-prune
// branch below — deleting live sessions on the user's other devices.
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+)?__$`);
let deleted = 0;
for (const p of all) {
+11
View File
@@ -26,6 +26,17 @@ export const ORBIT_ORPHAN_TTL_MS = 5 * 60_000;
*/
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
/**
* Upper bound on `OrbitState.queue` (the suggestion/attribution history).
* The list is append-only at the sweep-fold step, so without a cap a long
* session grows it without limit until the serialised blob blows past
* `ORBIT_STATE_MAX_BYTES` and the host silently stops publishing. We keep the
* most-recently-added entries (oldest dropped first) — those cover the
* upcoming play queue, which is all the attribution lookup needs. The wire
* serialiser trims further per-write if the blob still overflows.
*/
export const ORBIT_QUEUE_HISTORY_LIMIT = 64;
/**
* How long a soft-`removed` marker stays in the state blob. Long enough for
* the affected guest's 2.5 s read tick to surface the modal even after a
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
ORBIT_STATE_MAX_BYTES,
makeInitialOrbitState,
parseOrbitState,
type OrbitQueueItem,
type OrbitState,
} from '../../api/orbit';
import { OrbitStateTooLarge, serialiseOrbitState, serialiseOrbitStateForWire } from './helpers';
function baseState(): OrbitState {
return makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' });
}
function makeQueue(n: number): OrbitQueueItem[] {
// addedAt == index, so "oldest" is the lowest addedAt.
return Array.from({ length: n }, (_, i) => ({
trackId: `track-${i}-${'x'.repeat(40)}`,
addedBy: `user${i % 10}`,
addedAt: i,
}));
}
function byteLen(s: string): number {
return new TextEncoder().encode(s).length;
}
describe('serialiseOrbitStateForWire', () => {
it('passes a within-budget state through untouched', () => {
const state = { ...baseState(), queue: makeQueue(5) };
expect(serialiseOrbitStateForWire(state)).toBe(serialiseOrbitState(state));
});
it('trims oldest suggestions until the blob fits the byte budget', () => {
const state = { ...baseState(), queue: makeQueue(300) };
// Sanity: the untrimmed state really is over budget.
expect(() => serialiseOrbitState(state)).toThrow(OrbitStateTooLarge);
const wire = serialiseOrbitStateForWire(state);
expect(byteLen(wire)).toBeLessThanOrEqual(ORBIT_STATE_MAX_BYTES);
const parsed = parseOrbitState(JSON.parse(wire));
expect(parsed).not.toBeNull();
const retained = parsed!.queue;
expect(retained.length).toBeGreaterThan(0);
expect(retained.length).toBeLessThan(300);
// The dropped entries are the oldest — every retained addedAt is a
// contiguous suffix ending at the newest (299).
const addedAts = retained.map(q => q.addedAt);
expect(Math.max(...addedAts)).toBe(299);
expect(Math.min(...addedAts)).toBe(300 - retained.length);
});
it('falls back to trimming the play queue once history is exhausted', () => {
const playQueue = Array.from({ length: 400 }, (_, i) => ({
trackId: `pq-${i}-${'y'.repeat(40)}`,
addedBy: `user${i % 10}`,
}));
const state: OrbitState = { ...baseState(), queue: [], playQueue, playQueueTotal: playQueue.length };
expect(() => serialiseOrbitState(state)).toThrow(OrbitStateTooLarge);
const wire = serialiseOrbitStateForWire(state);
expect(byteLen(wire)).toBeLessThanOrEqual(ORBIT_STATE_MAX_BYTES);
const parsed = parseOrbitState(JSON.parse(wire));
expect(parsed!.queue).toEqual([]);
expect((parsed!.playQueue ?? []).length).toBeLessThan(400);
});
});
+45
View File
@@ -37,6 +37,51 @@ export class OrbitStateTooLarge extends Error {
}
}
function trySerialise(state: OrbitState): string | null {
try {
return serialiseOrbitState(state);
} catch (e) {
if (e instanceof OrbitStateTooLarge) return null;
throw e;
}
}
/**
* Serialise the state blob for the wire, shedding the least-important data
* instead of throwing when it would exceed the byte budget. Without this a
* single over-budget tick makes `writeOrbitState` throw, the host swallows it
* and retries the same too-large state forever — guests freeze and time out.
*
* Sheds, in order: oldest attribution history (`queue`), then the tail of the
* published `playQueue`. Operates on copies — the caller's local store keeps
* full state; only the published blob shrinks, which is all guests consume.
* If even a minimal blob overflows (pathological session name / participant
* list) it throws `OrbitStateTooLarge` as before, so the caller still logs.
*/
export function serialiseOrbitStateForWire(state: OrbitState): string {
const direct = trySerialise(state);
if (direct !== null) return direct;
// Drop oldest suggestions first — they're the least useful for attribution.
const queue = [...state.queue].sort((a, b) => a.addedAt - b.addedAt);
while (queue.length > 0) {
queue.shift();
const out = trySerialise({ ...state, queue });
if (out !== null) return out;
}
// Queue exhausted and still too large — shorten the published play queue.
const playQueue = [...(state.playQueue ?? [])];
while (playQueue.length > 0) {
playQueue.pop();
const out = trySerialise({ ...state, queue: [], playQueue });
if (out !== null) return out;
}
// Even the minimal blob overflows — surface it so the host tick logs it.
return serialiseOrbitState({ ...state, queue: [], playQueue: [] });
}
export function serialiseOutboxMeta(meta: OrbitOutboxMeta): string {
return JSON.stringify(meta);
}
+2 -2
View File
@@ -5,7 +5,7 @@ import {
type OrbitOutboxMeta,
type OrbitState,
} from '../../api/orbit';
import { serialiseOrbitState, serialiseOutboxMeta } from './helpers';
import { serialiseOrbitStateForWire, serialiseOutboxMeta } from './helpers';
/** Pull + parse the canonical state from the session playlist. Null on miss or parse error. */
export async function readOrbitState(sessionPlaylistId: string): Promise<OrbitState | null> {
@@ -31,7 +31,7 @@ export async function writeOrbitState(
sessionPlaylistId: string,
state: OrbitState,
): Promise<void> {
const comment = serialiseOrbitState(state);
const comment = serialiseOrbitStateForWire(state);
const name = orbitSessionPlaylistName(state.sid);
await updatePlaylistMeta(sessionPlaylistId, name, comment, /* public */ true);
}
+59
View File
@@ -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']);
});
});
+16 -1
View File
@@ -6,10 +6,25 @@ import type {
} from '../../api/orbit';
import {
ORBIT_HEARTBEAT_ALIVE_MS,
ORBIT_QUEUE_HISTORY_LIMIT,
ORBIT_REMOVED_TTL_MS,
ORBIT_SHUFFLE_INTERVAL_MS,
} from './constants';
/**
* Keep `OrbitState.queue` bounded. Drops the oldest suggestions (by `addedAt`)
* once the history exceeds the limit — `queue` is periodically shuffled, so a
* positional trim could discard recent entries; ordering by `addedAt` keeps
* the trim honest. The dropped tracks have long since played, so losing their
* attribution / dedupe entry is harmless.
*/
function capQueueHistory(queue: OrbitQueueItem[]): OrbitQueueItem[] {
if (queue.length <= ORBIT_QUEUE_HISTORY_LIMIT) return queue;
return [...queue]
.sort((a, b) => a.addedAt - b.addedAt)
.slice(queue.length - ORBIT_QUEUE_HISTORY_LIMIT);
}
/** Merge a patch into the store's state blob, keeping nullability. */
export function patchOrbitState(patch: Partial<OrbitState>): OrbitState | null {
const current = useOrbitStore.getState().state;
@@ -137,7 +152,7 @@ export function applyOutboxSnapshotsToState(
return {
...state,
queue: newItems.length > 0 ? [...state.queue, ...newItems] : state.queue,
queue: newItems.length > 0 ? capQueueHistory([...state.queue, ...newItems]) : state.queue,
participants,
removed,
};