mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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:
1 parent
5c2b3c235b
commit
a2a19b5e7e
7 files changed
+130
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user