diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index afcc9584c..9c089cf39 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/background-interval.spec.ts b/libs/portal/stalker/data-access/src/lib/background-interval.spec.ts new file mode 100644 index 000000000..da95a2d05 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/background-interval.spec.ts @@ -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); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/background-interval.ts b/libs/portal/stalker/data-access/src/lib/background-interval.ts new file mode 100644 index 000000000..f10f30225 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/background-interval.ts @@ -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 | 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; + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts index 3e94a8708..d4af1f1d0 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts @@ -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; + readPersistedPlaylist: ( + playlistId: string + ) => Promise; logger: ReturnType; } interface WatchdogTimers { timeslotTimeout?: ReturnType; - interval?: ReturnType; + /** 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 }; }