diff --git a/docs/architecture/idle-work-audit-2026-09.md b/docs/architecture/idle-work-audit-2026-09.md new file mode 100644 index 000000000..5201b9920 --- /dev/null +++ b/docs/architecture/idle-work-audit-2026-09.md @@ -0,0 +1,206 @@ +# Idle work audit (2026-09) + +One-off investigation for plan item D3 ("hidden work audit") of the +performance journeys plan. The question: what does IPTVnator do while the +user does nothing? This document records the measurement and the findings. +Nothing was changed. Every row marked **own thread** is a candidate for a +separate, benchmarked follow-up under the +[performance journeys](performance-journeys.md) process. + +## Result in brief + +- **The main process and its workers do no periodic work at idle.** Across + four two-minute windows the probes saw zero IPC handled, zero timers fired, + zero outbound HTTP requests, zero SQL statements and zero file writes. The + EPG refresh, download manager, source health probe, auto-updater, + remote-control server and connectivity guard are all demand-driven or off + by default. +- **All idle work is in the renderer, on the dashboard.** With a fresh + profile, two RxJS intervals (30 s and 60 s) cause 12 app-wide Angular + change-detection passes per two minutes. With recently watched live + channels, a third interval adds an IPC and SQL lookup about every + minute. It also triggers a 24-frame, full-document layout animation every + 30 s. +- **A minimized window does exactly the same work as a visible one.** The + main window sets `backgroundThrottling: false` + ([app.ts:125](../../apps/electron-backend/src/app/app.ts)). Chromium + therefore neither throttles timers nor reports the page as hidden. No + renderer code can pause itself when the window is minimized, because + `document.visibilityState` stays `visible`. + +The three worst offenders, in order: + +1. `backgroundThrottling: false` on the main window: every renderer timer, + rAF and CSS transition keeps running when minimized, and the page + visibility API is blind to minimization. +2. The dashboard live-EPG 30 s heartbeat with recent live cards: an IPC and + SQL batch about every minute, rebuilt rails, and a 0.4 s `width` + transition on progress bars. Each tick costs about 24 full-document + layouts plus GPU frames. This state has 17–76× the GPU time of the fresh + profile. +3. The always-on 30 s portal live-EPG heartbeat plus the 60 s source-expiry + tick: 12 app-wide change-detection passes per two minutes, even when there + is nothing to update. The 60 s tick also rebuilds the sources rail, which + schedules a scroll reset and re-observes every card. + +## Method + +- **Build.** `pnpm nx run electron-backend:build-e2e` on `8ebb7e342`. It + is unoptimized, with source maps and Angular dev mode. Dev mode runs every + change detection twice (the `checkNoChanges` pass), so per-firing + renderer costs below are upper bounds for a production build. Counts are + exact either way. +- **Servers.** The Xtream mock ran on loopback port 3411 with its control + plane enabled. A second E2E worktree already held 3310. A throwaway loopback + HTTP server served a 60-channel M3U with `url-tvg` and logo URLs, and it + logged every request. +- **Profile.** A fresh data directory seeded through the E2E fixture + helpers: one M3U URL source and one Xtream source (mock `user1`). The + "recent live" variant also opened two M3U and two Xtream live channels + during seeding. +- **Measurement launch.** The app was relaunched against the seeded profile + with `IPTVNATOR_TRACE_STARTUP=1`, `IPTVNATOR_TRACE_IPC=1` and + `--remote-debugging-port=9422`, because port 9222 was held by another + Electron. The app landed on `/workspace/dashboard`. After a 20 s settle + window came 120 s visible and focused, then 120 s minimized. The measured + state was `isMinimized() === true` and `isVisible() === false`. +- **Main-process probe.** A bootstrap entry point, the same pattern as + `playlist-refresh-write-gate.ts`, wrapped these before requiring + `dist/apps/electron-backend/main.js`: + - `ipcMain.handle/on` + - `webContents.send/postMessage` + - global `setTimeout/setInterval/setImmediate` + - `http(s).request/get`, `fetch`, `net.request/fetch` + - `fs` write and rename calls + - `Worker.postMessage` + + SQL came from the existing trace channel on stdout. +- **Renderer probe.** Injected before any page script with + `Page.addScriptToEvaluateOnNewDocument`. It wrapped timers, rAF, + IndexedDB writes, `localStorage`/`sessionStorage`, fetch and XHR. It also + counted `Zone.prototype.runTask` per zone and source, and change-detection + ticks through `ng.ɵsetProfiler` (event `ChangeDetectionStart`). DOM + mutations came from a document-wide `MutationObserver`. Separately, a CDP + `Tracing` profile and `Performance.getMetrics` covered each window, and + `app.getAppMetrics()` gave per-process CPU. +- **Two passes.** An instrumented pass collected traces and a 1 kHz + main-process CPU profile. A light pass left the profiler, tracing and + network capture off, so its per-process CPU numbers are not inflated by the + instruments. The instrumented pass had browser-process CPU of about 2.5 s + per window and 550 wakeups/s, almost all of it the sampling profiler. + +Harness artifacts that were excluded: + +- The E2E fixture's own 60 Hz rAF frame counter (`startRendererFrameCapture`), + which was cancelled after launch. +- An extra `about:blank` navigation before the app URL, used to register + the renderer probe. +- The probe's own stdout tee. +- Playwright `evaluate` round-trips at window boundaries. + +The harness scripts stayed in the session scratchpad and are not committed. +The method above is enough to rebuild them. + +## Measurements + +Counts per 120 s window. CPU is the light pass, taken from +`getAppMetrics().cpu.cumulativeCPUUsage` deltas. + +| Counter | Fresh, visible | Fresh, minimized | Recent live, visible | Recent live, minimized | +| --- | --- | --- | --- | --- | +| Renderer timer firings | 6 | 6 | 10 | 10 | +| Renderer rAF callbacks | 4 | 4 | 12 | 12 | +| Angular CD ticks | 12 | 12 | 29 | 30 | +| DOM mutation records | 0 | 0 | 12 | 12 | +| Layouts (`LayoutCount`) | 0 | 0 | 139 | 129 | +| IndexedDB / Storage writes | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | +| Renderer fetch/XHR, CDP network | 0 | 0 | 0 | 0 | +| IPC renderer→main (excluding trace echo) | 0 | 0 | 1 | 2 | +| IPC main→renderer | 0 | 0 | 0 | 0 | +| SQL statements (main) | 0 | 0 | 2 | 4 | +| Main timers fired / HTTP / fs writes | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | +| Renderer process CPU | 158 ms¹ | 11 ms | 308 ms | 463 ms | +| GPU process CPU | 19 ms | 7 ms | 332 ms | 530 ms | +| Browser (main) process CPU | 214 ms¹ | 67 ms | 180 ms | 281 ms | + +¹ The visible window came first, so it also absorbed a one-time post-startup +V8 incremental major GC and Playwright's evaluate calls. The trace shows 798 +`V8.GC_MC_INCREMENTAL` steps in the visible window and none in the minimized +one. Steady-state renderer cost for the fresh profile is closer to the +minimized figure. + +Per-firing costs, taken from the renderer trace (dev build): + +- Each 30 s or 60 s `TimerFire` is 1.6–2.2 ms, with rare outliers of 11–13 ms. +- The rail scroll-reset rAF pair is 0.4–1.5 ms. +- In the recent-live state, each 30 s tick adds about 24 consecutive frames, + each with a full-document `Layout` (30 dirty of 541 objects), `HitTest`, + `UpdateLayer` and two `IntersectionObserver` computations. That is about + 0.8–2 ms of renderer main-thread time per frame, or about 25–40 ms per tick. + The GPU adds 45–130 ms per tick. + +## Findings + +"Evidence" says whether the row was measured at idle or found by reading the +code. Costs are for the dev build unless noted. + +### Periodic work observed at idle + +| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | +| --- | --- | --- | --- | --- | --- | --- | +| [app.ts:125](../../apps/electron-backend/src/app/app.ts) `backgroundThrottling: false` | Disables Chromium background throttling and page-visibility changes for the main window. It was added without a stated reason in #1123. | Continuous (amplifier) | Makes every renderer row below cost the same while minimized. Minimized windows still ran 6–10 timers, 4–12 rAF and 129 layouts per 2 min. | **No.** Nothing on the idle path needs full-rate timers while minimized, and it defeats the keep-awake visibility gate (see the next section). | Measured: `isMinimized()` is true while `visibilityState` stays `visible` | **Own thread.** Find the playback or radio case that needed it, then enable throttling or toggle it per active playback through `webContents.setBackgroundThrottling`. Add a minimized idle counter to the journeys. | +| [dashboard-portal-live-epg.presenter.ts:52](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts) `interval(LIVE_EPG_TICK_MS)` feeding the effect at :86 | Heartbeat that calls `DashboardPortalLiveEpgService.sync(wanted)` for Xtream and Stalker live cards | 30 s, always on while the dashboard is mounted | One app-wide CD tick, about 2 ms. With no portal live cards `wanted` is empty, so there is no IPC. | **No** when `wanted` is empty. The tick has nothing to do but still runs a full zone CD pass. | Measured: 4 per 2 min in every state | **Own thread.** Run the interval only while `wanted` is non-empty, outside the Angular zone, and pause on hidden. | +| [workspace-dashboard-rails.component.ts:345](../../libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts) `interval(SOURCE_EXPIRY_TICK_MS)`, read at :753 | Minute heartbeat for source-expiry badges. It recomputes `sourceCards`, which returns a new array. | 60 s, whenever recent sources exist | One CD tick of about 2 ms. The new `items` input also fires the rail effects: two chained rAF CD ticks and a `scrollTo(0)` from `scheduleResetToStart` ([dashboard-rail.component.ts:176–187, :317](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts)), and an IntersectionObserver re-observe of every card. | **Partly.** Badges must cross day boundaries, but a minute tick for day-granular badges is excessive. Returning a new array each time also resets a user-scrolled rail. | Measured: 2 timer and 4 rAF per 2 min | **Own thread.** Schedule the next badge boundary instead of polling, give `sourceCards` a structural `equal`, and reset the rail scroll only when card identity changes. | +| [dashboard-live-epg.presenter.ts:116–132](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts) `interval(LIVE_EPG_TICK_MS)` with `forkJoin(askScope…)` | "Now on air" lookup for hero, live-favourite and recent-live cards | 30 s, only when live cards exist | `GET_CURRENT_PROGRAMS_BATCH` IPC about every second tick, because of the 60 s program cache, running 2 SQL `SELECT`s. Every emission is a new `Map`, so the live rails rebuild. That adds rAF scroll resets, IO re-observe, and new progress widths (next row). | **Partly.** Progress and "now" titles are a feature. Rebuilding the rails when the programme did not change is not. | Measured: 1–2 IPC and 2–4 SQL per 2 min | **Own thread.** Emit only on programme change, tick on the next programme boundary instead of every 30 s, and pause on hidden. | +| [dashboard-rail.component.scss:256–271](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss) `.rail__art-progress i { transition: width 0.4s ease }` | Animates the live-programme progress bar each time its width binding changes | Every 30 s tick with live cards | About 24 full-document layouts and paints over 0.4 s: about 25–40 ms renderer and 45–130 ms GPU per tick. Twelve `` attribute mutations per 2 min. | **No.** A sub-pixel progress change does not need a layout-driven animation, and it runs while minimized. | Measured: trace shows one rAF, then 24 frames of Layout, HitTest and IO every 30 s | **Own thread.** Animate `transform: scaleX()` (compositor-only), or drop the transition for tick updates. | +| Eager components on every tick: [app.component.ts:54](../../apps/web/src/app/app.component.ts), [workspace-shell.component.ts:52](../../libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts), [epg-progress-panel.component.ts:35](../../libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts), [app-update-notification-panel.component.ts:106](../../apps/web/src/app/app-update-notification-panel.component.ts) | `ChangeDetectionStrategy.Eager` roots re-render their templates on every zone tick | Every tick above | These four templates are checked on each tick (24 template updates per 12 ticks in dev mode) | **No** for idle. Nothing in them changes on a timer. | Measured through the `TemplateUpdateStart` profiler events | Fold into plan item C6 (OnPush/zoneless). No separate thread. | + +### Checked and not periodic at idle + +| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | +| --- | --- | --- | --- | --- | --- | --- | +| EPG refresh ([epg.events.ts:176–178](../../apps/electron-backend/src/app/events/epg.events.ts)) | There is no scheduler. `FETCH_EPG` and `EPG_CHECK_FRESHNESS` (default `maxAgeHours` 12) run only when the renderer asks. | None | One `FETCH_EPG` at boot with no configured EPG URL. The M3U `url-tvg` was never requested. | Yes | Measured: 0 at idle | None | +| Download manager ([download-transfer.ts:289](../../apps/electron-backend/src/app/events/database/download-transfer.ts), [download-reconnect.ts:104](../../apps/electron-backend/src/app/events/database/download-reconnect.ts)) | Writes progress at most every 500 ms from stream `data` events, and has a 1 s reconnect sleep. It runs only while bytes flow or a reconnect is pending. | Event-driven | One `UPDATE downloads` plus a `DOWNLOADS_UPDATE_EVENT` per progress write during a transfer | Yes (real transfers) | Static; `DOWNLOADS_GET_LIST` once at boot | None | +| Source health probe ([source-health.service.ts:111–114](../../libs/portal/shared/data-access/src/lib/source-health.service.ts), #1592) | On-mount and manual checks. TTL 60 s, or 15 s when uncertain. The documented contract is demand-driven with no polling ([m3u-playlist-module.md](m3u-playlist-module.md#desktop-source-health)). | None | One Xtream `player_api.php` at boot (dashboard expiry check). No M3U probe on the dashboard. | Yes | Measured: 0 at idle | None | +| Auto-updater ([app-update.service.ts:291](../../apps/electron-backend/src/app/services/app-update.service.ts)) | One check at startup, packaged builds only, then only on request | None | — | Yes | Static; `APP_UPDATE:GET_STATUS` once at boot | None | +| Remote-control server ([http-server.ts:126](../../apps/electron-backend/src/app/server/http-server.ts)) | Listens only when the `remoteControl` setting is on (default off). The 2 s poll lives in the phone client and is served from memory. | None by default | — | Yes | Static; off in the profile | None | +| Connectivity guard ([host-connectivity-guard.ts:43](../../libs/shared/host-health/src/lib/host-connectivity-guard.ts)) | Compares against the clock lazily per request. The contract says no heartbeat ([host-connectivity-guard.md](host-connectivity-guard.md)). | None | — | Yes | Measured: 0 at idle | None | +| Playlist auto-refresh ([app.component.ts:261–294](../../apps/web/src/app/app.component.ts)) | One pass at startup for playlists with `autoRefresh` (default false) | Once | — | Yes | Static | None | +| PWA service worker ([app.config.ts:126–129](../../apps/web/src/app/app.config.ts), `ngsw-config.json`) | `registerWhenStable:30000`. It has asset groups only: no `dataGroups`, no periodic sync, and no `checkForUpdate` polling (`PwaService.checkUpdates` is never called). Disabled in Electron. | None | — | Yes | Static only; no PWA runtime capture was run | None | + +### Conditional periodic work (not active on idle `/workspace`) + +Found by reading the code. They run only on the listed route or state, so a +user who leaves that screen open pays them indefinitely, minimized included. + +| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | +| --- | --- | --- | --- | --- | --- | --- | +| [mpv-session.service.ts:140–141](../../apps/electron-backend/src/app/events/mpv-session.service.ts) | External MPV position poll | 2 s delay, then 5 s | Two IPC-socket round-trips and one `playback-position-update` | Yes while playing. **No** after exit: the initial `setTimeout` handle is never stored, so `stopPositionPolling()` cannot cancel it. An exit within 2 s leaves an orphaned 5 s poll. Reuse mode keeps polling an idle MPV. | Static | **Own thread.** Store and clear the delay handle, and stop the poll when `time-pos` is null. | +| [vlc-session.service.ts:81–82](../../apps/electron-backend/src/app/events/vlc-session.service.ts) | External VLC position poll | 1.5 s delay, then 2 s | Up to three localhost TCP connections, then IPC | Same leak as MPV (orphaned 2 s poll after an early exit) | Static | **Own thread**, with the MPV row. | +| [embedded-mpv-native.service.ts:1068](../../apps/electron-backend/src/app/services/embedded-mpv-native.service.ts) | Embedded MPV session snapshot poll | 500 ms while a session exists | Native snapshot, diff, and IPC only on change | Yes during playback. It is cleared when the last session closes. | Static | None | +| [embedded-mpv-reconnect.ts:296](../../apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts) | Reconnect backoff | 2 s to 30 s, at most 6 attempts | Native reload | Yes | Static | None | +| [channel-list-container.component.ts:426, :431](../../libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts) | M3U list: re-queries current programmes and metadata for **all** channels in the list, plus a progress tick | 60 s / 30 s while an M3U list is mounted | IPC and SQL that grow with channel count (not measured) | **Partly.** Only visible rows need refreshing. | Static | **Own thread.** Measure on a 10k-channel list, then limit to the viewport and pause on hidden. | +| [epg-refresh-coordinator.service.ts:82](../../libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.ts) | Xtream live: refreshes stale visible or tracked EPG entries | 60 s while live lists are registered | Xtream HTTP through main for stale entries only | Yes (already scoped) | Static | Pause on hidden once throttling allows it. | +| [stalker-watchdog.controller.ts:188](../../libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts) | Stalker `get_events` keep-alive | Portal `watchdog_timeout` (default 120 s) | A playlist row read and one portal HTTP request | Yes (portal protocol) | Static | None | +| [downloads.component.ts:223](../../libs/portal/downloads/feature/src/lib/downloads.component.ts), [recording-queue.component.ts:88](../../libs/portal/downloads/feature/src/lib/recording-queue.component.ts) | Recordings reload and elapsed-time tick | 15 s / 1 s while recordings are active and the page is open | `loadRecordings()` IPC and SQL / CD tick | **Partly.** Downloads are already push-based, so recordings could be too. | Static | **Own thread.** Push recording state like `onDownloadsUpdate`. | +| [controls-stream-stats.ts:42](../../libs/ui/playback/src/lib/player-controls/controls-stream-stats.ts) and 30 s EPG clocks in the Xtream, Stalker, EPG guide and unified live views | Stats sampling and "now" clocks | 1 s / 30 s on their routes | CD tick each | Yes while visible | Static | Cover with the throttling thread (pause on hidden). | + +## Related observation + +[playback-keep-awake.service.ts](../../apps/web/src/app/services/playback-keep-awake.service.ts) +documents that "Visibility gates the lock in both modes: a minimized window +streaming audio in the background should not pin the display on". Under +`backgroundThrottling: false`, a minimized Electron window keeps +`visibilityState === 'visible'` and never fires `visibilitychange`, as +measured in this audit. So the Electron `powerSaveBlocker` likely stays held +for a minimized window playing video. Playback itself was not measured. Verify +this in the same thread as the first worst offender. + +## Not covered + +- Production (optimized, non-dev-mode) renderer costs. Counts carry over, but + per-firing milliseconds are upper bounds. +- Windows and Linux. Throttling and occlusion behavior differ per platform. +- Idle during playback, and on routes other than the dashboard. The + conditional table above is from code reading only. +- A PWA runtime capture of the service worker. diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 5c79decab..a6d59b474 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -481,3 +481,9 @@ reports slow imports of non-Latin playlists. `node:test` (`pnpm nx run electron-backend-e2e:test-performance-harness`). 6. Validate a counter before it becomes a guardrail: one PR must show that lowering it moved wall-clock in the same journey. + +## Idle work + +The [idle work audit](idle-work-audit-2026-09.md) records what the app does +while the user does nothing, measured on the dashboard with the window visible +and minimized. Its **own thread** rows are candidate performance threads.