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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 00:28:00 +02:00
1 parent d45210c4f7
commit 2320b64e01
4 files changed
+180 -54

No files matched your search

+2 -1
View File
@@ -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.
@@ -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,
@@ -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();
}
}
@@ -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<typeof fakeServer>,
local: ReturnType<typeof memoryStorage>,
tabStore: ReturnType<typeof memoryStorage>,
edit: (page: ServerUserDataStorage) => Promise<void>
) {
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<typeof send>) =>
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']);
});
});
});