mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
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.
|
||||
Reference in new issue
Block a user