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
+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);
});
});