From c65502a65dada9aef8aa24e330a4eb7b38277e0c Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 22:22:57 +0200 Subject: [PATCH] fix(server-storage): a duplicate leaves a running tab's kept page loads to it A duplicated tab copies the tab's session storage, so after a reload that kept older page loads its pointer lists them too, and the duplicate took them over at once, although the tab still running owns those edits and may have newer ones queued; its replay could then write older watch progress over newer. When the previous page load still runs, the kept ones now wait with it and `touch` adopts them, oldest first, once it no longer looks live. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 3 +- .../server-user-data-adopt.spec.ts | 42 +++++++++++++++++++ .../server-user-data-storage.ts | 25 +++++------ 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 174af7b99..393cb6079 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -237,7 +237,8 @@ settings and guards read them in every build. writes over and sends them first (the old copy goes only once the new one is saved, at the latest with this page's next saved copy; until then the tab's pointer names the kept page loads too, so another reload finds - them even without their registry records). A page marks + them even without their registry records; a duplicate of a tab that + still runs leaves them, like that tab's page load, to it). A page marks its record closed on `pagehide`, and it stays closed when a page-hide write failing afterwards rewrites it (not into the back-forward cache, where its record goes stale, as in a background tab the browser suspends; diff --git a/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts b/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts index 92891861c..5b61d9461 100644 --- a/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts @@ -539,4 +539,46 @@ describe('ServerUserDataStorage taking over other page loads', () => { expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); }); + + it("leaves a running tab's kept page loads to it in a duplicate", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const api = { + ...server.api, + sendJson: jest.fn(() => + Promise.reject(new ServerApiError(0, 'network', 'offline')) + ), + } as never; + const page = (store: ReturnType) => + new ServerUserDataStorage(api, local, jest.fn(), 'default', store); + const first = page(tabStore); + await first.hydrate(); + first.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await first.flush(); + first.close(); + // The reload cannot save its copy: it keeps the first one's. + const setItem = local.setItem; + local.setItem = (key: string, value: string) => { + if (key.startsWith('iptvnator-server:unsent-writes:')) { + throw new DOMException('full', 'QuotaExceededError'); + } + setItem(key, value); + }; + const running = page(tabStore); + expect(running.hasUnsentWrites()).toBe(true); + + // A duplicate copies the tab's session storage, pointer and all. + const copied = memoryStorage(); + for (const key of tabStore.keys()) { + copied.setItem(key, tabStore.getItem(key) ?? ''); + } + const duplicate = page(copied); + + // The running tab owns those writes and may hold newer ones. + expect(duplicate.hasUnsentWrites()).toBe(false); + running.close(); + duplicate.touch(); + expect(duplicate.hasUnsentWrites()).toBe(true); + }); }); 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 7790ea167..99f50c635 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 @@ -61,7 +61,7 @@ export class ServerUserDataStorage implements UserDataStorage { private readonly deferred = new Map(); private hydrated = false; /** The previous page load in this tab, while it still looks live. */ - private awaited: string | null = null; + private awaited: string[] = []; /** A record stands for this page: a stale one is adopted elsewhere. */ private recorded = false; /** Playlists deleted here, in order: a read sent earlier skips them. */ @@ -108,14 +108,14 @@ export class ServerUserDataStorage implements UserDataStorage { claimCache(local, instanceId); const [previous, ...kept] = swapPage(tabStore, instanceId, this.tabId); // Older page loads whose copies a reload kept come first. - kept.forEach((page) => this.takeOver(page)); - // 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` - // adopts it once it no longer looks live. + const pages = previous ? [...kept, previous] : kept; + // A duplicated tab copies the page load of a tab that still runs, + // and the ones it keeps; so does a reload whose close flag could not + // be saved: `touch` adopts them once it no longer looks live. if (previous && pageIsLive(local, instanceId, previous)) { - this.awaited = previous; - } else if (previous) { - this.takeOver(previous); + this.awaited = pages; + } else { + pages.forEach((page) => this.takeOver(page)); } } @@ -173,11 +173,12 @@ export class ServerUserDataStorage implements UserDataStorage { /** 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)) { + const pages = this.awaited; + const owner = pages[pages.length - 1]; + if (owner && !pageIsLive(this.local, this.instanceId, owner)) { // This page may have read and edited since: adopt, not restore. - this.awaited = null; - this.adoptPage(page); + this.awaited = []; + pages.forEach((page) => this.adoptPage(page)); } // A tab with nothing to sync never re-reads, which is where closed // tabs' saved writes are otherwise picked up.