mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
4febe1d420
commit
3193d0e8d1
4 files changed
+197
-7
No files matched your search
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user