From 2320b64e0147fa6ae1acb8199d2078c3ab332e3a Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 00:28:00 +0200 Subject: [PATCH] fix(server-storage): adopt a late previous page load instead of restoring it When touch() finds that the previous page load no longer looks live, this page may already have read and queued newer edits. Its saved writes and early edits are now adopted like a closed tab's (their steps ahead of this page's newer ones, queued at once) instead of restored as if this page had just started, which replaced newer progress with older and parked early edits that were never sent after the first read. Cache forgetting moves into UserDataCache.forget(). Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 3 +- .../server-storage/server-user-data-cache.ts | 18 ++- .../server-user-data-storage.ts | 97 +++++++-------- .../server-user-data-sync.spec.ts | 116 ++++++++++++++++++ 4 files changed, 180 insertions(+), 54 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index d415f9b48..1b4408f78 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -232,7 +232,8 @@ settings and guards read them in every build. only a closed, stale or missing record is taken over: a duplicated tab, which copies `sessionStorage`, leaves the running tab its writes. A page load that still looked live (or whose close flag storage refused) is - checked again every 30 s and taken over once it does not; the writes of a tab closed before it + checked again every 30 s and, once it does not, adopted like a closed + tab's: its edits go ahead of this page's newer ones; the writes of a tab closed before it could send them are queued, ahead of its own, by the next re-read in another tab once that tab's record has gone stale; the result replays the closed tab's edits first and this tab's newer ones last. Tabs never clear each other's. diff --git a/libs/services/src/lib/server-storage/server-user-data-cache.ts b/libs/services/src/lib/server-storage/server-user-data-cache.ts index 8e0ee00b3..422f74bb1 100644 --- a/libs/services/src/lib/server-storage/server-user-data-cache.ts +++ b/libs/services/src/lib/server-storage/server-user-data-cache.ts @@ -1,4 +1,7 @@ -import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; +import { + SERVER_USER_STATE_NAMESPACES, + type ServerUserStateNamespace, +} from '@iptvnator/shared/interfaces'; import type { UserDataStorage } from '../user-data-storage'; import { parseMap } from './server-user-data-entries'; @@ -45,6 +48,19 @@ export class UserDataCache { return parseMap(this.read(namespace) ?? '{}'); } + /** After a delete: one playlist's entries, without an id all of them. */ + forget(playlistId?: string): void { + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const map = this.map(namespace); + if (playlistId === undefined) { + this.write(namespace, null); + } else if (playlistId in map) { + delete map[playlistId]; + this.write(namespace, JSON.stringify(map)); + } + } + } + /** `undefined` removes the playlist's entry. */ setEntry( namespace: ServerUserStateNamespace, diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts index 266fe2f3c..4833acdf6 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -101,11 +101,15 @@ export class ServerUserDataStorage implements UserDataStorage { writeFailed: onWriteFailure, }); claimCache(local, instanceId); + const previous = swapPage(tabStore, instanceId, this.tabId); // A duplicated tab copies the page load of a tab that still runs; // so does a reload whose close flag could not be saved: `touch` - // takes it over once it no longer looks live. - this.awaited = swapPage(tabStore, instanceId, this.tabId); - this.touch(); + // adopts it once it no longer looks live. + if (previous && pageIsLive(local, instanceId, previous)) { + this.awaited = previous; + } else if (previous) { + this.takeOver(previous); + } } /** The page unloads: a reload of this tab may take its writes over. */ @@ -155,40 +159,46 @@ export class ServerUserDataStorage implements UserDataStorage { this.instanceId, this.tabId ).filter((page) => !this.adopted.has(page))) { - const edits = [ - ...readDeferredEdits(this.local, this.instanceId, page), - ...readUnsentWrites(this.local, this.instanceId, page), - ]; - const foreign = othersUnconfirmed( - this.local, - this.instanceId, - this.tabId - ); - for (const { namespace, playlistId, steps } of edits) { - // Queued even when the cache shows it already. - const value = adoptEdits( - this.writes, - namespace, - playlistId, - this.cache.map(namespace)[playlistId], - steps, - foreign.has(`${namespace}|${playlistId}`) - ); - this.cache.setEntry(namespace, playlistId, value); - } - this.adopted.add(page); - // The closed tab's copy goes only once this tab saved its own. - this.release(page, this.saveUnsent()); - this.writes.scheduleFlush(); + this.adoptPage(page); } } + /** Its edits go ahead of this tab's own, which may be newer. */ + private adoptPage(page: string): void { + const edits = [ + ...readDeferredEdits(this.local, this.instanceId, page), + ...readUnsentWrites(this.local, this.instanceId, page), + ]; + const foreign = othersUnconfirmed( + this.local, + this.instanceId, + this.tabId + ); + for (const { namespace, playlistId, steps } of edits) { + // Queued even when the cache shows it already. + const value = adoptEdits( + this.writes, + namespace, + playlistId, + this.cache.map(namespace)[playlistId], + steps, + foreign.has(`${namespace}|${playlistId}`) + ); + this.cache.setEntry(namespace, playlistId, value); + } + this.adopted.add(page); + // The other page's copy goes only once this tab saved its own. + this.release(page, this.saveUnsent()); + this.writes.scheduleFlush(); + } + /** Tells other tabs this one is alive and still sending its writes. */ touch(): void { const page = this.awaited; if (page && !pageIsLive(this.local, this.instanceId, page)) { + // This page may have read and edited since: adopt, not restore. this.awaited = null; - this.takeOver(page); + this.adoptPage(page); } if (this.hasUnsentWrites()) { this.publishUnconfirmed(); @@ -292,31 +302,14 @@ export class ServerUserDataStorage implements UserDataStorage { try { await this.writes.whenSent(); await remove(); - this.forget(matches, playlistId); - } finally { - release(); - } - } - - private forget( - matches: (key: string) => boolean, - playlistId: string | undefined - ): void { - for (const key of [...this.deferred.keys()]) { - if (matches(key)) { + for (const key of [...this.deferred.keys()].filter(matches)) { this.deferred.delete(key); } - } - this.saveDeferred(); - this.writes.drop(matches, playlistId); - for (const namespace of SERVER_USER_STATE_NAMESPACES) { - const map = this.cache.map(namespace); - if (playlistId === undefined) { - this.cache.write(namespace, null); - } else if (playlistId in map) { - delete map[playlistId]; - this.cache.write(namespace, JSON.stringify(map)); - } + this.saveDeferred(); + this.writes.drop(matches, playlistId); + this.cache.forget(playlistId); + } finally { + release(); } } diff --git a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts index 681e9509b..228e6b9e5 100644 --- a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts @@ -1067,4 +1067,120 @@ describe('ServerUserDataStorage with other devices', () => { expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); }); + + describe('a reload whose close flag was lost', () => { + const at = (seconds: number) => [ + { + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: seconds, + }, + ]; + const offline = () => + Promise.reject(new ServerApiError(0, 'network', 'offline')); + + /** The last page load saves its edit, then unloads on a full disk. */ + async function lastPageLoad( + server: ReturnType, + local: ReturnType, + tabStore: ReturnType, + edit: (page: ServerUserDataStorage) => Promise + ) { + const page = new ServerUserDataStorage( + { ...server.api, sendJson: jest.fn(offline) } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await edit(page); + const setItem = local.setItem; + local.setItem = () => { + throw new DOMException('full', 'QuotaExceededError'); + }; + page.close(); + local.setItem = setItem; + } + + it('keeps the newer progress of the page that adopts it later', async () => { + const server = fakeServer({ + 'xtream-playback-positions': { p1: at(10) }, + }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + await lastPageLoad(server, local, tabStore, async (page) => { + await page.hydrate(); + page.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(20) }) + ); + await page.flush(); + }); + let online = false; + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + const reloaded = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest.fn((...args: Parameters) => + online ? send(...args) : offline() + ), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + reloaded.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(30) }) + ); + await reloaded.flush(); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + online = true; + await reloaded.flush(); + await reloaded.flush(); + clock.mockRestore(); + + expect(server.state['xtream-playback-positions']['p1']).toEqual( + at(30) + ); + }); + + it('sends its early edits after this page has read', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + await lastPageLoad(server, local, tabStore, async (page) => { + // Never read: the edit stays an early one. + page.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X'] }) + ); + }); + const reloaded = new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + await reloaded.flush(); + await reloaded.flush(); + clock.mockRestore(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); + }); });