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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 22:22:57 +02:00
1 parent b3a493fa7f
commit c65502a65d
3 files changed
+57 -13

No files matched your search

+2 -1
View File
@@ -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;
@@ -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<typeof memoryStorage>) =>
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);
});
});
@@ -61,7 +61,7 @@ export class ServerUserDataStorage implements UserDataStorage {
private readonly deferred = new Map<string, DeferredEdit>();
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.