From de7b19aee286058eceb9da1ba6f907333feb5d2e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:03:01 +0200 Subject: [PATCH] docs(performance): record the idle work audit (#1719) --- docs/architecture/idle-work-audit-2026-09.md | 259 +++++++++++++++++++ docs/architecture/performance-journeys.md | 6 + 2 files changed, 265 insertions(+) create mode 100644 docs/architecture/idle-work-audit-2026-09.md 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..9a6123c35 --- /dev/null +++ b/docs/architecture/idle-work-audit-2026-09.md @@ -0,0 +1,259 @@ +# 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. + +**Historical results.** The capture ran on `8ebb7e342` (2026-09-26). The +cinematic rotating dashboard hero (#1725, `887ac64`) landed afterwards, so the +measured counts and the "all dashboard idle work" conclusions below describe +the dashboard without it. The hero is accounted for separately, from code +reading, under +[Added after the measurement](#added-after-the-measurement-rotating-hero). + +## Result in brief + +- **The main process starts no periodic work at idle.** Across four + two-minute windows its probes saw zero timers fired, zero outbound HTTP + requests and zero file writes. With a fresh profile they also saw zero IPC + and zero SQL. With recent live channels, the dashboard's renderer timers + caused 1–2 IPC calls and 2–4 SQL statements per window (see the table + below). Workers were not probed directly: the SQL trace covers the + database worker, and reading the code found worker timers only inside + running operations. 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. Timer, rAF, + IPC, SQL, HTTP, layout and DOM-mutation counts do not depend on dev mode. + The Angular change-detection and template-update counts are dev-build + counts: a production build skips the `checkNoChanges` pass, so it performs + fewer template updates per tick. +- **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. + +Playwright pins every page it attaches to as visible: `Emulation.setFocusEmulationEnabled` +raises the page's capturer count. The minimized windows above were measured +through Playwright. That is faithful for this build, because +`backgroundThrottling: false` keeps the page visible anyway. Measuring a build +where the window can become hidden needs a launch without Playwright over raw +CDP. The follow-up PRs do that. + +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 was a one-off and is deliberately not committed, so the figures +below cannot be rerun from the repository as is. They are observations from +this run, and the method above describes how to rebuild the harness. The +maintained way to measure is the journey harness in +[performance journeys](performance-journeys.md); an idle journey there is the +path to making these counters reproducible and guarded. + +## 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 the dashboard is mounted: `toSignal(interval(...))` subscribes at construction, so it also ticks with no recent sources or with that rail disabled; the rail rebuild only follows when 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** (#1722). Schedule the next badge boundary instead of polling, arm nothing when no badge can change, 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:67](../../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 | Each of the four templates updated 24 times over the 12 ticks: twice per tick, because dev mode adds the `checkNoChanges` pass | **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. | + +### Added after the measurement: rotating hero + +Not in the measured counts above; found by reading the code on master after +#1725. + +| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | +| --- | --- | --- | --- | --- | --- | --- | +| [dashboard-hero.component.scss:558](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss) `hero-dot-fill` and the `(animationend)="onRotationTick()"` at [dashboard-hero.component.html:233](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html) | The active rotation dot animates `width` 0 → 18 px over `HERO_ROTATION_MS` (8 s); its `animationend` advances the slide, which starts the next fill and the backdrop's 8 s `transform` transition | Continuous while the dashboard is visible with two or more hero slides, unless the rotation is paused or reduced motion is on | A layout pass on every animation frame, because `width` is a layout property (not measured) | **Partly.** The rotation is a feature. Animating a layout property for it is not, and it runs on an otherwise idle page. | Static | **Own thread.** Animate the fill with a compositor-only `transform: scaleX()`, keep the `animationend` advance, and decide whether rotation should pause on an idle or hidden dashboard. | +| [dashboard-live-epg.presenter.ts:121](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts) `heroLiveCandidates` (lookup) and :191 (pinned portal keys); limits in [dashboard-hero-slides.utils.ts:14–15](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts) | Up to five hero live candidates (three favourites, two recent) join the 30 s XMLTV "now on air" lookup, and are pinned for the portal live-EPG queue even when their rails are hidden or scrolled away | 30 s XMLTV heartbeat while the hero is enabled and any candidate exists; portal sync on the same tick | More lookup keys per `GET_CURRENT_PROGRAMS_BATCH` IPC and SQL, and portal `get_short_epg`-style requests for pinned Xtream/Stalker candidates when stale (not measured) | **Yes** for the programme shown on the hero; the 30 s re-ask of unchanged programmes is not | Static | Covered by #1722: the clock re-asks only when a programme ended, a key has no answer, the answer is five minutes old, or the guide changed, and it pauses while hidden. | + +### 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. The early-exit leak found here (an unstored start-delay handle leaving an orphaned 5 s poll) is **resolved by #1720**. Still open: in reuse mode the poll keeps querying an idle MPV (no IPC while `time-pos` is null). | Static | Early-exit leak: done (#1720). Reuse mode: needs an idle signal from MPV before the poll can stop; not worth its own thread at two local socket calls per 5 s. | +| [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 | Yes while playing. The early-exit leak (orphaned 2 s poll) is **resolved by #1720**. | Static | Done (#1720). | +| [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-session-controller.ts:197](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts) | Renderer-side bounds poll for a native-view embedded MPV session: compares `getBoundingClientRect()` with the last synced bounds | 500 ms while a native-view session is open (skipped for frame-copy) | One layout read outside the Angular zone; on drift a rAF and a `setEmbeddedMpvBounds` IPC | Yes during playback: a position-only layout shift would otherwise leave the native view misplaced | Static | Cover with the throttling thread: it kept its rate while minimized under `backgroundThrottling: false`. | +| [embedded-mpv-frame-pump.ts:212](../../apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts) (preload) | Frame-copy engine's frame pump: `pumpTick` re-arms itself with `requestAnimationFrame`, checks the shared-memory ring for a new frame, and uploads and draws it when one arrived | Every display frame while a frame-copy session is attached, paused included; stops only on detach. Opt-in (`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY`) | A ring read per frame; a texture upload and draw per new frame | Yes while the video is on screen. A paused or minimized session gains nothing from 60 Hz polling. | Static. Under `backgroundThrottling: false`, rAF kept firing in a minimized window: the E2E fixture's own rAF counter ran at 60 Hz there. | Cover with the throttling thread (rAF stops in a hidden window once throttling is on), and consider stopping the pump while paused. | +| [embedded-mpv-controls.adapter.ts:252](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts) (frame-copy) and [embedded-mpv-player.component.ts:587](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts) (native view) | Elapsed-time heartbeat of an active embedded-MPV recording; the two intervals are mutually exclusive by engine | 1 s while a recording is active | A signal write and a CD tick | Yes while the elapsed time is on screen. A minimized window gains nothing from it. | Static. Under `backgroundThrottling: false` it kept its rate while minimized. | Cover with the throttling thread (throttled while hidden once #1724 lands). | +| [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) | Stream-info sampling | 1 s, only while the stream-info popover is open; `stop()` clears it when the popover closes | CD tick each | Yes (a closed popover costs nothing) | Static | None | +| 30 s EPG clocks in the Xtream, Stalker, EPG guide and unified live views | "Now" clocks for progress and the current programme | 30 s for as long as their route is open | CD tick each | Yes while visible | Static | Cover with the throttling thread (pause on hidden). | +| [empty-state.welcome-dashboard.scss:58](../../libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-dashboard.scss) (`gridMove`, keyframes in `empty-state.component.scss:169`) | First-run dashboard with no playlists: the welcome state's `::before` grid drifts via an infinite `transform` animation | Continuous (60 s loop) while the empty dashboard is shown; the reduced-motion block does not stop the pseudo-element | Compositor and GPU frames only, no main-thread layout (not measured) | **Partly.** Decorative; a first-run screen left open keeps the GPU busy, and reduced motion should stop it. | Static | **Own thread** (small): stop the `::before` animation under `prefers-reduced-motion`, and consider pausing it while hidden (throttling stops compositor frames in a hidden window once #1724 lands). | +| [video-player.component.ts:790](../../libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts) `epgNowMs` | M3U playlist view: refreshes "now" for EPG state, unconditionally, separate from the channel-list timers above | 30 s while an M3U playlist route is open, with or without an active channel | A signal write and an app-wide CD tick | **Partly.** Needed while a programme is shown; not with no channel selected. | Static | Cover with the throttling thread (pause on hidden), and start it only while a channel with EPG is active. | +| [portal-empty-state.component.scss:25](../../libs/portal/shared/ui/src/lib/components/portal-empty-state/portal-empty-state.component.scss) `portal-empty-state-float` | Xtream, Stalker and unified live views with nothing selected or no results: the empty-state icon floats on an infinite `transform` animation | Continuous (2.8 s loop) while the empty state is shown | Compositor and GPU frames only (not measured) | **Partly.** Decorative; an empty portal view left open keeps the GPU busy. | Static | **Own thread** (small, with the welcome grid): stop decorative loops under reduced motion; #1724 stops their frames in a hidden window. | + +Every infinite CSS animation in the renderer styles (`animation: … infinite`) +falls into one of three groups. **Loading indicators** (skeleton and list +shimmers, spinners, the refresh and download spinners, the EPG import spinner) +end with their loading state. **Playback and recording indicators** (live +pulses, the radio artwork and ring, the recording pulse and slide) run only +while something plays or records. **Decorative loops in idle states** are the +welcome grid and the portal empty-state icon listed above; they are the only +ones that keep running on a screen left alone. + +## 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. Timer, rAF, IPC, SQL, + network, layout and DOM-mutation counts carry over; the Angular + change-detection and template-update counts do not (production skips + `checkNoChanges`), and 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.