docs(performance): record the idle work audit

Plan item D3: what the app does while the user does nothing, measured on
the dashboard for two minutes visible and two minutes minimized with a
fresh profile and with recent live channels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-28 21:41:58 +02:00
1 parent af44e2368b
commit c4de74c8c0
2 files changed
+212

No files matched your search

@@ -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 `<i>` 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.
@@ -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.