fix(stalker): keep the watchdog cadence while the window is hidden

With background throttling on, Chromium wakes a hidden, silent page's
timers at most once per minute after five minutes, so a portal that asks
for get_events every 30 s would see pings at half its cadence while the
window is minimized. Tick the watchdog from a dedicated worker
(createBackgroundInterval, an inline blob worker allowed by the renderer
CSP), whose timers are not subject to page throttling. It falls back to a
page setInterval where no worker is available or the worker fails to load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 20:48:57 +02:00
1 parent 4febe1d420
commit 3193d0e8d1
4 files changed
+197 -7

No files matched your search

+7
View File
@@ -840,6 +840,13 @@ the documented 120 s default. Failing to ping never invalidates the session —
it only affects the portal's admin-panel "online" reporting — so ping failures
are logged and never retried or escalated.
The periodic ping ticks in a dedicated worker (`createBackgroundInterval`,
an inline blob worker allowed by the renderer CSP's `worker-src 'self' blob:`).
After five minutes hidden and silent, Chromium wakes page timers at most once
per minute, which would halve a 30 s cadence while the window is minimized;
worker timers are not subject to that page throttling. Where no worker can
start, the controller falls back to a page `setInterval`.
## Request Transport and `cmd` Encoding
Requests to an unreachable portal are short-circuited by the main process' host
@@ -0,0 +1,101 @@
import { createBackgroundInterval } from './background-interval';
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readonly posted: unknown[] = [];
terminated = false;
constructor(readonly url: string) {
FakeWorker.instances.push(this);
}
postMessage(message: unknown): void {
this.posted.push(message);
}
terminate(): void {
this.terminated = true;
}
tick(): void {
this.onmessage?.({ data: 0 } as MessageEvent);
}
}
describe('createBackgroundInterval', () => {
const scope = globalThis as unknown as {
Worker?: unknown;
URL: typeof URL & {
createObjectURL?: (blob: Blob) => string;
revokeObjectURL?: (url: string) => void;
};
};
const originalWorker = scope.Worker;
const originalCreate = scope.URL.createObjectURL;
const originalRevoke = scope.URL.revokeObjectURL;
beforeEach(() => {
jest.useFakeTimers();
FakeWorker.instances = [];
scope.URL.createObjectURL = jest.fn(() => 'blob:ticker');
scope.URL.revokeObjectURL = jest.fn();
});
afterEach(() => {
jest.useRealTimers();
scope.Worker = originalWorker;
scope.URL.createObjectURL = originalCreate;
scope.URL.revokeObjectURL = originalRevoke;
});
it('falls back to a page interval where Worker is unavailable', () => {
delete scope.Worker;
const callback = jest.fn();
const stop = createBackgroundInterval(callback, 30_000);
jest.advanceTimersByTime(60_000);
expect(callback).toHaveBeenCalledTimes(2);
stop();
jest.advanceTimersByTime(60_000);
expect(callback).toHaveBeenCalledTimes(2);
});
it('ticks from a worker and schedules no page timer', () => {
scope.Worker = FakeWorker;
const callback = jest.fn();
const stop = createBackgroundInterval(callback, 30_000);
const [worker] = FakeWorker.instances;
expect(worker.url).toBe('blob:ticker');
expect(worker.posted).toEqual([30_000]);
expect(jest.getTimerCount()).toBe(0);
worker.tick();
worker.tick();
expect(callback).toHaveBeenCalledTimes(2);
stop();
expect(worker.terminated).toBe(true);
expect(scope.URL.revokeObjectURL).toHaveBeenCalledWith('blob:ticker');
});
it('falls back to a page interval when the worker fails to start', () => {
scope.Worker = FakeWorker;
const callback = jest.fn();
const stop = createBackgroundInterval(callback, 30_000);
const [worker] = FakeWorker.instances;
worker.onerror?.(new Event('error'));
expect(worker.terminated).toBe(true);
jest.advanceTimersByTime(30_000);
expect(callback).toHaveBeenCalledTimes(1);
stop();
jest.advanceTimersByTime(60_000);
expect(callback).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,69 @@
/**
* A repeating timer that keeps its cadence while the page is hidden.
*
* Chromium throttles timers on a hidden page's main thread, and after five
* minutes hidden and silent it wakes them at most once per minute. Timers in
* a dedicated worker are not subject to that page throttling, so the tick
* runs there and only the callback is posted back. Without `Worker` (tests,
* or a worker that cannot start) this falls back to a plain `setInterval`.
*
* The worker is an inline blob, allowed by the renderer CSP
* (`worker-src 'self' blob:`), so no bundler entry is needed.
*/
const TICKER_SOURCE = `
let timer;
onmessage = (event) => {
clearInterval(timer);
if (event.data > 0) timer = setInterval(() => postMessage(0), event.data);
};
`;
/** Starts calling `callback` every `periodMs`; returns the stop function. */
export function createBackgroundInterval(
callback: () => void,
periodMs: number
): () => void {
let fallback: ReturnType<typeof setInterval> | undefined;
const startFallback = () => {
fallback ??= setInterval(callback, periodMs);
};
const ticker = startTickerWorker();
const stopWorker = () => {
ticker?.worker.terminate();
if (ticker) URL.revokeObjectURL(ticker.url);
};
if (ticker) {
ticker.worker.onmessage = () => callback();
// A worker that cannot load its script (a stricter CSP, say) fails
// asynchronously; never leave the caller without a tick.
ticker.worker.onerror = () => {
stopWorker();
startFallback();
};
ticker.worker.postMessage(periodMs);
} else {
startFallback();
}
return () => {
stopWorker();
if (fallback !== undefined) clearInterval(fallback);
};
}
function startTickerWorker(): { worker: Worker; url: string } | null {
if (
typeof Worker === 'undefined' ||
typeof URL?.createObjectURL !== 'function'
) {
return null;
}
const url = URL.createObjectURL(
new Blob([TICKER_SOURCE], { type: 'text/javascript' })
);
try {
return { worker: new Worker(url), url };
} catch {
URL.revokeObjectURL(url);
return null;
}
}
@@ -3,6 +3,7 @@ import {
type Playlist,
} from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
import { createBackgroundInterval } from './background-interval';
/**
* The portal expects `get_events` every `watchdog_timeout` seconds — 120 by
@@ -30,13 +31,16 @@ export interface StalkerWatchdogDeps {
* Reads the persisted row for a playlist. The row is the source of truth
* for the configuration a ping authenticates as; see `resolvePlaylist`.
*/
readPersistedPlaylist: (playlistId: string) => Promise<Playlist | undefined>;
readPersistedPlaylist: (
playlistId: string
) => Promise<Playlist | undefined>;
logger: ReturnType<typeof createLogger>;
}
interface WatchdogTimers {
timeslotTimeout?: ReturnType<typeof setTimeout>;
interval?: ReturnType<typeof setInterval>;
/** Stops the periodic ping (see `createBackgroundInterval`). */
stopInterval?: () => void;
}
/**
@@ -98,7 +102,9 @@ export class StalkerWatchdogController {
* while persistence is pending (or failed), and pinging that would stop
* or misdirect the keepalive.
*/
registerPlaylistDecorator(decorator: (playlist: Playlist) => Playlist): void {
registerPlaylistDecorator(
decorator: (playlist: Playlist) => Playlist
): void {
this.playlistDecorator = decorator;
}
@@ -158,8 +164,8 @@ export class StalkerWatchdogController {
if (timers.timeslotTimeout) {
clearTimeout(timers.timeslotTimeout);
}
if (timers.interval) {
clearInterval(timers.interval);
if (timers.stopInterval) {
timers.stopInterval();
}
}
@@ -185,7 +191,10 @@ export class StalkerWatchdogController {
return;
}
current.timeslotTimeout = undefined;
current.interval = setInterval(() => {
// A worker-driven tick: a portal whose watchdog_timeout is below
// Chromium's one-minute wake-up limit for hidden pages must still
// see get_events on time while the window is minimized.
current.stopInterval = createBackgroundInterval(() => {
void this.sendPing(playlistId, '0');
}, periodSeconds * 1000);
};
@@ -282,7 +291,11 @@ function normalizeTiming(timing: {
MIN_PERIOD_SECONDS,
MAX_PERIOD_SECONDS
);
const timeslot = clamp(Math.floor(timing.timeslotSeconds ?? 0), 0, period - 1);
const timeslot = clamp(
Math.floor(timing.timeslotSeconds ?? 0),
0,
period - 1
);
return { periodSeconds: period, timeslotSeconds: timeslot };
}