From 41ac30494c9d4c8a178784d1a3a9fc5bb8a24200 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 14:16:20 +0200 Subject: [PATCH] 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 --- docs/architecture/parental-lock.md | 5 ++++- .../parental-lock-lock-store.service.spec.ts | 17 ++++++++++++++--- .../parental-lock-lock-store.service.ts | 17 +++++++++++++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 2d911ea21..31d44c589 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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. diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts index dce3e6f92..0bed76ec8 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts @@ -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); }); diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts index 73631a6fc..e9346d86f 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts @@ -79,10 +79,10 @@ export class ParentalLockLockStore { private writeQueue: Promise = 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));