fix(server-storage): a slow server does not reload a page started from memory forever

The start probe gives /v1/instance 2.5 s. A remembered server slower
than that started the page from memory; the recovery check (with the
request timeout) then got an answer and reloaded, and the reloaded page
timed out again: an endless reload loop for a server answering in 2.5
to 20 s. The reload now leaves a one-time mark in sessionStorage, and
the next probe waits as long as requests do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 23:51:35 +02:00
1 parent a2a19b5e7e
commit ad465a8fbe
7 files changed
+67 -3

No files matched your search

@@ -1,3 +1,4 @@
import { markSlowStart } from '@iptvnator/services';
import { probeServerInstance } from './server-instance-probe';
const instance = {
@@ -55,6 +56,28 @@ describe('probeServerInstance', () => {
expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance');
});
it('waits longer once after a reload for a server that answered late', async () => {
const timeout = jest.spyOn(AbortSignal, 'timeout');
try {
markSlowStart();
await probeServerInstance({
...config,
fetchFn: answer(instance) as never,
});
await probeServerInstance({
...config,
fetchFn: answer(instance) as never,
});
// A slow server otherwise times out again and reloads forever.
expect(timeout.mock.calls.map(([ms]) => ms)).toEqual([
20_000, 2_500,
]);
} finally {
timeout.mockRestore();
}
});
it('reports no server storage for an answer without it', async () => {
for (const fetchFn of [
answer({ ...instance, storage: 'none' }),
@@ -1,8 +1,10 @@
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { RuntimeCapabilitiesService, takeSlowStart } from '@iptvnator/services';
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
import type { IptvnatorRuntimeConfig } from '../services/runtime-config';
const PROBE_TIMEOUT_MS = 2_500;
/** After a reload for a server that answered later: as long as requests. */
const SLOW_PROBE_TIMEOUT_MS = 20_000;
/**
* - `skipped`: no runtime backend URL, or Electron.
@@ -47,7 +49,11 @@ export async function probeServerInstance(
cache: 'no-store',
signal:
typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(PROBE_TIMEOUT_MS)
? AbortSignal.timeout(
takeSlowStart()
? SLOW_PROBE_TIMEOUT_MS
: PROBE_TIMEOUT_MS
)
: undefined,
});
} catch {
+3 -1
View File
@@ -347,7 +347,9 @@ settings and guards read them in every build.
database (backend URL or instance id) than the one it remembers; sign-in
therefore remembers the server along with the token, or reports storage
trouble. A page started from memory first checks that the instance at the
address is the remembered one (`ensureVerified`), and reloads if not,
address is the remembered one (`ensureVerified`), and reloads if not
(a reload after a server answered late gives the next start probe as
long as requests take, once, so a slow server does not reload forever),
before sign-in sends the token. So does a page that went offline (any
backend call can tell: the storage API with its page-hide `keepalive`
writes, `HttpClient` calls through the interceptor and `PwaService`'s raw
+1
View File
@@ -35,3 +35,4 @@ export * from './lib/translation-tick';
export * from './lib/user-data-storage';
export * from './lib/server-storage-status.service';
export * from './lib/server-access-token';
export * from './lib/server-slow-start';
@@ -0,0 +1,26 @@
/**
* A page started from memory reloads once the server answers
* (`ServerStorageSession`), but a server slower than the start probe would
* time it out again and reload forever: the reloaded page's probe waits as
* long as requests do, once.
*/
const SLOW_START_KEY = 'iptvnator-server:slow-start';
export function markSlowStart(): void {
try {
sessionStorage.setItem(SLOW_START_KEY, '1');
} catch {
// Without session storage the next probe keeps its short wait.
}
}
/** Whether this page load follows such a reload; asks only once. */
export function takeSlowStart(): boolean {
try {
const slow = sessionStorage.getItem(SLOW_START_KEY) !== null;
sessionStorage.removeItem(SLOW_START_KEY);
return slow;
} catch {
return false;
}
}
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { PlaylistStorageBackend } from '../playlist-storage-backend';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { writeServerAccessToken } from '../server-access-token';
import { takeSlowStart } from '../server-slow-start';
import { ServerStorageStatusService } from '../server-storage-status.service';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
import { ServerStorageSession } from './server-storage-session';
@@ -370,6 +371,8 @@ describe('ServerStorageSession', () => {
await session.refresh();
expect(reload).toHaveBeenCalledTimes(1);
// The reloaded page's probe waits as long as this request did.
expect(takeSlowStart()).toBe(true);
});
it('sends no write before the reload checks the instance', async () => {
@@ -19,6 +19,7 @@ import {
createBrowserSessionStorage,
createBrowserUserDataStorage,
} from '../user-data-storage';
import { markSlowStart } from '../server-slow-start';
import { ServerApiClient, type ServerApiError } from './server-api-client';
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
@@ -150,6 +151,8 @@ export class ServerStorageSession {
}
if (!this.reloading) {
this.reloading = true;
// It answers, maybe slower than the start probe waits.
markSlowStart();
this.reloadPage();
}
}