fix(server-storage): flushes wait for a page-hide batch sent at once; keepalive 401s; parked restores move

A flush scheduled in the same turn as a page-hide flush was chained on
the queue before the keepalive batch, which went out at once beside it:
a later edit (removing a favorite the keepalive batch added) could land
first and be undone when the keepalive write arrived. A scheduled flush
now waits for a page-hide batch sent at once before it takes entries.

A keepalive write refused with 401 returned failed without telling the
status; like requests it now reports the token it sent as refused.

The move copies a parked Xtream restore (a browser backup import that
could not finish) to the server playlist id a playlist merged into, so
it is applied and exported there; one parked there already wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 23:38:28 +02:00
1 parent 5c2b3c235b
commit a2a19b5e7e
7 files changed
+130 -7

No files matched your search

+5 -2
View File
@@ -226,7 +226,8 @@ settings and guards read them in every build.
`keepalive` flush when the page hides. A change is compared with the map
the caller read: when the cache changed since (another tab, a re-read),
only the entries the caller changed are applied to it. Batches go out one after another
(the page-hide flush waits for a batch still travelling), a failed write,
(the page-hide flush waits for a batch still travelling, and a flush
scheduled before it waits for the page-hide batch it sent at once), a failed write,
including a refused `keepalive` one or one the server took and never
answered (it ends after the request timeout), stays queued for the next
flush (a write saved whose answer was cut off counts as saved on an
@@ -391,7 +392,9 @@ settings and guards read them in every build.
maps, the server's value winning on a
conflict. Parental locks stay in the browser but move to the server
playlist id with it; while the lock store or a browser user-data map
cannot be read the move counts as failed. Browser data is never
cannot be read the move counts as failed. A parked Xtream restore (a
browser backup import that could not finish) is copied to the server
playlist id it merged into, unless that one has its own. Browser data is never
deleted, the move does not start while user-data writes are unsent, and
the marker is written only when nothing failed, so Settings → Server
keeps offering a partial move.
@@ -127,6 +127,19 @@ describe('ServerApiClient', () => {
expect(status.reportOffline).toHaveBeenCalled();
});
it('reports a keepalive write refused by the access guard', async () => {
token = 'old';
const refused = jest.fn().mockResolvedValue(response(401));
await expect(
client(refused).sendKeepalive('PUT', '/user-state/a/b', {
value: 1,
})
).resolves.toEqual({ status: 'failed' });
// With the token it sent: one replaced since is not refused.
expect(status.reportAccessDenied).toHaveBeenCalledWith('old');
});
it('reports a gateway answering for a down backend on a keepalive write', async () => {
const down = jest.fn().mockResolvedValue(response(502));
@@ -184,12 +184,13 @@ export class ServerApiClient {
if (this.options.holdWrites?.()) {
return { status: 'failed' };
}
const token = this.token();
try {
const response = await this.fetchFn()(this.url(path), {
method,
keepalive: true,
headers: {
...bearer(this.token()),
...bearer(token),
...headers,
...(body === undefined
? {}
@@ -214,6 +215,10 @@ export class ServerApiClient {
// instance, checked before the next write goes out.
this.options.status.reportOffline();
}
if (response.status === 401) {
// As for requests: a token replaced since is not refused.
this.options.status.reportAccessDenied(token);
}
const { code, current } =
response.status === 412 || response.status === 404
? await readErrorBody(response)
@@ -1,6 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { of } from 'rxjs';
import { getXtreamPendingRestoreStorageKey } from '@iptvnator/shared/interfaces';
import { ParentalLockService } from '../parental-lock/parental-lock.service';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
import { ServerStorageSession } from './server-storage-session';
@@ -383,6 +384,26 @@ describe('ServerMigrationService', () => {
});
});
it('takes a parked Xtream restore along to the server playlist id', async () => {
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
{ ...browserPlaylists[1], _id: 'server-x' },
]);
// A browser backup import that could not finish.
const parked = JSON.stringify({
hiddenCategories: [],
favorites: [{ xtreamId: 5, contentType: 'movie' }],
recentlyViewed: [],
playbackPositions: [],
});
localStorage.setItem(getXtreamPendingRestoreStorageKey('x'), parked);
await TestBed.inject(ServerMigrationService).moveBrowserData();
expect(
localStorage.getItem(getXtreamPendingRestoreStorageKey('server-x'))
).toBe(parked);
});
it('keeps the move on offer when a playlist could not be uploaded', async () => {
session.documentStore.dbCreateAppPlaylist
.mockRejectedValueOnce(new Error('413'))
@@ -3,6 +3,7 @@ import { NgxIndexedDBService } from 'ngx-indexed-db';
import { firstValueFrom } from 'rxjs';
import {
DbStores,
getXtreamPendingRestoreStorageKey,
SERVER_USER_STATE_NAMESPACES,
type Playlist,
type ServerUserStateNamespace,
@@ -155,6 +156,7 @@ export class ServerMigrationService {
failed += await this.copyXtreamUserData(targets);
failed += await this.moveParentalLocks(targets);
failed += movePendingRestores(targets);
await this.session.userData.hydrate();
if (failed === 0) {
// A partial move stays on offer, in Settings → Server.
@@ -380,6 +382,33 @@ export class ServerMigrationService {
}
}
/**
* A parked Xtream restore (a browser backup import that could not finish)
* follows a playlist merged under another id, where it is applied and
* exported; one the server playlist has parked already wins. Returns how
* many could not be copied.
*/
function movePendingRestores(targets: ReadonlyMap<string, string>): number {
let failed = 0;
for (const [browserId, serverId] of targets) {
if (browserId === serverId) {
continue;
}
try {
const parked = localStorage.getItem(
getXtreamPendingRestoreStorageKey(browserId)
);
const target = getXtreamPendingRestoreStorageKey(serverId);
if (parked !== null && localStorage.getItem(target) === null) {
localStorage.setItem(target, parked);
}
} catch {
failed += 1;
}
}
return failed;
}
/** The M3U channels of a full playlist document. */
function channelsOf(playlist: Playlist): { id?: unknown; url?: unknown }[] {
const items = (playlist as { playlist?: { items?: unknown } }).playlist
@@ -682,4 +682,48 @@ describe('ServerUserDataStorage taking over other page loads', () => {
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'Y']);
});
});
it('lands a page-hide batch before a flush scheduled in the same turn', async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
const keepalive = server.api.sendKeepalive.getMockImplementation();
if (!keepalive) {
throw new Error('The fake server answers sendKeepalive');
}
let land = (): void => undefined;
let started = false;
const tab = new ServerUserDataStorage(
{
...server.api,
// The page-hide write is still on its way.
sendKeepalive: jest.fn(
(...args: Parameters<typeof keepalive>) =>
new Promise((resolve) => {
started = true;
land = () => resolve(keepalive(...args));
})
),
} as never,
memoryStorage(),
jest.fn(),
'default',
memoryStorage()
);
await tab.hydrate();
tab.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
const flushing = tab.flush();
tab.flushWithKeepalive();
// Removed again before either batch was answered.
tab.setItem('xtream-favorites', JSON.stringify({ p1: ['S'] }));
await until(() => started);
// The scheduled flush has its turn while the page-hide write travels.
for (let turn = 0; turn < 10; turn += 1) {
await Promise.resolve();
}
land();
await flushing;
await tab.flush();
await tab.flush();
expect(server.state['xtream-favorites']['p1']).toEqual(['S']);
});
});
@@ -61,6 +61,8 @@ export class UserDataWriteQueue {
private flushTimer: ReturnType<typeof setTimeout> | null = null;
/** Batches go out one at a time, so an older write never lands last. */
private sending: Promise<void> = Promise.resolve();
/** A page-hide batch sent at once, which a scheduled flush waits for. */
private keepaliveRun: Promise<void> = Promise.resolve();
private writesInFlight = 0;
/** Edit counter per `namespace|playlistId`, for re-reads to keep them. */
private readonly edits = new Map<string, number>();
@@ -210,6 +212,9 @@ export class UserDataWriteQueue {
/** Sends the queued entries after any batch still travelling. */
flush(): Promise<void> {
const run = this.sending.then(async () => {
// A page-hide batch that went out at once in the meantime is
// not chained on this one: its writes land first.
await this.keepaliveRun;
const batch = this.takePending();
if (batch.length === 0) {
return;
@@ -245,14 +250,17 @@ export class UserDataWriteQueue {
// Idle: send synchronously, while the hiding page still runs. Behind
// a travelling write the entries stay queued until it is answered,
// so the version it created can still become their baseline.
const run =
this.writesInFlight === 0
? this.sendKeepaliveBatch()
: this.sending.then(() => this.sendKeepaliveBatch());
const now = this.writesInFlight === 0;
const run = now
? this.sendKeepaliveBatch()
: this.sending.then(() => this.sendKeepaliveBatch());
this.sending = run.then(
() => undefined,
() => undefined
);
if (now) {
this.keepaliveRun = this.sending;
}
}
/**