fix(server-storage): a saved write without a readable tag counts as saved; unverified online pages keep retrying

A write whose headers said saved but whose body (with the new tag) was
cut off was retired without confirmation: a newer queued edit kept its
steps and old tag, and a later conflict replayed the saved step over a
newer value from another device. It now counts as saved on an unknown
version: the landed steps drop out and the next write conflicts, so
only edits made after it replay. Writes that were unconditional stay so.
The keepalive path does the same.

A late answer to a request sent before an outage can set an unverified
page online; with nothing to sync the retry then never ran and the token
stayed withheld. The retry now also runs for online pages that are not
verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 14:53:14 +02:00
1 parent 252ef9506f
commit dfe48f59a0
5 files changed
+126 -13

No files matched your search

+4 -2
View File
@@ -228,7 +228,8 @@ settings and guards read them in every build.
(the page-hide flush waits for a batch still travelling), 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, and
flush (a write saved whose answer was cut off counts as saved on an
unknown version: only later edits replay on the next conflict), and
every unconfirmed write (queued or travelling) is saved in `localStorage`
per page load (`iptvnator-server:unsent-writes:<instanceId>:<page>`). The
tab remembers its page load in `sessionStorage`, so a reload takes its
@@ -343,7 +344,8 @@ settings and guards read them in every build.
replaced volume at the same address is another database), and it checks
the instance as soon as it goes offline, so a blip costs no retry
interval (only the instance, and once per retry interval: a read or a
check that keeps failing waits for the retry). Sign-out always navigates to sign-in, also when a guard kept the
check that keeps failing waits for the retry; the retry also runs while a
late answer set an unchecked page online). Sign-out always navigates to sign-in, also when a guard kept the
page the first time. Both turn
the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`,
as the storage API client does with any 401, so a token changed on the
@@ -294,6 +294,34 @@ describe('ServerStorageSession', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('keeps checking an unverified page a late answer set online', async () => {
writeServerAccessToken('t', 'i-1');
const { session, status } = setup('token');
await session.start();
fetchMock.mockRejectedValue(new TypeError('offline'));
status.reportOffline();
TestBed.tick();
for (let turn = 0; turn < 10; turn += 1) {
await Promise.resolve();
}
// A request sent before the outage is answered late.
status.reportSuccess();
expect(status.state()).toBe('online');
expect(status.isVerified()).toBe(false);
fetchMock.mockReset();
fetchMock.mockResolvedValue(
answer({ instanceId: 'i-1', entries: {} })
);
jest.advanceTimersByTime(30_000);
for (let turn = 0; turn < 10; turn += 1) {
await Promise.resolve();
}
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/instance');
expect(status.isVerified()).toBe(true);
});
it('reloads when another instance answers at the address', async () => {
const { session, status } = setup('none');
const reload = jest
@@ -220,10 +220,13 @@ export class ServerStorageSession {
this.userData.touch();
// Also online: a write the server refused (a 5xx) or a first
// read that failed is not retried by anything else.
// Online but unverified too: a late answer to a request sent
// before the outage may have set it online again.
const state = this.status.state();
if (
state === 'offline' ||
(state === 'online' && this.userData.needsSync())
(state === 'online' &&
(this.userData.needsSync() || !this.status.isVerified()))
) {
void this.refresh();
}
@@ -357,10 +357,7 @@ export class UserDataWriteQueue {
));
this.retireInFlight(entry);
if (outcome.status === 'saved') {
// Without a tag a newer edit keeps this one's steps.
if (outcome.tag !== undefined) {
this.confirmed(entry, outcome.tag);
}
this.confirmed(entry, outcome.tag);
} else if (outcome.status === 'conflict') {
this.replayOnConflict(entry, outcome.current);
} else if (outcome.status === 'gone') {
@@ -390,9 +387,7 @@ export class UserDataWriteQueue {
.readJson<Partial<ServerUserStateWriteResponse>>(response)
.catch(() => ({ tag: undefined }));
this.retireInFlight(entry);
if (tag !== undefined) {
this.confirmed(entry, tag);
}
this.confirmed(entry, tag);
} catch (error) {
this.retireInFlight(entry);
if (
@@ -424,8 +419,21 @@ export class UserDataWriteQueue {
}
/** The server took `entry`: the edit queued behind it builds on it. */
private confirmed(entry: PendingEntry, tag: string | null): void {
this.tags.update(entry.namespace, entry.playlistId, tag);
/**
* `entry` landed. Without a readable tag (a body cut off after its
* headers) a conditional write leaves the version unknown: a newer edit
* drops the landed steps all the same and conflicts, so only the edits
* made after it replay. An unconditional one stays unconditional.
*/
private confirmed(
entry: PendingEntry,
read: string | null | undefined
): void {
const tag =
read !== undefined || entry.tag === undefined ? read : UNKNOWN_TAG;
if (tag !== undefined) {
this.tags.update(entry.namespace, entry.playlistId, tag);
}
const key = entryKey(entry);
const next = this.pending.get(key);
if (next?.steps.some((step) => step.sent === entry.edit)) {
@@ -433,7 +441,10 @@ export class UserDataWriteQueue {
this.pending.set(key, {
...next,
steps: next.steps.filter((step) => step.sent !== entry.edit),
tag: next.tag === UNKNOWN_TAG ? UNKNOWN_TAG : tag,
tag:
next.tag === UNKNOWN_TAG || tag === undefined
? next.tag
: tag,
});
}
}
@@ -681,6 +681,75 @@ describe('ServerUserDataStorage with other devices', () => {
expect(server.state['xtream-playback-positions']['p1']).toEqual(at(30));
});
it('does not replay a saved edit whose answer was cut off', async () => {
const at = (id: number, seconds: number) => ({
contentType: 'vod',
contentXtreamId: id,
positionSeconds: seconds,
});
const server = fakeServer({
'xtream-playback-positions': { p1: [at(1, 10), at(2, 5)] },
});
const send = server.api.sendJson.getMockImplementation();
if (!send) {
throw new Error('The fake server answers sendJson');
}
let cutOff = true;
let answer = (): void => undefined;
const tab = new ServerUserDataStorage(
{
...server.api,
// Saved, but the body with the new tag never arrives.
sendJson: jest.fn(async (...args: Parameters<typeof send>) => {
if (!cutOff) {
return send(...args);
}
cutOff = false;
await new Promise<void>((resolve) => (answer = resolve));
await send(...args);
return {
json: async () => {
throw new TypeError('body cut off');
},
} as unknown as Response;
}),
} as never,
memoryStorage(),
jest.fn(),
'default',
memoryStorage()
);
await tab.hydrate();
tab.setItem(
'xtream-playback-positions',
JSON.stringify({ p1: [at(1, 20), at(2, 5)] })
);
const saving = tab.flush();
for (let turn = 0; turn < 10; turn += 1) {
await Promise.resolve();
}
// Movie 2 changes here while that write travels.
tab.setItem(
'xtream-playback-positions',
JSON.stringify({ p1: [at(1, 20), at(2, 6)] })
);
answer();
await saving;
expect(server.state['xtream-playback-positions']['p1']).toEqual([
at(1, 20),
at(2, 5),
]);
// Another device saves movie 1 at 30 s.
server.state['xtream-playback-positions']['p1'] = [at(1, 30), at(2, 5)];
await tab.flush();
await tab.flush();
expect(server.state['xtream-playback-positions']['p1']).toEqual([
at(1, 30),
at(2, 6),
]);
});
it("keeps the last page load's saved writes when storage refuses the copy", async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
const local = memoryStorage();