From 78a6648c8d7f86df4af49f870b74817a0b3dbe05 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:17:30 +0200 Subject: [PATCH] perf(electron): let hidden and minimized windows report themselves hidden (#1724) * perf(electron): let hidden and minimized windows report themselves hidden The main window was created with backgroundThrottling: false (since #1123, without a stated reason). Electron then keeps document.visibilityState at "visible" for a hidden, minimized or fully covered window and never lets Chromium throttle it, so every renderer timer, rAF and CSS transition ran at full rate in the background, and the playback keep-awake gate, which releases the display for a minimized window, could never see one. Use Chromium's default. Audible media and picture-in-picture are exempt from background throttling in Chromium, and a local check confirmed HLS playback continues unchanged through more than six minutes minimized, audible and muted. Playwright's focus emulation pins every page it attaches to as visible, so the new window-visibility E2E launches the app without Playwright and drives it over raw CDP (electron-unautomated-launch.ts). Co-Authored-By: Claude Opus 5.5 * test(e2e): harden the unautomated Electron launch Review follow-ups for the window-visibility E2E: - Resolve `electron` in the main process through a require created from the `node:module` builtin instead of `process.mainModule`, which only exists when the app entry is CommonJS. - Bound teardown like closeElectronApplicationAndConfirmExit: SIGTERM, then SIGKILL, 5 s each, then fail instead of waiting forever. - Surface CDP protocol errors from Runtime.evaluate instead of returning undefined. - Wait until the window is actually shown before hiding it. The app shows its window on ready-to-show, and a hide() that lands earlier is undone by that show(); this was the first-attempt failure on the macOS shard. Co-Authored-By: Claude Opus 5.5 * test(e2e): check the playback display lock is released while hidden Review follow-ups for #1724: - Add an E2E that plays the webm fixture in the unautomated launch, records the main process's prevent-display-sleep blockers, and asserts the keep-awake lock is taken while visible, released when the window is hidden, and taken again when it is shown. The visibility tests alone would still pass if the renderer gate or the bridge stopped updating powerSaveBlocker. - Validate CDP replies before dispatch (CodeQL js/unvalidated-dynamic-method-call): only a numeric id with a pending settle function is called. Co-Authored-By: Claude Opus 5.5 * test(e2e): skip killing an Electron that already exited stopElectron now checks the recorded exit state before each signal and tolerates a kill that races the exit: on Windows taskkill throws for a PID that no longer exists. Startup cleanup can no longer replace the startup error that explains the failure; a cleanup failure there is logged. Co-Authored-By: Claude Opus 5.5 * 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 * fix(stalker): keep a stopped watchdog interval stopped A worker error that arrived after stop() started the page fallback interval, which nothing cleared, so pings continued for an inactive playlist. stop() now marks the interval stopped, detaches the worker handlers, and the fallback refuses to start afterwards. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .changes/electron-hidden-window-throttling.md | 8 + .../src/electron-unautomated-launch.ts | 242 ++++++++++++++++++ .../src/window-visibility.e2e.ts | 147 +++++++++++ apps/electron-backend/src/app/app.spec.ts | 2 +- apps/electron-backend/src/app/app.ts | 6 +- docs/architecture/electron-security.md | 4 + docs/architecture/player-controls-contract.md | 3 + docs/architecture/stalker-portal.md | 7 + docs/development/electron-debugging.md | 9 + .../src/lib/background-interval.spec.ts | 116 +++++++++ .../src/lib/background-interval.ts | 77 ++++++ .../src/lib/stalker-watchdog.controller.ts | 27 +- 12 files changed, 639 insertions(+), 9 deletions(-) create mode 100644 .changes/electron-hidden-window-throttling.md create mode 100644 apps/electron-backend-e2e/src/electron-unautomated-launch.ts create mode 100644 apps/electron-backend-e2e/src/window-visibility.e2e.ts create mode 100644 libs/portal/stalker/data-access/src/lib/background-interval.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/background-interval.ts 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 }; }