fix(orbit): stop the orphan sweep from deleting live session playlists

The sweep's name regex kept the trailing __ inside the optional _from_ group,
so a bare session name (__psyorbit_<sid>__) never matched and was pruned as
'corrupt' before the reconnect-breadcrumb guard ran. This was latent while
quitting deleted the session; once sessions survive quit (reconnect), the next
launch's sweep deleted the live session, so the reconnect prompt found nothing
to resume. The trailing __ is now anchored outside the group so both session
and outbox names match. Adds a regression test.
This commit is contained in:
Psychotoxical
2026-06-07 23:03:35 +02:00
parent ebef8aa061
commit 901d260bf1
2 changed files with 82 additions and 1 deletions
+76
View File
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { makeInitialOrbitState } from '../../api/orbit';
const playlists = vi.hoisted(() => ({ getPlaylists: vi.fn(), deletePlaylist: vi.fn() }));
const lastSession = vi.hoisted(() => ({ readOrbitLastSessionSid: vi.fn() }));
const orbitStoreState = vi.hoisted(() => ({ sessionId: null as string | null }));
vi.mock('../../api/subsonicPlaylists', () => playlists);
vi.mock('../../store/authStore', () => ({
useAuthStore: { getState: () => ({ getActiveServer: () => ({ username: 'bob' }) }) },
}));
vi.mock('../../store/orbitStore', () => ({
useOrbitStore: { getState: () => orbitStoreState },
}));
vi.mock('./lastSession', () => lastSession);
import { cleanupOrphanedOrbitPlaylists } from './cleanup';
function sessionComment(sid: string, ageMs = 0): string {
const s = makeInitialOrbitState({ sid, host: 'bob', name: 'X' });
return JSON.stringify({ ...s, positionAt: Date.now() - ageMs });
}
const pl = (name: string, comment: string | undefined, id = name) => ({
id, name, owner: 'bob', comment, changed: undefined as string | undefined,
});
beforeEach(() => {
vi.clearAllMocks();
orbitStoreState.sessionId = null;
lastSession.readOrbitLastSessionSid.mockReturnValue(null);
playlists.deletePlaylist.mockResolvedValue(undefined);
});
describe('cleanupOrphanedOrbitPlaylists', () => {
it('does not prune a live session protected by the reconnect breadcrumb', async () => {
// Regression: the bare session name (`__psyorbit_<sid>__`) must match the
// regex so the sid-skip is reached. A mis-anchored regex treated it as
// corrupt and deleted it before the breadcrumb guard — killing reconnect.
lastSession.readOrbitLastSessionSid.mockReturnValue('aaaa1111');
playlists.getPlaylists.mockResolvedValue([
pl('__psyorbit_aaaa1111__', sessionComment('aaaa1111', 60 * 60_000)),
pl('__psyorbit_aaaa1111_from_bob__', JSON.stringify({ ts: Date.now() - 60 * 60_000 })),
]);
const deleted = await cleanupOrphanedOrbitPlaylists();
expect(deleted).toBe(0);
expect(playlists.deletePlaylist).not.toHaveBeenCalled();
});
it('recognises a fresh session playlist instead of pruning the bare name', async () => {
playlists.getPlaylists.mockResolvedValue([
pl('__psyorbit_bbbb2222__', sessionComment('bbbb2222', 0)),
]);
const deleted = await cleanupOrphanedOrbitPlaylists();
expect(deleted).toBe(0);
expect(playlists.deletePlaylist).not.toHaveBeenCalled();
});
it('prunes a stale, unprotected session', async () => {
playlists.getPlaylists.mockResolvedValue([
pl('__psyorbit_cccc3333__', sessionComment('cccc3333', 60 * 60_000)),
]);
const deleted = await cleanupOrphanedOrbitPlaylists();
expect(deleted).toBe(1);
expect(playlists.deletePlaylist).toHaveBeenCalledWith('__psyorbit_cccc3333__');
});
it('prunes a corrupt / unrecognised orbit name', async () => {
playlists.getPlaylists.mockResolvedValue([
pl('__psyorbit_not-hex-no-suffix', undefined),
]);
const deleted = await cleanupOrphanedOrbitPlaylists();
expect(deleted).toBe(1);
});
});
+6 -1
View File
@@ -31,7 +31,12 @@ export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
// the next sweep prunes it.
const reconnectSid = readOrbitLastSessionSid();
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+__)?$`);
// Matches both a session playlist (`__psyorbit_<sid>__`) and an outbox
// (`__psyorbit_<sid>_from_<user>__`). The trailing `__` must live outside the
// optional `_from_…` group, otherwise a bare session name fails to match and
// gets pruned as "corrupt" — which would delete a live session a restart is
// about to reconnect to.
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+)?__$`);
let deleted = 0;
for (const p of all) {