fix(settings): restore the previous locks when an Xtream rollback write fails

When an Xtream lock edit's re-stamp fails and the rollback store write
fails too, memory now goes back to the previous locks and a pending
rewrite persists them on the next store access before the index is
re-stamped, so the failed edit cannot take effect through that re-stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 14:16:20 +02:00
1 parent 3b12b8a1c0
commit 41ac30494c
3 files changed
+31 -8

No files matched your search

+4 -1
View File
@@ -399,7 +399,10 @@ by the index alone:
reload on is published only once every touched type is stamped — also
after a rollback — so a reload cannot read a later type through its old
stamps. If the rollback write or its re-stamp fails too, the playlist is
marked stale and re-stamped on the next store access. For the whole
marked stale and re-stamped on the next store access. A failed rollback
WRITE still puts memory back to the previous locks and leaves a pending
rewrite: the next store access persists them before it re-stamps, so the
failed edit cannot take effect through that re-stamp. For the whole
re-stamp window — store changed, stamps not yet landed — the playlist
counts as stale, so `readable` is false and a relock inside the window
reloads fail-closed instead of through the old stamps.
@@ -384,9 +384,10 @@ describe('ParentalLockLockStore', () => {
expect(store.readable()).toBe(true);
});
it('re-stamps on the next access when even the rollback write failed', async () => {
it('restores the previous locks on the next access when even the rollback write failed', async () => {
await store.load();
await store.ensureReadable();
jest.spyOn(console, 'error').mockImplementation(() => undefined);
setCategoryLocks.mockClear();
setCategoryLocks.mockResolvedValueOnce(false);
storage.writeLocks
@@ -396,12 +397,22 @@ describe('ParentalLockLockStore', () => {
await expect(
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7, 9]);
// The failed edit does not take effect: memory holds the previous
// locks and the store stays fail-closed until they are persisted.
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
expect(store.readable()).toBe(false);
await expect(store.ensureReadable()).resolves.toBe(true);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7, 9]);
expect(storage.writeLocks).toHaveBeenLastCalledWith(
expect.objectContaining({
'pl-1': expect.objectContaining({
xtream: [{ categoryType: 'live', xtreamId: 7 }],
}),
})
);
// Re-stamped from the restored locks, not the failed edit.
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]);
expect(store.readable()).toBe(true);
});
@@ -79,10 +79,10 @@ export class ParentalLockLockStore {
private writeQueue: Promise<unknown> = Promise.resolve();
/**
* The persisted store differs from the in-memory one and must be
* rewritten from it: "Remove all playlists" could not clear it. Retried
* on the next store access;
* the store is not `readable` meanwhile, since a restart would load the
* persisted copy.
* rewritten from it: "Remove all playlists" could not clear it, or a
* failed edit could not be rolled back. Retried on the next store
* access (before the index is re-stamped from memory); the store is not
* `readable` meanwhile.
*/
private readonly pendingRewrite = signal(false);
private readonly staleIndex = new ParentalLockStaleIndex();
@@ -452,6 +452,15 @@ export class ParentalLockLockStore {
const restored = await this.persistPlaylistLocks(playlistId, previous, {
publish: false,
});
if (!restored) {
// Memory goes back to the previous locks anyway, and the
// persisted copy is rewritten from it on the next access: the
// failed edit must not take effect through a later re-stamp.
this.locks.set(
withPlaylistLocksInStore(this.locks(), playlistId, previous)
);
this.pendingRewrite.set(true);
}
const restamped =
restored &&
(await this.stampXtreamLocks(playlistId, categoryTypes));