From efece8628fe1d91c739b611dee96098273e727c9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 13:23:10 +0200 Subject: [PATCH] fix(settings): retire in-flight searches on relock clearing and publish lock revisions after the stamps - clearSearchResults() advances the search request version, so a search issued under the previous lock state cannot republish what a relock just cleared. - A lock write publishes its store revision only once every touched type is stamped, so a reload triggered by it cannot read a later type through its old stamps. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/parental-lock.md | 7 +++-- .../features/with-search.feature.spec.ts | 12 ++++++++ .../stores/features/with-search.feature.ts | 8 ++++- .../parental-lock-lock-store.service.spec.ts | 30 +++++++++++++++++++ .../parental-lock-lock-store.service.ts | 18 +++++++++-- 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 8028d5173..749643bb2 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -177,7 +177,8 @@ on either side. the lock store already names its category (the pre-reload category list maps Electron's row id to the provider id), then `withholdCatalog()` empties every catalog list and `clearSearchResults()` the stored search - before the filtered reads refill them; both reloads take a publish guard + (retiring a search still in flight, which was issued under the previous + lock state) before the filtered reads refill them; both reloads take a publish guard answered before every state patch, so a read issued under an older lock version is dropped instead of published. A lock change that overtakes the INITIAL hydration (the content is not initialized yet, so the reload @@ -305,7 +306,9 @@ on either side. backup restore stamps three types, and the ones before the failing type already carry the new locks; a category must not be recorded and shown as locked while Electron reads, which filter by the index alone, still - serve it); if the rollback write or its re-stamp fails too, the playlist is + serve it), and the store revision consumers reload on is published only + once every touched type is stamped, 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 re-stamped on the next store access, and every launch re-derives the index from the store for each playlist that has locks — which is why a write that removes a playlist's LAST lock clears the index first and diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts index 21755fe08..71d066118 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts @@ -161,6 +161,18 @@ describe('withSearch refreshSearchResults', () => { expect(store.searchResults()).toHaveLength(1); }); + it('retires a search still in flight when the results are cleared', async () => { + const pending = createDeferred(); + searchContent.mockReturnValueOnce(pending.promise); + const running = store.searchContent('news', ['live']); + + store.clearSearchResults(); + pending.resolve([{ xtream_id: 1 }]); + await running; + + expect(store.searchResults()).toEqual([]); + }); + it('does nothing without a previous search or after a reset', async () => { await store.refreshSearchResults(); expect(searchContent).not.toHaveBeenCalled(); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts index 765beed16..ed1a038b9 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts @@ -161,8 +161,14 @@ export function withSearch() { } }, - /** Drops the stored results; term, filters and the last search stay. */ + /** + * Drops the stored results; term, filters and the last search + * stay. Also retires a search still in flight: it was issued + * under the previous lock state and must not republish what + * was just cleared. + */ clearSearchResults(): void { + searchRequestVersion++; patchState(store, { searchResults: [] }); }, 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 45dae7052..e00ad258c 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 @@ -155,6 +155,36 @@ describe('ParentalLockLockStore', () => { expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]); }); + it('publishes the revision only after every type is stamped', async () => { + await store.load(); + await store.ensureReadable(); + const before = store.revision(); + let resolveStamp: (ok: boolean) => void = () => undefined; + setCategoryLocks.mockImplementation( + () => new Promise((resolve) => (resolveStamp = resolve)) + ); + + const writing = store.replacePlaylistLocks('pl-1', { + xtream: [{ categoryType: 'movies', xtreamId: 3 }], + stalker: [], + m3u: [], + }); + for ( + let i = 0; + i < 50 && storage.writeLocks.mock.calls.length === 0; + i += 1 + ) { + await Promise.resolve(); + } + expect(storage.writeLocks).toHaveBeenCalled(); + expect(store.revision()).toBe(before); + + setCategoryLocks.mockResolvedValue(true); + resolveStamp(true); + await expect(writing).resolves.toBe(true); + expect(store.revision()).toBe(before + 1); + }); + it('rolls the store back when the index re-stamp fails', async () => { await store.load(); setCategoryLocks.mockResolvedValue(false); 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 036e57ec5..680d69531 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 @@ -285,10 +285,19 @@ export class ParentalLockLockStore { this.revisionState.update((value) => value + 1); return false; } - if (!(await this.persistPlaylistLocks(playlistId, next))) { + // The revision is published only once every type is stamped: a + // consumer reloading on the revision while the stamps are still + // running would read a later type through its old stamps, and a + // successful stamp emits nothing afterwards to reload it. + if ( + !(await this.persistPlaylistLocks(playlistId, next, { + publish: false, + })) + ) { return false; } if (await this.stampXtreamLocks(playlistId, categoryTypes)) { + this.revisionState.update((value) => value + 1); return true; } await this.rollBack(playlistId, previous, categoryTypes); @@ -347,7 +356,8 @@ export class ParentalLockLockStore { private async persistPlaylistLocks( playlistId: string, - locks: ParentalLockPlaylistLocks + locks: ParentalLockPlaylistLocks, + options: { publish?: boolean } = {} ): Promise { if (!(await this.ensureReadable())) { return false; @@ -363,7 +373,9 @@ export class ParentalLockLockStore { return false; } this.locks.set(next); - this.revisionState.update((value) => value + 1); + if (options.publish !== false) { + this.revisionState.update((value) => value + 1); + } return true; } }