diff --git a/.changes/electron-hidden-window-throttling.md b/.changes/electron-hidden-window-throttling.md new file mode 100644 index 000000000..fbac80002 --- /dev/null +++ b/.changes/electron-hidden-window-throttling.md @@ -0,0 +1,8 @@ +--- +type: perf +area: electron +--- + +A minimized or hidden desktop window now lets the system slow down background +timers and animations, while playing video and radio keep running. A video +left playing in a minimized window no longer keeps the display awake. diff --git a/apps/electron-backend-e2e/src/electron-unautomated-launch.ts b/apps/electron-backend-e2e/src/electron-unautomated-launch.ts new file mode 100644 index 000000000..6f1d6428c --- /dev/null +++ b/apps/electron-backend-e2e/src/electron-unautomated-launch.ts @@ -0,0 +1,242 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { buildElectronLaunchArgs } from './electron-test-fixtures'; +import { terminateElectronProcess } from './electron-process-termination'; + +/** + * Launches the built app WITHOUT Playwright and drives it over plain Chrome + * DevTools Protocol sockets. + * + * Playwright sends `Emulation.setFocusEmulationEnabled` to every page it + * attaches to, and Chromium implements focus emulation by raising the page's + * capturer count, which pins the page visible. A test about + * `document.visibilityState` (or anything throttled while hidden) therefore + * cannot run through `launchElectronApp`. Raw `Runtime.evaluate` calls do + * not emulate anything, so this launch behaves like a user's. + */ +export interface UnautomatedElectronApp { + /** Evaluates an async function body in the main process with `electron`. */ + evaluateInMain(body: string): Promise; + /** Evaluates an expression in the main window's page. */ + evaluateInPage(expression: string): Promise; + close(): Promise; +} + +interface CdpSocket { + send(method: string, params?: object): Promise>; + close(): void; +} + +const EXIT_WAIT_MS = 5_000; +/** + * `electron` for main-process evaluation. `process.mainModule` exists only + * when the app entry is CommonJS; a require created from the core `module` + * builtin resolves Electron's built-in module either way. + */ +const MAIN_PROCESS_ELECTRON = `process.getBuiltinModule('node:module').createRequire(process.execPath)('electron')`; +const DEVTOOLS_PATTERN = /DevTools listening on (ws:\/\/[^\s]+)/; +const INSPECTOR_PATTERN = /Debugger listening on (ws:\/\/[^\s]+)/; +const STARTUP_TIMEOUT_MS = 30_000; + +async function openCdpSocket(url: string): Promise { + const socket = new WebSocket(url); + const pending = new Map< + number, + (message: Record) => void + >(); + let nextId = 0; + await new Promise((resolve, reject) => { + socket.addEventListener('open', () => resolve(), { once: true }); + socket.addEventListener( + 'error', + () => reject(new Error(`CDP ${url}`)), + { + once: true, + } + ); + }); + socket.addEventListener('message', (event) => { + const message = JSON.parse(String(event.data)) as Record< + string, + unknown + >; + // Only replies to our own numbered requests settle a promise; events + // and unknown ids are ignored. + const id = message['id']; + if (typeof id !== 'number') return; + const settle = pending.get(id); + if (typeof settle !== 'function') return; + pending.delete(id); + settle(message); + }); + return { + send: (method, params = {}) => + new Promise((resolve) => { + const id = ++nextId; + pending.set(id, resolve); + socket.send(JSON.stringify({ id, method, params })); + }), + close: () => socket.close(), + }; +} + +function waitForEndpoints( + child: ChildProcess +): Promise<{ devtools: string; inspector: string }> { + return new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout( + () => reject(new Error(`Electron did not start:\n${output}`)), + STARTUP_TIMEOUT_MS + ); + child.stderr?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + const devtools = output.match(DEVTOOLS_PATTERN)?.[1]; + const inspector = output.match(INSPECTOR_PATTERN)?.[1]; + if (devtools && inspector) { + clearTimeout(timer); + resolve({ devtools, inspector }); + } + }); + child.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`Electron exited (${code}):\n${output}`)); + }); + }); +} + +async function findMainPage(devtools: string): Promise { + const origin = new URL(devtools).host; + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + while (Date.now() < deadline) { + const targets = (await ( + await fetch(`http://${origin}/json/list`) + ).json()) as { + type: string; + url: string; + webSocketDebuggerUrl: string; + }[]; + const page = targets.find( + (target) => + target.type === 'page' && target.url.includes('index.html') + ); + if (page) return page.webSocketDebuggerUrl; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error('The main window never loaded its page'); +} + +async function evaluate(socket: CdpSocket, expression: string): Promise { + const response = await socket.send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }); + const protocolError = response['error'] as { message?: string } | undefined; + if (protocolError) { + throw new Error( + `CDP Runtime.evaluate: ${protocolError.message ?? 'failed'}` + ); + } + const result = response['result'] as { + result?: { value?: T }; + exceptionDetails?: { text?: string }; + }; + if (result?.exceptionDetails) { + throw new Error(result.exceptionDetails.text ?? 'evaluation failed'); + } + return result?.result?.value as T; +} + +function waitForExit( + child: ChildProcess, + exited: Promise, + timeoutMs: number +): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(true); + } + let timer: ReturnType | undefined; + return Promise.race([ + exited.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** + * Bounded like `closeElectronApplicationAndConfirmExit`: a stuck Electron + * must neither stall the worker nor keep holding the test profile. + */ +async function stopElectron( + child: ChildProcess, + exited: Promise +): Promise { + let lastKillError: unknown; + for (const signal of ['SIGTERM', 'SIGKILL'] as const) { + // Already gone (exited on its own, or during startup): nothing to + // kill. On Windows taskkill throws for a PID that no longer exists. + if (await waitForExit(child, exited, 0)) return; + try { + terminateElectronProcess(child, signal); + } catch (error) { + // The process may have exited between the check and the kill. + lastKillError = error; + } + if (await waitForExit(child, exited, EXIT_WAIT_MS)) return; + } + const killFailure = + lastKillError === undefined + ? '' + : ` (last kill: ${String(lastKillError)})`; + throw new Error( + `Electron (pid ${child.pid}) did not exit after SIGTERM and SIGKILL${killFailure}` + ); +} + +export async function launchUnautomatedElectronApp( + dataDir: string +): Promise { + // In a Node context the `electron` package resolves to its binary path. + const electronBinaryPath = require('electron') as unknown as string; + const child = spawn( + electronBinaryPath, + buildElectronLaunchArgs(['--remote-debugging-port=0', '--inspect=0']), + { + env: { + ...process.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + stdio: ['ignore', 'ignore', 'pipe'], + } + ); + const exited = new Promise((resolve) => + child.once('exit', () => resolve()) + ); + try { + const { devtools, inspector } = await waitForEndpoints(child); + const main = await openCdpSocket(inspector); + const page = await openCdpSocket(await findMainPage(devtools)); + return { + evaluateInMain: (body) => + evaluate( + main, + `(async (electron) => { ${body} })(${MAIN_PROCESS_ELECTRON})` + ), + evaluateInPage: (expression) => evaluate(page, expression), + close: async () => { + page.close(); + main.close(); + await stopElectron(child, exited); + }, + }; + } catch (error) { + // Cleanup must never replace the startup failure that explains the test. + await stopElectron(child, exited).catch((cleanupError: unknown) => + console.warn('Unautomated Electron cleanup failed:', cleanupError) + ); + throw error; + } +} diff --git a/apps/electron-backend-e2e/src/window-visibility.e2e.ts b/apps/electron-backend-e2e/src/window-visibility.e2e.ts new file mode 100644 index 000000000..ae323c541 --- /dev/null +++ b/apps/electron-backend-e2e/src/window-visibility.e2e.ts @@ -0,0 +1,147 @@ +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, test } from './electron-test-fixtures'; +import { + launchUnautomatedElectronApp, + type UnautomatedElectronApp, +} from './electron-unautomated-launch'; + +// Linux CI runs Electron under xvfb without a window manager, so a minimize +// request never takes effect there (see window-controls.e2e.ts). +const isWindowManagerlessCi = + process.platform === 'linux' && !!process.env['CI']; + +const videoFixtureUrl = pathToFileURL( + join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm') +).href; + +const visibility = (app: UnautomatedElectronApp) => + app.evaluateInPage('document.visibilityState'); + +const setWindowState = ( + app: UnautomatedElectronApp, + action: 'hide' | 'show' | 'minimize' | 'restore' +) => + app.evaluateInMain( + `electron.BrowserWindow.getAllWindows()[0].${action}();` + ); + +/** + * The app creates its window with `show: false` and shows it on + * `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`. + */ +async function waitUntilShown(app: UnautomatedElectronApp): Promise { + await expect + .poll( + () => + app.evaluateInMain( + 'return electron.BrowserWindow.getAllWindows()[0]?.isVisible() ?? false;' + ), + { timeout: 30_000 } + ) + .toBe(true); + await expect.poll(() => visibility(app)).toBe('visible'); +} + +/** + * The renderer must see a hidden or minimized window as hidden: idle timers + * pause on `visibilitychange`, and the playback keep-awake gate releases the + * display for a minimized window. A main window created with + * `backgroundThrottling: false` keeps reporting `visible` in both cases. + * + * Launched without Playwright: its focus emulation pins every page it + * attaches to as visible (see `electron-unautomated-launch.ts`). + */ +test.describe('Main window visibility', () => { + test('@electron reports a hidden window as hidden and a shown one as visible', async ({ + dataDir, + }) => { + const app = await launchUnautomatedElectronApp(dataDir); + try { + await waitUntilShown(app); + + await setWindowState(app, 'hide'); + await expect + .poll(() => visibility(app), { timeout: 10_000 }) + .toBe('hidden'); + + await setWindowState(app, 'show'); + await expect + .poll(() => visibility(app), { timeout: 10_000 }) + .toBe('visible'); + } finally { + await app.close(); + } + }); + + test('@electron reports a minimized window as hidden until it is restored', async ({ + dataDir, + }) => { + test.skip( + isWindowManagerlessCi, + 'xvfb on Linux CI has no window manager' + ); + const app = await launchUnautomatedElectronApp(dataDir); + try { + await waitUntilShown(app); + + await setWindowState(app, 'minimize'); + await expect + .poll(() => visibility(app), { timeout: 10_000 }) + .toBe('hidden'); + + await setWindowState(app, 'restore'); + await expect + .poll(() => visibility(app), { timeout: 10_000 }) + .toBe('visible'); + } finally { + await app.close(); + } + }); + + test('@electron releases the playback display lock while the window is hidden', async ({ + dataDir, + }) => { + const app = await launchUnautomatedElectronApp(dataDir); + try { + await waitUntilShown(app); + // Record the display-sleep blockers the keep-awake service holds. + await app.evaluateInMain(` + const blocker = electron.powerSaveBlocker; + const active = (globalThis.__e2eDisplayBlockers = new Set()); + const start = blocker.start.bind(blocker); + const stop = blocker.stop.bind(blocker); + blocker.start = (type) => { + const id = start(type); + if (type === 'prevent-display-sleep') active.add(id); + return id; + }; + blocker.stop = (id) => { + active.delete(id); + return stop(id); + }; + `); + const heldBlockers = () => + app.evaluateInMain( + 'return globalThis.__e2eDisplayBlockers.size;' + ); + await app.evaluateInPage(`(() => { + const video = document.createElement('video'); + video.muted = true; + video.loop = true; + video.src = ${JSON.stringify(videoFixtureUrl)}; + document.body.append(video); + return video.play().then(() => true); + })()`); + await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(1); + + await setWindowState(app, 'hide'); + await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(0); + + await setWindowState(app, 'show'); + await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(1); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 029227e2e..7e597144d 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -157,7 +157,7 @@ describe('Electron app security helpers', () => { nodeIntegration: false, sandbox: true, webSecurity: true, - backgroundThrottling: false, + backgroundThrottling: true, }) ); }); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index e275e1e03..09b927e6c 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -126,7 +126,11 @@ export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructor nodeIntegration: false, sandbox: !frameCopyExperiment, webSecurity: true, - backgroundThrottling: false, + // Chromium's default. A hidden or minimized window must report + // `document.hidden` so idle timers can pause and the playback + // keep-awake gate can release the display; Chromium itself keeps + // audible media and picture-in-picture running at full rate. + backgroundThrottling: true, preload: join(__dirname, 'main.preload.js'), }; } diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 060bdcc0c..9826ca097 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -13,6 +13,10 @@ explicit hardened `webPreferences` object: frame-copy experiment is the one path that disables the renderer sandbox (`contextIsolation`/`nodeIntegration` stay hardened regardless) - `webSecurity: true` +- `backgroundThrottling: true` — Chromium's default, kept explicit: a hidden + or minimized window must report `document.hidden` so idle timers pause and + the playback keep-awake gate works (see the player controls contract). + Audible media and picture-in-picture keep running at full rate regardless. - `preload: apps/electron-backend/src/app/api/main.preload.ts` Renderer code must use the preload bridge exposed as `window.electron`. diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 2417b6953..353555151 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -1786,6 +1786,9 @@ capture listeners because media events do not bubble. Release listeners also attach to the tracked element: Chromium's pause after DOM removal never reaches the document. A playing video holds a display-sleep lock only while the document is visible or that video is in picture-in-picture, which survives minimization. +In Electron this relies on the main window keeping Chromium background +throttling on: with `backgroundThrottling: false` a minimized window keeps +reporting `visible`, so the gate would never release the display. Electron uses main-process `powerSaveBlocker` through `window.electron.setPlaybackKeepAwake`. The renderer vote clears on reload, 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/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index f4c594095..5882f2ba2 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -89,6 +89,15 @@ Electron can exit before cleanup begins; Playwright disposes its dispatcher, so calling `electronApp.process()` at that point can throw even after a clean exit. The retained handle still provides the actual exit code and signal. +Playwright sends `Emulation.setFocusEmulationEnabled` to every page it attaches +to, and Chromium implements focus emulation by raising the page's capturer +count. A window launched through `launchElectronApp` therefore always reports +`document.visibilityState === 'visible'` and is never background-throttled, +even when hidden or minimized. Tests of hidden-window behavior launch through +`apps/electron-backend-e2e/src/electron-unautomated-launch.ts`, which spawns +the app without Playwright and evaluates over raw CDP sockets +(`window-visibility.e2e.ts` is the example). + The Linux portable build uploads `packaged-frame-copy-smoke` reports and traces even when the smoke fails. Check the paused-frame screenshot and trace before classifying a zero rendered-frame signal as an infrastructure flake. A zero 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..dc05d6505 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/background-interval.spec.ts @@ -0,0 +1,116 @@ +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); + }); + + it('stays stopped when a worker error arrives after stop', () => { + scope.Worker = FakeWorker; + const callback = jest.fn(); + + const stop = createBackgroundInterval(callback, 30_000); + const [worker] = FakeWorker.instances; + const lateError = worker.onerror; + stop(); + lateError?.(new Event('error')); + + jest.advanceTimersByTime(90_000); + expect(callback).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); +}); 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..c51950eec --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/background-interval.ts @@ -0,0 +1,77 @@ +/** + * 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; + let stopped = false; + const startFallback = () => { + // A worker error that arrives after stop() must not revive the tick. + if (stopped) return; + 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 () => { + stopped = true; + if (ticker) { + ticker.worker.onmessage = null; + ticker.worker.onerror = null; + } + 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 }; }