Files
psysonic/src/utils/orbit/helpers.ts
T
Psychotoxical 2c9b2eeb46 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)
2026-06-22 13:33:09 +02:00

104 lines
3.9 KiB
TypeScript

import {
ORBIT_PLAYLIST_PREFIX,
ORBIT_STATE_MAX_BYTES,
type OrbitOutboxMeta,
type OrbitQueueItem,
type OrbitState,
} from '../../api/orbit';
/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */
export function generateSessionId(): string {
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Serialise the state blob for writing into a playlist comment. Emits a
* plain JSON string. Throws when the output exceeds `ORBIT_STATE_MAX_BYTES`
* — callers should trim optional fields (oldest queue entries / kicked
* usernames) and retry, rather than write something truncated.
*/
export function serialiseOrbitState(state: OrbitState): string {
const json = JSON.stringify(state);
// Encode-length check — emoji-heavy session names could inflate UTF-8 bytes
// beyond the string's .length count.
const byteLen = new TextEncoder().encode(json).length;
if (byteLen > ORBIT_STATE_MAX_BYTES) {
throw new OrbitStateTooLarge(byteLen);
}
return json;
}
export class OrbitStateTooLarge extends Error {
constructor(public readonly bytes: number) {
super(`Orbit state blob (${bytes} bytes) exceeds ${ORBIT_STATE_MAX_BYTES} byte budget`);
this.name = 'OrbitStateTooLarge';
}
}
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);
}
/**
* Stable per-suggestion key across reshuffles — `addedBy`, `addedAt` and
* `trackId` are all immutable once the host sweep has written them.
* Shared between the host tick and the manual-approval UI.
*/
export const suggestionKey = (q: OrbitQueueItem): string =>
`${q.addedBy}:${q.addedAt}:${q.trackId}`;
/** Extract `<username>` from a filename matching `__psyorbit_<sid>_from_<username>__`. */
export function parseOutboxPlaylistName(name: string, sid: string): string | null {
const prefix = `${ORBIT_PLAYLIST_PREFIX}${sid}_from_`;
if (!name.startsWith(prefix) || !name.endsWith('__')) return null;
const user = name.slice(prefix.length, name.length - 2);
return user.length > 0 ? user : null;
}