diff --git a/.changes/README.md b/.changes/README.md index a2a8454b6..e41282f03 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -69,9 +69,20 @@ node tools/release/build-release-notes.mjs --consume ``` The release version comes from the root `package.json` — bump it first, then -generate. `--version 0.24.0` overrides it for a dry run before the bump. +generate. `--version 0.24.0` overrides it to preview a release before the bump: -Only `--consume` deletes anything; every other mode is a safe dry run. +```bash +pnpm run release:notes:github --version 0.24.0 +``` + +A bare `--` separator is accepted and ignored, so the npm habit of +`pnpm run release:notes:github -- --version 0.24.0` works too: pnpm forwards +that separator to the script rather than consuming it the way npm does. + +`--validate` and `--format github` only read and print. `--format changelog` +and `--format blog` write their target file (rerunning `changelog` for the same +version replaces that section rather than duplicating it). Only `--consume` +deletes anything. The release sequence is: bump the version → `release:notes:changelog` → `release:notes:blog` → `--consume` → commit → tag → push. The tag build then diff --git a/.changes/backup-hidden-categories.md b/.changes/backup-hidden-categories.md new file mode 100644 index 000000000..064f99137 --- /dev/null +++ b/.changes/backup-hidden-categories.md @@ -0,0 +1,9 @@ +--- +type: fix +area: backup +issues: [1017] +--- + +Backups carry hidden Xtream categories correctly. An export used to lose which +categories you had hidden, and restoring such a backup then hid every category +of that kind. diff --git a/.changes/deps-npm-minor-patch-group.md b/.changes/deps-npm-minor-patch-group.md new file mode 100644 index 000000000..1e2eb2a84 --- /dev/null +++ b/.changes/deps-npm-minor-patch-group.md @@ -0,0 +1,9 @@ +--- +type: internal +area: deps +--- + +Updated 43 dependencies, including the HTTP client behind every playlist and +portal request — that one closes seven advisories that affect the shipped app, +among them a proxy-credential leak on redirects. Also refreshes the ArtPlayer, +hls.js, Video.js and Shaka player engines. No behaviour change intended. diff --git a/.changes/downloads-pause-resume.md b/.changes/downloads-pause-resume.md new file mode 100644 index 000000000..2bde5dffb --- /dev/null +++ b/.changes/downloads-pause-resume.md @@ -0,0 +1,9 @@ +--- +type: feature +area: downloads +--- + +Downloads can be paused and picked up later. A paused transfer keeps what it +already fetched and continues from that point instead of starting over, and +downloads cut short by a crash or a closed app come back as paused rather than +lost. diff --git a/.changes/electron-remote-static-paths.md b/.changes/electron-remote-static-paths.md new file mode 100644 index 000000000..70313de33 --- /dev/null +++ b/.changes/electron-remote-static-paths.md @@ -0,0 +1,7 @@ +--- +type: fix +area: electron +--- + +The desktop remote-control server now blocks crafted static paths from escaping +bundled web files on Windows. diff --git a/.changes/embedded-mpv-frame-copy.md b/.changes/embedded-mpv-frame-copy.md new file mode 100644 index 000000000..6a866ae2f --- /dev/null +++ b/.changes/embedded-mpv-frame-copy.md @@ -0,0 +1,9 @@ +--- +type: feature +area: embedded-mpv +--- + +Experimental Embedded MPV can draw video inside the app window instead of into a +separate layer pinned on top of it, so menus, dialogs and the player controls +stop being swallowed by the picture. Available on macOS (Apple Silicon), Windows +and Linux x64; switching it on needs a restart. diff --git a/.changes/epg-manual-channel-mapping.md b/.changes/epg-manual-channel-mapping.md new file mode 100644 index 000000000..7cb429b15 --- /dev/null +++ b/.changes/epg-manual-channel-mapping.md @@ -0,0 +1,9 @@ +--- +type: feature +area: epg +--- + +Channels whose guide never matched can be mapped by hand: right-click a channel +in any list and pick "Map EPG channel" to attach it to a channel from your +uploaded XMLTV guide. The mapping is remembered and used everywhere the guide is +read — M3U playlists, Xtream and Stalker portals alike. diff --git a/.changes/epg-mapping-lookup-fail-soft.md b/.changes/epg-mapping-lookup-fail-soft.md new file mode 100644 index 000000000..dc35aebbe --- /dev/null +++ b/.changes/epg-mapping-lookup-fail-soft.md @@ -0,0 +1,9 @@ +--- +type: fix +area: epg +--- + +A database error while reading or saving a manual EPG channel mapping no longer +surfaces as a failed request. Looking up, saving, deleting, and searching +mappings now fall back quietly, so a transient storage hiccup can no longer take +down the EPG panel or the "Map EPG channel" dialog. diff --git a/.changes/favorites-lost-updates.md b/.changes/favorites-lost-updates.md new file mode 100644 index 000000000..a54c32de3 --- /dev/null +++ b/.changes/favorites-lost-updates.md @@ -0,0 +1,9 @@ +--- +type: fix +area: favorites +issues: [1137] +--- + +Favorites stop losing changes. The custom drag-and-drop order of an Xtream +playlist's own favorites is saved again, and two favorites or history entries +added at almost the same moment no longer quietly overwrite each other. diff --git a/.changes/i18n-hungarian.md b/.changes/i18n-hungarian.md new file mode 100644 index 000000000..691f77900 --- /dev/null +++ b/.changes/i18n-hungarian.md @@ -0,0 +1,7 @@ +--- +type: feature +area: i18n +issues: [1192] +--- + +IPTVnator speaks Hungarian, its 19th language — contributed by @htibcsike. diff --git a/.changes/m3u-dash-clearkey.md b/.changes/m3u-dash-clearkey.md new file mode 100644 index 000000000..c2853da6b --- /dev/null +++ b/.changes/m3u-dash-clearkey.md @@ -0,0 +1,10 @@ +--- +type: feature +area: m3u +issues: [86, 614, 656, 733, 752] +--- + +MPEG-DASH channels play in the built-in player, ClearKey-encrypted ones +included — the keys are read from the playlist's #KODIPROP lines, whether they +sit above or below the channel entry. Streams locked with Widevine or PlayReady +still cannot be played, but they now say so instead of failing silently. diff --git a/.changes/m3u-long-stream-urls.md b/.changes/m3u-long-stream-urls.md new file mode 100644 index 000000000..e084e55ee --- /dev/null +++ b/.changes/m3u-long-stream-urls.md @@ -0,0 +1,9 @@ +--- +type: fix +area: m3u +issues: [1189] +--- + +Playlists with very long stream URLs — Pluto TV style lists that carry a session +token in every link — import in full again instead of collapsing into a single +channel. diff --git a/.changes/playback-shared-player-controls.md b/.changes/playback-shared-player-controls.md new file mode 100644 index 000000000..d2f27b104 --- /dev/null +++ b/.changes/playback-shared-player-controls.md @@ -0,0 +1,9 @@ +--- +type: feature +area: playback +--- + +An optional new set of player controls that looks and behaves the same in the +HTML5, Video.js and ArtPlayer players, with picture-in-picture and, in +fullscreen, the name of what you are watching. Enable it in Settings → Playback; +left off, each of the three keeps its own controls exactly as before. diff --git a/.changes/playback-theater-stage.md b/.changes/playback-theater-stage.md new file mode 100644 index 000000000..b8f67a5c4 --- /dev/null +++ b/.changes/playback-theater-stage.md @@ -0,0 +1,9 @@ +--- +type: feature +area: playback +--- + +The inline player on movie and series pages fills the whole content area like a +theater: the video sits centered in black instead of leaving a strip of app +background beside it. In the built-in web players, an optional ambient mode +fills that space with a blurred, dimmed copy of the poster. diff --git a/.changes/playback-up-next-rail.md b/.changes/playback-up-next-rail.md new file mode 100644 index 000000000..13738a792 --- /dev/null +++ b/.changes/playback-up-next-rail.md @@ -0,0 +1,9 @@ +--- +type: feature +area: playback +--- + +A series playing inline on a wide window shows an "Up Next" rail beside the +video: the rest of the season and the start of the next one, the current episode +highlighted, watch progress on every card. Click one to jump straight to it. +Built-in web players only; switch the rail off in Settings → Playback. diff --git a/.changes/playlists-auto-refresh.md b/.changes/playlists-auto-refresh.md new file mode 100644 index 000000000..2104099f2 --- /dev/null +++ b/.changes/playlists-auto-refresh.md @@ -0,0 +1,10 @@ +--- +type: fix +area: playlists +issues: [931] +--- + +One unreachable playlist no longer holds up the rest. Refreshes give up after 30 +seconds and run a few at a time, so your other playlists still update, and the +message on startup names how many actually failed instead of always claiming +success. diff --git a/.changes/search-punctuation-words.md b/.changes/search-punctuation-words.md new file mode 100644 index 000000000..b33302a09 --- /dev/null +++ b/.changes/search-punctuation-words.md @@ -0,0 +1,9 @@ +--- +type: fix +area: search +issues: [1161] +--- + +Searching for names that carry punctuation inside them — "A&E", "X-Men", +"L'Equipe" — finds them anywhere in a title, including channels the provider +prefixes, like "US: A&E". diff --git a/.changes/settings-strip-country-prefix.md b/.changes/settings-strip-country-prefix.md new file mode 100644 index 000000000..bc8980306 --- /dev/null +++ b/.changes/settings-strip-country-prefix.md @@ -0,0 +1,9 @@ +--- +type: feature +area: settings +--- + +A new setting drops the country prefix from live channel names, turning +"UK - BBC One" into "BBC One" in lists, the guide and the player. Movie and +series titles are left alone, and names that only look like a prefix — "Sky - +Sports F1" — stay intact. diff --git a/.changes/stalker-detail-requests.md b/.changes/stalker-detail-requests.md new file mode 100644 index 000000000..f8651f05d --- /dev/null +++ b/.changes/stalker-detail-requests.md @@ -0,0 +1,9 @@ +--- +type: perf +area: stalker +--- + +Detail pages for anything that is not a series — a movie, a live channel, a VOD +item that only looks like a series — no longer fire an episode-list request +before they can show anything, so they open faster on every route in, from +browsing and search to Favorites, Recent and the dashboard. diff --git a/.changes/stalker-embedded-series-episodes.md b/.changes/stalker-embedded-series-episodes.md new file mode 100644 index 000000000..f808d4cbf --- /dev/null +++ b/.changes/stalker-embedded-series-episodes.md @@ -0,0 +1,9 @@ +--- +type: fix +area: stalker +--- + +Series opened from Favorites, Recent or the dashboard show their current +episodes. One saved back when a single episode existed used to keep showing that +one episode forever; the list is refreshed from the portal in the background +instead. diff --git a/.changes/stalker-live-tv-channel-list.md b/.changes/stalker-live-tv-channel-list.md new file mode 100644 index 000000000..d93bfe8c4 --- /dev/null +++ b/.changes/stalker-live-tv-channel-list.md @@ -0,0 +1,9 @@ +--- +type: feature +area: stalker +--- + +The Live TV section loads its full channel list up front: search covers every +channel instead of only the page you are on, genres show how many channels they +hold, and Live TV opens on a grid of all channels. Guide data for the visible +rows loads in bulk, so it shows up without playing a channel first. diff --git a/.changes/tmdb-detail-page-extras.md b/.changes/tmdb-detail-page-extras.md new file mode 100644 index 000000000..8b57a6181 --- /dev/null +++ b/.changes/tmdb-detail-page-extras.md @@ -0,0 +1,9 @@ +--- +type: feature +area: tmdb +--- + +Series pages show whether a show has ended or is still returning, so you know +before committing to it. Directors and creators became clickable avatar chips +like the cast — they open the person's page, where directing credits now sit +alongside acting ones. diff --git a/.changes/tmdb-title-matching.md b/.changes/tmdb-title-matching.md new file mode 100644 index 000000000..025cb790a --- /dev/null +++ b/.changes/tmdb-title-matching.md @@ -0,0 +1,9 @@ +--- +type: fix +area: tmdb +--- + +Titles that providers dress up with language or quality tags — "|ALB| Fallout", +"4K-DE - The Pitt (2025)", "Breaking Bad-eng" — now match against TMDB, so they +get artwork, plot and cast like the rest of the catalog. Shows split into one +entry per season also pull the season that entry really contains. diff --git a/.changes/xtream-catchup-collections.md b/.changes/xtream-catchup-collections.md new file mode 100644 index 000000000..b8aa5dda6 --- /dev/null +++ b/.changes/xtream-catchup-collections.md @@ -0,0 +1,9 @@ +--- +type: feature +area: xtream +issues: [1138] +--- + +Catch-up is available from Favorites and Recent, not just Live TV, so an +archived programme is reachable wherever the channel is. The programme currently +on air can also be restarted from the beginning. diff --git a/.changes/xtream-portal-connection.md b/.changes/xtream-portal-connection.md new file mode 100644 index 000000000..6d680359d --- /dev/null +++ b/.changes/xtream-portal-connection.md @@ -0,0 +1,9 @@ +--- +type: fix +area: xtream +--- + +Portals sitting behind Cloudflare or a similar firewall connect again. Those +setups answered the app with a challenge page instead of data, so "Test +connection" failed on portals that worked fine in every other player; requests +now identify themselves the way an ordinary IPTV player does. diff --git a/.changes/xtream-series-resume.md b/.changes/xtream-series-resume.md new file mode 100644 index 000000000..8f4b89b8a --- /dev/null +++ b/.changes/xtream-series-resume.md @@ -0,0 +1,9 @@ +--- +type: feature +area: xtream +--- + +Continue Watching resumes a series where you left it: opening one from the +dashboard starts the exact episode at its saved position, and the series page +offers "Play episode N" instead of always starting at the first one. Episodes +launched in MPV or VLC count towards this too. diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index abbac03eb..efc62626e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -56,7 +56,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v4 @@ -322,7 +322,7 @@ jobs: test -s "${RUNTIME_ROOT}/source-archive-binding.json" - name: Upload staged Linux runtime - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-embedded-mpv-runtime path: vendor/embedded-mpv/linux-x64 @@ -330,7 +330,7 @@ jobs: retention-days: 7 - name: Upload Linux runtime source compliance - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-frame-copy-runtime-sources path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz @@ -370,7 +370,7 @@ jobs: steps: &electron-build-steps - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -424,7 +424,7 @@ jobs: - name: Download pinned Linux Embedded MPV runtime if: matrix.os == 'linux' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: linux-embedded-mpv-runtime path: vendor/embedded-mpv/linux-x64 @@ -1187,7 +1187,7 @@ jobs: - name: Upload artifacts (macOS) if: matrix.os == 'macos' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: macos-${{ matrix.arch }}-artifacts path: | @@ -1199,7 +1199,7 @@ jobs: - name: Upload system-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'system' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-system-artifacts path: | @@ -1211,7 +1211,7 @@ jobs: - name: Upload portable-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'portable' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-portable-artifacts path: | @@ -1223,7 +1223,7 @@ jobs: - name: Upload Flatpak-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-flatpak-artifacts path: | @@ -1232,7 +1232,7 @@ jobs: - name: Upload artifacts (Windows) if: matrix.os == 'windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: windows-artifacts path: | @@ -1294,10 +1294,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc86e2950..14f32433b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Image pinned by digest (tag 1.7.12). False positives are # suppressed in .github/actionlint.yaml; shellcheck runs at @@ -53,7 +53,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 # The gate scripts are dependency-free Node, so this job skips # pnpm install entirely and stays cheap. @@ -92,7 +92,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: # nx affected needs the merge-base with the PR target branch. fetch-depth: 0 @@ -133,7 +133,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -167,7 +167,7 @@ jobs: - name: Upload unit coverage artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: unit-coverage path: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 498b03e7c..dfe88308a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. PR runs analyze the merge # commit checked out above (the modern default); JavaScript is diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 01a5143c7..aa235d701 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c68aaf8a7..dff445563 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Prepare Docker metadata id: docker-meta diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 62ed7c390..abd9d577a 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -49,7 +49,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -87,7 +87,7 @@ jobs: - name: Upload Electron Test Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report-electron-${{ matrix.os }} path: | @@ -103,7 +103,7 @@ jobs: NX_SKIP_NX_CACHE: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -131,7 +131,7 @@ jobs: - name: Upload Web Test Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report-web-ubuntu path: | diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml index b07fe5469..d74213ec2 100644 --- a/.github/workflows/publish-snap.yaml +++ b/.github/workflows/publish-snap.yaml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout released tooling - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ github.event.release.tag_name }} persist-credentials: false @@ -121,7 +121,7 @@ jobs: printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}" - name: Transfer verified release assets - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: verified-snap-release-assets path: /var/lib/iptvnator-snap-release/assets @@ -139,7 +139,7 @@ jobs: steps: - name: Download verified release assets - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: verified-snap-release-assets path: ${{ runner.temp }}/verified-snap-release-assets diff --git a/AGENTS.md b/AGENTS.md index 03059df4b..11083639a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ This file provides guidance to coding agents working in this repository. - Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. +- ESLint enforces `max-lines` on TypeScript files (target under 300, hard maximum 400). Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. - Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs. ## Documentation After Changes @@ -84,7 +85,8 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset + - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset + - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/CLAUDE.md b/CLAUDE.md index 9d5c22894..40c8fda01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,7 +141,8 @@ Useful narrower flags: - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal -- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset +- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset +- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching @@ -220,7 +221,11 @@ Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. -Never add new files to the baseline. +Never add new files to the baseline — the list must only shrink. A new file +that genuinely cannot be split (for example a function serialized into another +process) instead carries its own file-wide +`/* eslint-disable max-lines -- */`; the generator skips those files, so +a justified exemption never lands in the baseline. ## Architecture @@ -256,6 +261,7 @@ This is an Nx monorepo with the following structure: - **shared/logging** - Dependency-free structured redaction for diagnostic logs - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend) - **shared/m3u-utils** - M3U playlist utilities + - **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks - **shared/testing** - Shared test helpers - **ui/components** - Reusable UI components (incl. channel list) - **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs) @@ -355,10 +361,11 @@ Key patterns: - **Factory injection**: `provideXtreamDataSource()` selects Electron or PWA implementation at runtime Data strategies by environment: -| Environment | Strategy | -|-------------|----------| + +| Environment | Strategy | +| ------------ | ------------------------------------------------------- | | **Electron** | DB-first: Check DB → fetch API if missing → cache to DB | -| **PWA** | API-only: Always fetch from API, store in memory | +| **PWA** | API-only: Always fetch from API, store in memory | **M3U Playlist Module Architecture**: @@ -608,7 +615,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - **Event handlers**: `apps/electron-backend/src/app/events/` - `database.events.ts` - Database CRUD operations - `playlist.events.ts` - Playlist import/update - - `epg.events.ts` - EPG IPC registration and freshness/fetch orchestration; worker lifecycle lives in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts` + - `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts` - `xtream.events.ts` - Xtream Codes API - `stalker.events.ts` - Legacy simple/PWA-compatible Stalker portal API - `stalker-session.events.ts` - Typed full-portal session IPC; the diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 1918e34fa..ee68cd410 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,14 +12,31 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "test-performance-harness": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/performance/*.spec.ts" + } + }, "benchmark-m3u-refresh-cancellation": { - "dependsOn": ["electron-backend:build-e2e"], + "dependsOn": ["electron-backend:build-performance"], "executor": "nx:run-commands", "cache": false, "parallelism": false, "options": { "cwd": "apps/electron-backend-e2e", - "command": "pnpm exec playwright test --config=playwright.performance.config.ts" + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts" + } + }, + "benchmark-m3u-import": { + "dependsOn": ["electron-backend:build-performance"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts" } }, "packaged-frame-copy-smoke": { diff --git a/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts b/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts new file mode 100644 index 000000000..14c2dc93e --- /dev/null +++ b/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts @@ -0,0 +1,122 @@ +import type { ElectronApplication, Page } from '@playwright/test'; + +import { + closeElectronApp, + expect, + launchElectronApp, + test, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { + installMainCapture, + rolloverMainCapture, + startMainCapture, + stopMainCapture, +} from './performance/m3u-refresh-main-capture'; +import type { MainCaptureMetrics } from './performance/m3u-refresh-cancellation-contract'; +import type { RendererWindowIdentity } from './performance/renderer-window-rss-session'; + +test('captures the exact database worker post-GC heap in built Electron', async ({ + dataDir, +}) => { + const launchedApps: LaunchedElectronApp[] = []; + + try { + const app = await launchElectronApp(dataDir, { + args: ['--js-flags=--expose-gc'], + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + }, + }); + launchedApps.push(app); + await installMainCapture(app.electronApp); + const rendererWindowIdentity = await resolveRendererWindowIdentity( + app.electronApp, + app.mainWindow + ); + const captureOptions = { + diagnostic: false, + outputDirectory: dataDir, + rendererWindowIdentity, + } as const; + + await startMainCapture(app.electronApp, captureOptions); + await requestDatabaseWorker(app); + const rollover = await rolloverMainCapture( + app.electronApp, + captureOptions + ); + expect(rollover.nextCaptureStarted).toBe(true); + expect(rollover.nextCaptureUnavailableReason).toBeNull(); + const firstDatabaseWorker = expectExactDatabaseWorker( + rollover.completedCapture + ); + + await requestDatabaseWorker(app); + const secondDatabaseWorker = expectExactDatabaseWorker( + await stopMainCapture(app.electronApp) + ); + expect(secondDatabaseWorker.ordinal).toBe(firstDatabaseWorker.ordinal); + + await startMainCapture(app.electronApp, captureOptions); + const noDatabasePhase = await stopMainCapture(app.electronApp); + expect( + noDatabasePhase.workers.filter( + (worker) => worker.kind === 'database.worker' + ) + ).toHaveLength(0); + } finally { + await Promise.all(launchedApps.map((app) => closeElectronApp(app))); + } +}); + +async function requestDatabaseWorker(app: LaunchedElectronApp): Promise { + await app.mainWindow.evaluate(() => window.electron.dbGetAppPlaylists()); +} + +function expectExactDatabaseWorker(capture: MainCaptureMetrics) { + const databaseWorkers = capture.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + + expect(databaseWorkers).toHaveLength(1); + const databaseWorker = databaseWorkers[0]; + expect(databaseWorker?.ordinal).toBeGreaterThan(0); + expect(databaseWorker?.postGcHeapUnavailableReason).toBeNull(); + expect(databaseWorker?.postGcHeapUsedBytes).toBeGreaterThan(0); + expect(databaseWorker?.requests).toHaveLength(1); + expect(databaseWorker?.requests[0]).toEqual( + expect.objectContaining({ + invalidReason: null, + operation: 'DB_GET_APP_PLAYLISTS', + performanceCaptureUnavailableReason: null, + playlistId: null, + requestId: expect.any(String), + success: true, + }) + ); + return databaseWorker; +} + +async function resolveRendererWindowIdentity( + electronApp: ElectronApplication, + page: Page +): Promise { + const browserWindowHandle = await electronApp.browserWindow(page); + try { + return await browserWindowHandle.evaluate((browserWindow) => { + const exactWindow = browserWindow as unknown as { + readonly id: number; + readonly webContents: { readonly id: number }; + }; + return { + browserWindowId: exactWindow.id, + webContentsId: exactWindow.webContents.id, + }; + }); + } finally { + await browserWindowHandle.dispose(); + } +} diff --git a/apps/electron-backend-e2e/src/m3u-import.performance.ts b/apps/electron-backend-e2e/src/m3u-import.performance.ts new file mode 100644 index 000000000..d1ba60ab4 --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-import.performance.ts @@ -0,0 +1,8 @@ +import { test } from '@playwright/test'; + +import { runM3uImportBenchmark } from './performance/m3u-import.benchmark'; + +// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle. +test('profiles initial M3U imports at 10k, 50k, and 100k', async () => { + await runM3uImportBenchmark(); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts new file mode 100644 index 000000000..4c3eac434 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts @@ -0,0 +1,359 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + installDatabaseRequestIdentityCaptureInMain, + type DatabaseRequestIdentityCaptureApi, +} from './database-request-identity-capture'; + +const CHANNEL = 'IPTVNATOR_PERF_CAPTURE_MARKER'; +let nextStateId = 1; + +function createCapture(): { + api: DatabaseRequestIdentityCaptureApi; + ipcMain: EventEmitter; +} { + const ipcMain = new EventEmitter(); + const stateKey = `__identityCapture${nextStateId}`; + nextStateId += 1; + installDatabaseRequestIdentityCaptureInMain({ ipcMain } as never, stateKey); + const api = (globalThis as unknown as Record)[ + stateKey + ] as DatabaseRequestIdentityCaptureApi; + api.start(); + return { api, ipcMain }; +} + +function marker(overrides: Record = {}) { + return { + correlationState: 'correlated', + invalidReason: null, + ipcCallId: 2, + method: 'dbGetAppPlaylist', + operationId: 'refresh-operation-1', + phase: 'start', + playlistId: 'playlist-1', + sourceEpochMs: 100, + ...overrides, + }; +} + +test('correlates the real preload marker to a DB request whose payload omits operationId', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit(CHANNEL, {}, marker()); + + const identity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-1', + type: 'request', + }); + + assert.deepEqual(identity, { + ipcCallId: 2, + operationId: 'refresh-operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); +}); + +test('retains the initial-import upsert marker when its operationId is intentionally uncorrelated', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 7, + method: 'dbUpsertAppPlaylist', + operationId: null, + sourceEpochMs: 1_077.25, + }) + ); + + const identity = api.matchDatabaseRequest({ + operation: 'DB_UPSERT_APP_PLAYLIST', + payload: { _id: 'playlist-1' }, + requestId: 'initial-import-upsert', + type: 'request', + }); + + assert.deepEqual(identity, { + ipcCallId: 7, + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-uncorrelated', + sourceEpochMs: 1_077.25, + }); +}); + +test('captures exact successful preload completion markers for DB return-clone attribution', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 7, + method: 'dbUpsertAppPlaylist', + operationId: null, + phase: 'success', + sourceEpochMs: 140.25, + }) + ); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 8, + operationId: null, + phase: 'success', + sourceEpochMs: 175.5, + }) + ); + + assert.equal(api.successMarkerCount(), 2); + assert.deepEqual(api.takeSuccessMarkers(), [ + { + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + operationId: null, + playlistId: 'playlist-1', + sourceEpochMs: 140.25, + }, + { + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + operationId: null, + playlistId: 'playlist-1', + sourceEpochMs: 175.5, + }, + ]); + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); +}); + +test('fails closed for malformed or invalid successful preload markers and resets completions', () => { + const { api, ipcMain } = createCapture(); + for (const invalid of [ + { invalidReason: 'out-of-order' }, + { correlationState: 'invalid' }, + { ipcCallId: 0 }, + { playlistId: '' }, + { sourceEpochMs: Number.NaN }, + { method: 'refreshPlaylist' }, + { correlationState: 'correlated', operationId: null }, + ]) { + ipcMain.emit( + CHANNEL, + {}, + marker({ + phase: 'success', + ...invalid, + }) + ); + } + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); + + ipcMain.emit( + CHANNEL, + {}, + marker({ + phase: 'success', + }) + ); + api.stop(); + api.start(); + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); +}); + +test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => { + const { api, ipcMain } = createCapture(); + const identity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-before-marker', + type: 'request', + }); + assert.equal( + identity.operationIdUnavailableReason, + 'preload-performance-marker-missing' + ); + const responseOutcome = { identity }; + + ipcMain.emit(CHANNEL, {}, marker()); + assert.deepEqual(responseOutcome.identity, { + ipcCallId: 2, + operationId: 'refresh-operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + + api.stop(); + ipcMain.emit(CHANNEL, {}, marker({ operationId: 'marker-while-stopped' })); + api.start(); + const afterReset = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-after-reset', + type: 'request', + }); + assert.deepEqual(afterReset, { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-missing', + sourceEpochMs: null, + }); +}); + +test('fails closed for missing and ambiguous preload markers', () => { + const missingCapture = createCapture(); + const missing = missingCapture.api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-missing', + type: 'request', + }); + assert.deepEqual(missing, { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-missing', + sourceEpochMs: null, + }); + + const ambiguousCapture = createCapture(); + ambiguousCapture.ipcMain.emit(CHANNEL, {}, marker()); + ambiguousCapture.ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 3, + operationId: 'refresh-operation-2', + }) + ); + const ambiguous = ambiguousCapture.api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-ambiguous', + type: 'request', + }); + assert.deepEqual(ambiguous, { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + sourceEpochMs: null, + }); +}); + +test('quarantines a key after response-before-marker ambiguity', () => { + const { api, ipcMain } = createCapture(); + const requestA = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-a', + type: 'request', + }); + const requestB = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-b', + type: 'request', + }); + + ipcMain.emit( + CHANNEL, + {}, + marker({ ipcCallId: 2, operationId: 'refresh-operation-a' }) + ); + ipcMain.emit( + CHANNEL, + {}, + marker({ ipcCallId: 3, operationId: 'refresh-operation-b' }) + ); + + const requestC = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-c', + type: 'request', + }); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 4, + operationId: 'other-playlist-operation', + playlistId: 'playlist-2', + }) + ); + const otherPlaylist = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-2' }, + requestId: 'other-playlist-request', + type: 'request', + }); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 5, + method: 'dbUpsertAppPlaylist', + operationId: 'other-db-operation', + }) + ); + const otherOperation = api.matchDatabaseRequest({ + operation: 'DB_UPSERT_APP_PLAYLIST', + payload: { _id: 'playlist-1' }, + requestId: 'other-operation-request', + type: 'request', + }); + + const ambiguousIdentity = { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + sourceEpochMs: null, + }; + assert.deepEqual(requestA, ambiguousIdentity); + assert.deepEqual(requestB, ambiguousIdentity); + assert.deepEqual(requestC, ambiguousIdentity); + assert.deepEqual(otherPlaylist, { + ipcCallId: 4, + operationId: 'other-playlist-operation', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + assert.deepEqual(otherOperation, { + ipcCallId: 5, + operationId: 'other-db-operation', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + + api.stop(); + api.start(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 6, + operationId: 'fresh-capture-operation', + }) + ); + const freshCaptureIdentity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'fresh-capture-request', + type: 'request', + }); + assert.deepEqual(freshCaptureIdentity, { + ipcCallId: 6, + operationId: 'fresh-capture-operation', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts new file mode 100644 index 000000000..2de76e7e3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts @@ -0,0 +1,354 @@ +import type { ElectronApplication } from '@playwright/test'; + +export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY = + '__iptvnatorM3uRefreshDatabaseRequestIdentityCapture'; + +export interface DatabaseRequestIdentity { + ipcCallId: number | null; + operationId: string | null; + operationIdUnavailableReason: string | null; + sourceEpochMs: number | null; +} + +export interface PreloadDatabaseSuccessMarker { + ipcCallId: number; + operation: 'DB_GET_APP_PLAYLIST' | 'DB_UPSERT_APP_PLAYLIST'; + operationId: string | null; + playlistId: string; + sourceEpochMs: number; +} + +export interface DatabaseRequestIdentityCaptureApi { + matchDatabaseRequest(message: unknown): DatabaseRequestIdentity; + start(): void; + stop(): void; + successMarkerCount(): number; + takeSuccessMarkers(): readonly PreloadDatabaseSuccessMarker[]; +} + +interface DatabaseRequestIdentityElectron { + readonly ipcMain: { + on( + channel: string, + listener: (event: unknown, marker: unknown) => void + ): void; + }; +} + +export function installDatabaseRequestIdentityCaptureInMain( + { ipcMain }: DatabaseRequestIdentityElectron, + stateKey: string +): void { + type JsonRecord = Record; + interface PendingMarker { + readonly ipcCallId: number; + readonly operation: string; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly playlistId: string; + readonly sourceEpochMs: number; + } + interface PendingRequest { + readonly identity: DatabaseRequestIdentity; + readonly operation: string; + readonly playlistId: string; + } + interface QuarantinedKey { + readonly operation: string; + readonly playlistId: string; + } + + const target = globalThis as unknown as Record; + if (target[stateKey] !== undefined) { + return; + } + + const markerChannel = 'IPTVNATOR_PERF_CAPTURE_MARKER'; + const markerMethodToOperation: Readonly> = { + dbGetAppPlaylist: 'DB_GET_APP_PLAYLIST', + dbUpsertAppPlaylist: 'DB_UPSERT_APP_PLAYLIST', + }; + let active = false; + let pendingMarkers: PendingMarker[] = []; + let pendingRequests: PendingRequest[] = []; + let quarantinedKeys: QuarantinedKey[] = []; + let successMarkers: PreloadDatabaseSuccessMarker[] = []; + + const readRecord = (value: unknown): JsonRecord | null => + typeof value === 'object' && value !== null + ? (value as JsonRecord) + : null; + const readPlaylistId = ( + operation: string, + payload: unknown + ): string | null => { + const value = readRecord(payload); + if (!value) { + return null; + } + const playlistId = + operation === 'DB_UPSERT_APP_PLAYLIST' + ? value['_id'] + : value['playlistId']; + return typeof playlistId === 'string' && playlistId.length > 0 + ? playlistId + : null; + }; + const matches = ( + candidate: { operation: string; playlistId: string }, + operation: string, + playlistId: string + ): boolean => + candidate.operation === operation && + candidate.playlistId === playlistId; + const markAmbiguous = (requests: PendingRequest[]): void => { + for (const request of requests) { + request.identity.ipcCallId = null; + request.identity.operationId = null; + request.identity.operationIdUnavailableReason = + 'preload-performance-marker-ambiguous'; + request.identity.sourceEpochMs = null; + } + }; + const unavailableIdentity = (reason: string): DatabaseRequestIdentity => ({ + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: reason, + sourceEpochMs: null, + }); + const identityFromMarker = ( + marker: PendingMarker + ): DatabaseRequestIdentity => ({ + ipcCallId: marker.ipcCallId, + operationId: marker.operationId, + operationIdUnavailableReason: marker.operationIdUnavailableReason, + sourceEpochMs: marker.sourceEpochMs, + }); + const applyMarker = ( + identity: DatabaseRequestIdentity, + marker: PendingMarker + ): void => { + identity.ipcCallId = marker.ipcCallId; + identity.operationId = marker.operationId; + identity.operationIdUnavailableReason = + marker.operationIdUnavailableReason; + identity.sourceEpochMs = marker.sourceEpochMs; + }; + const isQuarantined = (operation: string, playlistId: string): boolean => + quarantinedKeys.some((key) => matches(key, operation, playlistId)); + const quarantine = (operation: string, playlistId: string): void => { + if (!isQuarantined(operation, playlistId)) { + quarantinedKeys.push({ operation, playlistId }); + } + const requestCandidates = pendingRequests.filter((request) => + matches(request, operation, playlistId) + ); + markAmbiguous(requestCandidates); + pendingRequests = pendingRequests.filter( + (request) => !matches(request, operation, playlistId) + ); + pendingMarkers = pendingMarkers.filter( + (marker) => !matches(marker, operation, playlistId) + ); + }; + + ipcMain.on(markerChannel, (_event, input) => { + if (!active) { + return; + } + const marker = readRecord(input); + const correlationState = marker?.['correlationState']; + const operation = + typeof marker?.['method'] === 'string' + ? markerMethodToOperation[marker['method']] + : undefined; + const operationId = marker?.['operationId']; + const playlistId = marker?.['playlistId']; + const ipcCallId = marker?.['ipcCallId']; + const sourceEpochMs = marker?.['sourceEpochMs']; + const validMarkerIdentity = + operation !== undefined && + typeof playlistId === 'string' && + playlistId.length > 0 && + Number.isSafeInteger(ipcCallId) && + Number(ipcCallId) >= 1 && + typeof sourceEpochMs === 'number' && + Number.isFinite(sourceEpochMs) && + sourceEpochMs >= 0; + if ( + marker?.['phase'] === 'success' && + (correlationState === 'correlated' || + correlationState === 'uncorrelated') && + marker['invalidReason'] === null && + validMarkerIdentity && + (correlationState !== 'correlated' || + (typeof operationId === 'string' && + operationId.length > 0)) + ) { + successMarkers.push({ + ipcCallId: Number(ipcCallId), + operation: operation as PreloadDatabaseSuccessMarker['operation'], + operationId: + correlationState === 'correlated' + ? String(operationId) + : null, + playlistId, + sourceEpochMs, + }); + return; + } + if ( + !marker || + marker['phase'] !== 'start' || + (correlationState !== 'correlated' && + correlationState !== 'uncorrelated') || + marker['invalidReason'] !== null + ) { + return; + } + const correlatedOperationId = + correlationState === 'correlated' && + typeof operationId === 'string' && + operationId.length > 0 + ? operationId + : null; + if ( + operation === undefined || + (correlationState === 'correlated' && + correlatedOperationId === null) || + typeof playlistId !== 'string' || + playlistId.length === 0 || + !Number.isSafeInteger(ipcCallId) || + Number(ipcCallId) < 1 || + typeof sourceEpochMs !== 'number' || + !Number.isFinite(sourceEpochMs) || + sourceEpochMs < 0 + ) { + return; + } + const pendingMarker: PendingMarker = { + ipcCallId: Number(ipcCallId), + operation, + operationId: correlatedOperationId, + operationIdUnavailableReason: + correlationState === 'correlated' + ? null + : 'preload-performance-marker-uncorrelated', + playlistId, + sourceEpochMs, + }; + + if (isQuarantined(operation, playlistId)) { + return; + } + const requestCandidates = pendingRequests.filter((request) => + matches(request, operation, playlistId) + ); + if (requestCandidates.length === 1) { + const request = requestCandidates[0]; + if (!request) { + return; + } + applyMarker(request.identity, pendingMarker); + pendingRequests = pendingRequests.filter( + (candidate) => candidate !== request + ); + return; + } + if (requestCandidates.length > 1) { + quarantine(operation, playlistId); + return; + } + + pendingMarkers.push(pendingMarker); + pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId); + }); + + const api: DatabaseRequestIdentityCaptureApi = { + matchDatabaseRequest: (input) => { + const message = readRecord(input); + const operation = message?.['operation']; + const playlistId = + typeof operation === 'string' + ? readPlaylistId(operation, message?.['payload']) + : null; + if ( + !active || + (operation !== 'DB_GET_APP_PLAYLIST' && + operation !== 'DB_UPSERT_APP_PLAYLIST') || + playlistId === null + ) { + return unavailableIdentity( + 'preload-performance-marker-not-applicable' + ); + } + + if (isQuarantined(operation, playlistId)) { + return unavailableIdentity( + 'preload-performance-marker-ambiguous' + ); + } + const markerCandidates = pendingMarkers.filter((marker) => + matches(marker, operation, playlistId) + ); + if (markerCandidates.length === 1) { + const marker = markerCandidates[0]; + if (!marker) { + return unavailableIdentity( + 'preload-performance-marker-missing' + ); + } + pendingMarkers = pendingMarkers.filter( + (candidate) => candidate !== marker + ); + return identityFromMarker(marker); + } + if (markerCandidates.length > 1) { + quarantine(operation, playlistId); + return unavailableIdentity( + 'preload-performance-marker-ambiguous' + ); + } + + const identity = unavailableIdentity( + 'preload-performance-marker-missing' + ); + pendingRequests.push({ + identity, + operation, + playlistId, + }); + return identity; + }, + start: () => { + pendingMarkers = []; + pendingRequests = []; + quarantinedKeys = []; + successMarkers = []; + active = true; + }, + stop: () => { + active = false; + pendingMarkers = []; + pendingRequests = []; + quarantinedKeys = []; + successMarkers = []; + }, + successMarkerCount: () => successMarkers.length, + takeSuccessMarkers: () => { + const captured = Object.freeze([...successMarkers]); + successMarkers = []; + return captured; + }, + }; + target[stateKey] = api; +} + +export async function installDatabaseRequestIdentityCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate( + installDatabaseRequestIdentityCaptureInMain, + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY + ); +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts new file mode 100644 index 000000000..37b19a714 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts @@ -0,0 +1,98 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' +); + +function section(startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.ok(start >= 0, `missing start marker: ${startMarker}`); + assert.ok(end > start, `missing end marker: ${endMarker}`); + return source.slice(start, end); +} + +test('counts only finite nonnegative worker heap and external-memory samples per capture generation', () => { + const workerRecord = section( + 'interface WorkerRecord', + 'interface TimelineRecord' + ); + const createRecord = section( + 'const record: WorkerRecord = {', + 'nextWorkerOrdinal += 1' + ); + const sampleWorker = section( + 'const sampleWorker =', + 'const resetWorkerForCapture' + ); + const resetWorker = section( + 'const resetWorkerForCapture', + 'const startWorker' + ); + + assert.match(workerRecord, /externalMemorySampleCount: number/); + assert.match(workerRecord, /heapUsedSampleCount: number/); + assert.match(createRecord, /externalMemorySampleCount: 0/); + assert.match(createRecord, /heapUsedSampleCount: 0/); + assert.match(resetWorker, /record\.externalMemorySampleCount = 0/); + assert.match(resetWorker, /record\.heapUsedSampleCount = 0/); + + assert.match(sampleWorker, /const heapUsedSample = stats\.used_heap_size/); + assert.match( + sampleWorker, + /Number\.isFinite\(heapUsedSample\)[\s\S]*heapUsedSample >= 0/ + ); + assert.match(sampleWorker, /record\.heapUsedSampleCount \+= 1/); + assert.match( + sampleWorker, + /const externalMemorySample = stats\.external_memory/ + ); + assert.match( + sampleWorker, + /Number\.isFinite\(externalMemorySample\)[\s\S]*externalMemorySample >= 0/ + ); + assert.match(sampleWorker, /record\.externalMemorySampleCount \+= 1/); + assert.doesNotMatch(sampleWorker, /used_heap_size \?\? 0/); + assert.doesNotMatch(sampleWorker, /external_memory \?\? 0/); +}); + +test('raw worker metrics distinguish missing samples from measured zero-byte peaks', () => { + const transport = section( + 'const workers = currentWorkerRecords', + 'const transport: MainCaptureGenerationTransport' + ); + + assert.match( + transport, + /externalMemorySampleCount:\s*record\.externalMemorySampleCount/ + ); + assert.match( + transport, + /heapUsedSampleCount:\s*record\.heapUsedSampleCount/ + ); + assert.match( + transport, + /peakExternalBytes:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*record\.externalPeak[\s\S]*:\s*null/ + ); + assert.match( + transport, + /peakHeapUsedBytes:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*record\.heapPeak[\s\S]*:\s*null/ + ); + assert.match( + transport, + /peakExternalUnavailableReason:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-external-memory-samples-missing'/ + ); + assert.match( + transport, + /peakHeapUsedUnavailableReason:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-heap-used-samples-missing'/ + ); + assert.doesNotMatch( + transport, + /peakExternalBytes: record\.externalPeak[,}]/ + ); + assert.doesNotMatch(transport, /peakHeapUsedBytes: record\.heapPeak[,}]/); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts new file mode 100644 index 000000000..ffe7d9fb2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assessDatabaseWorkerPeakMemoryValidity, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON, +} from './database-worker-peak-memory-validity'; + +describe('database worker peak-memory validity', () => { + it('requires successful heap and external-memory samples in every measured run', () => { + const validity = assessDatabaseWorkerPeakMemoryValidity([ + iteration('warmup', 'warmup', worker(0, 0)), + iteration('run-01', 'measured', worker(3, 4)), + iteration('diagnostic', 'diagnostic', worker(0, 0)), + ]); + + assert.deepEqual(validity, { + invalidMeasuredRuns: [], + measuredRunCount: 1, + validForComparison: true, + validMeasuredRunCount: 1, + }); + }); + + it('fails closed for absent samples instead of accepting initialized zero peaks', () => { + const missingCounts = assessDatabaseWorkerPeakMemoryValidity([ + iteration('run-01', 'measured', { + kind: 'database.worker', + peakExternalBytes: 0, + peakHeapUsedBytes: 0, + }), + iteration('run-02', 'measured', worker(0, 1)), + iteration('run-03', 'measured', worker(1, 0)), + ]); + + assert.deepEqual(missingCounts.invalidMeasuredRuns, [ + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING, + runId: 'run-01', + }, + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING, + runId: 'run-02', + }, + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING, + runId: 'run-03', + }, + ]); + assert.equal(missingCounts.validForComparison, false); + assert.equal(missingCounts.validMeasuredRunCount, 0); + }); + + it('reports stable worker-cardinality and malformed-peak reasons', () => { + const validity = assessDatabaseWorkerPeakMemoryValidity([ + { kind: 'measured', main: { workers: [] }, runId: 'missing' }, + { + kind: 'measured', + main: { workers: [worker(1, 1), worker(1, 1)] }, + runId: 'multiple', + }, + iteration('heap-invalid', 'measured', { + ...worker(1, 1), + peakHeapUsedBytes: Number.NaN, + }), + iteration('external-invalid', 'measured', { + ...worker(1, 1), + peakExternalBytes: -1, + }), + iteration('heap-reason-incoherent', 'measured', { + ...worker(1, 1), + peakHeapUsedUnavailableReason: + 'worker-heap-used-samples-missing', + }), + iteration('external-reason-incoherent', 'measured', { + ...worker(1, 1), + peakExternalUnavailableReason: + 'worker-external-memory-samples-missing', + }), + ]); + + assert.deepEqual( + validity.invalidMeasuredRuns.map(({ reason }) => reason), + [ + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID, + ] + ); + }); +}); + +function iteration( + runId: string, + kind: string, + databaseWorker: Record +) { + return { + kind, + main: { workers: [databaseWorker] }, + runId, + }; +} + +function worker( + heapUsedSampleCount: number, + externalMemorySampleCount: number +) { + return { + externalMemorySampleCount, + heapUsedSampleCount, + kind: 'database.worker', + peakExternalBytes: 600, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: 1_500, + peakHeapUsedUnavailableReason: null, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts new file mode 100644 index 000000000..09f9bf36a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts @@ -0,0 +1,114 @@ +export const DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON = { + EXTERNAL_MEMORY_PEAK_INVALID: + 'database-worker-external-memory-peak-invalid', + EXTERNAL_MEMORY_SAMPLES_MISSING: + 'database-worker-external-memory-samples-missing', + HEAP_PEAK_INVALID: 'database-worker-heap-peak-invalid', + HEAP_SAMPLES_MISSING: 'database-worker-heap-samples-missing', + MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers', + DATABASE_WORKER_MISSING: 'database-worker-missing', +} as const; + +export type DatabaseWorkerPeakMemoryInvalidReason = + (typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON)[keyof typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON]; + +export interface DatabaseWorkerPeakMemoryValidity { + readonly invalidMeasuredRuns: readonly { + readonly reason: DatabaseWorkerPeakMemoryInvalidReason; + readonly runId: string; + }[]; + readonly measuredRunCount: number; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +interface PeakMemoryWorker { + readonly externalMemorySampleCount?: unknown; + readonly heapUsedSampleCount?: unknown; + readonly kind: string; + readonly peakExternalBytes: unknown; + readonly peakExternalUnavailableReason?: unknown; + readonly peakHeapUsedBytes: unknown; + readonly peakHeapUsedUnavailableReason?: unknown; +} + +interface PeakMemoryIteration { + readonly kind: string; + readonly main: { + readonly workers: readonly PeakMemoryWorker[]; + }; + readonly runId: string; +} + +export function assessDatabaseWorkerPeakMemoryValidity( + iterations: readonly PeakMemoryIteration[] +): DatabaseWorkerPeakMemoryValidity { + const measured = iterations.filter( + (iteration) => iteration.kind === 'measured' + ); + const invalidMeasuredRuns: { + reason: DatabaseWorkerPeakMemoryInvalidReason; + runId: string; + }[] = []; + + for (const iteration of measured) { + const workers = iteration.main.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const reason = assessWorkers(workers); + if (reason !== null) { + invalidMeasuredRuns.push({ reason, runId: iteration.runId }); + } + } + + return Object.freeze({ + invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns), + measuredRunCount: measured.length, + validForComparison: + measured.length > 0 && invalidMeasuredRuns.length === 0, + validMeasuredRunCount: measured.length - invalidMeasuredRuns.length, + }); +} + +function assessWorkers( + workers: readonly PeakMemoryWorker[] +): DatabaseWorkerPeakMemoryInvalidReason | null { + if (workers.length === 0) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING; + } + if (workers.length !== 1) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS; + } + + const worker = workers[0]; + if (!worker) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING; + } + if (!isPositiveSampleCount(worker.heapUsedSampleCount)) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING; + } + if ( + !isPeak(worker.peakHeapUsedBytes) || + worker.peakHeapUsedUnavailableReason !== null + ) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID; + } + if (!isPositiveSampleCount(worker.externalMemorySampleCount)) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING; + } + if ( + !isPeak(worker.peakExternalBytes) || + worker.peakExternalUnavailableReason !== null + ) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID; + } + return null; +} + +function isPositiveSampleCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isPeak(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.spec.ts new file mode 100644 index 000000000..631081fd4 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.spec.ts @@ -0,0 +1,116 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +type RequestDisposition = 'after-cutoff' | 'capture' | 'outside-capture'; + +interface CutoffApi { + beginCapture(): void; + beginStop(): void; + finishStop(): void; + observeDatabaseRequest(): RequestDisposition; + rolloverCapture?(): void; + snapshot(): { + readonly lateRequestCount: number; + readonly phase: 'active' | 'idle' | 'stopping'; + }; +} + +interface CutoffModule { + createDatabaseWorkerPostGcCutoffApi?: () => CutoffApi; +} + +const cutoffModulePromise = import( + new URL('./database-worker-post-gc-cutoff.ts', import.meta.url).href +) + .then((module) => module as CutoffModule) + .catch(() => null); + +async function restoreSerializableApi(): Promise { + const module = await cutoffModulePromise; + assert.ok(module, 'database worker post-GC cutoff module must exist'); + const factory = module.createDatabaseWorkerPostGcCutoffApi; + assert.equal(typeof factory, 'function'); + + const source = factory.toString(); + assert.doesNotMatch(source, /__name/); + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => CutoffApi; + return restoredFactory(); +} + +test('classifies requests after the synchronous capture cutoff without carrying state across generations', async () => { + const api = await restoreSerializableApi(); + + assert.equal(api.observeDatabaseRequest(), 'outside-capture'); + api.beginCapture(); + assert.equal(api.observeDatabaseRequest(), 'capture'); + api.beginStop(); + assert.equal(api.observeDatabaseRequest(), 'after-cutoff'); + assert.equal(api.observeDatabaseRequest(), 'after-cutoff'); + assert.deepEqual(api.snapshot(), { + lateRequestCount: 2, + phase: 'stopping', + }); + api.finishStop(); + assert.equal(api.observeDatabaseRequest(), 'outside-capture'); + + api.beginCapture(); + assert.deepEqual(api.snapshot(), { + lateRequestCount: 0, + phase: 'active', + }); +}); + +test('atomically rolls a clean cutoff into the next generation without erasing contamination', async () => { + const clean = await restoreSerializableApi(); + assert.equal(typeof clean.rolloverCapture, 'function'); + clean.beginCapture(); + clean.beginStop(); + clean.rolloverCapture?.(); + assert.deepEqual(clean.snapshot(), { + lateRequestCount: 0, + phase: 'active', + }); + assert.equal(clean.observeDatabaseRequest(), 'capture'); + + const contaminated = await restoreSerializableApi(); + assert.equal(typeof contaminated.rolloverCapture, 'function'); + contaminated.beginCapture(); + contaminated.beginStop(); + assert.equal(contaminated.observeDatabaseRequest(), 'after-cutoff'); + assert.throws( + () => contaminated.rolloverCapture?.(), + /database-worker-post-gc-capture-contaminated/ + ); + assert.deepEqual(contaminated.snapshot(), { + lateRequestCount: 1, + phase: 'stopping', + }); +}); + +test('main capture arms cutoff before awaiting and rejects late DB work before startWorker can restart profiling', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const stopStart = source.indexOf('stop: async ('); + const cutoffStart = source.indexOf( + 'databaseWorkerPostGcCutoffApi.beginStop()', + stopStart + ); + const activeFalse = source.indexOf('state.active = false', stopStart); + const firstAwait = source.indexOf('await ', stopStart); + const observeRequest = source.indexOf( + 'databaseWorkerPostGcCutoffApi.observeDatabaseRequest()' + ); + const startWorker = source.indexOf('startWorker(record)', observeRequest); + + assert.ok(stopStart >= 0); + assert.ok(cutoffStart > stopStart && cutoffStart < firstAwait); + assert.ok(activeFalse > stopStart && activeFalse < firstAwait); + assert.ok(observeRequest >= 0 && observeRequest < startWorker); + assert.match(source, /database-worker-activity-after-cutoff/); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.ts new file mode 100644 index 000000000..997963bdb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.ts @@ -0,0 +1,74 @@ +export type DatabaseWorkerPostGcRequestDisposition = + 'after-cutoff' | 'capture' | 'outside-capture'; + +export interface DatabaseWorkerPostGcCutoffSnapshot { + readonly lateRequestCount: number; + readonly phase: 'active' | 'idle' | 'stopping'; +} + +export interface DatabaseWorkerPostGcCutoffApi { + beginCapture(): void; + beginStop(): void; + finishStop(): void; + observeDatabaseRequest(): DatabaseWorkerPostGcRequestDisposition; + rolloverCapture(): void; + snapshot(): DatabaseWorkerPostGcCutoffSnapshot; +} + +export function createDatabaseWorkerPostGcCutoffApi(): DatabaseWorkerPostGcCutoffApi { + let lateRequestCount = 0; + let phase: DatabaseWorkerPostGcCutoffSnapshot['phase'] = 'idle'; + + const api: DatabaseWorkerPostGcCutoffApi = { + beginCapture(): void { + if (phase !== 'idle') { + throw new Error( + phase === 'stopping' + ? 'database-worker-post-gc-stop-in-progress' + : 'database-worker-post-gc-capture-already-active' + ); + } + lateRequestCount = 0; + phase = 'active'; + }, + + beginStop(): void { + if (phase !== 'active') { + throw new Error('database-worker-post-gc-capture-not-active'); + } + phase = 'stopping'; + }, + + finishStop(): void { + phase = 'idle'; + }, + + observeDatabaseRequest(): DatabaseWorkerPostGcRequestDisposition { + if (phase === 'active') { + return 'capture'; + } + if (phase === 'stopping') { + lateRequestCount += 1; + return 'after-cutoff'; + } + return 'outside-capture'; + }, + + rolloverCapture(): void { + if (phase !== 'stopping') { + throw new Error('database-worker-post-gc-stop-not-in-progress'); + } + if (lateRequestCount > 0) { + throw new Error('database-worker-post-gc-capture-contaminated'); + } + lateRequestCount = 0; + phase = 'active'; + }, + + snapshot(): DatabaseWorkerPostGcCutoffSnapshot { + return Object.freeze({ lateRequestCount, phase }); + }, + }; + + return Object.freeze(api); +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts new file mode 100644 index 000000000..bcd3ac969 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts @@ -0,0 +1,392 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +type AncillaryFailureStage = 'heap-snapshot' | 'post-gc-probe' | 'profile-stop'; + +type PostGcOutcome = + | { + readonly postGcHeapUsedBytes: number; + readonly unavailableReason: null; + } + | { + readonly postGcHeapUsedBytes: null; + readonly unavailableReason: string; + }; + +interface FinalizationInput { + readonly finalizationKey: object; + readonly joinFinalSample: () => Promise; + readonly probePostGc: () => Promise; + readonly reportAncillaryFailure: ( + stage: AncillaryFailureStage, + error: unknown + ) => void; + readonly stopProfile: () => Promise; + readonly stopSampling: () => void; + readonly takeHeapSnapshot?: () => Promise; +} + +interface FinalizationApi { + finalize(input: FinalizationInput): Promise; +} + +interface FinalizationModule { + createDatabaseWorkerPostGcFinalizationApi?: () => FinalizationApi; +} + +const finalizationModulePromise = import( + new URL('./database-worker-post-gc-finalization.ts', import.meta.url).href +) + .then((module) => module as FinalizationModule) + .catch(() => null); + +function deferred(): { + readonly promise: Promise; + readonly resolve: () => void; +} { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function restoreSerializableApi(): Promise { + const module = await finalizationModulePromise; + assert.ok(module, 'database worker post-GC finalization module must exist'); + const factory = module.createDatabaseWorkerPostGcFinalizationApi; + assert.equal(typeof factory, 'function'); + + const source = factory.toString(); + assert.doesNotMatch(source, /__name/); + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => FinalizationApi; + return restoredFactory(); +} + +test('coordinates database worker post-GC finalization in order and single-flight', async () => { + const api = await restoreSerializableApi(); + const finalSampleGate = deferred(); + const events: string[] = []; + let probeCalls = 0; + const successfulPostGc = { + postGcHeapUsedBytes: 98_304, + unavailableReason: null, + } as const; + const input: FinalizationInput = { + finalizationKey: {}, + joinFinalSample: async () => { + events.push('final-sample:start'); + await finalSampleGate.promise; + events.push('final-sample:joined'); + }, + probePostGc: async () => { + probeCalls += 1; + events.push('post-gc-probe'); + return successfulPostGc; + }, + reportAncillaryFailure: () => { + assert.fail('the successful path has no ancillary failures'); + }, + stopProfile: async () => { + events.push('profile-stop'); + }, + stopSampling: () => { + events.push('sampling-stop'); + }, + takeHeapSnapshot: async () => { + events.push('heap-snapshot'); + }, + }; + + const firstFinalization = api.finalize(input); + const concurrentFinalization = api.finalize(input); + await Promise.resolve(); + + assert.deepEqual(events, ['sampling-stop', 'final-sample:start']); + assert.equal(probeCalls, 0); + + finalSampleGate.resolve(); + const [firstResult, concurrentResult] = await Promise.all([ + firstFinalization, + concurrentFinalization, + ]); + + assert.deepEqual(events, [ + 'sampling-stop', + 'final-sample:start', + 'final-sample:joined', + 'profile-stop', + 'post-gc-probe', + 'heap-snapshot', + ]); + assert.equal(probeCalls, 1); + assert.deepEqual(firstResult, successfulPostGc); + assert.deepEqual(concurrentResult, successfulPostGc); +}); + +test('reports profile and snapshot failures without erasing successful post-GC', async () => { + const api = await restoreSerializableApi(); + const events: string[] = []; + const profileError = new Error('profile-stop-failed'); + const snapshotError = new Error('heap-snapshot-failed'); + const failures: { + readonly error: unknown; + readonly stage: AncillaryFailureStage; + }[] = []; + + const result = await api.finalize({ + finalizationKey: {}, + joinFinalSample: async () => { + events.push('final-sample:joined'); + }, + probePostGc: async () => { + events.push('post-gc-probe'); + return { + postGcHeapUsedBytes: 131_072, + unavailableReason: null, + }; + }, + reportAncillaryFailure: (stage, error) => { + failures.push({ error, stage }); + }, + stopProfile: async () => { + events.push('profile-stop'); + throw profileError; + }, + stopSampling: () => { + events.push('sampling-stop'); + }, + takeHeapSnapshot: async () => { + events.push('heap-snapshot'); + throw snapshotError; + }, + }); + + assert.deepEqual(events, [ + 'sampling-stop', + 'final-sample:joined', + 'profile-stop', + 'post-gc-probe', + 'heap-snapshot', + ]); + assert.deepEqual(failures, [ + { error: profileError, stage: 'profile-stop' }, + { error: snapshotError, stage: 'heap-snapshot' }, + ]); + assert.deepEqual(result, { + postGcHeapUsedBytes: 131_072, + unavailableReason: null, + }); +}); + +test('fails closed when the post-GC probe throws without relabelling profile artifacts', async () => { + const api = await restoreSerializableApi(); + const probeError = new Error('probe-failed'); + const failures: { + readonly error: unknown; + readonly stage: AncillaryFailureStage; + }[] = []; + + const result = await api.finalize({ + finalizationKey: {}, + joinFinalSample: async () => undefined, + probePostGc: async () => { + throw probeError; + }, + reportAncillaryFailure: (stage, error) => { + failures.push({ error, stage }); + }, + stopProfile: async () => undefined, + stopSampling: () => undefined, + }); + + assert.deepEqual(result, { + postGcHeapUsedBytes: null, + unavailableReason: 'capture-failed', + }); + assert.deepEqual(failures, [{ error: probeError, stage: 'post-gc-probe' }]); +}); + +test('the Electron main capture wires exact DB selection and explicit-GC finalization', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match( + source, + /databaseWorkerPostGcSelectionApiFactorySource:\s+createDatabaseWorkerPostGcSelectionApi\.toString\(\)/ + ); + assert.match( + source, + /databaseWorkerPostGcProbeApiFactorySource:\s+createDatabaseWorkerPostGcProbeApi\.toString\(\)/ + ); + assert.match( + source, + /databaseWorkerPostGcFinalizationApiFactorySource:\s+createDatabaseWorkerPostGcFinalizationApi\.toString\(\)/ + ); + assert.match(source, /new workerThreads\.MessageChannel\(\)/); + assert.match( + source, + /databaseWorkerPostGcSelectionApi\.select\(\s*currentWorkerRecords,\s*state\.captureGeneration\s*\)/ + ); + assert.match(source, /databaseWorkerPostGcFinalizationApi\s*\.finalize\(/); + assert.match(source, /pendingCount: 0/); + assert.match(source, /record\.pendingCount \+= 1/); + assert.match( + source, + /request\.record\.pendingCount = Math\.max\(\s*0,\s*request\.record\.pendingCount - 1\s*\)/ + ); + assert.match(source, /samplePromise/); + assert.match(source, /postGcHeapUnavailableReason/); + assert.match(source, /ordinal: nextWorkerOrdinal/); + assert.match( + source, + /`\$\{record\.kind\}-\$\{record\.ordinal\}\.cpuprofile`/ + ); + assert.doesNotMatch(source, /sampleBusy/); + assert.doesNotMatch(source, /const postSnapshot/); +}); + +test('main CPU profiling stops at the operation cutoff before worker artifact finalization', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const stopStart = source.indexOf('stop: async ('); + const mainProfileStop = source.indexOf("'Profiler.stop'", stopStart); + const databaseSelection = source.indexOf( + 'databaseWorkerPostGcSelectionApi.select', + stopStart + ); + const databaseFinalization = source.indexOf( + 'await finalizeDatabaseWorker', + stopStart + ); + + assert.ok(stopStart >= 0); + assert.ok(mainProfileStop > stopStart); + assert.ok(mainProfileStop < databaseSelection); + assert.ok(mainProfileStop < databaseFinalization); +}); + +test('worker CPU profile serialization runs only after the main CPU profiler stops', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const profileStopStart = source.indexOf('const stopWorkerProfile'); + const profileStopEnd = source.indexOf( + 'const writeWorkerProfile', + profileStopStart + ); + const profileStop = source.slice(profileStopStart, profileStopEnd); + const stopStart = source.indexOf('stop: async ('); + const mainProfileStop = source.indexOf("'Profiler.stop'", stopStart); + const workerProfileFlush = source.indexOf( + 'flushWorkerProfiles(currentWorkerRecords)', + mainProfileStop + ); + + assert.match(profileStop, /const profileResult = await handle\.stop\(\)/); + assert.match(profileStop, /record\.profileResult = profileResult/); + assert.doesNotMatch(profileStop, /writeFileSync|JSON\.stringify/); + assert.ok(mainProfileStop > stopStart); + assert.ok(workerProfileFlush > mainProfileStop); +}); + +test('measured termination stays unperturbed while diagnostic capture finalizes its profile before termination', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const terminateStart = source.indexOf( + 'WorkerClass.prototype.terminate = function' + ); + const terminateEnd = source.indexOf('const inspectorPost', terminateStart); + const terminateBlock = source.slice(terminateStart, terminateEnd); + const terminatingFinalizerStart = source.indexOf( + 'const finalizeTerminatingWorker' + ); + const terminatingFinalizerEnd = source.indexOf( + 'WorkerClass.prototype.postMessage', + terminatingFinalizerStart + ); + const terminatingFinalizer = source.slice( + terminatingFinalizerStart, + terminatingFinalizerEnd + ); + + const measuredTerminate = terminateBlock.indexOf( + 'const termination = originalTerminate.call(this)' + ); + const measuredFinalization = terminateBlock.indexOf( + 'void finalizeTerminatingWorker(record, false)' + ); + const diagnosticFinalizationStart = terminateBlock.indexOf( + 'void finalizeTerminatingWorker(record, true)' + ); + const diagnosticFinalizationWait = terminateBlock.indexOf( + 'await waitForTerminatingWorkerFinalization(record)' + ); + const diagnosticTerminate = terminateBlock.indexOf( + 'return originalTerminate.call(this)', + diagnosticFinalizationWait + ); + + assert.ok( + diagnosticFinalizationStart >= 0 && + diagnosticFinalizationStart < diagnosticFinalizationWait && + diagnosticFinalizationWait < diagnosticTerminate, + 'diagnostic capture must start profile finalization, bound its wait, and then terminate the worker' + ); + assert.ok( + measuredTerminate >= 0 && measuredTerminate < measuredFinalization, + 'measured capture must initiate termination before best-effort finalization' + ); + assert.doesNotMatch(terminatingFinalizer, /takeWorkerHeapSnapshot/); + assert.match( + terminatingFinalizer, + /stopWorkerProfile\(record, waitForProfileHandle\)/ + ); + assert.match(source, /resolvedProfileHandle/); + assert.match(source, /worker-profile-finalization-timeout/); + assert.match(source, /finalizationTimedOut/); + assert.match(source, /profileCaptureKey/); + assert.match(source, /record\.profileCaptureKey !== profileCaptureKey/); +}); + +test('worker capture failures use an artifact-neutral timeline label', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match(source, /worker-artifact-error:\$\{stage\}/); + assert.doesNotMatch(source, /worker-profile-error:\$\{stage\}/); +}); + +test('capture generation resets artifact paths and always emits current DB records', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const start = source.indexOf('const startCapture = async ('); + const diagnostic = source.indexOf('if (state.diagnostic)', start); + const profileReset = source.indexOf('state.mainProfilePath = null', start); + const snapshotReset = source.indexOf( + 'state.mainSnapshotPath = null', + start + ); + + assert.ok(profileReset > start && profileReset < diagnostic); + assert.ok(snapshotReset > start && snapshotReset < diagnostic); + assert.match( + source, + /record\.captureGeneration === state\.captureGeneration[\s\S]*record\.kind === 'database\.worker'/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.ts new file mode 100644 index 000000000..53ff8a226 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.ts @@ -0,0 +1,90 @@ +import type { DatabaseWorkerPostGcProbeUnavailableReason } from './database-worker-post-gc-probe'; +import type { DatabaseWorkerPostGcUnavailableReason } from './database-worker-post-gc-selection'; + +export type DatabaseWorkerPostGcAncillaryFailureStage = + 'heap-snapshot' | 'post-gc-probe' | 'profile-stop' | 'profile-write'; + +export type DatabaseWorkerPostGcFinalizationOutcome = + | { + readonly postGcHeapUsedBytes: number; + readonly unavailableReason: null; + } + | { + readonly postGcHeapUsedBytes: null; + readonly unavailableReason: + | DatabaseWorkerPostGcProbeUnavailableReason + | DatabaseWorkerPostGcUnavailableReason; + }; + +export interface DatabaseWorkerPostGcFinalizationInput { + readonly finalizationKey: object; + readonly joinFinalSample: () => Promise; + readonly probePostGc: () => Promise; + readonly reportAncillaryFailure: ( + stage: DatabaseWorkerPostGcAncillaryFailureStage, + error: unknown + ) => void; + readonly stopProfile: () => Promise; + readonly stopSampling: () => void; + readonly takeHeapSnapshot?: () => Promise; +} + +export interface DatabaseWorkerPostGcFinalizationApi { + finalize( + input: DatabaseWorkerPostGcFinalizationInput + ): Promise; +} + +export function createDatabaseWorkerPostGcFinalizationApi(): DatabaseWorkerPostGcFinalizationApi { + const finalizations = new WeakMap< + object, + Promise + >(); + + const helpers = { + async run( + input: DatabaseWorkerPostGcFinalizationInput + ): Promise { + input.stopSampling(); + await input.joinFinalSample(); + try { + await input.stopProfile(); + } catch (error: unknown) { + input.reportAncillaryFailure('profile-stop', error); + } + + let outcome: DatabaseWorkerPostGcFinalizationOutcome; + try { + outcome = await input.probePostGc(); + } catch (error: unknown) { + input.reportAncillaryFailure('post-gc-probe', error); + outcome = { + postGcHeapUsedBytes: null, + unavailableReason: 'capture-failed', + }; + } + if (input.takeHeapSnapshot) { + try { + await input.takeHeapSnapshot(); + } catch (error: unknown) { + input.reportAncillaryFailure('heap-snapshot', error); + } + } + return outcome; + }, + + finalize( + input: DatabaseWorkerPostGcFinalizationInput + ): Promise { + const existing = finalizations.get(input.finalizationKey); + if (existing) { + return existing; + } + const finalization = helpers.run(input); + finalizations.set(input.finalizationKey, finalization); + return finalization; + }, + }; + + return Object.freeze({ finalize: helpers.finalize }); +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts new file mode 100644 index 000000000..7ddf4e4cf --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts @@ -0,0 +1,469 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +/* eslint-disable max-lines -- The one-shot transport contract keeps all terminal-path fixtures together. */ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +type WorkerUnavailableReason = + 'capture-failed' | 'gc-unavailable' | 'profiling-disabled' | 'worker-busy'; + +type MainUnavailableReason = + | 'post-gc-probe-invalid-response' + | 'post-gc-probe-message-error' + | 'post-gc-probe-port-closed' + | 'post-gc-probe-post-failed' + | 'post-gc-probe-timeout'; + +type ProbeUnavailableReason = WorkerUnavailableReason | MainUnavailableReason; + +type ProbeResult = + | { + readonly postGcHeapUsedBytes: number; + readonly unavailableReason: null; + } + | { + readonly postGcHeapUsedBytes: null; + readonly unavailableReason: ProbeUnavailableReason; + }; + +interface ProbePort { + close(): void; + off(event: 'close', listener: () => void): unknown; + off(event: 'message', listener: (message: unknown) => void): unknown; + off(event: 'messageerror', listener: (error: unknown) => void): unknown; + on(event: 'close', listener: () => void): unknown; + on(event: 'message', listener: (message: unknown) => void): unknown; + on(event: 'messageerror', listener: (error: unknown) => void): unknown; + postMessage(message: unknown): void; + start(): void; +} + +interface ProbeWorker { + postMessage(message: unknown, transferList: readonly unknown[]): void; +} + +interface ProbeTimers { + clearTimeout(handle: unknown): void; + setTimeout(callback: () => void, delayMs: number): unknown; +} + +interface ProbeApi { + probe(input: { + readonly createMessageChannel: () => { + readonly port1: ProbePort; + readonly port2: ProbePort; + }; + readonly timeoutMs?: number; + readonly timers?: ProbeTimers; + readonly worker: ProbeWorker; + }): Promise; +} + +interface ProbeModule { + createDatabaseWorkerPostGcProbeApi?: () => ProbeApi; +} + +interface ProductionWorkerPostGcModule { + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON?: Readonly< + Record + >; +} + +const probeModulePromise = import( + new URL('./database-worker-post-gc-probe.ts', import.meta.url).href +) + .then((module) => module as ProbeModule) + .catch(() => null); +const productionWorkerPostGcModulePromise = import( + new URL( + '../../../electron-backend/src/app/workers/database-worker-post-gc-heap.ts', + import.meta.url + ).href +) + .then((module) => module as ProductionWorkerPostGcModule) + .catch(() => null); + +class FakePort extends EventEmitter implements ProbePort { + closeCalls = 0; + peer: FakePort | null = null; + startCalls = 0; + + close(): void { + this.closeCalls += 1; + } + + postMessage(message: unknown): void { + this.peer?.emit('message', message); + } + + start(): void { + this.startCalls += 1; + } +} + +class FakeTimers implements ProbeTimers { + readonly cleared: unknown[] = []; + readonly scheduled: { + readonly callback: () => void; + readonly delayMs: number; + readonly handle: number; + }[] = []; + private nextHandle = 1; + + clearTimeout(handle: unknown): void { + this.cleared.push(handle); + } + + fire(handle: number): void { + const timer = this.scheduled.find( + (candidate) => candidate.handle === handle + ); + assert.ok(timer, `timer ${handle} must exist`); + timer.callback(); + } + + setTimeout(callback: () => void, delayMs: number): number { + const handle = this.nextHandle; + this.nextHandle += 1; + this.scheduled.push({ callback, delayMs, handle }); + return handle; + } +} + +interface ProbeHarness { + readonly createMessageChannel: () => { + readonly port1: FakePort; + readonly port2: FakePort; + }; + readonly port1: FakePort; + readonly port2: FakePort; + readonly timers: FakeTimers; +} + +function createProbeHarness(): ProbeHarness { + const port1 = new FakePort(); + const port2 = new FakePort(); + port1.peer = port2; + port2.peer = port1; + const timers = new FakeTimers(); + + return { + createMessageChannel: () => ({ port1, port2 }), + port1, + port2, + timers, + }; +} + +async function restoreSerializableApi(): Promise { + const module = await probeModulePromise; + assert.ok(module, 'database worker post-GC probe module must exist'); + const factory = module.createDatabaseWorkerPostGcProbeApi; + assert.equal(typeof factory, 'function'); + + const source = factory.toString(); + assert.doesNotMatch(source, /__name/); + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => ProbeApi; + return restoredFactory(); +} + +function assertCoherentResult(result: ProbeResult): void { + const hasHeap = + Number.isSafeInteger(result.postGcHeapUsedBytes) && + Number(result.postGcHeapUsedBytes) >= 0; + assert.equal( + hasHeap, + result.unavailableReason === null, + 'probe result must preserve the heap/reason XOR' + ); +} + +function cleanupCounts(port: FakePort): Record { + return { + close: port.listenerCount('close'), + message: port.listenerCount('message'), + messageerror: port.listenerCount('messageerror'), + }; +} + +test('serializes the factory and sends the exact one-shot worker request with its transfer port', async () => { + const api = await restoreSerializableApi(); + const harness = createProbeHarness(); + let postedMessage: unknown; + let postedTransferList: readonly unknown[] | null = null; + let terminateCalls = 0; + const worker = { + postMessage(message: unknown, transferList: readonly unknown[]): void { + postedMessage = message; + postedTransferList = transferList; + const responsePort = ( + message as { readonly responsePort: ProbePort } + ).responsePort; + responsePort.postMessage({ + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: 73_728, + unavailableReason: null, + }); + }, + terminate(): void { + terminateCalls += 1; + }, + }; + + const result = await api.probe({ + createMessageChannel: harness.createMessageChannel, + timers: harness.timers, + worker, + }); + + assert.deepEqual(postedMessage, { + type: 'performance:collect-post-gc-heap', + responsePort: harness.port2, + }); + assert.deepEqual(postedTransferList, [harness.port2]); + assert.deepEqual(result, { + postGcHeapUsedBytes: 73_728, + unavailableReason: null, + }); + assertCoherentResult(result); + assert.equal(harness.port1.startCalls, 1); + assert.equal(harness.port1.closeCalls, 1); + assert.deepEqual(cleanupCounts(harness.port1), { + close: 0, + message: 0, + messageerror: 0, + }); + assert.deepEqual( + harness.timers.scheduled.map((timer) => timer.delayMs), + [5_000] + ); + assert.deepEqual(harness.timers.cleared, [1]); + assert.equal(terminateCalls, 0); +}); + +test('preserves every coherent worker-side unavailable result', async () => { + const api = await restoreSerializableApi(); + const productionModule = await productionWorkerPostGcModulePromise; + assert.ok(productionModule, 'production worker post-GC module must load'); + const productionReasons = + productionModule.DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON; + assert.ok(productionReasons, 'production worker reasons must be exported'); + const reasons = Object.values(productionReasons); + assert.deepEqual( + new Set(reasons), + new Set([ + 'capture-failed', + 'gc-unavailable', + 'profiling-disabled', + 'worker-busy', + ]) + ); + + for (const unavailableReason of reasons) { + const harness = createProbeHarness(); + const result = await api.probe({ + createMessageChannel: harness.createMessageChannel, + timers: harness.timers, + worker: { + postMessage(message: unknown): void { + ( + message as { readonly responsePort: ProbePort } + ).responsePort.postMessage({ + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason, + }); + }, + }, + }); + + assert.deepEqual(result, { + postGcHeapUsedBytes: null, + unavailableReason, + }); + assertCoherentResult(result); + } +}); + +test('fails closed for malformed or incoherent worker responses', async () => { + const api = await restoreSerializableApi(); + const malformedResponses: readonly unknown[] = [ + null, + [], + { + type: 'wrong-result', + postGcHeapUsedBytes: 1, + unavailableReason: null, + }, + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: 1, + unavailableReason: 'capture-failed', + }, + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: null, + }, + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: 'unknown-reason', + }, + ...[-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY].map( + (postGcHeapUsedBytes) => ({ + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes, + unavailableReason: null, + }) + ), + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: Number.MAX_SAFE_INTEGER + 1, + unavailableReason: null, + }, + ]; + + for (const response of malformedResponses) { + const harness = createProbeHarness(); + const result = await api.probe({ + createMessageChannel: harness.createMessageChannel, + timers: harness.timers, + worker: { + postMessage(message: unknown): void { + ( + message as { readonly responsePort: ProbePort } + ).responsePort.postMessage(response); + }, + }, + }); + + assert.deepEqual(result, { + postGcHeapUsedBytes: null, + unavailableReason: 'post-gc-probe-invalid-response', + }); + assertCoherentResult(result); + } +}); + +test('times out once with an injectable deadline and ignores every later terminal signal', async () => { + const api = await restoreSerializableApi(); + const harness = createProbeHarness(); + let terminateCalls = 0; + const resultPromise = api.probe({ + createMessageChannel: harness.createMessageChannel, + timeoutMs: 37, + timers: harness.timers, + worker: { + postMessage(): void { + // Keep the one-shot probe pending until its bounded deadline. + }, + terminate(): void { + terminateCalls += 1; + }, + }, + }); + const timer = harness.timers.scheduled[0]; + assert.ok(timer); + assert.equal(timer.delayMs, 37); + + harness.timers.fire(timer.handle); + const result = await resultPromise; + harness.port1.emit('message', { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: 99, + unavailableReason: null, + }); + harness.port1.emit('messageerror', new Error('late message error')); + harness.port1.emit('close'); + timer.callback(); + + assert.deepEqual(result, { + postGcHeapUsedBytes: null, + unavailableReason: 'post-gc-probe-timeout', + }); + assertCoherentResult(result); + assert.equal(harness.port1.closeCalls, 1); + assert.deepEqual(cleanupCounts(harness.port1), { + close: 0, + message: 0, + messageerror: 0, + }); + assert.deepEqual(harness.timers.cleared, [timer.handle]); + assert.equal(terminateCalls, 0); +}); + +test('maps message errors and an early response-port close to fixed reasons', async () => { + const api = await restoreSerializableApi(); + const cases = [ + { + emit(port: FakePort): void { + port.emit('messageerror', new Error('clone failed')); + }, + reason: 'post-gc-probe-message-error', + }, + { + emit(port: FakePort): void { + port.emit('close'); + }, + reason: 'post-gc-probe-port-closed', + }, + ] as const; + + for (const testCase of cases) { + const harness = createProbeHarness(); + const resultPromise = api.probe({ + createMessageChannel: harness.createMessageChannel, + timers: harness.timers, + worker: { + postMessage(): void { + testCase.emit(harness.port1); + }, + }, + }); + + const result = await resultPromise; + assert.deepEqual(result, { + postGcHeapUsedBytes: null, + unavailableReason: testCase.reason, + }); + assertCoherentResult(result); + assert.equal(harness.port1.closeCalls, 1); + assert.deepEqual(cleanupCounts(harness.port1), { + close: 0, + message: 0, + messageerror: 0, + }); + } +}); + +test('closes both ports and reports a fixed reason when the request cannot be posted', async () => { + const api = await restoreSerializableApi(); + const harness = createProbeHarness(); + + const result = await api.probe({ + createMessageChannel: harness.createMessageChannel, + timers: harness.timers, + worker: { + postMessage(): void { + throw new Error('worker already exited'); + }, + }, + }); + + assert.deepEqual(result, { + postGcHeapUsedBytes: null, + unavailableReason: 'post-gc-probe-post-failed', + }); + assertCoherentResult(result); + assert.equal(harness.port1.closeCalls, 1); + assert.equal(harness.port2.closeCalls, 1); + assert.deepEqual(cleanupCounts(harness.port1), { + close: 0, + message: 0, + messageerror: 0, + }); + assert.deepEqual(harness.timers.cleared, [1]); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.ts new file mode 100644 index 000000000..7e11645c6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.ts @@ -0,0 +1,242 @@ +export type DatabaseWorkerPostGcProbeUnavailableReason = + | 'capture-failed' + | 'gc-unavailable' + | 'post-gc-probe-invalid-response' + | 'post-gc-probe-message-error' + | 'post-gc-probe-port-closed' + | 'post-gc-probe-post-failed' + | 'post-gc-probe-timeout' + | 'profiling-disabled' + | 'worker-busy'; + +export type DatabaseWorkerPostGcProbeResult = + | { + readonly postGcHeapUsedBytes: number; + readonly unavailableReason: null; + } + | { + readonly postGcHeapUsedBytes: null; + readonly unavailableReason: DatabaseWorkerPostGcProbeUnavailableReason; + }; + +export interface DatabaseWorkerPostGcProbePort { + close(): void; + off(event: 'close', listener: () => void): unknown; + off(event: 'message', listener: (message: unknown) => void): unknown; + off(event: 'messageerror', listener: (error: unknown) => void): unknown; + on(event: 'close', listener: () => void): unknown; + on(event: 'message', listener: (message: unknown) => void): unknown; + on(event: 'messageerror', listener: (error: unknown) => void): unknown; + start(): void; +} + +export interface DatabaseWorkerPostGcProbeWorker { + postMessage(message: unknown, transferList: readonly unknown[]): void; +} + +export interface DatabaseWorkerPostGcProbeTimers { + clearTimeout(handle: unknown): void; + setTimeout(callback: () => void, delayMs: number): unknown; +} + +export interface DatabaseWorkerPostGcProbeInput { + readonly createMessageChannel: () => { + readonly port1: DatabaseWorkerPostGcProbePort; + readonly port2: DatabaseWorkerPostGcProbePort; + }; + readonly timeoutMs?: number; + readonly timers?: DatabaseWorkerPostGcProbeTimers; + readonly worker: DatabaseWorkerPostGcProbeWorker; +} + +export interface DatabaseWorkerPostGcProbeApi { + probe( + input: DatabaseWorkerPostGcProbeInput + ): Promise; +} + +export function createDatabaseWorkerPostGcProbeApi(): DatabaseWorkerPostGcProbeApi { + type JsonRecord = Record; + + const DEFAULT_TIMEOUT_MS = 5_000; + const REQUEST_TYPE = 'performance:collect-post-gc-heap'; + const RESULT_TYPE = 'performance:post-gc-heap-result'; + const workerUnavailableReasons = new Set([ + 'capture-failed', + 'gc-unavailable', + 'profiling-disabled', + 'worker-busy', + ]); + const defaultTimers: DatabaseWorkerPostGcProbeTimers = { + clearTimeout(handle: unknown): void { + globalThis.clearTimeout( + handle as ReturnType + ); + }, + setTimeout(callback: () => void, delayMs: number): unknown { + return globalThis.setTimeout(callback, delayMs); + }, + }; + + const helpers = { + unavailable( + unavailableReason: DatabaseWorkerPostGcProbeUnavailableReason + ): DatabaseWorkerPostGcProbeResult { + return Object.freeze({ + postGcHeapUsedBytes: null, + unavailableReason, + }); + }, + + normalizeResponse(response: unknown): DatabaseWorkerPostGcProbeResult { + if ( + typeof response !== 'object' || + response === null || + Array.isArray(response) + ) { + return helpers.unavailable('post-gc-probe-invalid-response'); + } + + const candidate = response as JsonRecord; + const postGcHeapUsedBytes = candidate['postGcHeapUsedBytes']; + const unavailableReason = candidate['unavailableReason']; + if ( + candidate['type'] === RESULT_TYPE && + Number.isSafeInteger(postGcHeapUsedBytes) && + Number(postGcHeapUsedBytes) >= 0 && + unavailableReason === null + ) { + return Object.freeze({ + postGcHeapUsedBytes: Number(postGcHeapUsedBytes), + unavailableReason: null, + }); + } + if ( + candidate['type'] === RESULT_TYPE && + postGcHeapUsedBytes === null && + typeof unavailableReason === 'string' && + workerUnavailableReasons.has(unavailableReason) + ) { + return helpers.unavailable( + unavailableReason as DatabaseWorkerPostGcProbeUnavailableReason + ); + } + return helpers.unavailable('post-gc-probe-invalid-response'); + }, + + probe( + input: DatabaseWorkerPostGcProbeInput + ): Promise { + let channel: ReturnType; + try { + channel = input.createMessageChannel(); + } catch { + return Promise.resolve( + helpers.unavailable('post-gc-probe-post-failed') + ); + } + + const { port1, port2 } = channel; + const timers = input.timers ?? defaultTimers; + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + return new Promise((resolve) => { + let settled = false; + let timerHandle: unknown; + let timerScheduled = false; + const callbacks = { + cleanup(): void { + try { + port1.off('message', callbacks.onMessage); + } catch { + // Best-effort profiling cleanup must not escape. + } + try { + port1.off('messageerror', callbacks.onMessageError); + } catch { + // Best-effort profiling cleanup must not escape. + } + try { + port1.off('close', callbacks.onClose); + } catch { + // Best-effort profiling cleanup must not escape. + } + if (timerScheduled) { + try { + timers.clearTimeout(timerHandle); + } catch { + // Best-effort profiling cleanup must not escape. + } + } + try { + port1.close(); + } catch { + // The one-shot response port may already be closed. + } + }, + + settle(result: DatabaseWorkerPostGcProbeResult): void { + if (settled) { + return; + } + settled = true; + callbacks.cleanup(); + resolve(result); + }, + + onClose(): void { + callbacks.settle( + helpers.unavailable('post-gc-probe-port-closed') + ); + }, + + onMessage(message: unknown): void { + callbacks.settle(helpers.normalizeResponse(message)); + }, + + onMessageError(): void { + callbacks.settle( + helpers.unavailable('post-gc-probe-message-error') + ); + }, + + onTimeout(): void { + callbacks.settle( + helpers.unavailable('post-gc-probe-timeout') + ); + }, + }; + + try { + port1.on('message', callbacks.onMessage); + port1.on('messageerror', callbacks.onMessageError); + port1.on('close', callbacks.onClose); + port1.start(); + timerHandle = timers.setTimeout( + callbacks.onTimeout, + timeoutMs + ); + timerScheduled = true; + input.worker.postMessage( + { + type: REQUEST_TYPE, + responsePort: port2, + }, + [port2] + ); + } catch { + callbacks.settle( + helpers.unavailable('post-gc-probe-post-failed') + ); + try { + port2.close(); + } catch { + // A synchronous transfer failure may already close it. + } + } + }); + }, + }; + + return Object.freeze({ probe: helpers.probe }); +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts new file mode 100644 index 000000000..83b221927 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts @@ -0,0 +1,131 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + type CancellationBenchmarkManifest, + type CancellationIterationResult, + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, + type WorkerCaptureMetrics, +} from './m3u-refresh-cancellation-contract'; +import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report'; + +function databaseWorker( + postGcHeapUsedBytes: number | null, + postGcHeapUnavailableReason: string | null +): WorkerCaptureMetrics { + return { + kind: PERFORMANCE_WORKER_KIND.DATABASE, + peakExternalBytes: 0, + peakHeapUsedBytes: 0, + postGcHeapUnavailableReason, + postGcHeapUsedBytes, + requests: [], + } as unknown as WorkerCaptureMetrics; +} + +function measuredIteration( + workers: readonly WorkerCaptureMetrics[] +): CancellationIterationResult { + return { + cancellationEffectObserved: true, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 }, + eventLoopUtilization: null, + memory: { + peakHeapUsedBytes: 0, + peakRssBytes: 0, + postGcHeapUsedBytes: null, + postGcRssBytes: null, + }, + rendererWindow: { + responsiveEvents: 0, + rss: { + identity: { + creationTime: 1_721_234_567_890, + pid: 42, + }, + missingSampleCount: 0, + peakRssBytes: 2_048, + unavailableReason: null, + validSampleCount: 1, + }, + unresponsiveEvents: 0, + windowIdentity: { + browserWindowId: 7, + webContentsId: 11, + }, + }, + workers, + }, + phases: {}, + renderer: { + peakHeapUsedBytes: 0, + postGcHeapUsedBytes: null, + probe: { + frameGapsMs: [], + heartbeatDelaysMs: [], + longTasksMs: [], + }, + }, + runId: 'measured', + } as unknown as CancellationIterationResult; +} + +test('summary includes every database isolate instead of silently taking the first', () => { + const unavailable = databaseWorker(null, 'post-gc-probe-invalid-response'); + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [ + measuredIteration([ + databaseWorker(100, null), + databaseWorker(300, null), + unavailable, + ]), + ] + ); + + assert.deepEqual(summary.measured.databaseWorkerPostGcHeapBytes, { + count: 2, + max: 300, + mean: 200, + median: 200, + min: 100, + p95: 290, + p99: 298, + }); + assert.deepEqual( + ( + summary as unknown as { + readonly validity: { + readonly databaseWorkerPostGc: unknown; + }; + } + ).validity.databaseWorkerPostGc, + { + applicableMeasuredRunCount: 1, + invalidMeasuredRuns: [ + { + databaseWorkerCount: 3, + reason: 'database-worker-unexpected-activity', + runId: 'measured', + }, + ], + measuredRunCount: 1, + notApplicableMeasuredRuns: [], + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + } + ); + assert.equal( + ( + summary.iterations[0]?.main.workers[2] as WorkerCaptureMetrics & { + readonly postGcHeapUnavailableReason: string | null; + } + )?.postGcHeapUnavailableReason, + 'post-gc-probe-invalid-response' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.spec.ts new file mode 100644 index 000000000..89559182f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.spec.ts @@ -0,0 +1,145 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +type WorkerKind = 'database.worker' | 'playlist-refresh.worker'; + +interface TestWorkerRecord { + readonly captureGeneration: number | null; + readonly finalized: boolean; + readonly kind: WorkerKind; + readonly ordinal: number; + readonly pendingCount: number; + readonly sampleTimer: object | null; +} + +type SelectionReason = + | 'database-worker-missing' + | 'database-worker-not-idle' + | 'multiple-database-workers'; + +interface Selection { + readonly selected: T | null; + readonly unavailableReason: SelectionReason | null; +} + +type Selector = ( + records: readonly T[], + activeGeneration: number +) => Selection; + +interface SelectionModule { + createDatabaseWorkerPostGcSelectionApi?: () => { + readonly select: Selector; + }; +} + +const selectionModulePromise = import( + new URL('./database-worker-post-gc-selection.ts', import.meta.url).href +) + .then((module) => module as SelectionModule) + .catch(() => null); + +function workerRecord( + ordinal: number, + overrides: Partial = {} +): TestWorkerRecord { + return { + captureGeneration: 7, + finalized: true, + kind: 'database.worker', + ordinal, + pendingCount: 0, + sampleTimer: null, + ...overrides, + }; +} + +async function restoreSerializableSelector(): Promise { + const module = await selectionModulePromise; + assert.ok(module, 'database worker post-GC selector module must exist'); + const factory = module.createDatabaseWorkerPostGcSelectionApi; + assert.equal(typeof factory, 'function'); + const source = factory.toString(); + assert.doesNotMatch(source, /__name/); + + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => { + readonly select: Selector; + }; + return restoredFactory().select; +} + +test('selects the only current-generation database worker independently of sampling and finalization state', async () => { + const select = await restoreSerializableSelector(); + const currentDatabase = workerRecord(4, { + finalized: true, + sampleTimer: null, + }); + const previousDatabase = workerRecord(1, { + captureGeneration: 6, + finalized: false, + sampleTimer: {}, + }); + const currentPlaylist = workerRecord(2, { + finalized: false, + kind: 'playlist-refresh.worker', + sampleTimer: {}, + }); + + const selection = select( + [previousDatabase, currentPlaylist, currentDatabase], + 7 + ); + + assert.equal(selection.selected, currentDatabase); + assert.deepEqual(selection, { + selected: currentDatabase, + unavailableReason: null, + }); +}); + +test('reports a missing database worker after ignoring playlists and previous generations', async () => { + const select = await restoreSerializableSelector(); + + assert.deepEqual( + select( + [ + workerRecord(1, { captureGeneration: 6 }), + workerRecord(2, { kind: 'playlist-refresh.worker' }), + ], + 7 + ), + { + selected: null, + unavailableReason: 'database-worker-missing', + } + ); +}); + +test('fails closed for multiple current-generation database workers instead of taking the first', async () => { + const select = await restoreSerializableSelector(); + const first = workerRecord(1, { + finalized: false, + sampleTimer: {}, + }); + const second = workerRecord(2, { + finalized: true, + sampleTimer: null, + }); + + assert.deepEqual(select([first, second], 7), { + selected: null, + unavailableReason: 'multiple-database-workers', + }); +}); + +test('fails closed while the only current-generation database worker is not idle', async () => { + const select = await restoreSerializableSelector(); + + assert.deepEqual(select([workerRecord(3, { pendingCount: 2 })], 7), { + selected: null, + unavailableReason: 'database-worker-not-idle', + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.ts new file mode 100644 index 000000000..149e4bada --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.ts @@ -0,0 +1,70 @@ +export type DatabaseWorkerPostGcUnavailableReason = + | 'database-worker-missing' + | 'database-worker-not-idle' + | 'multiple-database-workers'; + +export interface DatabaseWorkerPostGcSelectionRecord { + readonly captureGeneration: number | null; + readonly kind: string; + readonly ordinal: number; + readonly pendingCount: number; +} + +export interface DatabaseWorkerPostGcSelection { + readonly selected: T | null; + readonly unavailableReason: DatabaseWorkerPostGcUnavailableReason | null; +} + +export interface DatabaseWorkerPostGcSelectionApi { + select( + records: readonly T[], + activeGeneration: number + ): DatabaseWorkerPostGcSelection; +} + +export function createDatabaseWorkerPostGcSelectionApi(): DatabaseWorkerPostGcSelectionApi { + const helpers = { + select( + records: readonly T[], + activeGeneration: number + ): DatabaseWorkerPostGcSelection { + const currentDatabaseWorkers: T[] = []; + for (const record of records) { + if ( + record.captureGeneration === activeGeneration && + record.kind === 'database.worker' + ) { + currentDatabaseWorkers.push(record); + } + } + + if (currentDatabaseWorkers.length === 0) { + return Object.freeze({ + selected: null, + unavailableReason: 'database-worker-missing', + }); + } + if (currentDatabaseWorkers.length > 1) { + return Object.freeze({ + selected: null, + unavailableReason: 'multiple-database-workers', + }); + } + + const selected = currentDatabaseWorkers[0] as T; + if (selected.pendingCount > 0) { + return Object.freeze({ + selected: null, + unavailableReason: 'database-worker-not-idle', + }); + } + + return Object.freeze({ + selected, + unavailableReason: null, + }); + }, + }; + + return Object.freeze({ select: helpers.select }); +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.spec.ts new file mode 100644 index 000000000..c26a4163d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.spec.ts @@ -0,0 +1,392 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +type IterationKind = 'diagnostic' | 'measured' | 'warmup'; +type WorkerKind = 'database.worker' | 'playlist-refresh.worker'; + +interface TestWorkerCapture { + readonly kind: WorkerKind; + readonly postGcHeapUnavailableReason: string | null; + readonly postGcHeapUsedBytes: number | null; +} + +interface TestIteration { + readonly cancellationEffectObserved: boolean; + readonly kind: IterationKind; + readonly main: { + readonly timeline: readonly { + readonly type: string; + }[]; + readonly workers: readonly TestWorkerCapture[]; + }; + readonly runId: string; +} + +interface InvalidMeasuredRun { + readonly databaseWorkerCount: number; + readonly reason: string; + readonly runId: string; +} + +interface DatabaseWorkerPostGcValidity { + readonly applicableMeasuredRunCount: number; + readonly invalidMeasuredRuns: readonly InvalidMeasuredRun[]; + readonly measuredRunCount: number; + readonly notApplicableMeasuredRuns: readonly { + readonly reason: string; + readonly runId: string; + }[]; + readonly validForBenchmark: boolean; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +interface ValidityModule { + assessDatabaseWorkerPostGcValidity?: ( + iterations: readonly TestIteration[] + ) => DatabaseWorkerPostGcValidity; +} + +const validityModulePromise = import( + new URL('./database-worker-post-gc-validity.ts', import.meta.url).href +) + .then((module) => module as ValidityModule) + .catch(() => null); + +function worker( + postGcHeapUsedBytes: number | null, + postGcHeapUnavailableReason: string | null, + kind: WorkerKind = 'database.worker' +): TestWorkerCapture { + return { + kind, + postGcHeapUnavailableReason, + postGcHeapUsedBytes, + }; +} + +function iteration( + runId: string, + kind: IterationKind, + workers: readonly TestWorkerCapture[], + timeline: readonly { readonly type: string }[] = [], + cancellationEffectObserved = false +): TestIteration { + return { + cancellationEffectObserved, + kind, + main: { timeline, workers }, + runId, + }; +} + +test('validates database post-GC heap per measured iteration without losing raw reasons', async () => { + const module = await validityModulePromise; + assert.ok(module, 'database worker post-GC validity helper must exist'); + const assess = module.assessDatabaseWorkerPostGcValidity; + assert.equal(typeof assess, 'function'); + + const valid = assess([ + iteration('warmup-broken', 'warmup', []), + iteration('diagnostic-broken', 'diagnostic', [ + worker(null, 'gc-unavailable'), + worker(100, null), + ]), + iteration('measured-valid', 'measured', [ + worker(4_096, null), + worker( + null, + 'worker-force-terminated-before-gc', + 'playlist-refresh.worker' + ), + ]), + ]); + assert.deepEqual(valid, { + applicableMeasuredRunCount: 1, + invalidMeasuredRuns: [], + measuredRunCount: 1, + notApplicableMeasuredRuns: [], + validForBenchmark: true, + validForComparison: true, + validMeasuredRunCount: 1, + }); + + const compensatingCardinality = assess([ + iteration('measured-duplicate', 'measured', [ + worker(1_024, null), + worker(2_048, null), + ]), + iteration( + 'measured-missing', + 'measured', + [worker(3_072, null, 'playlist-refresh.worker')], + [{ type: 'db-request' }] + ), + ]); + assert.deepEqual(compensatingCardinality, { + applicableMeasuredRunCount: 2, + invalidMeasuredRuns: [ + { + databaseWorkerCount: 2, + reason: 'multiple-database-workers', + runId: 'measured-duplicate', + }, + { + databaseWorkerCount: 0, + reason: 'database-worker-missing', + runId: 'measured-missing', + }, + ], + measuredRunCount: 2, + notApplicableMeasuredRuns: [], + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); + + const unavailableWorker = worker(null, 'post-gc-probe-timeout'); + const unavailableAndIncoherent = assess([ + iteration('measured-unavailable', 'measured', [unavailableWorker]), + iteration('measured-incoherent', 'measured', [ + worker(8_192, 'gc-unavailable'), + ]), + ]); + assert.deepEqual(unavailableAndIncoherent, { + applicableMeasuredRunCount: 2, + invalidMeasuredRuns: [ + { + databaseWorkerCount: 1, + reason: 'post-gc-probe-timeout', + runId: 'measured-unavailable', + }, + { + databaseWorkerCount: 1, + reason: 'post-gc-capture-invalid', + runId: 'measured-incoherent', + }, + ], + measuredRunCount: 2, + notApplicableMeasuredRuns: [], + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); + assert.equal( + unavailableWorker.postGcHeapUnavailableReason, + 'post-gc-probe-timeout' + ); +}); + +test('marks parsing cancellation before persistence as DB N/A without accepting late or missing captured DB work', async () => { + const module = await validityModulePromise; + assert.ok(module); + const assess = module.assessDatabaseWorkerPostGcValidity; + assert.equal(typeof assess, 'function'); + + const noDatabasePhase = assess([ + iteration( + 'run-01', + 'measured', + [ + worker( + null, + 'worker-force-terminated-before-gc', + 'playlist-refresh.worker' + ), + ], + [], + true + ), + iteration( + 'run-02', + 'measured', + [ + worker( + null, + 'worker-force-terminated-before-gc', + 'playlist-refresh.worker' + ), + ], + [], + true + ), + ]); + assert.deepEqual(noDatabasePhase, { + applicableMeasuredRunCount: 0, + invalidMeasuredRuns: [], + measuredRunCount: 2, + notApplicableMeasuredRuns: [ + { + reason: 'operation-cancelled-before-database-phase', + runId: 'run-01', + }, + { + reason: 'operation-cancelled-before-database-phase', + runId: 'run-02', + }, + ], + validForBenchmark: true, + validForComparison: false, + validMeasuredRunCount: 0, + }); + + const mixedApplicability = assess([ + iteration('run-cancelled', 'measured', [], [], true), + iteration('run-persisted', 'measured', [worker(4_096, null)]), + ]); + assert.deepEqual(mixedApplicability, { + applicableMeasuredRunCount: 1, + invalidMeasuredRuns: [], + measuredRunCount: 2, + notApplicableMeasuredRuns: [ + { + reason: 'operation-cancelled-before-database-phase', + runId: 'run-cancelled', + }, + ], + validForBenchmark: true, + validForComparison: false, + validMeasuredRunCount: 1, + }); + + const lateDatabaseWork = assess([ + iteration( + 'run-late', + 'measured', + [], + [{ type: 'db-request-after-capture-cutoff' }], + true + ), + ]); + assert.deepEqual(lateDatabaseWork, { + applicableMeasuredRunCount: 1, + invalidMeasuredRuns: [ + { + databaseWorkerCount: 0, + reason: 'database-worker-activity-after-cutoff', + runId: 'run-late', + }, + ], + measuredRunCount: 1, + notApplicableMeasuredRuns: [], + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); + + const unrelatedDatabaseWorker = assess([ + iteration( + 'run-unrelated-db', + 'measured', + [worker(4_096, null)], + [{ type: 'db-request' }], + true + ), + ]); + assert.deepEqual(unrelatedDatabaseWorker, { + applicableMeasuredRunCount: 1, + invalidMeasuredRuns: [ + { + databaseWorkerCount: 1, + reason: 'database-worker-unexpected-activity', + runId: 'run-unrelated-db', + }, + ], + measuredRunCount: 1, + notApplicableMeasuredRuns: [], + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); + + const nonCancelLateRequest = assess([ + iteration( + 'run-non-cancel-late', + 'measured', + [worker(8_192, null)], + [{ type: 'db-request-after-capture-cutoff' }] + ), + ]); + assert.deepEqual(nonCancelLateRequest, { + applicableMeasuredRunCount: 1, + invalidMeasuredRuns: [ + { + databaseWorkerCount: 1, + reason: 'database-worker-activity-after-cutoff', + runId: 'run-non-cancel-late', + }, + ], + measuredRunCount: 1, + notApplicableMeasuredRuns: [], + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); +}); + +test('the benchmark preserves raw artifacts before rejecting an invalid formal comparison', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const summaryWrite = source.indexOf( + "writeJson(join(config.outputDirectory, 'summary.json'), summary)" + ); + const validityGuard = source.indexOf( + 'summary.validity.databaseWorkerPostGc.validForBenchmark' + ); + const cancellationEffectGuard = source.indexOf( + 'summary.cancellationEffectRate !== 1' + ); + + assert.ok(summaryWrite >= 0, 'summary artifact write must exist'); + assert.ok( + cancellationEffectGuard >= 0, + 'formal cancellation-effect guard must exist' + ); + assert.ok(validityGuard >= 0, 'formal validity guard must exist'); + assert.ok( + summaryWrite < cancellationEffectGuard && summaryWrite < validityGuard, + 'raw summary must be durable before either formal guard fails' + ); + assert.match(source, /Cancellation effect was not observed/); + assert.match(source, /Database worker post-GC capture is invalid/); +}); + +test('the benchmark persists the real seed DB lifecycle and atomically arms the measured generation', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const seedCaptureStart = source.indexOf( + 'await startMainCapture(app.electronApp', + source.indexOf('const rendererWindowIdentity') + ); + const seedPlaylist = source.indexOf('await seedPlaylist'); + const seedSettlement = source.indexOf('await waitForSeedSettlement'); + const seedRollover = source.indexOf( + 'await rolloverMainCapture(app.electronApp', + seedSettlement + ); + const seedArtifactWrite = source.indexOf( + "'seed-main-capture.json'", + seedRollover + ); + + assert.ok(seedCaptureStart >= 0 && seedCaptureStart < seedPlaylist); + assert.ok(seedPlaylist < seedSettlement); + assert.ok(seedSettlement < seedRollover); + assert.ok(seedRollover < seedArtifactWrite); + assert.doesNotMatch( + source.slice(seedRollover + 1, source.indexOf('const renderer =')), + /await startMainCapture\(app\.electronApp/ + ); + assert.match(source, /seedRollover\.nextCaptureStarted/); + assert.match(source, /seedRollover\.nextCaptureUnavailableReason/); + assert.match( + source, + /status\.databaseRequests\s*>\s*0[\s\S]*status\.databaseUpsertsCompleted\s*>\s*0[\s\S]*status\.databasePending\s*===\s*0/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.ts new file mode 100644 index 000000000..43345e718 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.ts @@ -0,0 +1,141 @@ +import { + type DatabaseWorkerPostGcValidity, + type InvalidDatabaseWorkerPostGcMeasuredRun, + WORKER_POST_GC_HEAP_UNAVAILABLE_REASON, +} from './m3u-refresh-cancellation-contract'; + +export interface DatabaseWorkerPostGcValidityWorker { + readonly kind: string; + readonly postGcHeapUnavailableReason: unknown; + readonly postGcHeapUsedBytes: unknown; +} + +export interface DatabaseWorkerPostGcValidityIteration { + readonly cancellationEffectObserved?: boolean; + readonly kind: string; + readonly main: { + readonly timeline?: readonly { + readonly type: string; + }[]; + readonly workers: readonly DatabaseWorkerPostGcValidityWorker[]; + }; + readonly runId: string; +} + +const UNAVAILABLE_REASONS = new Set( + Object.values(WORKER_POST_GC_HEAP_UNAVAILABLE_REASON) +); + +export function assessDatabaseWorkerPostGcValidity( + iterations: readonly DatabaseWorkerPostGcValidityIteration[] +): DatabaseWorkerPostGcValidity { + const measured = iterations.filter( + (iteration) => iteration.kind === 'measured' + ); + const invalidMeasuredRuns: InvalidDatabaseWorkerPostGcMeasuredRun[] = []; + const notApplicableMeasuredRuns: { + readonly reason: 'operation-cancelled-before-database-phase'; + readonly runId: string; + }[] = []; + let applicableMeasuredRunCount = 0; + let validMeasuredRunCount = 0; + + for (const iteration of measured) { + const databaseWorkers = iteration.main.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const timeline = iteration.main.timeline ?? []; + const lateDatabaseRequest = timeline.some( + (record) => record.type === 'db-request-after-capture-cutoff' + ); + const databasePhaseObserved = + lateDatabaseRequest || + timeline.some((record) => record.type === 'db-request'); + + if (lateDatabaseRequest) { + applicableMeasuredRunCount += 1; + invalidMeasuredRuns.push({ + databaseWorkerCount: databaseWorkers.length, + reason: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.DATABASE_WORKER_ACTIVITY_AFTER_CUTOFF, + runId: iteration.runId, + }); + continue; + } + + if (iteration.cancellationEffectObserved === true) { + if (!databasePhaseObserved && databaseWorkers.length === 0) { + notApplicableMeasuredRuns.push({ + reason: 'operation-cancelled-before-database-phase', + runId: iteration.runId, + }); + continue; + } + + applicableMeasuredRunCount += 1; + invalidMeasuredRuns.push({ + databaseWorkerCount: databaseWorkers.length, + reason: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.DATABASE_WORKER_UNEXPECTED_ACTIVITY, + runId: iteration.runId, + }); + continue; + } + + if (databaseWorkers.length === 0) { + applicableMeasuredRunCount += 1; + invalidMeasuredRuns.push({ + databaseWorkerCount: 0, + reason: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.DATABASE_WORKER_MISSING, + runId: iteration.runId, + }); + continue; + } + + applicableMeasuredRunCount += 1; + if (databaseWorkers.length !== 1) { + invalidMeasuredRuns.push({ + databaseWorkerCount: databaseWorkers.length, + reason: WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.MULTIPLE_DATABASE_WORKERS, + runId: iteration.runId, + }); + continue; + } + + const worker = databaseWorkers[0] as DatabaseWorkerPostGcValidityWorker; + const heap = worker.postGcHeapUsedBytes; + const reason = worker.postGcHeapUnavailableReason; + if ( + Number.isSafeInteger(heap) && + Number(heap) >= 0 && + reason === null + ) { + validMeasuredRunCount += 1; + continue; + } + + invalidMeasuredRuns.push({ + databaseWorkerCount: 1, + reason: + heap === null && + typeof reason === 'string' && + UNAVAILABLE_REASONS.has(reason) + ? reason + : WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.INVALID_CAPTURE, + runId: iteration.runId, + }); + } + + return Object.freeze({ + applicableMeasuredRunCount, + invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns), + measuredRunCount: measured.length, + notApplicableMeasuredRuns: Object.freeze(notApplicableMeasuredRuns), + validForBenchmark: + measured.length > 0 && invalidMeasuredRuns.length === 0, + validForComparison: + measured.length > 0 && + applicableMeasuredRunCount === measured.length && + validMeasuredRunCount === measured.length && + invalidMeasuredRuns.length === 0, + validMeasuredRunCount, + }); +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts new file mode 100644 index 000000000..0b88917c6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assessDatabaseWorkerRequestMetricsValidity, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON, +} from './database-worker-request-metrics-validity'; +import { createCompleteTestMainCapture } from './m3u-import-report.test-helpers'; +import type { + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +describe('database worker request-metrics validity', () => { + it('requires exactly two complete request samples in every measured run', () => { + const worker = completeWorker(); + const validity = assessDatabaseWorkerRequestMetricsValidity([ + iteration('warmup', 'warmup', [worker]), + iteration('run-01', 'measured', [worker]), + iteration('run-02', 'measured', [worker]), + iteration('diagnostic', 'diagnostic', [worker]), + ]); + + assert.deepEqual(validity, { + expectedRequestCount: 4, + invalidRequests: [], + measuredRunCount: 2, + validForComparison: true, + validMeasuredRunCount: 2, + validRequestCount: 4, + }); + }); + + it('fails each measured run with missing ELD, ELU, CPU, or raw capture metrics', () => { + const validity = assessDatabaseWorkerRequestMetricsValidity([ + invalidRequestIteration('run-eld', { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + histogramFlushedEpochMs: null, + }), + invalidRequestIteration('run-elu', { + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'event-loop-utilization-unavailable', + }), + invalidRequestIteration('run-cpu', { + threadCpuSystemMicros: null, + threadCpuUnavailableReason: 'thread-cpu-usage-unavailable', + threadCpuUserMicros: null, + }), + invalidRequestIteration('run-capture', { + performanceCaptureUnavailableReason: + 'worker-performance-capture-invalid', + }), + ]); + + assert.equal(validity.expectedRequestCount, 8); + assert.equal(validity.validRequestCount, 4); + assert.equal(validity.validMeasuredRunCount, 0); + assert.equal(validity.validForComparison, false); + assert.deepEqual( + validity.invalidRequests.map(({ reason }) => reason), + [ + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID, + ] + ); + }); + + it('fails closed for missing, multiple, and wrong request sets', () => { + const worker = completeWorker(); + const validity = assessDatabaseWorkerRequestMetricsValidity([ + iteration('missing', 'measured', []), + iteration('multiple', 'measured', [worker, worker]), + iteration('wrong-requests', 'measured', [ + { + ...worker, + requests: worker.requests.slice(0, 1), + }, + ]), + ]); + + assert.equal(validity.expectedRequestCount, 6); + assert.equal(validity.validRequestCount, 0); + assert.equal(validity.validForComparison, false); + assert.deepEqual( + validity.invalidRequests.map(({ reason }) => reason), + [ + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID, + ] + ); + }); +}); + +function completeWorker(): WorkerCaptureMetrics { + const worker = createCompleteTestMainCapture().workers[0]; + assert.ok(worker); + return worker; +} + +function invalidRequestIteration( + runId: string, + updates: Partial +) { + const worker = completeWorker(); + const get = worker.requests[1]; + assert.ok(get); + return iteration(runId, 'measured', [ + { + ...worker, + requests: [worker.requests[0], { ...get, ...updates }], + }, + ]); +} + +function iteration( + runId: string, + kind: string, + workers: readonly WorkerCaptureMetrics[] +) { + return { kind, main: { workers }, runId }; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts new file mode 100644 index 000000000..5a924854b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts @@ -0,0 +1,197 @@ +import type { + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; + +export const DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON = { + DATABASE_WORKER_MISSING: 'database-worker-missing', + EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid', + EVENT_LOOP_UTILIZATION_INVALID: 'event-loop-utilization-invalid', + MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers', + REQUEST_SET_INVALID: 'initial-import-request-set-invalid', + THREAD_CPU_INVALID: 'thread-cpu-invalid', + WORKER_CAPTURE_INVALID: 'worker-performance-capture-invalid', +} as const; + +export type DatabaseWorkerRequestMetricsInvalidReason = + (typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON)[keyof typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON]; + +export interface DatabaseWorkerRequestMetricsValidity { + readonly expectedRequestCount: number; + readonly invalidRequests: readonly { + readonly operation: string | null; + readonly reason: DatabaseWorkerRequestMetricsInvalidReason; + readonly runId: string; + }[]; + readonly measuredRunCount: number; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; + readonly validRequestCount: number; +} + +interface RequestMetricsIteration { + readonly kind: string; + readonly main: { + readonly workers: readonly WorkerCaptureMetrics[]; + }; + readonly runId: string; +} + +export function assessDatabaseWorkerRequestMetricsValidity( + iterations: readonly RequestMetricsIteration[] +): DatabaseWorkerRequestMetricsValidity { + const measured = iterations.filter( + (iteration) => + iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + const invalidRequests: { + operation: string | null; + reason: DatabaseWorkerRequestMetricsInvalidReason; + runId: string; + }[] = []; + let validMeasuredRunCount = 0; + let validRequestCount = 0; + + for (const iteration of measured) { + const workers = iteration.main.workers.filter( + (worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE + ); + if (workers.length === 0) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING, + runId: iteration.runId, + }); + continue; + } + const worker = workers.length === 1 ? workers[0] : null; + if (!worker) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + runId: iteration.runId, + }); + continue; + } + const requests = requireInitialImportRequestPair(worker); + if (requests === null) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID, + runId: iteration.runId, + }); + continue; + } + + let runIsValid = true; + for (const request of requests) { + const reason = assessRequest(request); + if (reason === null) { + validRequestCount += 1; + } else { + runIsValid = false; + invalidRequests.push({ + operation: request.operation, + reason, + runId: iteration.runId, + }); + } + } + if (runIsValid) { + validMeasuredRunCount += 1; + } + } + + const expectedRequestCount = measured.length * 2; + return Object.freeze({ + expectedRequestCount, + invalidRequests: Object.freeze(invalidRequests), + measuredRunCount: measured.length, + validForComparison: + measured.length > 0 && + validMeasuredRunCount === measured.length && + validRequestCount === expectedRequestCount, + validMeasuredRunCount, + validRequestCount, + }); +} + +function requireInitialImportRequestPair( + worker: WorkerCaptureMetrics +): readonly [ + WorkerRequestPerformanceMetrics, + WorkerRequestPerformanceMetrics, +] | null { + const upserts = worker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success + ); + const gets = worker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && request.success + ); + return worker.requests.length === 2 && + upserts.length === 1 && + upserts[0] && + gets.length === 1 && + gets[0] + ? [upserts[0], gets[0]] + : null; +} + +function assessRequest( + request: WorkerRequestPerformanceMetrics +): DatabaseWorkerRequestMetricsInvalidReason | null { + if ( + request.performanceCaptureUnavailableReason !== null || + request.invalidReason !== null + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID; + } + if ( + request.eventLoopDelayUnavailableReason !== null || + request.histogramFlushedEpochMs === null || + !isEventLoopDelay(request.eventLoopDelay) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID; + } + if ( + request.eventLoopUtilizationUnavailableReason !== null || + !isUtilization(request.eventLoopUtilization) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID; + } + if ( + request.threadCpuUnavailableReason !== null || + !isFiniteNonNegative(request.threadCpuSystemMicros) || + !isFiniteNonNegative(request.threadCpuUserMicros) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID; + } + return null; +} + +function isEventLoopDelay( + value: WorkerRequestPerformanceMetrics['eventLoopDelay'] +): value is NonNullable { + return ( + value !== null && + isFiniteNonNegative(value.maxMs) && + isFiniteNonNegative(value.p95Ms) && + isFiniteNonNegative(value.p99Ms) && + value.p95Ms <= value.p99Ms && + value.p99Ms <= value.maxMs + ); +} + +function isUtilization(value: number | null): value is number { + return isFiniteNonNegative(value) && value <= 1; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.spec.ts b/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.spec.ts new file mode 100644 index 000000000..f8fdc7434 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.spec.ts @@ -0,0 +1,193 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; + +interface DiagnosticCaptureModule { + validateDiagnosticPlaylistWorkerCapture?: ( + main: MainCaptureMetrics, + iterationDirectory: string + ) => Promise; +} + +const diagnosticCaptureModulePromise = import( + new URL('./diagnostic-playlist-worker-capture.ts', import.meta.url).href +) + .then((module) => module as DiagnosticCaptureModule) + .catch(() => null); + +function mainCapture(input: { + readonly profilePath: string | null; + readonly snapshotPath?: string | null; + readonly timelineType?: string; +}): MainCaptureMetrics { + return { + timeline: input.timelineType + ? [{ epochMs: 1, type: input.timelineType }] + : [], + workers: [ + { + kind: 'playlist-refresh.worker', + ordinal: 2, + postGcHeapUnavailableReason: + 'worker-force-terminated-before-gc', + postGcHeapUsedBytes: null, + profilePath: input.profilePath, + snapshotPath: input.snapshotPath ?? null, + terminatedEpochMs: 2, + }, + ], + } as unknown as MainCaptureMetrics; +} + +test('accepts one parseable diagnostic playlist-worker profile inside the iteration directory', async () => { + const module = await diagnosticCaptureModulePromise; + assert.ok( + module, + 'diagnostic playlist worker capture validator must exist' + ); + const validate = module.validateDiagnosticPlaylistWorkerCapture; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-diagnostic-worker-') + ); + + try { + const profilePath = join( + directory, + 'playlist-refresh.worker-2.cpuprofile' + ); + await writeFile( + profilePath, + JSON.stringify({ nodes: [{ id: 1 }], samples: [1] }) + ); + await assert.doesNotReject(() => + validate?.(mainCapture({ profilePath }), directory) + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('fails closed for missing, escaped, malformed, or contaminated diagnostic artifacts', async () => { + const module = await diagnosticCaptureModulePromise; + assert.ok(module); + const validate = module.validateDiagnosticPlaylistWorkerCapture; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-diagnostic-worker-invalid-') + ); + + try { + await assert.rejects( + () => validate?.(mainCapture({ profilePath: null }), directory), + /diagnostic-playlist-worker-profile-missing/ + ); + await assert.rejects( + () => + validate?.( + mainCapture({ + profilePath: join( + directory, + '..', + 'escaped.cpuprofile' + ), + }), + directory + ), + /diagnostic-playlist-worker-profile-path-invalid/ + ); + const malformedPath = join( + directory, + 'playlist-refresh.worker-2.cpuprofile' + ); + await writeFile(malformedPath, '{"nodes":[]}'); + await assert.rejects( + () => + validate?.( + mainCapture({ profilePath: malformedPath }), + directory + ), + /diagnostic-playlist-worker-profile-invalid/ + ); + await assert.rejects( + () => + validate?.( + mainCapture({ + profilePath: malformedPath, + timelineType: + 'worker-artifact-error:profile-stop:failed', + }), + directory + ), + /diagnostic-playlist-worker-artifact-error/ + ); + await assert.rejects( + () => + validate?.( + mainCapture({ + profilePath: malformedPath, + snapshotPath: join( + directory, + 'playlist-refresh.worker-2.heapsnapshot' + ), + }), + directory + ), + /diagnostic-playlist-worker-snapshot-unexpected/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('the benchmark persists diagnostic raw metrics before requiring a parseable playlist-worker profile', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const resultWrite = source.indexOf( + "writeJson(join(iterationDirectory, 'result.json'), result)" + ); + const summaryWrite = source.indexOf( + "writeJson(join(config.outputDirectory, 'summary.json'), summary)" + ); + const diagnosticValidation = source.indexOf( + 'await validateDiagnosticPlaylistWorkerCapture(' + ); + + assert.ok(resultWrite >= 0); + assert.ok(summaryWrite >= 0); + assert.ok(diagnosticValidation > summaryWrite); +}); + +test('the benchmark requires the diagnostic worker profile to come from an observed cancellation', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const summaryWrite = source.indexOf( + "writeJson(join(config.outputDirectory, 'summary.json'), summary)" + ); + const diagnosticCancellationGuard = source.indexOf( + 'if (!diagnosticIteration.cancellationEffectObserved)' + ); + const diagnosticValidation = source.indexOf( + 'await validateDiagnosticPlaylistWorkerCapture(' + ); + + assert.ok(summaryWrite >= 0); + assert.ok( + diagnosticCancellationGuard > summaryWrite, + 'raw manifest and summary must remain durable when the diagnostic cancellation is invalid' + ); + assert.ok( + diagnosticCancellationGuard < diagnosticValidation, + 'the cancellation guard must reject a normal-completion worker profile before artifact validation' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.ts b/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.ts new file mode 100644 index 000000000..855931ed4 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.ts @@ -0,0 +1,72 @@ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; + +interface CpuProfile { + readonly nodes?: unknown; + readonly samples?: unknown; +} + +export async function validateDiagnosticPlaylistWorkerCapture( + main: MainCaptureMetrics, + iterationDirectory: string +): Promise { + if ( + main.timeline.some((record) => + record.type.startsWith('worker-artifact-error:') + ) + ) { + throw new Error('diagnostic-playlist-worker-artifact-error'); + } + + const workers = main.workers.filter( + (worker) => worker.kind === 'playlist-refresh.worker' + ); + if (workers.length !== 1) { + throw new Error('diagnostic-playlist-worker-cardinality-invalid'); + } + const worker = workers[0]; + if (!worker) { + throw new Error('diagnostic-playlist-worker-cardinality-invalid'); + } + if (worker.snapshotPath !== null) { + throw new Error('diagnostic-playlist-worker-snapshot-unexpected'); + } + if ( + worker.postGcHeapUsedBytes !== null || + worker.postGcHeapUnavailableReason !== + 'worker-force-terminated-before-gc' || + worker.terminatedEpochMs === null + ) { + throw new Error('diagnostic-playlist-worker-termination-invalid'); + } + if (worker.profilePath === null) { + throw new Error('diagnostic-playlist-worker-profile-missing'); + } + + const expectedProfilePath = join( + resolve(iterationDirectory), + `playlist-refresh.worker-${worker.ordinal}.cpuprofile` + ); + if (resolve(worker.profilePath) !== expectedProfilePath) { + throw new Error('diagnostic-playlist-worker-profile-path-invalid'); + } + + let profile: CpuProfile; + try { + profile = JSON.parse( + await readFile(expectedProfilePath, 'utf8') + ) as CpuProfile; + } catch { + throw new Error('diagnostic-playlist-worker-profile-invalid'); + } + if ( + !Array.isArray(profile.nodes) || + profile.nodes.length === 0 || + !Array.isArray(profile.samples) || + profile.samples.length === 0 + ) { + throw new Error('diagnostic-playlist-worker-profile-invalid'); + } +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts new file mode 100644 index 000000000..b162bdafa --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts @@ -0,0 +1,185 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isCpuProfile(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const startTime = value['startTime']; + const endTime = value['endTime']; + const nodes = value['nodes']; + const samples = value['samples']; + const timeDeltas = value['timeDeltas']; + if ( + !isNonNegativeSafeInteger(startTime) || + !isNonNegativeSafeInteger(endTime) || + endTime <= startTime || + !Array.isArray(nodes) || + nodes.length === 0 || + !Array.isArray(samples) || + samples.length === 0 || + !Array.isArray(timeDeltas) || + timeDeltas.length !== samples.length + ) { + return false; + } + + const nodeIds = new Set(); + for (const node of nodes) { + if (!isCpuProfileNode(node) || nodeIds.has(node['id'])) { + return false; + } + nodeIds.add(node['id']); + } + if ( + nodes.some((node) => + (node as Record)['children'] instanceof Array + ? ( + (node as Record)[ + 'children' + ] as unknown[] + ).some( + (childId) => + !isNonNegativeSafeInteger(childId) || + !nodeIds.has(childId) + ) + : false + ) || + samples.some( + (sampleId) => + !isNonNegativeSafeInteger(sampleId) || + !nodeIds.has(sampleId) + ) + ) { + return false; + } + + const duration = endTime - startTime; + let elapsed = 0; + for (const delta of timeDeltas) { + if (!Number.isSafeInteger(delta)) { + return false; + } + elapsed += delta as number; + if ( + !Number.isSafeInteger(elapsed) || + elapsed < 0 || + elapsed > duration + ) { + return false; + } + } + return elapsed > 0; +} + +export function isHeapSnapshot(value: unknown): boolean { + if ( + !isRecord(value) || + !isRecord(value['snapshot']) || + !isRecord(value['snapshot']['meta']) + ) { + return false; + } + const meta = value['snapshot']['meta']; + const nodeFields = meta['node_fields']; + const nodeTypes = meta['node_types']; + const edgeFields = meta['edge_fields']; + const edgeTypes = meta['edge_types']; + const nodes = value['nodes']; + const edges = value['edges']; + const strings = value['strings']; + return ( + isNonEmptyStringArray(nodeFields) && + isHeapTypeTable(nodeTypes, nodeFields.length) && + isNonEmptyStringArray(edgeFields) && + isHeapTypeTable(edgeTypes, edgeFields.length) && + Array.isArray(nodes) && + nodes.length > 0 && + nodes.length % nodeFields.length === 0 && + Array.isArray(edges) && + edges.length > 0 && + edges.length % edgeFields.length === 0 && + Array.isArray(strings) && + strings.length > 0 + ); +} + +export function isRendererTrace(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const events = value['traceEvents']; + return ( + Array.isArray(events) && + events.length > 0 && + events.every( + (event) => + isRecord(event) && + isNonEmptyString(event['name']) && + isNonEmptyString(event['ph']) && + isNonNegativeFiniteNumber(event['ts']) + ) + ); +} + +function isHeapTypeTable(value: unknown, fieldCount: number): boolean { + return ( + Array.isArray(value) && + value.length === fieldCount && + value.every( + (entry) => + isNonEmptyString(entry) || isNonEmptyStringArray(entry) + ) + ); +} + +function isCpuProfileNode( + value: unknown +): value is Record & { readonly id: number } { + if ( + !isRecord(value) || + !isNonNegativeSafeInteger(value['id']) || + !isRecord(value['callFrame']) + ) { + return false; + } + const callFrame = value['callFrame']; + const scriptId = callFrame['scriptId']; + const children = value['children']; + const hitCount = value['hitCount']; + return ( + typeof callFrame['functionName'] === 'string' && + (typeof scriptId === 'string' || + isNonNegativeSafeInteger(scriptId)) && + typeof callFrame['url'] === 'string' && + Number.isSafeInteger(callFrame['lineNumber']) && + Number.isSafeInteger(callFrame['columnNumber']) && + (children === undefined || + (Array.isArray(children) && + children.every(isNonNegativeSafeInteger))) && + (hitCount === undefined || isNonNegativeSafeInteger(hitCount)) + ); +} + +function isNonEmptyStringArray(value: unknown): value is readonly string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every(isNonEmptyString) + ); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function isNonNegativeFiniteNumber(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value >= 0 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts new file mode 100644 index 000000000..8a67af360 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts @@ -0,0 +1,234 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + type InitialImportDiagnosticCapture, + validateInitialImportDiagnosticArtifacts, +} from './initial-import-diagnostic-artifacts'; +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; +const HEAP_META = { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], +}; +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { meta: HEAP_META }, + strings: ['', 'root'], +}; +const TRACE = { + traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }], +}; + +function capture(directory: string): InitialImportDiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: 4, + profilePath: join( + directory, + 'database.worker-4.cpuprofile' + ), + snapshotPath: join( + directory, + 'database.worker-4.heapsnapshot' + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function writeArtifacts(directory: string): Promise { + const artifacts: Readonly> = { + 'database.worker-4.cpuprofile': CPU_PROFILE, + 'database.worker-4.heapsnapshot': HEAP_SNAPSHOT, + 'main.cpuprofile': CPU_PROFILE, + 'main.heapsnapshot': HEAP_SNAPSHOT, + 'renderer.cpuprofile': CPU_PROFILE, + 'renderer.heapsnapshot': HEAP_SNAPSHOT, + 'renderer.trace.json': TRACE, + }; + await Promise.all( + Object.entries(artifacts).map(([filename, value]) => + writeFile(join(directory, filename), JSON.stringify(value)) + ) + ); +} + +async function withArtifacts( + run: (directory: string) => Promise +): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-content-') + ); + try { + await writeArtifacts(directory); + await run(directory); + } finally { + await rm(directory, { force: true, recursive: true }); + } +} + +test('accepts bounded signed CPU time deltas emitted by Chromium', async () => { + await withArtifacts(async (directory) => { + await writeFile( + join(directory, 'renderer.cpuprofile'), + JSON.stringify({ + ...CPU_PROFILE, + endTime: 5_000, + samples: [1, 1, 1], + timeDeltas: [1_000, -100, 1_000], + }) + ); + await assert.doesNotReject(() => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ) + ); + }); +}); + +test('rejects CPU profiles without useful bounded samples and stack references', async () => { + await withArtifacts(async (directory) => { + const invalidProfiles = [ + { ...CPU_PROFILE, samples: [], timeDeltas: [] }, + { ...CPU_PROFILE, timeDeltas: undefined }, + { ...CPU_PROFILE, timeDeltas: [] }, + { + ...CPU_PROFILE, + endTime: CPU_PROFILE.startTime, + }, + { + ...CPU_PROFILE, + samples: [2], + }, + { + ...CPU_PROFILE, + timeDeltas: [-1], + }, + { + ...CPU_PROFILE, + timeDeltas: [2_001], + }, + { + ...CPU_PROFILE, + nodes: [ + ...CPU_PROFILE.nodes, + { ...CPU_PROFILE.nodes[0], id: 1 }, + ], + }, + { + ...CPU_PROFILE, + nodes: [{ id: 1 }], + }, + ]; + for (const profile of invalidProfiles) { + await writeFile( + join(directory, 'main.cpuprofile'), + JSON.stringify(profile) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:main-cpu-profile/ + ); + } + }); +}); + +test('rejects empty or malformed heap tables and invalid field strides', async () => { + await withArtifacts(async (directory) => { + const invalidSnapshots = [ + { snapshot: {}, nodes: [], edges: [], strings: [] }, + { ...HEAP_SNAPSHOT, nodes: [] }, + { ...HEAP_SNAPSHOT, edges: [] }, + { ...HEAP_SNAPSHOT, strings: [] }, + { + ...HEAP_SNAPSHOT, + snapshot: { + meta: { ...HEAP_META, node_types: undefined }, + }, + }, + { ...HEAP_SNAPSHOT, nodes: [...HEAP_SNAPSHOT.nodes, 0] }, + { ...HEAP_SNAPSHOT, edges: [...HEAP_SNAPSHOT.edges, 0] }, + ]; + for (const snapshot of invalidSnapshots) { + await writeFile( + join(directory, 'main.heapsnapshot'), + JSON.stringify(snapshot) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:main-heap-snapshot/ + ); + } + }); +}); + +test('rejects empty traces and malformed trace events', async () => { + await withArtifacts(async (directory) => { + const invalidTraces = [ + { traceEvents: [] }, + { traceEvents: [null] }, + { traceEvents: [{ name: '', ph: 'X', ts: 1 }] }, + { traceEvents: [{ name: 'initial-import', ph: '', ts: 1 }] }, + { + traceEvents: [ + { name: 'initial-import', ph: 'X', ts: 'invalid' }, + ], + }, + ]; + for (const trace of invalidTraces) { + await writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(trace) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:renderer-trace/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts new file mode 100644 index 000000000..1270709c3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts @@ -0,0 +1,394 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, symlink, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; + +interface DiagnosticCapture { + readonly main: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly workers: readonly { + readonly kind: string; + readonly ordinal: number; + readonly profilePath: string | null; + readonly snapshotPath: string | null; + }[]; + }; + readonly renderer: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly tracePath: string | null; + }; +} + +interface DiagnosticArtifactsModule { + validateInitialImportDiagnosticArtifacts?: ( + capture: DiagnosticCapture, + iterationDirectory: string + ) => Promise; +} + +const diagnosticArtifactsModulePromise = import( + new URL('./initial-import-diagnostic-artifacts.ts', import.meta.url).href +) + .then((module) => module as DiagnosticArtifactsModule) + .catch(() => null); + +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { + meta: { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], + }, + }, + strings: ['', 'root'], +}; +const RENDERER_TRACE = { + traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }], +}; + +function capture(directory: string, databaseOrdinal = 4): DiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: databaseOrdinal, + profilePath: join( + directory, + `database.worker-${databaseOrdinal}.cpuprofile` + ), + snapshotPath: join( + directory, + `database.worker-${databaseOrdinal}.heapsnapshot` + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function writeValidArtifacts( + directory: string, + databaseOrdinal = 4 +): Promise { + await Promise.all([ + writeFile( + join(directory, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, 'renderer.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, `database.worker-${databaseOrdinal}.cpuprofile`), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, `database.worker-${databaseOrdinal}.heapsnapshot`), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'main.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'renderer.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ), + ]); +} + +test('accepts the complete parseable initial-import diagnostic artifact set', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module, 'initial-import diagnostic validator must exist'); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-diagnostic-') + ); + + try { + await writeValidArtifacts(directory); + await assert.doesNotReject(() => + validate?.(capture(directory), directory) + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('fails closed for malformed or missing required process artifacts', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-invalid-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile(join(directory, 'renderer.trace.json'), '{'); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-invalid:renderer-trace/ + ); + + await writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ); + const missingMainProfile: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: null, + }, + }; + await assert.rejects( + () => validate?.(missingMainProfile, directory), + /initial-import-diagnostic-artifact-missing:main-cpu-profile/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('rejects escaped paths and symlinks instead of reading outside the iteration directory', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-contained-') + ); + const outsideDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-outside-') + ); + + try { + await writeValidArtifacts(directory); + const escapedCapture: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: join(outsideDirectory, 'main.cpuprofile'), + }, + }; + await writeFile( + join(outsideDirectory, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(escapedCapture, directory), + /initial-import-diagnostic-artifact-path-invalid:main-cpu-profile/ + ); + + const rendererProfilePath = join(directory, 'renderer.cpuprofile'); + await unlink(rendererProfilePath); + await symlink( + join(outsideDirectory, 'main.cpuprofile'), + rendererProfilePath + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-path-invalid:renderer-cpu-profile/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + await rm(outsideDirectory, { force: true, recursive: true }); + } +}); + +test('requires exactly one database profile and rejects every playlist-worker profile', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-workers-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile( + join(directory, 'database.worker-9.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-database-profile-cardinality-invalid/ + ); + + await unlink(join(directory, 'database.worker-9.cpuprofile')); + await writeFile( + join(directory, 'playlist-refresh.worker-2.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-playlist-profile-unexpected/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('requires a structurally valid database worker heap snapshot', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-') + ); + + try { + await writeValidArtifacts(directory); + const missingSnapshot: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + workers: [ + { + ...capture(directory).main.workers[0], + snapshotPath: null, + }, + ], + }, + }; + await assert.rejects( + () => validate?.(missingSnapshot, directory), + /initial-import-diagnostic-artifact-missing:database-heap-snapshot/ + ); + + await writeFile( + join(directory, 'database.worker-4.heapsnapshot'), + JSON.stringify({ snapshot: {}, nodes: [], edges: [] }) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-invalid:database-heap-snapshot/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('rejects escaped and symlinked database worker heap snapshots', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-contained-') + ); + const outsideDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-outside-') + ); + + try { + await writeValidArtifacts(directory); + const outsideSnapshotPath = join( + outsideDirectory, + 'database.worker-4.heapsnapshot' + ); + await writeFile(outsideSnapshotPath, JSON.stringify(HEAP_SNAPSHOT)); + const escapedSnapshot: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + workers: [ + { + ...capture(directory).main.workers[0], + snapshotPath: outsideSnapshotPath, + }, + ], + }, + }; + await assert.rejects( + () => validate?.(escapedSnapshot, directory), + /initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/ + ); + + const snapshotPath = join(directory, 'database.worker-4.heapsnapshot'); + await unlink(snapshotPath); + await symlink(outsideSnapshotPath, snapshotPath); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + await rm(outsideDirectory, { force: true, recursive: true }); + } +}); + +test('rejects extra database snapshots and every playlist-worker snapshot', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-count-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile( + join(directory, 'database.worker-9.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-database-snapshot-cardinality-invalid/ + ); + + await unlink(join(directory, 'database.worker-9.heapsnapshot')); + await writeFile( + join(directory, 'playlist-refresh.worker-2.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-playlist-snapshot-unexpected/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts new file mode 100644 index 000000000..f9f5cbdb3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts @@ -0,0 +1,262 @@ +import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { + isCpuProfile, + isHeapSnapshot, + isRendererTrace, +} from './initial-import-diagnostic-artifact-content-validation'; + +interface DiagnosticWorkerArtifact { + readonly kind: string; + readonly ordinal: number; + readonly profilePath: string | null; + readonly snapshotPath: string | null; +} + +export interface InitialImportDiagnosticCapture { + readonly main: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly workers: readonly DiagnosticWorkerArtifact[]; + }; + readonly renderer: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly tracePath: string | null; + }; +} + +type ArtifactLabel = + | 'database-cpu-profile' + | 'database-heap-snapshot' + | 'main-cpu-profile' + | 'main-heap-snapshot' + | 'renderer-cpu-profile' + | 'renderer-heap-snapshot' + | 'renderer-trace'; + +interface ArtifactDescription { + readonly filename: string; + readonly label: ArtifactLabel; + readonly path: string | null; + readonly validate: (value: unknown) => boolean; +} + +export async function validateInitialImportDiagnosticArtifacts( + capture: InitialImportDiagnosticCapture, + iterationDirectory: string +): Promise { + const directory = resolve(iterationDirectory); + let realDirectory: string; + try { + realDirectory = await realpath(directory); + } catch { + throw new Error( + 'initial-import-diagnostic-artifact-path-invalid:iteration-directory' + ); + } + + const databaseWorker = requireExactDatabaseWorker(capture.main.workers); + await assertWorkerArtifactCardinality(directory); + + const artifacts: readonly ArtifactDescription[] = [ + { + filename: 'main.cpuprofile', + label: 'main-cpu-profile', + path: capture.main.cpuProfilePath, + validate: isCpuProfile, + }, + { + filename: 'renderer.cpuprofile', + label: 'renderer-cpu-profile', + path: capture.renderer.cpuProfilePath, + validate: isCpuProfile, + }, + { + filename: 'renderer.trace.json', + label: 'renderer-trace', + path: capture.renderer.tracePath, + validate: isRendererTrace, + }, + { + filename: 'main.heapsnapshot', + label: 'main-heap-snapshot', + path: capture.main.heapSnapshotPath, + validate: isHeapSnapshot, + }, + { + filename: 'renderer.heapsnapshot', + label: 'renderer-heap-snapshot', + path: capture.renderer.heapSnapshotPath, + validate: isHeapSnapshot, + }, + { + filename: `database.worker-${databaseWorker.ordinal}.cpuprofile`, + label: 'database-cpu-profile', + path: databaseWorker.profilePath, + validate: isCpuProfile, + }, + { + filename: `database.worker-${databaseWorker.ordinal}.heapsnapshot`, + label: 'database-heap-snapshot', + path: databaseWorker.snapshotPath, + validate: isHeapSnapshot, + }, + ]; + + for (const artifact of artifacts) { + await validateJsonArtifact(directory, realDirectory, artifact); + } +} + +function requireExactDatabaseWorker( + workers: readonly DiagnosticWorkerArtifact[] +): DiagnosticWorkerArtifact { + if ( + workers.some( + (worker) => + worker.kind === 'playlist-refresh.worker' && + worker.profilePath !== null + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-profile-unexpected' + ); + } + if ( + workers.some( + (worker) => + worker.kind === 'playlist-refresh.worker' && + worker.snapshotPath !== null + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-snapshot-unexpected' + ); + } + const databaseWorkers = workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const worker = databaseWorkers[0]; + if ( + databaseWorkers.length !== 1 || + !worker || + !Number.isSafeInteger(worker.ordinal) || + worker.ordinal < 0 + ) { + throw new Error( + 'initial-import-diagnostic-database-profile-cardinality-invalid' + ); + } + return worker; +} + +async function assertWorkerArtifactCardinality( + directory: string +): Promise { + let entries: readonly string[]; + try { + entries = await readdir(directory); + } catch { + throw new Error( + 'initial-import-diagnostic-artifact-path-invalid:iteration-directory' + ); + } + if ( + entries.some((entry) => + /^playlist-refresh\.worker-\d+\.cpuprofile$/u.test(entry) + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-profile-unexpected' + ); + } + if ( + entries.filter((entry) => + /^database\.worker-\d+\.cpuprofile$/u.test(entry) + ).length !== 1 + ) { + throw new Error( + 'initial-import-diagnostic-database-profile-cardinality-invalid' + ); + } + if ( + entries.some((entry) => + /^playlist-refresh\.worker-\d+\.heapsnapshot$/u.test(entry) + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-snapshot-unexpected' + ); + } + if ( + entries.filter((entry) => + /^database\.worker-\d+\.heapsnapshot$/u.test(entry) + ).length !== 1 + ) { + throw new Error( + 'initial-import-diagnostic-database-snapshot-cardinality-invalid' + ); + } +} + +async function validateJsonArtifact( + directory: string, + realDirectory: string, + artifact: ArtifactDescription +): Promise { + if (artifact.path === null) { + throw new Error( + `initial-import-diagnostic-artifact-missing:${artifact.label}` + ); + } + const expectedPath = join(directory, artifact.filename); + if (resolve(artifact.path) !== expectedPath) { + throw new Error( + `initial-import-diagnostic-artifact-path-invalid:${artifact.label}` + ); + } + + let value: unknown; + try { + const stats = await lstat(expectedPath); + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('unsafe-artifact-type'); + } + const realArtifactPath = await realpath(expectedPath); + if (!isContained(realDirectory, realArtifactPath)) { + throw new Error('escaped-artifact'); + } + value = JSON.parse(await readFile(realArtifactPath, 'utf8')) as unknown; + } catch (error) { + if ( + error instanceof Error && + (error.message === 'unsafe-artifact-type' || + error.message === 'escaped-artifact') + ) { + throw new Error( + `initial-import-diagnostic-artifact-path-invalid:${artifact.label}` + ); + } + throw new Error( + `initial-import-diagnostic-artifact-invalid:${artifact.label}` + ); + } + + if (!artifact.validate(value)) { + throw new Error( + `initial-import-diagnostic-artifact-invalid:${artifact.label}` + ); + } +} + +function isContained(directory: string, artifactPath: string): boolean { + const relativePath = relative(directory, artifactPath); + return ( + relativePath.length > 0 && + !isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`) + ); +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts new file mode 100644 index 000000000..1746c9335 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts @@ -0,0 +1,19 @@ +export const CPU_PROFILE = Object.freeze({ + endTime: 3_000, + nodes: Object.freeze([ + Object.freeze({ + callFrame: Object.freeze({ + columnNumber: -1, + functionName: '(root)', + lineNumber: -1, + scriptId: '0', + url: '', + }), + hitCount: 1, + id: 1, + }), + ]), + samples: Object.freeze([1]), + startTime: 1_000, + timeDeltas: Object.freeze([1_000]), +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts new file mode 100644 index 000000000..21c4aadc4 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + assertSyntheticM3uImportUrl, + createM3uImportIterationDefinitions, + listM3uImportScenarios, + M3U_IMPORT_SCENARIO_ID, +} from './m3u-import-benchmark-contract'; + +describe('formal M3U import benchmark contract', () => { + it('defines the exact 10k, 50k, and 100k synthetic scenarios', () => { + assert.deepEqual(listM3uImportScenarios(), [ + { + channelCount: 10_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K, + }, + { + channelCount: 50_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K, + }, + { + channelCount: 100_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K, + }, + ]); + }); + + it('schedules one warm-up, five measured runs, and one diagnostic per size', () => { + const definitions = createM3uImportIterationDefinitions(false); + + assert.equal(definitions.length, 21); + for (const scenario of listM3uImportScenarios()) { + const scenarioRuns = definitions.filter( + (definition) => definition.scenarioId === scenario.id + ); + assert.deepEqual( + scenarioRuns.map(({ kind, runId }) => ({ kind, runId })), + [ + { + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: 'warmup-01', + }, + ...Array.from({ length: 5 }, (_, index) => ({ + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: `run-${String(index + 1).padStart(2, '0')}`, + })), + { + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }, + ] + ); + assert.ok( + scenarioRuns.every( + (definition) => + definition.channelCount === scenario.channelCount + ) + ); + } + }); + + it('keeps fixture sizes unchanged while smoke mode reduces measured runs to one', () => { + const definitions = createM3uImportIterationDefinitions(true); + + assert.equal(definitions.length, 9); + for (const scenario of listM3uImportScenarios()) { + assert.deepEqual( + definitions + .filter( + (definition) => definition.scenarioId === scenario.id + ) + .map(({ kind, runId }) => ({ kind, runId })), + [ + { + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: 'warmup-01', + }, + { + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: 'run-01', + }, + { + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }, + ] + ); + } + }); + + it('accepts only the exact credential-free loopback fixture URL', () => { + assert.doesNotThrow(() => + assertSyntheticM3uImportUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u' + ) + ); + + for (const unsafe of [ + 'https://127.0.0.1:43210/synthetic-performance.m3u', + 'http://localhost:43210/synthetic-performance.m3u', + 'http://127.0.0.1/synthetic-performance.m3u', + 'http://user:pass@127.0.0.1:43210/synthetic-performance.m3u', + 'http://127.0.0.1:43210/other.m3u', + 'http://127.0.0.1:43210/synthetic-performance.m3u?token=secret', + 'http://127.0.0.1:43210/synthetic-performance.m3u#fragment', + ]) { + assert.throws( + () => assertSyntheticM3uImportUrl(unsafe), + /exact synthetic HTTP loopback URL/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts new file mode 100644 index 000000000..6740be17f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts @@ -0,0 +1,118 @@ +import { + PERFORMANCE_ITERATION_KIND, + type PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import { SYNTHETIC_M3U_RESOURCE_PATH } from './synthetic-m3u-server'; +import type { SyntheticM3uChannelCount } from './synthetic-m3u'; + +export const M3U_IMPORT_SCENARIO_ID = { + IMPORT_10K: 'm3u-import-10k', + IMPORT_50K: 'm3u-import-50k', + IMPORT_100K: 'm3u-import-100k', +} as const; + +export type M3uImportScenarioId = + (typeof M3U_IMPORT_SCENARIO_ID)[keyof typeof M3U_IMPORT_SCENARIO_ID]; + +export interface M3uImportScenario { + readonly channelCount: SyntheticM3uChannelCount; + readonly id: M3uImportScenarioId; +} + +export interface M3uImportIterationDefinition { + readonly channelCount: SyntheticM3uChannelCount; + readonly kind: PerformanceIterationKind; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +} + +const SCENARIOS: readonly M3uImportScenario[] = Object.freeze([ + Object.freeze({ + channelCount: 10_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K, + }), + Object.freeze({ + channelCount: 50_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K, + }), + Object.freeze({ + channelCount: 100_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K, + }), +]); + +export function listM3uImportScenarios(): readonly M3uImportScenario[] { + return SCENARIOS; +} + +export function createM3uImportIterationDefinitions( + smoke: boolean +): readonly M3uImportIterationDefinition[] { + const measuredRuns = smoke ? 1 : 5; + const definitions: M3uImportIterationDefinition[] = []; + + for (const scenario of SCENARIOS) { + definitions.push( + iteration(scenario, PERFORMANCE_ITERATION_KIND.WARMUP, 'warmup-01') + ); + for (let index = 1; index <= measuredRuns; index += 1) { + definitions.push( + iteration( + scenario, + PERFORMANCE_ITERATION_KIND.MEASURED, + `run-${String(index).padStart(2, '0')}` + ) + ); + } + definitions.push( + iteration( + scenario, + PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + 'diagnostic' + ) + ); + } + + return Object.freeze(definitions); +} + +export function assertSyntheticM3uImportUrl(value: string): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw invalidSyntheticUrl(); + } + + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + url.port.length === 0 || + url.username.length > 0 || + url.password.length > 0 || + url.pathname !== SYNTHETIC_M3U_RESOURCE_PATH || + url.search.length > 0 || + url.hash.length > 0 + ) { + throw invalidSyntheticUrl(); + } +} + +function iteration( + scenario: M3uImportScenario, + kind: PerformanceIterationKind, + runId: string +): M3uImportIterationDefinition { + return Object.freeze({ + channelCount: scenario.channelCount, + kind, + runId, + scenarioId: scenario.id, + }); +} + +function invalidSyntheticUrl(): Error { + return new Error( + 'M3U performance fixture must use the exact synthetic HTTP loopback URL' + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts new file mode 100644 index 000000000..f1dcd053a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +import { + assertM3uImportOutputPathHasNoSymlinks, + assertM3uImportSourceState, + M3U_IMPORT_RENDERER_CDP_PORT, + resolveM3uImportRendererCdpPort, + resolveM3uImportOutputLayout, +} from './m3u-import-benchmark-support'; +import { assertM3uImportRequestDelta } from './m3u-import.benchmark'; + +describe('initial M3U import benchmark lifecycle', () => { + it('keeps requested output and variant strictly below dist/performance', () => { + const workspaceRoot = resolve('/workspace/iptvnator'); + const outputRoot = resolve( + workspaceRoot, + 'dist/performance/20260727T040000Z-m3u-import' + ); + assert.deepEqual( + resolveM3uImportOutputLayout(workspaceRoot, outputRoot, 'baseline'), + { + outputRoot, + variantDirectory: resolve(outputRoot, 'baseline'), + } + ); + + for (const requestedRoot of [ + 'dist/performance/run', + resolve(workspaceRoot, 'dist/performance'), + resolve(workspaceRoot, 'dist/performance-sibling/run'), + resolve(workspaceRoot, '../outside'), + ]) { + assert.throws( + () => + resolveM3uImportOutputLayout( + workspaceRoot, + requestedRoot, + 'baseline' + ), + /absolute path below dist\/performance/ + ); + } + assert.throws( + () => + resolveM3uImportOutputLayout( + workspaceRoot, + outputRoot, + '../escape' + ), + /variant is invalid/i + ); + }); + + it('requires a clean source tree for formal runs but permits dirty smoke evidence', () => { + const dirtyState = { + commit: 'a'.repeat(40), + diffSha256: 'b'.repeat(64), + dirty: true, + }; + + assert.throws( + () => assertM3uImportSourceState(false, dirtyState), + /Formal performance runs require a clean Git worktree/ + ); + assert.doesNotThrow(() => assertM3uImportSourceState(true, dirtyState)); + assert.doesNotThrow(() => + assertM3uImportSourceState(false, { + ...dirtyState, + dirty: false, + }) + ); + }); + + it('rejects an existing output ancestor symlink before artifact creation', async () => { + const fixtureRoot = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-output-path-') + ); + const repositoryRoot = join(fixtureRoot, 'repository'); + const performanceRoot = join( + repositoryRoot, + 'dist', + 'performance' + ); + const outside = join(fixtureRoot, 'outside'); + await mkdir(performanceRoot, { recursive: true }); + await mkdir(outside); + await symlink(outside, join(performanceRoot, 'redirect')); + + try { + await assert.rejects( + assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot, + join(performanceRoot, 'redirect', 'run') + ), + /Performance output path contains a symbolic link/ + ); + await assert.doesNotReject( + assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot, + join(performanceRoot, 'ordinary', 'run') + ) + ); + } finally { + await rm(fixtureRoot, { force: true, recursive: true }); + } + }); + + it('keeps formal CDP capture on 9222 while allowing an isolated smoke port', () => { + assert.equal( + resolveM3uImportRendererCdpPort(false, undefined), + M3U_IMPORT_RENDERER_CDP_PORT + ); + assert.equal( + resolveM3uImportRendererCdpPort(false, '9222'), + M3U_IMPORT_RENDERER_CDP_PORT + ); + assert.equal(resolveM3uImportRendererCdpPort(true, '9322'), 9322); + + assert.throws( + () => resolveM3uImportRendererCdpPort(false, '9322'), + /Formal performance runs require CDP port 9222/ + ); + for (const invalid of ['0', '1023', '65536', '9222.5', 'not-a-port']) { + assert.throws( + () => resolveM3uImportRendererCdpPort(true, invalid), + /IPTVNATOR_PERF_CDP_PORT is invalid/ + ); + } + }); + + it('fails closed unless one and only one app GET occurs', () => { + assert.doesNotThrow(() => assertM3uImportRequestDelta(3, 4)); + assert.throws( + () => assertM3uImportRequestDelta(3, 3), + /exactly one application GET/ + ); + assert.throws( + () => assertM3uImportRequestDelta(3, 5), + /exactly one application GET/ + ); + }); + + it('waits for the exact upsert plus route-reload GET sequence', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /status\.databaseRequests\s*===\s*2/); + assert.match(source, /status\.databaseUpsertsCompleted\s*===\s*1/); + assert.match(source, /status\.databaseGetsCompleted\s*===\s*1/); + assert.match(source, /status\.databasePending\s*===\s*0/); + assert.match(source, /status\.preloadSuccessMarkers\s*===\s*2/); + }); + + it('always disposes renderer capture before closing Electron on iteration failure', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + const runIteration = source.indexOf('async function runIteration'); + const finallyBlock = source.indexOf('} finally {', runIteration); + const rendererDispose = source.indexOf( + 'await renderer.dispose()', + finallyBlock + ); + const appClose = source.indexOf('await closeElectronApp(app)', finallyBlock); + + assert.ok(finallyBlock > runIteration); + assert.ok(rendererDispose > finallyBlock); + assert.ok(appClose > rendererDispose); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts new file mode 100644 index 000000000..c153a3581 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts @@ -0,0 +1,300 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer as createTcpServer } from 'node:net'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { expect, type Page } from '@playwright/test'; + +import { + type LaunchedElectronApp, + workspaceRoot, +} from '../electron-test-fixtures'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; +import type { RendererWindowIdentity } from './renderer-window-rss-session'; + +const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +export const M3U_IMPORT_RENDERER_CDP_PORT = 9222; + +export interface M3uImportGitSourceState { + readonly commit: string; + readonly diffSha256: string; + readonly dirty: boolean; +} + +export interface M3uImportOutputLayout { + readonly outputRoot: string; + readonly variantDirectory: string; +} + +export interface M3uImportBenchmarkConfiguration extends M3uImportOutputLayout { + readonly electronVersion: string; + readonly measuredRuns: number; + readonly rendererCdpPort: number; + readonly smoke: boolean; + readonly sourceState: M3uImportGitSourceState; + readonly variant: string; +} + +export async function resolveM3uImportBenchmarkConfiguration(): Promise { + const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR']; + if (!requestedRoot) { + throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required'); + } + const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline'; + const layout = resolveM3uImportOutputLayout( + workspaceRoot, + requestedRoot, + variant + ); + const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1'; + const rendererCdpPort = resolveM3uImportRendererCdpPort( + smoke, + process.env['IPTVNATOR_PERF_CDP_PORT'] + ); + const sourceState = readGitSourceState(workspaceRoot); + assertM3uImportSourceState(smoke, sourceState); + const electronPackage = JSON.parse( + await readFile( + join(workspaceRoot, 'node_modules', 'electron', 'package.json'), + 'utf8' + ) + ) as { version?: unknown }; + if (typeof electronPackage.version !== 'string') { + throw new Error('Unable to resolve the Electron runtime version'); + } + await assertM3uImportOutputPathHasNoSymlinks( + workspaceRoot, + layout.outputRoot + ); + await assertMissing(layout.variantDirectory); + await assertPerformanceArtifactCapacity(layout.variantDirectory); + await mkdir(layout.variantDirectory, { recursive: true }); + return Object.freeze({ + ...layout, + electronVersion: electronPackage.version, + measuredRuns: smoke ? 1 : 5, + rendererCdpPort, + smoke, + sourceState, + variant, + }); +} + +export function resolveM3uImportOutputLayout( + repositoryRoot: string, + requestedRoot: string, + variant: string +): M3uImportOutputLayout { + const performanceRoot = resolve(repositoryRoot, 'dist', 'performance'); + const outputRoot = resolve(requestedRoot); + if ( + !isAbsolute(requestedRoot) || + !isStrictDescendant(performanceRoot, outputRoot) + ) { + throw new Error( + 'Performance output must be an absolute path below dist/performance' + ); + } + if (!OUTPUT_VARIANT_PATTERN.test(variant)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + const variantDirectory = resolve(outputRoot, variant); + if (!isStrictDescendant(outputRoot, variantDirectory)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + return Object.freeze({ outputRoot, variantDirectory }); +} + +export async function assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot: string, + outputRoot: string +): Promise { + const root = resolve(repositoryRoot); + const output = resolve(outputRoot); + if (!isStrictDescendant(root, output)) { + throw new Error('Performance output path escapes the repository'); + } + const components = relative(root, output).split(sep); + let current = root; + + for (const component of ['', ...components]) { + current = component === '' ? current : join(current, component); + try { + const stats = await lstat(current); + if (stats.isSymbolicLink()) { + throw new Error( + `Performance output path contains a symbolic link: ${current}` + ); + } + } catch (error) { + if (isMissingPath(error)) { + return; + } + throw error; + } + } +} + +export function assertM3uImportSourceState( + smoke: boolean, + state: M3uImportGitSourceState +): void { + if (!smoke && state.dirty) { + throw new Error( + 'Formal performance runs require a clean Git worktree; commit or stash changes first' + ); + } +} + +export function resolveM3uImportRendererCdpPort( + smoke: boolean, + requestedPort: string | undefined +): number { + const port = + requestedPort === undefined + ? M3U_IMPORT_RENDERER_CDP_PORT + : Number(requestedPort); + if ( + (requestedPort !== undefined && !/^[1-9]\d*$/.test(requestedPort)) || + !Number.isSafeInteger(port) || + port < 1024 || + port > 65_535 + ) { + throw new Error('IPTVNATOR_PERF_CDP_PORT is invalid'); + } + if (!smoke && port !== M3U_IMPORT_RENDERER_CDP_PORT) { + throw new Error( + `Formal performance runs require CDP port ${M3U_IMPORT_RENDERER_CDP_PORT}` + ); + } + return port; +} + +export async function resolveRendererWindowIdentity( + app: LaunchedElectronApp +): Promise { + const handle = await app.electronApp.browserWindow(app.mainWindow); + try { + return await handle.evaluate((browserWindow) => { + const exactWindow = browserWindow as unknown as { + readonly id: number; + readonly webContents: { readonly id: number }; + }; + return { + browserWindowId: exactWindow.id, + webContentsId: exactWindow.webContents.id, + }; + }); + } finally { + await handle.dispose(); + } +} + +export async function assertTcpPortAvailable(port: number): Promise { + const server = createTcpServer(); + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen( + { exclusive: true, host: '127.0.0.1', port }, + resolvePromise + ); + }); + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => + error ? rejectPromise(error) : resolvePromise() + ); + }); +} + +export async function assertRendererCdpTarget( + page: Page, + port: number +): Promise { + await expect + .poll( + async () => { + try { + const response = await fetch( + `http://127.0.0.1:${port}/json/list` + ); + const targets = response.ok + ? ((await response.json()) as unknown) + : null; + return ( + Array.isArray(targets) && + targets.some( + (target) => + typeof target === 'object' && + target !== null && + (target as Record)['type'] === + 'page' && + (target as Record)['url'] === + page.url() + ) + ); + } catch { + return false; + } + }, + { timeout: 10_000 } + ) + .toBe(true); +} + +export async function writeM3uImportJson( + path: string, + value: unknown +): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function readGitSourceState(repositoryRoot: string): M3uImportGitSourceState { + const git = (args: readonly string[]): string => + execFileSync('git', [...args], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + const commit = git(['rev-parse', 'HEAD']).trim(); + const status = git(['status', '--porcelain=v1', '--untracked-files=all']); + const diffSha256 = createHash('sha256') + .update(status) + .update('\0') + .update(git(['diff', '--binary', 'HEAD'])) + .update('\0') + .update(git(['diff', '--binary', '--cached', 'HEAD'])) + .digest('hex'); + return Object.freeze({ + commit, + diffSha256, + dirty: status.trim().length > 0, + }); +} + +function isStrictDescendant(parent: string, candidate: string): boolean { + const pathFromParent = relative(resolve(parent), resolve(candidate)); + return ( + pathFromParent.length > 0 && + pathFromParent !== '..' && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent) + ); +} + +async function assertMissing(path: string): Promise { + try { + await access(path); + } catch { + return; + } + throw new Error(`Performance output already exists: ${path}`); +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts b/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts new file mode 100644 index 000000000..29e17edb6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts @@ -0,0 +1,250 @@ +import type { + MainTimelineRecord, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +type InitialImportDatabaseOperation = + | 'DB_GET_APP_PLAYLIST' + | 'DB_UPSERT_APP_PLAYLIST'; + +export interface M3uImportIpcAttribution { + readonly databaseWorkerToMainCloneProxyMs: number; + readonly ipcStructuredCloneProxyMs: number; + readonly mainToDatabaseWorkerCloneProxyMs: number; + readonly mainToRendererCloneProxyMs: number; + readonly rendererToMainCloneProxyMs: number; +} + +interface RequestBoundaryAttribution { + readonly mainToDatabaseWorkerMs: number; + readonly mainToRendererMs: number; + readonly rendererToMainMs: number; + readonly successEpochMs: number; + readonly workerToMainMs: number; +} + +export function deriveM3uImportIpcAttribution(input: { + readonly get: WorkerRequestPerformanceMetrics; + readonly getReadStartedEpochMs: number; + readonly importDispatchEndedEpochMs: number; + readonly importDispatchStartedEpochMs: number; + readonly normalizeEndedEpochMs: number; + readonly playlistId: string; + readonly publishChannelsStartedEpochMs: number; + readonly serializeStartedEpochMs: number; + readonly timeline: readonly MainTimelineRecord[]; + readonly upsert: WorkerRequestPerformanceMetrics; +}): M3uImportIpcAttribution { + requireOrdered( + input.normalizeEndedEpochMs, + input.importDispatchStartedEpochMs + ); + requireOrdered( + input.importDispatchStartedEpochMs, + input.importDispatchEndedEpochMs + ); + + const upsert = deriveRequestBoundaryAttribution({ + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: input.playlistId, + request: input.upsert, + timeline: input.timeline, + workPhaseStartedEpochMs: input.serializeStartedEpochMs, + }); + requireOrdered(input.importDispatchEndedEpochMs, requireSource(input.upsert)); + + const get = deriveRequestBoundaryAttribution({ + operation: 'DB_GET_APP_PLAYLIST', + playlistId: input.playlistId, + request: input.get, + timeline: input.timeline, + workPhaseStartedEpochMs: input.getReadStartedEpochMs, + }); + requireOrdered(upsert.successEpochMs, requireSource(input.get)); + requireOrdered( + get.successEpochMs, + input.publishChannelsStartedEpochMs + ); + + const initialMainToRendererMs = difference( + input.importDispatchStartedEpochMs, + input.normalizeEndedEpochMs + ); + const mainToRendererCloneProxyMs = + initialMainToRendererMs + + upsert.mainToRendererMs + + get.mainToRendererMs; + const rendererToMainCloneProxyMs = + upsert.rendererToMainMs + get.rendererToMainMs; + const mainToDatabaseWorkerCloneProxyMs = + upsert.mainToDatabaseWorkerMs + get.mainToDatabaseWorkerMs; + const databaseWorkerToMainCloneProxyMs = + upsert.workerToMainMs + get.workerToMainMs; + + return Object.freeze({ + databaseWorkerToMainCloneProxyMs, + ipcStructuredCloneProxyMs: + mainToRendererCloneProxyMs + + rendererToMainCloneProxyMs + + mainToDatabaseWorkerCloneProxyMs + + databaseWorkerToMainCloneProxyMs, + mainToDatabaseWorkerCloneProxyMs, + mainToRendererCloneProxyMs, + rendererToMainCloneProxyMs, + }); +} + +function deriveRequestBoundaryAttribution(input: { + readonly operation: InitialImportDatabaseOperation; + readonly playlistId: string; + readonly request: WorkerRequestPerformanceMetrics; + readonly timeline: readonly MainTimelineRecord[]; + readonly workPhaseStartedEpochMs: number; +}): RequestBoundaryAttribution { + requireInitialImportPreloadIdentity(input.request, input.operation); + const requestTimeline = requireTimelineRecord( + input.timeline, + 'db-request', + input.request, + input.operation, + input.playlistId + ); + requireTimelineRecord( + input.timeline, + 'db-response', + input.request, + input.operation, + input.playlistId + ); + const successTimeline = requireTimelineRecord( + input.timeline, + 'preload-performance-success', + input.request, + input.operation, + input.playlistId + ); + const sourceEpochMs = requireSource(input.request); + const requestReceivedEpochMs = requireTimestamp( + input.request.requestReceivedEpochMs + ); + const workStartedEpochMs = requireTimestamp( + input.request.workStartedEpochMs + ); + const workEndedEpochMs = requireTimestamp(input.request.workEndedEpochMs); + const responsePostedEpochMs = requireTimestamp( + input.request.responsePostedEpochMs + ); + const responseEpochMs = requireTimestamp(input.request.responseEpochMs); + const successEpochMs = requireTimestamp(successTimeline.sourceEpochMs); + + requireOrdered(sourceEpochMs, requestTimeline.epochMs); + requireOrdered(requestTimeline.epochMs, requestReceivedEpochMs); + requireOrdered(requestReceivedEpochMs, workStartedEpochMs); + requireOrdered(workStartedEpochMs, input.workPhaseStartedEpochMs); + requireOrdered(input.workPhaseStartedEpochMs, workEndedEpochMs); + requireOrdered(workEndedEpochMs, responsePostedEpochMs); + requireOrdered(responsePostedEpochMs, responseEpochMs); + requireOrdered(responseEpochMs, successEpochMs); + + return { + mainToDatabaseWorkerMs: difference( + requestReceivedEpochMs, + requestTimeline.epochMs + ), + mainToRendererMs: difference(successEpochMs, responseEpochMs), + rendererToMainMs: difference(requestTimeline.epochMs, sourceEpochMs), + successEpochMs, + workerToMainMs: difference(responseEpochMs, responsePostedEpochMs), + }; +} + +function requireTimelineRecord( + timeline: readonly MainTimelineRecord[], + type: 'db-request' | 'db-response' | 'preload-performance-success', + request: WorkerRequestPerformanceMetrics, + operation: InitialImportDatabaseOperation, + playlistId: string +): MainTimelineRecord { + if (!request.requestId || !Number.isSafeInteger(request.ipcCallId)) { + throw new Error('database-request-identity-missing'); + } + const matches = timeline.filter((record) => { + if ( + record.type !== type || + record.operation !== operation || + record.playlistId !== playlistId + ) { + return false; + } + if (type === 'preload-performance-success') { + return ( + record.ipcCallId === request.ipcCallId && + record.sourceEpochMs !== undefined && + record.success === true + ); + } + return ( + record.requestId === request.requestId && + (type !== 'db-response' || record.success === true) + ); + }); + if (matches.length !== 1 || !matches[0]) { + throw new Error(`database-${type}-timeline-correlation`); + } + const record = matches[0]; + if ( + type === 'db-request' && + (record.ipcCallId !== request.ipcCallId || + record.sourceEpochMs !== request.sourceEpochMs) + ) { + throw new Error('database-request-timeline-identity'); + } + return record; +} + +function requireInitialImportPreloadIdentity( + request: WorkerRequestPerformanceMetrics, + operation: InitialImportDatabaseOperation +): void { + if ( + request.operation !== operation || + !request.success || + request.performanceCaptureUnavailableReason !== null || + !Number.isSafeInteger(request.ipcCallId) || + Number(request.ipcCallId) < 1 || + typeof request.sourceEpochMs !== 'number' || + !Number.isFinite(request.sourceEpochMs) || + request.sourceEpochMs < 0 || + request.operationId !== null || + request.operationIdUnavailableReason !== + 'preload-performance-marker-uncorrelated' + ) { + throw new Error('database-request-preload-identity'); + } +} + +function requireSource(request: WorkerRequestPerformanceMetrics): number { + return requireTimestamp(request.sourceEpochMs); +} + +function requireTimestamp(value: number | null | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error('database-request-timestamp-missing'); + } + return value; +} + +function requireOrdered(startEpochMs: number, endEpochMs: number): void { + if ( + !Number.isFinite(startEpochMs) || + !Number.isFinite(endEpochMs) || + endEpochMs < startEpochMs + ) { + throw new Error('phase-order'); + } +} + +function difference(endEpochMs: number, startEpochMs: number): number { + requireOrdered(startEpochMs, endEpochMs); + return endEpochMs - startEpochMs; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts new file mode 100644 index 000000000..fa8f198cb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts @@ -0,0 +1,181 @@ +import { once } from 'node:events'; +import { createWriteStream } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { CDPSession } from '@playwright/test'; + +interface TraceData { + readonly value: readonly unknown[]; +} + +export interface RendererArtifactCapture { + beginTraceOutput(): void; + readonly cpuProfilePath: string | null; + finishTraceOutput(): Promise; + readonly heapSnapshotPath: string | null; + readonly onTraceComplete: () => void; + readonly onTraceData: (data: TraceData) => void; + readonly traceComplete: Promise; + readonly tracePath: string | null; +} + +export function createRendererArtifactCapture(options: { + readonly diagnostic: boolean; + readonly outputDirectory: string; +}): RendererArtifactCapture { + const tracePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.trace.json') + : null; + const traceOutput = tracePath ? createWriteStream(tracePath) : null; + let traceOutputOpened = false; + let traceOutputFinish: Promise | null = null; + let traceFirstEvent = true; + let completeTrace: (() => void) | null = null; + const traceComplete = new Promise((resolve) => { + completeTrace = resolve; + }); + return { + beginTraceOutput: () => { + if ( + traceOutput && + !traceOutputOpened && + traceOutputFinish === null + ) { + traceOutput.write('{"traceEvents":['); + traceOutputOpened = true; + } + }, + cpuProfilePath: options.diagnostic + ? join(options.outputDirectory, 'renderer.cpuprofile') + : null, + finishTraceOutput: () => { + traceOutputFinish ??= (async () => { + if (!traceOutput) { + return; + } + if (!traceOutputOpened) { + traceOutput.write('{"traceEvents":['); + traceOutputOpened = true; + } + if (!traceOutput.writableEnded) { + traceOutput.write(']}'); + traceOutput.end(); + } + if (!traceOutput.writableFinished) { + await once(traceOutput, 'finish'); + } + })(); + return traceOutputFinish; + }, + heapSnapshotPath: options.diagnostic + ? join(options.outputDirectory, 'renderer.heapsnapshot') + : null, + onTraceComplete: () => { + completeTrace?.(); + completeTrace = null; + }, + onTraceData: (data) => { + if (!traceOutputOpened || traceOutput?.writableEnded) { + return; + } + for (const event of data.value) { + traceOutput?.write( + `${traceFirstEvent ? '' : ','}${JSON.stringify(event)}` + ); + traceFirstEvent = false; + } + }, + traceComplete, + tracePath, + }; +} + +export async function startRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + await session.send('Profiler.enable'); + await session.send('Profiler.start'); + artifacts.beginTraceOutput(); + session.on('Tracing.dataCollected', artifacts.onTraceData); + session.on('Tracing.tracingComplete', artifacts.onTraceComplete); + await session.send('Tracing.start', { + categories: [ + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-v8.cpu_profiler', + 'v8', + ].join(','), + options: 'sampling-frequency=1000', + transferMode: 'ReportEvents', + }); +} + +export async function stopRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + try { + const cpuProfile = await session.send('Profiler.stop'); + await writeFile( + requirePath(artifacts.cpuProfilePath, 'renderer CPU profile'), + JSON.stringify(cpuProfile['profile']), + 'utf8' + ); + await session.send('Tracing.end'); + await artifacts.traceComplete; + } finally { + disposeRendererDiagnosticListeners(session, artifacts); + await artifacts.finishTraceOutput(); + } +} + +export async function disposeRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + disposeRendererDiagnosticListeners(session, artifacts); + await artifacts.finishTraceOutput(); +} + +export async function takeRendererHeapSnapshot( + session: CDPSession, + path: string | null +): Promise { + const output = createWriteStream( + requirePath(path, 'renderer heap snapshot') + ); + const onChunk = ({ chunk }: { readonly chunk: string }): void => { + output.write(chunk); + }; + session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + try { + await session.send('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + }); + } finally { + session.off('HeapProfiler.addHeapSnapshotChunk', onChunk); + output.end(); + if (!output.writableFinished) { + await once(output, 'finish'); + } + } +} + +function disposeRendererDiagnosticListeners( + session: CDPSession, + artifacts: RendererArtifactCapture +): void { + session.off('Tracing.dataCollected', artifacts.onTraceData); + session.off('Tracing.tracingComplete', artifacts.onTraceComplete); + artifacts.onTraceComplete(); +} + +function requirePath(value: string | null, label: string): string { + if (value === null) { + throw new Error(`${label} path is unavailable`); + } + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts new file mode 100644 index 000000000..d2121babd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts @@ -0,0 +1,313 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based lifecycle test. */ +import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { startM3uImportRendererCapture } from './m3u-import-renderer-capture'; +import { M3U_IMPORT_RENDERER_STATE_KEY } from './m3u-import-renderer-probe'; + +test('idempotently disposes failed-iteration timers, probe, trace stream, listeners, and CDP session', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-dispose-') + ); + const session = new FakeCdpSession(); + const control = { + click: async () => undefined, + fill: async () => undefined, + first: () => control, + getByRole: () => control, + isDisabled: async () => false, + last: () => control, + waitFor: async () => undefined, + }; + const page = { + ...browser.page, + context: () => ({ + newCDPSession: async () => session, + }), + getByRole: () => control, + locator: () => control, + }; + + try { + const capture = await startM3uImportRendererCapture( + page as never, + { + diagnostic: true, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + } + ); + assert.equal(session.listenerCount('Tracing.dataCollected'), 1); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 1); + + await capture.dispose(); + await capture.dispose(); + + assert.equal(session.detachCount, 1); + assert.equal(session.listenerCount('Tracing.dataCollected'), 0); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 0); + assert.equal(browser.readState(), undefined); + const heapSamplesAfterDispose = session.heapUsageCalls; + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(session.heapUsageCalls, heapSamplesAfterDispose); + assert.deepEqual( + JSON.parse( + await readFile( + join(outputDirectory, 'renderer.trace.json'), + 'utf8' + ) + ), + { traceEvents: [] } + ); + } finally { + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +test('failed-iteration disposal does not await a wedged renderer or CDP detach', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-wedged-') + ); + const session = new FakeCdpSession({ hangDetach: true }); + let evaluateCalls = 0; + const page = createFakePage(browser, session, async (callback, argument) => { + evaluateCalls += 1; + if (evaluateCalls > 1) { + return new Promise(() => undefined); + } + return browser.page.evaluate(callback, argument); + }); + + try { + const capture = await startM3uImportRendererCapture(page as never, { + diagnostic: false, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + }); + const outcome = await Promise.race([ + capture.dispose().then(() => 'disposed'), + new Promise<'timeout'>((resolve) => + setTimeout(() => resolve('timeout'), 150) + ), + ]); + + assert.equal(outcome, 'disposed'); + assert.equal(session.detachCount, 1); + const heapSamplesAfterDispose = session.heapUsageCalls; + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(session.heapUsageCalls, heapSamplesAfterDispose); + } finally { + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +test('rolls back partially started capture when Chromium tracing rejects', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-startup-') + ); + const session = new FakeCdpSession({ failMethod: 'Tracing.start' }); + const intervalToken = Object.freeze({}); + const setIntervalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'setInterval' + ); + const clearIntervalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'clearInterval' + ); + let clearIntervalCalls = 0; + Object.defineProperty(globalThis, 'setInterval', { + configurable: true, + value: () => intervalToken, + writable: true, + }); + Object.defineProperty(globalThis, 'clearInterval', { + configurable: true, + value: (token: unknown) => { + if (token === intervalToken) { + clearIntervalCalls += 1; + } + }, + writable: true, + }); + + try { + await assert.rejects( + startM3uImportRendererCapture( + createFakePage(browser, session) as never, + { + diagnostic: true, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + } + ), + /synthetic Tracing\.start failure/ + ); + + assert.equal(clearIntervalCalls, 1); + assert.equal(session.detachCount, 1); + assert.equal(session.listenerCount('Tracing.dataCollected'), 0); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 0); + assert.equal(browser.readState(), undefined); + assert.deepEqual( + JSON.parse( + await readFile( + join(outputDirectory, 'renderer.trace.json'), + 'utf8' + ) + ), + { traceEvents: [] } + ); + } finally { + restoreGlobal('setInterval', setIntervalDescriptor); + restoreGlobal('clearInterval', clearIntervalDescriptor); + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +class FakeCdpSession extends EventEmitter { + detachCount = 0; + heapUsageCalls = 0; + + constructor( + private readonly options: { + readonly failMethod?: string; + readonly hangDetach?: boolean; + } = {} + ) { + super(); + } + + async detach(): Promise { + this.detachCount += 1; + if (this.options.hangDetach) { + return new Promise(() => undefined); + } + } + + async send(method: string): Promise> { + if (method === this.options.failMethod) { + throw new Error(`synthetic ${method} failure`); + } + if (method === 'Runtime.getHeapUsage') { + this.heapUsageCalls += 1; + return { usedSize: 1_024 }; + } + return {}; + } +} + +function installFakeProbeEnvironment() { + const descriptors = new Map(); + let nextFrameId = 1; + const replaceGlobal = (key: string, value: unknown): void => { + descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); + }; + class FakePerformanceObserver { + static readonly supportedEntryTypes = ['longtask']; + + disconnect(): undefined { + return undefined; + } + observe(): undefined { + return undefined; + } + takeRecords(): PerformanceEntry[] { + return []; + } + } + replaceGlobal('performance', { now: () => 0, timeOrigin: 0 }); + replaceGlobal('PerformanceObserver', FakePerformanceObserver); + replaceGlobal('cancelAnimationFrame', () => undefined); + replaceGlobal('requestAnimationFrame', () => nextFrameId++); + replaceGlobal('location', { pathname: '/' }); + replaceGlobal('document', { querySelector: () => null }); + + return { + cleanup: () => { + for (const [key, descriptor] of descriptors) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + Reflect.deleteProperty( + globalThis, + Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY) + ); + Reflect.deleteProperty( + globalThis, + M3U_IMPORT_RENDERER_STATE_KEY + ); + }, + page: { + evaluate: async ( + callback: (argument: never) => unknown, + argument: never + ) => callback(argument), + }, + readState: () => + (globalThis as Record)[ + M3U_IMPORT_RENDERER_STATE_KEY + ], + }; +} + +function createFakePage( + browser: ReturnType, + session: FakeCdpSession, + evaluate = browser.page.evaluate +) { + const control = { + click: async () => undefined, + fill: async () => undefined, + first: () => control, + getByRole: () => control, + isDisabled: async () => false, + last: () => control, + waitFor: async () => undefined, + }; + return { + ...browser.page, + context: () => ({ + newCDPSession: async () => session, + }), + evaluate, + getByRole: () => control, + locator: () => control, + }; +} + +function restoreGlobal( + key: 'clearInterval' | 'setInterval', + descriptor: PropertyDescriptor | undefined +): void { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts new file mode 100644 index 000000000..a409caf69 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts @@ -0,0 +1,389 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface ImportProbe { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: readonly number[]; + heartbeatDelaysMs: readonly number[]; + longTasksMs: readonly number[]; + operationStartEpochMs: number; + performancePhaseEvents: readonly unknown[]; + playlistId: string | null; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintedEpochMs: number | null; +} + +interface RendererCaptureModule { + advanceM3uImportHeartbeatDeadline?: ( + deadlineEpochMs: number, + nowEpochMs: number + ) => number; + assertCompleteM3uImportRendererProbe?: (probe: ImportProbe) => void; + startM3uImportRendererCapture?: unknown; +} + +interface RendererProbeModule { + M3U_IMPORT_RENDERER_STATE_KEY?: string; + installM3uImportRendererProbe?: (page: unknown) => Promise; + recordM3uImportHeartbeat?: ( + page: unknown, + deadlineEpochMs: number + ) => Promise; + stopM3uImportRendererProbe?: (page: unknown) => Promise; +} + +interface FakeProbeState { + frameGapsMs: number[]; + heartbeatDelaysMs: number[]; + operationStartEpochMs: number; + operationStartMonotonicMs: number; + terminalEpochMs: number | null; +} + +const captureUrl = new URL('./m3u-import-renderer-capture.ts', import.meta.url); +const artifactsUrl = new URL( + './m3u-import-renderer-artifacts.ts', + import.meta.url +); +const probeUrl = new URL('./m3u-import-renderer-probe.ts', import.meta.url); +const captureModulePromise = import(captureUrl.href) + .then((module) => module as RendererCaptureModule) + .catch(() => null); +const probeModulePromise = import(probeUrl.href) + .then((module) => module as unknown as RendererProbeModule) + .catch(() => null); + +function installFakeBrowserEnvironment() { + const descriptors = new Map(); + const frames = new Map void>(); + let nextFrameId = 1; + let nowMs = 0; + let observerRecords: PerformanceEntry[] | null = null; + + const replaceGlobal = (key: string, value: unknown): void => { + descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); + }; + class FakePerformanceObserver { + static readonly supportedEntryTypes = ['longtask']; + + constructor() { + observerRecords = []; + } + + disconnect(): undefined { + return undefined; + } + observe(): undefined { + return undefined; + } + takeRecords(): PerformanceEntry[] { + return observerRecords?.splice(0) ?? []; + } + } + replaceGlobal('performance', { now: () => nowMs, timeOrigin: 0 }); + replaceGlobal('PerformanceObserver', FakePerformanceObserver); + replaceGlobal('cancelAnimationFrame', (requestId: number) => { + frames.delete(requestId); + }); + replaceGlobal( + 'requestAnimationFrame', + (callback: (timestamp: number) => void) => { + const requestId = nextFrameId++; + frames.set(requestId, callback); + return requestId; + } + ); + replaceGlobal('location', { pathname: '/' }); + replaceGlobal('document', { querySelector: () => null }); + + return { + cleanup: () => { + for (const [key, descriptor] of descriptors) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + Reflect.deleteProperty( + globalThis, + Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY) + ); + }, + getObserver: () => observerRecords, + page: { + evaluate: async ( + callback: (argument: unknown) => unknown, + argument: unknown + ) => callback(argument), + }, + readState: (stateKey: string) => + (globalThis as Record)[stateKey] as FakeProbeState, + runFrame: (timestamp: number) => { + nowMs = timestamp; + for (const [requestId, callback] of [...frames]) { + if (frames.delete(requestId)) { + callback(timestamp); + } + } + }, + setNow: (value: number) => { + nowMs = value; + }, + showTerminal: () => { + ( + globalThis as unknown as { + location: { pathname: string }; + } + ).location.pathname = + '/Users/test/iptvnator/dist/apps/web/workspace/playlists/synthetic-id/all'; + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { querySelector: () => ({}) }, + writable: true, + }); + }, + }; +} + +function readSource(url: URL): string | null { + try { + return readFileSync(url, 'utf8'); + } catch { + return null; + } +} + +function completeProbe(): ImportProbe { + return { + firstChannelVisibleEpochMs: 130, + frameGapsMs: [16, 48], + heartbeatDelaysMs: [0, 12], + longTasksMs: [72], + operationStartEpochMs: 100, + performancePhaseEvents: [], + playlistId: 'synthetic-id', + routeReadyEpochMs: 120, + terminalEpochMs: 150, + uiPaintedEpochMs: 150, + }; +} + +test('exports the formal M3U import renderer collector', async () => { + const module = await captureModulePromise; + + assert.ok(module, 'M3U import renderer capture module must exist'); + assert.equal(typeof module.startM3uImportRendererCapture, 'function'); + assert.equal( + typeof module.assertCompleteM3uImportRendererProbe, + 'function' + ); +}); + +test('prepares the URL dialog before starting any measured capture', () => { + const source = readSource(captureUrl); + assert.ok(source, 'M3U import renderer capture source must exist'); + + const dialogPreparation = source.indexOf( + 'await prepareM3uImportDialog(page, options)' + ); + const cdpSession = source.indexOf('newCDPSession(page)'); + const initialHeapSample = source.indexOf('await sampleHeap()'); + + assert.ok(dialogPreparation >= 0); + assert.ok(cdpSession > dialogPreparation); + assert.ok(initialHeapSample > cdpSession); +}); + +test('captures heap samples in every run and raw CPU, trace, and heap artifacts only in diagnostics', () => { + const source = readSource(captureUrl); + const artifactSource = readSource(artifactsUrl); + assert.ok(source); + assert.ok(artifactSource); + + assert.match(source, /setInterval\(\(\) => void sampleHeap\(\), 20\)/); + assert.match(source, /Runtime\.getHeapUsage/); + assert.match(source, /HeapProfiler\.collectGarbage/); + assert.match( + artifactSource, + /cpuProfilePath: options\.diagnostic[\s\S]*renderer\.cpuprofile/ + ); + assert.match(artifactSource, /Profiler\.start/); + assert.match(artifactSource, /Tracing\.start/); + assert.match(artifactSource, /HeapProfiler\.takeHeapSnapshot/); +}); + +test('clips drained long tasks and heartbeat samples to the operation window', async () => { + const module = await probeModulePromise; + assert.ok(module); + assert.equal(typeof module.installM3uImportRendererProbe, 'function'); + assert.equal(typeof module.recordM3uImportHeartbeat, 'function'); + assert.equal(typeof module.stopM3uImportRendererProbe, 'function'); + assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string'); + const browser = installFakeBrowserEnvironment(); + + try { + await module.installM3uImportRendererProbe?.(browser.page); + const state = browser.readState( + module.M3U_IMPORT_RENDERER_STATE_KEY ?? '' + ); + state.operationStartEpochMs = 100; + state.operationStartMonotonicMs = 100; + state.terminalEpochMs = 200; + const observerRecords = browser.getObserver(); + assert.ok(observerRecords); + observerRecords.push( + { duration: 40, startTime: 80 } as PerformanceEntry, + { duration: 30, startTime: 190 } as PerformanceEntry, + { duration: 30, startTime: 210 } as PerformanceEntry + ); + + browser.setNow(250); + await module.recordM3uImportHeartbeat?.(browser.page, 90); + await module.recordM3uImportHeartbeat?.(browser.page, 150); + await module.recordM3uImportHeartbeat?.(browser.page, 210); + const metrics = await module.stopM3uImportRendererProbe?.(browser.page); + + assert.deepEqual(metrics?.heartbeatDelaysMs, [50]); + assert.deepEqual(metrics?.longTasksMs, [20, 10]); + } finally { + browser.cleanup(); + } +}); + +test('records the terminal animation-frame gap exactly once', async () => { + const module = await probeModulePromise; + assert.ok(module?.installM3uImportRendererProbe); + assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string'); + const browser = installFakeBrowserEnvironment(); + + try { + await module.installM3uImportRendererProbe(browser.page); + browser.runFrame(80); + const state = browser.readState( + module.M3U_IMPORT_RENDERER_STATE_KEY ?? '' + ); + state.operationStartEpochMs = 100; + state.operationStartMonotonicMs = 100; + browser.showTerminal(); + browser.runFrame(120); + browser.runFrame(140); + browser.runFrame(160); + + assert.equal(state.terminalEpochMs, 160); + assert.deepEqual(state.frameGapsMs, [20, 20, 20]); + await module.stopM3uImportRendererProbe?.(browser.page); + } finally { + browser.cleanup(); + } +}); + +test('advances the external heartbeat on its fixed 50ms deadline grid', async () => { + const module = await captureModulePromise; + assert.ok(module); + const advance = module.advanceM3uImportHeartbeatDeadline; + assert.equal(typeof advance, 'function'); + + assert.equal(advance?.(1_000, 1_005), 1_050); + assert.equal(advance?.(1_000, 1_060), 1_100); + assert.equal(advance?.(1_000, 1_200), 1_250); +}); + +test('installs the production renderer phase hook and returns its raw events', () => { + const source = readSource(probeUrl); + assert.ok(source); + + assert.match(source, /RENDERER_PERFORMANCE_PHASE_HOOK_KEY/); + assert.match(source, /Symbol\.for\(hookKey\)/); + assert.match(source, /performancePhaseEvents\.push\(event\)/); + assert.match( + source, + /performancePhaseEvents: \[\.\.\.state\.performancePhaseEvents\]/ + ); + assert.match(source, /delete target\[hookSymbol\]/); +}); + +test('timestamps the click immediately before final Add and requires route, first item, and two animation frames', () => { + const source = readSource(probeUrl); + assert.ok(source); + + const operationStart = source.indexOf( + 'state.operationStartMonotonicMs = performance.now()' + ); + const click = source.indexOf('button.click()', operationStart); + assert.ok(operationStart >= 0); + assert.ok(click > operationStart); + assert.ok(source.includes('/\\/workspace\\/playlists\\/([^/]+)\\/all$/')); + assert.match(source, /\[data-test-id="channel-item"\]/); + const firstPaintFrame = source.indexOf( + 'state.uiPaintFrameRequestId = requestAnimationFrame' + ); + const secondPaintFrame = source.indexOf( + 'state.uiPaintFrameRequestId = requestAnimationFrame', + firstPaintFrame + 1 + ); + assert.ok(firstPaintFrame >= 0); + assert.ok(secondPaintFrame > firstPaintFrame); + for (const phase of [ + 'operationStartEpochMs', + 'routeReadyEpochMs', + 'firstChannelVisibleEpochMs', + 'uiPaintedEpochMs', + 'terminalEpochMs', + ]) { + assert.match(source, new RegExp(phase)); + } +}); + +test('fails closed when any terminal proof or the probe itself is missing', async () => { + const module = await captureModulePromise; + assert.ok(module); + const assertComplete = module.assertCompleteM3uImportRendererProbe; + assert.equal(typeof assertComplete, 'function'); + + assert.doesNotThrow(() => assertComplete?.(completeProbe())); + for (const field of [ + 'playlistId', + 'routeReadyEpochMs', + 'firstChannelVisibleEpochMs', + 'uiPaintedEpochMs', + 'terminalEpochMs', + ] as const) { + const incomplete = { ...completeProbe(), [field]: null }; + assert.throws( + () => assertComplete?.(incomplete), + /m3u-import-renderer-probe-incomplete/ + ); + } + assert.throws( + () => + assertComplete?.({ + ...completeProbe(), + operationStartEpochMs: 0, + }), + /m3u-import-renderer-probe-incomplete/ + ); +}); + +test('clears the harness heap sampler even when the renderer probe is missing', () => { + const source = readSource(captureUrl); + assert.ok(source); + const backgroundStop = source.indexOf( + 'await stopBackgroundWork()', + source.indexOf('const stopCapture') + ); + const probeStop = source.indexOf('probe = await stopProbe()', backgroundStop); + + assert.match(source, /clearInterval\(heapSampleTimer\)/); + assert.ok(backgroundStop >= 0); + assert.ok(probeStop > backgroundStop); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts new file mode 100644 index 000000000..db5bd80bf --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts @@ -0,0 +1,366 @@ +import type { CDPSession, Page } from '@playwright/test'; + +import type { NumericDistribution } from './m3u-refresh-cancellation-contract'; +import { + createRendererArtifactCapture, + disposeRendererDiagnosticCapture, + type RendererArtifactCapture, + startRendererDiagnosticCapture, + stopRendererDiagnosticCapture, + takeRendererHeapSnapshot, +} from './m3u-import-renderer-artifacts'; +import { + installM3uImportRendererProbe, + type M3uImportRendererProbeMetrics, + prepareM3uImportDialog, + recordM3uImportHeartbeat, + stopM3uImportRendererProbe, + triggerM3uImport, + waitForM3uImportTerminal, +} from './m3u-import-renderer-probe'; +import { summarizeNumbers } from './performance-statistics'; + +const HEARTBEAT_INTERVAL_MS = 50; + +export interface M3uImportRendererCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; + readonly playlistTitle: string; + readonly playlistUrl: string; +} + +export interface M3uImportRendererPhaseTimestamps { + readonly firstChannelVisibleEpochMs: number; + readonly operationStartEpochMs: number; + readonly routeReadyEpochMs: number; + readonly terminalEpochMs: number; + readonly uiPaintedEpochMs: number; +} + +export interface M3uImportRendererCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly frameGap: NumericDistribution; + readonly heapSnapshotPath: string | null; + readonly heartbeatDelay: NumericDistribution; + readonly longTask: NumericDistribution; + readonly peakHeapUsedBytes: number; + readonly phaseTimestamps: M3uImportRendererPhaseTimestamps; + readonly postGcHeapUsedBytes: number; + readonly probe: M3uImportRendererProbeMetrics; + readonly tracePath: string | null; +} + +export interface RunningM3uImportRendererCapture { + readonly session: CDPSession; + dispose(): Promise; + stop(): Promise; + triggerImport(): Promise; + waitForTerminal(): Promise; +} + +interface HeapUsageResult { + readonly usedSize: number; +} + +export async function startM3uImportRendererCapture( + page: Page, + options: M3uImportRendererCaptureStartOptions +): Promise { + await prepareM3uImportDialog(page, options); + const session = await page.context().newCDPSession(page); + let probeInstalled = false; + let heapSampleTimer: NodeJS.Timeout | null = null; + let artifacts: RendererArtifactCapture | null = null; + const heapSamples: number[] = []; + let heapSampleError: unknown = null; + let heapSamplePromise: Promise | null = null; + const sampleHeap = (): Promise => { + heapSamplePromise ??= (async () => { + try { + const sample = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (Number.isFinite(sample.usedSize)) { + heapSamples.push(sample.usedSize); + } + } catch (error) { + heapSampleError ??= error; + } finally { + heapSamplePromise = null; + } + })(); + return heapSamplePromise; + }; + + try { + await session.send('Runtime.enable'); + await session.send('Performance.enable'); + await installM3uImportRendererProbe(page); + probeInstalled = true; + await sampleHeap(); + throwCaptureError(heapSampleError, 'Renderer heap sampling failed'); + heapSampleTimer = setInterval(() => void sampleHeap(), 20); + artifacts = createRendererArtifactCapture(options); + if (options.diagnostic) { + await startRendererDiagnosticCapture(session, artifacts); + } + } catch (error) { + if (heapSampleTimer) { + clearInterval(heapSampleTimer); + heapSampleTimer = null; + } + if (artifacts && options.diagnostic) { + await disposeRendererDiagnosticCapture( + session, + artifacts + ).catch(() => undefined); + } + if (probeInstalled) { + continueBestEffort(() => stopM3uImportRendererProbe(page)); + } + continueBestEffort(() => session.detach()); + throw error; + } + if (!artifacts || !heapSampleTimer) { + throw new Error('M3U import renderer capture initialization failed'); + } + + let heartbeatDeadlineEpochMs: number | null = null; + let heartbeatError: unknown = null; + let heartbeatInFlight: Promise | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; + let stopped = false; + let backgroundStopPromise: Promise | null = null; + let probeStopPromise: Promise | null = null; + let sessionDetachPromise: Promise | null = null; + let abortPromise: Promise | null = null; + let stopPromise: Promise | null = null; + let stopRequested = false; + let disposeRequested = false; + const stopSchedulingBackgroundWork = (): void => { + stopped = true; + if (heartbeatTimer) { + clearTimeout(heartbeatTimer); + heartbeatTimer = null; + } + if (heapSampleTimer) { + clearInterval(heapSampleTimer); + heapSampleTimer = null; + } + }; + const scheduleHeartbeat = (): void => { + if (stopped || heartbeatDeadlineEpochMs === null) { + return; + } + const deadline = heartbeatDeadlineEpochMs; + heartbeatTimer = setTimeout( + () => { + heartbeatTimer = null; + heartbeatInFlight = recordM3uImportHeartbeat(page, deadline) + .catch((error: unknown) => { + heartbeatError ??= error; + }) + .finally(() => { + heartbeatInFlight = null; + if (stopped) { + return; + } + heartbeatDeadlineEpochMs = + advanceM3uImportHeartbeatDeadline( + deadline, + Date.now() + ); + scheduleHeartbeat(); + }); + }, + Math.max(0, deadline - Date.now()) + ); + }; + const stopBackgroundWork = (): Promise => { + backgroundStopPromise ??= (async () => { + stopSchedulingBackgroundWork(); + await heartbeatInFlight; + await heapSamplePromise; + await sampleHeap(); + })(); + return backgroundStopPromise; + }; + const stopProbe = (): Promise => { + probeStopPromise ??= stopM3uImportRendererProbe(page); + return probeStopPromise; + }; + const detachSession = (): Promise => { + sessionDetachPromise ??= session.detach(); + return sessionDetachPromise; + }; + const abortResources = (): Promise => { + abortPromise ??= (async () => { + stopSchedulingBackgroundWork(); + continueBestEffort(() => heartbeatInFlight ?? Promise.resolve()); + continueBestEffort(() => heapSamplePromise ?? Promise.resolve()); + continueBestEffort(stopProbe); + if (options.diagnostic) { + await disposeRendererDiagnosticCapture( + session, + artifacts + ).catch(() => undefined); + } + continueBestEffort(detachSession); + })(); + return abortPromise; + }; + const stopCapture = + async (): Promise => { + try { + await stopBackgroundWork(); + let probe: M3uImportRendererProbeMetrics | null = null; + let probeError: unknown = null; + try { + probe = await stopProbe(); + } catch (error) { + probeError = error; + } + + if (options.diagnostic) { + await stopRendererDiagnosticCapture(session, artifacts); + } + await session.send('HeapProfiler.enable'); + await session.send('HeapProfiler.collectGarbage'); + const postGc = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (options.diagnostic) { + await takeRendererHeapSnapshot( + session, + artifacts.heapSnapshotPath + ); + } + await detachSession(); + throwCaptureError( + heapSampleError, + 'Renderer heap sampling failed' + ); + throwCaptureError(heartbeatError, 'Renderer heartbeat failed'); + throwCaptureError(probeError, 'Renderer probe stop failed'); + assertCompleteM3uImportRendererProbe(probe); + return Object.freeze({ + cpuProfilePath: artifacts.cpuProfilePath, + frameGap: summarizeNumbers(probe.frameGapsMs), + heapSnapshotPath: artifacts.heapSnapshotPath, + heartbeatDelay: summarizeNumbers( + probe.heartbeatDelaysMs + ), + longTask: summarizeNumbers(probe.longTasksMs), + peakHeapUsedBytes: Math.max(0, ...heapSamples), + phaseTimestamps: Object.freeze({ + firstChannelVisibleEpochMs: + probe.firstChannelVisibleEpochMs, + operationStartEpochMs: probe.operationStartEpochMs, + routeReadyEpochMs: probe.routeReadyEpochMs, + terminalEpochMs: probe.terminalEpochMs, + uiPaintedEpochMs: probe.uiPaintedEpochMs, + }), + postGcHeapUsedBytes: postGc.usedSize, + probe, + tracePath: artifacts.tracePath, + }); + } catch (error) { + await abortResources(); + throw error; + } + }; + + return Object.freeze({ + dispose: async () => { + disposeRequested = true; + if (stopPromise) { + continueBestEffort(() => stopPromise as Promise); + } + await abortResources(); + }, + session, + stop: () => { + if (stopRequested || disposeRequested) { + return Promise.reject( + new Error('M3U import renderer capture already stopped') + ); + } + stopRequested = true; + stopPromise = stopCapture(); + return stopPromise; + }, + triggerImport: async () => { + const operationStartEpochMs = await triggerM3uImport(page); + heartbeatDeadlineEpochMs = + operationStartEpochMs + HEARTBEAT_INTERVAL_MS; + scheduleHeartbeat(); + return operationStartEpochMs; + }, + waitForTerminal: () => waitForM3uImportTerminal(page), + }); +} + +export function advanceM3uImportHeartbeatDeadline( + deadlineEpochMs: number, + nowEpochMs: number +): number { + const nextDeadline = deadlineEpochMs + HEARTBEAT_INTERVAL_MS; + if (nextDeadline > nowEpochMs) { + return nextDeadline; + } + const missedIntervals = + Math.floor((nowEpochMs - nextDeadline) / HEARTBEAT_INTERVAL_MS) + 1; + return nextDeadline + missedIntervals * HEARTBEAT_INTERVAL_MS; +} + +export function assertCompleteM3uImportRendererProbe( + probe: M3uImportRendererProbeMetrics | null | undefined +): asserts probe is M3uImportRendererProbeMetrics & { + readonly firstChannelVisibleEpochMs: number; + readonly playlistId: string; + readonly routeReadyEpochMs: number; + readonly terminalEpochMs: number; + readonly uiPaintedEpochMs: number; +} { + if ( + !probe || + typeof probe.playlistId !== 'string' || + probe.playlistId.length === 0 || + !isPositiveFinite(probe.operationStartEpochMs) || + !isPositiveFinite(probe.routeReadyEpochMs) || + !isPositiveFinite(probe.firstChannelVisibleEpochMs) || + !isPositiveFinite(probe.uiPaintedEpochMs) || + !isPositiveFinite(probe.terminalEpochMs) + ) { + throw new Error('m3u-import-renderer-probe-incomplete'); + } + if ( + probe.routeReadyEpochMs < probe.operationStartEpochMs || + probe.firstChannelVisibleEpochMs < probe.routeReadyEpochMs || + probe.uiPaintedEpochMs < probe.firstChannelVisibleEpochMs || + probe.terminalEpochMs < probe.uiPaintedEpochMs + ) { + throw new Error('m3u-import-renderer-probe-incomplete'); + } +} + +function throwCaptureError(error: unknown, label: string): void { + if (error instanceof Error) { + throw error; + } + if (error !== null) { + throw new Error(`${label}: ${String(error)}`); + } +} + +function continueBestEffort(work: () => Promise): void { + try { + void work().catch(() => undefined); + } catch { + // The owning Electron app is closed immediately after abort cleanup. + } +} + +function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts new file mode 100644 index 000000000..599abb88a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts @@ -0,0 +1,378 @@ +import type { Page } from '@playwright/test'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, + type RendererPerformancePhaseHook, +} from '@iptvnator/shared/logging'; + +export const M3U_IMPORT_RENDERER_STATE_KEY = + '__iptvnatorM3uImportRendererCapture'; + +export interface M3uImportDialogOptions { + readonly playlistTitle: string; + readonly playlistUrl: string; +} + +export interface M3uImportRendererProbeMetrics { + readonly firstChannelVisibleEpochMs: number | null; + readonly frameGapsMs: readonly number[]; + readonly heartbeatDelaysMs: readonly number[]; + readonly longTasksMs: readonly number[]; + readonly operationStartEpochMs: number; + readonly performancePhaseEvents: readonly RendererPerformancePhaseEvent[]; + readonly playlistId: string | null; + readonly routeReadyEpochMs: number | null; + readonly terminalEpochMs: number | null; + readonly uiPaintedEpochMs: number | null; +} + +export async function prepareM3uImportDialog( + page: Page, + options: M3uImportDialogOptions +): Promise { + await page.getByRole('button', { name: 'Add playlist' }).first().click(); + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + await dialog + .getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' }) + .fill(options.playlistUrl); + await dialog + .getByRole('textbox', { name: 'Playlist title' }) + .fill(options.playlistTitle); + const submit = dialog.getByRole('button', { name: 'Add playlist' }).last(); + await submit.waitFor({ state: 'visible' }); + if (await submit.isDisabled()) { + throw new Error('m3u-import-final-add-disabled'); + } +} + +export async function installM3uImportRendererProbe(page: Page): Promise { + await page.evaluate( + ({ hookKey, stateKey }) => { + interface ProbeState { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + lastFrameAt: number | null; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + operationStartEpochMs: number; + operationStartMonotonicMs: number; + performancePhaseEvents: RendererPerformancePhaseEvent[]; + playlistId: string | null; + recordFrameGap(timestamp: number): void; + recordLongTask(entry: PerformanceEntry): void; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + } + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const hookSymbol = Symbol.for(hookKey); + if ( + target[stateKey] !== undefined || + target[hookSymbol] !== undefined + ) { + throw new Error('m3u-import-renderer-probe-already-installed'); + } + const epoch = (): number => + performance.timeOrigin + performance.now(); + const state: ProbeState = { + firstChannelVisibleEpochMs: null, + frameGapsMs: [], + frameRequestId: 0, + heartbeatDelaysMs: [], + lastFrameAt: null, + longTaskObserver: null, + longTasksMs: [], + operationStartEpochMs: 0, + operationStartMonotonicMs: 0, + performancePhaseEvents: [], + playlistId: null, + recordFrameGap: (timestamp) => { + const previousFrameAt = state.lastFrameAt; + state.lastFrameAt = timestamp; + if ( + previousFrameAt === null || + state.operationStartEpochMs <= 0 || + state.terminalEpochMs !== null + ) { + return; + } + const measuredFrameStart = Math.max( + previousFrameAt, + state.operationStartMonotonicMs + ); + if (timestamp > measuredFrameStart) { + state.frameGapsMs.push(timestamp - measuredFrameStart); + } + }, + recordLongTask: (entry) => { + const entryEpochMs = + performance.timeOrigin + entry.startTime; + const measuredStartEpochMs = Math.max( + entryEpochMs, + state.operationStartEpochMs + ); + const measuredEndEpochMs = Math.min( + entryEpochMs + entry.duration, + state.terminalEpochMs ?? Number.POSITIVE_INFINITY + ); + if ( + state.operationStartEpochMs > 0 && + measuredEndEpochMs > measuredStartEpochMs + ) { + state.longTasksMs.push( + measuredEndEpochMs - measuredStartEpochMs + ); + } + }, + routeReadyEpochMs: null, + terminalEpochMs: null, + uiPaintFrameRequestId: null, + uiPaintedEpochMs: null, + }; + const performancePhaseHook: RendererPerformancePhaseHook = ( + event + ) => { + state.performancePhaseEvents.push(event); + }; + target[hookSymbol] = performancePhaseHook; + const terminalTarget = (): string | null => { + const route = location.pathname.match( + /\/workspace\/playlists\/([^/]+)\/all$/ + ); + if ( + !route || + !document.querySelector('[data-test-id="channel-item"]') + ) { + return null; + } + return route[1] ?? null; + }; + const inspectTerminal = (): void => { + if ( + state.operationStartEpochMs <= 0 || + state.terminalEpochMs !== null + ) { + return; + } + const route = location.pathname.match( + /\/workspace\/playlists\/([^/]+)\/all$/ + ); + if (!route) { + return; + } + state.routeReadyEpochMs ??= epoch(); + if (!document.querySelector('[data-test-id="channel-item"]')) { + return; + } + state.firstChannelVisibleEpochMs ??= epoch(); + if (state.uiPaintFrameRequestId !== null) { + return; + } + state.uiPaintFrameRequestId = requestAnimationFrame(() => { + state.uiPaintFrameRequestId = requestAnimationFrame( + (terminalFrameAt) => { + state.uiPaintFrameRequestId = null; + const playlistId = terminalTarget(); + if (playlistId === null) { + return; + } + state.recordFrameGap(terminalFrameAt); + state.playlistId = playlistId; + state.uiPaintedEpochMs = epoch(); + state.terminalEpochMs = state.uiPaintedEpochMs; + cancelAnimationFrame(state.frameRequestId); + state.frameRequestId = 0; + for (const entry of state.longTaskObserver?.takeRecords() ?? + []) { + state.recordLongTask(entry); + } + state.longTaskObserver?.disconnect(); + state.longTaskObserver = null; + } + ); + }); + }; + const frame = (timestamp: number): void => { + state.recordFrameGap(timestamp); + if (state.operationStartEpochMs > 0) { + inspectTerminal(); + } + state.frameRequestId = + state.terminalEpochMs === null + ? requestAnimationFrame(frame) + : 0; + }; + state.frameRequestId = requestAnimationFrame(frame); + if ( + typeof PerformanceObserver === 'function' && + PerformanceObserver.supportedEntryTypes.includes('longtask') + ) { + state.longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + state.recordLongTask(entry); + } + }); + state.longTaskObserver.observe({ entryTypes: ['longtask'] }); + } + target[stateKey] = state; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + stateKey: M3U_IMPORT_RENDERER_STATE_KEY, + } + ); +} + +export async function triggerM3uImport(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + | { + operationStartEpochMs: number; + operationStartMonotonicMs: number; + } + | undefined; + if (!state || state.operationStartEpochMs > 0) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + const dialogs = Array.from( + document.querySelectorAll('mat-dialog-container') + ); + const dialog = dialogs.at(-1); + const button = Array.from(dialog?.querySelectorAll('button') ?? []) + .reverse() + .find( + (candidate) => + candidate.textContent?.trim() === 'Add playlist' && + !candidate.disabled + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('m3u-import-final-add-unavailable'); + } + state.operationStartMonotonicMs = performance.now(); + state.operationStartEpochMs = + performance.timeOrigin + state.operationStartMonotonicMs; + button.click(); + return state.operationStartEpochMs; + }, M3U_IMPORT_RENDERER_STATE_KEY); +} + +export async function recordM3uImportHeartbeat( + page: Page, + deadlineEpochMs: number +): Promise { + await page.evaluate( + ({ deadlineEpochMs, stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + | { + heartbeatDelaysMs: number[]; + operationStartEpochMs: number; + terminalEpochMs: number | null; + } + | undefined; + if (!state || state.operationStartEpochMs <= 0) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + if ( + deadlineEpochMs >= state.operationStartEpochMs && + (state.terminalEpochMs === null || + deadlineEpochMs <= state.terminalEpochMs) + ) { + const receivedEpochMs = + performance.timeOrigin + performance.now(); + const measuredEpochMs = + state.terminalEpochMs === null + ? receivedEpochMs + : Math.min(receivedEpochMs, state.terminalEpochMs); + state.heartbeatDelaysMs.push( + Math.max(0, measuredEpochMs - deadlineEpochMs) + ); + } + }, + { deadlineEpochMs, stateKey: M3U_IMPORT_RENDERER_STATE_KEY } + ); +} + +export async function waitForM3uImportTerminal(page: Page): Promise { + await page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + { terminalEpochMs: number | null } | undefined; + return typeof state?.terminalEpochMs === 'number'; + }, + M3U_IMPORT_RENDERER_STATE_KEY, + { timeout: 120_000 } + ) + .then(() => undefined); +} + +export async function stopM3uImportRendererProbe( + page: Page +): Promise { + return page.evaluate( + ({ hookKey, stateKey }) => { + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const hookSymbol = Symbol.for(hookKey); + const state = target[stateKey] as + | { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + operationStartEpochMs: number; + performancePhaseEvents: RendererPerformancePhaseEvent[]; + playlistId: string | null; + recordLongTask(entry: PerformanceEntry): void; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + } + | undefined; + if (!state) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + cancelAnimationFrame(state.frameRequestId); + if (state.uiPaintFrameRequestId !== null) { + cancelAnimationFrame(state.uiPaintFrameRequestId); + } + for (const entry of state.longTaskObserver?.takeRecords() ?? []) { + state.recordLongTask(entry); + } + state.longTaskObserver?.disconnect(); + delete target[stateKey]; + delete target[hookSymbol]; + return { + firstChannelVisibleEpochMs: state.firstChannelVisibleEpochMs, + frameGapsMs: [...state.frameGapsMs], + heartbeatDelaysMs: [...state.heartbeatDelaysMs], + longTasksMs: [...state.longTasksMs], + operationStartEpochMs: state.operationStartEpochMs, + performancePhaseEvents: [...state.performancePhaseEvents], + playlistId: state.playlistId, + routeReadyEpochMs: state.routeReadyEpochMs, + terminalEpochMs: state.terminalEpochMs, + uiPaintedEpochMs: state.uiPaintedEpochMs, + }; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + stateKey: M3U_IMPORT_RENDERER_STATE_KEY, + } + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts new file mode 100644 index 000000000..951030aed --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import { deriveM3uImportPhaseMetrics } from './m3u-import-report'; +import { + createTestMainCapture, + createTestRendererCapture, + TEST_M3U_CHANNEL_COUNT, + TEST_M3U_FIXTURE_BYTES, +} from './m3u-import-report.test-helpers'; + +describe('formal M3U import phase report', () => { + it('attributes every required phase without presenting indexes as zero work', () => { + const phases = deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: createTestMainCapture(), + renderer: createTestRendererCapture(), + }); + + assert.deepEqual(phases, { + angularRenderingMs: 10, + dataAcquireMs: 20, + databaseWorkerToMainCloneProxyMs: 12, + indexAndCommitMs: null, + indexAndCommitUnavailableReason: + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write', + ipcStructuredCloneProxyMs: 44, + mainToDatabaseWorkerCloneProxyMs: 2, + mainToRendererCloneProxyMs: 28, + m3uParsingMs: 40, + normalizationMs: 10, + playlistDeserializationMs: 12, + playlistSerializationMs: 20, + rendererToMainCloneProxyMs: 2, + sqliteReadMs: 5, + sqliteWriteMs: 30, + storeImportDispatchMs: 2, + storePublishChannelsMs: 10, + storeUpdateMs: 12, + totalMs: 215, + }); + assert.equal( + phases.ipcStructuredCloneProxyMs, + phases.mainToRendererCloneProxyMs + + phases.rendererToMainCloneProxyMs + + phases.mainToDatabaseWorkerCloneProxyMs + + phases.databaseWorkerToMainCloneProxyMs + ); + }); + + it('fails closed when any structured-clone boundary is missing, ambiguous, or out of order', () => { + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + const upsert = databaseWorker?.requests[0]; + assert.ok(databaseWorker); + assert.ok(upsert); + + for (const invalidMain of [ + { + ...main, + timeline: main.timeline.filter( + (event) => event.type !== 'db-request' + ), + }, + { + ...main, + timeline: [ + ...main.timeline, + main.timeline.find((event) => event.type === 'db-request'), + ].filter( + (event): event is (typeof main.timeline)[number] => + event !== undefined + ), + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + sourceEpochMs: 1080, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + requestReceivedEpochMs: 1078, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + ipcCallId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + sourceEpochMs: null, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + workStartedEpochMs: 1079, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + ]) { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: invalidMain, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + } + }); + + it('fails closed when a required phase, worker identity, or fixture count is ambiguous', () => { + const missingNormalize = createTestMainCapture(); + const missingTimeline = missingNormalize.timeline.filter( + (event) => event.phase !== 'normalize' + ); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...missingNormalize, + timeline: missingTimeline, + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...createTestMainCapture(), + workers: [ + ...createTestMainCapture().workers, + { + ...createTestMainCapture().workers[0], + ordinal: 2, + }, + ], + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + const wrongBytes = createTestMainCapture(); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES + 1, + main: wrongBytes, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + }); + + it('requires the route-reload GET and both worker-to-renderer return paths', () => { + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + const upsert = databaseWorker?.requests[0]; + const get = databaseWorker?.requests[1]; + assert.ok(databaseWorker); + assert.ok(upsert); + assert.ok(get); + + const invalidCaptures: MainCaptureMetrics[] = [ + { + ...main, + workers: [ + { + ...databaseWorker, + requests: databaseWorker.requests.slice(0, 1), + }, + ], + }, + { + ...main, + timeline: main.timeline.filter( + (event) => + !( + event.type === 'preload-performance-success' && + event.operation === 'DB_GET_APP_PLAYLIST' + ) + ), + }, + { + ...main, + timeline: main.timeline.filter( + (event) => + !( + event.type === 'preload-performance-success' && + event.operation === 'DB_UPSERT_APP_PLAYLIST' + ) + ), + }, + { + ...main, + timeline: main.timeline.map((event) => + event.type === 'preload-performance-success' && + event.operation === 'DB_GET_APP_PLAYLIST' + ? { + ...event, + sourceEpochMs: 1196, + } + : event + ), + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + upsert, + { + ...get, + responsePostedEpochMs: 1175, + }, + ], + }, + ], + }, + ]; + + for (const invalidMain of invalidCaptures) { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: invalidMain, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + } + }); + + it('rejects playlist workers and cross-playlist database writes', () => { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...createTestMainCapture(), + workers: [ + ...createTestMainCapture().workers, + { + kind: 'playlist-refresh.worker', + requests: [], + }, + ], + } as MainCaptureMetrics, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + assert.ok(databaseWorker); + const upsert = databaseWorker.requests[0]; + assert.ok(upsert); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + playlistId: 'other-playlist', + }, + ], + }, + ], + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts new file mode 100644 index 000000000..e00b7e637 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts @@ -0,0 +1,458 @@ +/* eslint-disable max-lines -- The deterministic fixture keeps the full production-shaped import sequence visible. */ +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import type { M3uImportBenchmarkManifest } from './m3u-import-summary'; + +export const TEST_M3U_CHANNEL_COUNT = 10_000; +export const TEST_M3U_FIXTURE_BYTES = 1_024_000; + +export function createTestMainCapture(): MainCaptureMetrics { + return { + timeline: [ + mainPhase('start', 'data.acquire', 1001, null), + mainPhase('end', 'data.acquire', 1021, 20, { + byteCount: TEST_M3U_FIXTURE_BYTES, + }), + mainPhase('start', 'parse.m3u', 1021, null), + mainPhase('end', 'parse.m3u', 1061, 40, { + itemCount: TEST_M3U_CHANNEL_COUNT, + }), + mainPhase('start', 'normalize', 1061, null), + mainPhase('end', 'normalize', 1071, 10, { + itemCount: TEST_M3U_CHANNEL_COUNT, + }), + { + epochMs: 1080, + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-1', + sourceEpochMs: 1079, + type: 'db-request', + }, + { + epochMs: 1137, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-1', + success: true, + type: 'db-response', + }, + { + epochMs: 1140, + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + sourceEpochMs: 1140, + success: true, + type: 'preload-performance-success', + }, + { + epochMs: 1142, + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-2', + sourceEpochMs: 1141, + type: 'db-request', + }, + { + epochMs: 1174, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-2', + success: true, + type: 'db-response', + }, + { + epochMs: 1194, + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + sourceEpochMs: 1194, + success: true, + type: 'preload-performance-success', + }, + ], + workers: [ + { + kind: 'database.worker', + requests: [ + { + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-uncorrelated', + performanceCaptureUnavailableReason: null, + phaseEvents: [ + workerPhase( + 'start', + 'serialize.playlist', + 1082, + null, + 'db-request-1' + ), + workerPhase( + 'end', + 'serialize.playlist', + 1102, + 20, + 'db-request-1', + { + itemCount: TEST_M3U_CHANNEL_COUNT, + } + ), + workerPhase( + 'start', + 'sqlite.write', + 1102, + null, + 'db-request-1' + ), + workerPhase( + 'end', + 'sqlite.write', + 1132, + 30, + 'db-request-1', + { + itemCount: 1, + } + ), + ], + playlistId: 'playlist-1', + requestId: 'db-request-1', + requestReceivedEpochMs: 1081, + responseEpochMs: 1137, + responsePostedEpochMs: 1135, + sourceEpochMs: 1079, + success: true, + workEndedEpochMs: 1133, + workStartedEpochMs: 1082, + }, + { + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-uncorrelated', + performanceCaptureUnavailableReason: null, + phaseEvents: [ + workerPhase( + 'start', + 'sqlite.read', + 1144, + null, + 'db-request-2' + ), + workerPhase( + 'end', + 'sqlite.read', + 1149, + 5, + 'db-request-2', + { + itemCount: 1, + } + ), + workerPhase( + 'start', + 'deserialize.playlist', + 1149, + null, + 'db-request-2' + ), + workerPhase( + 'end', + 'deserialize.playlist', + 1161, + 12, + 'db-request-2', + { + itemCount: TEST_M3U_CHANNEL_COUNT, + } + ), + ], + playlistId: 'playlist-1', + requestId: 'db-request-2', + requestReceivedEpochMs: 1143, + responseEpochMs: 1174, + responsePostedEpochMs: 1164, + sourceEpochMs: 1141, + success: true, + workEndedEpochMs: 1162, + workStartedEpochMs: 1144, + }, + ], + }, + ], + } as unknown as MainCaptureMetrics; +} + +export function createTestRendererCapture(): M3uImportRendererCaptureMetrics { + return { + phaseTimestamps: { + firstChannelVisibleEpochMs: 1210, + operationStartEpochMs: 1000, + routeReadyEpochMs: 1140, + terminalEpochMs: 1215, + uiPaintedEpochMs: 1215, + }, + probe: { + performancePhaseEvents: [ + rendererPhase( + 'start', + 'store.m3u-import-dispatch', + 'renderer-1', + 1076, + 76 + ), + rendererPhase( + 'end', + 'store.m3u-import-dispatch', + 'renderer-1', + 1078, + 78 + ), + rendererPhase( + 'start', + 'store.m3u-publish-channels', + 'renderer-2', + 1195, + 195, + TEST_M3U_CHANNEL_COUNT + ), + rendererPhase( + 'end', + 'store.m3u-publish-channels', + 'renderer-2', + 1205, + 205, + TEST_M3U_CHANNEL_COUNT + ), + ], + playlistId: 'playlist-1', + }, + } as unknown as M3uImportRendererCaptureMetrics; +} + +export function createCompleteTestMainCapture( + overrides: { mainRss?: number; rendererRss?: number } = {} +): MainCaptureMetrics { + const base = createTestMainCapture(); + const databaseWorker = base.workers[0]; + const upsert = databaseWorker?.requests[0]; + const get = databaseWorker?.requests[1]; + if (!databaseWorker || !upsert || !get) { + throw new Error('invalid-test-main-capture'); + } + return { + ...base, + cpuProfilePath: null, + cpuSystemMicros: 20, + cpuUserMicros: 30, + eventLoopDelay: { maxMs: 3, p95Ms: 1, p99Ms: 2 }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'electron-main-embedded-event-loop', + heapSnapshotPath: null, + memory: { + peakHeapUsedBytes: 2_000, + peakRssBytes: overrides.mainRss ?? 5_000, + postGcHeapUsedBytes: 1_000, + postGcRssBytes: 4_500, + }, + rendererWindow: { + responsiveEvents: 0, + rss: { + identity: { creationTime: 100, pid: 42 }, + missingSampleCount: 0, + peakRssBytes: overrides.rendererRss ?? 4_000, + unavailableReason: null, + validSampleCount: 5, + }, + unresponsiveEvents: 0, + windowIdentity: { + browserWindowId: 1, + webContentsId: 2, + }, + }, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + workers: [ + { + ...databaseWorker, + externalMemorySampleCount: 4, + heapUsedSampleCount: 4, + peakExternalBytes: 600, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: 1_500, + peakHeapUsedUnavailableReason: null, + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: 900, + requests: [ + { + ...upsert, + eventLoopDelay: { maxMs: 6, p95Ms: 4, p99Ms: 5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.8, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1134, + invalidReason: null, + threadCpuSystemMicros: 40, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 50, + }, + { + ...get, + eventLoopDelay: { maxMs: 9, p95Ms: 7, p99Ms: 8 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.9, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1163, + invalidReason: null, + threadCpuSystemMicros: 60, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 70, + }, + ], + }, + ], + }; +} + +export function createCompleteTestRendererCapture( + overrides: { rendererHeap?: number } = {} +): M3uImportRendererCaptureMetrics { + const base = createTestRendererCapture(); + return { + ...base, + cpuProfilePath: null, + frameGap: distribution([16, 40]), + heapSnapshotPath: null, + heartbeatDelay: distribution([0, 5]), + longTask: distribution([60]), + peakHeapUsedBytes: overrides.rendererHeap ?? 3_000, + postGcHeapUsedBytes: 1_100, + probe: { + ...base.probe, + firstChannelVisibleEpochMs: 1210, + frameGapsMs: [16, 40], + heartbeatDelaysMs: [0, 5], + longTasksMs: [60], + operationStartEpochMs: 1000, + routeReadyEpochMs: 1140, + terminalEpochMs: 1215, + uiPaintedEpochMs: 1215, + }, + tracePath: null, + }; +} + +export function createTestM3uImportManifest(): M3uImportBenchmarkManifest { + return { + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + fixtureSha256: 'fixture-sha', + gitCommit: 'commit', + gitDiffSha256: 'diff', + gitDirty: false, + measuredRuns: 1, + memoryAccounting: { + databaseWorkerRss: 'unavailable-per-worker-thread', + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + }, + runtime: { + arch: 'arm64', + cdpPort: 9222, + electronVersion: '1', + harnessNodeVersion: '1', + platform: 'darwin', + }, + scenario: 'm3u-import-10k', + syntheticSeed: 240_724, + variant: 'baseline', + warmupRuns: 1, + }; +} + +function mainPhase( + boundary: 'end' | 'start', + phase: string, + epochMs: number, + durationMs: number | null, + metadata: { byteCount?: number; itemCount?: number } = {} +) { + return { + boundary, + durationMs, + epochMs, + ...metadata, + phase, + requestId: 'main-request-1', + type: 'm3u-import-phase', + }; +} + +function workerPhase( + boundary: 'end' | 'start', + phase: + | 'deserialize.playlist' + | 'serialize.playlist' + | 'sqlite.read' + | 'sqlite.write', + epochMs: number, + durationMs: number | null, + requestId: string, + metadata?: { byteCount?: number; itemCount?: number } +) { + return { + boundary, + durationMs, + epochMs, + ...(metadata ? { metadata } : {}), + phase, + requestId, + }; +} + +function rendererPhase( + boundary: 'end' | 'start', + phase: 'store.m3u-import-dispatch' | 'store.m3u-publish-channels', + phaseId: string, + epochMs: number, + monotonicMs: number, + items?: number +) { + return { + boundary, + epochMs, + ...(items === undefined ? {} : { metadata: { items } }), + monotonicMs, + ...(boundary === 'end' ? { outcome: 'success' as const } : {}), + phase, + phaseId, + schemaVersion: 1 as const, + }; +} + +function distribution(values: readonly number[]) { + const min = Math.min(...values); + const max = Math.max(...values); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + return { + count: values.length, + max, + mean, + median: mean, + min, + p95: values.length === 1 ? max : min + (max - min) * 0.95, + p99: values.length === 1 ? max : min + (max - min) * 0.99, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.ts new file mode 100644 index 000000000..9fb2fe9a3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.ts @@ -0,0 +1,367 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE, +} from '@iptvnator/shared/interfaces'; + +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { + MainCaptureMetrics, + MainTimelineRecord, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { deriveM3uImportIpcAttribution } from './m3u-import-ipc-attribution'; +import { + pairPerformancePhaseEvents, + requirePerformancePhase, + type PairedPerformancePhase, +} from './performance-phase-events'; +import { + pairRendererPerformancePhaseEvents, + requireRendererPerformancePhase, +} from './renderer-performance-phase-events'; + +export const M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON = + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write'; + +export interface M3uImportPhaseMetrics { + readonly angularRenderingMs: number; + readonly dataAcquireMs: number; + readonly databaseWorkerToMainCloneProxyMs: number; + readonly indexAndCommitMs: null; + readonly indexAndCommitUnavailableReason: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON; + readonly ipcStructuredCloneProxyMs: number; + readonly mainToDatabaseWorkerCloneProxyMs: number; + readonly mainToRendererCloneProxyMs: number; + readonly m3uParsingMs: number; + readonly normalizationMs: number; + readonly playlistDeserializationMs: number; + readonly playlistSerializationMs: number; + readonly rendererToMainCloneProxyMs: number; + readonly sqliteReadMs: number; + readonly sqliteWriteMs: number; + readonly storeImportDispatchMs: number; + readonly storePublishChannelsMs: number; + readonly storeUpdateMs: number; + readonly totalMs: number; +} + +export interface M3uImportPhaseDerivationInput { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly main: MainCaptureMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; +} + +export function deriveM3uImportPhaseMetrics( + input: M3uImportPhaseDerivationInput +): M3uImportPhaseMetrics { + try { + return deriveM3uImportPhaseMetricsStrict(input); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`m3u-import-phase-attribution-invalid:${detail}`); + } +} + +function deriveM3uImportPhaseMetricsStrict( + input: M3uImportPhaseDerivationInput +): M3uImportPhaseMetrics { + requirePositiveSafeCount(input.channelCount, 'channel-count'); + requirePositiveSafeCount(input.fixtureBytes, 'fixture-bytes'); + + const mainPhases = pairPerformancePhaseEvents( + input.main.timeline + .filter((event) => event.type === 'm3u-import-phase') + .map(toPerformancePhaseEvent) + ); + if (mainPhases.length !== 3) { + throw new Error('main-phase-cardinality'); + } + const mainRequestIds = new Set(mainPhases.map((phase) => phase.requestId)); + if (mainRequestIds.size !== 1) { + throw new Error('main-request-correlation'); + } + const mainRequestId = mainPhases[0]?.requestId; + if (!mainRequestId) { + throw new Error('main-request-missing'); + } + const dataAcquire = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE + ); + const parseM3u = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U + ); + const normalize = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE + ); + requireMetadataCount( + dataAcquire, + 'byteCount', + input.fixtureBytes, + 'data-acquire-bytes' + ); + requireMetadataCount( + parseM3u, + 'itemCount', + input.channelCount, + 'parse-items' + ); + requireMetadataCount( + normalize, + 'itemCount', + input.channelCount, + 'normalize-items' + ); + requireOrdered(dataAcquire.endedEpochMs, parseM3u.startedEpochMs); + requireOrdered(parseM3u.endedEpochMs, normalize.startedEpochMs); + + const rendererEvents = ( + input.renderer.probe as typeof input.renderer.probe & { + readonly performancePhaseEvents?: readonly unknown[]; + } + ).performancePhaseEvents; + if (!rendererEvents) { + throw new Error('renderer-phase-events-missing'); + } + const rendererPhases = pairRendererPerformancePhaseEvents(rendererEvents); + if (rendererPhases.length !== 2) { + throw new Error('renderer-phase-cardinality'); + } + const importDispatch = requireRendererPerformancePhase( + rendererPhases, + 'store.m3u-import-dispatch' + ); + const publishChannels = requireRendererPerformancePhase( + rendererPhases, + 'store.m3u-publish-channels' + ); + requireRendererItems( + publishChannels.metadata?.items, + input.channelCount, + 'publish-channels-items' + ); + + const playlistId = input.renderer.probe.playlistId; + if (typeof playlistId !== 'string' || playlistId.length === 0) { + throw new Error('renderer-playlist-id-missing'); + } + const databaseWorkers = input.main.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + if ( + databaseWorkers.length !== 1 || + input.main.workers.some( + (worker) => worker.kind === 'playlist-refresh.worker' + ) + ) { + throw new Error('worker-cardinality'); + } + const databaseWorker = databaseWorkers[0]; + if (!databaseWorker) { + throw new Error('database-worker-missing'); + } + const upserts = databaseWorker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && + request.playlistId === playlistId && + request.success + ); + if (upserts.length !== 1 || !upserts[0]) { + throw new Error('database-upsert-correlation'); + } + const upsert = upserts[0]; + const gets = databaseWorker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && + request.playlistId === playlistId && + request.success + ); + if ( + gets.length !== 1 || + !gets[0] || + databaseWorker.requests.length !== 2 + ) { + throw new Error('database-get-correlation'); + } + const get = gets[0]; + const databasePhases = pairPerformancePhaseEvents(upsert.phaseEvents); + if ( + databasePhases.length !== 2 || + !upsert.requestId || + databasePhases.some((phase) => phase.requestId !== upsert.requestId) + ) { + throw new Error('database-phase-cardinality'); + } + const serializePlaylist = requireDatabasePhase( + databasePhases, + upsert, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST + ); + const sqliteWrite = requireDatabasePhase( + databasePhases, + upsert, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE + ); + requireMetadataCount( + serializePlaylist, + 'itemCount', + input.channelCount, + 'serialization-items' + ); + requireMetadataCount(sqliteWrite, 'itemCount', 1, 'sqlite-row-count'); + const getPhases = pairPerformancePhaseEvents(get.phaseEvents); + if ( + getPhases.length !== 2 || + !get.requestId || + getPhases.some((phase) => phase.requestId !== get.requestId) + ) { + throw new Error('database-get-phase-cardinality'); + } + const sqliteRead = requireDatabasePhase( + getPhases, + get, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ + ); + const deserializePlaylist = requireDatabasePhase( + getPhases, + get, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST + ); + requireMetadataCount(sqliteRead, 'itemCount', 1, 'sqlite-read-row-count'); + requireMetadataCount( + deserializePlaylist, + 'itemCount', + input.channelCount, + 'deserialization-items' + ); + + const operationStart = input.renderer.phaseTimestamps.operationStartEpochMs; + const uiPainted = input.renderer.phaseTimestamps.uiPaintedEpochMs; + const terminal = input.renderer.phaseTimestamps.terminalEpochMs; + requireOrdered(operationStart, dataAcquire.startedEpochMs); + requireOrdered(serializePlaylist.endedEpochMs, sqliteWrite.startedEpochMs); + requireOrdered(sqliteWrite.endedEpochMs, sqliteRead.startedEpochMs); + requireOrdered(sqliteRead.endedEpochMs, deserializePlaylist.startedEpochMs); + requireOrdered( + deserializePlaylist.endedEpochMs, + publishChannels.startedEpochMs + ); + requireOrdered(publishChannels.endedEpochMs, uiPainted); + requireOrdered(uiPainted, terminal); + + const ipc = deriveM3uImportIpcAttribution({ + get, + getReadStartedEpochMs: sqliteRead.startedEpochMs, + importDispatchEndedEpochMs: importDispatch.endedEpochMs, + importDispatchStartedEpochMs: importDispatch.startedEpochMs, + normalizeEndedEpochMs: normalize.endedEpochMs, + playlistId, + publishChannelsStartedEpochMs: publishChannels.startedEpochMs, + serializeStartedEpochMs: serializePlaylist.startedEpochMs, + timeline: input.main.timeline, + upsert, + }); + const angularRenderingMs = difference( + uiPainted, + publishChannels.endedEpochMs + ); + const totalMs = difference(terminal, operationStart); + + return Object.freeze({ + angularRenderingMs, + dataAcquireMs: dataAcquire.durationMs, + indexAndCommitMs: null, + indexAndCommitUnavailableReason: + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + ...ipc, + m3uParsingMs: parseM3u.durationMs, + normalizationMs: normalize.durationMs, + playlistDeserializationMs: deserializePlaylist.durationMs, + playlistSerializationMs: serializePlaylist.durationMs, + sqliteReadMs: sqliteRead.durationMs, + sqliteWriteMs: sqliteWrite.durationMs, + storeImportDispatchMs: importDispatch.durationMs, + storePublishChannelsMs: publishChannels.durationMs, + storeUpdateMs: importDispatch.durationMs + publishChannels.durationMs, + totalMs, + }); +} + +function toPerformancePhaseEvent(event: MainTimelineRecord): unknown { + const metadata = { + ...(event.byteCount === undefined + ? {} + : { byteCount: event.byteCount }), + ...(event.itemCount === undefined + ? {} + : { itemCount: event.itemCount }), + }; + return { + boundary: event.boundary, + durationMs: event.durationMs, + epochMs: event.epochMs, + ...(Object.keys(metadata).length === 0 ? {} : { metadata }), + phase: event.phase, + requestId: event.requestId, + }; +} + +function requireDatabasePhase( + phases: readonly PairedPerformancePhase[], + request: WorkerRequestPerformanceMetrics, + phase: string +): PairedPerformancePhase { + if (!request.requestId) { + throw new Error('database-request-id-missing'); + } + return requirePerformancePhase(phases, request.requestId, phase); +} + +function requireMetadataCount( + phase: PairedPerformancePhase, + key: 'byteCount' | 'itemCount', + expected: number, + label: string +): void { + if (phase.metadata?.[key] !== expected) { + throw new Error(label); + } +} + +function requireRendererItems( + value: number | undefined, + expected: number, + label: string +): void { + if (value !== expected) { + throw new Error(label); + } +} + +function requirePositiveSafeCount(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(label); + } +} + +function requireOrdered(startEpochMs: number, endEpochMs: number): void { + if ( + !Number.isFinite(startEpochMs) || + !Number.isFinite(endEpochMs) || + endEpochMs < startEpochMs + ) { + throw new Error('phase-order'); + } +} + +function difference(endEpochMs: number, startEpochMs: number): number { + requireOrdered(startEpochMs, endEpochMs); + return endEpochMs - startEpochMs; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts new file mode 100644 index 000000000..6f843f8b2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts @@ -0,0 +1,131 @@ +import type { DatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity'; +import type { DatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity'; +import type { M3uImportScenarioId } from './m3u-import-benchmark-contract'; +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import { + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + type M3uImportPhaseMetrics, +} from './m3u-import-report'; +import type { + DatabaseWorkerPostGcValidity, + MainCaptureMetrics, + NumericDistribution, + PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import type { RendererRssValidity } from './renderer-rss-validity'; + +export interface M3uImportIterationResult { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly phases: M3uImportPhaseMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +} + +export interface M3uImportBenchmarkManifest { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly fixtureSha256: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly memoryAccounting: { + readonly databaseWorkerRss: 'unavailable-per-worker-thread'; + readonly mainRss: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable'; + readonly rendererRss: 'separate-renderer-process'; + readonly workerJsHeap: 'separate-v8-isolate'; + }; + readonly runtime: { + readonly arch: string; + readonly cdpPort: number; + readonly electronVersion: string; + readonly harnessNodeVersion: string; + readonly platform: string; + }; + readonly scenario: M3uImportScenarioId; + readonly syntheticSeed: number; + readonly variant: string; + readonly warmupRuns: number; +} + +export interface M3uImportMeasuredSummary { + readonly angularRenderingMs: NumericDistribution; + readonly dataAcquireMs: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerGetEventLoopUtilization: NumericDistribution; + readonly databaseWorkerGetThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerGetThreadCpuUserMicros: NumericDistribution; + readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerEventLoopUtilization: NumericDistribution; + readonly databaseWorkerExternalPeakBytes: NumericDistribution; + readonly databaseWorkerHeapPeakBytes: NumericDistribution; + readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly databaseWorkerThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerThreadCpuUserMicros: NumericDistribution; + readonly databaseWorkerToMainCloneProxyMs: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerUpsertEventLoopUtilization: NumericDistribution; + readonly databaseWorkerUpsertThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerUpsertThreadCpuUserMicros: NumericDistribution; + readonly ipcStructuredCloneProxyMs: NumericDistribution; + readonly mainToDatabaseWorkerCloneProxyMs: NumericDistribution; + readonly mainToRendererCloneProxyMs: NumericDistribution; + readonly m3uParsingMs: NumericDistribution; + readonly mainEventLoopDelayMaxMs: NumericDistribution; + readonly mainEventLoopDelayP95Ms: NumericDistribution; + readonly mainEventLoopDelayP99Ms: NumericDistribution; + readonly mainEventLoopUtilization: NumericDistribution; + readonly mainHeapPeakBytes: NumericDistribution; + readonly mainHeapPostGcBytes: NumericDistribution; + readonly mainRssPeakBytes: NumericDistribution; + readonly mainRssPostGcBytes: NumericDistribution; + readonly normalizationMs: NumericDistribution; + readonly playlistDeserializationMs: NumericDistribution; + readonly playlistSerializationMs: NumericDistribution; + readonly rendererFrameGapMs: NumericDistribution; + readonly rendererHeartbeatDelayMs: NumericDistribution; + readonly rendererHeapPeakBytes: NumericDistribution; + readonly rendererHeapPostGcBytes: NumericDistribution; + readonly rendererLongTaskCount: number; + readonly rendererLongTaskMs: NumericDistribution; + readonly rendererRssPeakBytes: NumericDistribution; + readonly rendererToMainCloneProxyMs: NumericDistribution; + readonly responsiveEvents: number; + readonly sqliteReadMs: NumericDistribution; + readonly sqliteWriteMs: NumericDistribution; + readonly storeImportDispatchMs: NumericDistribution; + readonly storePublishChannelsMs: NumericDistribution; + readonly storeUpdateMs: NumericDistribution; + readonly totalMs: NumericDistribution; + readonly unresponsiveEvents: number; +} + +export interface M3uImportBenchmarkSummary { + readonly iterations: readonly M3uImportIterationResult[]; + readonly manifest: M3uImportBenchmarkManifest; + readonly measured: M3uImportMeasuredSummary; + readonly notApplicable: { + readonly cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation'; + readonly databaseWorkerRss: 'unavailable-per-worker-thread'; + readonly indexAndCommitMs: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON; + readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable'; + readonly playlistRefreshWorker: 'not-used-by-initial-import'; + }; + readonly validity: { + readonly databaseWorkerPeakMemory: DatabaseWorkerPeakMemoryValidity; + readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity; + readonly databaseWorkerRequestMetrics: DatabaseWorkerRequestMetricsValidity; + readonly rendererRss: RendererRssValidity; + }; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts new file mode 100644 index 000000000..558b33365 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { it } from 'node:test'; + +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + createM3uImportBenchmarkSummary, + createM3uImportIterationResult, + type M3uImportBenchmarkSummary, +} from './m3u-import-summary'; +import { assertM3uImportComparisonValidity } from './m3u-import.benchmark'; +import { + createCompleteTestMainCapture, + createCompleteTestRendererCapture, + createTestM3uImportManifest, + TEST_M3U_CHANNEL_COUNT, + TEST_M3U_FIXTURE_BYTES, +} from './m3u-import-report.test-helpers'; + +it('keeps summary aggregation below the hard line limit without a suppression', () => { + const source = readFileSync( + resolve(process.cwd(), 'src/performance/m3u-import-summary.ts'), + 'utf8' + ); + + assert.doesNotMatch(source, /eslint-disable max-lines/); + assert.ok(source.split(/\r?\n/).length <= 400); +}); + +it('summarizes measured runs only while preserving per-process memory boundaries', () => { + const warmup = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'warmup-01', + scenarioId: 'm3u-import-10k', + }); + const measured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const diagnostic = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + main: createCompleteTestMainCapture({ + mainRss: 9_999, + rendererRss: 8_888, + }), + renderer: createCompleteTestRendererCapture({ + rendererHeap: 7_777, + }), + runId: 'diagnostic', + scenarioId: 'm3u-import-10k', + }); + const summary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, measured, diagnostic] + ); + + assert.equal(summary.measured.totalMs.count, 1); + assert.equal(summary.measured.totalMs.mean, 215); + assert.equal(summary.measured.ipcStructuredCloneProxyMs.mean, 44); + assert.equal(summary.measured.mainToDatabaseWorkerCloneProxyMs.mean, 2); + assert.equal(summary.measured.databaseWorkerToMainCloneProxyMs.mean, 12); + assert.equal(summary.measured.mainToRendererCloneProxyMs.mean, 28); + assert.equal(summary.measured.rendererToMainCloneProxyMs.mean, 2); + assert.equal(summary.measured.sqliteReadMs.mean, 5); + assert.equal(summary.measured.playlistDeserializationMs.mean, 12); + assert.equal(summary.measured.databaseWorkerThreadCpuSystemMicros.mean, 100); + assert.equal(summary.measured.databaseWorkerThreadCpuUserMicros.mean, 120); + assert.equal( + summary.measured.databaseWorkerUpsertEventLoopDelayP95Ms.mean, + 4 + ); + assert.equal( + summary.measured.databaseWorkerGetEventLoopDelayP95Ms.mean, + 7 + ); + assert.equal(summary.measured.mainRssPeakBytes.mean, 5_000); + assert.equal(summary.measured.mainHeapPeakBytes.mean, 2_000); + assert.equal(summary.measured.rendererRssPeakBytes.mean, 4_000); + assert.equal(summary.measured.rendererHeapPeakBytes.mean, 3_000); + assert.equal(summary.measured.databaseWorkerHeapPeakBytes.mean, 1_500); + assert.equal(summary.measured.databaseWorkerPostGcHeapBytes.mean, 900); + assert.equal(summary.measured.rendererLongTaskCount, 1); + assert.equal(summary.measured.unresponsiveEvents, 0); + assert.deepEqual(summary.notApplicable, { + cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation', + databaseWorkerRss: 'unavailable-per-worker-thread', + indexAndCommitMs: + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + playlistRefreshWorker: 'not-used-by-initial-import', + }); + assert.equal(summary.validity.rendererRss.validForComparison, true); + assert.equal( + summary.validity.databaseWorkerPeakMemory.validForComparison, + true + ); + assert.equal( + summary.validity.databaseWorkerPostGc.validForComparison, + true + ); + assert.doesNotThrow(() => assertM3uImportComparisonValidity(summary)); + + const databaseWorker = measured.main.workers[0]; + assert.ok(databaseWorker); + const invalidMeasured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + ...measured.main, + workers: [ + { + ...databaseWorker, + externalMemorySampleCount: 0, + heapUsedSampleCount: 0, + peakExternalBytes: null, + peakExternalUnavailableReason: + 'worker-external-memory-samples-missing', + peakHeapUsedBytes: null, + peakHeapUsedUnavailableReason: + 'worker-heap-used-samples-missing', + }, + ], + }, + renderer: measured.renderer, + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const invalidPeakSummary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, invalidMeasured, diagnostic] + ); + assert.equal( + invalidPeakSummary.validity.databaseWorkerPeakMemory.validForComparison, + false + ); + assert.equal( + invalidPeakSummary.measured.databaseWorkerHeapPeakBytes.count, + 0 + ); + assert.throws( + () => + assertM3uImportComparisonValidity( + invalidPeakSummary as M3uImportBenchmarkSummary + ), + /Database worker peak-memory capture is invalid/ + ); + assert.throws( + () => + createM3uImportBenchmarkSummary(createTestM3uImportManifest(), [ + measured, + diagnostic, + ]), + /m3u-import-summary-schedule-invalid/ + ); +}); + +it('fails comparison when any measured worker request omits mandatory event-loop or CPU metrics', () => { + const completeMain = createCompleteTestMainCapture(); + const databaseWorker = completeMain.workers[0]; + const get = databaseWorker?.requests[1]; + assert.ok(databaseWorker); + assert.ok(get); + const invalidMain = { + ...completeMain, + workers: [ + { + ...databaseWorker, + requests: [ + databaseWorker.requests[0], + { + ...get, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + histogramFlushedEpochMs: null, + }, + ], + }, + ], + }; + const warmup = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'warmup-01', + scenarioId: 'm3u-import-10k', + }); + const measured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: invalidMain, + renderer: createCompleteTestRendererCapture(), + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const diagnostic = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'diagnostic', + scenarioId: 'm3u-import-10k', + }); + const summary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, measured, diagnostic] + ); + + assert.equal( + summary.validity.databaseWorkerRequestMetrics.validForComparison, + false + ); + assert.equal( + summary.validity.databaseWorkerRequestMetrics.expectedRequestCount, + 2 + ); + assert.equal( + summary.validity.databaseWorkerRequestMetrics.validRequestCount, + 1 + ); + assert.equal( + summary.measured.databaseWorkerGetEventLoopDelayP95Ms.count, + 0 + ); + assert.throws( + () => assertM3uImportComparisonValidity(summary), + /Database worker request metrics are invalid/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts new file mode 100644 index 000000000..5a81e0a87 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts @@ -0,0 +1,382 @@ +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { M3uImportScenarioId } from './m3u-import-benchmark-contract'; +import type { + MainCaptureMetrics, + NumericDistribution, + PerformanceIterationKind, + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; +import { + deriveM3uImportPhaseMetrics, + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, +} from './m3u-import-report'; +import { summarizeNumbers } from './performance-statistics'; +import { assessDatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity'; +import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity'; +import { assessDatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity'; +import { assessRendererRssValidity } from './renderer-rss-validity'; +import type { + M3uImportBenchmarkManifest, + M3uImportBenchmarkSummary, + M3uImportIterationResult, +} from './m3u-import-summary-contract'; + +export type { + M3uImportBenchmarkManifest, + M3uImportBenchmarkSummary, + M3uImportIterationResult, + M3uImportMeasuredSummary, +} from './m3u-import-summary-contract'; + +export function createM3uImportIterationResult(input: { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +}): M3uImportIterationResult { + return Object.freeze({ + channelCount: input.channelCount, + fixtureBytes: input.fixtureBytes, + kind: input.kind, + main: input.main, + phases: deriveM3uImportPhaseMetrics(input), + renderer: input.renderer, + runId: input.runId, + scenarioId: input.scenarioId, + }); +} + +export function createM3uImportBenchmarkSummary( + manifest: M3uImportBenchmarkManifest, + iterations: readonly M3uImportIterationResult[] +): M3uImportBenchmarkSummary { + validateIterationSchedule(manifest, iterations); + const measured = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + validateMeasuredIterations(manifest, measured); + const values = ( + select: (iteration: M3uImportIterationResult) => number | null + ): NumericDistribution => summarizeNumbers(measured.map(select)); + const rendererSamples = ( + select: ( + probe: M3uImportRendererCaptureMetrics['probe'] + ) => readonly number[] + ): NumericDistribution => + summarizeNumbers( + measured.flatMap((iteration) => [ + ...select(iteration.renderer.probe), + ]) + ); + const databaseWorkers = measured.map(requireDatabaseWorker); + const databaseRequestPairs = databaseWorkers.map( + requireInitialImportRequests + ); + const databaseUpserts = databaseRequestPairs.map((pair) => pair.upsert); + const databaseGets = databaseRequestPairs.map((pair) => pair.get); + const databaseRequests = databaseRequestPairs.flatMap((pair) => [ + pair.upsert, + pair.get, + ]); + + return Object.freeze({ + iterations: Object.freeze([...iterations]), + manifest, + measured: Object.freeze({ + angularRenderingMs: values( + (iteration) => iteration.phases.angularRenderingMs + ), + dataAcquireMs: values( + (iteration) => iteration.phases.dataAcquireMs + ), + databaseWorkerGetEventLoopDelayMaxMs: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerGetEventLoopDelayP95Ms: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerGetEventLoopDelayP99Ms: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerGetEventLoopUtilization: summarizeNumbers( + databaseGets.map((request) => request.eventLoopUtilization) + ), + databaseWorkerGetThreadCpuSystemMicros: summarizeNumbers( + databaseGets.map((request) => request.threadCpuSystemMicros) + ), + databaseWorkerGetThreadCpuUserMicros: summarizeNumbers( + databaseGets.map((request) => request.threadCpuUserMicros) + ), + databaseWorkerEventLoopDelayMaxMs: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerEventLoopDelayP95Ms: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerEventLoopDelayP99Ms: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerEventLoopUtilization: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopUtilization) + ), + databaseWorkerExternalPeakBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.peakExternalBytes) + ), + databaseWorkerHeapPeakBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.peakHeapUsedBytes) + ), + databaseWorkerPostGcHeapBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.postGcHeapUsedBytes) + ), + databaseWorkerThreadCpuSystemMicros: summarizeNumbers( + databaseRequestPairs.map((pair) => + sumRequestMetric( + pair.upsert.threadCpuSystemMicros, + pair.get.threadCpuSystemMicros + ) + ) + ), + databaseWorkerThreadCpuUserMicros: summarizeNumbers( + databaseRequestPairs.map((pair) => + sumRequestMetric( + pair.upsert.threadCpuUserMicros, + pair.get.threadCpuUserMicros + ) + ) + ), + databaseWorkerToMainCloneProxyMs: values( + (iteration) => + iteration.phases.databaseWorkerToMainCloneProxyMs + ), + databaseWorkerUpsertEventLoopDelayMaxMs: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerUpsertEventLoopDelayP95Ms: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerUpsertEventLoopDelayP99Ms: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerUpsertEventLoopUtilization: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopUtilization) + ), + databaseWorkerUpsertThreadCpuSystemMicros: summarizeNumbers( + databaseUpserts.map( + (request) => request.threadCpuSystemMicros + ) + ), + databaseWorkerUpsertThreadCpuUserMicros: summarizeNumbers( + databaseUpserts.map((request) => request.threadCpuUserMicros) + ), + ipcStructuredCloneProxyMs: values( + (iteration) => iteration.phases.ipcStructuredCloneProxyMs + ), + mainToDatabaseWorkerCloneProxyMs: values( + (iteration) => iteration.phases.mainToDatabaseWorkerCloneProxyMs + ), + mainToRendererCloneProxyMs: values( + (iteration) => iteration.phases.mainToRendererCloneProxyMs + ), + m3uParsingMs: values((iteration) => iteration.phases.m3uParsingMs), + mainEventLoopDelayMaxMs: values( + (iteration) => iteration.main.eventLoopDelay.maxMs + ), + mainEventLoopDelayP95Ms: values( + (iteration) => iteration.main.eventLoopDelay.p95Ms + ), + mainEventLoopDelayP99Ms: values( + (iteration) => iteration.main.eventLoopDelay.p99Ms + ), + mainEventLoopUtilization: values( + (iteration) => iteration.main.eventLoopUtilization + ), + mainHeapPeakBytes: values( + (iteration) => iteration.main.memory.peakHeapUsedBytes + ), + mainHeapPostGcBytes: values( + (iteration) => iteration.main.memory.postGcHeapUsedBytes + ), + mainRssPeakBytes: values( + (iteration) => iteration.main.memory.peakRssBytes + ), + mainRssPostGcBytes: values( + (iteration) => iteration.main.memory.postGcRssBytes + ), + normalizationMs: values( + (iteration) => iteration.phases.normalizationMs + ), + playlistDeserializationMs: values( + (iteration) => iteration.phases.playlistDeserializationMs + ), + playlistSerializationMs: values( + (iteration) => iteration.phases.playlistSerializationMs + ), + rendererFrameGapMs: rendererSamples((probe) => probe.frameGapsMs), + rendererHeartbeatDelayMs: rendererSamples( + (probe) => probe.heartbeatDelaysMs + ), + rendererHeapPeakBytes: values( + (iteration) => iteration.renderer.peakHeapUsedBytes + ), + rendererHeapPostGcBytes: values( + (iteration) => iteration.renderer.postGcHeapUsedBytes + ), + rendererLongTaskCount: measured.reduce( + (total, iteration) => + total + iteration.renderer.probe.longTasksMs.length, + 0 + ), + rendererLongTaskMs: rendererSamples((probe) => probe.longTasksMs), + rendererRssPeakBytes: values( + (iteration) => iteration.main.rendererWindow.rss.peakRssBytes + ), + rendererToMainCloneProxyMs: values( + (iteration) => iteration.phases.rendererToMainCloneProxyMs + ), + responsiveEvents: measured.reduce( + (total, iteration) => + total + iteration.main.rendererWindow.responsiveEvents, + 0 + ), + sqliteReadMs: values((iteration) => iteration.phases.sqliteReadMs), + sqliteWriteMs: values( + (iteration) => iteration.phases.sqliteWriteMs + ), + storeImportDispatchMs: values( + (iteration) => iteration.phases.storeImportDispatchMs + ), + storePublishChannelsMs: values( + (iteration) => iteration.phases.storePublishChannelsMs + ), + storeUpdateMs: values( + (iteration) => iteration.phases.storeUpdateMs + ), + totalMs: values((iteration) => iteration.phases.totalMs), + unresponsiveEvents: measured.reduce( + (total, iteration) => + total + iteration.main.rendererWindow.unresponsiveEvents, + 0 + ), + }), + notApplicable: Object.freeze({ + cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation', + databaseWorkerRss: 'unavailable-per-worker-thread', + indexAndCommitMs: M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + playlistRefreshWorker: 'not-used-by-initial-import', + }), + validity: Object.freeze({ + databaseWorkerPeakMemory: + assessDatabaseWorkerPeakMemoryValidity(iterations), + databaseWorkerPostGc: + assessDatabaseWorkerPostGcValidity(iterations), + databaseWorkerRequestMetrics: + assessDatabaseWorkerRequestMetricsValidity(iterations), + rendererRss: assessRendererRssValidity(iterations), + }), + }); +} + +function validateIterationSchedule( + manifest: M3uImportBenchmarkManifest, + iterations: readonly M3uImportIterationResult[] +): void { + const warmupCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.WARMUP + ).length; + const measuredCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ).length; + const diagnosticCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC + ).length; + if ( + warmupCount !== manifest.warmupRuns || + measuredCount !== manifest.measuredRuns || + diagnosticCount !== 1 || + iterations.length !== warmupCount + measuredCount + diagnosticCount || + new Set(iterations.map((iteration) => iteration.runId)).size !== + iterations.length || + iterations.some( + (iteration) => + iteration.channelCount !== manifest.channelCount || + iteration.fixtureBytes !== manifest.fixtureBytes || + iteration.scenarioId !== manifest.scenario + ) + ) { + throw new Error('m3u-import-summary-schedule-invalid'); + } +} + +function validateMeasuredIterations( + manifest: M3uImportBenchmarkManifest, + measured: readonly M3uImportIterationResult[] +): void { + if ( + measured.length !== manifest.measuredRuns || + new Set(measured.map((iteration) => iteration.runId)).size !== + measured.length || + measured.some( + (iteration) => + iteration.channelCount !== manifest.channelCount || + iteration.fixtureBytes !== manifest.fixtureBytes || + iteration.scenarioId !== manifest.scenario + ) + ) { + throw new Error('m3u-import-summary-measured-runs-invalid'); + } +} + +function requireDatabaseWorker( + iteration: M3uImportIterationResult +): WorkerCaptureMetrics { + const databaseWorkers = iteration.main.workers.filter( + (worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE + ); + if (databaseWorkers.length !== 1 || !databaseWorkers[0]) { + throw new Error('m3u-import-summary-database-worker-invalid'); + } + return databaseWorkers[0]; +} + +function requireInitialImportRequests( + worker: WorkerCaptureMetrics +): { + readonly get: WorkerRequestPerformanceMetrics; + readonly upsert: WorkerRequestPerformanceMetrics; +} { + const upserts = worker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success + ); + const gets = worker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && request.success + ); + if ( + worker.requests.length !== 2 || + upserts.length !== 1 || + !upserts[0] || + gets.length !== 1 || + !gets[0] + ) { + throw new Error('m3u-import-summary-database-request-invalid'); + } + return { get: gets[0], upsert: upserts[0] }; +} + +function sumRequestMetric( + first: number | null, + second: number | null +): number | null { + return first === null || second === null ? null : first + second; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts new file mode 100644 index 000000000..dab027bc9 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts @@ -0,0 +1,341 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + closeElectronApp, + expect, + launchElectronApp, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import { validateInitialImportDiagnosticArtifacts } from './initial-import-diagnostic-artifacts'; +import { + assertRendererCdpTarget, + assertTcpPortAvailable, + type M3uImportBenchmarkConfiguration, + resolveM3uImportBenchmarkConfiguration, + resolveRendererWindowIdentity, + writeM3uImportJson as writeJson, +} from './m3u-import-benchmark-support'; +import { + assertSyntheticM3uImportUrl, + createM3uImportIterationDefinitions, + listM3uImportScenarios, + type M3uImportIterationDefinition, + type M3uImportScenario, +} from './m3u-import-benchmark-contract'; +import { + createM3uImportBenchmarkSummary, + createM3uImportIterationResult, + type M3uImportBenchmarkManifest, + type M3uImportBenchmarkSummary, + type M3uImportIterationResult, +} from './m3u-import-summary'; +import { + startM3uImportRendererCapture, + type RunningM3uImportRendererCapture, +} from './m3u-import-renderer-capture'; +import { + installMainCapture, + readMainCaptureStatus, + startMainCapture, + stopMainCapture, +} from './m3u-refresh-main-capture'; +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; +import { + startSyntheticM3uServer, + type SyntheticM3uServer, +} from './synthetic-m3u-server'; +import { + createSyntheticM3uFixture, + SYNTHETIC_M3U_SEED, + type SyntheticM3uFixture, +} from './synthetic-m3u'; + +export async function runM3uImportBenchmark(): Promise { + const config = await resolveM3uImportBenchmarkConfiguration(); + const definitions = createM3uImportIterationDefinitions(config.smoke); + + for (const scenario of listM3uImportScenarios()) { + await runScenario( + config, + scenario, + definitions.filter( + (definition) => definition.scenarioId === scenario.id + ) + ); + } +} + +async function runScenario( + config: M3uImportBenchmarkConfiguration, + scenario: M3uImportScenario, + definitions: readonly M3uImportIterationDefinition[] +): Promise { + const fixture = createSyntheticM3uFixture(scenario.channelCount); + const server = await startSyntheticM3uServer(fixture); + const scenarioDirectory = join(config.variantDirectory, scenario.id); + const iterations: M3uImportIterationResult[] = []; + + try { + assertSyntheticM3uImportUrl(server.resourceUrl); + await server.prime(); + await assertPerformanceArtifactCapacity(scenarioDirectory); + await mkdir(scenarioDirectory, { recursive: false }); + for (const definition of definitions) { + console.log( + `[performance] ${config.variant}/${scenario.id}/${definition.runId} starting` + ); + const result = await runIteration( + config, + definition, + fixture, + server, + scenarioDirectory + ); + iterations.push(result); + console.log( + `[performance] ${definition.runId} total=${result.phases.totalMs.toFixed(3)}ms` + ); + } + } finally { + await server.close(); + } + + if (server.requestCount() !== definitions.length + 1) { + throw new Error('Synthetic M3U server request total is invalid'); + } + const manifest = createManifest(config, scenario, fixture); + const summary = createM3uImportBenchmarkSummary(manifest, iterations); + await writeJson(join(scenarioDirectory, 'manifest.json'), manifest); + await writeJson(join(scenarioDirectory, 'summary.json'), summary); + const diagnostic = requireDiagnosticIteration(iterations); + await validateInitialImportDiagnosticArtifacts( + { main: diagnostic.main, renderer: diagnostic.renderer }, + join(scenarioDirectory, diagnostic.runId) + ); + assertM3uImportComparisonValidity(summary); + console.log( + `[performance] completed: ${join(scenarioDirectory, 'summary.json')}` + ); +} + +export function assertM3uImportComparisonValidity( + summary: M3uImportBenchmarkSummary +): void { + if (!summary.validity.rendererRss.validForComparison) { + throw new Error( + `Renderer RSS capture is invalid for comparison: ${JSON.stringify( + summary.validity.rendererRss.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerPostGc.validForComparison) { + throw new Error( + `Database worker post-GC capture is invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerPostGc.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerPeakMemory.validForComparison) { + throw new Error( + `Database worker peak-memory capture is invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerPeakMemory.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerRequestMetrics.validForComparison) { + throw new Error( + `Database worker request metrics are invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerRequestMetrics.invalidRequests + )}` + ); + } +} + +async function runIteration( + config: M3uImportBenchmarkConfiguration, + definition: M3uImportIterationDefinition, + fixture: SyntheticM3uFixture, + server: SyntheticM3uServer, + scenarioDirectory: string +): Promise { + const iterationDirectory = join(scenarioDirectory, definition.runId); + await assertPerformanceArtifactCapacity(iterationDirectory); + await mkdir(iterationDirectory, { recursive: false }); + const dataDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-import-performance-') + ); + let app: LaunchedElectronApp | null = null; + let renderer: RunningM3uImportRendererCapture | null = null; + + try { + await assertTcpPortAvailable(config.rendererCdpPort); + app = await launchElectronApp(dataDirectory, { + args: [ + '--js-flags=--expose-gc', + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${config.rendererCdpPort}`, + `--user-data-dir=${join(dataDirectory, 'user-data')}`, + ], + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + IPTVNATOR_TRACE_RENDERER_CONSOLE: + process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', + }, + }); + app.mainWindow.setDefaultTimeout(120_000); + await assertRendererCdpTarget( + app.mainWindow, + config.rendererCdpPort + ); + await installMainCapture(app.electronApp); + const rendererWindowIdentity = await resolveRendererWindowIdentity(app); + const requestsBefore = server.requestCount(); + const diagnostic = + definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; + renderer = await startM3uImportRendererCapture(app.mainWindow, { + diagnostic, + outputDirectory: iterationDirectory, + playlistTitle: `Synthetic M3U import ${definition.channelCount}`, + playlistUrl: server.resourceUrl, + }); + + await startMainCapture(app.electronApp, { + diagnostic, + outputDirectory: iterationDirectory, + rendererWindowIdentity, + }); + await renderer.triggerImport(); + await renderer.waitForTerminal(); + await waitForDatabaseSettlement(app); + assertM3uImportRequestDelta(requestsBefore, server.requestCount()); + + const [rendererMetrics, mainMetrics] = await Promise.all([ + renderer.stop(), + stopMainCapture(app.electronApp), + ]); + await Promise.all([ + writeJson( + join(iterationDirectory, 'main-capture.json'), + mainMetrics + ), + writeJson( + join(iterationDirectory, 'renderer-capture.json'), + rendererMetrics + ), + ]); + const result = createM3uImportIterationResult({ + channelCount: definition.channelCount, + fixtureBytes: fixture.bytes, + kind: definition.kind, + main: mainMetrics, + renderer: rendererMetrics, + runId: definition.runId, + scenarioId: definition.scenarioId, + }); + await writeJson(join(iterationDirectory, 'result.json'), result); + return result; + } finally { + if (renderer) { + await renderer.dispose().catch((error: unknown) => { + console.error( + '[performance] Renderer capture cleanup failed', + error + ); + }); + } + if (app) { + await closeElectronApp(app).catch((error: unknown) => { + console.error('[performance] Electron cleanup failed', error); + }); + } + await rm(dataDirectory, { force: true, recursive: true }); + } +} + +async function waitForDatabaseSettlement( + app: LaunchedElectronApp +): Promise { + await expect + .poll( + async () => { + const status = await readMainCaptureStatus(app.electronApp); + return ( + status.databaseRequests === 2 && + status.databaseUpsertsCompleted === 1 && + status.databaseGetsCompleted === 1 && + status.databasePending === 0 && + status.preloadSuccessMarkers === 2 + ); + }, + { timeout: 120_000 } + ) + .toBe(true); +} + +function createManifest( + config: M3uImportBenchmarkConfiguration, + scenario: M3uImportScenario, + fixture: SyntheticM3uFixture +): M3uImportBenchmarkManifest { + return Object.freeze({ + channelCount: fixture.channelCount, + fixtureBytes: fixture.bytes, + fixtureSha256: fixture.sha256, + gitCommit: config.sourceState.commit, + gitDiffSha256: config.sourceState.diffSha256, + gitDirty: config.sourceState.dirty, + measuredRuns: config.measuredRuns, + memoryAccounting: Object.freeze({ + databaseWorkerRss: 'unavailable-per-worker-thread', + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + }), + runtime: Object.freeze({ + arch: process.arch, + cdpPort: config.rendererCdpPort, + electronVersion: config.electronVersion, + harnessNodeVersion: process.versions.node, + platform: process.platform, + }), + scenario: scenario.id, + syntheticSeed: SYNTHETIC_M3U_SEED, + variant: config.variant, + warmupRuns: 1, + }); +} + +function requireDiagnosticIteration( + iterations: readonly M3uImportIterationResult[] +): M3uImportIterationResult { + const diagnostic = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC + ); + if (diagnostic.length !== 1 || !diagnostic[0]) { + throw new Error('M3U import diagnostic iteration is missing'); + } + return diagnostic[0]; +} + +export function assertM3uImportRequestDelta( + before: number, + after: number +): void { + const oneRequest = + Number.isSafeInteger(before) && + Number.isSafeInteger(after) && + after === before + 1; + if (!oneRequest) { + throw new Error( + 'Synthetic M3U import must issue exactly one application GET' + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index f0abcd22f..a89f95dc8 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -1,3 +1,10 @@ +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; +import type { RendererRssValidity } from './renderer-rss-validity'; +import type { + AppPlaylistPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + export const PERFORMANCE_ITERATION_KIND = { DIAGNOSTIC: 'diagnostic', MEASURED: 'measured', @@ -15,6 +22,46 @@ export const PERFORMANCE_WORKER_KIND = { export type PerformanceWorkerKind = (typeof PERFORMANCE_WORKER_KIND)[keyof typeof PERFORMANCE_WORKER_KIND]; +export const WORKER_POST_GC_HEAP_UNAVAILABLE_REASON = { + CAPTURE_FAILED: 'capture-failed', + DATABASE_WORKER_ACTIVITY_AFTER_CUTOFF: + 'database-worker-activity-after-cutoff', + DATABASE_WORKER_MISSING: 'database-worker-missing', + DATABASE_WORKER_NOT_IDLE: 'database-worker-not-idle', + DATABASE_WORKER_UNEXPECTED_ACTIVITY: 'database-worker-unexpected-activity', + GC_UNAVAILABLE: 'gc-unavailable', + INVALID_CAPTURE: 'post-gc-capture-invalid', + MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers', + PROBE_INVALID_RESPONSE: 'post-gc-probe-invalid-response', + PROBE_MESSAGE_ERROR: 'post-gc-probe-message-error', + PROBE_NOT_RUN: 'post-gc-probe-not-run', + PROBE_PORT_CLOSED: 'post-gc-probe-port-closed', + PROBE_POST_FAILED: 'post-gc-probe-post-failed', + PROBE_TIMEOUT: 'post-gc-probe-timeout', + PROFILING_DISABLED: 'profiling-disabled', + WORKER_BUSY: 'worker-busy', + WORKER_FORCE_TERMINATED: 'worker-force-terminated-before-gc', +} as const; + +export type WorkerPostGcHeapUnavailableReason = + (typeof WORKER_POST_GC_HEAP_UNAVAILABLE_REASON)[keyof typeof WORKER_POST_GC_HEAP_UNAVAILABLE_REASON]; + +export type WorkerPostGcHeapCapture = + | { + readonly postGcHeapUnavailableReason: null; + readonly postGcHeapUsedBytes: number; + } + | { + readonly postGcHeapUnavailableReason: WorkerPostGcHeapUnavailableReason; + readonly postGcHeapUsedBytes: null; + }; + +export const WORKER_PEAK_MEMORY_UNAVAILABLE_REASON = { + EXTERNAL_MEMORY_SAMPLES_MISSING: + 'worker-external-memory-samples-missing', + HEAP_USED_SAMPLES_MISSING: 'worker-heap-used-samples-missing', +} as const; + export interface NumericDistribution { readonly count: number; readonly max: number | null; @@ -39,33 +86,76 @@ export interface ProcessMemoryMetrics { } export interface MainTimelineRecord { + readonly boundary?: 'end' | 'start'; + readonly byteCount?: number; + readonly durationMs?: number | null; readonly epochMs: number; + readonly ipcCallId?: number; + readonly itemCount?: number; readonly operation?: string; readonly operationId?: string; + readonly phase?: string; readonly playlistId?: string; readonly requestId?: string; readonly success?: boolean; + readonly sourceEpochMs?: number; readonly type: string; } -export interface WorkerCaptureMetrics { +export interface WorkerRequestPerformanceMetrics { + readonly eventLoopDelay: EventLoopDelayMetrics | null; + readonly eventLoopDelayUnavailableReason: string | null; + readonly eventLoopUtilization: number | null; + readonly eventLoopUtilizationUnavailableReason: string | null; + readonly histogramFlushedEpochMs: number | null; + readonly ipcCallId: number | null; + readonly invalidReason: string | null; + readonly operation: string | null; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly performanceCaptureUnavailableReason: string | null; + readonly phaseEvents: readonly PerformancePhaseEvent[]; + readonly playlistId: string | null; + readonly requestId: string | null; + readonly requestReceivedEpochMs: number | null; + readonly responseEpochMs: number; + readonly responsePostedEpochMs: number | null; + readonly sourceEpochMs: number | null; + readonly success: boolean; + readonly threadCpuSystemMicros: number | null; + readonly threadCpuUnavailableReason: string | null; + readonly threadCpuUserMicros: number | null; + readonly workEndedEpochMs: number | null; + readonly workStartedEpochMs: number | null; +} + +export type WorkerCaptureMetrics = { readonly cancelPostedEpochMs: number | null; readonly cpuSystemMicros: number | null; readonly cpuUserMicros: number | null; readonly eventLoopDelay: EventLoopDelayMetrics | null; readonly eventLoopDelayUnavailableReason: string | null; readonly eventLoopUtilization: number | null; + readonly externalMemorySampleCount: number; + readonly heapUsedSampleCount: number; readonly kind: PerformanceWorkerKind; readonly operationId: string | null; - readonly peakExternalBytes: number; - readonly peakHeapUsedBytes: number; + readonly ordinal: number; + readonly peakExternalBytes: number | null; + readonly peakExternalUnavailableReason: + | typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING + | null; + readonly peakHeapUsedBytes: number | null; + readonly peakHeapUsedUnavailableReason: + | typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.HEAP_USED_SAMPLES_MISSING + | null; readonly playlistId: string | null; - readonly postGcHeapUsedBytes: number | null; readonly profilePath: string | null; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; readonly responseEpochMs: number | null; readonly snapshotPath: string | null; readonly terminatedEpochMs: number | null; -} +} & WorkerPostGcHeapCapture; export interface MainCaptureMetrics { readonly cpuProfilePath: string | null; @@ -76,11 +166,17 @@ export interface MainCaptureMetrics { readonly eventLoopUtilizationUnavailableReason: string | null; readonly heapSnapshotPath: string | null; readonly memory: ProcessMemoryMetrics; - readonly rendererPeakRssBytes: number; + readonly rendererWindow: { + readonly responsiveEvents: number; + readonly rss: RendererProcessRssCapture; + readonly unresponsiveEvents: number; + readonly windowIdentity: { + readonly browserWindowId: number; + readonly webContentsId: number; + }; + }; readonly rssScope: 'electron-main-process-including-worker-threads-and-native-memory'; readonly timeline: readonly MainTimelineRecord[]; - readonly unresponsiveEvents: number; - readonly responsiveEvents: number; readonly workers: readonly WorkerCaptureMetrics[]; } @@ -167,6 +263,27 @@ export interface CancellationBenchmarkManifest { readonly warmupRuns: number; } +export interface InvalidDatabaseWorkerPostGcMeasuredRun { + readonly databaseWorkerCount: number; + readonly reason: string; + readonly runId: string; +} + +export interface NotApplicableDatabaseWorkerPostGcMeasuredRun { + readonly reason: 'operation-cancelled-before-database-phase'; + readonly runId: string; +} + +export interface DatabaseWorkerPostGcValidity { + readonly applicableMeasuredRunCount: number; + readonly invalidMeasuredRuns: readonly InvalidDatabaseWorkerPostGcMeasuredRun[]; + readonly measuredRunCount: number; + readonly notApplicableMeasuredRuns: readonly NotApplicableDatabaseWorkerPostGcMeasuredRun[]; + readonly validForBenchmark: boolean; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + export interface CancellationBenchmarkSummary { readonly cancellationEffectRate: number; readonly iterations: readonly CancellationIterationResult[]; @@ -176,13 +293,15 @@ export interface CancellationBenchmarkSummary { readonly cancelToDurableTerminalMs: NumericDistribution; readonly cancelToWorkerTerminatedMs: NumericDistribution; readonly cancelTransportLatencyMs: NumericDistribution; - readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; - readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; - readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; - readonly databaseWorkerEventLoopUtilization: NumericDistribution; readonly databaseWorkerExternalPeakBytes: NumericDistribution; readonly databaseWorkerHeapPeakBytes: NumericDistribution; readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopUtilization: NumericDistribution; + readonly databaseWorkerRequestThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerRequestThreadCpuUserMicros: NumericDistribution; readonly dataFetchMs: NumericDistribution; readonly dbCommitProxyMs: NumericDistribution; readonly ipcStoreDispatchProxyMs: NumericDistribution; @@ -196,13 +315,15 @@ export interface CancellationBenchmarkSummary { readonly mainRssPostGcBytes: NumericDistribution; readonly parseNormalizeCloneProxyMs: NumericDistribution; readonly persistencePreparationProxyMs: NumericDistribution; - readonly playlistWorkerEventLoopUtilization: NumericDistribution; - readonly playlistWorkerEventLoopDelayMaxMs: NumericDistribution; - readonly playlistWorkerEventLoopDelayP95Ms: NumericDistribution; - readonly playlistWorkerEventLoopDelayP99Ms: NumericDistribution; readonly playlistWorkerExternalPeakBytes: NumericDistribution; readonly playlistWorkerHeapPeakBytes: NumericDistribution; readonly playlistWorkerPostGcHeapBytes: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly playlistWorkerRequestEventLoopUtilization: NumericDistribution; + readonly playlistWorkerRequestThreadCpuSystemMicros: NumericDistribution; + readonly playlistWorkerRequestThreadCpuUserMicros: NumericDistribution; readonly rendererFrameGapMs: NumericDistribution; readonly rendererHeartbeatDelayMs: NumericDistribution; readonly rendererHeapPeakBytes: NumericDistribution; @@ -216,4 +337,8 @@ export interface CancellationBenchmarkSummary { readonly unresponsiveEvents: number; readonly visibleTotalMs: NumericDistribution; }; + readonly validity: { + readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity; + readonly rendererRss: RendererRssValidity; + }; } diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index 2f063cd0a..f95d075e6 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -9,6 +9,7 @@ import type { NumericDistribution, PerformanceWorkerKind, RendererCaptureMetrics, + WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { PERFORMANCE_ITERATION_KIND, @@ -18,6 +19,8 @@ import { nonNegativeDifference, summarizeNumbers, } from './performance-statistics'; +import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity'; +import { assessRendererRssValidity } from './renderer-rss-validity'; export function createCancellationIterationResult(input: { readonly kind: CancellationIterationResult['kind']; @@ -56,22 +59,34 @@ export function createCancellationBenchmarkSummary( select: (worker: MainCaptureMetrics['workers'][number]) => number | null ): NumericDistribution => summarizeNumbers( - measured.map((iteration) => { - const worker = iteration.main.workers.find( - (candidate) => candidate.kind === kind - ); - return worker ? select(worker) : null; - }) + measured.flatMap((iteration) => + iteration.main.workers + .filter((worker) => worker.kind === kind) + .map((worker) => select(worker)) + ) ); - const workerEventLoopDelayMetric = ( + const workerRequestMetric = ( + kind: PerformanceWorkerKind, + select: (request: WorkerRequestPerformanceMetrics) => number | null + ): NumericDistribution => + summarizeNumbers( + measured.flatMap((iteration) => + iteration.main.workers + .filter((worker) => worker.kind === kind) + .flatMap((worker) => + worker.requests.map((request) => select(request)) + ) + ) + ); + const workerRequestEventLoopDelayMetric = ( kind: PerformanceWorkerKind, percentile: keyof NonNullable< - MainCaptureMetrics['workers'][number]['eventLoopDelay'] + WorkerRequestPerformanceMetrics['eventLoopDelay'] > ): NumericDistribution => - workerMetric( + workerRequestMetric( kind, - (worker) => worker.eventLoopDelay?.[percentile] ?? null + (request) => request.eventLoopDelay?.[percentile] ?? null ); return Object.freeze({ @@ -105,22 +120,6 @@ export function createCancellationBenchmarkSummary( (iteration) => iteration.phases.cancelTransportLatencyMs ) ), - databaseWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'maxMs' - ), - databaseWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'p95Ms' - ), - databaseWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'p99Ms' - ), - databaseWorkerEventLoopUtilization: workerMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - (worker) => worker.eventLoopUtilization - ), databaseWorkerExternalPeakBytes: workerMetric( PERFORMANCE_WORKER_KIND.DATABASE, (worker) => worker.peakExternalBytes @@ -133,6 +132,33 @@ export function createCancellationBenchmarkSummary( PERFORMANCE_WORKER_KIND.DATABASE, (worker) => worker.postGcHeapUsedBytes ), + databaseWorkerRequestEventLoopDelayMaxMs: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'maxMs' + ), + databaseWorkerRequestEventLoopDelayP95Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p95Ms' + ), + databaseWorkerRequestEventLoopDelayP99Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p99Ms' + ), + databaseWorkerRequestEventLoopUtilization: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.eventLoopUtilization + ), + databaseWorkerRequestThreadCpuSystemMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.threadCpuSystemMicros + ), + databaseWorkerRequestThreadCpuUserMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.threadCpuUserMicros + ), dataFetchMs: summarizeNumbers( measured.map((iteration) => iteration.phases.dataFetchMs) ), @@ -185,22 +211,6 @@ export function createCancellationBenchmarkSummary( iteration.phases.persistencePreparationProxyMs ) ), - playlistWorkerEventLoopUtilization: workerMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - (worker) => worker.eventLoopUtilization - ), - playlistWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'maxMs' - ), - playlistWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'p95Ms' - ), - playlistWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'p99Ms' - ), playlistWorkerExternalPeakBytes: workerMetric( PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, (worker) => worker.peakExternalBytes @@ -213,6 +223,33 @@ export function createCancellationBenchmarkSummary( PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, (worker) => worker.postGcHeapUsedBytes ), + playlistWorkerRequestEventLoopDelayMaxMs: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'maxMs' + ), + playlistWorkerRequestEventLoopDelayP95Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p95Ms' + ), + playlistWorkerRequestEventLoopDelayP99Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p99Ms' + ), + playlistWorkerRequestEventLoopUtilization: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.eventLoopUtilization + ), + playlistWorkerRequestThreadCpuSystemMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.threadCpuSystemMicros + ), + playlistWorkerRequestThreadCpuUserMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.threadCpuUserMicros + ), rendererFrameGapMs: summarizeNumbers( flattenRenderer( (iteration) => iteration.renderer.probe.frameGapsMs @@ -244,10 +281,14 @@ export function createCancellationBenchmarkSummary( ) ), rendererRssPeakBytes: summarizeNumbers( - measured.map((iteration) => iteration.main.rendererPeakRssBytes) + measured.map( + (iteration) => + iteration.main.rendererWindow.rss.peakRssBytes + ) ), responsiveEvents: measured.reduce( - (sum, iteration) => sum + iteration.main.responsiveEvents, + (sum, iteration) => + sum + iteration.main.rendererWindow.responsiveEvents, 0 ), totalMs: summarizeNumbers( @@ -259,13 +300,19 @@ export function createCancellationBenchmarkSummary( ) ), unresponsiveEvents: measured.reduce( - (sum, iteration) => sum + iteration.main.unresponsiveEvents, + (sum, iteration) => + sum + iteration.main.rendererWindow.unresponsiveEvents, 0 ), visibleTotalMs: summarizeNumbers( measured.map((iteration) => iteration.phases.visibleTotalMs) ), }), + validity: Object.freeze({ + databaseWorkerPostGc: + assessDatabaseWorkerPostGcValidity(iterations), + rendererRss: assessRendererRssValidity(iterations), + }), }); } diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index fcfd83d1f..243803989 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -35,13 +35,17 @@ import { createCancellationBenchmarkSummary, createCancellationIterationResult, } from './m3u-refresh-cancellation-report'; +import { validateDiagnosticPlaylistWorkerCapture } from './diagnostic-playlist-worker-capture'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; import { installMainCapture, readMainCaptureStatus, + rolloverMainCapture, startMainCapture, stopMainCapture, } from './m3u-refresh-main-capture'; import { startRendererCapture } from './m3u-refresh-renderer-capture'; +import type { RendererWindowIdentity } from './renderer-window-rss-session'; import { createSyntheticM3uFixture, SYNTHETIC_M3U_CHANNEL_COUNT, @@ -133,6 +137,40 @@ export async function runM3uRefreshCancellationBenchmark(): Promise { const summary = createCancellationBenchmarkSummary(manifest, iterations); await writeJson(join(config.outputDirectory, 'manifest.json'), manifest); await writeJson(join(config.outputDirectory, 'summary.json'), summary); + const diagnosticIteration = iterations.find( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC + ); + if (!diagnosticIteration) { + throw new Error('Diagnostic iteration is missing'); + } + if (!diagnosticIteration.cancellationEffectObserved) { + throw new Error( + 'Cancellation effect was not observed in the diagnostic run' + ); + } + await validateDiagnosticPlaylistWorkerCapture( + diagnosticIteration.main, + join(config.outputDirectory, diagnosticIteration.runId) + ); + if (summary.cancellationEffectRate !== 1) { + throw new Error( + `Cancellation effect was not observed in every measured run: ${summary.cancellationEffectRate}` + ); + } + if (!summary.validity.rendererRss.validForBenchmark) { + throw new Error( + `Renderer RSS capture is invalid: ${JSON.stringify( + summary.validity.rendererRss.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerPostGc.validForBenchmark) { + throw new Error( + `Database worker post-GC capture is invalid: ${JSON.stringify( + summary.validity.databaseWorkerPostGc.invalidMeasuredRuns + )}` + ); + } console.log( `[performance] completed: ${join( config.outputDirectory, @@ -147,10 +185,11 @@ async function runIteration( server: SyntheticServer ): Promise { const iterationDirectory = join(config.outputDirectory, definition.runId); + await assertPerformanceArtifactCapacity(iterationDirectory); + await mkdir(iterationDirectory, { recursive: false }); const dataDirectory = await mkdtemp( join(tmpdir(), 'iptvnator-m3u-performance-') ); - await mkdir(iterationDirectory, { recursive: false }); let app: LaunchedElectronApp | null = null; let rendererConsoleListener: ((message: ConsoleMessage) => void) | null = null; @@ -160,12 +199,14 @@ async function runIteration( server.serveSeedFixture(); app = await launchElectronApp(dataDirectory, { args: [ + '--js-flags=--expose-gc', '--remote-debugging-address=127.0.0.1', `--remote-debugging-port=${RENDERER_CDP_PORT}`, `--user-data-dir=${join(dataDirectory, 'user-data')}`, ], env: { IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', IPTVNATOR_PERF_WORKER_PROFILING: '1', IPTVNATOR_TRACE_RENDERER_CONSOLE: process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', @@ -174,6 +215,12 @@ async function runIteration( await assertRendererCdpTarget(app.mainWindow); app.mainWindow.setDefaultTimeout(120_000); await installMainCapture(app.electronApp); + const rendererWindowIdentity = await resolveRendererWindowIdentity(app); + await startMainCapture(app.electronApp, { + diagnostic: false, + outputDirectory: iterationDirectory, + rendererWindowIdentity, + }); await seedPlaylist(app.mainWindow, server.resourceUrl); await waitForSeedSettlement(app); server.serveLargeFixture(); @@ -190,10 +237,20 @@ async function runIteration( const diagnostic = definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; - await startMainCapture(app.electronApp, { + const seedRollover = await rolloverMainCapture(app.electronApp, { diagnostic, outputDirectory: iterationDirectory, + rendererWindowIdentity, }); + await writeJson( + join(iterationDirectory, 'seed-main-capture.json'), + seedRollover.completedCapture + ); + if (!seedRollover.nextCaptureStarted) { + throw new Error( + `Seed capture could not atomically arm the measured generation: ${seedRollover.nextCaptureUnavailableReason}` + ); + } const renderer = await startRendererCapture(app.mainWindow, { diagnostic, outputDirectory: iterationDirectory, @@ -235,6 +292,28 @@ async function runIteration( } } +async function resolveRendererWindowIdentity( + app: LaunchedElectronApp +): Promise { + const browserWindowHandle = await app.electronApp.browserWindow( + app.mainWindow + ); + try { + return await browserWindowHandle.evaluate((browserWindow) => { + const exactWindow = browserWindow as unknown as { + readonly id: number; + readonly webContents: { readonly id: number }; + }; + return { + browserWindowId: exactWindow.id, + webContentsId: exactWindow.webContents.id, + }; + }); + } finally { + await browserWindowHandle.dispose(); + } +} + async function seedPlaylist(page: Page, playlistUrl: string): Promise { await page.getByRole('button', { name: 'Add playlist' }).first().click(); const dialog = page.locator('mat-dialog-container').last(); @@ -258,11 +337,17 @@ async function seedPlaylist(page: Page, playlistUrl: string): Promise { async function waitForSeedSettlement(app: LaunchedElectronApp): Promise { await expect .poll( - async () => - (await readMainCaptureStatus(app.electronApp)).databasePending, + async () => { + const status = await readMainCaptureStatus(app.electronApp); + return ( + status.databaseRequests > 0 && + status.databaseUpsertsCompleted > 0 && + status.databasePending === 0 + ); + }, { timeout: 120_000 } ) - .toBe(0); + .toBe(true); await waitForTwoAnimationFrames(app.mainWindow); } @@ -362,6 +447,7 @@ async function resolveConfiguration(): Promise { } const outputDirectory = join(outputRoot, variant); await assertMissing(outputDirectory); + await assertPerformanceArtifactCapacity(outputDirectory); await mkdir(outputDirectory, { recursive: true }); return Object.freeze({ electronVersion: electronPackage.version, diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index fd9435445..2c4536f8e 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -1,16 +1,68 @@ /* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ import type { ElectronApplication } from '@playwright/test'; +import { + M3U_IMPORT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, +} from '@iptvnator/shared/interfaces'; -import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import { + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + installDatabaseRequestIdentityCapture, + type DatabaseRequestIdentity, + type DatabaseRequestIdentityCaptureApi, +} from './database-request-identity-capture'; +import { + createDatabaseWorkerPostGcCutoffApi, + type DatabaseWorkerPostGcCutoffApi, +} from './database-worker-post-gc-cutoff'; +import { + createDatabaseWorkerPostGcFinalizationApi, + type DatabaseWorkerPostGcAncillaryFailureStage, + type DatabaseWorkerPostGcFinalizationApi, +} from './database-worker-post-gc-finalization'; +import { + createDatabaseWorkerPostGcProbeApi, + type DatabaseWorkerPostGcProbeApi, +} from './database-worker-post-gc-probe'; +import { + createDatabaseWorkerPostGcSelectionApi, + type DatabaseWorkerPostGcSelectionApi, + type DatabaseWorkerPostGcUnavailableReason, +} from './database-worker-post-gc-selection'; +import type { + MainCaptureMetrics, + WorkerPostGcHeapUnavailableReason, +} from './m3u-refresh-cancellation-contract'; +import { + selectMainCaptureGeneration, + type MainCaptureGenerationTransport, +} from './worker-request-performance'; +import { + createWorkerTerminationGenerationApi, + type WorkerTerminationGenerationApi, +} from './worker-termination-generation'; +import { + createRendererProcessRssCaptureApi, + type RendererProcessRssCaptureApi, +} from './renderer-process-rss-capture'; +import { + createRendererWindowRssSessionApi, + type RendererWindowIdentity, + type RendererWindowRssSession, + type RendererWindowRssSessionApi, + type RendererWindowRssSessionMetrics, +} from './renderer-window-rss-session'; const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; export interface MainCaptureStartOptions { readonly diagnostic: boolean; readonly outputDirectory: string; + readonly rendererWindowIdentity: RendererWindowIdentity; } export interface MainCaptureStatus { + readonly databaseGetsCompleted: number; readonly databasePending: number; readonly databaseRequests: number; readonly databaseUpsertsCompleted: number; @@ -18,12 +70,51 @@ export interface MainCaptureStatus { readonly playlistResponsesFailed: number; readonly playlistResponses: number; readonly playlistResponsesSucceeded: number; + readonly preloadSuccessMarkers: number; } +export interface MainCaptureRolloverResult { + readonly completedCapture: MainCaptureMetrics; + readonly nextCaptureStarted: boolean; + readonly nextCaptureUnavailableReason: string | null; +} + +interface MainCaptureRolloverStatus { + readonly nextCaptureStarted: boolean; + readonly nextCaptureUnavailableReason: string | null; +} + +type MainCaptureStopTransport = MainCaptureGenerationTransport & { + readonly rollover: MainCaptureRolloverStatus | null; +}; + export async function installMainCapture( electronApp: ElectronApplication ): Promise { - await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => { + await installDatabaseRequestIdentityCapture(electronApp); + const captureStateKeys = { + databaseRequestIdentityStateKey: + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + databaseWorkerPostGcCutoffApiFactorySource: + createDatabaseWorkerPostGcCutoffApi.toString(), + databaseWorkerPostGcFinalizationApiFactorySource: + createDatabaseWorkerPostGcFinalizationApi.toString(), + databaseWorkerPostGcProbeApiFactorySource: + createDatabaseWorkerPostGcProbeApi.toString(), + databaseWorkerPostGcSelectionApiFactorySource: + createDatabaseWorkerPostGcSelectionApi.toString(), + rendererProcessRssApiFactorySource: + createRendererProcessRssCaptureApi.toString(), + rendererWindowRssSessionApiFactorySource: + createRendererWindowRssSessionApi.toString(), + m3uImportPerformancePhaseEventChannel: + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, + m3uImportPerformancePhases: Object.values(M3U_IMPORT_PERFORMANCE_PHASE), + stateKey: MAIN_CAPTURE_STATE_KEY, + workerTerminationGenerationApiFactorySource: + createWorkerTerminationGenerationApi.toString(), + }; + await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { type JsonRecord = Record; type WorkerTransferable = import('node:worker_threads').Transferable; interface CpuProfileHandle { @@ -42,6 +133,15 @@ export async function installMainCapture( idle: number; utilization: number; } + interface WorkerRequestPerformance { + identity: DatabaseRequestIdentity; + operation: string | null; + performanceCapture: unknown; + playlistId: string | null; + requestId: string | null; + responseEpochMs: number; + success: boolean; + } interface InstrumentedWorker { cpuUsage?(): Promise; getHeapSnapshot?(): Promise; @@ -62,46 +162,97 @@ export async function installMainCapture( } interface WorkerRecord { cancelPostedEpochMs: number | null; + captureGeneration: number | null; cpuFirst: WorkerCpuUsage | null; cpuLast: WorkerCpuUsage | null; elu: number | null; eluStart: WorkerElu | null; - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - } | null; + externalMemorySampleCount: number; externalPeak: number; finalized: boolean; + finalizationKey: object; + finalizationTimedOut: boolean; finalizing: Promise | null; heapPeak: number; + heapUsedSampleCount: number; kind: 'database.worker' | 'playlist-refresh.worker'; operationId: string | null; + ordinal: number; + pendingCount: number; playlistId: string | null; + postGcHeapUnavailableReason: WorkerPostGcHeapUnavailableReason | null; postGcHeapUsed: number | null; profileHandle: Promise | null; + profileCaptureKey: object; profilePath: string | null; + profileResult: unknown | null; + resolvedProfileHandle: CpuProfileHandle | null; + requestPerformance: WorkerRequestPerformance[]; responseEpochMs: number | null; - sampleBusy: boolean; + samplePromise: Promise | null; sampleTimer: NodeJS.Timeout | null; snapshotPath: string | null; terminatedEpochMs: number | null; worker: InstrumentedWorker; } interface TimelineRecord { + readonly boundary?: 'end' | 'start'; + readonly byteCount?: number; + readonly durationMs?: number | null; readonly epochMs: number; + readonly ipcCallId?: number; + readonly itemCount?: number; readonly operation?: string; readonly operationId?: string; + readonly phase?: string; readonly playlistId?: string; readonly requestId?: string; readonly success?: boolean; + readonly sourceEpochMs?: number; readonly type: string; } const target = globalThis as unknown as Record; - if (target[stateKey] !== undefined) { + if (target[input.stateKey] !== undefined) { return; } + const databaseRequestIdentityCapture = target[ + input.databaseRequestIdentityStateKey + ] as DatabaseRequestIdentityCaptureApi; + const restoreFactory = (source: string): T => { + const factory = new Function( + `"use strict"; return (${source});` + )() as () => T; + return factory(); + }; + const databaseWorkerPostGcCutoffApi = + restoreFactory( + input.databaseWorkerPostGcCutoffApiFactorySource + ); + const databaseWorkerPostGcFinalizationApi = + restoreFactory( + input.databaseWorkerPostGcFinalizationApiFactorySource + ); + const databaseWorkerPostGcProbeApi = + restoreFactory( + input.databaseWorkerPostGcProbeApiFactorySource + ); + const databaseWorkerPostGcSelectionApi = + restoreFactory( + input.databaseWorkerPostGcSelectionApiFactorySource + ); + const rendererProcessRssApi = + restoreFactory( + input.rendererProcessRssApiFactorySource + ); + const rendererWindowRssSessionApi = + restoreFactory( + input.rendererWindowRssSessionApiFactorySource + ); + const workerTerminationGenerationApi = + restoreFactory( + input.workerTerminationGenerationApiFactorySource + ); const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; @@ -112,6 +263,9 @@ export async function installMainCapture( const inspector = runtimeProcess.getBuiltinModule( 'node:inspector' ) as typeof import('node:inspector'); + const diagnosticsChannel = runtimeProcess.getBuiltinModule( + 'node:diagnostics_channel' + ) as typeof import('node:diagnostics_channel'); const path = runtimeProcess.getBuiltinModule( 'node:path' ) as typeof import('node:path'); @@ -128,10 +282,12 @@ export async function installMainCapture( const originalPostMessage = WorkerClass.prototype.postMessage; const originalTerminate = WorkerClass.prototype.terminate; const records = new Map(); + let nextWorkerOrdinal = 1; const operationWorkers = new Map(); const dbRequests = new Map< string, { + identity: DatabaseRequestIdentity; operation: string; playlistId: string | null; record: WorkerRecord; @@ -140,6 +296,7 @@ export async function installMainCapture( const state = { active: false, + captureGeneration: 0, cpuStart: null as NodeJS.CpuUsage | null, diagnostic: false, eventLoopDelay: null as ReturnType< @@ -156,16 +313,10 @@ export async function installMainCapture( outputDirectory: '', postGcHeap: null as number | null, postGcRss: null as number | null, - rendererPeakRss: 0, - responsiveEvents: 0, + rendererWindowSession: null as RendererWindowRssSession | null, sampleTimer: null as NodeJS.Timeout | null, + stopping: false, timeline: [] as TimelineRecord[], - unresponsiveEvents: 0, - windowListeners: [] as { - responsive: () => void; - unresponsive: () => void; - window: Electron.BrowserWindow; - }[], }; const nowEpochMs = (): number => @@ -173,10 +324,64 @@ export async function installMainCapture( const recordTimeline = ( record: Omit ): void => { - if (state.active) { + if (state.active || state.stopping) { state.timeline.push({ epochMs: nowEpochMs(), ...record }); } }; + const m3uImportPhases = new Set( + input.m3uImportPerformancePhases + ); + diagnosticsChannel + .channel(input.m3uImportPerformancePhaseEventChannel) + .subscribe((incoming: unknown) => { + if (!state.active && !state.stopping) { + return; + } + if (typeof incoming !== 'object' || incoming === null) { + return; + } + const event = incoming as JsonRecord; + const boundary = event['boundary']; + const durationMs = event['durationMs']; + const epochMs = event['epochMs']; + const metadata = + typeof event['metadata'] === 'object' && + event['metadata'] !== null + ? (event['metadata'] as JsonRecord) + : null; + const phase = event['phase']; + const requestId = event['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + typeof epochMs !== 'number' || + !Number.isFinite(epochMs) || + typeof phase !== 'string' || + !m3uImportPhases.has(phase) || + typeof requestId !== 'string' || + requestId.length === 0 || + (boundary === 'start' && durationMs !== null) || + (boundary === 'end' && + (typeof durationMs !== 'number' || + !Number.isFinite(durationMs) || + durationMs < 0)) + ) { + return; + } + const byteCount = metadata?.['byteCount']; + const itemCount = metadata?.['itemCount']; + state.timeline.push({ + boundary, + ...(isCount(byteCount) ? { byteCount } : {}), + durationMs: durationMs as number | null, + epochMs, + ...(isCount(itemCount) ? { itemCount } : {}), + phase, + requestId, + type: 'm3u-import-phase', + }); + }); + const isCount = (value: unknown): value is number => + Number.isSafeInteger(value) && Number(value) >= 0; const classifyRequest = ( message: JsonRecord ): 'database.worker' | 'playlist-refresh.worker' | null => { @@ -200,6 +405,9 @@ export async function installMainCapture( const playlistId = value['playlistId'] ?? value['_id']; return typeof playlistId === 'string' ? playlistId : null; }; + const isCurrentCaptureRecord = (record: WorkerRecord): boolean => + state.active && + record.captureGeneration === state.captureGeneration; const createWorkerRecord = ( worker: InstrumentedWorker, kind: WorkerRecord['kind'] @@ -210,58 +418,53 @@ export async function installMainCapture( } const record: WorkerRecord = { cancelPostedEpochMs: null, + captureGeneration: null, cpuFirst: null, cpuLast: null, elu: null, eluStart: null, - eventLoopDelay: null, + externalMemorySampleCount: 0, externalPeak: 0, finalized: false, + finalizationKey: {}, + finalizationTimedOut: false, finalizing: null, heapPeak: 0, + heapUsedSampleCount: 0, kind, operationId: null, + ordinal: nextWorkerOrdinal, + pendingCount: 0, playlistId: null, + postGcHeapUnavailableReason: 'post-gc-probe-not-run', postGcHeapUsed: null, profileHandle: null, + profileCaptureKey: {}, profilePath: null, + profileResult: null, + resolvedProfileHandle: null, + requestPerformance: [], responseEpochMs: null, - sampleBusy: false, + samplePromise: null, sampleTimer: null, snapshotPath: null, terminatedEpochMs: null, worker, }; + nextWorkerOrdinal += 1; records.set(worker, record); worker.on('message', (incoming) => { if (typeof incoming !== 'object' || incoming === null) { return; } const message = incoming as JsonRecord; - const performanceCapture = readWorkerPerformance(message); - if (performanceCapture) { - record.eventLoopDelay = record.eventLoopDelay - ? { - maxMs: Math.max( - record.eventLoopDelay.maxMs, - performanceCapture.eventLoopDelay.maxMs - ), - p95Ms: Math.max( - record.eventLoopDelay.p95Ms, - performanceCapture.eventLoopDelay.p95Ms - ), - p99Ms: Math.max( - record.eventLoopDelay.p99Ms, - performanceCapture.eventLoopDelay.p99Ms - ), - } - : performanceCapture.eventLoopDelay; + if (!isCurrentCaptureRecord(record)) { + return; } if (record.kind === 'playlist-refresh.worker') { if (message['type'] === 'event') { const event = message['event'] as - | JsonRecord - | undefined; + JsonRecord | undefined; recordTimeline({ operationId: record.operationId ?? undefined, playlistId: record.playlistId ?? undefined, @@ -270,7 +473,22 @@ export async function installMainCapture( )}:${String(event?.['phase'] ?? 'none')}`, }); } else if (message['type'] === 'response') { - record.responseEpochMs = nowEpochMs(); + const responseEpochMs = nowEpochMs(); + record.responseEpochMs = responseEpochMs; + record.requestPerformance.push({ + identity: { + ipcCallId: null, + operationId: record.operationId, + operationIdUnavailableReason: null, + sourceEpochMs: null, + }, + operation: null, + performanceCapture: message['performance'] ?? null, + playlistId: record.playlistId, + requestId: null, + responseEpochMs, + success: message['success'] === true, + }); recordTimeline({ operationId: record.operationId ?? undefined, playlistId: record.playlistId ?? undefined, @@ -285,10 +503,27 @@ export async function installMainCapture( typeof message['requestId'] === 'string' ) { const request = dbRequests.get(message['requestId']); + if (request) { + request.record.pendingCount = Math.max( + 0, + request.record.pendingCount - 1 + ); + record.requestPerformance.push({ + identity: request.identity, + operation: request.operation, + performanceCapture: message['performance'] ?? null, + playlistId: request.playlistId, + requestId: message['requestId'], + responseEpochMs: nowEpochMs(), + success: message['success'] === true, + }); + } if (request) { dbRequests.delete(message['requestId']); recordTimeline({ operation: request.operation, + operationId: + request.identity.operationId ?? undefined, playlistId: request.playlistId ?? undefined, requestId: message['requestId'], success: message['success'] === true, @@ -299,88 +534,109 @@ export async function installMainCapture( }); return record; }; - const readWorkerPerformance = ( - message: JsonRecord - ): { - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - }; - } | null => { - const performanceCapture = message['performance']; - if ( - typeof performanceCapture !== 'object' || - performanceCapture === null - ) { - return null; + const sampleWorker = (record: WorkerRecord): Promise => { + if (record.finalized) { + return Promise.resolve(); } - const eventLoopDelay = (performanceCapture as JsonRecord)[ - 'eventLoopDelay' - ]; - if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) { - return null; + if (record.samplePromise) { + return record.samplePromise; } - const value = eventLoopDelay as JsonRecord; - const maxMs = value['maxMs']; - const p95Ms = value['p95Ms']; - const p99Ms = value['p99Ms']; - if ( - typeof maxMs !== 'number' || - !Number.isFinite(maxMs) || - typeof p95Ms !== 'number' || - !Number.isFinite(p95Ms) || - typeof p99Ms !== 'number' || - !Number.isFinite(p99Ms) - ) { - return null; - } - return { - eventLoopDelay: { maxMs, p95Ms, p99Ms }, - }; + record.samplePromise = (async () => { + try { + const stats = await record.worker.getHeapStatistics?.(); + if (stats) { + const heapUsedSample = stats.used_heap_size; + if ( + typeof heapUsedSample === 'number' && + Number.isFinite(heapUsedSample) && + heapUsedSample >= 0 + ) { + record.heapUsedSampleCount += 1; + record.heapPeak = Math.max( + record.heapPeak, + heapUsedSample + ); + } + const externalMemorySample = stats.external_memory; + if ( + typeof externalMemorySample === 'number' && + Number.isFinite(externalMemorySample) && + externalMemorySample >= 0 + ) { + record.externalMemorySampleCount += 1; + record.externalPeak = Math.max( + record.externalPeak, + externalMemorySample + ); + } + } + const cpu = await record.worker.cpuUsage?.(); + if (cpu) { + record.cpuFirst ??= cpu; + record.cpuLast = cpu; + } + const elu = record.worker.performance?.eventLoopUtilization( + record.eluStart ?? undefined + ); + if (elu) { + record.elu = elu.utilization; + } + } catch { + // A one-shot worker may terminate between sampling calls. + } + })().finally(() => { + record.samplePromise = null; + }); + return record.samplePromise; }; - const sampleWorker = async (record: WorkerRecord): Promise => { - if (record.sampleBusy || record.finalized) { - return; - } - record.sampleBusy = true; - try { - const stats = await record.worker.getHeapStatistics?.(); - if (stats) { - record.heapPeak = Math.max( - record.heapPeak, - Number(stats.used_heap_size ?? 0) - ); - record.externalPeak = Math.max( - record.externalPeak, - Number(stats.external_memory ?? 0) - ); - } - const cpu = await record.worker.cpuUsage?.(); - if (cpu) { - record.cpuFirst ??= cpu; - record.cpuLast = cpu; - } - const elu = record.worker.performance?.eventLoopUtilization( - record.eluStart ?? undefined - ); - if (elu) { - record.elu = elu.utilization; - } - } catch { - // A one-shot worker may terminate between sampling calls. - } finally { - record.sampleBusy = false; + const resetWorkerForCapture = (record: WorkerRecord): void => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); } + record.cancelPostedEpochMs = null; + record.captureGeneration = state.captureGeneration; + record.cpuFirst = null; + record.cpuLast = null; + record.elu = null; + record.eluStart = null; + record.externalMemorySampleCount = 0; + record.externalPeak = 0; + record.finalized = false; + record.finalizationKey = {}; + record.finalizationTimedOut = false; + record.finalizing = null; + record.heapPeak = 0; + record.heapUsedSampleCount = 0; + record.operationId = null; + record.pendingCount = 0; + record.playlistId = null; + record.postGcHeapUnavailableReason = 'post-gc-probe-not-run'; + record.postGcHeapUsed = null; + record.profileHandle = null; + record.profileCaptureKey = {}; + record.profilePath = null; + record.profileResult = null; + record.resolvedProfileHandle = null; + record.requestPerformance = []; + record.responseEpochMs = null; + record.samplePromise = null; + record.sampleTimer = null; + record.snapshotPath = null; + record.terminatedEpochMs = null; }; const startWorker = (record: WorkerRecord): void => { - if (!state.active || record.sampleTimer !== null) { + if (!state.active) { + return; + } + if (record.captureGeneration !== state.captureGeneration) { + resetWorkerForCapture(record); + } + if (record.sampleTimer !== null) { return; } record.elu = null; record.eluStart = record.worker.performance?.eventLoopUtilization() ?? null; - record.eventLoopDelay = null; void sampleWorker(record); record.sampleTimer = setInterval( () => void sampleWorker(record), @@ -392,58 +648,242 @@ export async function installMainCapture( ) { record.profilePath = path.join( state.outputDirectory, - `${record.kind}.cpuprofile` + `${record.kind}-${record.ordinal}.cpuprofile` + ); + const profileHandle = record.worker.startCpuProfile(); + const profileCaptureKey = record.profileCaptureKey; + record.profileHandle = profileHandle; + void profileHandle.then( + (handle) => { + if ( + record.profileCaptureKey === profileCaptureKey && + record.profileHandle === profileHandle + ) { + record.resolvedProfileHandle = handle; + } + }, + () => undefined ); - record.profileHandle = record.worker.startCpuProfile(); } }; - const finalizeWorker = (record: WorkerRecord): Promise => { - record.finalizing ??= (async () => { - if (record.sampleTimer) { - clearInterval(record.sampleTimer); - record.sampleTimer = null; + const stopWorkerSampling = (record: WorkerRecord): void => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + record.sampleTimer = null; + } + }; + const joinFinalWorkerSample = async ( + record: WorkerRecord + ): Promise => { + if (record.samplePromise) { + await record.samplePromise; + } + await sampleWorker(record); + }; + const stopWorkerProfile = async ( + record: WorkerRecord, + waitForHandle = true + ): Promise => { + const profileCaptureKey = record.profileCaptureKey; + const profileHandle = record.profileHandle; + if (!profileHandle || !record.profilePath) { + return; + } + const handle = + record.resolvedProfileHandle ?? + (waitForHandle ? await profileHandle : null); + if (!handle) { + throw new Error( + 'cpu-profile-handle-not-ready-before-worker-termination' + ); + } + const profileResult = await handle.stop(); + if ( + record.profileCaptureKey !== profileCaptureKey || + record.finalizationTimedOut + ) { + return; + } + record.profileResult = profileResult; + }; + const writeWorkerProfile = (record: WorkerRecord): void => { + if (!record.profileHandle || !record.profilePath) { + return; + } + if (record.profileResult === null) { + throw new Error('worker-cpu-profile-result-missing'); + } + fs.writeFileSync( + record.profilePath, + JSON.stringify(normalizeWorkerCpuProfile(record.profileResult)) + ); + record.profileHandle = null; + record.profileResult = null; + record.resolvedProfileHandle = null; + }; + const flushWorkerProfiles = (workerRecords: WorkerRecord[]): void => { + for (const record of workerRecords) { + try { + writeWorkerProfile(record); + } catch (error: unknown) { + reportWorkerArtifactFailure(record, 'profile-write', error); } - await sampleWorker(record); - if (record.profileHandle && record.profilePath) { - const handle = await record.profileHandle; - const profile = await handle.stop(); - fs.writeFileSync( - record.profilePath, - JSON.stringify(normalizeWorkerCpuProfile(profile)) - ); - } - if ( - state.diagnostic && - typeof record.worker.getHeapSnapshot === 'function' - ) { - record.snapshotPath = path.join( - state.outputDirectory, - `${record.kind}.heapsnapshot` - ); - const snapshot = await record.worker.getHeapSnapshot(); - await streamPromises.pipeline( - snapshot, - fs.createWriteStream(record.snapshotPath) - ); - const postSnapshot = - await record.worker.getHeapStatistics?.(); - record.postGcHeapUsed = Number( - postSnapshot?.used_heap_size ?? 0 - ); - } - record.finalized = true; - })().catch((error: unknown) => { - recordTimeline({ - type: `worker-profile-error:${ - error instanceof Error - ? error.message.slice(0, 160) - : String(error).slice(0, 160) - }`, - }); - record.finalized = true; + } + }; + const takeWorkerHeapSnapshot = async ( + record: WorkerRecord + ): Promise => { + if (typeof record.worker.getHeapSnapshot !== 'function') { + return; + } + record.snapshotPath = path.join( + state.outputDirectory, + `${record.kind}-${record.ordinal}.heapsnapshot` + ); + const snapshot = await record.worker.getHeapSnapshot(); + await streamPromises.pipeline( + snapshot, + fs.createWriteStream(record.snapshotPath) + ); + }; + const reportWorkerArtifactFailure = ( + record: WorkerRecord, + stage: DatabaseWorkerPostGcAncillaryFailureStage, + error: unknown + ): void => { + if (stage === 'heap-snapshot') { + record.snapshotPath = null; + } else if (stage === 'profile-stop' || stage === 'profile-write') { + record.profilePath = null; + record.profileResult = null; + record.profileHandle = null; + record.resolvedProfileHandle = null; + } + recordTimeline({ + type: `worker-artifact-error:${stage}:${ + error instanceof Error + ? error.message.slice(0, 160) + : String(error).slice(0, 160) + }`, }); + }; + const finalizeDatabaseWorker = ( + record: WorkerRecord, + selectionUnavailableReason: DatabaseWorkerPostGcUnavailableReason | null + ): Promise => { + record.finalizing ??= databaseWorkerPostGcFinalizationApi + .finalize({ + finalizationKey: record.finalizationKey, + joinFinalSample: () => joinFinalWorkerSample(record), + probePostGc: () => + selectionUnavailableReason === null + ? databaseWorkerPostGcProbeApi.probe({ + createMessageChannel: () => + new workerThreads.MessageChannel(), + worker: { + postMessage( + message: unknown, + transferList: readonly unknown[] + ): void { + record.worker.postMessage( + message, + transferList as readonly WorkerTransferable[] + ); + }, + }, + }) + : Promise.resolve({ + postGcHeapUsedBytes: null, + unavailableReason: selectionUnavailableReason, + }), + reportAncillaryFailure: (stage, error) => + reportWorkerArtifactFailure(record, stage, error), + stopProfile: () => stopWorkerProfile(record), + stopSampling: () => stopWorkerSampling(record), + ...(state.diagnostic + ? { + takeHeapSnapshot: () => + takeWorkerHeapSnapshot(record), + } + : {}), + }) + .then((outcome) => { + record.postGcHeapUsed = outcome.postGcHeapUsedBytes; + record.postGcHeapUnavailableReason = + outcome.unavailableReason; + record.finalized = true; + }) + .catch((error: unknown) => { + record.postGcHeapUsed = null; + record.postGcHeapUnavailableReason = 'capture-failed'; + reportWorkerArtifactFailure(record, 'post-gc-probe', error); + record.finalized = true; + }); return record.finalizing; }; + const finalizeTerminatingWorker = ( + record: WorkerRecord, + waitForProfileHandle: boolean + ): Promise => { + if (record.finalizing) { + return record.finalizing; + } + const finalizationKey = record.finalizationKey; + record.finalizing = (async () => { + stopWorkerSampling(record); + record.postGcHeapUsed = null; + record.postGcHeapUnavailableReason = + 'worker-force-terminated-before-gc'; + try { + await stopWorkerProfile(record, waitForProfileHandle); + } catch (error: unknown) { + if ( + record.finalizationKey === finalizationKey && + !record.finalizationTimedOut + ) { + reportWorkerArtifactFailure( + record, + 'profile-stop', + error + ); + } + } + if (record.finalizationKey === finalizationKey) { + record.finalized = true; + } + })(); + return record.finalizing; + }; + const waitForTerminatingWorkerFinalization = async ( + record: WorkerRecord + ): Promise => { + if (!record.finalizing || record.finalizationTimedOut) { + return; + } + let timeout: NodeJS.Timeout | null = null; + let timedOut = false; + await Promise.race([ + record.finalizing, + new Promise((resolve) => { + timeout = setTimeout(() => { + timedOut = true; + resolve(); + }, 5_000); + }), + ]); + if (timeout) { + clearTimeout(timeout); + } + if (timedOut) { + record.finalizationTimedOut = true; + record.profileCaptureKey = {}; + reportWorkerArtifactFailure( + record, + 'profile-stop', + new Error('worker-profile-finalization-timeout') + ); + } + }; WorkerClass.prototype.postMessage = function ( this: InstrumentedWorker, @@ -455,8 +895,38 @@ export async function installMainCapture( if (value['type'] === 'request') { const kind = classifyRequest(value); if (kind) { + const requestDisposition = + kind === 'database.worker' + ? databaseWorkerPostGcCutoffApi.observeDatabaseRequest() + : null; + if (requestDisposition === 'after-cutoff') { + state.timeline.push({ + epochMs: nowEpochMs(), + operation: + typeof value['operation'] === 'string' + ? value['operation'] + : undefined, + playlistId: + readDatabasePlaylistId(value) ?? undefined, + requestId: + typeof value['requestId'] === 'string' + ? value['requestId'] + : undefined, + type: 'db-request-after-capture-cutoff', + }); + originalPostMessage.call( + this, + message, + transferList + ); + return; + } const record = createWorkerRecord(this, kind); - if (kind === 'playlist-refresh.worker') { + startWorker(record); + if ( + isCurrentCaptureRecord(record) && + kind === 'playlist-refresh.worker' + ) { const payload = value['payload'] as JsonRecord; record.operationId = String(payload['operationId']); record.playlistId = String(payload['playlistId']); @@ -467,23 +937,53 @@ export async function installMainCapture( type: 'playlist-request', }); } else if ( + isCurrentCaptureRecord(record) && typeof value['requestId'] === 'string' && typeof value['operation'] === 'string' ) { + const payload = value['payload']; + const payloadOperationId = + typeof payload === 'object' && + payload !== null && + typeof (payload as JsonRecord)[ + 'operationId' + ] === 'string' && + String((payload as JsonRecord)['operationId']) + .length > 0 + ? String( + (payload as JsonRecord)['operationId'] + ) + : null; + const identity = + payloadOperationId === null + ? databaseRequestIdentityCapture.matchDatabaseRequest( + value + ) + : { + ipcCallId: null, + operationId: payloadOperationId, + operationIdUnavailableReason: null, + sourceEpochMs: null, + }; + record.pendingCount += 1; dbRequests.set(value['requestId'], { + identity, operation: value['operation'], playlistId: readDatabasePlaylistId(value), record, }); recordTimeline({ + ipcCallId: identity.ipcCallId ?? undefined, operation: value['operation'], + operationId: identity.operationId ?? undefined, playlistId: readDatabasePlaylistId(value) ?? undefined, requestId: value['requestId'], + sourceEpochMs: + identity.sourceEpochMs ?? undefined, type: 'db-request', }); } - startWorker(record); } } else if ( value['type'] === 'cancel' && @@ -505,10 +1005,20 @@ export async function installMainCapture( this: InstrumentedWorker ): Promise { const record = records.get(this); - if (!record || !state.active) { + if (!record || !isCurrentCaptureRecord(record)) { return originalTerminate.call(this); } + const terminationGeneration = record.captureGeneration; const markTerminated = (code: number): number => { + if ( + !workerTerminationGenerationApi.isCurrent({ + capturedGeneration: terminationGeneration, + currentGeneration: state.captureGeneration, + recordGeneration: record.captureGeneration, + }) + ) { + return code; + } record.terminatedEpochMs = nowEpochMs(); record.finalized = true; recordTimeline({ @@ -518,16 +1028,17 @@ export async function installMainCapture( }); return code; }; - if (!state.diagnostic) { - if (record.sampleTimer) { - clearInterval(record.sampleTimer); - record.sampleTimer = null; - } - return originalTerminate.call(this).then(markTerminated); + if (state.diagnostic) { + const diagnosticTermination = (async (): Promise => { + void finalizeTerminatingWorker(record, true); + await waitForTerminatingWorkerFinalization(record); + return originalTerminate.call(this); + })(); + return diagnosticTermination.then(markTerminated); } - return finalizeWorker(record) - .then(() => originalTerminate.call(this)) - .then(markTerminated); + const termination = originalTerminate.call(this); + void finalizeTerminatingWorker(record, false); + return termination.then(markTerminated); }; const inspectorPost = ( @@ -585,36 +1096,70 @@ export async function installMainCapture( const memory = process.memoryUsage(); state.mainPeakHeap = Math.max(state.mainPeakHeap, memory.heapUsed); state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss); - for (const metric of app.getAppMetrics()) { - const type = String(metric.type).toLowerCase(); - if (type.includes('tab') || type.includes('renderer')) { - state.rendererPeakRss = Math.max( - state.rendererPeakRss, - Number(metric.memory?.workingSetSize ?? 0) * 1024 - ); - } - } + state.rendererWindowSession?.sample(); }; - const attachWindowListeners = (): void => { - for (const window of BrowserWindow.getAllWindows()) { - const unresponsive = () => { - state.unresponsiveEvents += 1; - }; - const responsive = () => { - state.responsiveEvents += 1; - }; - window.on('unresponsive', unresponsive); - window.on('responsive', responsive); - state.windowListeners.push({ - responsive, - unresponsive, - window, - }); + const startCapture = async ( + options: MainCaptureStartOptions + ): Promise => { + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + const rendererWindowSession = rendererWindowRssSessionApi.create({ + browserWindowFromId: (browserWindowId) => + BrowserWindow.fromId(browserWindowId), + browserWindowId: options.rendererWindowIdentity.browserWindowId, + getAppMetrics: () => app.getAppMetrics(), + rendererRssApi: rendererProcessRssApi, + webContentsId: options.rendererWindowIdentity.webContentsId, + }); + state.stopping = false; + state.captureGeneration += 1; + state.active = true; + databaseRequestIdentityCapture.start(); + dbRequests.clear(); + operationWorkers.clear(); + state.diagnostic = options.diagnostic; + state.outputDirectory = options.outputDirectory; + state.timeline = []; + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + state.mainProfilePath = null; + state.mainSnapshotPath = null; + state.postGcHeap = null; + state.postGcRss = null; + state.rendererWindowSession = rendererWindowSession; + state.cpuStart = process.cpuUsage(); + state.eventLoopStart = perfHooks.performance.eventLoopUtilization(); + state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ + resolution: 1, + }); + state.eventLoopDelay.enable(); + sampleMain(); + state.sampleTimer = setInterval(sampleMain, 20); + if (state.diagnostic) { + const session = new inspector.Session(); + session.connect(); + state.inspectorSession = session; + await inspectorPost(session, 'Profiler.enable'); + await inspectorPost(session, 'Profiler.start'); + state.mainProfilePath = path.join( + state.outputDirectory, + 'main.cpuprofile' + ); + state.mainSnapshotPath = path.join( + state.outputDirectory, + 'main.heapsnapshot' + ); } }; const api = { status: (): MainCaptureStatus => ({ + databaseGetsCompleted: state.timeline.filter( + (entry) => + entry['type'] === 'db-response' && + entry['operation'] === 'DB_GET_APP_PLAYLIST' && + entry['success'] === true + ).length, databasePending: dbRequests.size, databaseRequests: state.timeline.filter( (entry) => entry['type'] === 'db-request' @@ -627,6 +1172,7 @@ export async function installMainCapture( ).length, playlistTerminated: [...records.values()].filter( (record) => + isCurrentCaptureRecord(record) && record.kind === 'playlist-refresh.worker' && record.terminatedEpochMs !== null ).length, @@ -643,52 +1189,52 @@ export async function installMainCapture( entry['type'] === 'playlist-response' && entry['success'] === true ).length, + preloadSuccessMarkers: + databaseRequestIdentityCapture.successMarkerCount(), }), start: async (options: MainCaptureStartOptions): Promise => { - state.active = true; - state.diagnostic = options.diagnostic; - state.outputDirectory = options.outputDirectory; - state.timeline = []; - state.mainPeakHeap = 0; - state.mainPeakRss = 0; - state.rendererPeakRss = 0; - state.postGcHeap = null; - state.postGcRss = null; - state.unresponsiveEvents = 0; - state.responsiveEvents = 0; - state.cpuStart = process.cpuUsage(); - state.eventLoopStart = - perfHooks.performance.eventLoopUtilization(); - state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ - resolution: 1, - }); - state.eventLoopDelay.enable(); - attachWindowListeners(); - sampleMain(); - state.sampleTimer = setInterval(sampleMain, 20); - if (state.diagnostic) { - const session = new inspector.Session(); - session.connect(); - state.inspectorSession = session; - await inspectorPost(session, 'Profiler.enable'); - await inspectorPost(session, 'Profiler.start'); - state.mainProfilePath = path.join( - state.outputDirectory, - 'main.cpuprofile' - ); - state.mainSnapshotPath = path.join( - state.outputDirectory, - 'main.heapsnapshot' - ); - } + databaseWorkerPostGcCutoffApi.beginCapture(); + await startCapture(options); }, - stop: async (): Promise => { + stop: async ( + nextOptions?: MainCaptureStartOptions + ): Promise => { + databaseWorkerPostGcCutoffApi.beginStop(); + state.stopping = true; + state.active = false; + for (const marker of databaseRequestIdentityCapture.takeSuccessMarkers()) { + state.timeline.push({ + epochMs: marker.sourceEpochMs, + ipcCallId: marker.ipcCallId, + operation: marker.operation, + operationId: marker.operationId ?? undefined, + playlistId: marker.playlistId, + sourceEpochMs: marker.sourceEpochMs, + success: true, + type: 'preload-performance-success', + }); + } + databaseRequestIdentityCapture.stop(); if (state.sampleTimer) { clearInterval(state.sampleTimer); state.sampleTimer = null; } + const currentWorkerRecords = [...records.values()].filter( + (record) => + record.captureGeneration === state.captureGeneration + ); + for (const record of currentWorkerRecords) { + stopWorkerSampling(record); + } state.eventLoopDelay?.disable(); sampleMain(); + const rendererWindow: RendererWindowRssSessionMetrics | null = + state.rendererWindowSession?.snapshot() ?? null; + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + if (rendererWindow === null) { + throw new Error('renderer-window-session-missing'); + } const cpu = process.cpuUsage(state.cpuStart ?? undefined); const eluEnd = perfHooks.performance.eventLoopUtilization(); const elu = perfHooks.performance.eventLoopUtilization( @@ -703,14 +1249,6 @@ export async function installMainCapture( ? null : elu.utilization; const delay = state.eventLoopDelay; - const databaseRecords = [...records.values()].filter( - (record) => - record.kind === 'database.worker' && - record.sampleTimer !== null - ); - await Promise.all( - databaseRecords.map((record) => finalizeWorker(record)) - ); const session = state.inspectorSession ?? new inspector.Session(); if (!state.inspectorSession) { @@ -726,6 +1264,41 @@ export async function installMainCapture( JSON.stringify(result['profile']) ); } + await Promise.all( + currentWorkerRecords + .filter( + (record) => + record.kind === 'playlist-refresh.worker' && + record.finalizing !== null + ) + .map((record) => + waitForTerminatingWorkerFinalization(record) + ) + ); + const databaseSelection = + databaseWorkerPostGcSelectionApi.select( + currentWorkerRecords, + state.captureGeneration + ); + const currentDatabaseRecords = currentWorkerRecords.filter( + (record) => record.kind === 'database.worker' + ); + if (databaseSelection.selected) { + await finalizeDatabaseWorker( + databaseSelection.selected, + null + ); + } else if (databaseSelection.unavailableReason !== null) { + await Promise.all( + currentDatabaseRecords.map((record) => + finalizeDatabaseWorker( + record, + databaseSelection.unavailableReason + ) + ) + ); + } + flushWorkerProfiles(currentWorkerRecords); await inspectorPost(session, 'HeapProfiler.enable'); await inspectorPost(session, 'HeapProfiler.collectGarbage'); const postGc = process.memoryUsage(); @@ -750,84 +1323,163 @@ export async function installMainCapture( } session.disconnect(); state.inspectorSession = null; - for (const listener of state.windowListeners) { - listener.window.off('unresponsive', listener.unresponsive); - listener.window.off('responsive', listener.responsive); + const cutoff = databaseWorkerPostGcCutoffApi.snapshot(); + if (cutoff.lateRequestCount > 0) { + for (const record of currentDatabaseRecords) { + record.postGcHeapUsed = null; + record.postGcHeapUnavailableReason = + 'database-worker-activity-after-cutoff'; + } } - state.windowListeners = []; - state.active = false; - const workers = [...records.values()] + const workers = currentWorkerRecords .filter( (record) => - record.kind === 'playlist-refresh.worker' || - record.heapPeak > 0 + record.kind === 'database.worker' || + record.kind === 'playlist-refresh.worker' ) .map((record) => ({ - cancelPostedEpochMs: record.cancelPostedEpochMs, - cpuSystemMicros: - record.cpuFirst && record.cpuLast - ? record.cpuLast.system - record.cpuFirst.system - : null, - cpuUserMicros: - record.cpuFirst && record.cpuLast - ? record.cpuLast.user - record.cpuFirst.user - : null, - eventLoopDelay: record.eventLoopDelay, - eventLoopDelayUnavailableReason: - record.eventLoopDelay === null - ? record.terminatedEpochMs !== null - ? 'worker-terminated-before-profile-flush' - : 'worker-self-profile-result-unavailable' - : null, - eventLoopUtilization: record.elu, - kind: record.kind, - operationId: record.operationId, - peakExternalBytes: record.externalPeak, - peakHeapUsedBytes: record.heapPeak, - playlistId: record.playlistId, - postGcHeapUsedBytes: record.postGcHeapUsed, - profilePath: record.profilePath, - responseEpochMs: record.responseEpochMs, - snapshotPath: record.snapshotPath, - terminatedEpochMs: record.terminatedEpochMs, + captureGeneration: record.captureGeneration, + metrics: { + cancelPostedEpochMs: record.cancelPostedEpochMs, + cpuSystemMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.system - + record.cpuFirst.system + : null, + cpuUserMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.user - record.cpuFirst.user + : null, + eventLoopUtilization: record.elu, + externalMemorySampleCount: + record.externalMemorySampleCount, + heapUsedSampleCount: record.heapUsedSampleCount, + kind: record.kind, + operationId: record.operationId, + ordinal: record.ordinal, + peakExternalBytes: + record.externalMemorySampleCount > 0 + ? record.externalPeak + : null, + peakExternalUnavailableReason: + record.externalMemorySampleCount > 0 + ? null + : 'worker-external-memory-samples-missing', + peakHeapUsedBytes: + record.heapUsedSampleCount > 0 + ? record.heapPeak + : null, + peakHeapUsedUnavailableReason: + record.heapUsedSampleCount > 0 + ? null + : 'worker-heap-used-samples-missing', + playlistId: record.playlistId, + postGcHeapUnavailableReason: + record.postGcHeapUnavailableReason, + postGcHeapUsedBytes: record.postGcHeapUsed, + profilePath: record.profilePath, + responseEpochMs: record.responseEpochMs, + snapshotPath: record.snapshotPath, + terminatedEpochMs: record.terminatedEpochMs, + }, + requests: record.requestPerformance.map((request) => ({ + ipcCallId: request.identity.ipcCallId, + operation: request.operation, + operationId: request.identity.operationId, + operationIdUnavailableReason: + request.identity.operationIdUnavailableReason, + performanceCapture: request.performanceCapture, + playlistId: request.playlistId, + requestId: request.requestId, + responseEpochMs: request.responseEpochMs, + sourceEpochMs: request.identity.sourceEpochMs, + success: request.success, + })), })); - return { - cpuProfilePath: state.mainProfilePath, - cpuSystemMicros: cpu.system, - cpuUserMicros: cpu.user, - eventLoopDelay: { - maxMs: Number(delay?.max ?? 0) / 1e6, - p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, - p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + const transport: MainCaptureGenerationTransport = { + captureGeneration: state.captureGeneration, + metrics: { + cpuProfilePath: state.mainProfilePath, + cpuSystemMicros: cpu.system, + cpuUserMicros: cpu.user, + eventLoopDelay: { + maxMs: Number(delay?.max ?? 0) / 1e6, + p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, + p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + }, + // Electron's Chromium-owned main message pump currently + // leaves Node's libuv ELU counters at zero. Preserve that + // as unavailable instead of reporting a misleading 0%. + eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + eventLoopUtilization === null + ? 'electron-main-embedded-event-loop' + : null, + heapSnapshotPath: state.mainSnapshotPath, + memory: { + peakHeapUsedBytes: state.mainPeakHeap, + peakRssBytes: state.mainPeakRss, + postGcHeapUsedBytes: state.postGcHeap, + postGcRssBytes: state.postGcRss, + }, + rendererWindow, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: state.timeline, }, - // Electron's Chromium-owned main message pump currently - // leaves Node's libuv ELU counters at zero. Preserve that - // as unavailable instead of reporting a misleading 0%. - eventLoopUtilization, - eventLoopUtilizationUnavailableReason: - eventLoopUtilization === null - ? 'electron-main-embedded-event-loop' - : null, - heapSnapshotPath: state.mainSnapshotPath, - memory: { - peakHeapUsedBytes: state.mainPeakHeap, - peakRssBytes: state.mainPeakRss, - postGcHeapUsedBytes: state.postGcHeap, - postGcRssBytes: state.postGcRss, - }, - rendererPeakRssBytes: state.rendererPeakRss, - responsiveEvents: state.responsiveEvents, - rssScope: - 'electron-main-process-including-worker-threads-and-native-memory', - timeline: state.timeline, - unresponsiveEvents: state.unresponsiveEvents, workers, }; + let rollover: MainCaptureRolloverStatus | null = null; + if (nextOptions) { + const databaseRecord = + currentDatabaseRecords.length === 1 + ? currentDatabaseRecords[0] + : null; + const nextCaptureUnavailableReason = + cutoff.lateRequestCount > 0 + ? 'database-worker-activity-after-cutoff' + : dbRequests.size > 0 + ? 'database-worker-not-idle' + : currentDatabaseRecords.length === 0 + ? 'database-worker-missing' + : currentDatabaseRecords.length > 1 + ? 'multiple-database-workers' + : !databaseRecord || + !Number.isSafeInteger( + databaseRecord.postGcHeapUsed + ) || + Number(databaseRecord.postGcHeapUsed) < + 0 || + databaseRecord.postGcHeapUnavailableReason !== + null + ? (databaseRecord?.postGcHeapUnavailableReason ?? + 'post-gc-capture-invalid') + : null; + if (nextCaptureUnavailableReason === null) { + databaseWorkerPostGcCutoffApi.rolloverCapture(); + await startCapture(nextOptions); + rollover = { + nextCaptureStarted: true, + nextCaptureUnavailableReason: null, + }; + } else { + databaseWorkerPostGcCutoffApi.finishStop(); + state.stopping = false; + rollover = { + nextCaptureStarted: false, + nextCaptureUnavailableReason, + }; + } + } else { + databaseWorkerPostGcCutoffApi.finishStop(); + state.stopping = false; + } + return { ...transport, rollover }; }, }; - target[stateKey] = api; - }, MAIN_CAPTURE_STATE_KEY); + target[input.stateKey] = api; + }, captureStateKeys); } export async function startMainCapture( @@ -858,14 +1510,45 @@ export async function readMainCaptureStatus( }, MAIN_CAPTURE_STATE_KEY); } +export async function rolloverMainCapture( + electronApp: ElectronApplication, + options: MainCaptureStartOptions +): Promise { + const transport = await electronApp.evaluate( + async (_electron, input) => { + const target = globalThis as unknown as Record; + const api = target[input.stateKey] as { + stop( + nextOptions?: MainCaptureStartOptions + ): Promise; + }; + return api.stop(input.options); + }, + { options, stateKey: MAIN_CAPTURE_STATE_KEY } + ); + if (transport.rollover === null) { + throw new Error('main-capture-rollover-status-missing'); + } + return Object.freeze({ + completedCapture: selectMainCaptureGeneration(transport), + nextCaptureStarted: transport.rollover.nextCaptureStarted, + nextCaptureUnavailableReason: + transport.rollover.nextCaptureUnavailableReason, + }); +} + export async function stopMainCapture( electronApp: ElectronApplication ): Promise { - return electronApp.evaluate(async (_electron, stateKey) => { - const target = globalThis as unknown as Record; - const api = target[stateKey] as { - stop(): Promise; - }; - return api.stop(); - }, MAIN_CAPTURE_STATE_KEY); + const transport = await electronApp.evaluate( + async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + stop(): Promise; + }; + return api.stop(); + }, + MAIN_CAPTURE_STATE_KEY + ); + return selectMainCaptureGeneration(transport); } diff --git a/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts new file mode 100644 index 000000000..4ca080932 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts @@ -0,0 +1,124 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { dirname, resolve } from 'node:path'; + +import { + assertPerformanceArtifactCapacity, + MIN_PERFORMANCE_ARTIFACT_FREE_BYTES, +} from './performance-artifact-preflight'; + +test('checks the nearest existing ancestor using available blocks', async () => { + const requestedPath = resolve( + '/workspace/dist/performance/2026-07-26/baseline' + ); + const checkedPaths: string[] = []; + const existingAncestor = dirname(dirname(requestedPath)); + + const result = await assertPerformanceArtifactCapacity(requestedPath, { + statfs: async (path) => { + checkedPaths.push(path); + if (path !== existingAncestor) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + } + return { + bavail: 4_194_304n, + bsize: 1_024n, + }; + }, + }); + + assert.deepEqual(checkedPaths, [ + requestedPath, + dirname(requestedPath), + existingAncestor, + ]); + assert.deepEqual(result, { + availableBytes: 4_294_967_296n, + checkedPath: existingAncestor, + minimumBytes: MIN_PERFORMANCE_ARTIFACT_FREE_BYTES, + }); +}); + +test('accepts exactly two GiB of available capacity', async () => { + const result = await assertPerformanceArtifactCapacity('/existing', { + statfs: async () => ({ + bavail: 2_097_152n, + bsize: 1_024n, + }), + }); + + assert.equal(result.availableBytes, MIN_PERFORMANCE_ARTIFACT_FREE_BYTES); +}); + +test('fails before artifact creation when available capacity is below two GiB', async () => { + await assert.rejects( + assertPerformanceArtifactCapacity('/existing', { + statfs: async () => ({ + bavail: 2_097_151n, + bsize: 1_024n, + }), + }), + /requires at least 2147483648 available bytes.*2147482624/ + ); +}); + +test('does not substitute free blocks that are unavailable to the process', async () => { + await assert.rejects( + assertPerformanceArtifactCapacity('/existing', { + statfs: async () => ({ + bavail: 1n, + bfree: 9_999_999_999n, + bsize: 1_024n, + }), + }), + /performance-artifact-space-insufficient/ + ); +}); + +test('does not hide filesystem errors other than a missing path', async () => { + const denied = Object.assign(new Error('denied'), { code: 'EACCES' }); + let calls = 0; + + await assert.rejects( + assertPerformanceArtifactCapacity('/denied/child', { + statfs: async () => { + calls += 1; + throw denied; + }, + }), + denied + ); + assert.equal(calls, 1); +}); + +test('the benchmark preflights capacity before creating output directories', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const runIteration = source.slice( + source.indexOf('async function runIteration('), + source.indexOf('async function seedPlaylist(') + ); + const resolveConfiguration = source.slice( + source.indexOf('async function resolveConfiguration('), + source.indexOf('function readGitSourceState(') + ); + + for (const [name, section] of [ + ['iteration', runIteration], + ['variant', resolveConfiguration], + ] as const) { + const preflight = section.indexOf( + 'await assertPerformanceArtifactCapacity(' + ); + const createDirectory = section.indexOf('await mkdir('); + assert.ok(preflight >= 0, `${name} preflight is missing`); + assert.ok( + preflight < createDirectory, + `${name} preflight must run before mkdir` + ); + } +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts new file mode 100644 index 000000000..7c0433854 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts @@ -0,0 +1,76 @@ +import { statfs } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +export const MIN_PERFORMANCE_ARTIFACT_FREE_BYTES = + 2n * 1_024n * 1_024n * 1_024n; + +interface FileSystemCapacity { + readonly bavail: bigint; + readonly bsize: bigint; +} + +interface PerformanceArtifactPreflightDependencies { + readonly statfs?: (path: string) => Promise; +} + +export interface PerformanceArtifactCapacity { + readonly availableBytes: bigint; + readonly checkedPath: string; + readonly minimumBytes: bigint; +} + +async function readFileSystemCapacity( + path: string +): Promise { + const capacity = await statfs(path, { bigint: true }); + return { + bavail: capacity.bavail, + bsize: capacity.bsize, + }; +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} + +export async function assertPerformanceArtifactCapacity( + targetPath: string, + dependencies: PerformanceArtifactPreflightDependencies = {} +): Promise { + const readCapacity = dependencies.statfs ?? readFileSystemCapacity; + let checkedPath = resolve(targetPath); + + while (true) { + try { + const capacity = await readCapacity(checkedPath); + const availableBytes = capacity.bavail * capacity.bsize; + if (availableBytes < MIN_PERFORMANCE_ARTIFACT_FREE_BYTES) { + throw new Error( + `performance-artifact-space-insufficient: benchmark requires at least ${MIN_PERFORMANCE_ARTIFACT_FREE_BYTES} available bytes; ${availableBytes} available at ${checkedPath}` + ); + } + return Object.freeze({ + availableBytes, + checkedPath, + minimumBytes: MIN_PERFORMANCE_ARTIFACT_FREE_BYTES, + }); + } catch (error) { + if (!isMissingPath(error)) { + throw error; + } + const parent = dirname(checkedPath); + if (parent === checkedPath) { + throw new Error( + `performance-artifact-space-unavailable: no existing ancestor for ${targetPath}`, + { cause: error } + ); + } + checkedPath = parent; + } + } +} diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts new file mode 100644 index 000000000..34f157d5a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -0,0 +1,303 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based configuration contract tests. */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { join } from 'node:path'; + +interface TargetConfiguration { + configurations?: Record>; + dependsOn?: unknown; + executor?: unknown; + options?: Record; +} + +interface ProjectConfiguration { + targets: Record; +} + +interface NxGraphTask { + outputs?: string[]; + target: { + project: string; + target: string; + }; +} + +interface NxGraph { + tasks: { + tasks: Record; + }; +} + +const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + +function readProject(relativePath: string): ProjectConfiguration { + return JSON.parse( + readFileSync(join(workspaceRoot, relativePath), 'utf8') + ) as ProjectConfiguration; +} + +function readResolvedWebBuildTask(): NxGraphTask | undefined { + const environment = { + ...process.env, + FORCE_COLOR: '0', + NX_DAEMON: 'false', + }; + delete environment['NO_COLOR']; + + const graph = JSON.parse( + execFileSync( + process.execPath, + [ + join(workspaceRoot, 'node_modules/nx/dist/bin/nx.js'), + 'run', + 'web:build', + '--graph=stdout', + ], + { + cwd: workspaceRoot, + encoding: 'utf8', + env: environment, + } + ) + ) as NxGraph; + + return Object.values(graph.tasks.tasks).find( + (task) => + task.target.project === 'web' && task.target.target === 'build' + ); +} + +const e2eProject = readProject('apps/electron-backend-e2e/project.json'); +const electronProject = readProject('apps/electron-backend/project.json'); +const remoteControlProject = readProject( + 'apps/remote-control-web/project.json' +); +const webProject = readProject('apps/web/project.json'); + +test('the performance harness target runs only Node performance specs', () => { + const target = e2eProject.targets['test-performance-harness']; + + assert.ok( + target, + 'electron-backend-e2e must define test-performance-harness' + ); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal(target.options?.['cwd'], 'apps/electron-backend-e2e'); + assert.equal( + target.options?.['command'], + 'pnpm exec tsx --test src/performance/*.spec.ts' + ); +}); + +test('the web performance build keeps production renderer behavior with profiling source maps', () => { + const build = webProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'web:build must define production'); + assert.ok(performance, 'web:build must define electron-performance'); + assert.equal(performance['baseHref'], './'); + assert.equal(performance['serviceWorker'], false); + assert.deepEqual(performance['optimization'], production['optimization']); + assert.equal(performance['outputHashing'], production['outputHashing']); + assert.deepEqual( + performance['fileReplacements'], + production['fileReplacements'] + ); + assert.equal(performance['sourceMap'], true); +}); + +test('the resolved web build cache output is the renderer directory', () => { + const task = readResolvedWebBuildTask(); + + assert.ok(task, 'the Nx graph must include web:build'); + assert.deepEqual(task.outputs, ['dist/apps/web']); +}); + +test('the remote-control performance build keeps optimized hashed output with profiling source maps', () => { + const build = remoteControlProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'remote-control-web:build must define production'); + assert.ok( + performance, + 'remote-control-web:build must define electron-performance' + ); + assert.equal(performance['optimization'], true); + assert.equal(performance['outputHashing'], production['outputHashing']); + assert.equal(performance['sourceMap'], true); +}); + +test('the Electron performance build keeps production main-process behavior with profiling source maps', () => { + const build = electronProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'electron-backend:build must define production'); + assert.ok( + performance, + 'electron-backend:build must define electron-performance' + ); + assert.equal(performance['optimization'], production['optimization']); + assert.equal(performance['inspect'], production['inspect']); + assert.deepEqual( + performance['fileReplacements'], + production['fileReplacements'] + ); + assert.equal(performance['sourceMap'], true); +}); + +test('the regular Electron build keeps its existing renderer dependency contract', () => { + const build = electronProject.targets['build']; + const dependencies = build.dependsOn as Array< + Record | string + >; + const rendererBuild = dependencies.find( + (dependency): dependency is Record => + typeof dependency === 'object' && + dependency !== null && + dependency['target'] === 'build' + ); + + assert.deepEqual(rendererBuild, { + projects: ['web', 'remote-control-web'], + target: 'build', + }); +}); + +test('the web performance wrapper selects the profiling configuration', () => { + const target = webProject.targets['build-performance']; + + assert.ok(target, 'web must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'pnpm nx run web:build:electron-performance' + ); +}); + +test('the remote-control performance wrapper selects the profiling configuration', () => { + const target = remoteControlProject.targets['build-performance']; + + assert.ok(target, 'remote-control-web must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'pnpm nx run remote-control-web:build:electron-performance' + ); +}); + +test('the Electron performance wrapper owns build dependencies before its profiled build', () => { + const target = electronProject.targets['build-performance']; + + assert.ok(target, 'electron-backend must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.deepEqual(target.dependsOn, [ + 'electron-backend:build-worker-performance', + 'electron-backend:build-embedded-mpv', + { + projects: ['web', 'remote-control-web'], + target: 'build-performance', + }, + ]); + assert.equal( + target.options?.['command'], + 'pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies' + ); +}); + +test('the Electron performance wrapper builds optimized source-mapped workers', () => { + const target = electronProject.targets['build-worker-performance']; + const source = readFileSync( + join(workspaceRoot, 'apps/electron-backend/build-worker.js'), + 'utf8' + ); + + assert.ok(target, 'electron-backend must define build-worker-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'node apps/electron-backend/build-worker.js --performance' + ); + assert.match( + source, + /const isPerformance = process\.argv\.includes\('--performance'\)/ + ); + assert.match(source, /minify: isProduction \|\| isPerformance/); + assert.match(source, /sourcemap: isPerformance \|\| !isProduction/); +}); + +test('the cancellation benchmark uses the production-equivalent performance build', () => { + const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; + + assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']); +}); + +test('the cancellation benchmark command is pinned to its Playwright test file', () => { + const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; + + assert.equal( + target.options?.['command'], + 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts' + ); +}); + +test('the cancellation benchmark enables preload performance capture', () => { + const source = readFileSync( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/); +}); + +test('the cancellation benchmark exposes GC to Electron worker isolates', () => { + const source = readFileSync( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /'--js-flags=--expose-gc'/); + assert.doesNotMatch(source, /execArgv/); +}); + +test('the initial M3U import benchmark uses the production-equivalent performance build', () => { + const target = e2eProject.targets['benchmark-m3u-import']; + + assert.ok(target, 'electron-backend-e2e must define benchmark-m3u-import'); + assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']); +}); + +test('the initial M3U import benchmark command is pinned to its Playwright test file', () => { + const target = e2eProject.targets['benchmark-m3u-import']; + + assert.equal( + target.options?.['command'], + 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts' + ); +}); + +test('the initial M3U import benchmark enables opt-in capture and worker GC', () => { + const source = readFileSync( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/); + assert.match(source, /IPTVNATOR_PERF_WORKER_PROFILING:\s*'1'/); + assert.match(source, /'--js-flags=--expose-gc'/); + assert.doesNotMatch(source, /execArgv/); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts new file mode 100644 index 000000000..dd471db9b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + pairPerformancePhaseEvents, + requirePerformancePhase, +} from './performance-phase-events'; + +describe('performance phase event pairing', () => { + it('pairs request-scoped boundaries and preserves end metadata', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'data.acquire', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12.5, + epochMs: 113, + metadata: { byteCount: 2048, itemCount: 10 }, + phase: 'data.acquire', + requestId: 'request-1', + }, + ]); + + assert.deepEqual(phases, [ + { + durationMs: 12.5, + endedEpochMs: 113, + metadata: { byteCount: 2048, itemCount: 10 }, + phase: 'data.acquire', + requestId: 'request-1', + startedEpochMs: 100, + }, + ]); + assert.deepEqual( + requirePerformancePhase(phases, 'request-1', 'data.acquire'), + phases[0] + ); + }); + + it('pairs interleaved requests without cross-correlating them', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'serialize.playlist', + requestId: 'request-a', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 101, + phase: 'serialize.playlist', + requestId: 'request-b', + }, + { + boundary: 'end', + durationMs: 3, + epochMs: 104, + phase: 'serialize.playlist', + requestId: 'request-b', + }, + { + boundary: 'end', + durationMs: 8, + epochMs: 108, + phase: 'serialize.playlist', + requestId: 'request-a', + }, + ]); + + assert.deepEqual( + phases.map(({ durationMs, requestId }) => ({ + durationMs, + requestId, + })), + [ + { durationMs: 8, requestId: 'request-a' }, + { durationMs: 3, requestId: 'request-b' }, + ] + ); + }); + + it('fails closed for orphaned, duplicate, malformed, or reversed boundaries', () => { + const validStart = { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'normalize', + requestId: 'request-1', + } as const; + const validEnd = { + boundary: 'end', + durationMs: 2, + epochMs: 102, + phase: 'normalize', + requestId: 'request-1', + } as const; + + for (const invalid of [ + [validStart], + [validStart, validStart, validEnd], + [validStart, { ...validEnd, durationMs: null }], + [validStart, { ...validEnd, epochMs: 99 }], + [{ ...validStart, durationMs: 0 }, validEnd], + [ + validStart, + { + ...validEnd, + metadata: { byteCount: -1 }, + }, + ], + ]) { + assert.throws( + () => pairPerformancePhaseEvents(invalid), + /performance-phase-events-invalid/ + ); + } + }); + + it('requires one exact request and phase match', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'normalize', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 2, + epochMs: 102, + phase: 'normalize', + requestId: 'request-1', + }, + ]); + + assert.throws( + () => requirePerformancePhase(phases, 'request-2', 'normalize'), + /performance-phase-missing:request-2:normalize/ + ); + assert.throws( + () => + requirePerformancePhase( + [...phases, ...phases], + 'request-1', + 'normalize' + ), + /performance-phase-ambiguous:request-1:normalize/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts new file mode 100644 index 000000000..15468b018 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts @@ -0,0 +1,166 @@ +import type { PerformancePhaseMetadata } from '@iptvnator/shared/interfaces'; + +export interface PairedPerformancePhase { + readonly durationMs: number; + readonly endedEpochMs: number; + readonly metadata: PerformancePhaseMetadata | null; + readonly phase: string; + readonly requestId: string; + readonly startedEpochMs: number; +} + +interface ParsedPerformancePhaseEvent { + readonly boundary: 'end' | 'start'; + readonly durationMs: number | null; + readonly epochMs: number; + readonly metadata: PerformancePhaseMetadata | null; + readonly phase: string; + readonly requestId: string; +} + +export function pairPerformancePhaseEvents( + input: readonly unknown[] +): readonly PairedPerformancePhase[] { + const grouped = new Map(); + + for (const value of input) { + const event = parsePerformancePhaseEvent(value); + const key = `${event.requestId}\u0000${event.phase}`; + const events = grouped.get(key) ?? []; + events.push(event); + if (events.length > 2) { + throw invalidPhaseEvents(); + } + grouped.set(key, events); + } + + const paired: PairedPerformancePhase[] = []; + for (const events of grouped.values()) { + const start = events.find((event) => event.boundary === 'start'); + const end = events.find((event) => event.boundary === 'end'); + if ( + events.length !== 2 || + !start || + !end || + start.durationMs !== null || + start.metadata !== null || + end.durationMs === null || + end.epochMs < start.epochMs + ) { + throw invalidPhaseEvents(); + } + paired.push( + Object.freeze({ + durationMs: end.durationMs, + endedEpochMs: end.epochMs, + metadata: end.metadata, + phase: start.phase, + requestId: start.requestId, + startedEpochMs: start.epochMs, + }) + ); + } + + paired.sort( + (left, right) => + left.startedEpochMs - right.startedEpochMs || + left.endedEpochMs - right.endedEpochMs || + left.requestId.localeCompare(right.requestId) || + left.phase.localeCompare(right.phase) + ); + return Object.freeze(paired); +} + +export function requirePerformancePhase( + phases: readonly PairedPerformancePhase[], + requestId: string, + phase: string +): PairedPerformancePhase { + const matches = phases.filter( + (candidate) => + candidate.requestId === requestId && candidate.phase === phase + ); + if (matches.length === 0) { + throw new Error(`performance-phase-missing:${requestId}:${phase}`); + } + if (matches.length !== 1 || !matches[0]) { + throw new Error(`performance-phase-ambiguous:${requestId}:${phase}`); + } + return matches[0]; +} + +function parsePerformancePhaseEvent( + value: unknown +): ParsedPerformancePhaseEvent { + if (!isRecord(value)) { + throw invalidPhaseEvents(); + } + const boundary = value['boundary']; + const durationMs = value['durationMs']; + const epochMs = value['epochMs']; + const phase = value['phase']; + const requestId = value['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + (durationMs !== null && !isFiniteNonNegativeNumber(durationMs)) || + !isFiniteNonNegativeNumber(epochMs) || + typeof phase !== 'string' || + phase.length === 0 || + typeof requestId !== 'string' || + requestId.length === 0 + ) { + throw invalidPhaseEvents(); + } + + return { + boundary, + durationMs, + epochMs, + metadata: parseMetadata(value['metadata']), + phase, + requestId, + }; +} + +function parseMetadata(value: unknown): PerformancePhaseMetadata | null { + if (value === undefined) { + return null; + } + if (!isRecord(value)) { + throw invalidPhaseEvents(); + } + const keys = Object.keys(value); + if ( + keys.some((key) => key !== 'byteCount' && key !== 'itemCount') || + keys.some((key) => !isSafeCount(value[key])) + ) { + throw invalidPhaseEvents(); + } + return Object.freeze({ + ...(value['byteCount'] === undefined + ? {} + : { byteCount: Number(value['byteCount']) }), + ...(value['itemCount'] === undefined + ? {} + : { itemCount: Number(value['itemCount']) }), + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isSafeCount(value: unknown): boolean { + return ( + value === undefined || + (Number.isSafeInteger(value) && Number(value) >= 0) + ); +} + +function invalidPhaseEvents(): Error { + return new Error('performance-phase-events-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts new file mode 100644 index 000000000..10c078015 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + pairRendererPerformancePhaseEvents, + requireRendererPerformancePhase, +} from './renderer-performance-phase-events'; + +describe('renderer performance phase event pairing', () => { + it('pairs a successful renderer phase using monotonic duration', () => { + const phases = pairRendererPerformancePhaseEvents([ + { + boundary: 'start', + epochMs: 100, + metadata: { items: 10_000 }, + monotonicMs: 20, + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + schemaVersion: 1, + }, + { + boundary: 'end', + epochMs: 106, + metadata: { items: 10_000 }, + monotonicMs: 25.5, + outcome: 'success', + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + schemaVersion: 1, + }, + ]); + + assert.deepEqual(phases, [ + { + durationMs: 5.5, + endedEpochMs: 106, + metadata: { items: 10_000 }, + outcome: 'success', + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + startedEpochMs: 100, + }, + ]); + assert.deepEqual( + requireRendererPerformancePhase( + phases, + 'store.m3u-publish-channels' + ), + phases[0] + ); + }); + + it('fails closed for inconsistent identity, metadata, outcome, and clocks', () => { + const start = { + boundary: 'start', + epochMs: 100, + metadata: { items: 50_000 }, + monotonicMs: 20, + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + schemaVersion: 1, + } as const; + const end = { + boundary: 'end', + epochMs: 102, + metadata: { items: 50_000 }, + monotonicMs: 22, + outcome: 'success', + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + schemaVersion: 1, + } as const; + + for (const invalid of [ + [start], + [start, start, end], + [start, { ...end, phase: 'store.m3u-publish-channels' }], + [start, { ...end, metadata: { items: 10_000 } }], + [{ ...start, outcome: 'success' }, end], + [start, { ...end, outcome: undefined }], + [start, { ...end, epochMs: 99 }], + [start, { ...end, monotonicMs: 19 }], + [start, { ...end, metadata: { items: -1 } }], + ]) { + assert.throws( + () => pairRendererPerformancePhaseEvents(invalid), + /renderer-performance-phase-events-invalid/ + ); + } + }); + + it('requires exactly one successful event for a named phase', () => { + const successful = { + durationMs: 2, + endedEpochMs: 102, + metadata: { items: 10_000 }, + outcome: 'success', + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + startedEpochMs: 100, + } as const; + const failed = { + ...successful, + outcome: 'error', + phaseId: 'phase-2', + } as const; + + assert.throws( + () => + requireRendererPerformancePhase( + [successful], + 'store.m3u-publish-channels' + ), + /renderer-performance-phase-missing:store.m3u-publish-channels/ + ); + assert.throws( + () => + requireRendererPerformancePhase( + [successful, successful], + 'store.m3u-import-dispatch' + ), + /renderer-performance-phase-ambiguous:store.m3u-import-dispatch/ + ); + assert.throws( + () => + requireRendererPerformancePhase( + [failed], + 'store.m3u-import-dispatch' + ), + /renderer-performance-phase-failed:store.m3u-import-dispatch/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts new file mode 100644 index 000000000..23cb976e8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts @@ -0,0 +1,163 @@ +export interface PairedRendererPerformancePhase { + readonly durationMs: number; + readonly endedEpochMs: number; + readonly metadata: Readonly<{ items: number }> | null; + readonly outcome: 'error' | 'success'; + readonly phase: string; + readonly phaseId: string; + readonly startedEpochMs: number; +} + +interface ParsedRendererPerformancePhaseEvent { + readonly boundary: 'end' | 'start'; + readonly epochMs: number; + readonly metadata: Readonly<{ items: number }> | null; + readonly monotonicMs: number; + readonly outcome: 'error' | 'success' | null; + readonly phase: string; + readonly phaseId: string; +} + +export function pairRendererPerformancePhaseEvents( + input: readonly unknown[] +): readonly PairedRendererPerformancePhase[] { + const grouped = new Map(); + + for (const value of input) { + const event = parseRendererPerformancePhaseEvent(value); + const events = grouped.get(event.phaseId) ?? []; + events.push(event); + if (events.length > 2) { + throw invalidRendererEvents(); + } + grouped.set(event.phaseId, events); + } + + const paired: PairedRendererPerformancePhase[] = []; + for (const events of grouped.values()) { + const start = events.find((event) => event.boundary === 'start'); + const end = events.find((event) => event.boundary === 'end'); + if ( + events.length !== 2 || + !start || + !end || + start.outcome !== null || + end.outcome === null || + start.phase !== end.phase || + !sameMetadata(start.metadata, end.metadata) || + end.epochMs < start.epochMs || + end.monotonicMs < start.monotonicMs + ) { + throw invalidRendererEvents(); + } + paired.push( + Object.freeze({ + durationMs: end.monotonicMs - start.monotonicMs, + endedEpochMs: end.epochMs, + metadata: end.metadata, + outcome: end.outcome, + phase: end.phase, + phaseId: end.phaseId, + startedEpochMs: start.epochMs, + }) + ); + } + + paired.sort( + (left, right) => + left.startedEpochMs - right.startedEpochMs || + left.endedEpochMs - right.endedEpochMs || + left.phaseId.localeCompare(right.phaseId) + ); + return Object.freeze(paired); +} + +export function requireRendererPerformancePhase( + phases: readonly PairedRendererPerformancePhase[], + phase: string +): PairedRendererPerformancePhase { + const matches = phases.filter((candidate) => candidate.phase === phase); + if (matches.length === 0) { + throw new Error(`renderer-performance-phase-missing:${phase}`); + } + if (matches.length !== 1 || !matches[0]) { + throw new Error(`renderer-performance-phase-ambiguous:${phase}`); + } + if (matches[0].outcome !== 'success') { + throw new Error(`renderer-performance-phase-failed:${phase}`); + } + return matches[0]; +} + +function parseRendererPerformancePhaseEvent( + value: unknown +): ParsedRendererPerformancePhaseEvent { + if (!isRecord(value) || value['schemaVersion'] !== 1) { + throw invalidRendererEvents(); + } + const boundary = value['boundary']; + const epochMs = value['epochMs']; + const monotonicMs = value['monotonicMs']; + const outcome = value['outcome']; + const phase = value['phase']; + const phaseId = value['phaseId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + !isFiniteNonNegativeNumber(epochMs) || + !isFiniteNonNegativeNumber(monotonicMs) || + typeof phase !== 'string' || + phase.length === 0 || + typeof phaseId !== 'string' || + phaseId.length === 0 || + (boundary === 'start' && outcome !== undefined) || + (boundary === 'end' && outcome !== 'success' && outcome !== 'error') + ) { + throw invalidRendererEvents(); + } + + return { + boundary, + epochMs, + metadata: parseMetadata(value['metadata']), + monotonicMs, + outcome: outcome === 'success' || outcome === 'error' ? outcome : null, + phase, + phaseId, + }; +} + +function parseMetadata(value: unknown): Readonly<{ items: number }> | null { + if (value === undefined) { + return null; + } + if ( + !isRecord(value) || + Object.keys(value).length !== 1 || + !Number.isSafeInteger(value['items']) || + Number(value['items']) < 0 + ) { + throw invalidRendererEvents(); + } + return Object.freeze({ items: Number(value['items']) }); +} + +function sameMetadata( + left: Readonly<{ items: number }> | null, + right: Readonly<{ items: number }> | null +): boolean { + return left === null || right === null + ? left === right + : left.items === right.items; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function invalidRendererEvents(): Error { + return new Error('renderer-performance-phase-events-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts new file mode 100644 index 000000000..ec335bc73 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts @@ -0,0 +1,309 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as rendererProcessRssCaptureModule from './renderer-process-rss-capture'; +import { + captureRendererProcessRssSample, + createRendererProcessRssCapture, + type RendererProcessRssCapture, + type RendererProcessRssUnavailableReason, +} from './renderer-process-rss-capture'; + +const RENDERER_PID = 42; +const RENDERER_CREATION_TIME = 1_721_234_567_890; + +test('exposes a self-contained factory for the Electron main-process injection boundary', () => { + const factory = ( + rendererProcessRssCaptureModule as unknown as Record + )['createRendererProcessRssCaptureApi']; + + assert.equal(typeof factory, 'function'); + const source = (factory as () => unknown).toString(); + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => { + create( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ): RendererProcessRssCapture; + }; + + assert.deepEqual(restoredFactory().create(RENDERER_PID, [metric()]), { + identity: { + creationTime: RENDERER_CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: 1_263_616, + unavailableReason: null, + validSampleCount: 1, + }); +}); + +interface MetricOverrides { + readonly creationTime?: unknown; + readonly pid?: unknown; + readonly type?: unknown; + readonly workingSetSize?: unknown; +} + +function metric(overrides: MetricOverrides = {}): unknown { + return { + creationTime: + overrides.creationTime === undefined + ? RENDERER_CREATION_TIME + : overrides.creationTime, + memory: { + workingSetSize: + overrides.workingSetSize === undefined + ? 1_234 + : overrides.workingSetSize, + }, + pid: overrides.pid === undefined ? RENDERER_PID : overrides.pid, + type: overrides.type === undefined ? 'Tab' : overrides.type, + }; +} + +function assertUnavailable( + capture: RendererProcessRssCapture, + unavailableReason: RendererProcessRssUnavailableReason, + expected: { + readonly identity: RendererProcessRssCapture['identity']; + readonly missingSampleCount?: number; + readonly validSampleCount?: number; + } +): void { + assert.deepEqual(capture, { + identity: expected.identity, + missingSampleCount: expected.missingSampleCount ?? 0, + peakRssBytes: null, + unavailableReason, + validSampleCount: expected.validSampleCount ?? 0, + }); +} + +test('binds only the exact BrowserWindow renderer identity and converts KiB to bytes once', () => { + const capture = createRendererProcessRssCapture(RENDERER_PID, [ + metric({ + creationTime: RENDERER_CREATION_TIME + 1, + pid: 900, + type: 'Renderer', + workingSetSize: 9_999_999, + }), + metric(), + ]); + + assert.deepEqual(capture, { + identity: { + creationTime: RENDERER_CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: 1_263_616, + unavailableReason: null, + validSampleCount: 1, + }); +}); + +test('accepts only exact Tab or Renderer process types and tracks the peak without mutating prior state', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [ + metric({ type: 'Tab', workingSetSize: 2_000 }), + ]); + const lower = captureRendererProcessRssSample(initial, RENDERER_PID, [ + metric({ type: 'Renderer', workingSetSize: 1_500 }), + metric({ + pid: 777, + type: 'Renderer', + workingSetSize: 8_000_000, + }), + ]); + + assert.equal(initial.validSampleCount, 1); + assert.equal(initial.peakRssBytes, 2_048_000); + assert.deepEqual(lower, { + identity: { + creationTime: RENDERER_CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: 2_048_000, + unavailableReason: null, + validSampleCount: 2, + }); + + const higher = captureRendererProcessRssSample(lower, RENDERER_PID, [ + metric({ workingSetSize: 2_500 }), + ]); + assert.equal(higher.peakRssBytes, 2_560_000); + assert.equal(higher.validSampleCount, 3); +}); + +test('fails closed when the BrowserWindow OS process id is invalid', () => { + for (const invalidPid of [0, -1, 1.5, Number.NaN, '42'] as const) { + assertUnavailable( + createRendererProcessRssCapture(invalidPid, [metric()]), + 'renderer-os-pid-invalid', + { identity: null } + ); + } +}); + +test('fails closed when the initial target metric is missing or ambiguous', () => { + assertUnavailable( + createRendererProcessRssCapture(RENDERER_PID, [ + metric({ pid: 99, workingSetSize: 99_999 }), + ]), + 'renderer-process-metric-missing-at-start', + { + identity: null, + missingSampleCount: 1, + } + ); + + assertUnavailable( + createRendererProcessRssCapture(RENDERER_PID, [ + metric(), + metric({ type: 'Renderer', workingSetSize: 2_000 }), + ]), + 'renderer-process-metric-ambiguous', + { identity: null } + ); +}); + +test('fails closed for invalid initial type, creation time, or working set', () => { + const cases: ReadonlyArray<{ + readonly metric: unknown; + readonly reason: RendererProcessRssUnavailableReason; + }> = [ + { + metric: metric({ type: 'RendererHelper' }), + reason: 'renderer-process-metric-type-invalid', + }, + { + metric: metric({ creationTime: '1721234567890' }), + reason: 'renderer-process-metric-creation-time-invalid', + }, + { + metric: metric({ workingSetSize: Number.POSITIVE_INFINITY }), + reason: 'renderer-process-metric-working-set-invalid', + }, + { + metric: metric({ workingSetSize: 0 }), + reason: 'renderer-process-metric-working-set-invalid', + }, + ]; + + for (const testCase of cases) { + assertUnavailable( + createRendererProcessRssCapture(RENDERER_PID, [testCase.metric]), + testCase.reason, + { identity: null } + ); + } +}); + +test('invalidates the capture when the bound renderer is missing and never recovers', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [metric()]); + const missing = captureRendererProcessRssSample(initial, RENDERER_PID, [ + metric({ pid: 700, workingSetSize: 9_999_999 }), + ]); + + assertUnavailable( + missing, + 'renderer-process-metric-missing-during-capture', + { + identity: initial.identity, + missingSampleCount: 1, + validSampleCount: 1, + } + ); + + const laterValid = captureRendererProcessRssSample(missing, RENDERER_PID, [ + metric({ workingSetSize: 5_000 }), + ]); + assert.deepEqual(laterValid, missing); +}); + +test('invalidates the capture when the BrowserWindow or process metric identity changes', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [metric()]); + + assertUnavailable( + captureRendererProcessRssSample(initial, 43, [ + metric({ + creationTime: RENDERER_CREATION_TIME + 1, + pid: 43, + }), + ]), + 'renderer-process-identity-changed', + { + identity: initial.identity, + validSampleCount: 1, + } + ); + + assertUnavailable( + captureRendererProcessRssSample(initial, RENDERER_PID, [ + metric({ creationTime: RENDERER_CREATION_TIME + 1 }), + ]), + 'renderer-process-identity-changed', + { + identity: initial.identity, + validSampleCount: 1, + } + ); +}); + +test('fails closed for invalid or ambiguous metrics after binding', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [metric()]); + const cases: ReadonlyArray<{ + readonly metrics: readonly unknown[]; + readonly reason: RendererProcessRssUnavailableReason; + }> = [ + { + metrics: [metric(), metric({ type: 'Renderer' })], + reason: 'renderer-process-metric-ambiguous', + }, + { + metrics: [metric({ type: 'RendererHelper' })], + reason: 'renderer-process-metric-type-invalid', + }, + { + metrics: [metric({ creationTime: Number.NaN })], + reason: 'renderer-process-metric-creation-time-invalid', + }, + { + metrics: [metric({ workingSetSize: -1 })], + reason: 'renderer-process-metric-working-set-invalid', + }, + ]; + + for (const testCase of cases) { + assertUnavailable( + captureRendererProcessRssSample( + initial, + RENDERER_PID, + testCase.metrics + ), + testCase.reason, + { + identity: initial.identity, + validSampleCount: 1, + } + ); + } +}); + +test('keeps invalid renderer PIDs sticky after binding', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [metric()]); + const invalid = captureRendererProcessRssSample(initial, 0, [metric()]); + + assertUnavailable(invalid, 'renderer-os-pid-invalid', { + identity: initial.identity, + validSampleCount: 1, + }); + assert.deepEqual( + captureRendererProcessRssSample(invalid, RENDERER_PID, [metric()]), + invalid + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts new file mode 100644 index 000000000..3e18a76c8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts @@ -0,0 +1,267 @@ +export type RendererProcessRssUnavailableReason = + | 'renderer-os-pid-invalid' + | 'renderer-process-identity-changed' + | 'renderer-process-metric-ambiguous' + | 'renderer-process-metric-creation-time-invalid' + | 'renderer-process-metric-missing-at-start' + | 'renderer-process-metric-missing-during-capture' + | 'renderer-process-metric-type-invalid' + | 'renderer-process-metric-working-set-invalid'; + +export interface RendererProcessIdentity { + readonly creationTime: number; + readonly pid: number; +} + +export interface RendererProcessRssCapture { + readonly identity: RendererProcessIdentity | null; + readonly missingSampleCount: number; + readonly peakRssBytes: number | null; + readonly unavailableReason: RendererProcessRssUnavailableReason | null; + readonly validSampleCount: number; +} + +export interface RendererProcessRssCaptureApi { + readonly create: ( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ) => RendererProcessRssCapture; + readonly sample: ( + capture: RendererProcessRssCapture, + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ) => RendererProcessRssCapture; +} + +export function createRendererProcessRssCaptureApi(): RendererProcessRssCaptureApi { + type JsonRecord = Record; + + const KIBIBYTE_BYTES = 1_024; + const MAX_WORKING_SET_KIB = Math.floor( + Number.MAX_SAFE_INTEGER / KIBIBYTE_BYTES + ); + + const helpers = { + readRecord(value: unknown): JsonRecord | null { + return typeof value === 'object' && value !== null + ? (value as JsonRecord) + : null; + }, + + isValidOsPid(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; + }, + + findPidCandidates( + processMetrics: readonly unknown[], + pid: number + ): JsonRecord[] { + return processMetrics + .map((metric) => helpers.readRecord(metric)) + .filter( + (metric): metric is JsonRecord => + metric !== null && metric['pid'] === pid + ); + }, + + readMetric(metric: JsonRecord): + | { + readonly creationTime: number; + readonly rssBytes: number; + } + | RendererProcessRssUnavailableReason { + if (metric['type'] !== 'Tab' && metric['type'] !== 'Renderer') { + return 'renderer-process-metric-type-invalid'; + } + + const creationTime = metric['creationTime']; + if ( + typeof creationTime !== 'number' || + !Number.isFinite(creationTime) || + creationTime < 0 + ) { + return 'renderer-process-metric-creation-time-invalid'; + } + + const memory = helpers.readRecord(metric['memory']); + const workingSetSize = memory?.['workingSetSize']; + if ( + !Number.isSafeInteger(workingSetSize) || + Number(workingSetSize) <= 0 || + Number(workingSetSize) > MAX_WORKING_SET_KIB + ) { + return 'renderer-process-metric-working-set-invalid'; + } + + return { + creationTime, + rssBytes: Number(workingSetSize) * KIBIBYTE_BYTES, + }; + }, + + unavailableCapture( + unavailableReason: RendererProcessRssUnavailableReason, + options: { + readonly identity?: RendererProcessIdentity | null; + readonly missingSampleCount?: number; + readonly validSampleCount?: number; + } = {} + ): RendererProcessRssCapture { + return Object.freeze({ + identity: options.identity ?? null, + missingSampleCount: options.missingSampleCount ?? 0, + peakRssBytes: null, + unavailableReason, + validSampleCount: options.validSampleCount ?? 0, + }); + }, + + invalidateCapture( + capture: RendererProcessRssCapture, + unavailableReason: RendererProcessRssUnavailableReason, + missingSampleCount = capture.missingSampleCount + ): RendererProcessRssCapture { + return helpers.unavailableCapture(unavailableReason, { + identity: capture.identity, + missingSampleCount, + validSampleCount: capture.validSampleCount, + }); + }, + + create( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ): RendererProcessRssCapture { + if (!helpers.isValidOsPid(webContentsOsPid)) { + return helpers.unavailableCapture('renderer-os-pid-invalid'); + } + + const candidates = helpers.findPidCandidates( + processMetrics, + webContentsOsPid + ); + if (candidates.length === 0) { + return helpers.unavailableCapture( + 'renderer-process-metric-missing-at-start', + { + missingSampleCount: 1, + } + ); + } + if (candidates.length !== 1) { + return helpers.unavailableCapture( + 'renderer-process-metric-ambiguous' + ); + } + + const metric = helpers.readMetric(candidates[0] as JsonRecord); + if (typeof metric === 'string') { + return helpers.unavailableCapture(metric); + } + + return Object.freeze({ + identity: Object.freeze({ + creationTime: metric.creationTime, + pid: webContentsOsPid, + }), + missingSampleCount: 0, + peakRssBytes: metric.rssBytes, + unavailableReason: null, + validSampleCount: 1, + }); + }, + + sample( + capture: RendererProcessRssCapture, + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ): RendererProcessRssCapture { + if (capture.unavailableReason !== null) { + return capture; + } + if (!helpers.isValidOsPid(webContentsOsPid)) { + return helpers.invalidateCapture( + capture, + 'renderer-os-pid-invalid' + ); + } + + const identity = capture.identity; + if (identity === null || webContentsOsPid !== identity.pid) { + return helpers.invalidateCapture( + capture, + 'renderer-process-identity-changed' + ); + } + + const candidates = helpers.findPidCandidates( + processMetrics, + webContentsOsPid + ); + if (candidates.length === 0) { + return helpers.invalidateCapture( + capture, + 'renderer-process-metric-missing-during-capture', + capture.missingSampleCount + 1 + ); + } + if (candidates.length !== 1) { + return helpers.invalidateCapture( + capture, + 'renderer-process-metric-ambiguous' + ); + } + + const metric = helpers.readMetric(candidates[0] as JsonRecord); + if (typeof metric === 'string') { + return helpers.invalidateCapture(capture, metric); + } + if (metric.creationTime !== identity.creationTime) { + return helpers.invalidateCapture( + capture, + 'renderer-process-identity-changed' + ); + } + + return Object.freeze({ + identity, + missingSampleCount: capture.missingSampleCount, + peakRssBytes: Math.max( + capture.peakRssBytes ?? metric.rssBytes, + metric.rssBytes + ), + unavailableReason: null, + validSampleCount: capture.validSampleCount + 1, + }); + }, + }; + + return Object.freeze({ + create: helpers.create, + sample: helpers.sample, + }); +} + +const rendererProcessRssCaptureApi = createRendererProcessRssCaptureApi(); + +export function createRendererProcessRssCapture( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] +): RendererProcessRssCapture { + return rendererProcessRssCaptureApi.create( + webContentsOsPid, + processMetrics + ); +} + +export function captureRendererProcessRssSample( + capture: RendererProcessRssCapture, + webContentsOsPid: unknown, + processMetrics: readonly unknown[] +): RendererProcessRssCapture { + return rendererProcessRssCaptureApi.sample( + capture, + webContentsOsPid, + processMetrics + ); +} diff --git a/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts new file mode 100644 index 000000000..2e94cee01 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts @@ -0,0 +1,172 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface TestIteration { + readonly kind: 'diagnostic' | 'measured' | 'warmup'; + readonly main: { + readonly rendererWindow: { + readonly rss: { + readonly identity: { + readonly creationTime: number; + readonly pid: number; + } | null; + readonly missingSampleCount: number; + readonly peakRssBytes: number | null; + readonly unavailableReason: string | null; + readonly validSampleCount: number; + }; + }; + }; + readonly runId: string; +} + +interface RendererRssValidity { + readonly invalidMeasuredRuns: readonly { + readonly missingSampleCount: number; + readonly reason: string; + readonly runId: string; + readonly validSampleCount: number; + }[]; + readonly measuredRunCount: number; + readonly validForBenchmark: boolean; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +interface RendererRssValidityModule { + assessRendererRssValidity?: ( + iterations: readonly TestIteration[] + ) => RendererRssValidity; +} + +const validityModulePromise = import( + new URL('./renderer-rss-validity.ts', import.meta.url).href +) + .then((module) => module as RendererRssValidityModule) + .catch(() => null); + +function iteration( + runId: string, + kind: TestIteration['kind'], + input: Partial = {} +): TestIteration { + return { + kind, + main: { + rendererWindow: { + rss: { + identity: { + creationTime: 1_721_234_567_890, + pid: 42, + }, + missingSampleCount: 0, + peakRssBytes: 4_096, + unavailableReason: null, + validSampleCount: 3, + ...input, + }, + }, + }, + runId, + }; +} + +test('validates only measured renderer RSS runs and preserves the exact summary shape', async () => { + const module = await validityModulePromise; + assert.ok(module, 'generic renderer RSS validity helper must exist'); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + const result = assess([ + iteration('warmup-invalid', 'warmup', { identity: null }), + iteration('run-01', 'measured'), + iteration('diagnostic-invalid', 'diagnostic', { + unavailableReason: 'renderer-process-gone', + }), + ]); + + assert.deepEqual(result, { + invalidMeasuredRuns: [], + measuredRunCount: 1, + validForBenchmark: true, + validForComparison: true, + validMeasuredRunCount: 1, + }); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.invalidMeasuredRuns)); +}); + +test('fails every incomplete measured capture without losing its raw reason and counts', async () => { + const module = await validityModulePromise; + assert.ok(module); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + const result = assess([ + iteration('run-no-identity', 'measured', { identity: null }), + iteration('run-missing-sample', 'measured', { + missingSampleCount: 1, + peakRssBytes: null, + unavailableReason: 'renderer-process-metric-missing-during-capture', + validSampleCount: 2, + }), + iteration('run-zero-peak', 'measured', { peakRssBytes: 0 }), + ]); + + assert.deepEqual(result, { + invalidMeasuredRuns: [ + { + missingSampleCount: 0, + reason: 'renderer-rss-capture-invalid', + runId: 'run-no-identity', + validSampleCount: 3, + }, + { + missingSampleCount: 1, + reason: 'renderer-process-metric-missing-during-capture', + runId: 'run-missing-sample', + validSampleCount: 2, + }, + { + missingSampleCount: 0, + reason: 'renderer-rss-capture-invalid', + runId: 'run-zero-peak', + validSampleCount: 3, + }, + ], + measuredRunCount: 3, + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); +}); + +test('requires at least one measured run', async () => { + const module = await validityModulePromise; + assert.ok(module); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + assert.deepEqual(assess([iteration('warmup-01', 'warmup')]), { + invalidMeasuredRuns: [], + measuredRunCount: 0, + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); +}); + +test('the cancellation report delegates renderer RSS validity to the generic helper', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation-report.ts', import.meta.url), + 'utf8' + ); + + assert.match( + source, + /import \{ assessRendererRssValidity \} from '\.\/renderer-rss-validity';/ + ); + assert.doesNotMatch(source, /function assessRendererRssValidity\(/); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts new file mode 100644 index 000000000..ccab61639 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts @@ -0,0 +1,72 @@ +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; + +export interface RendererRssValidityIteration { + readonly kind: string; + readonly main: { + readonly rendererWindow: { + readonly rss: RendererProcessRssCapture; + }; + }; + readonly runId: string; +} + +export interface InvalidRendererRssMeasuredRun { + readonly missingSampleCount: number; + readonly reason: + | Exclude + | 'renderer-rss-capture-invalid'; + readonly runId: string; + readonly validSampleCount: number; +} + +export interface RendererRssValidity { + readonly invalidMeasuredRuns: readonly InvalidRendererRssMeasuredRun[]; + readonly measuredRunCount: number; + readonly validForBenchmark: boolean; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +export function assessRendererRssValidity( + iterations: readonly RendererRssValidityIteration[] +): RendererRssValidity { + const measured = iterations.filter( + (iteration) => iteration.kind === 'measured' + ); + const invalidMeasuredRuns: RendererRssValidity['invalidMeasuredRuns'][number][] = + []; + let validMeasuredRunCount = 0; + + for (const iteration of measured) { + const rss = iteration.main.rendererWindow.rss; + if ( + rss.identity !== null && + Number.isSafeInteger(rss.peakRssBytes) && + Number(rss.peakRssBytes) > 0 && + rss.unavailableReason === null && + rss.missingSampleCount === 0 && + Number.isSafeInteger(rss.validSampleCount) && + rss.validSampleCount > 0 + ) { + validMeasuredRunCount += 1; + continue; + } + + invalidMeasuredRuns.push({ + missingSampleCount: rss.missingSampleCount, + reason: rss.unavailableReason ?? 'renderer-rss-capture-invalid', + runId: iteration.runId, + validSampleCount: rss.validSampleCount, + }); + } + + const valid = + measured.length > 0 && validMeasuredRunCount === measured.length; + return Object.freeze({ + invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns), + measuredRunCount: measured.length, + validForBenchmark: valid, + validForComparison: valid, + validMeasuredRunCount, + }); +} diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts new file mode 100644 index 000000000..a6f0c2b21 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts @@ -0,0 +1,153 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { + type CancellationBenchmarkManifest, + type CancellationIterationResult, + PERFORMANCE_ITERATION_KIND, +} from './m3u-refresh-cancellation-contract'; +import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report'; +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; + +function measuredIteration( + rss: RendererProcessRssCapture, + responsiveEvents: number, + unresponsiveEvents: number, + runId: string +): CancellationIterationResult { + return { + cancellationEffectObserved: true, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 }, + eventLoopUtilization: null, + memory: { + peakHeapUsedBytes: 0, + peakRssBytes: 0, + postGcHeapUsedBytes: null, + postGcRssBytes: null, + }, + rendererPeakRssBytes: 999_999_999, + rendererWindow: { + responsiveEvents, + rss, + unresponsiveEvents, + windowIdentity: { + browserWindowId: 7, + webContentsId: 11, + }, + }, + responsiveEvents: 100, + unresponsiveEvents: 100, + workers: [], + }, + phases: {}, + renderer: { + peakHeapUsedBytes: 0, + postGcHeapUsedBytes: null, + probe: { + frameGapsMs: [], + heartbeatDelaysMs: [], + longTasksMs: [], + }, + }, + runId, + } as unknown as CancellationIterationResult; +} + +test('summary uses only exact target-window RSS and scoped responsiveness events', () => { + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [ + measuredIteration( + { + identity: { + creationTime: 1_721_234_567_890, + pid: 42, + }, + missingSampleCount: 0, + peakRssBytes: 2_048, + unavailableReason: null, + validSampleCount: 3, + }, + 1, + 2, + 'run-01' + ), + measuredIteration( + { + identity: { + creationTime: 1_721_234_567_890, + pid: 43, + }, + missingSampleCount: 1, + peakRssBytes: null, + unavailableReason: + 'renderer-process-metric-missing-during-capture', + validSampleCount: 2, + }, + 3, + 4, + 'run-02' + ), + ] + ); + + assert.deepEqual(summary.measured.rendererRssPeakBytes, { + count: 1, + max: 2_048, + mean: 2_048, + median: 2_048, + min: 2_048, + p95: 2_048, + p99: 2_048, + }); + assert.equal(summary.measured.responsiveEvents, 4); + assert.equal(summary.measured.unresponsiveEvents, 6); + assert.deepEqual( + ( + summary as unknown as { + readonly validity: { + readonly rendererRss: unknown; + }; + } + ).validity.rendererRss, + { + invalidMeasuredRuns: [ + { + missingSampleCount: 1, + reason: 'renderer-process-metric-missing-during-capture', + runId: 'run-02', + validSampleCount: 2, + }, + ], + measuredRunCount: 2, + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 1, + } + ); +}); + +test('benchmark preserves raw artifacts before rejecting incomplete renderer RSS', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const summaryWrite = source.indexOf( + "writeJson(join(config.outputDirectory, 'summary.json'), summary)" + ); + const validityGuard = source.indexOf( + 'summary.validity.rendererRss.validForBenchmark' + ); + + assert.ok(summaryWrite >= 0, 'summary artifact write must exist'); + assert.ok(validityGuard >= 0, 'renderer RSS validity guard must exist'); + assert.ok( + summaryWrite < validityGuard, + 'raw summary must be durable before the renderer RSS guard fails' + ); + assert.match(source, /Renderer RSS capture is invalid/); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts new file mode 100644 index 000000000..3aa641215 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts @@ -0,0 +1,323 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + createRendererProcessRssCaptureApi, + type RendererProcessRssCapture, + type RendererProcessRssCaptureApi, +} from './renderer-process-rss-capture'; + +interface RendererWindowIdentity { + readonly browserWindowId: number; + readonly webContentsId: number; +} + +interface RendererWindowRssSessionMetrics { + readonly responsiveEvents: number; + readonly rss: RendererProcessRssCapture; + readonly unresponsiveEvents: number; + readonly windowIdentity: RendererWindowIdentity; +} + +interface RendererWindowRssSession { + detach(): void; + sample(): RendererWindowRssSessionMetrics; + snapshot(): RendererWindowRssSessionMetrics; +} + +interface RendererWindowRssSessionFactory { + create(input: { + readonly browserWindowFromId: ( + browserWindowId: number + ) => FakeBrowserWindow | null; + readonly browserWindowId: number; + readonly getAppMetrics: () => readonly unknown[]; + readonly rendererRssApi: RendererProcessRssCaptureApi; + readonly webContentsId: number; + }): RendererWindowRssSession; +} + +interface RendererWindowRssSessionModule { + createRendererWindowRssSessionApi?: () => RendererWindowRssSessionFactory; +} + +class FakeWebContents { + pidReads = 0; + throwOnPidRead = false; + + constructor( + readonly id: number, + public pid: number + ) {} + + getOSProcessId(): number { + this.pidReads += 1; + if (this.throwOnPidRead) { + throw new Error('renderer unavailable'); + } + return this.pid; + } +} + +class FakeBrowserWindow extends EventEmitter { + constructor( + readonly id: number, + readonly webContents: FakeWebContents + ) { + super(); + } +} + +const BROWSER_WINDOW_ID = 7; +const WEB_CONTENTS_ID = 70; +const RENDERER_PID = 700; +const CREATION_TIME = 1_721_234_567_890; + +const sessionModulePromise = import( + new URL('./renderer-window-rss-session.ts', import.meta.url).href +) + .then((module) => module as RendererWindowRssSessionModule) + .catch(() => null); + +function processMetric( + pid: number, + workingSetSize: number, + creationTime = CREATION_TIME +): unknown { + return { + creationTime, + memory: { workingSetSize }, + pid, + type: 'Tab', + }; +} + +async function restoreSerializableFactory(): Promise { + const module = await sessionModulePromise; + assert.ok(module, 'renderer window RSS session module must exist'); + const factory = module.createRendererWindowRssSessionApi; + assert.equal(typeof factory, 'function'); + + const source = factory.toString(); + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => RendererWindowRssSessionFactory; + return restoredFactory(); +} + +test('scopes renderer RSS and window events to the exact BrowserWindow', async () => { + const api = await restoreSerializableFactory(); + const targetWebContents = new FakeWebContents( + WEB_CONTENTS_ID, + RENDERER_PID + ); + const targetWindow = new FakeBrowserWindow( + BROWSER_WINDOW_ID, + targetWebContents + ); + const decoyWebContents = new FakeWebContents(71, 701); + const decoyWindow = new FakeBrowserWindow(8, decoyWebContents); + let targetExternalEvents = 0; + let decoyExternalEvents = 0; + targetWindow.on('unresponsive', () => { + targetExternalEvents += 1; + }); + decoyWindow.on('unresponsive', () => { + decoyExternalEvents += 1; + }); + + let appMetricReads = 0; + const targetWorkingSets = [1_000, 1_500]; + const session = api.create({ + browserWindowFromId: (id) => + id === BROWSER_WINDOW_ID ? targetWindow : decoyWindow, + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => { + const targetWorkingSet = targetWorkingSets[ + Math.min(appMetricReads, 1) + ] as number; + appMetricReads += 1; + return [ + processMetric(701, 9_999_999, CREATION_TIME + 1), + processMetric(RENDERER_PID, targetWorkingSet), + ]; + }, + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }); + + decoyWindow.emit('unresponsive'); + decoyWindow.emit('responsive'); + targetWindow.emit('unresponsive'); + targetWindow.emit('responsive'); + const sampled = session.sample(); + + assert.deepEqual(sampled, { + rss: { + identity: { + creationTime: CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: 1_536_000, + unavailableReason: null, + validSampleCount: 2, + }, + responsiveEvents: 1, + unresponsiveEvents: 1, + windowIdentity: { + browserWindowId: BROWSER_WINDOW_ID, + webContentsId: WEB_CONTENTS_ID, + }, + }); + assert.equal(targetWebContents.pidReads, 2); + assert.equal(decoyWebContents.pidReads, 0); + assert.equal(appMetricReads, 2); + assert.equal(targetExternalEvents, 1); + assert.equal(decoyExternalEvents, 1); + assert.equal(targetWindow.listenerCount('unresponsive'), 2); + assert.equal(decoyWindow.listenerCount('unresponsive'), 1); + + session.detach(); + assert.equal(targetWindow.listenerCount('unresponsive'), 1); + assert.equal(targetWindow.listenerCount('responsive'), 0); + assert.equal(decoyWindow.listenerCount('unresponsive'), 1); + targetWindow.emit('unresponsive'); + decoyWindow.emit('unresponsive'); + assert.equal(session.snapshot().unresponsiveEvents, 1); + assert.equal(targetExternalEvents, 2); + assert.equal(decoyExternalEvents, 2); +}); + +test('rejects a missing or mismatched BrowserWindow before attaching listeners', async () => { + const api = await restoreSerializableFactory(); + const targetWindow = new FakeBrowserWindow( + BROWSER_WINDOW_ID, + new FakeWebContents(WEB_CONTENTS_ID, RENDERER_PID) + ); + let appMetricReads = 0; + const baseInput = { + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => { + appMetricReads += 1; + return [processMetric(RENDERER_PID, 1_000)]; + }, + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }; + + assert.throws( + () => + api.create({ + ...baseInput, + browserWindowFromId: () => null, + }), + /renderer-browser-window-missing/ + ); + assert.throws( + () => + api.create({ + ...baseInput, + browserWindowFromId: () => + new FakeBrowserWindow( + BROWSER_WINDOW_ID + 1, + targetWindow.webContents + ), + }), + /renderer-browser-window-identity-mismatch/ + ); + assert.throws( + () => + api.create({ + ...baseInput, + browserWindowFromId: () => + new FakeBrowserWindow( + BROWSER_WINDOW_ID, + new FakeWebContents(WEB_CONTENTS_ID + 1, RENDERER_PID) + ), + }), + /renderer-web-contents-identity-mismatch/ + ); + assert.equal(targetWindow.listenerCount('unresponsive'), 0); + assert.equal(targetWindow.listenerCount('responsive'), 0); + assert.equal(targetWindow.webContents.pidReads, 0); + assert.equal(appMetricReads, 0); +}); + +test('keeps PID and metric failures sticky while sampling the exact target every time', async () => { + const api = await restoreSerializableFactory(); + const targetWebContents = new FakeWebContents( + WEB_CONTENTS_ID, + RENDERER_PID + ); + const targetWindow = new FakeBrowserWindow( + BROWSER_WINDOW_ID, + targetWebContents + ); + let appMetricReads = 0; + let metrics: readonly unknown[] = [processMetric(RENDERER_PID, 1_000)]; + const session = api.create({ + browserWindowFromId: () => targetWindow, + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => { + appMetricReads += 1; + return metrics; + }, + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }); + + targetWebContents.pid = RENDERER_PID + 1; + metrics = [processMetric(RENDERER_PID + 1, 5_000, CREATION_TIME + 1)]; + const invalid = session.sample(); + assert.deepEqual(invalid.rss, { + identity: { + creationTime: CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: null, + unavailableReason: 'renderer-process-identity-changed', + validSampleCount: 1, + }); + + targetWebContents.pid = RENDERER_PID; + metrics = [processMetric(RENDERER_PID, 10_000)]; + assert.deepEqual(session.sample().rss, invalid.rss); + assert.equal(targetWebContents.pidReads, 3); + assert.equal(appMetricReads, 3); + + const missingMetricSession = api.create({ + browserWindowFromId: () => targetWindow, + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => [], + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }); + assert.deepEqual(missingMetricSession.snapshot().rss, { + identity: null, + missingSampleCount: 1, + peakRssBytes: null, + unavailableReason: 'renderer-process-metric-missing-at-start', + validSampleCount: 0, + }); + + targetWebContents.throwOnPidRead = true; + const invalidPidSession = api.create({ + browserWindowFromId: () => targetWindow, + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => [processMetric(RENDERER_PID, 1_000)], + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }); + assert.equal( + invalidPidSession.snapshot().rss.unavailableReason, + 'renderer-os-pid-invalid' + ); + + session.detach(); + missingMetricSession.detach(); + invalidPidSession.detach(); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts new file mode 100644 index 000000000..bef34fc32 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts @@ -0,0 +1,174 @@ +import type { + RendererProcessRssCapture, + RendererProcessRssCaptureApi, +} from './renderer-process-rss-capture'; + +export interface RendererWindowIdentity { + readonly browserWindowId: number; + readonly webContentsId: number; +} + +export interface RendererWindowRssSessionMetrics { + readonly responsiveEvents: number; + readonly rss: RendererProcessRssCapture; + readonly unresponsiveEvents: number; + readonly windowIdentity: RendererWindowIdentity; +} + +export interface RendererWindowWebContents { + readonly id: number; + getOSProcessId(): number; +} + +export interface RendererWindow { + readonly id: number; + readonly webContents: RendererWindowWebContents; + off(event: 'responsive' | 'unresponsive', listener: () => void): unknown; + on(event: 'responsive' | 'unresponsive', listener: () => void): unknown; +} + +export interface RendererWindowRssSession { + detach(): void; + sample(): RendererWindowRssSessionMetrics; + snapshot(): RendererWindowRssSessionMetrics; +} + +export interface RendererWindowRssSessionCreateInput { + readonly browserWindowFromId: ( + browserWindowId: number + ) => RendererWindow | null; + readonly browserWindowId: number; + readonly getAppMetrics: () => readonly unknown[]; + readonly rendererRssApi: RendererProcessRssCaptureApi; + readonly webContentsId: number; +} + +export interface RendererWindowRssSessionApi { + create( + input: RendererWindowRssSessionCreateInput + ): RendererWindowRssSession; +} + +export function createRendererWindowRssSessionApi(): RendererWindowRssSessionApi { + const helpers = { + isValidIdentityId(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; + }, + + create( + input: RendererWindowRssSessionCreateInput + ): RendererWindowRssSession { + if (!helpers.isValidIdentityId(input.browserWindowId)) { + throw new Error('renderer-browser-window-id-invalid'); + } + if (!helpers.isValidIdentityId(input.webContentsId)) { + throw new Error('renderer-web-contents-id-invalid'); + } + + let browserWindow: RendererWindow | null = null; + try { + browserWindow = input.browserWindowFromId( + input.browserWindowId + ); + } catch { + throw new Error('renderer-browser-window-missing'); + } + if (browserWindow === null) { + throw new Error('renderer-browser-window-missing'); + } + if (browserWindow.id !== input.browserWindowId) { + throw new Error('renderer-browser-window-identity-mismatch'); + } + const webContents = browserWindow.webContents; + if ( + webContents === null || + typeof webContents !== 'object' || + webContents.id !== input.webContentsId + ) { + throw new Error('renderer-web-contents-identity-mismatch'); + } + + const windowIdentity = Object.freeze({ + browserWindowId: input.browserWindowId, + webContentsId: input.webContentsId, + }); + let rendererRss = input.rendererRssApi.create( + helpers.readOsProcessId(webContents), + helpers.readAppMetrics(input.getAppMetrics) + ); + let responsiveEvents = 0; + let unresponsiveEvents = 0; + let detached = false; + const callbacks = { + onResponsive(): void { + if (!detached) { + responsiveEvents += 1; + } + }, + onUnresponsive(): void { + if (!detached) { + unresponsiveEvents += 1; + } + }, + snapshot(): RendererWindowRssSessionMetrics { + return Object.freeze({ + responsiveEvents, + rss: rendererRss, + unresponsiveEvents, + windowIdentity, + }); + }, + }; + browserWindow.on('unresponsive', callbacks.onUnresponsive); + try { + browserWindow.on('responsive', callbacks.onResponsive); + } catch (error) { + browserWindow.off('unresponsive', callbacks.onUnresponsive); + throw error; + } + + return Object.freeze({ + detach(): void { + if (detached) { + return; + } + detached = true; + browserWindow.off('unresponsive', callbacks.onUnresponsive); + browserWindow.off('responsive', callbacks.onResponsive); + }, + sample(): RendererWindowRssSessionMetrics { + if (!detached) { + rendererRss = input.rendererRssApi.sample( + rendererRss, + helpers.readOsProcessId(webContents), + helpers.readAppMetrics(input.getAppMetrics) + ); + } + return callbacks.snapshot(); + }, + snapshot: callbacks.snapshot, + }); + }, + + readAppMetrics( + getAppMetrics: () => readonly unknown[] + ): readonly unknown[] { + try { + const metrics = getAppMetrics(); + return Array.isArray(metrics) ? metrics : []; + } catch { + return []; + } + }, + + readOsProcessId(webContents: RendererWindowWebContents): unknown { + try { + return webContents.getOSProcessId(); + } catch { + return null; + } + }, + }; + + return Object.freeze({ create: helpers.create }); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts new file mode 100644 index 000000000..2d6e87a49 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts @@ -0,0 +1,49 @@ +/* eslint-disable playwright/expect-expect -- Node assertions define the loopback-only fixture server contract. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createSyntheticM3uFixture } from './synthetic-m3u'; +import { startSyntheticM3uServer } from './synthetic-m3u-server'; + +test('serves and primes only the deterministic fixture on ephemeral IPv4 loopback', async () => { + const fixture = createSyntheticM3uFixture(10_000); + const server = await startSyntheticM3uServer(fixture); + + try { + const url = new URL(server.resourceUrl); + assert.equal(url.protocol, 'http:'); + assert.equal(url.hostname, '127.0.0.1'); + assert.notEqual(url.port, ''); + assert.equal(url.username, ''); + assert.equal(url.password, ''); + assert.equal(server.requestCount(), 0); + + await server.prime(); + assert.equal(server.requestCount(), 1); + + const response = await fetch(server.resourceUrl); + assert.equal(response.status, 200); + assert.equal( + response.headers.get('content-length'), + String(fixture.bytes) + ); + assert.equal(await response.text(), fixture.body); + assert.equal(server.requestCount(), 2); + + const missing = await fetch(`${server.origin}/not-the-fixture.m3u`); + assert.equal(missing.status, 404); + assert.equal(server.requestCount(), 2); + } finally { + await server.close(); + } +}); + +test('closes idempotently and rejects priming after close', async () => { + const server = await startSyntheticM3uServer( + createSyntheticM3uFixture(10_000) + ); + + await server.close(); + await server.close(); + await assert.rejects(server.prime(), /closed/i); +}); diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts new file mode 100644 index 000000000..269c27f45 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts @@ -0,0 +1,114 @@ +import { once } from 'node:events'; +import { createServer, type Server } from 'node:http'; + +import type { SyntheticM3uFixture } from './synthetic-m3u'; + +export const SYNTHETIC_M3U_RESOURCE_PATH = '/synthetic-performance.m3u'; + +export interface SyntheticM3uServer { + readonly origin: string; + readonly resourceUrl: string; + close(): Promise; + prime(): Promise; + requestCount(): number; +} + +export async function startSyntheticM3uServer( + fixture: SyntheticM3uFixture +): Promise { + let successfulRequestCount = 0; + const server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + if ( + request.method !== 'GET' || + requestUrl.pathname !== SYNTHETIC_M3U_RESOURCE_PATH || + requestUrl.search.length > 0 + ) { + response.writeHead(404, { + 'Content-Length': '0', + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end(); + return; + } + + successfulRequestCount += 1; + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Length': String(fixture.bytes), + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + }); + response.end(fixture.body); + }); + server.listen({ + exclusive: true, + host: '127.0.0.1', + port: 0, + }); + try { + await once(server, 'listening'); + } catch (error: unknown) { + await closeServer(server).catch(() => undefined); + throw error; + } + + const address = server.address(); + if ( + address === null || + typeof address === 'string' || + address.address !== '127.0.0.1' || + !Number.isSafeInteger(address.port) || + address.port <= 0 + ) { + await closeServer(server); + throw new Error( + 'Synthetic M3U server did not bind exact IPv4 loopback' + ); + } + + const origin = `http://127.0.0.1:${address.port}`; + const resourceUrl = `${origin}${SYNTHETIC_M3U_RESOURCE_PATH}`; + let closePromise: Promise | null = null; + + return Object.freeze({ + origin, + resourceUrl, + close: () => { + closePromise ??= closeServer(server); + return closePromise; + }, + prime: async () => { + if (closePromise !== null) { + throw new Error('Synthetic M3U server is closed'); + } + const response = await fetch(resourceUrl); + const body = await response.arrayBuffer(); + if ( + response.status !== 200 || + Number(response.headers.get('content-length')) !== + fixture.bytes || + body.byteLength !== fixture.bytes + ) { + throw new Error( + 'Synthetic M3U server priming response was invalid' + ); + } + }, + requestCount: () => successfulRequestCount, + }); +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts new file mode 100644 index 000000000..094f4f80d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { describe, it } from 'node:test'; + +import { + createSyntheticM3uFixture, + SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS, + SYNTHETIC_M3U_SEED, +} from './synthetic-m3u'; + +const GOLDEN_SHA256 = { + 10_000: '1d151ab2a878db8818f2bbfbdd689007cbe390182cdd1520c316bd163b7e7db3', + 50_000: '4a5336a5d1c66b8b706752784815617a012499cb7a231ab35c88df9bd55ee78a', + 100_000: '2ffe912f61ea106ba8de3414b0a927cdaa3264ac637d44c4b219f199eb94c353', +} as const; + +describe('synthetic M3U performance fixture', () => { + it('generates each supported catalog deterministically with exact metadata', () => { + assert.deepEqual( + SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS, + [10_000, 50_000, 100_000] + ); + assert.equal(SYNTHETIC_M3U_SEED, 240_724); + + for (const channelCount of SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS) { + const fixture = createSyntheticM3uFixture(channelCount); + const lines = fixture.body.trimEnd().split('\n'); + + assert.equal(fixture.channelCount, channelCount); + assert.equal( + fixture.bytes, + Buffer.byteLength(fixture.body, 'utf8') + ); + assert.equal( + fixture.sha256, + createHash('sha256').update(fixture.body, 'utf8').digest('hex') + ); + assert.equal(fixture.sha256, GOLDEN_SHA256[channelCount]); + assert.equal(lines[0], '#EXTM3U'); + assert.equal(lines.length, 1 + channelCount * 2); + assert.equal( + lines.filter((line) => line.startsWith('#EXTINF:')).length, + channelCount + ); + assert.doesNotMatch(fixture.body, /\b(?:tvg-|logo|provider)\b/i); + assertOnlyLoopbackUrls(fixture.body); + } + }); + + it('rejects unsupported channel counts', () => { + assert.throws( + () => createSyntheticM3uFixture(9_999), + /Unsupported synthetic M3U channel count/ + ); + }); +}); + +function assertOnlyLoopbackUrls(body: string): void { + const urls = body.match(/https?:\/\/[^\s"]+/g) ?? []; + + for (const rawUrl of urls) { + const url = new URL(rawUrl); + assert.equal(url.protocol, 'http:'); + assert.equal(url.hostname, '127.0.0.1'); + assert.equal(url.username, ''); + assert.equal(url.password, ''); + } +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts index 14e65387d..1cdf8d18b 100644 --- a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts @@ -2,19 +2,30 @@ import { createHash } from 'node:crypto'; export const SYNTHETIC_M3U_SEED = 240_724; export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000; +export const SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS = [ + 10_000, + 50_000, + SYNTHETIC_M3U_CHANNEL_COUNT, +] as const; + +export type SyntheticM3uChannelCount = + (typeof SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS)[number]; export interface SyntheticM3uFixture { readonly body: string; readonly bytes: number; - readonly channelCount: number; + readonly channelCount: SyntheticM3uChannelCount; readonly sha256: string; } -export function createSyntheticM3uFixture(): SyntheticM3uFixture { - const lines = new Array(1 + SYNTHETIC_M3U_CHANNEL_COUNT * 2); +export function createSyntheticM3uFixture( + channelCount: number = SYNTHETIC_M3U_CHANNEL_COUNT +): SyntheticM3uFixture { + assertSupportedChannelCount(channelCount); + const lines = new Array(1 + channelCount * 2); lines[0] = '#EXTM3U'; - for (let offset = 0; offset < SYNTHETIC_M3U_CHANNEL_COUNT; offset += 1) { + for (let offset = 0; offset < channelCount; offset += 1) { const index = offset + 1; const group = (offset % 100) + 1; const lineOffset = 1 + offset * 2; @@ -34,7 +45,21 @@ export function createSyntheticM3uFixture(): SyntheticM3uFixture { return Object.freeze({ body, bytes: Buffer.byteLength(body, 'utf8'), - channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + channelCount, sha256: createHash('sha256').update(body, 'utf8').digest('hex'), }); } + +function assertSupportedChannelCount( + channelCount: number +): asserts channelCount is SyntheticM3uChannelCount { + if ( + !SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS.some( + (supported) => supported === channelCount + ) + ) { + throw new Error( + `Unsupported synthetic M3U channel count: ${channelCount}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts new file mode 100644 index 000000000..1423da627 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts @@ -0,0 +1,149 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +const PHASE_SEQUENCES = { + DB_GET_APP_PLAYLIST: [ + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + ], + DB_UPSERT_APP_PLAYLIST: [ + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + ], +} as const satisfies Readonly< + Record +>; +const EVENT_KEYS = new Set([ + 'boundary', + 'durationMs', + 'epochMs', + 'metadata', + 'phase', + 'requestId', +]); + +export function parseWorkerPerformancePhaseEvents( + operation: string | null, + input: unknown, + workStartedEpochMs: number, + workEndedEpochMs: number +): readonly PerformancePhaseEvent[] | null { + const phaseSequence = getPhaseSequence(operation); + if (phaseSequence.length === 0) { + return input === undefined || + (Array.isArray(input) && input.length === 0) + ? [] + : null; + } + if ( + !Array.isArray(input) || + input.length !== phaseSequence.length * 2 + ) { + return null; + } + + const parsed: PerformancePhaseEvent[] = + []; + let lastEpochMs = workStartedEpochMs; + for (let index = 0; index < input.length; index += 1) { + const raw = input[index]; + if ( + !isRecord(raw) || + Object.keys(raw).some((key) => !EVENT_KEYS.has(key)) + ) { + return null; + } + + const boundary = raw['boundary']; + const durationMs = raw['durationMs']; + const epochMs = raw['epochMs']; + const metadata = parseMetadata(raw['metadata']); + const phase = raw['phase']; + const requestId = raw['requestId']; + if ( + boundary !== (index % 2 === 0 ? 'start' : 'end') || + phase !== phaseSequence[Math.floor(index / 2)] || + typeof requestId !== 'string' || + requestId.length === 0 || + !isFiniteNonNegativeNumber(epochMs) || + epochMs < lastEpochMs || + epochMs > workEndedEpochMs || + (boundary === 'start' && + (durationMs !== null || metadata !== undefined)) || + (boundary === 'end' && !isFiniteNonNegativeNumber(durationMs)) || + metadata === null || + (parsed.length > 0 && parsed[0]?.requestId !== requestId) + ) { + return null; + } + + parsed.push({ + boundary: boundary as 'end' | 'start', + durationMs: durationMs as number | null, + epochMs, + ...(metadata === undefined ? {} : { metadata }), + phase: phase as AppPlaylistPerformancePhase, + requestId, + }); + lastEpochMs = epochMs; + } + return parsed; +} + +function getPhaseSequence( + operation: string | null +): readonly AppPlaylistPerformancePhase[] { + if (operation === 'DB_GET_APP_PLAYLIST') { + return PHASE_SEQUENCES.DB_GET_APP_PLAYLIST; + } + if (operation === 'DB_UPSERT_APP_PLAYLIST') { + return PHASE_SEQUENCES.DB_UPSERT_APP_PLAYLIST; + } + return []; +} + +function parseMetadata( + input: unknown +): PerformancePhaseMetadata | undefined | null { + if (input === undefined) { + return undefined; + } + if ( + !isRecord(input) || + Object.keys(input).some( + (key) => key !== 'byteCount' && key !== 'itemCount' + ) + ) { + return null; + } + + const byteCount = input['byteCount']; + const itemCount = input['itemCount']; + if ( + (byteCount !== undefined && !isSafeCount(byteCount)) || + (itemCount !== undefined && !isSafeCount(itemCount)) + ) { + return null; + } + return { + ...(byteCount === undefined ? {} : { byteCount: Number(byteCount) }), + ...(itemCount === undefined ? {} : { itemCount: Number(itemCount) }), + }; +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isSafeCount(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts new file mode 100644 index 000000000..59bc4ecc6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts @@ -0,0 +1,327 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +const validCapture = { + eventLoopDelay: { maxMs: 6, p95Ms: 5, p99Ms: 5.5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.5, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1_050, + invalidReason: null, + requestReceivedEpochMs: 1_000, + responsePostedEpochMs: 1_055, + threadCpuSystemMicros: 50, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 100, + workEndedEpochMs: 1_040, + workStartedEpochMs: 1_010, +} as const; + +function normalizeForOperation( + operation: string, + performanceCapture: unknown +) { + return normalizeWorkerRequestPerformanceOutcome({ + ipcCallId: 2, + operation, + operationId: 'operation-42', + operationIdUnavailableReason: null, + performanceCapture, + playlistId: 'playlist-1', + requestId: 'request-1', + responseEpochMs: 1_060, + sourceEpochMs: 990, + success: true, + }); +} + +function normalize(performanceCapture: unknown) { + return normalizeForOperation('DB_GET_CONTENT', performanceCapture); +} + +function assertClosedCapture( + capture: WorkerRequestPerformanceMetrics, + reason: string +): void { + assert.equal(capture.performanceCaptureUnavailableReason, reason); + assert.equal(capture.operationId, 'operation-42'); + for (const value of [ + capture.eventLoopDelay, + capture.eventLoopDelayUnavailableReason, + capture.eventLoopUtilization, + capture.eventLoopUtilizationUnavailableReason, + capture.histogramFlushedEpochMs, + capture.invalidReason, + capture.requestReceivedEpochMs, + capture.responsePostedEpochMs, + capture.threadCpuSystemMicros, + capture.threadCpuUnavailableReason, + capture.threadCpuUserMicros, + capture.workEndedEpochMs, + capture.workStartedEpochMs, + ]) { + assert.equal(value, null); + } + assert.deepEqual(capture.phaseEvents, []); +} + +test('absent captures are missing while present non-record captures are invalid', () => { + for (const missing of [undefined, null]) { + assertClosedCapture( + normalize(missing), + 'worker-performance-capture-missing' + ); + } + for (const malformed of [42, [], 'capture']) { + assertClosedCapture( + normalize(malformed), + 'worker-performance-capture-invalid' + ); + } +}); + +test('malformed nested fields and cross-field invariants fail the whole capture closed', () => { + const malformed = [ + { ...validCapture, eventLoopDelay: [] }, + { + ...validCapture, + eventLoopDelay: { maxMs: 6, p95Ms: 'bad', p99Ms: 5.5 }, + }, + { + ...validCapture, + eventLoopDelay: { maxMs: 5, p95Ms: 6, p99Ms: 5.5 }, + }, + { ...validCapture, eventLoopDelayUnavailableReason: 42 }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: null, + }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + }, + { ...validCapture, eventLoopUtilization: 1.01 }, + { + ...validCapture, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: null, + }, + { ...validCapture, threadCpuSystemMicros: null }, + { + ...validCapture, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: null, + threadCpuUserMicros: null, + }, + { ...validCapture, threadCpuUnavailableReason: 'unavailable' }, + { ...validCapture, histogramFlushedEpochMs: null }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-invalid', + histogramFlushedEpochMs: null, + }, + { ...validCapture, histogramFlushedEpochMs: 1_039 }, + { ...validCapture, responsePostedEpochMs: undefined }, + { ...validCapture, responsePostedEpochMs: 1_039 }, + { ...validCapture, responsePostedEpochMs: 1_061 }, + { ...validCapture, workStartedEpochMs: 999 }, + { + ...validCapture, + invalidReason: 'overlapping-database-worker-requests', + }, + { ...validCapture, threadCpuUserMicros: undefined }, + ]; + for (const performanceCapture of malformed) { + assertClosedCapture( + normalize(performanceCapture), + 'worker-performance-capture-invalid' + ); + } +}); + +test('coherent metric-unavailable and invalid worker results remain valid raw captures', () => { + const unavailable = { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-capture-unavailable', + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'event-loop-utilization-unavailable', + histogramFlushedEpochMs: null, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: 'thread-cpu-usage-unavailable', + threadCpuUserMicros: null, + }; + for (const capture of [ + unavailable, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-invalid', + }, + ]) { + const accepted = normalize(capture); + assert.equal(accepted.performanceCaptureUnavailableReason, null); + assert.equal(accepted.requestReceivedEpochMs, 1_000); + } + + const invalidReason = 'overlapping-database-worker-requests'; + const legitimateInvalid = normalize({ + ...unavailable, + eventLoopDelayUnavailableReason: invalidReason, + eventLoopUtilizationUnavailableReason: invalidReason, + invalidReason, + threadCpuUnavailableReason: invalidReason, + }); + assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null); + assert.equal(legitimateInvalid.invalidReason, invalidReason); +}); + +test('requires the exact operation-specific worker phase sequence', () => { + const upsertPhaseEvents = [ + { + boundary: 'start', + durationMs: null, + epochMs: 1_015, + phase: 'serialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 5, + epochMs: 1_020, + metadata: { byteCount: 2048, itemCount: 10_000 }, + phase: 'serialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 1_021, + phase: 'sqlite.write', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12, + epochMs: 1_033, + metadata: { byteCount: 2048, itemCount: 1 }, + phase: 'sqlite.write', + requestId: 'request-1', + }, + ] as const; + const getPhaseEvents = [ + { + boundary: 'start', + durationMs: null, + epochMs: 1_015, + phase: 'sqlite.read', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 5, + epochMs: 1_020, + metadata: { itemCount: 1 }, + phase: 'sqlite.read', + requestId: 'request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 1_021, + phase: 'deserialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12, + epochMs: 1_033, + metadata: { itemCount: 10_000 }, + phase: 'deserialize.playlist', + requestId: 'request-1', + }, + ] as const; + + assert.deepEqual( + normalizeForOperation('DB_UPSERT_APP_PLAYLIST', { + ...validCapture, + phaseEvents: upsertPhaseEvents, + }).phaseEvents, + upsertPhaseEvents + ); + assert.deepEqual( + normalizeForOperation('DB_GET_APP_PLAYLIST', { + ...validCapture, + phaseEvents: getPhaseEvents, + }).phaseEvents, + getPhaseEvents + ); + + for (const invalidPhaseEvents of [ + getPhaseEvents.slice(0, 2), + getPhaseEvents.slice(1), + [...getPhaseEvents].reverse(), + getPhaseEvents.map((event) => ({ + ...event, + requestId: 'other-request', + })), + [ + getPhaseEvents[0], + { + ...getPhaseEvents[1], + metadata: { itemCount: 1, secret: 'not allowed' }, + }, + ], + [ + getPhaseEvents[0], + { + ...getPhaseEvents[1], + phase: 'arbitrary.phase', + }, + ], + ]) { + assertClosedCapture( + normalizeForOperation('DB_GET_APP_PLAYLIST', { + ...validCapture, + phaseEvents: invalidPhaseEvents, + }), + 'worker-performance-capture-invalid' + ); + } + + for (const [operation, phaseEvents] of [ + ['DB_GET_APP_PLAYLIST', upsertPhaseEvents], + ['DB_UPSERT_APP_PLAYLIST', getPhaseEvents], + ['DB_GET_CONTENT', getPhaseEvents], + ] as const) { + assertClosedCapture( + normalizeForOperation(operation, { + ...validCapture, + phaseEvents, + }), + 'worker-performance-capture-invalid' + ); + } + + for (const operation of [ + 'DB_GET_APP_PLAYLIST', + 'DB_UPSERT_APP_PLAYLIST', + ]) { + assertClosedCapture( + normalizeForOperation(operation, validCapture), + 'worker-performance-capture-invalid' + ); + } + + assert.deepEqual(normalize(validCapture).phaseEvents, []); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts new file mode 100644 index 000000000..e5149e442 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -0,0 +1,397 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { + type CancellationBenchmarkManifest, + type CancellationIterationResult, + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, + type WorkerCaptureMetrics, + type WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + type MainCaptureGenerationTransport, + normalizeWorkerRequestPerformanceOutcome, + selectMainCaptureGeneration, +} from './worker-request-performance'; +import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report'; + +type RequestPerformanceFixture = WorkerRequestPerformanceMetrics; +function requestPerformance( + requestId: string, + p95Ms: number, + threadCpuUserMicros: number +): RequestPerformanceFixture { + return normalizeWorkerRequestPerformanceOutcome( + requestTransport( + { + eventLoopDelay: { + maxMs: p95Ms + 1, + p95Ms, + p99Ms: p95Ms + 0.5, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: p95Ms / 100, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1_050, + invalidReason: null, + requestReceivedEpochMs: 1_000, + responsePostedEpochMs: 1_055, + threadCpuSystemMicros: threadCpuUserMicros / 2, + threadCpuUnavailableReason: null, + threadCpuUserMicros, + workEndedEpochMs: 1_040, + workStartedEpochMs: 1_010, + }, + requestId + ) + ); +} +function requestTransport( + performanceCapture: unknown, + requestId = 'request-1' +) { + return { + ipcCallId: 7, + operation: 'DB_GET_CONTENT', + operationId: 'operation-42', + operationIdUnavailableReason: null, + performanceCapture, + playlistId: 'playlist-1', + requestId, + responseEpochMs: 1_060, + sourceEpochMs: 990, + success: true, + } as const; +} +function assertClosedCapture( + capture: WorkerRequestPerformanceMetrics, + reason: string +): void { + assert.equal(capture.performanceCaptureUnavailableReason, reason); + assert.equal(capture.operationId, 'operation-42'); + for (const value of [ + capture.eventLoopDelay, + capture.eventLoopDelayUnavailableReason, + capture.eventLoopUtilization, + capture.eventLoopUtilizationUnavailableReason, + capture.histogramFlushedEpochMs, + capture.invalidReason, + capture.requestReceivedEpochMs, + capture.responsePostedEpochMs, + capture.threadCpuSystemMicros, + capture.threadCpuUnavailableReason, + capture.threadCpuUserMicros, + capture.workEndedEpochMs, + capture.workStartedEpochMs, + ]) { + assert.equal(value, null); + } +} +function databaseWorker( + requests: readonly RequestPerformanceFixture[] +): WorkerCaptureMetrics { + return { + kind: PERFORMANCE_WORKER_KIND.DATABASE, + operationId: null, + peakExternalBytes: 0, + peakHeapUsedBytes: 0, + playlistId: null, + postGcHeapUsedBytes: null, + requests, + responseEpochMs: null, + terminatedEpochMs: null, + } as WorkerCaptureMetrics; +} +function playlistWorker( + request: RequestPerformanceFixture, + operationId: string, + terminatedEpochMs: number +): WorkerCaptureMetrics { + return { + ...databaseWorker([request]), + kind: PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + operationId, + playlistId: request.playlistId, + responseEpochMs: request.responseEpochMs, + terminatedEpochMs, + }; +} + +function measuredIteration( + workers: readonly WorkerCaptureMetrics[] +): CancellationIterationResult { + return { + cancellationEffectObserved: true, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 }, + eventLoopUtilization: null, + memory: { + peakHeapUsedBytes: 0, + peakRssBytes: 0, + postGcHeapUsedBytes: null, + postGcRssBytes: null, + }, + rendererWindow: { + responsiveEvents: 0, + rss: { + identity: { + creationTime: 1_721_234_567_890, + pid: 42, + }, + missingSampleCount: 0, + peakRssBytes: 0, + unavailableReason: null, + validSampleCount: 1, + }, + unresponsiveEvents: 0, + windowIdentity: { + browserWindowId: 7, + webContentsId: 11, + }, + }, + workers, + } as CancellationIterationResult['main'], + phases: {} as CancellationIterationResult['phases'], + renderer: { + peakHeapUsedBytes: 0, + postGcHeapUsedBytes: null, + probe: { + frameGapsMs: [], + heartbeatDelaysMs: [], + longTasksMs: [], + }, + } as CancellationIterationResult['renderer'], + runId: 'measured-1', + }; +} + +test('summary distributions use every request capture instead of a worker-level percentile maximum', () => { + const requests = [ + requestPerformance('request-1', 5, 100), + requestPerformance('request-2', 15, 300), + ]; + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [measuredIteration([databaseWorker(requests)])] + ); + const measured = summary.measured as unknown as Record< + string, + { count: number; max: number | null; min: number | null } + >; + + assert.deepEqual( + summary.measured.databaseWorkerRequestEventLoopDelayP95Ms, + { + count: 2, + max: 15, + mean: 10, + median: 10, + min: 5, + p95: 14.5, + p99: 14.9, + } + ); + assert.deepEqual(measured['databaseWorkerRequestThreadCpuUserMicros'], { + count: 2, + max: 300, + mean: 200, + median: 200, + min: 100, + p95: 290, + p99: 298, + }); + assert.equal( + summary.iterations[0]?.main.workers[0]?.requests[0]?.operationId, + 'operation-42' + ); + assert.equal( + 'databaseWorkerEventLoopDelayP95Ms' in summary.measured, + false, + 'request-percentile distributions must not look like an operation-wide percentile' + ); +}); + +test('main capture retains raw request identity, timestamps, metrics, and reasons', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match(source, /requestPerformance: \[\]/); + assert.match(source, /record\.requestPerformance\.map/); + assert.match(source, /operationId: request\.identity\.operationId/); + assert.match(source, /ipcCallId: request\.identity\.ipcCallId/); + assert.match(source, /identity: request\.identity/); + assert.match(source, /performanceCapture:\s+message\['performance'\]/); + assert.match(source, /sourceEpochMs: request\.identity\.sourceEpochMs/); + assert.match(source, /captureGeneration: state\.captureGeneration/); + assert.doesNotMatch( + source, + /record\.eventLoopDelay = record\.eventLoopDelay/ + ); +}); + +test('normalized raw request metrics retain the exact preload IPC call identity', () => { + const capture = requestPerformance('request-1', 5, 100); + + assert.equal(capture.ipcCallId, 7); + assert.equal(capture.sourceEpochMs, 990); +}); + +test('benchmark resolves one exact BrowserWindow and main capture never scans all windows', () => { + const benchmarkSource = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const mainCaptureSource = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match( + benchmarkSource, + /electronApp\.browserWindow\(\s*app\.mainWindow\s*\)/ + ); + assert.match(benchmarkSource, /rendererWindowIdentity/); + assert.doesNotMatch(mainCaptureSource, /BrowserWindow\.getAllWindows/); + assert.doesNotMatch(mainCaptureSource, /type\.includes\('renderer'\)/); + assert.doesNotMatch(mainCaptureSource, /type\.includes\('tab'\)/); +}); + +test('capture-generation selection excludes a pre-start seed worker', () => { + const seed = playlistWorker( + requestPerformance('seed-request', 999, 999), + 'seed-operation', + 1_100 + ); + const measured = playlistWorker( + { + ...requestPerformance('measured-request', 8, 80), + success: false, + }, + 'measured-cancel-operation', + 2_100 + ); + + const mainMetrics = measuredIteration([]).main; + const toTransportWorker = ( + captureGeneration: number | null, + worker: WorkerCaptureMetrics + ) => ({ + captureGeneration, + metrics: worker, + requests: worker.requests.map((request) => ({ + ...requestTransport(request, request.requestId ?? undefined), + success: request.success, + })), + }); + const selected = selectMainCaptureGeneration({ + captureGeneration: 2, + metrics: mainMetrics, + workers: [ + toTransportWorker(null, seed), + toTransportWorker(2, measured), + ], + }); + + assert.equal(selected.workers.length, 1); + assert.equal( + selected.workers[0]?.requests[0]?.requestId, + 'measured-request' + ); + assert.equal(selected.workers[0]?.operationId, 'measured-cancel-operation'); + assert.equal(selected.workers[0]?.terminatedEpochMs, 2_100); +}); + +test('fails closed for incoherent worker post-GC metrics at the Electron boundary', () => { + const invalidOutcomes = [ + { + postGcHeapUnavailableReason: 'gc-unavailable', + postGcHeapUsedBytes: 100, + }, + { + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: null, + }, + { + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: -1, + }, + { + postGcHeapUnavailableReason: 'unknown-reason', + postGcHeapUsedBytes: null, + }, + ] as const; + + for (const outcome of invalidOutcomes) { + const selected = selectMainCaptureGeneration({ + captureGeneration: 3, + metrics: measuredIteration([]).main, + workers: [ + { + captureGeneration: 3, + metrics: { + ...databaseWorker([]), + ...outcome, + }, + requests: [], + }, + ], + } as unknown as MainCaptureGenerationTransport); + const worker = selected.workers[0] as WorkerCaptureMetrics & { + readonly postGcHeapUnavailableReason: string | null; + }; + + assert.equal(worker.postGcHeapUsedBytes, null); + assert.equal( + worker.postGcHeapUnavailableReason, + 'post-gc-capture-invalid' + ); + } +}); + +test('raw outcomes retain missing and malformed captures while summaries exclude their null metrics', () => { + const valid = normalizeWorkerRequestPerformanceOutcome( + requestTransport(requestPerformance('request-1', 5, 100)) + ); + const missing = normalizeWorkerRequestPerformanceOutcome( + requestTransport(null, 'request-2') + ); + const malformed = normalizeWorkerRequestPerformanceOutcome( + requestTransport( + { + requestReceivedEpochMs: 'invalid', + workEndedEpochMs: 1_080, + workStartedEpochMs: 1_075, + }, + 'request-3' + ) + ); + const worker = databaseWorker([valid, missing, malformed]); + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [measuredIteration([worker])] + ); + + assert.equal(worker.requests.length, 3); + assertClosedCapture( + worker.requests[1] as WorkerRequestPerformanceMetrics, + 'worker-performance-capture-missing' + ); + assertClosedCapture( + worker.requests[2] as WorkerRequestPerformanceMetrics, + 'worker-performance-capture-invalid' + ); + assert.equal( + summary.measured.databaseWorkerRequestEventLoopDelayP95Ms.count, + 1 + ); + assert.equal( + summary.measured.databaseWorkerRequestThreadCpuUserMicros.count, + 1 + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts new file mode 100644 index 000000000..4a5cc00ac --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -0,0 +1,385 @@ +import type { + EventLoopDelayMetrics, + MainCaptureMetrics, + WorkerCaptureMetrics, + WorkerPostGcHeapCapture, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { WORKER_POST_GC_HEAP_UNAVAILABLE_REASON } from './m3u-refresh-cancellation-contract'; +import { parseWorkerPerformancePhaseEvents } from './worker-performance-phase-events'; + +export interface WorkerRequestPerformanceOutcomeTransport { + readonly ipcCallId: number | null; + readonly operation: string | null; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly performanceCapture: unknown; + readonly playlistId: string | null; + readonly requestId: string | null; + readonly responseEpochMs: number; + readonly sourceEpochMs: number | null; + readonly success: boolean; +} + +export interface MainCaptureGenerationTransport { + readonly captureGeneration: number; + readonly metrics: Omit; + readonly workers: readonly { + readonly captureGeneration: number | null; + readonly metrics: Omit< + WorkerCaptureMetrics, + 'eventLoopDelay' | 'eventLoopDelayUnavailableReason' | 'requests' + >; + readonly requests: readonly WorkerRequestPerformanceOutcomeTransport[]; + }[]; +} + +type ParsedWorkerPerformanceCapture = Pick< + WorkerRequestPerformanceMetrics, + | 'eventLoopDelay' + | 'eventLoopDelayUnavailableReason' + | 'eventLoopUtilization' + | 'eventLoopUtilizationUnavailableReason' + | 'histogramFlushedEpochMs' + | 'invalidReason' + | 'phaseEvents' + | 'requestReceivedEpochMs' + | 'responsePostedEpochMs' + | 'threadCpuSystemMicros' + | 'threadCpuUnavailableReason' + | 'threadCpuUserMicros' + | 'workEndedEpochMs' + | 'workStartedEpochMs' +>; + +const INVALID_REASONS = new Set(['overlapping-database-worker-requests']); +const EVENT_LOOP_DELAY_REASONS = new Set([ + ...INVALID_REASONS, + 'event-loop-delay-arm-timeout', + 'event-loop-delay-capture-unavailable', + 'event-loop-delay-flush-timeout', + 'event-loop-delay-invalid', +]); +const EVENT_LOOP_UTILIZATION_REASONS = new Set([ + ...INVALID_REASONS, + 'event-loop-utilization-unavailable', +]); +const THREAD_CPU_REASONS = new Set([ + ...INVALID_REASONS, + 'thread-cpu-usage-invalid', + 'thread-cpu-usage-unavailable', +]); +const WORKER_POST_GC_HEAP_UNAVAILABLE_REASONS = new Set( + Object.values(WORKER_POST_GC_HEAP_UNAVAILABLE_REASON) +); + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isNullableFiniteNonNegativeNumber( + value: unknown +): value is number | null { + return value === null || isFiniteNonNegativeNumber(value); +} + +function isNullableReason( + value: unknown, + allowed: ReadonlySet +): value is string | null { + return value === null || (typeof value === 'string' && allowed.has(value)); +} + +function normalizeWorkerPostGcHeapCapture( + input: Record +): WorkerPostGcHeapCapture { + const postGcHeapUnavailableReason = input['postGcHeapUnavailableReason']; + const postGcHeapUsedBytes = input['postGcHeapUsedBytes']; + if ( + Number.isSafeInteger(postGcHeapUsedBytes) && + Number(postGcHeapUsedBytes) >= 0 && + postGcHeapUnavailableReason === null + ) { + return { + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: Number(postGcHeapUsedBytes), + }; + } + if ( + postGcHeapUsedBytes === null && + typeof postGcHeapUnavailableReason === 'string' && + WORKER_POST_GC_HEAP_UNAVAILABLE_REASONS.has(postGcHeapUnavailableReason) + ) { + return { + postGcHeapUnavailableReason, + postGcHeapUsedBytes: null, + } as WorkerPostGcHeapCapture; + } + return { + postGcHeapUnavailableReason: + WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.INVALID_CAPTURE, + postGcHeapUsedBytes: null, + }; +} + +function parseEventLoopDelay( + input: unknown +): EventLoopDelayMetrics | null | undefined { + if (input === null) { + return null; + } + if (!isRecord(input)) { + return undefined; + } + const maxMs = input['maxMs']; + const p95Ms = input['p95Ms']; + const p99Ms = input['p99Ms']; + if ( + !isFiniteNonNegativeNumber(maxMs) || + !isFiniteNonNegativeNumber(p95Ms) || + !isFiniteNonNegativeNumber(p99Ms) || + p95Ms > p99Ms || + p99Ms > maxMs + ) { + return undefined; + } + return { maxMs, p95Ms, p99Ms }; +} + +function parseWorkerPerformanceCapture( + input: unknown, + operation: string | null +): ParsedWorkerPerformanceCapture | null { + if (!isRecord(input)) { + return null; + } + + const eventLoopDelay = parseEventLoopDelay(input['eventLoopDelay']); + const eventLoopDelayUnavailableReason = + input['eventLoopDelayUnavailableReason']; + const eventLoopUtilization = input['eventLoopUtilization']; + const eventLoopUtilizationUnavailableReason = + input['eventLoopUtilizationUnavailableReason']; + const histogramFlushedEpochMs = input['histogramFlushedEpochMs']; + const invalidReason = input['invalidReason']; + const requestReceivedEpochMs = input['requestReceivedEpochMs']; + const responsePostedEpochMs = input['responsePostedEpochMs']; + const threadCpuSystemMicros = input['threadCpuSystemMicros']; + const threadCpuUnavailableReason = input['threadCpuUnavailableReason']; + const threadCpuUserMicros = input['threadCpuUserMicros']; + const workEndedEpochMs = input['workEndedEpochMs']; + const workStartedEpochMs = input['workStartedEpochMs']; + + if ( + eventLoopDelay === undefined || + !isNullableReason( + eventLoopDelayUnavailableReason, + EVENT_LOOP_DELAY_REASONS + ) || + !isNullableFiniteNonNegativeNumber(eventLoopUtilization) || + (eventLoopUtilization !== null && eventLoopUtilization > 1) || + !isNullableReason( + eventLoopUtilizationUnavailableReason, + EVENT_LOOP_UTILIZATION_REASONS + ) || + !isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) || + !isNullableReason(invalidReason, INVALID_REASONS) || + !isFiniteNonNegativeNumber(requestReceivedEpochMs) || + !isFiniteNonNegativeNumber(responsePostedEpochMs) || + !isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) || + !isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) || + !isNullableFiniteNonNegativeNumber(threadCpuUserMicros) || + !isFiniteNonNegativeNumber(workEndedEpochMs) || + !isFiniteNonNegativeNumber(workStartedEpochMs) + ) { + return null; + } + + const phaseEvents = parseWorkerPerformancePhaseEvents( + operation, + input['phaseEvents'], + workStartedEpochMs, + workEndedEpochMs + ); + if (phaseEvents === null) { + return null; + } + + const timestampsAreOrdered = + requestReceivedEpochMs <= workStartedEpochMs && + workStartedEpochMs <= workEndedEpochMs && + workEndedEpochMs <= responsePostedEpochMs && + (histogramFlushedEpochMs === null || + (workEndedEpochMs <= histogramFlushedEpochMs && + histogramFlushedEpochMs <= responsePostedEpochMs)); + const eventLoopDelayIsCoherent = + eventLoopDelay === null + ? eventLoopDelayUnavailableReason !== null && + (eventLoopDelayUnavailableReason === 'event-loop-delay-invalid' + ? histogramFlushedEpochMs !== null + : histogramFlushedEpochMs === null) + : eventLoopDelayUnavailableReason === null && + histogramFlushedEpochMs !== null; + const eventLoopUtilizationIsCoherent = + eventLoopUtilization === null + ? eventLoopUtilizationUnavailableReason !== null + : eventLoopUtilizationUnavailableReason === null; + const threadCpuIsCoherent = + threadCpuSystemMicros === null && threadCpuUserMicros === null + ? threadCpuUnavailableReason !== null + : threadCpuSystemMicros !== null && + threadCpuUserMicros !== null && + threadCpuUnavailableReason === null; + const invalidCaptureIsCoherent = + invalidReason === null || + (eventLoopDelay === null && + eventLoopDelayUnavailableReason === invalidReason && + eventLoopUtilization === null && + eventLoopUtilizationUnavailableReason === invalidReason && + histogramFlushedEpochMs === null && + threadCpuSystemMicros === null && + threadCpuUnavailableReason === invalidReason && + threadCpuUserMicros === null); + if ( + !timestampsAreOrdered || + !eventLoopDelayIsCoherent || + !eventLoopUtilizationIsCoherent || + !threadCpuIsCoherent || + !invalidCaptureIsCoherent + ) { + return null; + } + + return { + eventLoopDelay, + eventLoopDelayUnavailableReason, + eventLoopUtilization, + eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs, + invalidReason, + phaseEvents, + requestReceivedEpochMs, + responsePostedEpochMs, + threadCpuSystemMicros, + threadCpuUnavailableReason, + threadCpuUserMicros, + workEndedEpochMs, + workStartedEpochMs, + }; +} + +export function normalizeWorkerRequestPerformanceOutcome( + input: WorkerRequestPerformanceOutcomeTransport +): WorkerRequestPerformanceMetrics { + const identity = { + ipcCallId: input.ipcCallId, + operation: input.operation, + operationId: input.operationId, + operationIdUnavailableReason: input.operationIdUnavailableReason, + playlistId: input.playlistId, + requestId: input.requestId, + responseEpochMs: input.responseEpochMs, + sourceEpochMs: input.sourceEpochMs, + success: input.success, + }; + const unavailable = ( + reason: + | 'worker-performance-capture-invalid' + | 'worker-performance-capture-missing' + ): WorkerRequestPerformanceMetrics => ({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: null, + invalidReason: null, + ...identity, + performanceCaptureUnavailableReason: reason, + phaseEvents: [], + requestReceivedEpochMs: null, + responsePostedEpochMs: null, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: null, + threadCpuUserMicros: null, + workEndedEpochMs: null, + workStartedEpochMs: null, + }); + + if ( + input.performanceCapture === undefined || + input.performanceCapture === null + ) { + return unavailable('worker-performance-capture-missing'); + } + + const performanceCapture = parseWorkerPerformanceCapture( + input.performanceCapture, + input.operation + ); + if (!performanceCapture) { + return unavailable('worker-performance-capture-invalid'); + } + if ( + !isFiniteNonNegativeNumber(input.responseEpochMs) || + performanceCapture.responsePostedEpochMs > input.responseEpochMs + ) { + return unavailable('worker-performance-capture-invalid'); + } + if ( + performanceCapture.phaseEvents.length > 0 && + (input.requestId === null || + performanceCapture.phaseEvents.some( + (event) => event.requestId !== input.requestId + )) + ) { + return unavailable('worker-performance-capture-invalid'); + } + + return { + ...performanceCapture, + ...identity, + performanceCaptureUnavailableReason: null, + }; +} + +export function selectMainCaptureGeneration( + input: MainCaptureGenerationTransport +): MainCaptureMetrics { + const workers = input.workers + .filter( + (worker) => worker.captureGeneration === input.captureGeneration + ) + .map((worker): WorkerCaptureMetrics => { + const requests = worker.requests.map( + normalizeWorkerRequestPerformanceOutcome + ); + const onlyRequest = + requests.length === 1 ? (requests[0] ?? null) : null; + const postGcHeap = normalizeWorkerPostGcHeapCapture( + worker.metrics as unknown as Record + ); + return { + ...worker.metrics, + ...postGcHeap, + eventLoopDelay: onlyRequest?.eventLoopDelay ?? null, + eventLoopDelayUnavailableReason: onlyRequest + ? (onlyRequest.eventLoopDelayUnavailableReason ?? + onlyRequest.performanceCaptureUnavailableReason ?? + 'worker-self-profile-result-unavailable') + : requests.length > 1 + ? 'multiple-request-scoped-captures' + : worker.metrics.terminatedEpochMs !== null + ? 'worker-terminated-before-profile-flush' + : 'worker-self-profile-result-unavailable', + requests, + }; + }); + return { + ...input.metrics, + workers, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/worker-termination-generation.spec.ts b/apps/electron-backend-e2e/src/performance/worker-termination-generation.spec.ts new file mode 100644 index 000000000..f1ac4d93a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-termination-generation.spec.ts @@ -0,0 +1,102 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface WorkerTerminationGenerationApi { + isCurrent(input: { + readonly capturedGeneration: number | null; + readonly currentGeneration: number; + readonly recordGeneration: number | null; + }): boolean; +} + +interface WorkerTerminationGenerationModule { + createWorkerTerminationGenerationApi?: () => WorkerTerminationGenerationApi; +} + +const modulePromise = import( + new URL('./worker-termination-generation.ts', import.meta.url).href +) + .then((module) => module as WorkerTerminationGenerationModule) + .catch(() => null); + +test('rejects a delayed termination completion after capture rollover or record reuse', async () => { + const module = await modulePromise; + assert.ok(module, 'worker termination generation guard must exist'); + const api = module.createWorkerTerminationGenerationApi?.(); + assert.ok(api); + + assert.equal( + api.isCurrent({ + capturedGeneration: 4, + currentGeneration: 4, + recordGeneration: 4, + }), + true + ); + assert.equal( + api.isCurrent({ + capturedGeneration: 4, + currentGeneration: 5, + recordGeneration: 4, + }), + false, + 'a late completion must not write into the next global capture generation' + ); + assert.equal( + api.isCurrent({ + capturedGeneration: 4, + currentGeneration: 5, + recordGeneration: 5, + }), + false, + 'a reused record must not be finalized by the prior termination promise' + ); +}); + +test('fails closed for missing or invalid generation identities', async () => { + const module = await modulePromise; + assert.ok(module); + const api = module.createWorkerTerminationGenerationApi?.(); + assert.ok(api); + + for (const capturedGeneration of [null, 0, -1, 1.5, Number.NaN]) { + assert.equal( + api.isCurrent({ + capturedGeneration, + currentGeneration: 1, + recordGeneration: 1, + }), + false + ); + } +}); + +test('the Electron termination callback applies the generation guard before mutating capture state', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const terminateStart = source.indexOf( + 'WorkerClass.prototype.terminate = function' + ); + const terminateEnd = source.indexOf('const inspectorPost', terminateStart); + const terminateBlock = source.slice(terminateStart, terminateEnd); + const terminationGeneration = terminateBlock.indexOf( + 'const terminationGeneration = record.captureGeneration' + ); + const generationGuard = terminateBlock.indexOf( + 'workerTerminationGenerationApi.isCurrent' + ); + const terminationMutation = terminateBlock.indexOf( + 'record.terminatedEpochMs = nowEpochMs()' + ); + + assert.ok( + terminationGeneration >= 0 && + terminationGeneration < generationGuard && + generationGuard < terminationMutation, + 'late termination completion must be generation-gated before mutating the record or timeline' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-termination-generation.ts b/apps/electron-backend-e2e/src/performance/worker-termination-generation.ts new file mode 100644 index 000000000..68f77efbd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-termination-generation.ts @@ -0,0 +1,24 @@ +export interface WorkerTerminationGenerationInput { + readonly capturedGeneration: number | null; + readonly currentGeneration: number; + readonly recordGeneration: number | null; +} + +export interface WorkerTerminationGenerationApi { + isCurrent(input: WorkerTerminationGenerationInput): boolean; +} + +export function createWorkerTerminationGenerationApi(): WorkerTerminationGenerationApi { + const isCaptureGeneration = (value: number | null): value is number => + Number.isSafeInteger(value) && Number(value) > 0; + + return Object.freeze({ + isCurrent(input: WorkerTerminationGenerationInput): boolean { + return ( + isCaptureGeneration(input.capturedGeneration) && + input.capturedGeneration === input.currentGeneration && + input.capturedGeneration === input.recordGeneration + ); + }, + }); +} diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index eac33083a..ad7ca63ae 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -677,44 +677,23 @@ function copyGenericRuntimeToNativeBuild(runtime) { return manifest; } -function resolveElectronNodeGypBin() { - const pnpmRoot = path.join(workspaceRoot, 'node_modules', '.pnpm'); - - if (!fs.existsSync(pnpmRoot)) { - throw new Error('Unable to find node_modules/.pnpm.'); - } - - const packageDirs = fs - .readdirSync(pnpmRoot, { withFileTypes: true }) - .filter( - (entry) => - entry.isDirectory() && - entry.name.startsWith('@electron+node-gyp@') - ) - .map((entry) => entry.name) - .sort(); - - for (const packageDir of packageDirs) { - const candidate = path.join( - pnpmRoot, - packageDir, - 'node_modules', - '@electron', - 'node-gyp', - 'bin', - 'node-gyp.js' +// Upstream node-gyp, resolved as a declared devDependency. This used to scan +// node_modules/.pnpm for the `@electron/node-gyp` fork, which was only ever in +// the tree as a transitive of `@electron/rebuild` 3 — rebuild 4 moved to +// upstream `node-gyp` and the scan started throwing. The Electron target is +// selected through the npm_config_* env below, not by the binary. +function resolveNodeGypBin() { + try { + return require.resolve('node-gyp/bin/node-gyp.js'); + } catch (error) { + throw new Error( + `Unable to resolve node-gyp. Is it installed? (${error.message})` ); - - if (fs.existsSync(candidate)) { - return candidate; - } } - - throw new Error('Unable to resolve @electron/node-gyp.'); } function runNodeGyp(command, env) { - const nodeGypBin = resolveElectronNodeGypBin(); + const nodeGypBin = resolveNodeGypBin(); const result = spawnSync( process.execPath, [nodeGypBin, command, '--directory', addonRoot], diff --git a/apps/electron-backend/build-worker.js b/apps/electron-backend/build-worker.js index a80817272..649391365 100644 --- a/apps/electron-backend/build-worker.js +++ b/apps/electron-backend/build-worker.js @@ -18,6 +18,12 @@ const nativeModules = [ ]; const isProduction = process.env.NODE_ENV === 'production'; +const isPerformance = process.argv.includes('--performance'); +const buildMode = isPerformance + ? 'performance' + : isProduction + ? 'production' + : 'development'; async function buildWorker() { try { @@ -59,7 +65,7 @@ async function buildWorker() { for (const worker of workers) { console.log( - `Building ${worker.label} with esbuild (${isProduction ? 'production' : 'development'})...` + `Building ${worker.label} with esbuild (${buildMode})...` ); await esbuild.build({ @@ -74,8 +80,8 @@ async function buildWorker() { ...nodeBuiltins, ...nativeModules, ], - sourcemap: !isProduction, - minify: isProduction, + sourcemap: isPerformance || !isProduction, + minify: isProduction || isPerformance, alias: { '@iptvnator/shared/interfaces': path.join( __dirname, diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 8e6b9febb..6e4107999 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -12,6 +12,13 @@ "command": "node apps/electron-backend/build-worker.js" } }, + "build-worker-performance": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"], + "options": { + "command": "node apps/electron-backend/build-worker.js --performance" + } + }, "build-embedded-mpv": { "executor": "nx:run-commands", "cache": false, @@ -81,9 +88,35 @@ "with": "apps/electron-backend/src/environments/environment.prod.ts" } ] + }, + "electron-performance": { + "optimization": true, + "extractLicenses": true, + "inspect": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/electron-backend/src/environments/environment.ts", + "with": "apps/electron-backend/src/environments/environment.prod.ts" + } + ] } } }, + "build-performance": { + "dependsOn": [ + "electron-backend:build-worker-performance", + "electron-backend:build-embedded-mpv", + { + "projects": ["web", "remote-control-web"], + "target": "build-performance" + } + ], + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies" + } + }, "build-e2e": { "dependsOn": [ "electron-backend:build-worker", diff --git a/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts new file mode 100644 index 000000000..e4dc78a44 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts @@ -0,0 +1,343 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type Playlist, + type PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylist, + createPreloadPerformanceHarness, + createRefreshPayload, + expectFixedMarkers, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker contract', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + }); + + async function loadPerformancePreload(): Promise { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + } + + it('preserves calls while emitting ordered, correlated, fixed, redacted markers', async () => { + const secrets = { + favorite: 'sentinel-favorite-secret', + filePath: '/sentinel/private/playlist.m3u', + host: 'sentinel.private.example', + item: 'sentinel-item-secret', + result: 'sentinel-result-secret', + title: 'sentinel-title-secret', + url: 'https://sentinel.example/private.m3u?token=secret', + }; + const payload = createRefreshPayload({ + filePath: secrets.filePath, + title: secrets.title, + trustedInsecureTlsHosts: [secrets.host], + url: secrets.url, + }); + const refreshedPlaylist = createPlaylist(payload.playlistId, { + favorites: [secrets.favorite], + items: [{ name: secrets.item }], + title: secrets.title, + url: secrets.url, + }); + const storedPlaylist = createPlaylist(payload.playlistId, { + filePath: secrets.filePath, + title: secrets.title, + }); + const upsertPlaylist = createPlaylist(payload.playlistId, { + favorites: [secrets.favorite], + items: [{ name: secrets.item }], + title: secrets.title, + }); + const upsertResult = { + secretResult: secrets.result, + success: true, + }; + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(refreshedPlaylist) + .mockResolvedValueOnce(storedPlaylist) + .mockResolvedValueOnce(upsertResult); + const api = harness.getApi(); + + await expect(api.refreshPlaylist(payload)).resolves.toBe( + refreshedPlaylist + ); + await expect( + api.dbGetAppPlaylist(payload.playlistId, payload.operationId) + ).resolves.toBe(storedPlaylist); + await expect( + api.dbUpsertAppPlaylist(upsertPlaylist, payload.operationId) + ).resolves.toBe(upsertResult); + + expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ + ['PLAYLIST:REFRESH', payload], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', upsertPlaylist], + ]); + const markers = harness.getMarkers(); + expect(markers).toHaveLength(6); + expect( + markers.map(({ ipcCallId, method, phase }) => [ + ipcCallId, + method, + phase, + ]) + ).toEqual([ + [1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'start'], + [1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'success'], + [2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'start'], + [2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'success'], + [3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'start'], + [3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'success'], + ]); + expect(markers.map(({ correlationState }) => correlationState)).toEqual( + [ + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + ] + ); + expect(markers.map(({ operationId }) => operationId)).toEqual( + Array(6).fill(payload.operationId) + ); + expect(markers.map(({ playlistId }) => playlistId)).toEqual( + Array(6).fill(payload.playlistId) + ); + expect(markers.map(({ invalidReason }) => invalidReason)).toEqual( + Array(6).fill(null) + ); + expectFixedMarkers(markers); + + const markerSendOrders = harness.ipcRenderer.send.mock.calls.flatMap( + ([channel], index) => + channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL + ? [harness.ipcRenderer.send.mock.invocationCallOrder[index]] + : [] + ); + const invokeOrders = + harness.ipcRenderer.invoke.mock.invocationCallOrder; + for (const [index, invokeOrder] of invokeOrders.entries()) { + expect(markerSendOrders[index * 2]).toBeLessThan(invokeOrder); + } + + const serializedMarkers = JSON.stringify(markers); + for (const secret of Object.values(secrets)) { + expect(serializedMarkers).not.toContain(secret); + } + }); + + it('preserves rejection identity and emits a redacted error', async () => { + const payload = createRefreshPayload({ + title: 'sentinel-rejection-title', + url: 'https://sentinel.example/rejected.m3u', + }); + const rejection = new Error('sentinel-rejection-error'); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect(harness.getApi().refreshPlaylist(payload)).rejects.toBe( + rejection + ); + + const markers = harness.getMarkers(); + expect(markerPhases(markers)).toEqual([ + [1, 'start'], + [1, 'error'], + ]); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + operationId: payload.operationId, + playlistId: payload.playlistId, + }); + expect(JSON.stringify(markers)).not.toContain(rejection.message); + expect(JSON.stringify(markers)).not.toContain(payload.title); + expect(JSON.stringify(markers)).not.toContain(payload.url); + }); + + it('preserves synchronous throw identity and emits a redacted error', async () => { + const synchronousError = new Error('sentinel-synchronous-error'); + const payload = createRefreshPayload(); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockImplementationOnce(() => { + throw synchronousError; + }); + + let caughtError: unknown; + try { + harness.getApi().refreshPlaylist(payload); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBe(synchronousError); + const markers = harness.getMarkers(); + expect(markerPhases(markers)).toEqual([ + [1, 'start'], + [1, 'error'], + ]); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + }); + expect(JSON.stringify(markers)).not.toContain(synchronousError.message); + }); + + it('closes a structured refresh cancellation with a fixed reason', async () => { + const payload = createRefreshPayload(); + const cancelledResult = { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + } as const; + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(cancelledResult) + .mockResolvedValueOnce(null); + const api = harness.getApi(); + + await expect(api.refreshPlaylist(payload)).resolves.toBe( + cancelledResult + ); + await api.dbGetAppPlaylist(payload.playlistId); + + const markers = harness.getMarkers(); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED, + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: payload.operationId, + phase: 'success', + playlistId: payload.playlistId, + }); + expect(markers[2]).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + }); + }); + + it('uses the exact upsert _id and keeps one malformed reason in both phases', async () => { + const malformedPlaylist = { + ...createPlaylist('placeholder'), + _id: { + secret: 'sentinel-malformed-playlist-id', + }, + } as unknown as Playlist; + const result = { success: true }; + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect( + harness.getApi().dbUpsertAppPlaylist(malformedPlaylist) + ).resolves.toBe(result); + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + 'DB_UPSERT_APP_PLAYLIST', + malformedPlaylist + ); + + const markers = harness.getMarkers(); + expect( + markers.map( + ({ correlationState, invalidReason, phase, playlistId }) => ({ + correlationState, + invalidReason, + phase, + playlistId, + }) + ) + ).toEqual([ + { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + phase: 'start', + playlistId: null, + }, + { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + phase: 'success', + playlistId: null, + }, + ]); + expect(markers[0].ipcCallId).toBe(markers[1].ipcCallId); + expect(JSON.stringify(markers)).not.toContain( + 'sentinel-malformed-playlist-id' + ); + }); + + it('poisons correlation after the first marker delivery failure', async () => { + const payload = createRefreshPayload(); + const result = createPlaylist(payload.playlistId); + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(result) + .mockResolvedValueOnce(result) + .mockResolvedValueOnce({ success: true }); + harness.ipcRenderer.send.mockImplementationOnce((channel: string) => { + if (channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL) { + throw new Error('marker-send-failed'); + } + }); + + await expect(harness.getApi().refreshPlaylist(payload)).resolves.toBe( + result + ); + await expect( + harness + .getApi() + .dbGetAppPlaylist(payload.playlistId, payload.operationId) + ).resolves.toBe(result); + await expect( + harness.getApi().dbUpsertAppPlaylist(result, payload.operationId) + ).resolves.toEqual({ success: true }); + expect( + harness.ipcRenderer.send.mock.calls.filter( + ([channel]) => channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL + ) + ).toHaveLength(1); + expect(harness.getMarkers()).toHaveLength(1); + expect(harness.getMarkers()[0]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + phase: 'start', + }); + expect( + harness + .getMarkers() + .some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + }); +}); + +function markerPhases( + markers: PreloadPerformanceMarker[] +): [number, PreloadPerformanceMarker['phase']][] { + return markers.map(({ ipcCallId, phase }) => [ipcCallId, phase]); +} diff --git a/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts new file mode 100644 index 000000000..518f9376d --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts @@ -0,0 +1,247 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type Settings, + Theme, +} from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; +import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace'; +import { + createPreloadPerformanceHarness, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker gates', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + jest.restoreAllMocks(); + }); + + it.each([undefined, '', '0', 'false', 'no', 'off'])( + 'emits no marker when the performance flag is %s', + async (flagValue) => { + await harness.load( + flagValue === undefined + ? {} + : { + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + } + ); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect(harness.getMarkers()).toEqual([]); + } + ); + + it.each(['1', ' TRUE ', 'yes', 'On'])( + 'accepts the existing true flag value %s', + async (flagValue) => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness.getMarkers().map(({ correlationState, phase }) => ({ + correlationState, + phase, + })) + ).toEqual([ + { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + phase: 'start', + }, + { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + phase: 'success', + }, + ]); + } + ); + + it('keeps trace-only calls on the existing debug channel', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness + .getSentPayloads< + Record + >(DEBUG_TRACE_EVENT_CHANNEL) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + ]); + expect(harness.getMarkers()).toEqual([]); + }); + + it('keeps debug tracing unchanged when both gates are enabled', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness + .getSentPayloads< + Record + >(DEBUG_TRACE_EVENT_CHANNEL) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + ]); + expect( + harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL).length + ).toBe(2); + }); + + it('does not inspect or mark non-target calls in performance-only mode', async () => { + const result = { success: true }; + const settings = Object.defineProperty({}, 'sentinel', { + enumerable: true, + get: () => { + throw new Error('non-target arguments must stay opaque'); + }, + }) as Partial; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect(harness.getApi().updateSettings(settings)).resolves.toBe( + result + ); + + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + 'SETTINGS_UPDATE', + settings + ); + expect(harness.getMarkers()).toEqual([]); + }); + + it('preserves the full legacy trace payload for non-target success', async () => { + const settings = { + password: 'sentinel-argument-secret', + theme: Theme.DarkTheme, + } as Partial; + const result = { + password: 'sentinel-result-secret', + success: true, + }; + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + jest.spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(10) + .mockReturnValueOnce(12.34); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect(harness.getApi().updateSettings(settings)).resolves.toBe( + result + ); + + const traces = harness.getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ); + expect(traces).toEqual([ + { + args: { + items: [ + { + password: 'sentinel-argument-secret', + theme: Theme.DarkTheme, + }, + ], + length: 1, + type: 'array', + }, + method: 'updateSettings', + phase: 'start', + }, + { + durationMs: 2.3, + method: 'updateSettings', + phase: 'success', + result, + }, + ]); + const redacted = JSON.stringify(redactSensitiveData(traces)); + expect(redacted).not.toContain('sentinel-argument-secret'); + expect(redacted).not.toContain('sentinel-result-secret'); + expect(harness.getMarkers()).toEqual([]); + }); + + it('preserves the full legacy trace payload for non-target errors', async () => { + const rejection = new Error('token=sentinel-error-secret'); + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + jest.spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(20) + .mockReturnValueOnce(23.26); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect( + harness.getApi().updateSettings({ theme: Theme.LightTheme }) + ).rejects.toBe(rejection); + + const traces = harness.getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ); + expect(traces).toEqual([ + { + args: { + items: [{ theme: Theme.LightTheme }], + length: 1, + type: 'array', + }, + method: 'updateSettings', + phase: 'start', + }, + { + durationMs: 3.3, + error: { + message: rejection.message, + name: 'Error', + }, + method: 'updateSettings', + phase: 'error', + }, + ]); + expect(JSON.stringify(redactSensitiveData(traces))).not.toContain( + 'sentinel-error-secret' + ); + expect(harness.getMarkers()).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts new file mode 100644 index 000000000..ae6306c18 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts @@ -0,0 +1,79 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylist, + createPreloadPerformanceHarness, + createRefreshPayload, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker identities', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(async () => { + harness = createPreloadPerformanceHarness(); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + }); + + afterEach(() => { + harness.cleanup(); + }); + + it('completes only for database calls carrying the refresh operation ID', async () => { + const payload = createRefreshPayload(); + const playlist = createPlaylist(payload.playlistId); + harness.ipcRenderer.invoke.mockResolvedValue(playlist); + const api = harness.getApi(); + + await api.refreshPlaylist(payload); + await api.dbGetAppPlaylist(payload.playlistId); + await api.dbUpsertAppPlaylist(playlist); + await api.dbGetAppPlaylist(payload.playlistId, 'other-operation'); + await api.dbUpsertAppPlaylist(playlist, 'other-operation'); + + expect( + harness + .getMarkers() + .slice(2) + .every( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) + ).toBe(true); + expect( + harness + .getMarkers() + .some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + + await api.dbGetAppPlaylist(payload.playlistId, payload.operationId); + await api.dbUpsertAppPlaylist(playlist, payload.operationId); + + const markers = harness.getMarkers(); + expect(markers.at(-1)).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: payload.operationId, + playlistId: payload.playlistId, + }); + expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ + ['PLAYLIST:REFRESH', payload], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ]); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts new file mode 100644 index 000000000..69c8a7e0f --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts @@ -0,0 +1,162 @@ +import type { + ElectronBridgeApi, + Playlist, + PlaylistRefreshPayload, + PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; + +export const PRELOAD_ENVIRONMENT = { + PERF_CAPTURE: 'IPTVNATOR_PERF_CAPTURE', + TRACE_IPC: 'IPTVNATOR_TRACE_IPC', + TRACE_STARTUP: 'IPTVNATOR_TRACE_STARTUP', +} as const; + +type PreloadEnvironmentName = + (typeof PRELOAD_ENVIRONMENT)[keyof typeof PRELOAD_ENVIRONMENT]; + +type PreloadEnvironment = Partial>; + +export type ExposedElectronApi = ElectronBridgeApi & + Record unknown>; + +export interface MockIpcRenderer { + invoke: jest.Mock; + off: jest.Mock; + on: jest.Mock; + send: jest.Mock; +} + +export interface PreloadPerformanceHarness { + cleanup: () => void; + getApi: () => ExposedElectronApi; + getMarkers: () => PreloadPerformanceMarker[]; + getSentPayloads: (channel: string) => T[]; + ipcRenderer: MockIpcRenderer; + load: (environment?: PreloadEnvironment) => Promise; +} + +const MARKER_KEYS = [ + 'correlationState', + 'invalidReason', + 'ipcCallId', + 'method', + 'operationId', + 'phase', + 'playlistId', + 'sourceEpochMs', +] as const; + +export function createPlaylist( + playlistId: string, + overrides: Partial = {} +): Playlist { + return { + _id: playlistId, + autoRefresh: false, + count: 1, + importDate: '2026-07-26T00:00:00.000Z', + lastUsage: '2026-07-26T00:00:00.000Z', + title: 'Performance fixture', + ...overrides, + }; +} + +export function createRefreshPayload( + overrides: Partial = {} +): PlaylistRefreshPayload { + return { + operationId: 'operation-1', + playlistId: 'playlist-1', + title: 'Performance fixture', + ...overrides, + }; +} + +export function expectFixedMarkers(markers: PreloadPerformanceMarker[]): void { + for (const [index, marker] of markers.entries()) { + expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort()); + expect(Number.isFinite(marker.sourceEpochMs)).toBe(true); + if (index > 0) { + expect(marker.sourceEpochMs).toBeGreaterThanOrEqual( + markers[index - 1].sourceEpochMs + ); + } + } +} + +export function createPreloadPerformanceHarness(): PreloadPerformanceHarness { + const originalEnvironment = new Map< + PreloadEnvironmentName, + string | undefined + >( + Object.values(PRELOAD_ENVIRONMENT).map((name) => [ + name, + process.env[name], + ]) + ); + let exposedApi: ExposedElectronApi | null = null; + const ipcRenderer: MockIpcRenderer = { + invoke: jest.fn(), + off: jest.fn(), + on: jest.fn(), + send: jest.fn(), + }; + + function getSentPayloads(channel: string): T[] { + return ipcRenderer.send.mock.calls + .filter(([sentChannel]) => sentChannel === channel) + .map(([, payload]) => payload as T); + } + + return { + cleanup: () => { + for (const [name, value] of originalEnvironment) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + jest.dontMock('electron'); + }, + getApi: () => { + if (!exposedApi) { + throw new Error('Expected preload API to be exposed'); + } + return exposedApi; + }, + getMarkers: () => + getSentPayloads( + 'IPTVNATOR_PERF_CAPTURE_MARKER' + ), + getSentPayloads, + ipcRenderer, + load: async (environment = {}) => { + jest.resetModules(); + for (const name of Object.values(PRELOAD_ENVIRONMENT)) { + delete process.env[name]; + } + Object.assign(process.env, environment); + exposedApi = null; + ipcRenderer.invoke.mockReset().mockResolvedValue({ success: true }); + ipcRenderer.off.mockReset(); + ipcRenderer.on.mockReset(); + ipcRenderer.send.mockReset(); + jest.doMock('electron', () => ({ + contextBridge: { + exposeInMainWorld: jest.fn( + (_name: string, api: ExposedElectronApi) => { + exposedApi = api; + } + ), + }, + ipcRenderer, + webUtils: { + getPathForFile: jest.fn(), + }, + })); + + await import('./main.preload'); + }, + }; +} diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 5376ed494..a48223092 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -15,6 +15,10 @@ import { attachEmbeddedMpvFrameView, detachEmbeddedMpvFrameView, } from './embedded-mpv-frame-pump'; +import { + createPreloadPerformanceCapture, + toPreloadPerformanceTargetMethod, +} from './preload-performance-capture'; import type { EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, @@ -53,6 +57,7 @@ import type { } from '@iptvnator/shared/interfaces'; import { DEBUG_TRACE_EVENT_CHANNEL, + isPerformanceCaptureEnabled, isRendererApiTraceEnabled, roundTraceDuration, summarizeForTrace, @@ -77,6 +82,11 @@ const dbSaveContentProgressListeners = new Set< >(); const shouldTraceRendererApi = isRendererApiTraceEnabled(); +const shouldCapturePerformance = isPerformanceCaptureEnabled(); +const preloadPerformanceCapture = createPreloadPerformanceCapture( + shouldCapturePerformance, + (channel, marker) => ipcRenderer.send(channel, marker) +); function emitRendererTrace(payload: { method: string; @@ -94,7 +104,7 @@ function emitRendererTrace(payload: { } function wrapElectronApi(api: T): T { - if (!shouldTraceRendererApi) { + if (!shouldTraceRendererApi && !shouldCapturePerformance) { return api; } @@ -103,7 +113,9 @@ function wrapElectronApi(api: T): T { if ( typeof value !== 'function' || name.startsWith('on') || - name.startsWith('remove') + name.startsWith('remove') || + (!shouldTraceRendererApi && + !toPreloadPerformanceTargetMethod(name)) ) { return [name, value]; } @@ -113,13 +125,20 @@ function wrapElectronApi(api: T): T { return [ name, (...args: unknown[]) => { - const startedAt = - globalThis.performance?.now?.() ?? Date.now(); - emitRendererTrace({ - args: summarizeForTrace(args), - method: name, - phase: 'start', - }); + const startedAt = shouldTraceRendererApi + ? (globalThis.performance?.now?.() ?? Date.now()) + : 0; + if (shouldTraceRendererApi) { + emitRendererTrace({ + args: summarizeForTrace(args), + method: name, + phase: 'start', + }); + } + const performanceCall = preloadPerformanceCapture.start( + name, + args + ); try { const result = original(...args); @@ -131,54 +150,74 @@ function wrapElectronApi(api: T): T { ) { return (result as Promise) .then((resolvedValue) => { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - method: name, - phase: 'success', - result: summarizeForTrace( - resolvedValue - ), - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace( + resolvedValue + ), + }); + } + preloadPerformanceCapture.success( + performanceCall, + resolvedValue + ); return resolvedValue; }) .catch((error: unknown) => { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - error: summarizeForTrace(error), - method: name, - phase: 'error', - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + } + preloadPerformanceCapture.error( + performanceCall + ); throw error; }); } - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - method: name, - phase: 'success', - result: summarizeForTrace(result), - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace(result), + }); + } + preloadPerformanceCapture.success( + performanceCall, + result + ); return result; } catch (error) { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - error: summarizeForTrace(error), - method: name, - phase: 'error', - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + } + preloadPerformanceCapture.error(performanceCall); throw error; } }, @@ -551,8 +590,16 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }), getEpgMappingsBatch: (channelKeys: string[]) => ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }), - setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) => - ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }), + setEpgMapping: ( + channelKey: string, + epgChannelId: string, + playlistId?: string + ) => + ipcRenderer.invoke('EPG_MAPPING_SET', { + channelKey, + epgChannelId, + playlistId, + }), deleteEpgMapping: (channelKey: string) => ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }), searchEpgChannels: (searchTerm: string, limit?: number) => @@ -600,14 +647,14 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), dbGetPlaylist: (playlistId: string) => ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId), - dbUpsertAppPlaylist: (playlist: Playlist) => + dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist), dbUpsertAppPlaylists: (playlists: Playlist[]) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists), dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'), dbGetAppPlaylistMetas: () => ipcRenderer.invoke('DB_GET_APP_PLAYLIST_METAS'), - dbGetAppPlaylist: (playlistId: string) => + dbGetAppPlaylist: (playlistId: string, _operationId?: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId), dbGetAppPlaylistFavoriteChannels: (playlistId: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', playlistId), diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts b/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts new file mode 100644 index 000000000..9d900e13a --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts @@ -0,0 +1,50 @@ +import { + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { createPreloadPerformanceCapture } from './preload-performance-capture'; + +describe('preload performance capture timestamps', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses fractional monotonic time on top of the epoch origin', () => { + const markers: PreloadPerformanceMarker[] = []; + const monotonicNow = jest + .spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(10.125) + .mockReturnValueOnce(10.375); + const capture = createPreloadPerformanceCapture( + true, + (channel, marker) => { + expect(channel).toBe(PRELOAD_PERFORMANCE_MARKER_CHANNEL); + markers.push(marker); + } + ); + + const call = capture.start( + PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + [ + { + operationId: 'operation-1', + playlistId: 'playlist-1', + }, + ] + ); + capture.success(call, {}); + + expect(monotonicNow).toHaveBeenCalledTimes(2); + expect(markers).toHaveLength(2); + expect( + markers[0].sourceEpochMs - globalThis.performance.timeOrigin + ).toBeCloseTo(10.125, 3); + expect( + markers[1].sourceEpochMs - globalThis.performance.timeOrigin + ).toBeCloseTo(10.375, 3); + expect(markers[1].sourceEpochMs).toBeGreaterThan( + markers[0].sourceEpochMs + ); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.ts b/apps/electron-backend/src/app/api/preload-performance-capture.ts new file mode 100644 index 000000000..62ae3e25d --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-capture.ts @@ -0,0 +1,197 @@ +import { + isPlaylistRefreshCancelledResult, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type PreloadPerformanceMarker, + type PreloadPerformanceMethod, + type PreloadPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { + advancePreloadPerformanceCorrelation, + createPreloadPerformanceCorrelationState, + normalizePreloadPerformanceIdentifier, + type PreloadPerformanceCorrelationState, +} from './preload-performance-correlation'; + +export interface PreloadPerformanceCallContext { + ipcCallId: number; + method: PreloadPerformanceMethod; + operationId: string | null; + playlistId: string | null; +} + +export interface PreloadPerformanceCapture { + error: (call: PreloadPerformanceCallContext | null) => void; + start: ( + methodName: string, + args: readonly unknown[] + ) => PreloadPerformanceCallContext | null; + success: ( + call: PreloadPerformanceCallContext | null, + result: unknown + ) => void; +} + +type MarkerSender = ( + channel: typeof PRELOAD_PERFORMANCE_MARKER_CHANNEL, + marker: PreloadPerformanceMarker +) => void; + +function readProperty(value: unknown, property: string): unknown { + if ( + (typeof value !== 'object' || value === null) && + typeof value !== 'function' + ) { + return undefined; + } + + try { + return Reflect.get(value, property); + } catch { + return undefined; + } +} + +export function toPreloadPerformanceTargetMethod( + methodName: string +): PreloadPerformanceMethod | null { + switch (methodName) { + case PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST: + case PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST: + case PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST: + return methodName; + default: + return null; + } +} + +function readHighResolutionEpochMs(): number { + const timeOrigin = globalThis.performance?.timeOrigin; + const monotonicNow = globalThis.performance?.now?.(); + const highResolutionEpoch = + typeof timeOrigin === 'number' && typeof monotonicNow === 'number' + ? timeOrigin + monotonicNow + : Number.NaN; + + return Number.isFinite(highResolutionEpoch) + ? highResolutionEpoch + : Date.now(); +} + +function extractIdentifiers( + method: PreloadPerformanceMethod, + args: readonly unknown[] +): Pick { + if (method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + const payload = args[0]; + return { + operationId: normalizePreloadPerformanceIdentifier( + readProperty(payload, 'operationId') + ), + playlistId: normalizePreloadPerformanceIdentifier( + readProperty(payload, 'playlistId') + ), + }; + } + + if (method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + return { + operationId: normalizePreloadPerformanceIdentifier(args[1]), + playlistId: normalizePreloadPerformanceIdentifier(args[0]), + }; + } + + return { + operationId: normalizePreloadPerformanceIdentifier(args[1]), + playlistId: normalizePreloadPerformanceIdentifier( + readProperty(args[0], '_id') + ), + }; +} + +export function createPreloadPerformanceCapture( + enabled: boolean, + sendMarker: MarkerSender +): PreloadPerformanceCapture { + let correlationState: PreloadPerformanceCorrelationState = + createPreloadPerformanceCorrelationState(); + let markerDeliveryFailed = false; + let lastSourceEpochMs = 0; + let nextIpcCallId = 1; + + function emit( + call: PreloadPerformanceCallContext, + phase: PreloadPerformancePhase, + refreshCancelled = false + ): void { + if (markerDeliveryFailed) { + return; + } + + try { + const transition = advancePreloadPerformanceCorrelation( + correlationState, + { + ...call, + phase, + refreshCancelled, + } + ); + const sourceEpochMs = Math.max( + readHighResolutionEpochMs(), + lastSourceEpochMs + ); + sendMarker(PRELOAD_PERFORMANCE_MARKER_CHANNEL, { + ...transition.marker, + sourceEpochMs, + }); + correlationState = transition.state; + lastSourceEpochMs = sourceEpochMs; + } catch { + markerDeliveryFailed = true; + // Performance instrumentation must never affect the bridge call. + } + } + + return { + error: (call) => { + if (call) { + emit(call, 'error'); + } + }, + start: (methodName, args) => { + if (!enabled) { + return null; + } + + const method = toPreloadPerformanceTargetMethod(methodName); + if (!method) { + return null; + } + + const call: PreloadPerformanceCallContext = { + ipcCallId: nextIpcCallId, + method, + ...extractIdentifiers(method, args), + }; + nextIpcCallId += 1; + emit(call, 'start'); + return call; + }, + success: (call, result) => { + if (!call) { + return; + } + + let refreshCancelled = false; + if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + try { + refreshCancelled = isPlaylistRefreshCancelledResult(result); + } catch { + refreshCancelled = false; + } + } + emit(call, 'success', refreshCancelled); + }, + }; +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts new file mode 100644 index 000000000..e8dc4e699 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts @@ -0,0 +1,246 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + PRELOAD_PERFORMANCE_SEQUENCE_STAGE, + closeInvalidPreloadPerformanceSequenceWhenSettled, + createPreloadPerformanceMarkerMetadata, + invalidatePreloadPerformanceSequence, + type PreloadPerformanceCall, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceMarkerMetadata, + type PreloadPerformanceSequence, +} from './preload-performance-correlation.model'; + +function eventForRememberedCall( + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall +): PreloadPerformanceCorrelationEvent { + return { + ...event, + method: call.method, + operationId: call.operationId, + playlistId: call.playlistId, + }; +} + +function invalidCompletion( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall +): PreloadPerformanceMarkerMetadata { + const rememberedEvent = eventForRememberedCall(event, call); + const invalidSequence = + call.playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + call.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + rememberedEvent, + call.playlistId, + invalidSequence?.operationId ?? call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidSequence?.invalidReason ?? + call.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); +} + +function completeCorrelatedCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall & { + operationId: string; + playlistId: string; + } +): PreloadPerformanceMarkerMetadata { + const sequence = sequences.get(call.playlistId); + const expectedStage = + call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED + : call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + if ( + !sequence || + sequence.operationId !== call.operationId || + sequence.stage !== expectedStage + ) { + return invalidCompletion(calls, sequences, event, call); + } + + if (event.phase === 'error') { + invalidatePreloadPerformanceSequence( + calls, + sequences, + call.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + ); + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + ); + } + + if ( + call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + event.refreshCancelled === true + ) { + calls.delete(event.ipcCallId); + sequences.delete(call.playlistId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED + ); + } + + calls.delete(event.ipcCallId); + if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + sequences.set(call.playlistId, { + ...sequence, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED, + }); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); + } + + if (call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + sequences.set(call.playlistId, { + ...sequence, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED, + }); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); + } + + sequences.delete(call.playlistId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + null + ); +} + +export function completePreloadPerformanceCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + const call = calls.get(event.ipcCallId); + if (!call) { + const invalidSequence = + playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + invalidSequence?.operationId ?? operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + } + + const identityMatches = + call.method === event.method && + call.operationId === operationId && + call.playlistId === playlistId; + if (!identityMatches) { + return invalidCompletion(calls, sequences, event, call); + } + + const rememberedEvent = eventForRememberedCall(event, call); + if ( + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) { + calls.delete(event.ipcCallId); + return createPreloadPerformanceMarkerMetadata( + rememberedEvent, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); + } + + if ( + call.correlationState === PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID + ) { + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + rememberedEvent, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + call.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + } + + return completeCorrelatedCall( + calls, + sequences, + rememberedEvent, + call as PreloadPerformanceCall & { + operationId: string; + playlistId: string; + } + ); +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts new file mode 100644 index 000000000..e10251ddd --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts @@ -0,0 +1,311 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +function completeDatabaseSequence( + advance: CorrelationHarness['advance'], + firstCallId: number, + playlistId: string, + operationId: string +) { + return [ + advance({ + ipcCallId: firstCallId, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId, + phase: 'start', + playlistId, + }), + advance({ + ipcCallId: firstCallId, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId, + phase: 'success', + playlistId, + }), + advance({ + ipcCallId: firstCallId + 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId, + phase: 'start', + playlistId, + }), + advance({ + ipcCallId: firstCallId + 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId, + phase: 'success', + playlistId, + }), + ]; +} + +describe('preload performance marker correlation completions', () => { + let advance: CorrelationHarness['advance']; + + beforeEach(() => { + ({ advance } = createCorrelationHarness()); + }); + + it.each(['success', 'error'] as const)( + 'invalidates only the matching playlist after an unknown call ID %s', + (phase) => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + + const unknownCompletion = advance({ + ipcCallId: 99, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase, + playlistId: PLAYLIST_ONE, + }); + const matchingCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const samePlaylistPersistence = completeDatabaseSequence( + advance, + 20, + PLAYLIST_ONE, + OPERATION_ONE + ); + + const unrelatedRefreshCompletion = advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const unrelatedPersistence = completeDatabaseSequence( + advance, + 30, + PLAYLIST_TWO, + OPERATION_TWO + ); + + expect(unknownCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(matchingCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect( + [matchingCompletion, ...samePlaylistPersistence].some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + expect(unrelatedRefreshCompletion.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + expect(unrelatedPersistence.at(-1)).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + } + ); + + it('invalidates persistence after a duplicate completion without touching another playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const duplicateCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const samePlaylistPersistence = completeDatabaseSequence( + advance, + 20, + PLAYLIST_ONE, + OPERATION_ONE + ); + + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const unrelatedPersistence = completeDatabaseSequence( + advance, + 30, + PLAYLIST_TWO, + OPERATION_TWO + ); + + expect(duplicateCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect( + samePlaylistPersistence.some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + expect(unrelatedPersistence.at(-1)).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + }); + + it('invalidates a correlated sequence when a target call errors', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const error = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'error', + playlistId: PLAYLIST_ONE, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(error).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + operationId: OPERATION_ONE, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: OPERATION_ONE, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + }); + + it('closes a cancelled refresh without awaiting database persistence', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const cancelled = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + refreshCancelled: true, + }); + const laterGet = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(cancelled).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED, + operationId: OPERATION_ONE, + }); + expect(laterGet).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: OPERATION_ONE, + }); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts new file mode 100644 index 000000000..aec9e4c7b --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts @@ -0,0 +1,183 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance correlation identities', () => { + it('advances only database calls tagged with the refresh operation', () => { + const { advance } = createCorrelationHarness(); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const unrelated = [ + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }), + ]; + + expect( + unrelated.every( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) + ).toBe(true); + + for (const event of [ + { + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + { + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + phase: 'start', + }, + ] as const) { + advance({ + ...event, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + } + const complete = advance({ + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(complete).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + }); + + it.each([ + { + label: 'operation', + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_ONE, + }, + { + label: 'playlist', + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_TWO, + }, + { + label: 'method', + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }, + ])( + 'fails closed when the completion changes the started $label', + ({ method, operationId, playlistId }) => { + const { advance } = createCorrelationHarness(); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const completion = advance({ + ipcCallId: 2, + method, + operationId, + phase: 'success', + playlistId, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(completion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + } + ); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts new file mode 100644 index 000000000..65c63c3cf --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts @@ -0,0 +1,173 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + type PreloadPerformanceCorrelationStateName, + type PreloadPerformanceInvalidReason, + type PreloadPerformanceMarker, + type PreloadPerformanceMethod, + type PreloadPerformancePhase, +} from '@iptvnator/shared/interfaces'; + +const MAX_IDENTIFIER_LENGTH = 128; +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +export const PRELOAD_PERFORMANCE_SEQUENCE_STAGE = { + DB_GET_STARTED: 'db-get-started', + DB_GET_SUCCEEDED: 'db-get-succeeded', + DB_UPSERT_STARTED: 'db-upsert-started', + INVALID: 'invalid', + REFRESH_STARTED: 'refresh-started', + REFRESH_SUCCEEDED: 'refresh-succeeded', +} as const; + +export type PreloadPerformanceSequenceStage = + (typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE)[keyof typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE]; + +export interface PreloadPerformanceSequence { + invalidReason: PreloadPerformanceInvalidReason | null; + operationId: string; + stage: PreloadPerformanceSequenceStage; +} + +export interface PreloadPerformanceCall { + correlationState: PreloadPerformanceCorrelationStateName; + invalidReason: PreloadPerformanceInvalidReason | null; + method: PreloadPerformanceMethod; + operationId: string | null; + playlistId: string | null; +} + +export interface PreloadPerformanceCorrelationEvent { + ipcCallId: number; + method: PreloadPerformanceMethod; + operationId: unknown; + phase: PreloadPerformancePhase; + playlistId: unknown; + refreshCancelled?: boolean; +} + +export interface PreloadPerformanceCorrelationState { + readonly calls: ReadonlyMap; + readonly sequences: ReadonlyMap; +} + +export interface PreloadPerformanceCorrelationResult { + marker: PreloadPerformanceMarkerMetadata; + state: PreloadPerformanceCorrelationState; +} + +export type PreloadPerformanceMarkerMetadata = Omit< + PreloadPerformanceMarker, + 'sourceEpochMs' +>; + +export function normalizePreloadPerformanceIdentifier( + value: unknown +): string | null { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_IDENTIFIER_LENGTH || + value !== value.trim() || + !SAFE_IDENTIFIER_PATTERN.test(value) + ) { + return null; + } + + return value; +} + +export function createPreloadPerformanceMarkerMetadata( + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null, + correlationState: PreloadPerformanceCorrelationStateName, + invalidReason: PreloadPerformanceInvalidReason | null +): PreloadPerformanceMarkerMetadata { + return { + correlationState, + invalidReason, + ipcCallId: event.ipcCallId, + method: event.method, + operationId, + phase: event.phase, + playlistId, + }; +} + +export function createInvalidPreloadPerformanceCall( + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null, + invalidReason: PreloadPerformanceInvalidReason +): PreloadPerformanceCall { + return { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason, + method: event.method, + operationId, + playlistId, + }; +} + +export function invalidatePreloadPerformanceSequence( + calls: Map, + sequences: Map, + playlistId: string, + reason: PreloadPerformanceInvalidReason +): PreloadPerformanceSequence | undefined { + const sequence = sequences.get(playlistId); + if (!sequence) { + return undefined; + } + + const invalidReason = + sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (sequence.invalidReason ?? reason) + : reason; + const invalidSequence: PreloadPerformanceSequence = { + ...sequence, + invalidReason, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID, + }; + sequences.set(playlistId, invalidSequence); + + for (const [callId, call] of calls) { + if ( + call.playlistId === playlistId && + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ) { + calls.set(callId, { + ...call, + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason, + }); + } + } + + return invalidSequence; +} + +export function closeInvalidPreloadPerformanceSequenceWhenSettled( + calls: ReadonlyMap, + sequences: Map, + playlistId: string | null +): void { + if ( + playlistId === null || + sequences.get(playlistId)?.stage !== + PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ) { + return; + } + + const hasInvalidCall = Array.from(calls.values()).some( + (call) => + call.playlistId === playlistId && + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID + ); + if (!hasInvalidCall) { + sequences.delete(playlistId); + } +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts new file mode 100644 index 000000000..c73f50769 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts @@ -0,0 +1,338 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, + type CorrelationEvent, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation sequences', () => { + let advance: CorrelationHarness['advance']; + let reset: CorrelationHarness['reset']; + + beforeEach(() => { + ({ advance, reset } = createCorrelationHarness()); + }); + + it('correlates the successful refresh persistence sequence and propagates its operation ID', () => { + const refreshStart = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const refreshSuccess = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const getStart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const getSuccess = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const upsertStart = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const upsertSuccess = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(refreshStart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(refreshSuccess).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + operationId: OPERATION_ONE, + }); + expect(getStart.operationId).toBe(OPERATION_ONE); + expect(getSuccess.operationId).toBe(OPERATION_ONE); + expect(upsertStart.operationId).toBe(OPERATION_ONE); + expect(upsertSuccess).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + invalidReason: null, + operationId: OPERATION_ONE, + }); + }); + + it('fails closed for wrong-order and duplicate targeted database calls', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const wrongOrder = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(wrongOrder).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + }); + + reset(); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const duplicate = advance({ + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const originalCompletion = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(duplicate.invalidReason).toBe( + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + expect(originalCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + }); + + it('invalidates concurrent refreshes for the same playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const concurrentStart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const firstCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const concurrentCompletion = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: 'operation-3', + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(concurrentStart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + operationId: OPERATION_TWO, + }); + expect(concurrentCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + operationId: OPERATION_TWO, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + expect(firstCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + }); + }); + + it('keeps different playlists independent when one sequence invalidates', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const playlistTwoGet = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + + expect(playlistTwoGet).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + }); + + it('allows a new refresh after the prior sequence completes', () => { + const events: CorrelationEvent[] = [ + { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + ]; + events.forEach((event) => advance(event)); + + const restart = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(restart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_TWO, + }); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts new file mode 100644 index 000000000..3363ba13c --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts @@ -0,0 +1,235 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + PRELOAD_PERFORMANCE_SEQUENCE_STAGE, + createInvalidPreloadPerformanceCall, + createPreloadPerformanceMarkerMetadata, + invalidatePreloadPerformanceSequence, + type PreloadPerformanceCall, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceMarkerMetadata, + type PreloadPerformanceSequence, +} from './preload-performance-correlation.model'; + +function rememberUncorrelatedCall( + calls: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); +} + +function startRefresh( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string +): PreloadPerformanceMarkerMetadata { + const existing = sequences.get(playlistId); + if (existing) { + const reason = + existing.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (existing.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER) + : PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH; + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + reason + ); + const invalidReason = invalidSequence?.invalidReason ?? reason; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + operationId, + invalidReason + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + sequences.set(playlistId, { + invalidReason: null, + operationId, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED, + }); + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); +} + +function startDatabaseCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + if (operationId === null) { + return rememberUncorrelatedCall(calls, event, playlistId, operationId); + } + + const sequence = sequences.get(playlistId); + if (!sequence || sequence.operationId !== operationId) { + return rememberUncorrelatedCall(calls, event, playlistId, operationId); + } + + if (sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID) { + const invalidReason = + sequence.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + operationId, + invalidReason + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + const isGet = + event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST; + const expectedStage = isGet + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED; + const startedStage = isGet + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + + if (sequence.stage !== expectedStage) { + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + const invalidReason = + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + operationId, + invalidReason + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + sequences.set(playlistId, { + ...sequence, + stage: startedStage, + }); + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); +} + +export function startPreloadPerformanceCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + if ( + playlistId === null || + (event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + operationId === null) + ) { + if (playlistId !== null) { + invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ); + } + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ); + } + + return event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? startRefresh(calls, sequences, event, playlistId, operationId) + : startDatabaseCall(calls, sequences, event, playlistId, operationId); +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts new file mode 100644 index 000000000..bf4d29321 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts @@ -0,0 +1,29 @@ +import { + advancePreloadPerformanceCorrelation, + createPreloadPerformanceCorrelationState, + type PreloadPerformanceCorrelationEvent, +} from './preload-performance-correlation'; + +export const PLAYLIST_ONE = 'playlist-1'; +export const PLAYLIST_TWO = 'playlist-2'; +export const OPERATION_ONE = 'operation-1'; +export const OPERATION_TWO = 'operation-2'; + +export type CorrelationEvent = PreloadPerformanceCorrelationEvent; + +export function createCorrelationHarness() { + let state = createPreloadPerformanceCorrelationState(); + + return { + advance: (event: PreloadPerformanceCorrelationEvent) => { + const result = advancePreloadPerformanceCorrelation(state, event); + state = result.state; + return result.marker; + }, + reset: () => { + state = createPreloadPerformanceCorrelationState(); + }, + }; +} + +export type CorrelationHarness = ReturnType; diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.ts new file mode 100644 index 000000000..a42c82749 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.ts @@ -0,0 +1,58 @@ +import { completePreloadPerformanceCall } from './preload-performance-correlation.complete'; +import { + normalizePreloadPerformanceIdentifier, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceCorrelationResult, + type PreloadPerformanceCorrelationState, +} from './preload-performance-correlation.model'; +import { startPreloadPerformanceCall } from './preload-performance-correlation.start'; + +export type { + PreloadPerformanceCorrelationEvent, + PreloadPerformanceCorrelationResult, + PreloadPerformanceCorrelationState, +} from './preload-performance-correlation.model'; +export { normalizePreloadPerformanceIdentifier } from './preload-performance-correlation.model'; + +export function createPreloadPerformanceCorrelationState(): PreloadPerformanceCorrelationState { + return { + calls: new Map(), + sequences: new Map(), + }; +} + +export function advancePreloadPerformanceCorrelation( + state: PreloadPerformanceCorrelationState, + event: PreloadPerformanceCorrelationEvent +): PreloadPerformanceCorrelationResult { + const calls = new Map(state.calls); + const sequences = new Map(state.sequences); + const playlistId = normalizePreloadPerformanceIdentifier(event.playlistId); + const operationId = normalizePreloadPerformanceIdentifier( + event.operationId + ); + const marker = + event.phase === 'start' + ? startPreloadPerformanceCall( + calls, + sequences, + event, + playlistId, + operationId + ) + : completePreloadPerformanceCall( + calls, + sequences, + event, + playlistId, + operationId + ); + + return { + marker, + state: { + calls, + sequences, + }, + }; +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts new file mode 100644 index 000000000..9252bf378 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts @@ -0,0 +1,117 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation validation', () => { + let advance: CorrelationHarness['advance']; + + beforeEach(() => { + ({ advance } = createCorrelationHarness()); + }); + + it('uses null identifiers and a fixed reason for malformed inputs', () => { + const malformedPlaylist = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: 'https://secret.example/private.m3u', + }); + const malformedOperation = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: { secret: OPERATION_TWO }, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const malformedDbId = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: undefined, + }); + const malformedPlaylistCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: 'https://secret.example/private.m3u', + }); + + expect(malformedPlaylist).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + expect(malformedOperation).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: PLAYLIST_ONE, + }); + expect(malformedDbId).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + expect(malformedPlaylistCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + }); + + it('keeps targeted database calls outside a sequence visible and uncorrelated', () => { + const getStart = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const getSuccess = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(getStart).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + expect(getSuccess).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts new file mode 100644 index 000000000..d4b6d49b8 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts @@ -0,0 +1,123 @@ +import type { + AppPlaylistGetPerformancePhase, + PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, +} from '@iptvnator/shared/interfaces'; + +import type { AppDatabase } from '../database.types'; +import { getAppPlaylist } from './playlist.operations'; + +type TestGetPhaseCapture = { + captureAsync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +}; + +function createDatabaseMock(rows: unknown[]) { + const limit = jest.fn().mockResolvedValue(rows); + const where = jest.fn(() => ({ limit })); + const from = jest.fn(() => ({ where })); + const select = jest.fn(() => ({ from })); + + return { + db: { select } as unknown as AppDatabase, + from, + limit, + select, + where, + }; +} + +const playlistRow = { + id: 'synthetic-playlist', + name: 'Synthetic playlist', + type: 'm3u-url', + payload: JSON.stringify({ + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + title: 'Synthetic playlist', + }), + favorites: null, + recentlyViewed: null, +} as never; + +describe('app playlist GET performance boundaries', () => { + it('keeps the default read and parse path unchanged', async () => { + const { db, limit, select } = createDatabaseMock([playlistRow]); + + await expect( + getAppPlaylist(db, 'synthetic-playlist') + ).resolves.toEqual( + expect.objectContaining({ + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + }) + ); + expect(select).toHaveBeenCalledTimes(1); + expect(limit).toHaveBeenCalledWith(1); + }); + + it('separates the awaited SQLite read from playlist deserialization', async () => { + const { db, limit } = createDatabaseMock([playlistRow]); + const phases: Array<{ + metadata: PerformancePhaseMetadata | undefined; + phase: AppPlaylistGetPerformancePhase; + }> = []; + const capture: TestGetPhaseCapture = { + async captureAsync(phase, execute, metadata) { + const result = await execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + captureSync(phase, execute, metadata) { + const result = execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + }; + const getWithCapture = getAppPlaylist as unknown as ( + database: AppDatabase, + playlistId: string, + phaseCapture: TestGetPhaseCapture + ) => Promise | null>; + + await expect( + getWithCapture(db, 'synthetic-playlist', capture) + ).resolves.toEqual( + expect.objectContaining({ + _id: 'synthetic-playlist', + }) + ); + + expect(phases).toEqual([ + { + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + }, + { + metadata: { itemCount: 2 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + }, + ]); + expect(limit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts new file mode 100644 index 000000000..278d2bfb6 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts @@ -0,0 +1,101 @@ +import { + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistUpsertPerformancePhase, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +import type { AppDatabase } from '../database.types'; +import { + type AppPlaylistUpsertPhaseCapture, + upsertAppPlaylist, +} from './playlist.operations'; + +function createDatabaseMock(runImplementation: () => unknown = () => ({})) { + const run = jest.fn(runImplementation); + const then = jest.fn( + ( + onFulfilled: (value: unknown) => unknown, + onRejected: (error: unknown) => unknown + ) => Promise.resolve().then(run).then(onFulfilled, onRejected) + ); + const onConflictDoUpdate = jest.fn(() => ({ run, then })); + const values = jest.fn(() => ({ onConflictDoUpdate })); + const insert = jest.fn(() => ({ values })); + + return { + db: { insert } as unknown as AppDatabase, + insert, + onConflictDoUpdate, + run, + values, + }; +} + +describe('app playlist upsert performance boundaries', () => { + const playlist = { + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + title: 'Synthetic playlist', + type: 'm3u-url', + url: 'http://127.0.0.1:43210/synthetic-performance.m3u', + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('keeps the default path free of phase hooks and executes one autocommit run', async () => { + const { db, run } = createDatabaseMock(); + + await expect(upsertAppPlaylist(db, playlist)).resolves.toEqual({ + success: true, + }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('separates row/JSON serialization from SQLite without rescanning the payload for metadata', async () => { + const { db, run } = createDatabaseMock(); + const byteLength = jest.spyOn(Buffer, 'byteLength'); + const phases: Array<{ + metadata: PerformancePhaseMetadata | undefined; + phase: AppPlaylistUpsertPerformancePhase; + }> = []; + const capture: AppPlaylistUpsertPhaseCapture = { + async captureAsync(phase, execute, metadata) { + const result = await execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + captureSync(phase, execute, metadata) { + const result = execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + }; + + await expect(upsertAppPlaylist(db, playlist, capture)).resolves.toEqual( + { success: true } + ); + + expect(phases).toEqual([ + { + metadata: { itemCount: 2 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + }, + { + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + }, + ]); + expect(byteLength).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 5b6333dc9..c8f5843db 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -1,6 +1,13 @@ import { eq, inArray } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistGetPerformancePhase, + type AppPlaylistUpsertPerformancePhase, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { checkpointOperation, @@ -19,6 +26,32 @@ const PLAYLIST_TYPES = { const DEFAULT_BATCH_SIZE = 100; +export interface AppPlaylistUpsertPhaseCapture { + captureAsync: ( + phase: AppPlaylistUpsertPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistUpsertPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +} + +export interface AppPlaylistGetPhaseCapture { + captureAsync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +} + type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES]; const PLAYLIST_TYPE_VALUES = new Set( Object.values(PLAYLIST_TYPES) @@ -196,6 +229,21 @@ function buildPlaylistRow( }; } +function getPlaylistItemCount( + playlist: Record +): number | undefined { + try { + const value = playlist.playlist; + if (typeof value !== 'object' || value === null) { + return undefined; + } + const items = Reflect.get(value, 'items'); + return Array.isArray(items) ? items.length : undefined; + } catch { + return undefined; + } +} + export function parseAppPlaylist( row: schema.Playlist ): Record { @@ -300,17 +348,42 @@ export async function createPlaylist( export async function upsertAppPlaylist( db: AppDatabase, - playlist: Record + playlist: Record, + capturePhase?: AppPlaylistUpsertPhaseCapture ): Promise<{ success: boolean }> { - const row = buildPlaylistRow(playlist); + const row = capturePhase + ? capturePhase.captureSync( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + () => buildPlaylistRow(playlist), + () => ({ + itemCount: getPlaylistItemCount(playlist), + }) + ) + : buildPlaylistRow(playlist); if (!row) { throw new Error('Playlist ID is required for upsert'); } - await db.insert(schema.playlists).values(row).onConflictDoUpdate({ - target: schema.playlists.id, - set: row, - }); + const write = async () => { + await db + .insert(schema.playlists) + .values(row) + .onConflictDoUpdate({ + target: schema.playlists.id, + set: row, + }); + }; + if (capturePhase) { + await capturePhase.captureAsync( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + write, + () => ({ + itemCount: 1, + }) + ); + } else { + await write(); + } return { success: true }; } @@ -392,14 +465,38 @@ export async function getAppPlaylistMetas(db: AppDatabase) { ); } -export async function getAppPlaylist(db: AppDatabase, playlistId: string) { - const rows = await db - .select() - .from(schema.playlists) - .where(eq(schema.playlists.id, playlistId)) - .limit(1); +export async function getAppPlaylist( + db: AppDatabase, + playlistId: string, + capturePhase?: AppPlaylistGetPhaseCapture +) { + const read = () => + db + .select() + .from(schema.playlists) + .where(eq(schema.playlists.id, playlistId)) + .limit(1); + const rows = capturePhase + ? await capturePhase.captureAsync( + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + read, + (value) => ({ itemCount: value.length }) + ) + : await read(); + const deserialize = () => + rows[0] ? parseAppPlaylist(rows[0]) : null; - return rows[0] ? parseAppPlaylist(rows[0]) : null; + return capturePhase + ? capturePhase.captureSync( + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + deserialize, + (value) => ({ + itemCount: value + ? (getPlaylistItemCount(value) ?? 0) + : 0, + }) + ) + : deserialize(); } export async function getAppPlaylistFavoriteChannels( diff --git a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts index ccced7e98..090eecabb 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts @@ -7,6 +7,20 @@ const mockBroadcastDownloadUpdate = jest.fn(); const mockRemovePartialDownloadFile = jest.fn(); const mockPauseDownload = jest.fn(); const mockResumeDownloadRequest = jest.fn(); +const mockExistsSync = jest.fn(); +const mockOpenPath = jest.fn(); +const mockShowItemInFolder = jest.fn(); +const mockEq = jest.fn(); +let downloadsFilePathColumn: unknown; + +const MANAGED_PATH_STATE = { + ERROR: 'error', + MANAGED: 'managed', + UNMANAGED: 'unmanaged', +} as const; + +type ManagedPathState = + (typeof MANAGED_PATH_STATE)[keyof typeof MANAGED_PATH_STATE]; function getHandler(channel: string): IpcHandler { const handler = mockRegisteredHandlers.get(channel); @@ -34,7 +48,24 @@ describe('downloads events', () => { mockRemovePartialDownloadFile.mockReset(); mockPauseDownload.mockReset(); mockResumeDownloadRequest.mockReset(); + mockExistsSync.mockReset(); + mockOpenPath.mockReset().mockResolvedValue(''); + mockShowItemInFolder.mockReset(); + mockEq.mockReset(); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + existsSync: mockExistsSync, + })); + jest.doMock('drizzle-orm', () => { + const actual = + jest.requireActual('drizzle-orm'); + mockEq.mockImplementation(actual.eq); + return { + ...actual, + eq: mockEq, + }; + }); jest.doMock('electron', () => ({ app: { getPath: jest.fn((name: string) => @@ -50,8 +81,8 @@ describe('downloads events', () => { }), }, shell: { - openPath: jest.fn(), - showItemInFolder: jest.fn(), + openPath: mockOpenPath, + showItemInFolder: mockShowItemInFolder, }, })); jest.doMock('../../database/connection', () => ({ @@ -77,8 +108,45 @@ describe('downloads events', () => { })); await import('./downloads.events'); + const schema = await import('../../database/schema'); + downloadsFilePathColumn = schema.downloads.filePath; }); + function mockManagedPath(state: ManagedPathState) { + const limit = jest.fn(() => { + if (state === MANAGED_PATH_STATE.ERROR) { + return Promise.reject(new Error('database unavailable')); + } + return Promise.resolve( + state === MANAGED_PATH_STATE.MANAGED ? [{ id: 42 }] : [] + ); + }); + const where = jest.fn((_predicate: unknown) => ({ limit })); + const from = jest.fn(() => ({ where })); + const select = jest.fn(() => ({ from })); + const db = { select }; + mockGetDatabase.mockResolvedValue(db); + return { from, limit, select, where }; + } + + function expectManagedPathLookup( + lookup: ReturnType, + filePath: string + ) { + expect(mockGetDatabase).toHaveBeenCalledTimes(1); + expect(lookup.select).toHaveBeenCalledTimes(1); + expect(lookup.from).toHaveBeenCalledTimes(1); + expect(lookup.where).toHaveBeenCalledTimes(1); + expect(mockEq).toHaveBeenCalledTimes(1); + expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true); + expect(mockEq.mock.calls[0][1]).toBe(filePath); + expect( + lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value + ).toBe(true); + expect(lookup.limit).toHaveBeenCalledTimes(1); + expect(lookup.limit).toHaveBeenCalledWith(1); + } + function mockDownloadRow(row: { filePath: string | null; status: string }) { const deleteWhere = jest.fn().mockResolvedValue(undefined); const db = { @@ -101,7 +169,9 @@ describe('downloads events', () => { const deleteWhere = jest.fn().mockResolvedValue(undefined); const selectWhere = jest .fn() - .mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row }))); + .mockResolvedValue( + rows.map((row, index) => ({ id: index + 1, ...row })) + ); const db = { delete: jest.fn(() => ({ where: deleteWhere })), select: jest.fn(() => ({ @@ -117,9 +187,11 @@ describe('downloads events', () => { it('removes queued resumed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('queued')); - await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ - success: true, - }); + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual( + { + success: true, + } + ); expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42); expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( @@ -127,7 +199,9 @@ describe('downloads events', () => { ); expect( mockRemovePartialDownloadFile.mock.invocationCallOrder[0] - ).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]); + ).toBeLessThan( + mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0] + ); expect( mockRemovePartialDownloadFile.mock.invocationCallOrder[0] ).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]); @@ -137,9 +211,11 @@ describe('downloads events', () => { it('removes completed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('completed')); - await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ - success: true, - }); + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual( + { + success: true, + } + ); expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( '/downloads/resume.mp4' @@ -328,4 +404,127 @@ describe('downloads events', () => { expect.anything() ); }); + + describe.each([ + { + channel: 'DOWNLOADS_REVEAL_FILE', + filePath: '/downloads/reveal-boundary.mp4', + operation: 'reveal', + }, + { + channel: 'DOWNLOADS_PLAY_FILE', + filePath: '/downloads/play-boundary.mp4', + operation: 'play', + }, + ])('$operation managed-path boundary', ({ channel, filePath }) => { + it('rejects an unmanaged database path before accessing the filesystem', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED); + mockExistsSync.mockReturnValue(true); + + await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + + it('rejects a managed database path that is missing from disk', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(false); + + await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + + it('fails closed when the managed-path database query rejects', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR); + mockExistsSync.mockReturnValue(true); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler(channel)(null, filePath) + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + 'Error verifying managed download path:', + expect.objectContaining({ + message: 'database unavailable', + }) + ); + } finally { + consoleError.mockRestore(); + } + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + }); + + it('reveals a managed file that exists on disk', async () => { + const filePath = '/downloads/reveal-success.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath) + ).resolves.toEqual({ success: true }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockShowItemInFolder).toHaveBeenCalledTimes(1); + expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).not.toHaveBeenCalled(); + }); + + it('waits for the native shell before reporting a managed file as played', async () => { + const filePath = '/downloads/play-success.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(true); + let resolveOpenPath!: (value: string) => void; + const openPathResult = new Promise((resolve) => { + resolveOpenPath = resolve; + }); + mockOpenPath.mockReturnValue(openPathResult); + + let responseSettled = false; + const response = getHandler('DOWNLOADS_PLAY_FILE')(null, filePath).then( + (result) => { + responseSettled = true; + return result; + } + ); + await new Promise((resolve) => setImmediate(resolve)); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).toHaveBeenCalledTimes(1); + expect(mockOpenPath).toHaveBeenCalledWith(filePath); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + expect(responseSettled).toBe(false); + + resolveOpenPath(''); + await expect(response).resolves.toEqual({ success: true }); + }); }); diff --git a/apps/electron-backend/src/app/events/database/playlist.events.spec.ts b/apps/electron-backend/src/app/events/database/playlist.events.spec.ts index 6516e0db8..623e9e47c 100644 --- a/apps/electron-backend/src/app/events/database/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/playlist.events.spec.ts @@ -1,3 +1,5 @@ +export {}; + type IpcHandler = ( event: MockIpcEvent, ...args: unknown[] diff --git a/apps/electron-backend/src/app/events/epg-fetch.service.ts b/apps/electron-backend/src/app/events/epg-fetch.service.ts new file mode 100644 index 000000000..10b79f8a7 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-fetch.service.ts @@ -0,0 +1,167 @@ +import { eq } from 'drizzle-orm'; +import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces'; +import { getDatabase } from '../database/connection'; +import * as schema from '../database/schema'; +import { epgWorkerService } from './epg-worker.service'; + +/** + * EPG freshness checks and multi-URL fetch orchestration. + * Worker lifecycle itself lives in `epg-worker.service.ts`; this module only + * decides *which* URLs are worth handing to it, and in what order. + */ + +const loggerLabel = '[EPG Events]'; + +/** Age after which stored EPG data is refetched. */ +export const EPG_FRESHNESS_MAX_AGE_HOURS = 12; + +export interface EpgFreshnessResult { + staleUrls: string[]; + freshUrls: string[]; +} + +export interface EpgFetchResult { + success: boolean; + message?: string; + skipped?: string[]; +} + +/** + * Check which EPG URLs have fresh data vs stale/missing data + * @param urls - EPG source URLs to check + * @param maxAgeHours - Maximum age in hours before data is considered stale + */ +export async function checkEpgFreshness( + urls: string[], + maxAgeHours: number +): Promise { + const staleUrls: string[] = []; + const freshUrls: string[] = []; + const cutoffTime = new Date( + Date.now() - maxAgeHours * 60 * 60 * 1000 + ).toISOString(); + + try { + const db = await getDatabase(); + + for (const url of urls) { + if (!url?.trim()) continue; + + const result = await db + .select({ updatedAt: schema.epgChannels.updatedAt }) + .from(schema.epgChannels) + .where(eq(schema.epgChannels.sourceUrl, url)) + .limit(1); + + const isFresh = + result.length > 0 && + result[0].updatedAt && + result[0].updatedAt >= cutoffTime; + + if (isFresh) { + freshUrls.push(url); + epgWorkerService.markFetchedUrl(url); + } else { + staleUrls.push(url); + } + } + } catch (error) { + console.error(loggerLabel, 'Error checking EPG freshness:', error); + return { staleUrls: urls, freshUrls: [] }; + } + + if (freshUrls.length > 0) { + console.log( + loggerLabel, + `EPG fresh (skipping): ${freshUrls.length} source(s)` + ); + } + if (staleUrls.length > 0) { + console.log( + loggerLabel, + `EPG stale (will fetch): ${staleUrls.length} source(s)` + ); + } + + return { staleUrls, freshUrls }; +} + +/** + * Handle EPG fetch from URLs + * Automatically skips URLs with fresh data (less than 12 hours old) + * Processes URLs sequentially to avoid SQLite database locking issues + */ +export async function handleFetchEpg( + urls: string[], + options: ElectronBridgeTrustOptions = {} +): Promise { + const validUrls = urls.filter((url) => url?.trim()); + + if (validUrls.length === 0) { + return { success: false, message: 'No valid URLs provided' }; + } + + const { staleUrls, freshUrls } = await checkEpgFreshness( + validUrls, + EPG_FRESHNESS_MAX_AGE_HOURS + ); + + if (staleUrls.length === 0) { + return { + success: true, + message: 'All EPG data is fresh', + skipped: freshUrls, + }; + } + + // Exclude URLs already processed this session — otherwise the loop sends + // a 'queued' status, then fetchEpgFromUrl silently skips the URL and no + // completion update ever arrives, leaving the UI stuck at "queued". + const urlsToFetch = staleUrls.filter( + (url) => !epgWorkerService.hasFetchedUrl(url) + ); + + if (urlsToFetch.length === 0) { + console.log( + loggerLabel, + `All ${staleUrls.length} stale URL(s) already fetched this session; skipping` + ); + return { success: true, skipped: freshUrls }; + } + + urlsToFetch.forEach((url, index) => { + epgWorkerService.sendProgressToRenderer( + url, + 'queued', + undefined, + undefined, + index + 1 + ); + }); + + const errors: string[] = []; + for (const url of urlsToFetch) { + try { + await epgWorkerService.fetchEpgFromUrl(url, options); + } catch (error) { + console.error( + loggerLabel, + `Error fetching EPG from ${url}:`, + error + ); + errors.push( + error instanceof Error ? error.message : String(error) + ); + } + } + + if (errors.length > 0) { + return { + success: errors.length < urlsToFetch.length, + message: errors.join('; '), + skipped: freshUrls, + }; + } + + return { success: true, skipped: freshUrls }; +} diff --git a/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts b/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts new file mode 100644 index 000000000..0af8125fe --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts @@ -0,0 +1,174 @@ +import type EpgEventsType from './epg.events'; + +const getDatabase = jest.fn(); +const getEpgMapping = jest.fn(); +const getEpgMappingsBatch = jest.fn(); +const setEpgMapping = jest.fn(); +const deleteEpgMapping = jest.fn(); +const searchEpgChannels = jest.fn(); +const ipcHandlers = new Map< + string, + (event: unknown, args: unknown) => Promise +>(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => [], + }, + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => getDatabase(...args), +})); + +jest.mock('../database/operations/epg-mapping.operations', () => ({ + getEpgMapping: (...args: unknown[]) => getEpgMapping(...args), + getEpgMappingsBatch: (...args: unknown[]) => getEpgMappingsBatch(...args), + setEpgMapping: (...args: unknown[]) => setEpgMapping(...args), + deleteEpgMapping: (...args: unknown[]) => deleteEpgMapping(...args), + searchEpgChannels: (...args: unknown[]) => searchEpgChannels(...args), +})); + +/** + * The EPG mapping IPC handlers must fail soft: a mapping lookup is a side + * concern of an EPG request and must never take that request down. The + * handlers wrap the DB call in try/catch, but a bare `return promise` inside + * an async function is *adopted*, not awaited — so the catch block only ever + * saw a synchronous `getDatabase()` failure while a rejection from the query + * itself escaped to the IPC caller. These tests pin both failure modes. + */ +describe('EPG mapping IPC handlers', () => { + let EpgEvents: typeof EpgEventsType; + + beforeEach(async () => { + jest.resetModules(); + ipcHandlers.clear(); + [ + getDatabase, + getEpgMapping, + getEpgMappingsBatch, + setEpgMapping, + deleteEpgMapping, + searchEpgChannels, + ].forEach((mock) => mock.mockReset()); + + getDatabase.mockResolvedValue({}); + + ({ default: EpgEvents } = await import('./epg.events')); + + const { ipcMain } = jest.requireMock('electron'); + (ipcMain.handle as jest.Mock).mockReset(); + (ipcMain.handle as jest.Mock).mockImplementation( + ( + channel: string, + handler: (event: unknown, args: unknown) => Promise + ) => { + ipcHandlers.set(channel, handler); + } + ); + + EpgEvents.bootstrapEpgEvents(); + }); + + function invoke(channel: string, args: unknown): Promise { + const handler = ipcHandlers.get(channel); + if (!handler) { + throw new Error(`No IPC handler registered for "${channel}"`); + } + return handler({}, args); + } + + describe.each([ + { + channel: 'EPG_MAPPING_GET', + args: { channelKey: 'bbc.one.uk' }, + operation: () => getEpgMapping, + resolved: { + id: 1, + channelKey: 'bbc.one.uk', + epgChannelId: 'BBC.ONE.UK', + playlistId: null, + }, + fallback: null, + }, + { + channel: 'EPG_MAPPING_SET', + args: { channelKey: 'bbc.one.uk', epgChannelId: 'BBC.ONE.UK' }, + operation: () => setEpgMapping, + resolved: { success: true }, + fallback: { success: false }, + }, + { + channel: 'EPG_MAPPING_DELETE', + args: { channelKey: 'bbc.one.uk' }, + operation: () => deleteEpgMapping, + resolved: { success: true }, + fallback: { success: false }, + }, + { + channel: 'EPG_CHANNEL_SEARCH', + args: { searchTerm: 'bbc' }, + operation: () => searchEpgChannels, + resolved: [ + { id: 'BBC.ONE.UK', displayName: 'BBC One', iconUrl: null }, + ], + fallback: [], + }, + { + channel: 'EPG_MAPPING_GET_BATCH', + args: { channelKeys: ['bbc.one.uk'] }, + operation: () => getEpgMappingsBatch, + resolved: new Map([['bbc.one.uk', 'BBC.ONE.UK']]), + fallback: {}, + }, + ])('$channel', ({ channel, args, operation, resolved, fallback }) => { + it('returns the fallback when the underlying query rejects', async () => { + operation().mockRejectedValue(new Error('database is locked')); + + await expect(invoke(channel, args)).resolves.toEqual(fallback); + }); + + it('returns the fallback when opening the database rejects', async () => { + getDatabase.mockRejectedValue(new Error('no such file')); + + await expect(invoke(channel, args)).resolves.toEqual(fallback); + expect(operation()).not.toHaveBeenCalled(); + }); + + it('passes the resolved value through on success', async () => { + operation().mockResolvedValue(resolved); + + const result = await invoke(channel, args); + + expect(result).toEqual( + // The batch handler is the one that reshapes its result. + resolved instanceof Map + ? Object.fromEntries(resolved) + : resolved + ); + }); + }); + + it('short-circuits a blank search term without touching the database', async () => { + await expect( + invoke('EPG_CHANNEL_SEARCH', { searchTerm: ' ' }) + ).resolves.toEqual([]); + expect(getDatabase).not.toHaveBeenCalled(); + expect(searchEpgChannels).not.toHaveBeenCalled(); + }); + + it('short-circuits an empty batch without touching the database', async () => { + await expect( + invoke('EPG_MAPPING_GET_BATCH', { channelKeys: [] }) + ).resolves.toEqual({}); + expect(getDatabase).not.toHaveBeenCalled(); + expect(getEpgMappingsBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-mapping.service.ts b/apps/electron-backend/src/app/events/epg-mapping.service.ts new file mode 100644 index 000000000..7b767ce0a --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-mapping.service.ts @@ -0,0 +1,130 @@ +import { getDatabase } from '../database/connection'; +import { + deleteEpgMapping, + getEpgMapping, + getEpgMappingsBatch, + searchEpgChannels, + setEpgMapping, +} from '../database/operations/epg-mapping.operations'; + +/** + * Manual EPG channel mapping resolution and CRUD, called at the IPC boundary + * before EPG queries run. Every method fails soft: a mapping lookup must never + * take down an EPG request, it just falls back to the unmapped channel id. + */ + +export interface EpgMappingRecord { + id: number; + channelKey: string; + epgChannelId: string; + playlistId: string | null; +} + +export interface EpgChannelSearchResult { + id: string; + displayName: string; + iconUrl: string | null; +} + +/** + * Batch-resolve channel IDs through manual mappings. + * Returns a Map of original ID → mapped ID (identity when no mapping). + */ +export async function resolveChannelIds( + channelIds: string[] +): Promise> { + try { + const db = await getDatabase(); + const mappings = await getEpgMappingsBatch(db, channelIds); + return mappings; + } catch { + return new Map(); + } +} + +/** + * Run a batch query against mapping-resolved channel IDs and remap the results + * back onto the original request keys the renderer asked for. + */ +export async function queryByResolvedChannelIds( + channelIds: string[], + query: (resolvedIds: string[]) => Promise> +): Promise> { + const resolvedMap = await resolveChannelIds(channelIds); + const resolvedIds = channelIds.map((id) => resolvedMap.get(id) ?? id); + const results = await query(resolvedIds); + + const remapped: Record = {}; + for (const originalId of channelIds) { + const resolvedId = resolvedMap.get(originalId) ?? originalId; + remapped[originalId] = results[resolvedId] ?? null; + } + return remapped; +} + +export async function handleGetEpgMapping( + channelKey: string +): Promise { + try { + const db = await getDatabase(); + return await getEpgMapping(db, channelKey); + } catch { + return null; + } +} + +export async function handleGetEpgMappingsBatch( + channelKeys: string[] +): Promise> { + if (!Array.isArray(channelKeys) || channelKeys.length === 0) { + return {}; + } + + try { + const db = await getDatabase(); + const mappings = await getEpgMappingsBatch(db, channelKeys); + return Object.fromEntries(mappings); + } catch { + return {}; + } +} + +export async function handleSetEpgMapping( + channelKey: string, + epgChannelId: string, + playlistId?: string +): Promise<{ success: boolean }> { + try { + const db = await getDatabase(); + return await setEpgMapping(db, channelKey, epgChannelId, playlistId); + } catch { + return { success: false }; + } +} + +export async function handleDeleteEpgMapping( + channelKey: string +): Promise<{ success: boolean }> { + try { + const db = await getDatabase(); + return await deleteEpgMapping(db, channelKey); + } catch { + return { success: false }; + } +} + +export async function handleSearchEpgChannels( + searchTerm: string, + limit?: number +): Promise { + if (!searchTerm?.trim()) { + return []; + } + + try { + const db = await getDatabase(); + return await searchEpgChannels(db, searchTerm, limit); + } catch { + return []; + } +} diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 795f93a75..c7085939a 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,29 +1,29 @@ -import { eq } from 'drizzle-orm'; import { ipcMain } from 'electron'; import { ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; -import { getDatabase } from '../database/connection'; -import * as schema from '../database/schema'; import { epgQueryService } from './epg-query.service'; import { epgWorkerService } from './epg-worker.service'; +import { checkEpgFreshness, handleFetchEpg } from './epg-fetch.service'; +import type { EpgFetchResult, EpgFreshnessResult } from './epg-fetch.service'; import { - getEpgMapping, - getEpgMappingsBatch, - setEpgMapping, - deleteEpgMapping, - searchEpgChannels, -} from '../database/operations/epg-mapping.operations'; + handleDeleteEpgMapping, + handleGetEpgMapping, + handleGetEpgMappingsBatch, + handleSearchEpgChannels, + handleSetEpgMapping, + queryByResolvedChannelIds, +} from './epg-mapping.service'; /** * EPG Events Handler * Manages EPG IPC registration and delegates worker/query behavior. + * Freshness and fetch orchestration live in `epg-fetch.service.ts`; manual + * channel-mapping resolution and CRUD live in `epg-mapping.service.ts`. */ export default class EpgEvents { - private static readonly loggerLabel = '[EPG Events]'; - /** * Bootstrap EPG events */ @@ -136,11 +136,11 @@ export default class EpgEvents { } ); - // EPG channel mapping CRUD + // EPG channel mapping CRUD — handled entirely by epg-mapping.service. ipcMain.handle( 'EPG_MAPPING_GET', async (_event, args: { channelKey: string }) => { - return this.handleGetEpgMapping(args.channelKey); + return handleGetEpgMapping(args.channelKey); } ); @@ -154,7 +154,7 @@ export default class EpgEvents { playlistId?: string; } ) => { - return this.handleSetEpgMapping( + return handleSetEpgMapping( args.channelKey, args.epgChannelId, args.playlistId @@ -165,14 +165,14 @@ export default class EpgEvents { ipcMain.handle( 'EPG_MAPPING_GET_BATCH', async (_event, args: { channelKeys: string[] }) => { - return this.handleGetEpgMappingsBatch(args.channelKeys); + return handleGetEpgMappingsBatch(args.channelKeys); } ); ipcMain.handle( 'EPG_MAPPING_DELETE', async (_event, args: { channelKey: string }) => { - return this.handleDeleteEpgMapping(args.channelKey); + return handleDeleteEpgMapping(args.channelKey); } ); @@ -182,158 +182,25 @@ export default class EpgEvents { _event, args: { searchTerm: string; limit?: number } ) => { - return this.handleSearchEpgChannels( - args.searchTerm, - args.limit - ); + return handleSearchEpgChannels(args.searchTerm, args.limit); } ); return ipcMain; } - /** - * Check which EPG URLs have fresh data vs stale/missing data - * @param urls - EPG source URLs to check - * @param maxAgeHours - Maximum age in hours before data is considered stale - */ private static async checkEpgFreshness( urls: string[], maxAgeHours: number - ): Promise<{ staleUrls: string[]; freshUrls: string[] }> { - const staleUrls: string[] = []; - const freshUrls: string[] = []; - const cutoffTime = new Date( - Date.now() - maxAgeHours * 60 * 60 * 1000 - ).toISOString(); - - try { - const db = await getDatabase(); - - for (const url of urls) { - if (!url?.trim()) continue; - - const result = await db - .select({ updatedAt: schema.epgChannels.updatedAt }) - .from(schema.epgChannels) - .where(eq(schema.epgChannels.sourceUrl, url)) - .limit(1); - - const isFresh = - result.length > 0 && - result[0].updatedAt && - result[0].updatedAt >= cutoffTime; - - if (isFresh) { - freshUrls.push(url); - epgWorkerService.markFetchedUrl(url); - } else { - staleUrls.push(url); - } - } - } catch (error) { - console.error( - this.loggerLabel, - 'Error checking EPG freshness:', - error - ); - return { staleUrls: urls, freshUrls: [] }; - } - - if (freshUrls.length > 0) { - console.log( - this.loggerLabel, - `EPG fresh (skipping): ${freshUrls.length} source(s)` - ); - } - if (staleUrls.length > 0) { - console.log( - this.loggerLabel, - `EPG stale (will fetch): ${staleUrls.length} source(s)` - ); - } - - return { staleUrls, freshUrls }; + ): Promise { + return checkEpgFreshness(urls, maxAgeHours); } - /** - * Handle EPG fetch from URLs - * Automatically skips URLs with fresh data (less than 12 hours old) - * Processes URLs sequentially to avoid SQLite database locking issues - */ private static async handleFetchEpg( urls: string[], options: ElectronBridgeTrustOptions = {} - ): Promise<{ success: boolean; message?: string; skipped?: string[] }> { - const validUrls = urls.filter((url) => url?.trim()); - - if (validUrls.length === 0) { - return { success: false, message: 'No valid URLs provided' }; - } - - const { staleUrls, freshUrls } = await this.checkEpgFreshness( - validUrls, - 12 - ); - - if (staleUrls.length === 0) { - return { - success: true, - message: 'All EPG data is fresh', - skipped: freshUrls, - }; - } - - // Exclude URLs already processed this session — otherwise the loop sends - // a 'queued' status, then fetchEpgFromUrl silently skips the URL and no - // completion update ever arrives, leaving the UI stuck at "queued". - const urlsToFetch = staleUrls.filter( - (url) => !epgWorkerService.hasFetchedUrl(url) - ); - - if (urlsToFetch.length === 0) { - console.log( - this.loggerLabel, - `All ${staleUrls.length} stale URL(s) already fetched this session; skipping` - ); - return { success: true, skipped: freshUrls }; - } - - urlsToFetch.forEach((url, index) => { - epgWorkerService.sendProgressToRenderer( - url, - 'queued', - undefined, - undefined, - index + 1 - ); - }); - - const errors: string[] = []; - for (const url of urlsToFetch) { - try { - await this.fetchEpgFromUrl(url, options); - } catch (error) { - console.error( - this.loggerLabel, - `Error fetching EPG from ${url}:`, - error - ); - errors.push( - error instanceof Error ? error.message : String(error) - ); - } - } - - if (errors.length > 0) { - return { - success: errors.length < urlsToFetch.length, - message: errors.join('; '), - skipped: freshUrls, - }; - } - - return { success: true, skipped: freshUrls }; + ): Promise { + return handleFetchEpg(urls, options); } private static async fetchEpgFromUrl( @@ -354,21 +221,9 @@ export default class EpgEvents { channelIds: string[], options?: { sourceUrls?: string[] } ): Promise> { - const resolvedMap = await this.resolveChannelIds(channelIds); - const resolvedIds = channelIds.map( - (id) => resolvedMap.get(id) ?? id + return queryByResolvedChannelIds(channelIds, (resolvedIds) => + epgQueryService.getCurrentProgramsBatch(resolvedIds, options) ); - const results = await epgQueryService.getCurrentProgramsBatch( - resolvedIds, - options - ); - // Remap results back to the original request keys. - const remapped: Record = {}; - for (const originalId of channelIds) { - const resolvedId = resolvedMap.get(originalId) ?? originalId; - remapped[originalId] = results[resolvedId] ?? null; - } - return remapped; } private static async handleGetAllChannels(): Promise<{ @@ -382,21 +237,9 @@ export default class EpgEvents { channelIds: string[], options?: { sourceUrls?: string[] } ): Promise> { - const resolvedMap = await this.resolveChannelIds(channelIds); - const resolvedIds = channelIds.map( - (id) => resolvedMap.get(id) ?? id + return queryByResolvedChannelIds(channelIds, (resolvedIds) => + epgQueryService.getChannelMetadata(resolvedIds, options) ); - const results = await epgQueryService.getChannelMetadata( - resolvedIds, - options - ); - // Remap results back to the original request keys. - const remapped: Record = {}; - for (const originalId of channelIds) { - const resolvedId = resolvedMap.get(originalId) ?? originalId; - remapped[originalId] = results[resolvedId] ?? null; - } - return remapped; } private static async handleGetChannelsByRange( @@ -413,97 +256,6 @@ export default class EpgEvents { return epgQueryService.getChannelsByRange(skip, limit); } - // --------------------------------------------------------------------------- - // EPG mapping resolution — called at the IPC boundary before queries. - // --------------------------------------------------------------------------- - - /** - * Batch-resolve multiple channel IDs through manual mappings. - * Returns a Map of original ID → mapped ID (identity when no mapping). - */ - private static async resolveChannelIds( - channelIds: string[] - ): Promise> { - try { - const db = await getDatabase(); - const mappings = await getEpgMappingsBatch(db, channelIds); - return mappings; - } catch { - return new Map(); - } - } - - // --------------------------------------------------------------------------- - // EPG mapping CRUD handlers - // --------------------------------------------------------------------------- - - private static async handleGetEpgMapping( - channelKey: string - ): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> { - try { - const db = await getDatabase(); - return getEpgMapping(db, channelKey); - } catch { - return null; - } - } - - private static async handleGetEpgMappingsBatch( - channelKeys: string[] - ): Promise> { - if (!Array.isArray(channelKeys) || channelKeys.length === 0) { - return {}; - } - - try { - const db = await getDatabase(); - const mappings = await getEpgMappingsBatch(db, channelKeys); - return Object.fromEntries(mappings); - } catch { - return {}; - } - } - - private static async handleSetEpgMapping( - channelKey: string, - epgChannelId: string, - playlistId?: string - ): Promise<{ success: boolean }> { - try { - const db = await getDatabase(); - return setEpgMapping(db, channelKey, epgChannelId, playlistId); - } catch { - return { success: false }; - } - } - - private static async handleDeleteEpgMapping( - channelKey: string - ): Promise<{ success: boolean }> { - try { - const db = await getDatabase(); - return deleteEpgMapping(db, channelKey); - } catch { - return { success: false }; - } - } - - private static async handleSearchEpgChannels( - searchTerm: string, - limit?: number - ): Promise> { - if (!searchTerm?.trim()) { - return []; - } - - try { - const db = await getDatabase(); - return searchEpgChannels(db, searchTerm, limit); - } catch { - return []; - } - } - static async clearEpgData(): Promise { return epgWorkerService.clearEpgData(); } diff --git a/apps/electron-backend/src/app/events/playlist-import-performance.ts b/apps/electron-backend/src/app/events/playlist-import-performance.ts new file mode 100644 index 000000000..f5c6dc82d --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-import-performance.ts @@ -0,0 +1,215 @@ +import { + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, + PERFORMANCE_PHASE_BOUNDARY, + type M3uImportPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import { randomUUID } from 'node:crypto'; +import { channel, type Channel } from 'node:diagnostics_channel'; +import { performance } from 'node:perf_hooks'; + +export interface M3uImportPerformanceCaptureRuntime { + readonly createRequestId: () => string; + readonly readEpochMs: () => number; + readonly readMonotonicMs: () => number; +} + +export interface M3uImportPerformanceCapture { + measure( + phase: M3uImportPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): TResult; + measureAsync( + phase: M3uImportPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): Promise; +} + +interface CaptureOptions { + readonly emit?: ( + event: PerformancePhaseEvent + ) => void; + readonly enabled: boolean; + readonly requestId?: string; + readonly runtime?: M3uImportPerformanceCaptureRuntime; +} + +interface PhaseStart { + readonly monotonicMs: number; + readonly phase: M3uImportPerformancePhase; +} + +const DEFAULT_RUNTIME: M3uImportPerformanceCaptureRuntime = { + createRequestId: () => randomUUID(), + readEpochMs: () => performance.timeOrigin + performance.now(), + readMonotonicMs: () => performance.now(), +}; + +let performanceChannel: Channel | null = null; +let fallbackRequestSequence = 0; + +export function publishM3uImportPerformancePhaseEvent( + event: PerformancePhaseEvent +): void { + try { + performanceChannel ??= channel( + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + performanceChannel.publish(event); + } catch { + // Development-only instrumentation must never affect playlist import. + } +} + +export function createM3uImportPerformanceCapture( + options: CaptureOptions +): M3uImportPerformanceCapture | null { + if (!options.enabled) { + return null; + } + + const runtime = options.runtime ?? DEFAULT_RUNTIME; + const requestId = + normalizeRequestId(options.requestId) ?? createRequestId(runtime); + const emit = options.emit ?? publishM3uImportPerformancePhaseEvent; + + const begin = (phase: M3uImportPerformancePhase): PhaseStart => { + const monotonicMs = safeReadMonotonicMs(runtime); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(runtime), + metadata: undefined, + phase, + requestId, + }); + return { monotonicMs, phase }; + }; + + const end = ( + start: PhaseStart, + createMetadata?: () => PerformancePhaseMetadata + ): void => { + const endedMonotonicMs = safeReadMonotonicMs(runtime); + const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs); + const epochMs = safeReadEpochMs(runtime); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: Number.isFinite(durationMs) ? durationMs : 0, + epochMs, + metadata: sanitizeMetadata(safelyCreateMetadata(createMetadata)), + phase: start.phase, + requestId, + }); + }; + + return { + measure: (phase, execute, metadata) => { + const start = begin(phase); + try { + const result = execute(); + end(start, metadata ? () => metadata(result) : undefined); + return result; + } catch (error) { + end(start); + throw error; + } + }, + measureAsync: async (phase, execute, metadata) => { + const start = begin(phase); + try { + const result = await execute(); + end(start, metadata ? () => metadata(result) : undefined); + return result; + } catch (error) { + end(start); + throw error; + } + }, + }; +} + +function createRequestId(runtime: M3uImportPerformanceCaptureRuntime): string { + try { + const requestId = normalizeRequestId(runtime.createRequestId()); + if (requestId) { + return requestId; + } + } catch { + // Fall through to a process-local, credential-free identifier. + } + fallbackRequestSequence += 1; + return `m3u-import-${Date.now()}-${fallbackRequestSequence}`; +} + +function normalizeRequestId(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function safeReadEpochMs(runtime: M3uImportPerformanceCaptureRuntime): number { + try { + const value = runtime.readEpochMs(); + return Number.isFinite(value) ? value : Date.now(); + } catch { + return Date.now(); + } +} + +function safeReadMonotonicMs( + runtime: M3uImportPerformanceCaptureRuntime +): number { + try { + const value = runtime.readMonotonicMs(); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +} + +function safelyCreateMetadata( + createMetadata: (() => PerformancePhaseMetadata) | undefined +): PerformancePhaseMetadata | undefined { + if (!createMetadata) { + return undefined; + } + try { + return createMetadata(); + } catch { + return undefined; + } +} + +function sanitizeMetadata( + metadata: PerformancePhaseMetadata | undefined +): PerformancePhaseMetadata | undefined { + if (!metadata) { + return undefined; + } + + const sanitized: PerformancePhaseMetadata = {}; + if (isCount(metadata.byteCount)) { + (sanitized as { byteCount?: number }).byteCount = metadata.byteCount; + } + if (isCount(metadata.itemCount)) { + (sanitized as { itemCount?: number }).itemCount = metadata.itemCount; + } + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function isCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function safeEmit( + emit: (event: PerformancePhaseEvent) => void, + event: PerformancePhaseEvent +): void { + try { + emit(event); + } catch { + // Instrumentation delivery is deliberately fail-neutral. + } +} diff --git a/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts b/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts new file mode 100644 index 000000000..b3ac6cd25 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts @@ -0,0 +1,312 @@ +import { + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE, + type PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + +import { + createM3uImportPerformanceCapture, + type M3uImportPerformanceCaptureRuntime, +} from './playlist-import-performance'; +import { fetchPlaylistFromUrl } from './playlist-source'; + +const mockCreatePlaylistObject = jest.fn(); +const mockGetFilenameFromUrl = jest.fn(); +const mockParse = jest.fn(); +const mockRequestWithValidatedRedirects = jest.fn(); + +jest.mock('@iptvnator/shared/m3u-utils', () => ({ + createPlaylistObject: (...args: unknown[]) => + mockCreatePlaylistObject(...args), + getFilenameFromUrl: (...args: unknown[]) => mockGetFilenameFromUrl(...args), +})); + +jest.mock('iptv-playlist-parser', () => ({ + parse: (...args: unknown[]) => mockParse(...args), +})); + +jest.mock('../util/secure-https', () => ({ + createPlaylistAgentFactory: jest.fn(() => jest.fn()), +})); + +jest.mock('../util/validated-axios', () => ({ + requestWithValidatedRedirects: (...args: unknown[]) => + mockRequestWithValidatedRedirects(...args), +})); + +type ImportPhaseEvent = PerformancePhaseEvent< + (typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE] +>; + +function createRuntime(): M3uImportPerformanceCaptureRuntime { + const epochValues = [100, 110, 120, 130, 140, 150]; + const monotonicValues = [10, 14, 20, 23, 30, 36]; + + return { + createRequestId: jest.fn(() => 'm3u-request-1'), + readEpochMs: jest.fn(() => epochValues.shift() ?? 150), + readMonotonicMs: jest.fn(() => monotonicValues.shift() ?? 36), + }; +} + +describe('URL playlist import performance phases', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + mockCreatePlaylistObject.mockReset(); + mockGetFilenameFromUrl.mockReset(); + mockParse.mockReset(); + mockRequestWithValidatedRedirects.mockReset(); + }); + + it('does not allocate phase state or read clocks while capture is disabled', async () => { + const runtime = { + createRequestId: jest.fn(() => { + throw new Error('request id must stay untouched'); + }), + readEpochMs: jest.fn(() => { + throw new Error('epoch clock must stay untouched'); + }), + readMonotonicMs: jest.fn(() => { + throw new Error('monotonic clock must stay untouched'); + }), + }; + const emit = jest.fn(); + const playlist = { playlist: { items: [] }, title: 'Synthetic' }; + + mockRequestWithValidatedRedirects.mockResolvedValue({ + data: '#EXTM3U', + }); + mockParse.mockReturnValue({ items: [] }); + mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + + const capture = createM3uImportPerformanceCapture({ + emit, + enabled: false, + runtime, + }); + const result = await fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u', + undefined, + { performanceCapture: capture } + ); + + expect(capture).toBeNull(); + expect(result).toBe(playlist); + expect(emit).not.toHaveBeenCalled(); + expect(runtime.createRequestId).not.toHaveBeenCalled(); + expect(runtime.readEpochMs).not.toHaveBeenCalled(); + expect(runtime.readMonotonicMs).not.toHaveBeenCalled(); + }); + + it('emits ordered epoch-aligned pairs with durations and only byte/item metadata', async () => { + const events: ImportPhaseEvent[] = []; + const body = '#EXTM3U\n#EXTINF:-1,One\nhttps://stream.test/1'; + const bodyBytes = Buffer.byteLength(body, 'utf8'); + const parsed = { items: [{ name: 'One' }, { name: 'Two' }] }; + const playlist = { + playlist: { items: parsed.items }, + title: 'Synthetic', + }; + + mockRequestWithValidatedRedirects.mockResolvedValue({ + data: body, + headers: { + 'content-length': String(bodyBytes), + }, + }); + mockParse.mockReturnValue(parsed); + mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + + const capture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-1', + runtime: createRuntime(), + }); + const byteLength = jest.spyOn(Buffer, 'byteLength'); + const result = await fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u', + undefined, + { performanceCapture: capture } + ); + + expect(result).toBe(playlist); + expect(byteLength).not.toHaveBeenCalled(); + expect( + events.map( + ({ + boundary, + durationMs, + epochMs, + metadata, + phase, + requestId, + }) => ({ + boundary, + durationMs, + epochMs, + metadata, + phase, + requestId, + }) + ) + ).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 4, + epochMs: 110, + metadata: { byteCount: bodyBytes }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestId: 'm3u-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 120, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 3, + epochMs: 130, + metadata: { itemCount: 2 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestId: 'm3u-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 140, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 6, + epochMs: 150, + metadata: { itemCount: 2 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestId: 'm3u-request-1', + }, + ]); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain('127.0.0.1'); + expect(serialized).not.toContain('stream.test'); + expect(serialized).not.toContain('Synthetic'); + expect(serialized).not.toContain('username'); + expect(serialized).not.toContain('password'); + expect(serialized).not.toContain( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE + ); + }); + + it('preserves exact results and errors when event or metadata hooks fail', async () => { + const resultIdentity = { result: 'same-object' }; + const capture = createM3uImportPerformanceCapture({ + emit: () => { + throw new Error('event sink failed'); + }, + enabled: true, + requestId: 'm3u-request-2', + runtime: createRuntime(), + }); + + expect( + capture?.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + () => resultIdentity, + () => { + throw new Error('metadata failed'); + } + ) + ).toBe(resultIdentity); + + const businessError = new Error('download failed'); + const events: ImportPhaseEvent[] = []; + mockRequestWithValidatedRedirects.mockRejectedValue(businessError); + const errorCapture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-3', + runtime: createRuntime(), + }); + + await expect( + fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/failure.m3u', + undefined, + { + performanceCapture: errorCapture, + } + ) + ).rejects.toBe(businessError); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + }, + { + boundary: 'end', + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + }, + ]); + }); + + it('samples the end boundary before creating phase metadata', () => { + const events: ImportPhaseEvent[] = []; + let epochMs = 100; + let monotonicMs = 10; + const resultIdentity = { itemCount: 2 }; + const capture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-boundary', + runtime: { + createRequestId: jest.fn(() => 'unused-request-id'), + readEpochMs: jest.fn(() => epochMs), + readMonotonicMs: jest.fn(() => monotonicMs), + }, + }); + + const result = capture?.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + () => { + epochMs = 110; + monotonicMs = 20; + return resultIdentity; + }, + ({ itemCount }) => { + epochMs = 1_110; + monotonicMs = 1_020; + return { itemCount }; + } + ); + + expect(result).toBe(resultIdentity); + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + boundary: 'end', + durationMs: 10, + epochMs: 110, + metadata: { itemCount: 2 }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts index a55b9a96d..e147ae806 100644 --- a/apps/electron-backend/src/app/events/playlist-source.ts +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -13,8 +13,11 @@ import { isInvalidTlsCertificateError, } from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +import type { M3uImportPerformanceCapture } from './playlist-import-performance'; +import { M3U_IMPORT_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; export interface PlaylistFetchOptions { + performanceCapture?: M3uImportPerformanceCapture | null; trustedInsecureTlsHosts?: readonly string[]; } @@ -26,14 +29,67 @@ export interface PlaylistFetchOptions { */ export const PLAYLIST_FETCH_TIMEOUT_MS = 30000; +function countPlaylistItems(value: unknown): number | undefined { + try { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const items = Reflect.get(value, 'items'); + return Array.isArray(items) ? items.length : undefined; + } catch { + return undefined; + } +} + +function countNormalizedPlaylistItems(value: unknown): number | undefined { + try { + if (typeof value !== 'object' || value === null) { + return undefined; + } + return countPlaylistItems(Reflect.get(value, 'playlist')); + } catch { + return undefined; + } +} + +function readResponseContentLength(response: unknown): number | undefined { + try { + if (typeof response !== 'object' || response === null) { + return undefined; + } + const headers = Reflect.get(response, 'headers'); + if (typeof headers !== 'object' || headers === null) { + return undefined; + } + const directValue = Reflect.get(headers, 'content-length'); + const getHeader = Reflect.get(headers, 'get'); + const value = + directValue ?? + (typeof getHeader === 'function' + ? Reflect.apply(getHeader, headers, ['content-length']) + : undefined); + const numericValue = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim().length > 0 + ? Number(value) + : Number.NaN; + return Number.isSafeInteger(numericValue) && numericValue >= 0 + ? numericValue + : undefined; + } catch { + return undefined; + } +} + export async function fetchPlaylistFromUrl( url: string, title?: string, options: PlaylistFetchOptions = {} ): Promise { - let result; - try { - result = await requestWithValidatedRedirects( + const performanceCapture = options.performanceCapture; + const request = () => + requestWithValidatedRedirects( url, { agentFactory: createPlaylistAgentFactory({ @@ -44,6 +100,17 @@ export async function fetchPlaylistFromUrl( }, { allowPrivateNetworks: true } ); + let result; + try { + result = performanceCapture + ? await performanceCapture.measureAsync( + M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + request, + (response) => ({ + byteCount: readResponseContentLength(response), + }) + ) + : await request(); } catch (error) { if (isInvalidTlsCertificateError(error)) { throw createInvalidTlsCertificateError( @@ -53,19 +120,38 @@ export async function fetchPlaylistFromUrl( throw error; } - const parsedPlaylist = parse(result.data); - const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : ''; - const playlistName = - !extractedName || extractedName === 'Untitled playlist' - ? 'Imported from URL' - : extractedName; + const parsedPlaylist = performanceCapture + ? performanceCapture.measure( + M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + () => parse(result.data), + (parsed) => ({ itemCount: countPlaylistItems(parsed) }) + ) + : parse(result.data); + const normalize = (): Playlist => { + const extractedName = + url && url.length > 1 ? getFilenameFromUrl(url) : ''; + const playlistName = + !extractedName || extractedName === 'Untitled playlist' + ? 'Imported from URL' + : extractedName; - return createPlaylistObject( - title ?? playlistName, - parsedPlaylist, - url, - 'URL' - ); + return createPlaylistObject( + title ?? playlistName, + parsedPlaylist, + url, + 'URL' + ); + }; + + return performanceCapture + ? performanceCapture.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + normalize, + (playlist) => ({ + itemCount: countNormalizedPlaylistItems(playlist), + }) + ) + : normalize(); } export async function fetchPlaylistFromFile( diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts index f1300f5a6..0b12a65b1 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -6,10 +6,13 @@ import type { } from '@iptvnator/shared/interfaces'; import { AUTO_UPDATE_PLAYLISTS, + M3U_IMPORT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, PLAYLIST_REFRESH_EVENT, } from '@iptvnator/shared/interfaces'; +import { channel } from 'node:diagnostics_channel'; import { resolve } from 'node:path'; type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise; @@ -41,6 +44,8 @@ const mockCreatePlaylistObject = jest.fn(); const mockGetFilenameFromUrl = jest.fn(); const mockResolveWorkerRuntimeBootstrap = jest.fn(); const mockWorkerInstances: MockWorker[] = []; +const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE'; +const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV]; jest.mock('electron', () => ({ app: { @@ -169,6 +174,11 @@ describe('playlist IPC events', () => { }); afterEach(() => { + if (originalPerformanceCaptureValue === undefined) { + delete process.env[PERF_CAPTURE_ENV]; + } else { + process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue; + } consoleErrorSpy.mockRestore(); consoleLogSpy.mockRestore(); consoleWarnSpy.mockRestore(); @@ -210,6 +220,101 @@ describe('playlist IPC events', () => { expect(result).toEqual(playlist); }); + it('publishes request-scoped URL import phases only through the opt-in internal channel', async () => { + process.env[PERF_CAPTURE_ENV] = '1'; + const body = '#EXTM3U\n#EXTINF:-1,News\nhttps://stream.test/news'; + const parsedPlaylist = { items: [{ name: 'News' }] }; + const playlist = createPlaylist({ + title: 'synthetic-performance.m3u', + url: 'http://127.0.0.1:43210/synthetic-performance.m3u', + }); + const events: unknown[] = []; + const performanceChannel = channel( + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => events.push(event); + + mockAxiosGet.mockResolvedValue({ + data: body, + headers: { + 'content-length': String(Buffer.byteLength(body, 'utf8')), + }, + }); + mockParse.mockReturnValue(parsedPlaylist); + mockGetFilenameFromUrl.mockReturnValue('synthetic-performance.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + performanceChannel.subscribe(subscriber); + + try { + await expect( + getHandler('fetch-playlist-by-url')( + createIpcEvent(), + 'http://127.0.0.1:43210/synthetic-performance.m3u' + ) + ).resolves.toBe(playlist); + } finally { + performanceChannel.unsubscribe(subscriber); + } + + expect( + events.map((event) => { + const marker = event as { + boundary: string; + metadata?: { byteCount?: number; itemCount?: number }; + phase: string; + requestId: string; + }; + return { + boundary: marker.boundary, + metadata: marker.metadata, + phase: marker.phase, + requestIdType: typeof marker.requestId, + }; + }) + ).toEqual([ + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { byteCount: Buffer.byteLength(body, 'utf8') }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestIdType: 'string', + }, + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestIdType: 'string', + }, + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestIdType: 'string', + }, + ]); + expect( + new Set(events.map((event) => (event as any).requestId)).size + ).toBe(1); + expect(JSON.stringify(events)).not.toContain('stream.test'); + }); + it('returns null when the open playlist dialog is cancelled', async () => { mockShowOpenDialog.mockResolvedValue({ canceled: true, diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index ddd273d5a..71ea32af1 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -32,6 +32,8 @@ import { fetchPlaylistFromUrl, } from './playlist-source'; import { PlaylistWriteAuthorizer } from './playlist-write-authorization'; +import { createM3uImportPerformanceCapture } from './playlist-import-performance'; +import { isPerformanceCaptureEnabled } from '../services/debug-trace'; export default class PlaylistEvents { static bootstrapPlaylistEvents(): Electron.IpcMain { @@ -95,7 +97,11 @@ ipcMain.handle( options?: ElectronBridgeTrustOptions ) => { try { + const performanceCapture = createM3uImportPerformanceCapture({ + enabled: isPerformanceCaptureEnabled(), + }); return await fetchPlaylistFromUrl(url, title, { + performanceCapture, trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts, }); } catch (error) { diff --git a/apps/electron-backend/src/app/events/remote-control.events.spec.ts b/apps/electron-backend/src/app/events/remote-control.events.spec.ts new file mode 100644 index 000000000..67713fe93 --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control.events.spec.ts @@ -0,0 +1,588 @@ +import type * as http from 'node:http'; +import { PassThrough } from 'node:stream'; + +type HttpHandler = ( + request: http.IncomingMessage, + response: http.ServerResponse +) => void; +type IpcCallback = (...args: unknown[]) => unknown; + +interface RemoteControlSettings { + enabled: boolean; + port: number; +} + +interface ResponseSnapshot { + statusCode: number; + headers: http.OutgoingHttpHeaders; + body: string; +} + +interface ResponseRecorder { + response: http.ServerResponse; + completed: Promise; +} + +interface RequestInvocation { + response: ResponseSnapshot; + request: PassThrough; + requestClosed: Promise; + requestErrors: Error[]; +} + +interface InvokeOptions { + method: string; + body?: string | Buffer; +} + +const REMOTE_CONTROL_PATHS = { + STATUS: '/api/remote-control/status', + SELECT_NUMBER: '/api/remote-control/channel/select-number', + CHANNEL_UP: '/api/remote-control/channel/up', + CHANNEL_DOWN: '/api/remote-control/channel/down', + VOLUME_UP: '/api/remote-control/volume/up', + VOLUME_DOWN: '/api/remote-control/volume/down', + VOLUME_TOGGLE_MUTE: '/api/remote-control/volume/toggle-mute', +} as const; + +const JSON_HEADERS = { 'Content-Type': 'application/json' } as const; +const SUCCESS_RESPONSE = { + statusCode: 200, + headers: JSON_HEADERS, + body: JSON.stringify({ success: true }), +} as const; +const METHOD_NOT_ALLOWED_RESPONSE = { + statusCode: 405, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Method not allowed' }), +} as const; + +const mockHttpHandlers = new Map(); +const mockIpcHandlers = new Map(); +const mockIpcListeners = new Map(); +const mockRegisterRemoteControlHandler = jest.fn( + (path: string, handler: HttpHandler) => { + mockHttpHandlers.set(path, handler); + } +); +const mockStartHttpServer = jest.fn(); +const mockStoreGet = jest.fn(); +const mockFirstRendererSend = jest.fn(); +const mockSecondRendererSend = jest.fn(); +const mockGetAllWindows = jest.fn(); +const mockIpcHandle = jest.fn((channel: string, handler: IpcCallback) => { + mockIpcHandlers.set(channel, handler); +}); +const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => { + mockIpcListeners.set(channel, listener); +}); + +jest.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: mockGetAllWindows, + }, + ipcMain: { + handle: mockIpcHandle, + on: mockIpcOn, + }, +})); + +jest.mock('../server/http-server', () => ({ + httpServer: { + registerRemoteControlHandler: mockRegisterRemoteControlHandler, + start: mockStartHttpServer, + }, +})); + +jest.mock('../services/store.service', () => ({ + store: { + get: mockStoreGet, + }, +})); + +import { RemoteControlEvents } from './remote-control.events'; + +function createResponseRecorder(): ResponseRecorder { + let statusCode = 0; + let headers: http.OutgoingHttpHeaders = {}; + let response: http.ServerResponse; + let responseEnded = false; + let resolveCompleted: (snapshot: ResponseSnapshot) => void = () => { + throw new Error('Response completion promise is not initialized'); + }; + const completed = new Promise((resolve) => { + resolveCompleted = resolve; + }); + + response = { + writeHead: ( + nextStatusCode: number, + nextHeaders?: http.OutgoingHttpHeaders + ) => { + statusCode = nextStatusCode; + headers = { ...nextHeaders }; + return response; + }, + end: (chunk?: string | Uint8Array) => { + if (responseEnded) { + throw new Error('Response ended more than once'); + } + responseEnded = true; + const body = + typeof chunk === 'string' + ? chunk + : chunk + ? Buffer.from(chunk).toString('utf8') + : ''; + resolveCompleted({ statusCode, headers, body }); + return response; + }, + } as unknown as http.ServerResponse; + + return { response, completed }; +} + +function getHttpHandler(path: string): HttpHandler { + const handler = mockHttpHandlers.get(path); + if (!handler) { + throw new Error(`Expected HTTP handler for ${path}`); + } + + return handler; +} + +function getIpcHandler(channel: string): IpcCallback { + const handler = mockIpcHandlers.get(channel); + if (!handler) { + throw new Error(`Expected IPC handler for ${channel}`); + } + + return handler; +} + +function getIpcListener(channel: string): IpcCallback { + const listener = mockIpcListeners.get(channel); + if (!listener) { + throw new Error(`Expected IPC listener for ${channel}`); + } + + return listener; +} + +function bootstrapRemoteControl( + settings: RemoteControlSettings = { enabled: false, port: 8765 } +): RemoteControlEvents { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => { + if (key === 'remoteControl') { + return settings.enabled; + } + if (key === 'remoteControlPort') { + return settings.port; + } + + return fallbackValue; + } + ); + + const events = new RemoteControlEvents(); + events.bootstrapRemoteControlEvents(); + return events; +} + +async function invokeHttpHandler( + path: string, + options: InvokeOptions +): Promise { + const request = new PassThrough(); + const requestErrors: Error[] = []; + request.on('error', (error: Error) => { + requestErrors.push(error); + }); + const requestClosed = new Promise((resolve) => { + request.once('close', resolve); + }); + const incomingMessage = Object.assign(request, { + method: options.method, + url: path, + }) as unknown as http.IncomingMessage; + const recorder = createResponseRecorder(); + + getHttpHandler(path)(incomingMessage, recorder.response); + request.end(options.body); + + return { + response: await recorder.completed, + request, + requestClosed, + requestErrors, + }; +} + +function createBodyAtByteLength(byteLength: number): string { + const prefix = '{"number":7,"padding":"'; + const suffix = '"}'; + const paddingLength = + byteLength - + Buffer.byteLength(prefix, 'utf8') - + Buffer.byteLength(suffix, 'utf8'); + if (paddingLength < 0) { + throw new Error(`Cannot create a JSON body at ${byteLength} bytes`); + } + + const body = `${prefix}${'x'.repeat(paddingLength)}${suffix}`; + if (Buffer.byteLength(body, 'utf8') !== byteLength) { + throw new Error(`Expected a ${byteLength}-byte JSON body`); + } + + return body; +} + +describe('RemoteControlEvents', () => { + let consoleLog: jest.SpyInstance; + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + mockHttpHandlers.clear(); + mockIpcHandlers.clear(); + mockIpcListeners.clear(); + mockRegisterRemoteControlHandler.mockClear(); + mockStartHttpServer.mockReset(); + mockStoreGet.mockReset(); + mockFirstRendererSend.mockReset(); + mockSecondRendererSend.mockReset(); + mockGetAllWindows.mockReset(); + mockIpcHandle.mockClear(); + mockIpcOn.mockClear(); + mockGetAllWindows.mockReturnValue([ + { webContents: { send: mockFirstRendererSend } }, + { webContents: { send: mockSecondRendererSend } }, + ]); + consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + }); + + it('registers exactly the seven remote-control HTTP endpoints', () => { + bootstrapRemoteControl(); + + expect(mockRegisterRemoteControlHandler).toHaveBeenCalledTimes(7); + expect([...mockHttpHandlers.keys()].sort()).toEqual( + Object.values(REMOTE_CONTROL_PATHS).sort() + ); + }); + + it('starts the HTTP server once with the stored port when enabled', () => { + bootstrapRemoteControl({ enabled: true, port: 9987 }); + + expect(mockStoreGet).toHaveBeenNthCalledWith(1, 'remoteControl', false); + expect(mockStoreGet).toHaveBeenNthCalledWith( + 2, + 'remoteControlPort', + 8765 + ); + expect(mockStartHttpServer).toHaveBeenCalledTimes(1); + expect(mockStartHttpServer).toHaveBeenCalledWith(9987); + }); + + it('does not start the HTTP server when remote control is disabled', () => { + bootstrapRemoteControl({ enabled: false, port: 9987 }); + + expect(mockStartHttpServer).not.toHaveBeenCalled(); + }); + + it('registers and dispatches both main-app channel IPC handlers', () => { + bootstrapRemoteControl(); + + expect(mockIpcHandle).toHaveBeenCalledTimes(2); + expect([...mockIpcHandlers.keys()].sort()).toEqual([ + 'REMOTE_CONTROL_CHANNEL_DOWN', + 'REMOTE_CONTROL_CHANNEL_UP', + ]); + + const ipcEventPlaceholder = {}; + expect( + getIpcHandler('REMOTE_CONTROL_CHANNEL_UP')(ipcEventPlaceholder) + ).toBeUndefined(); + expect( + getIpcHandler('REMOTE_CONTROL_CHANNEL_DOWN')(ipcEventPlaceholder) + ).toBeUndefined(); + + expect(mockFirstRendererSend).toHaveBeenCalledTimes(2); + expect(mockFirstRendererSend).toHaveBeenNthCalledWith( + 1, + 'CHANNEL_CHANGE', + { direction: 'up' } + ); + expect(mockFirstRendererSend).toHaveBeenNthCalledWith( + 2, + 'CHANNEL_CHANGE', + { direction: 'down' } + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it.each([ + { + path: REMOTE_CONTROL_PATHS.CHANNEL_UP, + channel: 'CHANNEL_CHANGE', + payload: { direction: 'up' }, + }, + { + path: REMOTE_CONTROL_PATHS.CHANNEL_DOWN, + channel: 'CHANNEL_CHANGE', + payload: { direction: 'down' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_UP, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-up' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_DOWN, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-down' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-toggle-mute' }, + }, + ] as const)( + 'dispatches POST $path to the first renderer', + async ({ path, channel, payload }) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(path, { method: 'POST' }); + + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledTimes(1); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + channel, + payload + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + } + ); + + it.each([ + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + REMOTE_CONTROL_PATHS.CHANNEL_UP, + REMOTE_CONTROL_PATHS.CHANNEL_DOWN, + REMOTE_CONTROL_PATHS.VOLUME_UP, + REMOTE_CONTROL_PATHS.VOLUME_DOWN, + REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE, + ])('rejects GET on POST-only endpoint %s', async (path) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(path, { method: 'GET' }); + + expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('rejects POST on the status endpoint', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { + method: 'POST', + }); + + expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('floors a finite positive channel number and dispatches it', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body: JSON.stringify({ number: 7.9 }), + } + ); + + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + 'REMOTE_CONTROL_COMMAND', + { + type: 'channel-select-number', + number: 7, + } + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it.each([ + { label: 'missing', body: '{}' }, + { label: 'zero', body: '{"number":0}' }, + { label: 'negative', body: '{"number":-2}' }, + { label: 'infinite', body: '{"number":1e309}' }, + { label: 'nonnumeric', body: '{"number":"seven"}' }, + ])('rejects a $label channel number', async ({ body }) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + + expect(result.response).toEqual({ + statusCode: 400, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Invalid channel number' }), + }); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('rejects malformed JSON without dispatching', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body: '{"number":', + } + ); + + expect(result.response).toEqual({ + statusCode: 400, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Invalid JSON payload' }), + }); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('accepts a valid JSON request at the 10,240-byte body limit', async () => { + bootstrapRemoteControl(); + const body = createBodyAtByteLength(10 * 1024); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + + expect(Buffer.byteLength(body, 'utf8')).toBe(10_240); + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + 'REMOTE_CONTROL_COMMAND', + { + type: 'channel-select-number', + number: 7, + } + ); + }); + + it('rejects and destroys a 10,241-byte request without dispatching', async () => { + bootstrapRemoteControl(); + const body = Buffer.alloc(10 * 1024 + 1, 'x'); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + await result.requestClosed; + + expect(body.byteLength).toBe(10_241); + expect(result.response).toEqual({ + statusCode: 413, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Payload too large' }), + }); + expect(result.request.destroyed).toBe(true); + expect(result.requestErrors).toEqual([]); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('merges partial status updates and refreshes updatedAt', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z')); + bootstrapRemoteControl(); + const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE'); + + jest.setSystemTime(new Date('2026-07-25T10:01:00.000Z')); + updateStatus( + {}, + { + portal: 'm3u', + isLiveView: true, + channelName: 'News', + volume: 35, + } + ); + jest.setSystemTime(new Date('2026-07-25T10:02:00.000Z')); + updateStatus({}, { channelName: 'Sports', muted: true }); + + const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { + method: 'GET', + }); + + expect(result.response).toEqual({ + statusCode: 200, + headers: JSON_HEADERS, + body: JSON.stringify({ + portal: 'm3u', + isLiveView: true, + supportsVolume: false, + updatedAt: '2026-07-25T10:02:00.000Z', + channelName: 'Sports', + volume: 35, + muted: true, + }), + }); + }); + + it.each([ + { + path: REMOTE_CONTROL_PATHS.CHANNEL_UP, + warning: 'No browser windows found to send channel change', + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_UP, + warning: 'No browser windows found to send remote command', + }, + ])( + 'returns success and warns instead of throwing when $path has no renderer', + async ({ path, warning }) => { + mockGetAllWindows.mockReturnValue([]); + bootstrapRemoteControl(); + + await expect( + invokeHttpHandler(path, { method: 'POST' }) + ).resolves.toMatchObject({ + response: SUCCESS_RESPONSE, + }); + expect(consoleWarn).toHaveBeenCalledWith(warning); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/electron-backend/src/app/events/remote-control.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts index 366284ade..9f20d8bec 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -26,7 +26,7 @@ interface RemoteControlStatus { updatedAt?: string; } -class RemoteControlEvents { +export class RemoteControlEvents { private remoteControlStatus: RemoteControlStatus = { portal: 'unknown', isLiveView: false, diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index fa0825ea3..d115c0514 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -1,45 +1,196 @@ -const handlers = new Map unknown>(); +import type { Settings } from '@iptvnator/shared/interfaces'; + +type ExternalPlayerArgumentsSetting = string | readonly unknown[]; +type SettingsUpdatePayload = Omit< + Partial, + | 'embeddedMpvFrameCopy' + | 'language' + | 'mpvPlayerArguments' + | 'vlcPlayerArguments' +> & { + embeddedMpvFrameCopy?: boolean | number; + language?: string; + mpvPlayerArguments?: ExternalPlayerArgumentsSetting; + vlcPlayerArguments?: ExternalPlayerArgumentsSetting; +}; +type SettingsUpdateHandler = ( + event: unknown, + settings: SettingsUpdatePayload +) => unknown; + +const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; +const STORE_KEYS = { + EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY', + MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS', + MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', + VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', +} as const; + +const handlers = new Map(); +const mockStoreGet = jest.fn(); +const mockStoreSet = jest.fn(); +const mockUpdateSettings = jest.fn(); +const mockIpcHandle = jest.fn( + (channel: string, handler: SettingsUpdateHandler): void => { + handlers.set(channel, handler); + } +); jest.mock('electron', () => ({ ipcMain: { - handle: jest.fn( - (channel: string, handler: (...args: unknown[]) => unknown) => { - handlers.set(channel, handler); - } - ), + handle: mockIpcHandle, }, })); jest.mock('../services/store.service', () => ({ - MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments', - MPV_REUSE_INSTANCE: 'mpvReuseInstance', - VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments', - VLC_REUSE_INSTANCE: 'vlcReuseInstance', - store: { get: jest.fn(), set: jest.fn() }, + EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, + MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS, + MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE, + VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS, + VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE, + store: { + get: mockStoreGet, + set: mockStoreSet, + }, })); jest.mock('../server/http-server', () => ({ - httpServer: { updateSettings: jest.fn() }, + httpServer: { + updateSettings: mockUpdateSettings, + }, })); -describe('SETTINGS_UPDATE logging', () => { +describe('SETTINGS_UPDATE', () => { + let consoleLogSpy: jest.SpiedFunction; + let settingsUpdateHandler: SettingsUpdateHandler; + beforeEach(async () => { - jest.spyOn(console, 'log').mockImplementation(() => undefined); + handlers.clear(); + jest.resetModules(); + mockIpcHandle.mockClear(); + mockStoreGet.mockReset(); + mockStoreSet.mockReset(); + mockUpdateSettings.mockReset(); + mockStoreGet.mockImplementation( + (_key: string, fallbackValue: unknown): unknown => fallbackValue + ); + consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + await import('./settings.events'); + + const registeredHandler = handlers.get(SETTINGS_UPDATE); + if (!registeredHandler) { + throw new Error(`Missing ipcMain handler for ${SETTINGS_UPDATE}`); + } + settingsUpdateHandler = registeredHandler; }); afterEach(() => { - jest.restoreAllMocks(); + jest.useRealTimers(); + consoleLogSpy.mockRestore(); + }); + + it('normalizes external-player arguments and preserves explicit false reuse settings', () => { + settingsUpdateHandler( + {}, + { + mpvPlayerArguments: [' --screen=1 ', '', ' --hwdec=auto-safe '], + mpvReuseInstance: false, + vlcPlayerArguments: + ' --network-caching=1000 \r\n\n --fullscreen ', + vlcReuseInstance: false, + } + ); + + expect(mockStoreSet.mock.calls).toEqual([ + [STORE_KEYS.MPV_PLAYER_ARGUMENTS, '--screen=1\n--hwdec=auto-safe'], + [ + STORE_KEYS.VLC_PLAYER_ARGUMENTS, + '--network-caching=1000\n--fullscreen', + ], + [STORE_KEYS.MPV_REUSE_INSTANCE, false], + [STORE_KEYS.VLC_REUSE_INSTANCE, false], + ]); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('does not persist or reconcile an empty settings update', () => { + settingsUpdateHandler({}, {}); + + expect(mockStoreGet).not.toHaveBeenCalled(); + expect(mockStoreSet).not.toHaveBeenCalled(); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('coerces an enabled embedded-MPV frame-copy value to boolean true', () => { + settingsUpdateHandler({}, { embeddedMpvFrameCopy: 1 }); + + expect(mockStoreSet).toHaveBeenCalledTimes(1); + expect(mockStoreSet).toHaveBeenCalledWith( + STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, + true + ); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('reconciles an enabled remote-control update with the stored port', () => { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => + key === 'remoteControlPort' ? 9988 : fallbackValue + ); + + settingsUpdateHandler({}, { remoteControl: true }); + + expect(mockStoreGet.mock.calls).toEqual([['remoteControlPort', 8765]]); + expect(mockStoreSet.mock.calls).toEqual([['remoteControl', true]]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9988); + }); + + it('reconciles a remote-control port update with the stored enabled state', () => { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => + key === 'remoteControl' ? true : fallbackValue + ); + + settingsUpdateHandler({}, { remoteControlPort: 9123 }); + + expect(mockStoreGet.mock.calls).toEqual([['remoteControl', false]]); + expect(mockStoreSet.mock.calls).toEqual([['remoteControlPort', 9123]]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9123); + }); + + it('preserves explicit false and zero remote-control values', () => { + settingsUpdateHandler( + {}, + { remoteControl: false, remoteControlPort: 0 } + ); + + expect(mockStoreGet).not.toHaveBeenCalled(); + expect(mockStoreSet.mock.calls).toEqual([ + ['remoteControl', false], + ['remoteControlPort', 0], + ]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(false, 0); }); it('does not print a TMDB apiKey while retaining useful fields', () => { const apiKey = 'tmdb-settings-api-key-secret'; - const handler = handlers.get('SETTINGS_UPDATE'); - expect(handler).toBeDefined(); - handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } }); + settingsUpdateHandler( + {}, + { + language: 'de', + tmdb: { apiKey, enabled: true }, + } + ); - const output = JSON.stringify((console.log as jest.Mock).mock.calls); + const output = JSON.stringify(consoleLogSpy.mock.calls); expect(output).not.toContain(apiKey); expect(output).toContain('language'); expect(output).toContain('de'); @@ -49,10 +200,8 @@ describe('SETTINGS_UPDATE logging', () => { it('does not print credentials embedded in external player arguments', () => { const authorizationSecret = 'player-authorization-secret'; const cookieSecret = 'player-cookie-secret'; - const handler = handlers.get('SETTINGS_UPDATE'); - expect(handler).toBeDefined(); - handler?.( + settingsUpdateHandler( {}, { language: 'de', @@ -61,7 +210,7 @@ describe('SETTINGS_UPDATE logging', () => { } ); - const output = JSON.stringify((console.log as jest.Mock).mock.calls); + const output = JSON.stringify(consoleLogSpy.mock.calls); expect(output).not.toContain(authorizationSecret); expect(output).not.toContain(cookieSecret); expect(output).toContain('language'); diff --git a/apps/electron-backend/src/app/server/http-server.spec.ts b/apps/electron-backend/src/app/server/http-server.spec.ts new file mode 100644 index 000000000..88f3d8d38 --- /dev/null +++ b/apps/electron-backend/src/app/server/http-server.spec.ts @@ -0,0 +1,448 @@ +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, posix, win32 } from 'node:path'; + +import { HttpServer, resolveStaticFilePath } from './http-server'; + +interface CapturedServer { + listening: Promise; + listeningEvents: number; + server: http.Server; +} + +interface TestResponse { + body: Buffer; + contentType: string | undefined; + statusCode: number | undefined; +} + +const INDEX_BODY = Buffer.from('
IPTVnator remote control
'); +const ASSETS = { + '/app.js': { + body: Buffer.from('globalThis.remoteControlLoaded = true;'), + contentType: 'application/javascript', + }, + '/styles.css': { + body: Buffer.from('main { color: rebeccapurple; }'), + contentType: 'text/css', + }, + '/data.json': { + body: Buffer.from('{"ready":true}'), + contentType: 'application/json', + }, + '/asset.unknown': { + body: Buffer.from([0x00, 0xff, 0x49, 0x50, 0x54, 0x56]), + contentType: 'application/octet-stream', + }, +} as const; + +jest.setTimeout(15_000); + +function request(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + const clientRequest = http.request( + { + host: '127.0.0.1', + method: 'GET', + path, + port, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + response.on('end', () => { + const contentType = response.headers['content-type']; + resolve({ + body: Buffer.concat(chunks), + contentType: + typeof contentType === 'string' + ? contentType + : undefined, + statusCode: response.statusCode, + }); + }); + response.on('error', reject); + } + ); + + clientRequest.on('error', reject); + clientRequest.end(); + }); +} + +function getAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the HTTP server to have an IP address'); + } + return address; +} + +async function closeServer(server: http.Server): Promise { + if (!server.listening) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +async function getAvailablePort(): Promise { + const probe = http.createServer(); + + await new Promise((resolve, reject) => { + probe.once('error', reject); + probe.listen(0, '127.0.0.1', resolve); + }); + + const port = getAddress(probe).port; + await closeServer(probe); + return port; +} + +describe('resolveStaticFilePath', () => { + const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control'; + const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control'; + + it('rejects a Windows double-leading-slash traversal', () => { + expect( + resolveStaticFilePath( + WINDOWS_STATIC_ROOT, + '//../../outside-secret.txt', + win32 + ) + ).toBeNull(); + }); + + it('rejects encoded traversal segments after one decode', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/%2e%2e/outside-secret.txt', + posix + ) + ).toBeNull(); + }); + + it('fails closed for malformed percent encoding without throwing', () => { + expect( + resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix) + ).toBeNull(); + }); + + it('rejects decoded NUL bytes', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/assets/%00secret.js', + posix + ) + ).toBeNull(); + }); + + it('resolves a valid Windows-style asset inside the static root', () => { + expect( + resolveStaticFilePath( + WINDOWS_STATIC_ROOT, + '/assets\\app.js?version=1', + win32 + ) + ).toBe('C:\\iptvnator\\remote-control\\assets\\app.js'); + }); + + it('ignores query and fragment data for filesystem resolution', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/data.json?cache=1#ignored', + posix + ) + ).toBe('/opt/iptvnator/remote-control/data.json'); + }); + + it('decodes the URL pathname exactly once', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/%252e%252e/asset.js', + posix + ) + ).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js'); + }); +}); + +describe('HttpServer', () => { + let capturedServers: CapturedServer[]; + let distPath: string; + let server: HttpServer; + let tempRoot: string; + + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + + capturedServers = []; + tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-http-server-')); + distPath = join(tempRoot, 'static', 'app'); + mkdirSync(distPath, { recursive: true }); + writeFileSync(join(distPath, 'index.html'), INDEX_BODY); + + for (const [assetPath, asset] of Object.entries(ASSETS)) { + writeFileSync(join(distPath, assetPath), asset.body); + } + + writeFileSync( + join(tempRoot, 'outside-secret.txt'), + 'must-not-leave-the-static-directory' + ); + + server = new HttpServer({ + createServer: (requestListener) => { + const nodeServer = http.createServer(requestListener); + const captured: CapturedServer = { + listening: once(nodeServer, 'listening').then( + () => undefined + ), + listeningEvents: 0, + server: nodeServer, + }; + nodeServer.on('listening', () => { + captured.listeningEvents += 1; + }); + capturedServers.push(captured); + return nodeServer; + }, + distPath, + }); + }); + + afterEach(async () => { + let closeError: unknown; + + try { + await Promise.all( + capturedServers.map(({ server: nodeServer }) => + closeServer(nodeServer) + ) + ); + } catch (error) { + closeError = error; + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + jest.restoreAllMocks(); + } + + if (closeError) { + throw closeError; + } + }); + + async function startServer(): Promise { + const serverIndex = capturedServers.length; + server.start(0); + const captured = capturedServers[serverIndex]; + if (!captured) { + throw new Error('Expected HttpServer.start() to create a server'); + } + + await captured.listening; + return captured; + } + + it('serves the exact index document at the root', async () => { + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('asks the operating system for an ephemeral port when started with zero', async () => { + const captured = await startServer(); + + expect(getAddress(captured.server).port).not.toBe(8765); + }); + + it.each(Object.entries(ASSETS))( + 'serves %s with its expected MIME type', + async (assetPath, asset) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + assetPath + ); + + expect(response).toEqual({ + body: asset.body, + contentType: asset.contentType, + statusCode: 200, + }); + } + ); + + it('falls back to the exact index document for a client route', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/channels/favorites' + ); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('returns a plain-text 404 when the index document is missing', async () => { + unlinkSync(join(distPath, 'index.html')); + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + }); + + it('dispatches a registered API request without serving a static file', async () => { + let receivedRequest: http.IncomingMessage | undefined; + server.registerRemoteControlHandler( + '/api/remote-control/status', + (incomingRequest, response) => { + receivedRequest = incomingRequest; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"source":"api-handler"}'); + } + ); + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/status' + ); + + expect(receivedRequest?.url).toBe('/api/remote-control/status'); + expect(response).toEqual({ + body: Buffer.from('{"source":"api-handler"}'), + contentType: 'application/json', + statusCode: 200, + }); + expect(response.body).not.toEqual(INDEX_BODY); + }); + + it('returns a JSON 404 for an unknown remote-control API endpoint', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/missing' + ); + + expect(response).toEqual({ + body: Buffer.from('{"error":"Endpoint not found"}'), + contentType: 'application/json', + statusCode: 404, + }); + }); + + it.each([ + '/../../outside-secret.txt', + '//../../outside-secret.txt', + '/%2e%2e/%2e%2e/outside-secret.txt', + '/%E0%A4%A', + ])( + 'rejects the invalid static request target %s without serving an outside file', + async (requestTarget) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + requestTarget + ); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + expect(response.body.toString()).not.toContain( + 'must-not-leave-the-static-directory' + ); + } + ); + + it('creates and listens on only one server when started twice', async () => { + server.start(0); + server.start(0); + + expect(capturedServers).toHaveLength(1); + await capturedServers[0].listening; + expect(capturedServers[0].listeningEvents).toBe(1); + }); + + it('stops the running server when disabled', async () => { + const captured = await startServer(); + const closed = once(captured.server, 'close'); + + server.updateSettings(false, 0); + await closed; + + expect(captured.server.listening).toBe(false); + expect(capturedServers).toHaveLength(1); + }); + + it('stops once and starts once on the new port when the port changes', async () => { + const first = await startServer(); + const newPort = await getAvailablePort(); + let closeEvents = 0; + first.server.on('close', () => { + closeEvents += 1; + }); + const firstClosed = once(first.server, 'close'); + + server.updateSettings(true, newPort); + const second = capturedServers[1]; + if (!second) { + throw new Error('Expected a replacement HTTP server'); + } + await Promise.all([firstClosed, second.listening]); + + expect(capturedServers).toHaveLength(2); + expect(closeEvents).toBe(1); + expect(first.server.listening).toBe(false); + expect(second.listeningEvents).toBe(1); + expect(getAddress(second.server).port).toBe(newPort); + await expect(request(newPort, '/')).resolves.toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); +}); diff --git a/apps/electron-backend/src/app/server/http-server.ts b/apps/electron-backend/src/app/server/http-server.ts index 23a235133..a6b24ce37 100644 --- a/apps/electron-backend/src/app/server/http-server.ts +++ b/apps/electron-backend/src/app/server/http-server.ts @@ -3,10 +3,61 @@ import * as fs from 'fs'; import * as http from 'http'; import * as path from 'path'; +type HttpServerFactory = (requestListener: http.RequestListener) => http.Server; + +interface HttpServerOptions { + createServer?: HttpServerFactory; + distPath?: string; +} + +/** + * Resolve an HTTP request target within the configured static root. + * The path implementation is injectable so platform-specific semantics remain testable. + */ +export function resolveStaticFilePath( + staticRoot: string, + requestTarget: string, + pathImplementation: path.PlatformPath = path +): string | null { + const separatorIndex = requestTarget.search(/[?#]/); + const encodedPathname = + separatorIndex === -1 + ? requestTarget + : requestTarget.slice(0, separatorIndex); + + let pathname: string; + try { + pathname = decodeURIComponent(encodedPathname); + } catch { + return null; + } + + if (pathname.includes('\0')) { + return null; + } + + const relativeCandidate = pathname.replace(/^[/\\]+/, '') || 'index.html'; + const resolvedRoot = pathImplementation.resolve(staticRoot); + const candidate = pathImplementation.resolve( + resolvedRoot, + relativeCandidate + ); + + if ( + candidate === resolvedRoot || + candidate.startsWith(`${resolvedRoot}${pathImplementation.sep}`) + ) { + return candidate; + } + + return null; +} + /** * HTTP server for serving the remote control web app and providing REST API endpoints */ export class HttpServer { + private readonly createServer: HttpServerFactory; private server: http.Server | null = null; private port = 8765; private isEnabled = false; @@ -16,6 +67,11 @@ export class HttpServer { (req: http.IncomingMessage, res: http.ServerResponse) => void > = new Map(); + constructor(options: HttpServerOptions = {}) { + this.createServer = options.createServer ?? http.createServer; + this.distPath = options.distPath ?? null; + } + /** * Get the path to the remote-control-web static files. * Lazily computed to avoid calling Electron APIs before app is ready. @@ -43,11 +99,7 @@ export class HttpServer { } else { // Production mode - files are bundled with the app // electron-builder copies remote-control-web/**/* directly to app root - this.distPath = path.join( - appPath, - 'remote-control-web', - 'browser' - ); + this.distPath = path.join(appPath, 'remote-control-web', 'browser'); } console.log('[HTTP Server] Serving from:', this.distPath); @@ -58,7 +110,7 @@ export class HttpServer { * Start the HTTP server */ start(port?: number): void { - if (port) { + if (port !== undefined) { this.port = port; } @@ -67,7 +119,7 @@ export class HttpServer { return; } - this.server = http.createServer((req, res) => { + this.server = this.createServer((req, res) => { this.handleRequest(req, res); }); @@ -153,18 +205,21 @@ export class HttpServer { * Serve static files */ private serveStaticFile(url: string, res: http.ServerResponse): void { - // Default to index.html for root path - let filePath = url === '/' ? '/index.html' : url; - - // Security: prevent directory traversal - filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ''); - - const fullPath = path.join(this.getDistPath(), filePath); + const distPath = this.getDistPath(); + const fullPath = resolveStaticFilePath(distPath, url); + if (!fullPath) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 Not Found'); + return; + } fs.readFile(fullPath, (err, data) => { if (err) { // If file not found, try serving index.html (for Angular routing) - if (err.code === 'ENOENT' && filePath !== '/index.html') { + if ( + err.code === 'ENOENT' && + fullPath !== path.resolve(distPath, 'index.html') + ) { this.serveStaticFile('/', res); return; } diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index f134dc7f3..6b926a6c3 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -52,6 +52,10 @@ export function isRendererApiTraceEnabled(): boolean { return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_IPC'); } +export function isPerformanceCaptureEnabled(): boolean { + return readFlag('IPTVNATOR_PERF_CAPTURE'); +} + export function isDbTraceEnabled(): boolean { return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB'); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts new file mode 100644 index 000000000..51d6146b1 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts @@ -0,0 +1,138 @@ +import { + EmbeddedMpvFrameSource, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import type { NativeEmbeddedMpvSessionSnapshot } from './embedded-mpv-native.service'; + +/** + * Wire protocol for the `iptvnator_mpv_helper` frame-copy process: + * tab-separated commands over stdin, JSON events over stdout. + * + * Kept separate from the adapter so the encoding rules and the event contract + * can be read (and tested) without the process-lifecycle machinery around them. + */ + +/** + * Percent-escape the protocol's structural characters so a title or URL can + * never inject an extra field or line into a command. + */ +export function encodeProtocolValue(value: string): string { + return value + .replace(/%/g, '%25') + .replace(/\t/g, '%09') + .replace(/\n/g, '%0A') + .replace(/\r/g, '%0D'); +} + +export function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot { + return { + status: 'loading', + positionSeconds: 0, + durationSeconds: null, + volume: 1, + streamUrl: '', + audioTracks: [], + selectedAudioTrackId: null, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + recording: { active: false }, + }; +} + +/** Build the `load` command line for a resolved playback target. */ +export function buildLoadPlaybackCommand( + playback: ResolvedPortalPlayback +): string { + const fields: string[] = [`url=${encodeProtocolValue(playback.streamUrl)}`]; + if (playback.title) { + fields.push( + `opt.force-media-title=${encodeProtocolValue(playback.title)}` + ); + } + if (playback.userAgent) { + fields.push(`opt.user-agent=${encodeProtocolValue(playback.userAgent)}`); + } + if (playback.referer) { + fields.push(`opt.referrer=${encodeProtocolValue(playback.referer)}`); + } + if ( + typeof playback.startTime === 'number' && + Number.isFinite(playback.startTime) && + playback.startTime >= 0 + ) { + fields.push(`opt.start=${playback.startTime}`); + } + if (playback.headers && Object.keys(playback.headers).length > 0) { + const headerFields = Object.entries(playback.headers) + .map(([key, value]) => `${key}: ${value}`) + .join(','); + fields.push( + `opt.http-header-fields=${encodeProtocolValue(headerFields)}` + ); + } + return `load\t${fields.join('\t')}`; +} + +/** The mutable per-session state a helper event can act on. */ +export interface HelperEventTarget { + readonly id: string; + snapshot: NativeEmbeddedMpvSessionSnapshot; + frameSource: EmbeddedMpvFrameSource | null; +} + +export interface HelperEventHandlers { + resolveReaderPath: () => string; + onFrameSourceChanged: ( + sessionId: string, + source: EmbeddedMpvFrameSource + ) => void; +} + +/** Apply one decoded helper stdout event to the session. */ +export function applyHelperEvent( + session: HelperEventTarget, + event: Record, + handlers: HelperEventHandlers +): void { + switch (event.event) { + case 'snapshot': { + const { event: _ignored, ...snapshot } = event; + session.snapshot = { + ...session.snapshot, + ...(snapshot as Partial), + } as NativeEmbeddedMpvSessionSnapshot; + break; + } + case 'shm': { + const source: EmbeddedMpvFrameSource = { + shmName: String(event.name ?? ''), + width: Number(event.width ?? 0), + height: Number(event.height ?? 0), + generation: Number(event.generation ?? 0), + readerPath: handlers.resolveReaderPath(), + }; + session.frameSource = source; + handlers.onFrameSourceChanged(session.id, source); + break; + } + case 'fatal': + session.snapshot.status = 'error'; + session.snapshot.error = String( + event.error ?? 'Embedded MPV helper failed.' + ); + break; + case 'log': + if (event.level === 'error' || event.level === 'fatal') { + console.error( + `[embedded-mpv-fc][${session.id}][mpv/${String( + event.prefix ?? '' + )}] ${String(event.text ?? '').trim()}` + ); + } + break; + default: + break; + } +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-spawn.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-spawn.ts new file mode 100644 index 000000000..d0480626f --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-spawn.ts @@ -0,0 +1,94 @@ +import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces'; +import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyRuntimeMode, + LinuxFrameCopyHelperLaunchFileSystem, +} from './embedded-mpv-frame-copy-runtime'; + +/** + * Resolves how a frame-copy helper process is launched: the offscreen render + * size in device pixels, and — on Linux — the sanitized loader environment and + * graphics-provider wrapper the validated runtime requires. + */ + +export interface FrameCopyHelperSpawnRequest { + bounds: EmbeddedMpvBounds; + environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: LinuxFrameCopyHelperLaunchFileSystem; + helperPath: string; + initialVolume?: number; + resolveRuntimeMode: () => EmbeddedMpvFrameCopyRuntimeMode | null; + scale: number; + sessionId: string; +} + +export interface FrameCopyHelperSpawnPlan { + args: string[]; + command: string; + env?: NodeJS.ProcessEnv; + height: number; + width: number; +} + +export function resolveFrameCopyHelperSpawn( + request: FrameCopyHelperSpawnRequest +): FrameCopyHelperSpawnPlan { + const { + bounds, + environment, + helperLaunchFileSystem, + helperPath, + initialVolume, + resolveRuntimeMode, + scale, + sessionId, + } = request; + + const width = Math.max(16, Math.round(bounds.width * scale)); + const height = Math.max(16, Math.round(bounds.height * scale)); + const helperArgs = [ + '--shm-base', + `/${sessionId}`, + '--width', + String(width), + '--height', + String(height), + '--volume', + String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), + // Lip-sync compensation for the video path's added latency + // (~10 ms measured on M1 Pro); tunable until calibration + // lands, see the architecture doc. + ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY + ? ['--audio-delay', process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY] + : []), + ]; + + if (process.platform !== 'linux') { + return { args: helperArgs, command: helperPath, height, width }; + } + + const runtimeMode = resolveRuntimeMode(); + if (!runtimeMode) { + throw new Error( + 'A validated Linux frame-copy runtime is not available.' + ); + } + const launch = createLinuxFrameCopyHelperLaunch({ + environment: environment ?? process.env, + helperPath, + helperArgs, + runtimeMode, + fileSystem: helperLaunchFileSystem, + }); + if (!launch.usable) { + throw new Error('The connected Snap graphics provider is not available.'); + } + + return { + args: launch.args, + command: launch.command, + env: launch.env, + height, + width, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.linux-env.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.linux-env.spec.ts new file mode 100644 index 000000000..b2b542a7f --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.linux-env.spec.ts @@ -0,0 +1,295 @@ +import type { Stats } from 'fs'; +import path from 'path'; + +const spawnMock = jest.fn(); +jest.mock('child_process', () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), +})); + +import type { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import { + createFrameCopyAdapter, + createFrameCopySession, + fakeStat, + FakeHelperProcess, + GRAPHICS_SELECTOR_ENVIRONMENT, + HOSTILE_LOADER_ENVIRONMENT, +} from './embedded-mpv-frame-copy.adapter.test-helpers'; + +/** + * The Linux side of session creation: probe/playback must share one sanitized + * loader environment, and a bundled Snap runtime must launch through the + * graphics provider wrapper with trusted GL roots ahead of generic Snap + * libraries. General adapter behavior lives in + * embedded-mpv-frame-copy.adapter.spec.ts. + */ +describe('EmbeddedMpvFrameCopyAdapter Linux loader environment', () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + let child: FakeHelperProcess; + let adapter: EmbeddedMpvFrameCopyAdapter; + + beforeEach(() => { + jest.useFakeTimers(); + child = new FakeHelperProcess(); + spawnMock.mockReset(); + spawnMock.mockReturnValue(child); + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + }); + + afterEach(() => { + jest.useRealTimers(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + Object.defineProperty(process, 'arch', { value: originalArch }); + }); + + const createSession = () => createFrameCopySession(adapter); + + it('uses a sanitized system environment for the real helper session', () => { + ({ adapter } = createFrameCopyAdapter('/opt/iptvnator/native/helper', { + runtimeMode: 'system', + environment: { + PATH: '/usr/bin', + HOME: '/home/user', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + })); + + createSession(); + + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/bin', + HOME: '/home/user', + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + }); + }); + + it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + ({ adapter } = createFrameCopyAdapter(path.join(nativeDir, 'helper'), { + runtimeMode: 'bundled', + helperLaunchFileSystem: { + lstatSync: (candidatePath) => + fakeStat( + candidatePath.endsWith('/graphics') + ? 'directory' + : 'file' + ) as Stats, + accessSync: () => undefined, + }, + environment: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', + XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', + XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', + XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + })); + + createSession(); + + expect(spawnMock.mock.calls[0][0]).toBe( + path.join( + snapRoot, + 'graphics', + 'bin', + 'graphics-core22-provider-wrapper' + ) + ); + expect(spawnMock.mock.calls[0][1][0]).toBe( + path.join(nativeDir, 'helper') + ); + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl', + SNAP_ARCH: 'amd64', + SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + ...GRAPHICS_SELECTOR_ENVIRONMENT, + GBM_BACKENDS_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'gbm' + ), + '/var/lib/snapd/lib/gl/gbm', + ].join(':'), + LIBGL_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + LIBVA_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'egl', + 'egl_external_platform.d' + ), + __EGL_VENDOR_LIBRARY_DIRS: [ + '/var/lib/snapd/lib/glvnd/egl_vendor.d', + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'glvnd', + 'egl_vendor.d' + ), + ].join(':'), + VK_LAYER_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'implicit_layer.d' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'explicit_layer.d' + ), + ].join(':'), + XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), + XDG_CONFIG_DIRS: [ + path.join(snapRoot, 'etc', 'xdg'), + '/etc/xdg', + ].join(':'), + XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), + XDG_DATA_DIRS: [ + path.join(snapRoot, 'graphics', 'usr', 'share'), + path.join(snapRoot, 'gnome-platform', 'usr', 'share'), + path.join(snapRoot, 'usr', 'share'), + '/usr/share', + ].join(':'), + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + '/usr/lib/x86_64-linux-gnu', + path.join( + snapRoot, + 'gnome-platform', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa-egl' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'pulseaudio' + ), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ].join(':'), + }, + }); + }); + + it('refuses a Linux session without a validated runtime mode', () => { + ({ adapter } = createFrameCopyAdapter('/native/helper', { + runtimeMode: null, + })); + + expect(() => createSession()).toThrow( + 'validated Linux frame-copy runtime' + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index 10698bd55..0947fe4bb 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -1,5 +1,3 @@ -import { EventEmitter } from 'events'; -import type { Stats } from 'fs'; import path from 'path'; const spawnMock = jest.fn(); @@ -7,134 +5,34 @@ jest.mock('child_process', () => ({ spawn: (...args: unknown[]) => spawnMock(...args), })); -import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; -import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; - -const HOSTILE_LOADER_ENVIRONMENT = { - BASH_ENV: '/tmp/hostile-bash-env', - ENV: '/tmp/hostile-shell-env', - BASHOPTS: 'extdebug', - SHELLOPTS: 'xtrace', - PS4: '$(/tmp/hostile-trace-hook)', - BASH_XTRACEFD: '9', - CDPATH: '/tmp/hostile-cdpath', - 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', - LD_AUDIT: '/tmp/audit.so', - LD_LIBRARY_PATH: '/tmp/hostile-libs', - LD_ORIGIN_PATH: '/tmp/hostile-origin', - LD_PRELOAD: '/tmp/inject.so', - __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', - __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', - __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', - __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', - GBM_BACKEND: '../../../../../tmp/hostile-gbm', - GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', - LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', - MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', - LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', - LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', - VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', - VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', - VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', - VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', - VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', - VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', - VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', - VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', -} as const; - -const GRAPHICS_SELECTOR_ENVIRONMENT = { - LIBGL_ALWAYS_SOFTWARE: '1', - GALLIUM_DRIVER: 'llvmpipe', -} as const; - -function fakeStat( - kind: 'directory' | 'file' -): Pick { - return { - isDirectory: () => kind === 'directory', - isFile: () => kind === 'file', - isSymbolicLink: () => false, - }; -} - -class FakeHelperProcess extends EventEmitter { - exitCode: number | null = null; - readonly stdout = new EventEmitter(); - readonly stderr = new EventEmitter(); - readonly stdin = { - writable: true, - written: [] as string[], - write(line: string) { - this.written.push(line); - return true; - }, - }; - readonly kill = jest.fn((signal?: string) => { - this.exitCode = 0; - this.emit('exit', 0, signal ?? null); - return true; - }); - - emitStdout(payload: object): void { - this.stdout.emit('data', Buffer.from(`${JSON.stringify(payload)}\n`)); - } -} +import type { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import { + createFrameCopyAdapter, + createFrameCopySession, + FakeHelperProcess, + type FrameSourceChange, +} from './embedded-mpv-frame-copy.adapter.test-helpers'; +// The Linux loader-environment contract lives in +// embedded-mpv-frame-copy.adapter.linux-env.spec.ts. describe('EmbeddedMpvFrameCopyAdapter', () => { let child: FakeHelperProcess; - let frameSourceChanges: Array<{ sessionId: string; shmName: string }>; + let frameSourceChanges: FrameSourceChange[]; let adapter: EmbeddedMpvFrameCopyAdapter; - const createAdapter = ( - helperPath: string | null = '/native/helper', - { - runtimeMode = 'system', - environment, - helperLaunchFileSystem, - }: { - runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null; - environment?: NodeJS.ProcessEnv; - helperLaunchFileSystem?: { - lstatSync(filePath: string): Stats; - accessSync(filePath: string, mode: number): void; - }; - } = {} - ) => { - frameSourceChanges = []; - return new EmbeddedMpvFrameCopyAdapter({ - resolveHelperPath: () => helperPath, - resolveRuntimeMode: () => runtimeMode, - environment, - helperLaunchFileSystem, - getScaleFactor: () => 2, - onFrameSourceChanged: (sessionId, source) => - frameSourceChanges.push({ - sessionId, - shmName: source.shmName, - }), - } as ConstructorParameters[0]); - }; - beforeEach(() => { jest.useFakeTimers(); child = new FakeHelperProcess(); spawnMock.mockReset(); spawnMock.mockReturnValue(child); - adapter = createAdapter(); + ({ adapter, frameSourceChanges } = createFrameCopyAdapter()); }); afterEach(() => { jest.useRealTimers(); }); - const createSession = () => - adapter.createSession( - Buffer.alloc(0), - { x: 0, y: 0, width: 640, height: 360 }, - 'Title', - 0.8 - ); + const createSession = () => createFrameCopySession(adapter); it('spawns the helper with device-pixel size and initial volume', () => { const sessionId = createSession(); @@ -153,269 +51,6 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { ]); }); - describe('Linux loader environment', () => { - const originalPlatform = process.platform; - const originalArch = process.arch; - - beforeEach(() => { - Object.defineProperty(process, 'platform', { value: 'linux' }); - Object.defineProperty(process, 'arch', { value: 'x64' }); - }); - - afterEach(() => { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - Object.defineProperty(process, 'arch', { value: originalArch }); - }); - - it('uses a sanitized system environment for the real helper session', () => { - adapter = createAdapter('/opt/iptvnator/native/helper', { - runtimeMode: 'system', - environment: { - PATH: '/usr/bin', - HOME: '/home/user', - ...HOSTILE_LOADER_ENVIRONMENT, - ...GRAPHICS_SELECTOR_ENVIRONMENT, - }, - }); - - createSession(); - - expect(spawnMock.mock.calls[0][2]).toEqual({ - stdio: ['pipe', 'pipe', 'pipe'], - env: { - PATH: '/usr/bin', - HOME: '/home/user', - ...GRAPHICS_SELECTOR_ENVIRONMENT, - }, - }); - }); - - it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => { - const snapRoot = '/snap/iptvnator/42'; - const nativeDir = path.join( - snapRoot, - 'resources', - 'app.asar.unpacked', - 'electron-backend', - 'native' - ); - adapter = createAdapter(path.join(nativeDir, 'helper'), { - runtimeMode: 'bundled', - helperLaunchFileSystem: { - lstatSync: (candidatePath) => - fakeStat( - candidatePath.endsWith('/graphics') - ? 'directory' - : 'file' - ) as Stats, - accessSync: () => undefined, - }, - environment: { - PATH: '/snap/bin:/usr/bin', - SNAP: snapRoot, - SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', - SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', - SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), - GBM_BACKENDS_PATH: '/tmp/hostile-gbm', - LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', - LIBVA_DRIVERS_PATH: '/tmp/hostile-va', - __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: - '/tmp/hostile-egl-platform', - __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', - VK_LAYER_PATH: '/tmp/hostile-vulkan', - XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', - XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', - XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', - XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', - ...HOSTILE_LOADER_ENVIRONMENT, - ...GRAPHICS_SELECTOR_ENVIRONMENT, - }, - }); - - createSession(); - - expect(spawnMock.mock.calls[0][0]).toBe( - path.join( - snapRoot, - 'graphics', - 'bin', - 'graphics-core22-provider-wrapper' - ) - ); - expect(spawnMock.mock.calls[0][1][0]).toBe( - path.join(nativeDir, 'helper') - ); - expect(spawnMock.mock.calls[0][2]).toEqual({ - stdio: ['pipe', 'pipe', 'pipe'], - env: { - PATH: '/usr/sbin:/usr/bin:/sbin:/bin', - SNAP: snapRoot, - SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl', - SNAP_ARCH: 'amd64', - SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', - SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), - ...GRAPHICS_SELECTOR_ENVIRONMENT, - GBM_BACKENDS_PATH: [ - path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'gbm' - ), - '/var/lib/snapd/lib/gl/gbm', - ].join(':'), - LIBGL_DRIVERS_PATH: path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'dri' - ), - LIBVA_DRIVERS_PATH: path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'dri' - ), - __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( - snapRoot, - 'graphics', - 'usr', - 'share', - 'egl', - 'egl_external_platform.d' - ), - __EGL_VENDOR_LIBRARY_DIRS: [ - '/var/lib/snapd/lib/glvnd/egl_vendor.d', - path.join( - snapRoot, - 'graphics', - 'usr', - 'share', - 'glvnd', - 'egl_vendor.d' - ), - ].join(':'), - VK_LAYER_PATH: [ - path.join( - snapRoot, - 'graphics', - 'usr', - 'share', - 'vulkan', - 'implicit_layer.d' - ), - path.join( - snapRoot, - 'graphics', - 'usr', - 'share', - 'vulkan', - 'explicit_layer.d' - ), - ].join(':'), - XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), - XDG_CONFIG_DIRS: [ - path.join(snapRoot, 'etc', 'xdg'), - '/etc/xdg', - ].join(':'), - XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), - XDG_DATA_DIRS: [ - path.join(snapRoot, 'graphics', 'usr', 'share'), - path.join(snapRoot, 'gnome-platform', 'usr', 'share'), - path.join(snapRoot, 'usr', 'share'), - '/usr/share', - ].join(':'), - LD_LIBRARY_PATH: [ - path.join(nativeDir, 'lib'), - '/var/lib/snapd/lib/gl', - path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu' - ), - path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'vdpau' - ), - '/usr/lib/x86_64-linux-gnu', - path.join( - snapRoot, - 'gnome-platform', - 'lib', - 'x86_64-linux-gnu' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'mesa' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'mesa-egl' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'dri' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'pulseaudio' - ), - path.join(snapRoot, 'lib'), - path.join(snapRoot, 'usr', 'lib'), - path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), - path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), - ].join(':'), - }, - }); - }); - - it('refuses a Linux session without a validated runtime mode', () => { - adapter = createAdapter('/native/helper', { runtimeMode: null }); - - expect(() => createSession()).toThrow( - 'validated Linux frame-copy runtime' - ); - expect(spawnMock).not.toHaveBeenCalled(); - }); - }); - it('caches helper snapshot events for getSessionSnapshot', () => { const sessionId = createSession(); child.emitStdout({ @@ -511,7 +146,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { }); it('reports unsupported without a helper binary', () => { - const withoutHelper = createAdapter(null); + const { adapter: withoutHelper } = createFrameCopyAdapter(null); expect(withoutHelper.isSupported()).toBe(false); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.test-helpers.ts new file mode 100644 index 000000000..128a612d3 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.test-helpers.ts @@ -0,0 +1,144 @@ +import { EventEmitter } from 'events'; +import type { Stats } from 'fs'; +import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; + +/** + * Shared fixtures for the frame-copy adapter specs. The `child_process` spawn + * mock stays in each spec file (it must be registered before the adapter is + * imported), and is handed to `createFrameCopyAdapter` via the spec's own + * `beforeEach`. + */ + +/** + * Every loader/shell override the sanitized helper environment must strip. + * Kept in one place so both specs assert against the same hostile input. + */ +export const HOSTILE_LOADER_ENVIRONMENT = { + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', +} as const; + +/** Selectors that must survive sanitization (CI uses them for llvmpipe). */ +export const GRAPHICS_SELECTOR_ENVIRONMENT = { + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', +} as const; + +export function fakeStat( + kind: 'directory' | 'file' +): Pick { + return { + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => false, + }; +} + +export class FakeHelperProcess extends EventEmitter { + exitCode: number | null = null; + readonly stdout = new EventEmitter(); + readonly stderr = new EventEmitter(); + readonly stdin = { + writable: true, + written: [] as string[], + write(line: string) { + this.written.push(line); + return true; + }, + }; + readonly kill = jest.fn((signal?: string) => { + this.exitCode = 0; + this.emit('exit', 0, signal ?? null); + return true; + }); + + emitStdout(payload: object): void { + this.stdout.emit('data', Buffer.from(`${JSON.stringify(payload)}\n`)); + } +} + +export interface CreateFrameCopyAdapterOptions { + runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null; + environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: { + lstatSync(filePath: string): Stats; + accessSync(filePath: string, mode: number): void; + }; +} + +export interface FrameSourceChange { + sessionId: string; + shmName: string; +} + +/** + * Build an adapter plus the list that records its frame-source callbacks. + * `getScaleFactor` is fixed at 2 so specs can assert device-pixel maths. + */ +export function createFrameCopyAdapter( + helperPath: string | null = '/native/helper', + { + runtimeMode = 'system', + environment, + helperLaunchFileSystem, + }: CreateFrameCopyAdapterOptions = {} +): { + adapter: EmbeddedMpvFrameCopyAdapter; + frameSourceChanges: FrameSourceChange[]; +} { + const frameSourceChanges: FrameSourceChange[] = []; + const adapter = new EmbeddedMpvFrameCopyAdapter({ + resolveHelperPath: () => helperPath, + resolveRuntimeMode: () => runtimeMode, + environment, + helperLaunchFileSystem, + getScaleFactor: () => 2, + onFrameSourceChanged: (sessionId, source) => + frameSourceChanges.push({ + sessionId, + shmName: source.shmName, + }), + } as ConstructorParameters[0]); + return { adapter, frameSourceChanges }; +} + +/** The standard 640x360 session every spec opens. */ +export function createFrameCopySession( + adapter: EmbeddedMpvFrameCopyAdapter +): string { + return adapter.createSession( + Buffer.alloc(0), + { x: 0, y: 0, width: 640, height: 360 }, + 'Title', + 0.8 + ); +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts index 7c14720b0..b16331b8d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts @@ -7,7 +7,13 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util'; -import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime'; +import { + applyHelperEvent, + buildLoadPlaybackCommand, + createInitialSnapshot, + encodeProtocolValue, +} from './embedded-mpv-frame-copy-protocol'; +import { resolveFrameCopyHelperSpawn } from './embedded-mpv-frame-copy-spawn'; import type { EmbeddedMpvFrameCopyRuntimeMode, LinuxFrameCopyHelperLaunchFileSystem, @@ -24,11 +30,10 @@ import type { * size, audio) and publishes BGRA frames into a shared-memory ring that the * preload frame pump uploads to a renderer canvas. * - * Protocol: tab-separated commands over stdin, JSON events over stdout. - * The helper's `snapshot` events already carry the - * NativeEmbeddedMpvSessionSnapshot shape, so this adapter is mostly a - * process-lifecycle wrapper plus a snapshot cache that the existing - * EmbeddedMpvNativeService polling consumes unchanged. + * The wire protocol lives in `embedded-mpv-frame-copy-protocol.ts` and the + * launch/environment rules in `embedded-mpv-frame-copy-spawn.ts`, so this + * class is mostly a process-lifecycle wrapper plus a snapshot cache that the + * existing EmbeddedMpvNativeService polling consumes unchanged. */ export interface EmbeddedMpvFrameCopyAdapterOptions { @@ -56,31 +61,6 @@ interface FrameCopyRuntimeSession { const HELPER_QUIT_GRACE_MS = 500; const HELPER_KILL_GRACE_MS = 2000; -function encodeProtocolValue(value: string): string { - return value - .replace(/%/g, '%25') - .replace(/\t/g, '%09') - .replace(/\n/g, '%0A') - .replace(/\r/g, '%0D'); -} - -function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot { - return { - status: 'loading', - positionSeconds: 0, - durationSeconds: null, - volume: 1, - streamUrl: '', - audioTracks: [], - selectedAudioTrackId: null, - subtitleTracks: [], - selectedSubtitleTrackId: null, - playbackSpeed: 1, - aspectOverride: 'no', - recording: { active: false }, - }; -} - export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { private readonly sessions = new Map(); @@ -113,62 +93,24 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } const sessionId = `impv-fc-${randomUUID().slice(0, 8)}`; - const scale = this.options.getScaleFactor(); - const width = Math.max(16, Math.round(bounds.width * scale)); - const height = Math.max(16, Math.round(bounds.height * scale)); - const helperArgs = [ - '--shm-base', - `/${sessionId}`, - '--width', - String(width), - '--height', - String(height), - '--volume', - String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), - // Lip-sync compensation for the video path's added latency - // (~10 ms measured on M1 Pro); tunable until calibration - // lands, see the architecture doc. - ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY - ? [ - '--audio-delay', - process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY, - ] - : []), - ]; - let helperCommand = helperPath; - let resolvedHelperArgs = helperArgs; - let helperEnvironment: NodeJS.ProcessEnv | undefined; - if (process.platform === 'linux') { - const runtimeMode = this.options.resolveRuntimeMode(); - if (!runtimeMode) { - throw new Error( - 'A validated Linux frame-copy runtime is not available.' - ); - } - const launch = createLinuxFrameCopyHelperLaunch({ - environment: this.options.environment ?? process.env, - helperPath, - helperArgs, - runtimeMode, - fileSystem: this.options.helperLaunchFileSystem, - }); - if (!launch.usable) { - throw new Error( - 'The connected Snap graphics provider is not available.' - ); - } - helperCommand = launch.command; - resolvedHelperArgs = launch.args; - helperEnvironment = launch.env; - } + const plan = resolveFrameCopyHelperSpawn({ + bounds, + environment: this.options.environment, + helperLaunchFileSystem: this.options.helperLaunchFileSystem, + helperPath, + initialVolume, + resolveRuntimeMode: this.options.resolveRuntimeMode, + scale: this.options.getScaleFactor(), + sessionId, + }); - const child = spawn(helperCommand, resolvedHelperArgs, { + const child = spawn(plan.command, plan.args, { stdio: ['pipe', 'pipe', 'pipe'], - ...(helperEnvironment ? { env: helperEnvironment } : {}), + ...(plan.env ? { env: plan.env } : {}), }); console.log( - `[embedded-mpv-fc][${sessionId}] spawn ${width}x${height} (pid pending)` + `[embedded-mpv-fc][${sessionId}] spawn ${plan.width}x${plan.height} (pid pending)` ); const session: FrameCopyRuntimeSession = { id: sessionId, @@ -215,40 +157,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void { - const fields: string[] = [ - `url=${encodeProtocolValue(playback.streamUrl)}`, - ]; - if (playback.title) { - fields.push( - `opt.force-media-title=${encodeProtocolValue(playback.title)}` - ); - } - if (playback.userAgent) { - fields.push( - `opt.user-agent=${encodeProtocolValue(playback.userAgent)}` - ); - } - if (playback.referer) { - fields.push( - `opt.referrer=${encodeProtocolValue(playback.referer)}` - ); - } - if ( - typeof playback.startTime === 'number' && - Number.isFinite(playback.startTime) && - playback.startTime >= 0 - ) { - fields.push(`opt.start=${playback.startTime}`); - } - if (playback.headers && Object.keys(playback.headers).length > 0) { - const headerFields = Object.entries(playback.headers) - .map(([key, value]) => `${key}: ${value}`) - .join(','); - fields.push( - `opt.http-header-fields=${encodeProtocolValue(headerFields)}` - ); - } - this.send(sessionId, `load\t${fields.join('\t')}`); + this.send(sessionId, buildLoadPlaybackCommand(playback)); } setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void { @@ -362,7 +271,13 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { newlineIndex = session.stdoutBuffer.indexOf('\n'); if (!line) continue; try { - this.handleEvent(session, JSON.parse(line)); + applyHelperEvent(session, JSON.parse(line), { + resolveReaderPath: () => this.resolveReaderPath(), + // Call through `this.options` so a callback that relies on + // its own receiver keeps working, as it did inline. + onFrameSourceChanged: (id, source) => + this.options.onFrameSourceChanged(id, source), + }); } catch { console.error( `[embedded-mpv-fc][${session.id}] unparseable event: ${line}` @@ -371,51 +286,6 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } } - private handleEvent( - session: FrameCopyRuntimeSession, - event: Record - ): void { - switch (event.event) { - case 'snapshot': { - const { event: _ignored, ...snapshot } = event; - session.snapshot = { - ...session.snapshot, - ...(snapshot as Partial), - } as NativeEmbeddedMpvSessionSnapshot; - break; - } - case 'shm': { - const source: EmbeddedMpvFrameSource = { - shmName: String(event.name ?? ''), - width: Number(event.width ?? 0), - height: Number(event.height ?? 0), - generation: Number(event.generation ?? 0), - readerPath: this.resolveReaderPath(), - }; - session.frameSource = source; - this.options.onFrameSourceChanged(session.id, source); - break; - } - case 'fatal': - session.snapshot.status = 'error'; - session.snapshot.error = String( - event.error ?? 'Embedded MPV helper failed.' - ); - break; - case 'log': - if (event.level === 'error' || event.level === 'fatal') { - console.error( - `[embedded-mpv-fc][${session.id}][mpv/${String( - event.prefix ?? '' - )}] ${String(event.text ?? '').trim()}` - ); - } - break; - default: - break; - } - } - private resolveReaderPath(): string { const helperPath = this.options.resolveHelperPath(); return helperPath diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage-validation.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage-validation.spec.ts new file mode 100644 index 000000000..de90ba8e8 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage-validation.spec.ts @@ -0,0 +1,147 @@ +import { unlinkSync } from 'fs'; +import path from 'path'; + +import { + cleanupTemporaryDirectories, + createArtifactFixture, + loadLinkageModule, + readelfDynamic, +} from './embedded-mpv-linux-linkage.test-helpers'; + +/** + * Artifact-level linkage validation: only the helper may link libmpv, and it + * must do so through `$ORIGIN/lib` with no RPATH. SONAME resolution and build + * mode inputs live in embedded-mpv-linux-linkage.spec.ts. + */ +describe('Linux Embedded MPV linkage validation', () => { + afterEach(() => { + cleanupTemporaryDirectories(); + }); + + it('accepts only process-isolated Linux frame-copy linkage', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).not.toThrow(); + }); + + it('rejects a helper linked to the wrong libmpv SONAME', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + fixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.3'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(/helper.*DT_NEEDED must contain exactly libmpv\.so\.2/i); + }); + + it('rejects helper RPATH and any RUNPATH other than $ORIGIN/lib', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const rpathFixture = createArtifactFixture(); + rpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RPATH', '/host/lib'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: rpathFixture.outputDir, + readDynamicSection: rpathFixture.readDynamicSection, + }) + ).toThrow(/helper must not contain RPATH/i); + + const runpathFixture = createArtifactFixture(); + runpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RUNPATH', '$ORIGIN'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: runpathFixture.outputDir, + readDynamicSection: runpathFixture.readDynamicSection, + }) + ).toThrow(/helper RUNPATH must be exactly \$ORIGIN\/lib/i); + }); + + it.each([ + ['embedded_mpv.node', 'addon'], + ['embedded_mpv_frame_reader.node', 'frame reader'], + ])('rejects Electron-side libmpv linkage from %s', (fileName, label) => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + fixture.outputs[fileName] = readelfDynamic([['NEEDED', 'libmpv.so.2']]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(new RegExp(`${label} must not have a direct libmpv`, 'i')); + }); + + it('rejects missing artifacts and readelf failures', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const missingArtifactFixture = createArtifactFixture(); + unlinkSync( + path.join( + missingArtifactFixture.outputDir, + 'embedded_mpv_frame_reader.node' + ) + ); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: missingArtifactFixture.outputDir, + readDynamicSection: missingArtifactFixture.readDynamicSection, + }) + ).toThrow(/missing.*frame reader/i); + + const readelfFailureFixture = createArtifactFixture(); + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: readelfFailureFixture.outputDir, + readDynamicSection: () => { + throw new Error('readelf is unavailable'); + }, + }) + ).toThrow(/readelf is unavailable/); + }); + + it('runs cleanup before rethrowing the original transaction failure', () => { + const { runWithCleanup } = loadLinkageModule(); + const calls: string[] = []; + const failure = new Error('post-link validation failed'); + + expect(() => + runWithCleanup( + () => { + calls.push('operation'); + throw failure; + }, + () => calls.push('cleanup') + ) + ).toThrow(failure); + expect(calls).toEqual(['operation', 'cleanup']); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts index 5ce94928d..12e71dc5d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts @@ -1,157 +1,22 @@ -import { - existsSync, - mkdtempSync, - mkdirSync, - rmSync, - symlinkSync, - unlinkSync, - writeFileSync, -} from 'fs'; -import { createHash } from 'crypto'; -import { createRequire } from 'module'; -import { tmpdir } from 'os'; +import { symlinkSync, unlinkSync, writeFileSync } from 'fs'; import path from 'path'; -const linkageModulePath = path.resolve( - __dirname, - '../../../embedded-mpv-linux-linkage.cjs' -); -const requireBuildHelper = createRequire(__filename); - -interface RuntimeFileRecord { - name: string; - size: number; - sha256: string; -} - -interface SonameFixture { - exactPath: string; - outputLibDir: string; - runtimeDependencyClosure: { - entries: Array<{ - name: string; - needed: string[]; - rpath: string[]; - runpath: string[]; - soname: string | null; - }>; - }; - runtimeFiles: RuntimeFileRecord[]; -} - -function loadLinkageModule(): { - parseReadelfDynamic: (output: string) => { - needed: string[]; - rpath: string[]; - runpath: string[]; - soname: string[]; - }; - resolveVerifiedLinuxLibMpvSoname: (options: { - outputLibDir: string; - readDynamicSection: (filePath: string) => string; - runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure']; - runtimeFiles: RuntimeFileRecord[]; - }) => string; - resolveLinuxFrameCopyLinkageInputs: (options: { - buildInputMode: string; - outputLibDir: string; - packagedLibmpvSoname: string | null; - readDynamicSection: (filePath: string) => string; - runtimeLibDir: string; - }) => { - expectedLibmpvSoname: string | null; - linkerLibraryDir: string; - }; - runWithCleanup: (operation: () => T, cleanup: () => void) => T; - validateLinuxFrameCopyLinkage: (options: { - expectedLibmpvSoname: string; - outputDir: string; - readDynamicSection: (filePath: string) => string; - }) => void; -} { - expect(existsSync(linkageModulePath)).toBe(true); - return requireBuildHelper(linkageModulePath); -} - -function sha256(contents: Buffer): string { - return createHash('sha256').update(contents).digest('hex'); -} - -function runtimeFile(name: string, contents: Buffer): RuntimeFileRecord { - return { - name, - size: contents.byteLength, - sha256: sha256(contents), - }; -} - -function readelfDynamic( - entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]> -): string { - return entries - .map( - ([tag, value], index) => - ` 0x${index - .toString(16) - .padStart(16, '0')} (${tag}) Library value: [${value}]` - ) - .join('\n'); -} - -describe('Linux Embedded MPV linkage verification', () => { - const temporaryDirectories: string[] = []; +import { + cleanupTemporaryDirectories, + createSonameFixture, + loadLinkageModule, + readelfDynamic, +} from './embedded-mpv-linux-linkage.test-helpers'; +/** + * SONAME resolution and build-mode linker inputs. The artifact-level linkage + * checks live in embedded-mpv-linux-linkage-validation.spec.ts. + */ +describe('Linux Embedded MPV linkage resolution', () => { afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } + cleanupTemporaryDirectories(); }); - function temporaryDirectory(): string { - const directory = mkdtempSync( - path.join(tmpdir(), 'iptvnator-mpv-linkage-') - ); - temporaryDirectories.push(directory); - return directory; - } - - function createSonameFixture(soname = 'libmpv.so.2'): SonameFixture { - const outputLibDir = path.join(temporaryDirectory(), 'lib'); - mkdirSync(outputLibDir, { recursive: true }); - const contents = Buffer.from('verified libmpv ELF contents'); - const aliasPath = path.join(outputLibDir, 'libmpv.so'); - const exactPath = path.join(outputLibDir, soname); - writeFileSync(aliasPath, contents); - writeFileSync(exactPath, contents); - - return { - exactPath, - outputLibDir, - runtimeFiles: [ - runtimeFile('libmpv.so', contents), - runtimeFile(soname, contents), - ], - runtimeDependencyClosure: { - entries: [ - { - name: 'libmpv.so', - needed: [], - rpath: [], - runpath: ['$ORIGIN'], - soname, - }, - { - name: soname, - needed: [], - rpath: [], - runpath: ['$ORIGIN'], - soname, - }, - ], - }, - }; - } - it('parses every dynamic tag without hiding duplicate SONAME entries', () => { const { parseReadelfDynamic } = loadLinkageModule(); @@ -383,157 +248,4 @@ describe('Linux Embedded MPV linkage verification', () => { }) ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); }); - - function createArtifactFixture(): { - outputDir: string; - readDynamicSection: (filePath: string) => string; - outputs: Record; - } { - const outputDir = temporaryDirectory(); - const outputs: Record = { - 'embedded_mpv.node': readelfDynamic([['NEEDED', 'libX11.so.6']]), - 'embedded_mpv_frame_reader.node': readelfDynamic([]), - iptvnator_mpv_helper: readelfDynamic([ - ['NEEDED', 'libmpv.so.2'], - ['NEEDED', 'libEGL.so.1'], - ['RUNPATH', '$ORIGIN/lib'], - ]), - }; - for (const artifact of Object.keys(outputs)) { - writeFileSync(path.join(outputDir, artifact), 'ELF'); - } - return { - outputDir, - outputs, - readDynamicSection: (filePath: string) => - outputs[path.basename(filePath)], - }; - } - - it('accepts only process-isolated Linux frame-copy linkage', () => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const fixture = createArtifactFixture(); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: fixture.outputDir, - readDynamicSection: fixture.readDynamicSection, - }) - ).not.toThrow(); - }); - - it('rejects a helper linked to the wrong libmpv SONAME', () => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const fixture = createArtifactFixture(); - - fixture.outputs.iptvnator_mpv_helper = readelfDynamic([ - ['NEEDED', 'libmpv.so.3'], - ['RUNPATH', '$ORIGIN/lib'], - ]); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: fixture.outputDir, - readDynamicSection: fixture.readDynamicSection, - }) - ).toThrow(/helper.*DT_NEEDED must contain exactly libmpv\.so\.2/i); - }); - - it('rejects helper RPATH and any RUNPATH other than $ORIGIN/lib', () => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const rpathFixture = createArtifactFixture(); - rpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ - ['NEEDED', 'libmpv.so.2'], - ['RPATH', '/host/lib'], - ['RUNPATH', '$ORIGIN/lib'], - ]); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: rpathFixture.outputDir, - readDynamicSection: rpathFixture.readDynamicSection, - }) - ).toThrow(/helper must not contain RPATH/i); - - const runpathFixture = createArtifactFixture(); - runpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ - ['NEEDED', 'libmpv.so.2'], - ['RUNPATH', '$ORIGIN'], - ]); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: runpathFixture.outputDir, - readDynamicSection: runpathFixture.readDynamicSection, - }) - ).toThrow(/helper RUNPATH must be exactly \$ORIGIN\/lib/i); - }); - - it.each([ - ['embedded_mpv.node', 'addon'], - ['embedded_mpv_frame_reader.node', 'frame reader'], - ])('rejects Electron-side libmpv linkage from %s', (fileName, label) => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const fixture = createArtifactFixture(); - fixture.outputs[fileName] = readelfDynamic([['NEEDED', 'libmpv.so.2']]); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: fixture.outputDir, - readDynamicSection: fixture.readDynamicSection, - }) - ).toThrow(new RegExp(`${label} must not have a direct libmpv`, 'i')); - }); - - it('rejects missing artifacts and readelf failures', () => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const missingArtifactFixture = createArtifactFixture(); - unlinkSync( - path.join( - missingArtifactFixture.outputDir, - 'embedded_mpv_frame_reader.node' - ) - ); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: missingArtifactFixture.outputDir, - readDynamicSection: missingArtifactFixture.readDynamicSection, - }) - ).toThrow(/missing.*frame reader/i); - - const readelfFailureFixture = createArtifactFixture(); - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: readelfFailureFixture.outputDir, - readDynamicSection: () => { - throw new Error('readelf is unavailable'); - }, - }) - ).toThrow(/readelf is unavailable/); - }); - - it('runs cleanup before rethrowing the original transaction failure', () => { - const { runWithCleanup } = loadLinkageModule(); - const calls: string[] = []; - const failure = new Error('post-link validation failed'); - - expect(() => - runWithCleanup( - () => { - calls.push('operation'); - throw failure; - }, - () => calls.push('cleanup') - ) - ).toThrow(failure); - expect(calls).toEqual(['operation', 'cleanup']); - }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.test-helpers.ts new file mode 100644 index 000000000..bd7d14045 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.test-helpers.ts @@ -0,0 +1,196 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from 'fs'; +import { createHash } from 'crypto'; +import { createRequire } from 'module'; +import { tmpdir } from 'os'; +import path from 'path'; + +/** + * Shared fixtures for the Linux frame-copy linkage specs. The module under + * test is the CommonJS build helper `embedded-mpv-linux-linkage.cjs`, loaded + * through `createRequire` because it is packaging tooling rather than app code. + */ + +const linkageModulePath = path.resolve( + __dirname, + '../../../embedded-mpv-linux-linkage.cjs' +); +const requireBuildHelper = createRequire(__filename); + +export interface RuntimeFileRecord { + name: string; + size: number; + sha256: string; +} + +export interface SonameFixture { + exactPath: string; + outputLibDir: string; + runtimeDependencyClosure: { + entries: Array<{ + name: string; + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string | null; + }>; + }; + runtimeFiles: RuntimeFileRecord[]; +} + +export interface ArtifactFixture { + outputDir: string; + readDynamicSection: (filePath: string) => string; + outputs: Record; +} + +export interface LinkageModule { + parseReadelfDynamic: (output: string) => { + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string[]; + }; + resolveVerifiedLinuxLibMpvSoname: (options: { + outputLibDir: string; + readDynamicSection: (filePath: string) => string; + runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure']; + runtimeFiles: RuntimeFileRecord[]; + }) => string; + resolveLinuxFrameCopyLinkageInputs: (options: { + buildInputMode: string; + outputLibDir: string; + packagedLibmpvSoname: string | null; + readDynamicSection: (filePath: string) => string; + runtimeLibDir: string; + }) => { + expectedLibmpvSoname: string | null; + linkerLibraryDir: string; + }; + runWithCleanup: (operation: () => T, cleanup: () => void) => T; + validateLinuxFrameCopyLinkage: (options: { + expectedLibmpvSoname: string; + outputDir: string; + readDynamicSection: (filePath: string) => string; + }) => void; +} + +export function loadLinkageModule(): LinkageModule { + expect(existsSync(linkageModulePath)).toBe(true); + return requireBuildHelper(linkageModulePath); +} + +function sha256(contents: Buffer): string { + return createHash('sha256').update(contents).digest('hex'); +} + +export function runtimeFile( + name: string, + contents: Buffer +): RuntimeFileRecord { + return { + name, + size: contents.byteLength, + sha256: sha256(contents), + }; +} + +/** Render a `readelf -d` style dynamic section from tag/value pairs. */ +export function readelfDynamic( + entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]> +): string { + return entries + .map( + ([tag, value], index) => + ` 0x${index + .toString(16) + .padStart(16, '0')} (${tag}) Library value: [${value}]` + ) + .join('\n'); +} + +/** + * Temporary-directory registry. Specs call `createTemporaryDirectory()` while + * building fixtures and `cleanupTemporaryDirectories()` from their `afterEach`. + */ +const temporaryDirectories: string[] = []; + +export function createTemporaryDirectory(): string { + const directory = mkdtempSync( + path.join(tmpdir(), 'iptvnator-mpv-linkage-') + ); + temporaryDirectories.push(directory); + return directory; +} + +export function cleanupTemporaryDirectories(): void { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +} + +/** A verified libmpv copy: matching alias + exact-SONAME regular files. */ +export function createSonameFixture(soname = 'libmpv.so.2'): SonameFixture { + const outputLibDir = path.join(createTemporaryDirectory(), 'lib'); + mkdirSync(outputLibDir, { recursive: true }); + const contents = Buffer.from('verified libmpv ELF contents'); + const aliasPath = path.join(outputLibDir, 'libmpv.so'); + const exactPath = path.join(outputLibDir, soname); + writeFileSync(aliasPath, contents); + writeFileSync(exactPath, contents); + + return { + exactPath, + outputLibDir, + runtimeFiles: [ + runtimeFile('libmpv.so', contents), + runtimeFile(soname, contents), + ], + runtimeDependencyClosure: { + entries: [ + { + name: 'libmpv.so', + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + { + name: soname, + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + ], + }, + }; +} + +/** A built output directory with process-isolated linkage. */ +export function createArtifactFixture(): ArtifactFixture { + const outputDir = createTemporaryDirectory(); + const outputs: Record = { + 'embedded_mpv.node': readelfDynamic([['NEEDED', 'libX11.so.6']]), + 'embedded_mpv_frame_reader.node': readelfDynamic([]), + iptvnator_mpv_helper: readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['NEEDED', 'libEGL.so.1'], + ['RUNPATH', '$ORIGIN/lib'], + ]), + }; + for (const artifact of Object.keys(outputs)) { + writeFileSync(path.join(outputDir, artifact), 'ELF'); + } + return { + outputDir, + outputs, + readDynamicSection: (filePath: string) => + outputs[path.basename(filePath)], + }; +} diff --git a/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts new file mode 100644 index 000000000..0ce33a94e --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts @@ -0,0 +1,303 @@ +import { once } from 'node:events'; +import { getHeapStatistics } from 'node:v8'; +import { MessageChannel, type MessagePort } from 'node:worker_threads'; +import { + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON, + handleDatabaseWorkerPostGcHeapRequest, + isDatabaseWorkerPostGcHeapRequest, + type DatabaseWorkerPostGcHeapRequest, + type DatabaseWorkerPostGcHeapResult, +} from './database-worker-post-gc-heap'; + +jest.mock('node:v8', () => ({ + getHeapStatistics: jest.fn(), +})); + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +const mockedGetHeapStatistics = getHeapStatistics as jest.MockedFunction< + typeof getHeapStatistics +>; + +type FakeResponsePort = { + close: jest.Mock; + messages: DatabaseWorkerPostGcHeapResult[]; + port: MessagePort; + postMessage: jest.Mock; +}; + +function createFakeResponsePort(): FakeResponsePort { + const messages: DatabaseWorkerPostGcHeapResult[] = []; + const close = jest.fn(); + const postMessage = jest.fn( + (message) => { + messages.push(message); + } + ); + + return { + close, + messages, + port: { + close, + postMessage, + } as unknown as MessagePort, + postMessage, + }; +} + +function handleWithFakePort(isIdle: boolean): { + gc: jest.Mock | null; + responsePort: FakeResponsePort; +} { + const responsePort = createFakeResponsePort(); + const gcCandidate = Reflect.get(globalThis, 'gc'); + const request: DatabaseWorkerPostGcHeapRequest = { + type: 'performance:collect-post-gc-heap', + responsePort: responsePort.port, + }; + + handleDatabaseWorkerPostGcHeapRequest(request, isIdle); + + return { + gc: + typeof gcCandidate === 'function' + ? (gcCandidate as unknown as jest.Mock) + : null, + responsePort, + }; +} + +describe('database worker post-GC heap capture', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + const originalGc = Reflect.get(globalThis, 'gc'); + + beforeEach(() => { + process.env[PROFILING_ENV] = '1'; + Reflect.set(globalThis, 'gc', jest.fn()); + mockedGetHeapStatistics.mockReset(); + mockedGetHeapStatistics.mockReturnValue({ + used_heap_size: 42_000, + } as ReturnType); + }); + + afterAll(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + + if (originalGc === undefined) { + Reflect.deleteProperty(globalThis, 'gc'); + } else { + Reflect.set(globalThis, 'gc', originalGc); + } + }); + + it('recognizes only the exact request with a real MessagePort', () => { + const { port1, port2 } = new MessageChannel(); + + try { + expect( + isDatabaseWorkerPostGcHeapRequest({ + type: 'performance:collect-post-gc-heap', + responsePort: port2, + }) + ).toBe(true); + expect(isDatabaseWorkerPostGcHeapRequest(null)).toBe(false); + expect( + isDatabaseWorkerPostGcHeapRequest({ + type: 'performance:collect-post-gc-heap', + }) + ).toBe(false); + expect( + isDatabaseWorkerPostGcHeapRequest({ + type: 'performance:collect-post-gc-heap', + responsePort: { + close: jest.fn(), + postMessage: jest.fn(), + }, + }) + ).toBe(false); + expect( + isDatabaseWorkerPostGcHeapRequest({ + type: 'request', + responsePort: port2, + }) + ).toBe(false); + } finally { + port1.close(); + port2.close(); + } + }); + + it('forces GC before reading and returning the worker V8 heap', async () => { + const lifecycle: string[] = []; + const { port1, port2 } = new MessageChannel(); + Reflect.set( + globalThis, + 'gc', + jest.fn(() => lifecycle.push('gc')) + ); + mockedGetHeapStatistics.mockImplementation(() => { + lifecycle.push('heap'); + return { + used_heap_size: 73_728, + } as ReturnType; + }); + const responsePromise = once(port1, 'message') as Promise< + [DatabaseWorkerPostGcHeapResult] + >; + const peerClosedPromise = once(port1, 'close'); + + handleDatabaseWorkerPostGcHeapRequest( + { + type: 'performance:collect-post-gc-heap', + responsePort: port2, + }, + true + ); + + await expect(responsePromise).resolves.toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: 73_728, + unavailableReason: null, + }, + ]); + await peerClosedPromise; + expect(lifecycle).toEqual(['gc', 'heap']); + }); + + it('fails closed without profiling opt-in', () => { + delete process.env[PROFILING_ENV]; + + const { gc, responsePort } = handleWithFakePort(true); + + expect(gc).not.toHaveBeenCalled(); + expect(mockedGetHeapStatistics).not.toHaveBeenCalled(); + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.PROFILING_DISABLED, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); + + it('does not perturb an active database operation', () => { + const { gc, responsePort } = handleWithFakePort(false); + + expect(gc).not.toHaveBeenCalled(); + expect(mockedGetHeapStatistics).not.toHaveBeenCalled(); + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.WORKER_BUSY, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); + + it('reports an unavailable exposed-GC runtime without reading heap statistics', () => { + Reflect.deleteProperty(globalThis, 'gc'); + + const { responsePort } = handleWithFakePort(true); + + expect(mockedGetHeapStatistics).not.toHaveBeenCalled(); + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.GC_UNAVAILABLE, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['negative', -1], + ['fractional', 1.5], + ['NaN', Number.NaN], + ['infinite', Number.POSITIVE_INFINITY], + ['unsafe', Number.MAX_SAFE_INTEGER + 1], + ])('rejects a %s heap measurement', (_label, usedHeapSize) => { + mockedGetHeapStatistics.mockReturnValue({ + used_heap_size: usedHeapSize, + } as ReturnType); + + const { gc, responsePort } = handleWithFakePort(true); + + expect(gc).toHaveBeenCalledTimes(1); + expect(mockedGetHeapStatistics).toHaveBeenCalledTimes(1); + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); + + it.each(['gc', 'heap'] as const)( + 'reports capture failure when %s collection throws', + (failurePoint) => { + if (failurePoint === 'gc') { + Reflect.set( + globalThis, + 'gc', + jest.fn(() => { + throw new Error('gc failed'); + }) + ); + } else { + mockedGetHeapStatistics.mockImplementation(() => { + throw new Error('heap failed'); + }); + } + + const { responsePort } = handleWithFakePort(true); + + expect(responsePort.messages).toEqual([ + { + type: 'performance:post-gc-heap-result', + postGcHeapUsedBytes: null, + unavailableReason: + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED, + }, + ]); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + } + ); + + it('still closes the one-shot port when posting the result fails', () => { + const responsePort = createFakeResponsePort(); + responsePort.postMessage.mockImplementation(() => { + throw new Error('port closed'); + }); + + expect(() => + handleDatabaseWorkerPostGcHeapRequest( + { + type: 'performance:collect-post-gc-heap', + responsePort: responsePort.port, + }, + true + ) + ).not.toThrow(); + expect(responsePort.postMessage).toHaveBeenCalledTimes(1); + expect(responsePort.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts new file mode 100644 index 000000000..fa95bc390 --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts @@ -0,0 +1,124 @@ +import { getHeapStatistics } from 'node:v8'; +import { MessagePort } from 'node:worker_threads'; +import { WORKER_PROFILING_ENV } from './worker-performance-capture.runtime'; + +export const DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE = + 'performance:collect-post-gc-heap' as const; +export const DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE = + 'performance:post-gc-heap-result' as const; + +export const DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON = { + CAPTURE_FAILED: 'capture-failed', + GC_UNAVAILABLE: 'gc-unavailable', + PROFILING_DISABLED: 'profiling-disabled', + WORKER_BUSY: 'worker-busy', +} as const; + +export type DatabaseWorkerPostGcHeapUnavailableReason = + (typeof DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON)[keyof typeof DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON]; + +export interface DatabaseWorkerPostGcHeapRequest { + readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE; + readonly responsePort: MessagePort; +} + +export type DatabaseWorkerPostGcHeapResult = + | { + readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE; + readonly postGcHeapUsedBytes: number; + readonly unavailableReason: null; + } + | { + readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE; + readonly postGcHeapUsedBytes: null; + readonly unavailableReason: DatabaseWorkerPostGcHeapUnavailableReason; + }; + +export function isDatabaseWorkerPostGcHeapRequest( + message: unknown +): message is DatabaseWorkerPostGcHeapRequest { + if ( + typeof message !== 'object' || + message === null || + Array.isArray(message) + ) { + return false; + } + + const candidate = message as Record; + return ( + candidate['type'] === DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE && + candidate['responsePort'] instanceof MessagePort + ); +} + +function unavailableResult( + unavailableReason: DatabaseWorkerPostGcHeapUnavailableReason +): DatabaseWorkerPostGcHeapResult { + return { + type: DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE, + postGcHeapUsedBytes: null, + unavailableReason, + }; +} + +function postResult( + responsePort: MessagePort, + result: DatabaseWorkerPostGcHeapResult +): void { + try { + responsePort.postMessage(result); + } catch { + // Development-only profiling transport must not affect worker behavior. + } finally { + try { + responsePort.close(); + } catch { + // The one-shot port may already be closed by its peer. + } + } +} + +export function handleDatabaseWorkerPostGcHeapRequest( + message: DatabaseWorkerPostGcHeapRequest, + isIdle: boolean +): void { + let result: DatabaseWorkerPostGcHeapResult; + + if (process.env[WORKER_PROFILING_ENV] !== '1') { + result = unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.PROFILING_DISABLED + ); + } else if (!isIdle) { + result = unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.WORKER_BUSY + ); + } else if (typeof globalThis.gc !== 'function') { + result = unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.GC_UNAVAILABLE + ); + } else { + try { + globalThis.gc(); + const postGcHeapUsedBytes = getHeapStatistics().used_heap_size; + + result = + Number.isSafeInteger(postGcHeapUsedBytes) && + postGcHeapUsedBytes >= 0 + ? { + type: DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE, + postGcHeapUsedBytes, + unavailableReason: null, + } + : unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED + ); + } catch { + result = unavailableResult( + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED + ); + } + } + + postResult(message.responsePort, result); +} diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index b3b4d968a..61ca7f54d 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -64,6 +64,8 @@ import { getAppState, getPlaylist, setAppState, + type AppPlaylistGetPhaseCapture, + type AppPlaylistUpsertPhaseCapture, updatePlaylist, upsertAppPlaylist, upsertAppPlaylists, @@ -90,9 +92,21 @@ import { } from '../database/operations/xtream.operations'; import { armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + registerDatabaseWorkerPerformanceCapture, + releaseDatabaseWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, + type WorkerPerformanceCapture, } from './worker-performance-capture'; +import { + captureWorkerPerformancePhase, + captureWorkerPerformancePhaseAsync, +} from './worker-performance-phase'; +import { + handleDatabaseWorkerPostGcHeapRequest, + isDatabaseWorkerPostGcHeapRequest, +} from './database-worker-post-gc-heap'; const loggerLabel = '[DB Worker]'; const batchDelayMs = Number.parseInt( @@ -104,6 +118,11 @@ type ActiveOperationState = { cancelled: boolean; }; +type PreRegisteredActiveOperation = { + operationId: string; + state: ActiveOperationState; +}; + type OperationController = { control: { checkpoint: () => Promise; @@ -129,6 +148,7 @@ type OperationController = { }; const activeOperations = new Map(); +const activePerformanceCaptures = new Set(); if (!parentPort) { throw new Error('Database worker must be started with a parent port'); @@ -178,17 +198,22 @@ async function pauseBetweenBatches(): Promise { await new Promise((resolve) => setTimeout(resolve, batchDelayMs)); } -function createOperationController(config: { - operationId?: string; - operation: string; - playlistId?: string; - requestId: string; - cancellable?: boolean; -}): OperationController { +function createOperationController( + config: { + operationId?: string; + operation: string; + playlistId?: string; + requestId: string; + cancellable?: boolean; + }, + preRegisteredState?: ActiveOperationState +): OperationController { const { operationId, operation, playlistId, requestId } = config; const cancellable = config.cancellable ?? true; const activeState = - operationId && cancellable ? { cancelled: false } : null; + operationId && cancellable + ? (preRegisteredState ?? { cancelled: false }) + : null; if (operationId && activeState) { activeOperations.set(operationId, activeState); @@ -255,7 +280,10 @@ function createOperationController(config: { }); }, cleanup: () => { - if (operationId) { + if ( + operationId && + activeOperations.get(operationId) === activeState + ) { activeOperations.delete(operationId); } }, @@ -264,9 +292,10 @@ function createOperationController(config: { async function executeTrackedOperation( config: Parameters[0], - handler: (controller: OperationController) => Promise + handler: (controller: OperationController) => Promise, + preRegisteredState?: ActiveOperationState ): Promise { - const controller = createOperationController(config); + const controller = createOperationController(config, preRegisteredState); try { return await handler(controller); @@ -282,7 +311,54 @@ async function executeTrackedOperation( } } -async function executeRequest(message: DbWorkerRequestMessage) { +function preRegisterCancellableOperation( + message: DbWorkerRequestMessage +): PreRegisteredActiveOperation | null { + switch (message.operation) { + case 'DB_SAVE_CONTENT': + case 'DB_DELETE_PLAYLIST': + case 'DB_DELETE_XTREAM_CONTENT': + case 'DB_RESTORE_XTREAM_USER_DATA': + break; + default: + return null; + } + + if ( + typeof message.payload !== 'object' || + message.payload === null || + Array.isArray(message.payload) + ) { + return null; + } + const operationId = (message.payload as Record)[ + 'operationId' + ]; + if (typeof operationId !== 'string' || operationId.length === 0) { + return null; + } + + const state: ActiveOperationState = { cancelled: false }; + activeOperations.set(operationId, state); + return { operationId, state }; +} + +function releasePreRegisteredOperation( + operation: PreRegisteredActiveOperation | null +): void { + if ( + operation && + activeOperations.get(operation.operationId) === operation.state + ) { + activeOperations.delete(operation.operationId); + } +} + +async function executeRequest( + message: DbWorkerRequestMessage, + preRegisteredState?: ActiveOperationState, + performanceCapture: WorkerPerformanceCapture | null = null +) { const db = await getWorkerDatabase(); switch (message.operation) { @@ -362,11 +438,7 @@ async function executeRequest(message: DbWorkerRequestMessage) { kind?: 'all' | 'vod' | 'series'; limit?: number; playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url'; + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; }; return getGlobalRecentlyAdded( db, @@ -413,7 +485,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -506,9 +579,29 @@ async function executeRequest(message: DbWorkerRequestMessage) { } case 'DB_UPSERT_APP_PLAYLIST': { + const capturePhase: AppPlaylistUpsertPhaseCapture | undefined = + performanceCapture + ? { + captureAsync: (phase, execute, metadata) => + captureWorkerPerformancePhaseAsync( + performanceCapture, + phase, + execute, + metadata + ), + captureSync: (phase, execute, metadata) => + captureWorkerPerformancePhase( + performanceCapture, + phase, + execute, + metadata + ), + } + : undefined; return upsertAppPlaylist( db, - message.payload as Record + message.payload as Record, + capturePhase ); } @@ -527,7 +620,26 @@ async function executeRequest(message: DbWorkerRequestMessage) { case 'DB_GET_APP_PLAYLIST': { const payload = message.payload as { playlistId: string }; - return getAppPlaylist(db, payload.playlistId); + const capturePhase: AppPlaylistGetPhaseCapture | undefined = + performanceCapture + ? { + captureAsync: (phase, execute, metadata) => + captureWorkerPerformancePhaseAsync( + performanceCapture, + phase, + execute, + metadata + ), + captureSync: (phase, execute, metadata) => + captureWorkerPerformancePhase( + performanceCapture, + phase, + execute, + metadata + ), + } + : undefined; + return getAppPlaylist(db, payload.playlistId, capturePhase); } case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS': { @@ -586,7 +698,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -695,7 +808,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -739,7 +853,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -921,6 +1036,14 @@ async function executeRequest(message: DbWorkerRequestMessage) { } parentPort.on('message', async (message: DbWorkerIncomingMessage) => { + if (isDatabaseWorkerPostGcHeapRequest(message)) { + handleDatabaseWorkerPostGcHeapRequest( + message, + activePerformanceCaptures.size === 0 + ); + return; + } + if (message.type === 'cancel') { const activeOperation = activeOperations.get(message.operationId); if (activeOperation) { @@ -929,33 +1052,62 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { return; } - const performanceCapture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(performanceCapture); - try { - const result = await executeRequest(message); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); + const preRegisteredOperation = preRegisterCancellableOperation(message); + let performanceCapture: WorkerPerformanceCapture | null = null; + const execution = await (async () => { + try { + performanceCapture = startWorkerPerformanceCapture({ + requestId: message.requestId, + }); + registerDatabaseWorkerPerformanceCapture( + activePerformanceCaptures, + performanceCapture + ); + await armWorkerPerformanceCapture(performanceCapture); + return await executeWithWorkerPerformanceCapture( + performanceCapture, + () => + executeRequest( + message, + preRegisteredOperation?.state, + performanceCapture + ) + ); + } finally { + releaseDatabaseWorkerPerformanceCapture( + activePerformanceCaptures, + performanceCapture + ); + releasePreRegisteredOperation(preRegisteredOperation); + } + })(); + + if (execution.success) { postMessage({ type: 'response', requestId: message.requestId, success: true, - result, - performance, + result: execution.result, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); - } catch (error) { + } else { console.error( loggerLabel, `Error handling ${message.operation}:`, - error + execution.error ); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: false, - error: serializeError(error), - performance, + error: serializeError(execution.error), + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index 790d6ac55..a541d8baf 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -24,7 +24,8 @@ import { requestWithValidatedRedirects } from '../util/validated-axios'; import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; import { armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, } from './worker-performance-capture'; @@ -86,6 +87,15 @@ function checkpoint(payload: PlaylistRefreshPayload): void { } } +function releaseActiveRefresh( + operationId: string, + activeRefresh: ActiveRefreshState +): void { + if (activeRefreshes.get(operationId) === activeRefresh) { + activeRefreshes.delete(operationId); + } +} + async function fetchPlaylistFromUrl( payload: PlaylistRefreshPayload, controller: AbortController @@ -159,23 +169,32 @@ async function fetchPlaylistFromFile( } async function executeRefresh( - payload: PlaylistRefreshPayload + payload: PlaylistRefreshPayload, + activeRefresh: ActiveRefreshState ): Promise { - const controller = new AbortController(); - activeRefreshes.set(payload.operationId, { - cancelled: false, - controller, - }); - try { const playlist = payload.url - ? await fetchPlaylistFromUrl(payload, controller) + ? await fetchPlaylistFromUrl(payload, activeRefresh.controller) : await fetchPlaylistFromFile(payload); checkpoint(payload); return playlist; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + emitEvent(payload, { + status: 'cancelled', + phase: 'parsing', + }); + } else { + emitEvent(payload, { + status: 'error', + phase: payload.url ? 'fetching' : 'reading-file', + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; } finally { - activeRefreshes.delete(payload.operationId); + releaseActiveRefresh(payload.operationId, activeRefresh); } } @@ -191,39 +210,45 @@ parentPort.on( return; } - const performanceCapture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(performanceCapture); + const payload = message.payload; + const activeRefresh: ActiveRefreshState = { + cancelled: false, + controller: new AbortController(), + }; + activeRefreshes.set(payload.operationId, activeRefresh); + try { - const result = await executeRefresh(message.payload); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); - postMessage({ - type: 'response', - success: true, - result, - performance, - }); - } catch (error) { - const payload = message.payload; - if (error instanceof Error && error.name === 'AbortError') { - emitEvent(payload, { status: 'cancelled', phase: 'parsing' }); + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); + const execution = await executeWithWorkerPerformanceCapture( + performanceCapture, + () => executeRefresh(payload, activeRefresh) + ); + + if (execution.success) { + postMessage({ + type: 'response', + success: true, + result: execution.result, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), + }); } else { - emitEvent(payload, { - status: 'error', - phase: payload.url ? 'fetching' : 'reading-file', - error: - error instanceof Error ? error.message : String(error), + const error = execution.error; + postMessage({ + type: 'response', + success: false, + error: serializeError(error), + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } - - const performance = - await finishWorkerPerformanceCapture(performanceCapture); - postMessage({ - type: 'response', - success: false, - error: serializeError(error), - performance, - }); + } finally { + releaseActiveRefresh(payload.operationId, activeRefresh); } } ); diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts new file mode 100644 index 000000000..eadd2617f --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -0,0 +1,364 @@ +import type { + DbWorkerIncomingMessage, + DbWorkerMessage, +} from './database-worker.types'; +import type { + PlaylistRefreshWorkerIncomingMessage, + PlaylistRefreshWorkerMessage, +} from './playlist-refresh.worker.types'; + +type WorkerMessageHandler = ( + message: TMessage +) => Promise | void; + +interface ArmGate { + readonly promise: Promise; + readonly release: () => void; +} + +function createArmGate(): ArmGate { + let release = (): void => undefined; + const promise = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return { promise, release }; +} + +function createParentPortHarness() { + let messageHandler: WorkerMessageHandler | null = null; + const postMessage = jest.fn(); + const parentPort = { + on: jest.fn( + (event: string, handler: WorkerMessageHandler): void => { + if (event === 'message') { + messageHandler = handler; + } + } + ), + postMessage, + }; + + return { + getMessageHandler: (): WorkerMessageHandler => { + if (!messageHandler) { + throw new Error('Worker message handler was not registered'); + } + return messageHandler; + }, + parentPort, + postMessage, + }; +} + +function mockPerformanceCapture( + armGate: ArmGate, + failureGates?: { + readonly observed: ArmGate; + readonly profilingFinished: ArmGate; + } +): void { + jest.doMock('./worker-performance-capture', () => ({ + armWorkerPerformanceCapture: jest.fn(() => armGate.promise), + executeWithWorkerPerformanceCapture: jest.fn( + async (_capture: unknown, execute: () => Promise) => { + try { + return { + error: null, + performance: undefined, + result: await execute(), + success: true, + }; + } catch (error) { + failureGates?.observed.release(); + await failureGates?.profilingFinished.promise; + return { + error, + performance: undefined, + result: undefined, + success: false, + }; + } + } + ), + registerDatabaseWorkerPerformanceCapture: jest.fn(), + releaseDatabaseWorkerPerformanceCapture: jest.fn(), + stampWorkerPerformanceResponsePostedEpoch: jest.fn( + (_capture: unknown, performance: unknown) => performance + ), + startWorkerPerformanceCapture: jest.fn(() => ({})), + })); +} + +describe('worker cancellation while performance capture arms', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it('cancels playlist work before file access when cancel arrives during arming', async () => { + const armGate = createArmGate(); + const profilingFinished = createArmGate(); + const failureObserved = createArmGate(); + const port = createParentPortHarness< + PlaylistRefreshWorkerIncomingMessage, + PlaylistRefreshWorkerMessage + >(); + const readFile = jest.fn().mockResolvedValue('#EXTM3U'); + mockPerformanceCapture(armGate, { + observed: failureObserved, + profilingFinished, + }); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('node:fs/promises', () => ({ readFile })); + jest.doMock('iptv-playlist-parser', () => ({ + parse: jest.fn(() => ({ items: [] })), + })); + jest.doMock('@iptvnator/shared/m3u-utils', () => ({ + createPlaylistObject: jest.fn(() => ({ id: 'playlist-1' })), + getFilenameFromUrl: jest.fn(() => 'playlist.m3u'), + })); + + await import('./playlist-refresh.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + payload: { + filePath: '/fixtures/playlist.m3u', + operationId: 'refresh-1', + playlistId: 'playlist-1', + title: 'Fixture', + }, + }); + + await handleMessage({ + type: 'cancel', + operationId: 'refresh-1', + }); + armGate.release(); + await failureObserved.promise; + + const cancelledBeforeProfilingFinished = + port.postMessage.mock.calls.some( + ([message]) => + message.type === 'event' && + message.event.status === 'cancelled' + ); + const responseBeforeProfilingFinished = + port.postMessage.mock.calls.some( + ([message]) => message.type === 'response' + ); + + profilingFinished.release(); + await requestPromise; + + expect(readFile).not.toHaveBeenCalled(); + expect(cancelledBeforeProfilingFinished).toBe(true); + expect(responseBeforeProfilingFinished).toBe(false); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'refresh-1', + status: 'cancelled', + }), + type: 'event', + }) + ); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + success: false, + type: 'response', + }) + ); + }); + + it('cancels cancellable database work when cancel arrives during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const saveContent = jest.fn( + async ( + _db: unknown, + _playlistId: string, + _streams: unknown[], + _type: string, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { count: 0 }; + } + ); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/content.operations', () => ({ + saveContent, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'database-operation-1', + playlistId: 'playlist-1', + streams: [], + type: 'movie', + }, + requestId: 'request-1', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-1', + }); + armGate.release(); + await requestPromise; + + expect(saveContent).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'database-operation-1', + status: 'cancelled', + }), + requestId: 'request-1', + type: 'event', + }) + ); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + requestId: 'request-1', + success: false, + type: 'response', + }) + ); + }); + + it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const deleteAllPlaylists = jest.fn( + async ( + _db: unknown, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { success: true }; + } + ); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/playlist.operations', () => ({ + deleteAllPlaylists, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_DELETE_ALL_PLAYLISTS', + payload: { + operationId: 'database-operation-2', + }, + requestId: 'request-2', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-2', + }); + armGate.release(); + await requestPromise; + + expect(deleteAllPlaylists).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'request-2', + result: { success: true }, + success: true, + type: 'response', + }) + ); + expect(port.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ status: 'cancelled' }), + requestId: 'request-2', + type: 'event', + }) + ); + }); +}); + +describe('database worker performance control messages', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it('handles a post-GC probe without dispatching a database request', async () => { + const armGate = createArmGate(); + armGate.release(); + const port = createParentPortHarness(); + const getWorkerDatabase = jest.fn().mockResolvedValue({}); + const handlePostGcHeapRequest = jest.fn(); + const responsePort = { marker: 'response-port' }; + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase, + })); + jest.doMock('./database-worker-post-gc-heap', () => ({ + handleDatabaseWorkerPostGcHeapRequest: handlePostGcHeapRequest, + isDatabaseWorkerPostGcHeapRequest: jest.fn( + (message: unknown) => + typeof message === 'object' && + message !== null && + (message as Record)['type'] === + 'performance:collect-post-gc-heap' + ), + })); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const message = { + type: 'performance:collect-post-gc-heap', + responsePort, + }; + + await handleMessage(message); + + expect(handlePostGcHeapRequest).toHaveBeenCalledWith(message, true); + expect(getWorkerDatabase).not.toHaveBeenCalled(); + expect(port.postMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts new file mode 100644 index 000000000..900f4ccde --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts @@ -0,0 +1,95 @@ +import { + WORKER_PERFORMANCE_INVALID_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + registerDatabaseWorkerPerformanceCapture, + releaseDatabaseWorkerPerformanceCapture, + startWorkerPerformanceCapture, + type WorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +describe('worker performance capture concurrency and real timers', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + + afterEach(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('fails every overlapping database request capture closed without serializing execution', async () => { + const activeCaptures = new Set(); + const firstHarness = createFakeRuntime(); + const secondHarness = createFakeRuntime(); + const first = startWorkerPerformanceCapture({ + enabled: true, + runtime: firstHarness.runtime, + }); + const second = startWorkerPerformanceCapture({ + enabled: true, + runtime: secondHarness.runtime, + }); + registerDatabaseWorkerPerformanceCapture(activeCaptures, first); + registerDatabaseWorkerPerformanceCapture(activeCaptures, second); + await Promise.all([ + armWorkerPerformanceCapture(first), + armWorkerPerformanceCapture(second), + ]); + + const [firstExecution, secondExecution] = await Promise.all([ + executeWithWorkerPerformanceCapture(first, async () => 'first'), + executeWithWorkerPerformanceCapture(second, async () => 'second'), + ]); + releaseDatabaseWorkerPerformanceCapture(activeCaptures, first); + releaseDatabaseWorkerPerformanceCapture(activeCaptures, second); + + for (const execution of [firstExecution, secondExecution]) { + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + histogramFlushedEpochMs: null, + invalidReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + threadCpuUserMicros: null, + }); + } + expect(firstExecution.result).toBe('first'); + expect(secondExecution.result).toBe('second'); + expect(activeCaptures.size).toBe(0); + }); + + it('reliably observes a real 20ms event-loop block after condition-based arming', async () => { + process.env[PROFILING_ENV] = '1'; + + const capture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + const blockStartedAt = performance.now(); + while (performance.now() - blockStartedAt < 20) { + // This deliberate block is the behavior under measurement. + } + } + ); + + expect(execution.success).toBe(true); + expect(execution.performance?.eventLoopDelay).not.toBeNull(); + expect(execution.performance?.eventLoopDelay?.maxMs).toBeGreaterThan( + 10 + ); + expect(execution.performance?.histogramFlushedEpochMs).not.toBeNull(); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts new file mode 100644 index 000000000..19462fe5a --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts @@ -0,0 +1,39 @@ +import { + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +describe('worker performance histogram lifecycle', () => { + it('accepts a nonthrowing false disable result because the histogram is already disabled', async () => { + const harness = createFakeRuntime({ + histogramDisableResult: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: 145, + }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts new file mode 100644 index 000000000..a4951d6cc --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts @@ -0,0 +1,136 @@ +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerEventLoopDelayHistogram, + type WorkerEventLoopDelayMetrics, + type WorkerPerformanceCapture, + type WorkerPerformanceMetricUnavailableReason, +} from './worker-performance-capture.model'; +import { + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS, +} from './worker-performance-capture.runtime'; + +export function disableWorkerPerformanceHistogram( + capture: WorkerPerformanceCapture +): boolean { + if (!capture.eventLoopDelay || capture.histogramDisabled) { + return true; + } + + try { + capture.eventLoopDelay.disable(); + capture.histogramDisabled = true; + return true; + } catch { + // Performance instrumentation must never affect worker execution. + return false; + } +} + +export function markEventLoopDelayUnavailable( + capture: WorkerPerformanceCapture, + reason: WorkerPerformanceMetricUnavailableReason +): void { + capture.eventLoopDelayUnavailableReason ??= reason; + disableWorkerPerformanceHistogram(capture); +} + +export async function waitForHistogramCondition( + capture: WorkerPerformanceCapture, + condition: (histogram: WorkerEventLoopDelayHistogram) => boolean +): Promise { + const histogram = capture.eventLoopDelay; + if ( + !histogram || + capture.histogramDisabled || + capture.invalidReason !== null + ) { + return false; + } + + let startedAt: number; + try { + startedAt = capture.runtime.readMonotonicMs(); + } catch { + return false; + } + + const maximumPolls = Math.ceil( + HISTOGRAM_WAIT_CAP_MS / HISTOGRAM_POLL_INTERVAL_MS + ); + let pollCount = 0; + + while (true) { + if (capture.invalidReason !== null || capture.histogramDisabled) { + return false; + } + try { + if (condition(histogram)) { + return true; + } + } catch { + return false; + } + + let elapsedMs: number; + try { + elapsedMs = capture.runtime.readMonotonicMs() - startedAt; + } catch { + return false; + } + if ( + !Number.isFinite(elapsedMs) || + elapsedMs >= HISTOGRAM_WAIT_CAP_MS || + pollCount >= maximumPolls + ) { + return false; + } + + const delayMs = Math.min( + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS - Math.max(0, elapsedMs) + ); + try { + pollCount += 1; + await new Promise((resolvePromise) => { + capture.runtime.scheduleTimeout(resolvePromise, delayMs); + }); + } catch { + return false; + } + } +} + +export function readWorkerEventLoopDelay( + capture: WorkerPerformanceCapture +): WorkerEventLoopDelayMetrics | null { + if ( + capture.invalidReason !== null || + capture.eventLoopDelayUnavailableReason !== null || + capture.histogramFlushedEpochMs === null || + !capture.eventLoopDelay + ) { + return null; + } + + try { + const metrics: WorkerEventLoopDelayMetrics = { + maxMs: capture.eventLoopDelay.max / 1e6, + p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, + p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, + }; + if ( + Object.values(metrics).every( + (value) => Number.isFinite(value) && value >= 0 + ) + ) { + return metrics; + } + } catch { + // The fixed reason below is intentionally payload-free. + } + + capture.eventLoopDelayUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID; + return null; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts new file mode 100644 index 000000000..4f1e95ca4 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts @@ -0,0 +1,249 @@ +import type { EventLoopUtilization } from 'node:perf_hooks'; +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerPerformanceCapture, + type WorkerPerformanceCaptureResult, +} from './worker-performance-capture.model'; +import { readWorkerEventLoopDelay } from './worker-performance-capture.histogram'; +import { safeReadEpochMs } from './worker-performance-capture.runtime'; + +function readThreadCpuBoundary( + capture: WorkerPerformanceCapture +): NodeJS.CpuUsage | null { + if (!capture.runtime.readThreadCpuUsage) { + capture.threadCpuUnavailableReason ??= + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE; + return null; + } + + try { + const usage = capture.runtime.readThreadCpuUsage(); + if ( + !Number.isFinite(usage.system) || + !Number.isFinite(usage.user) || + usage.system < 0 || + usage.user < 0 + ) { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return null; + } + return usage; + } catch { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE; + return null; + } +} + +function readEventLoopUtilizationBoundary( + capture: WorkerPerformanceCapture +): EventLoopUtilization | null { + try { + const utilization = capture.runtime.readEventLoopUtilization(); + if ( + !Number.isFinite(utilization.active) || + !Number.isFinite(utilization.idle) + ) { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } + return utilization; + } catch { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } +} + +export function beginWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): void { + if (!capture) { + return; + } + + capture.workStartedEpochMs = safeReadEpochMs(capture.runtime); + capture.threadCpuStart = readThreadCpuBoundary(capture); + capture.eventLoopUtilizationStart = + readEventLoopUtilizationBoundary(capture); +} + +function calculateThreadCpu( + capture: WorkerPerformanceCapture +): Pick< + WorkerPerformanceCaptureResult, + 'threadCpuSystemMicros' | 'threadCpuUserMicros' +> { + if ( + capture.invalidReason !== null || + capture.threadCpuUnavailableReason !== null || + !capture.threadCpuStart || + !capture.threadCpuEnd + ) { + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } + + try { + const system = + capture.threadCpuEnd.system - capture.threadCpuStart.system; + const user = capture.threadCpuEnd.user - capture.threadCpuStart.user; + if ( + !Number.isFinite(system) || + !Number.isFinite(user) || + system < 0 || + user < 0 + ) { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } + + return { + threadCpuSystemMicros: system, + threadCpuUserMicros: user, + }; + } catch { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } +} + +function calculateEventLoopUtilization( + capture: WorkerPerformanceCapture +): number | null { + if ( + capture.invalidReason !== null || + capture.eventLoopUtilizationUnavailableReason !== null || + !capture.eventLoopUtilizationStart || + !capture.eventLoopUtilizationEnd + ) { + return null; + } + + try { + const active = + capture.eventLoopUtilizationEnd.active - + capture.eventLoopUtilizationStart.active; + const idle = + capture.eventLoopUtilizationEnd.idle - + capture.eventLoopUtilizationStart.idle; + const total = active + idle; + if ( + !Number.isFinite(active) || + !Number.isFinite(idle) || + active < 0 || + idle < 0 || + total < 0 + ) { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } + + return total === 0 ? 0 : active / total; + } catch { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } +} + +function createFallbackResult( + capture: WorkerPerformanceCapture +): WorkerPerformanceCaptureResult { + const invalidReason = capture.invalidReason; + return { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + invalidReason ?? + capture.eventLoopDelayUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + invalidReason ?? + capture.eventLoopUtilizationUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, + histogramFlushedEpochMs: null, + invalidReason, + ...copyPhaseEvents(capture), + requestReceivedEpochMs: capture.requestReceivedEpochMs, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + invalidReason ?? + capture.threadCpuUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + workEndedEpochMs: + capture.workEndedEpochMs ?? capture.requestReceivedEpochMs, + workStartedEpochMs: + capture.workStartedEpochMs ?? capture.requestReceivedEpochMs, + }; +} + +export function createWorkerPerformanceCaptureResult( + capture: WorkerPerformanceCapture +): WorkerPerformanceCaptureResult { + try { + const overlapReason = capture.invalidReason; + if (overlapReason) { + capture.eventLoopDelayUnavailableReason = overlapReason; + capture.eventLoopUtilizationUnavailableReason = overlapReason; + capture.threadCpuUnavailableReason = overlapReason; + capture.histogramFlushedEpochMs = null; + } + const threadCpu = calculateThreadCpu(capture); + + return { + eventLoopDelay: readWorkerEventLoopDelay(capture), + eventLoopDelayUnavailableReason: + capture.eventLoopDelayUnavailableReason, + eventLoopUtilization: calculateEventLoopUtilization(capture), + eventLoopUtilizationUnavailableReason: + capture.eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs: capture.histogramFlushedEpochMs, + invalidReason: capture.invalidReason, + ...copyPhaseEvents(capture), + requestReceivedEpochMs: capture.requestReceivedEpochMs, + ...threadCpu, + threadCpuUnavailableReason: capture.threadCpuUnavailableReason, + workEndedEpochMs: + capture.workEndedEpochMs ?? capture.requestReceivedEpochMs, + workStartedEpochMs: + capture.workStartedEpochMs ?? capture.requestReceivedEpochMs, + }; + } catch { + return createFallbackResult(capture); + } +} + +function copyPhaseEvents( + capture: WorkerPerformanceCapture +): Pick { + try { + return capture.phaseEvents.length > 0 + ? { phaseEvents: Object.freeze([...capture.phaseEvents]) } + : {}; + } catch { + return {}; + } +} + +export function finishWorkerPerformanceMetricBoundaries( + capture: WorkerPerformanceCapture +): void { + capture.workEndedEpochMs = safeReadEpochMs(capture.runtime); + capture.threadCpuEnd = readThreadCpuBoundary(capture); + capture.eventLoopUtilizationEnd = readEventLoopUtilizationBoundary(capture); +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts new file mode 100644 index 000000000..dff916076 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts @@ -0,0 +1,103 @@ +import type { EventLoopUtilization } from 'node:perf_hooks'; +import type { + AppPlaylistPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + +export const WORKER_PERFORMANCE_INVALID_REASON = { + OVERLAPPING_DATABASE_WORKER_REQUESTS: + 'overlapping-database-worker-requests', +} as const; + +export type WorkerPerformanceInvalidReason = + (typeof WORKER_PERFORMANCE_INVALID_REASON)[keyof typeof WORKER_PERFORMANCE_INVALID_REASON]; + +export const WORKER_PERFORMANCE_UNAVAILABLE_REASON = { + EVENT_LOOP_DELAY_ARM_TIMEOUT: 'event-loop-delay-arm-timeout', + EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE: + 'event-loop-delay-capture-unavailable', + EVENT_LOOP_DELAY_FLUSH_TIMEOUT: 'event-loop-delay-flush-timeout', + EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid', + EVENT_LOOP_UTILIZATION_UNAVAILABLE: 'event-loop-utilization-unavailable', + THREAD_CPU_USAGE_INVALID: 'thread-cpu-usage-invalid', + THREAD_CPU_USAGE_UNAVAILABLE: 'thread-cpu-usage-unavailable', +} as const; + +export type WorkerPerformanceUnavailableReason = + (typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON)[keyof typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON]; + +export type WorkerPerformanceMetricUnavailableReason = + WorkerPerformanceInvalidReason | WorkerPerformanceUnavailableReason; + +export interface WorkerEventLoopDelayMetrics { + maxMs: number; + p95Ms: number; + p99Ms: number; +} + +export interface WorkerEventLoopDelayHistogram { + readonly count: number; + readonly max: number; + disable: () => boolean; + enable: () => boolean; + percentile: (percentile: number) => number; +} + +export interface WorkerPerformanceCaptureResult { + eventLoopDelay: WorkerEventLoopDelayMetrics | null; + eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + eventLoopUtilization: number | null; + eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + histogramFlushedEpochMs: number | null; + invalidReason: WorkerPerformanceInvalidReason | null; + phaseEvents?: readonly PerformancePhaseEvent[]; + requestReceivedEpochMs: number; + responsePostedEpochMs?: number; + threadCpuSystemMicros: number | null; + threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + threadCpuUserMicros: number | null; + workEndedEpochMs: number; + workStartedEpochMs: number; +} + +export interface WorkerPerformanceCaptureRuntime { + createEventLoopDelayHistogram: () => WorkerEventLoopDelayHistogram; + readEpochMs: () => number; + readEventLoopUtilization: () => EventLoopUtilization; + readMonotonicMs: () => number; + readThreadCpuUsage: (() => NodeJS.CpuUsage) | null; + scheduleTimeout: (callback: () => void, delayMs: number) => void; +} + +export interface WorkerPerformanceCaptureOptions { + enabled?: boolean; + requestId?: string; + runtime?: WorkerPerformanceCaptureRuntime; +} + +export interface WorkerPerformanceExecutionResult { + error: unknown | null; + performance: WorkerPerformanceCaptureResult | undefined; + result: TResult | undefined; + success: boolean; +} + +export interface WorkerPerformanceCapture { + eventLoopDelay: WorkerEventLoopDelayHistogram | null; + eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + eventLoopUtilizationEnd: EventLoopUtilization | null; + eventLoopUtilizationStart: EventLoopUtilization | null; + eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + histogramDisabled: boolean; + histogramFlushedEpochMs: number | null; + invalidReason: WorkerPerformanceInvalidReason | null; + phaseEvents: PerformancePhaseEvent[]; + requestReceivedEpochMs: number; + requestId: string | null; + runtime: WorkerPerformanceCaptureRuntime; + threadCpuEnd: NodeJS.CpuUsage | null; + threadCpuStart: NodeJS.CpuUsage | null; + threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + workEndedEpochMs: number | null; + workStartedEpochMs: number | null; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts new file mode 100644 index 000000000..15c0a4308 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts @@ -0,0 +1,45 @@ +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import type { + WorkerPerformanceCaptureRuntime, + WorkerEventLoopDelayHistogram, +} from './worker-performance-capture.model'; + +export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +export const HISTOGRAM_POLL_INTERVAL_MS = 1; +export const HISTOGRAM_WAIT_CAP_MS = 50; + +function readRuntimeThreadCpuUsage(): (() => NodeJS.CpuUsage) | null { + const candidate: unknown = Reflect.get(process, 'threadCpuUsage'); + if (typeof candidate !== 'function') { + return null; + } + + return () => + (candidate as (this: NodeJS.Process) => NodeJS.CpuUsage).call(process); +} + +export const DEFAULT_WORKER_PERFORMANCE_RUNTIME: WorkerPerformanceCaptureRuntime = + { + createEventLoopDelayHistogram: (): WorkerEventLoopDelayHistogram => + monitorEventLoopDelay({ + resolution: HISTOGRAM_POLL_INTERVAL_MS, + }), + readEpochMs: () => performance.timeOrigin + performance.now(), + readEventLoopUtilization: () => performance.eventLoopUtilization(), + readMonotonicMs: () => performance.now(), + readThreadCpuUsage: readRuntimeThreadCpuUsage(), + scheduleTimeout: (callback, delayMs) => { + setTimeout(callback, delayMs); + }, + }; + +export function safeReadEpochMs( + runtime: WorkerPerformanceCaptureRuntime +): number { + try { + const epochMs = runtime.readEpochMs(); + return Number.isFinite(epochMs) ? epochMs : Date.now(); + } catch { + return Date.now(); + } +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts index 2801935c3..d6438dec3 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -1,8 +1,11 @@ import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, } from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; @@ -23,26 +26,383 @@ describe('worker performance capture', () => { expect(startWorkerPerformanceCapture()).toBeNull(); }); - it('reports finite worker event-loop metrics when opted in', async () => { - process.env[PROFILING_ENV] = '1'; - - const capture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(capture); - const blockStartedAt = performance.now(); - while (performance.now() - blockStartedAt < 20) { - // Deliberately block the loop so the histogram contract is tested. - } - const result = await finishWorkerPerformanceCapture(capture); - - expect(result).toEqual({ - eventLoopDelay: { - maxMs: expect.any(Number), - p95Ms: expect.any(Number), - p99Ms: expect.any(Number), - }, - eventLoopUtilization: expect.any(Number), + it('captures exact work-boundary CPU, ELU, timestamps, and a flushed fresh histogram', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + harness.lifecycle.push('execute'); + await Promise.resolve(); + harness.lifecycle.push('execute:settled'); + return 'result'; + } + ); + + expect(execution).toEqual({ + error: null, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.75, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 145, + invalidReason: null, + requestReceivedEpochMs: 100, + threadCpuSystemMicros: 30, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 80, + workEndedEpochMs: 140, + workStartedEpochMs: 110, + }, + result: 'result', + success: true, + }); + expect(harness.lifecycle).toEqual([ + 'epoch:100', + 'histogram:create', + 'timeout:1', + 'epoch:110', + 'cpu:1', + 'elu:1', + 'execute', + 'execute:settled', + 'epoch:140', + 'cpu:2', + 'elu:2', + 'timeout:1', + 'epoch:145', + ]); + expect(harness.histogramLifecycle.enableCalls).toBe(1); + expect(harness.histogramLifecycle.disableCalls).toBe(1); + expect('reset' in harness.histogram).toBe(false); + }); + + it('stamps the worker-send boundary only after request profiling has finished', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + const stamped = stampWorkerPerformanceResponsePostedEpoch( + capture, + execution.performance + ); + + expect(stamped).toMatchObject({ + histogramFlushedEpochMs: 145, + responsePostedEpochMs: 149, + workEndedEpochMs: 140, + }); + expect(harness.lifecycle.at(-1)).toBe('epoch:149'); + expect( + stampWorkerPerformanceResponsePostedEpoch(null, undefined) + ).toBeUndefined(); + }); + + it('finishes profiling before returning a rejected execution to worker side effects', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + const failure = new Error('worker failed'); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + harness.lifecycle.push('execute'); + await Promise.resolve(); + harness.lifecycle.push('execute:rejected'); + throw failure; + } + ); + harness.lifecycle.push('worker:emit-error'); + + expect(execution).toMatchObject({ + error: failure, + success: false, + }); + expect(harness.lifecycle.indexOf('epoch:140')).toBe( + harness.lifecycle.indexOf('execute:rejected') + 1 + ); + expect(harness.lifecycle.indexOf('worker:emit-error')).toBeGreaterThan( + harness.lifecycle.indexOf('epoch:145') + ); + }); + + it('times out arming after a monotonic 50ms without blocking work metrics', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect( + harness.scheduledTimeouts.reduce( + (total, timeout) => total + timeout, + 0 + ) + ).toBe(50); + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }); + }); + + it('cannot poll forever when the monotonic runtime clock stalls', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + stallMonotonicClock: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(harness.scheduledTimeouts).toHaveLength(50); + expect(execution).toMatchObject({ + result: 'result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + }, + }); + }); + + it('times out flushing after a monotonic 50ms while preserving work CPU and ELU', async () => { + const harness = createFakeRuntime({ + flushHistogram: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect( + harness.scheduledTimeouts + .slice(1) + .reduce((total, timeout) => total + timeout, 0) + ).toBe(50); + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }); + }); + + it('reports unavailable thread CPU without falling back to process CPU usage', async () => { + const harness = createFakeRuntime({ + threadCpuAvailable: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(execution.performance).toMatchObject({ + eventLoopUtilization: 0.75, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + }); + expect(harness.lifecycle).not.toContain('cpu:1'); + expect(harness.lifecycle).not.toContain('cpu:2'); + }); + + it('preserves the business result when CPU and ELU runtime callbacks throw', async () => { + const harness = createFakeRuntime({ + throwBoundaryCallbacks: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + }, + }); + }); + + it('preserves work metrics and the business result when timer scheduling throws', async () => { + const harness = createFakeRuntime({ + throwScheduleTimeout: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + }); + + it.each([ + { + label: 'count getter during finish', + options: { + throwHistogramCountAtRead: 3, + }, + reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + }, + { + label: 'metric getter after flush', + options: { + throwHistogramMetric: true, + }, + reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID, + }, + ])( + 'preserves the business result when the histogram $label throws', + async ({ options, reason }) => { + const harness = createFakeRuntime(options); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: reason, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + } + ); + + it('keeps the business result and rejects delay metrics when histogram disable throws', async () => { + const harness = createFakeRuntime({ + throwHistogramDisable: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, }); - expect(result?.eventLoopUtilization).toBeGreaterThanOrEqual(0); - expect(result?.eventLoopDelay.maxMs).toBeGreaterThan(10); }); }); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts new file mode 100644 index 000000000..54517e637 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts @@ -0,0 +1,158 @@ +import type { + WorkerEventLoopDelayHistogram, + WorkerPerformanceCaptureRuntime, +} from './worker-performance-capture'; + +interface FakeRuntimeOptions { + armHistogram?: boolean; + flushHistogram?: boolean; + histogramDisableResult?: boolean; + stallMonotonicClock?: boolean; + threadCpuAvailable?: boolean; + throwBoundaryCallbacks?: boolean; + throwHistogramDisable?: boolean; + throwHistogramCountAtRead?: number; + throwHistogramMetric?: boolean; + throwScheduleTimeout?: boolean; +} + +export interface FakeRuntimeHarness { + histogram: WorkerEventLoopDelayHistogram; + histogramLifecycle: { + disableCalls: number; + enableCalls: number; + }; + lifecycle: string[]; + runtime: WorkerPerformanceCaptureRuntime; + scheduledTimeouts: number[]; +} + +export function createFakeRuntime( + options: FakeRuntimeOptions = {} +): FakeRuntimeHarness { + const lifecycle: string[] = []; + const scheduledTimeouts: number[] = []; + let epochIndex = 0; + let monotonicMs = 0; + let timeoutCount = 0; + let cpuIndex = 0; + let eluIndex = 0; + let histogramCount = 0; + let histogramCountReads = 0; + const histogramLifecycle = { + disableCalls: 0, + enableCalls: 0, + }; + const epochValues = [100, 110, 140, 145]; + const cpuValues = [ + { system: 50, user: 100 }, + { system: 80, user: 180 }, + ]; + const eluValues = [ + { active: 10, idle: 20, utilization: 1 / 3 }, + { active: 40, idle: 30, utilization: 4 / 7 }, + ]; + const histogram = { + get count() { + histogramCountReads += 1; + if (histogramCountReads === options.throwHistogramCountAtRead) { + throw new Error('histogram count unavailable'); + } + return histogramCount; + }, + disable: () => { + histogramLifecycle.disableCalls += 1; + if (options.throwHistogramDisable === true) { + throw new Error('histogram disable unavailable'); + } + return options.histogramDisableResult ?? true; + }, + enable: () => { + histogramLifecycle.enableCalls += 1; + return true; + }, + get max() { + if (options.throwHistogramMetric === true) { + throw new Error('histogram max unavailable'); + } + return 24_000_000; + }, + percentile: (percentile: number) => { + if (options.throwHistogramMetric === true) { + throw new Error('histogram percentile unavailable'); + } + if (percentile === 95) { + return 18_000_000; + } + if (percentile === 99) { + return 22_000_000; + } + return 0; + }, + } satisfies WorkerEventLoopDelayHistogram; + const runtime: WorkerPerformanceCaptureRuntime = { + createEventLoopDelayHistogram: () => { + lifecycle.push('histogram:create'); + return histogram; + }, + readEpochMs: () => { + const value = epochValues[epochIndex] ?? 145 + epochIndex; + epochIndex += 1; + lifecycle.push(`epoch:${value}`); + return value; + }, + readEventLoopUtilization: () => { + if (options.throwBoundaryCallbacks === true) { + throw new Error('ELU callback unavailable'); + } + const value = eluValues[eluIndex] ?? eluValues.at(-1)!; + eluIndex += 1; + lifecycle.push(`elu:${eluIndex}`); + return value; + }, + readMonotonicMs: () => monotonicMs, + readThreadCpuUsage: + options.threadCpuAvailable === false + ? null + : () => { + if (options.throwBoundaryCallbacks === true) { + throw new Error('thread CPU callback unavailable'); + } + const value = cpuValues[cpuIndex] ?? cpuValues.at(-1)!; + cpuIndex += 1; + lifecycle.push(`cpu:${cpuIndex}`); + return value; + }, + scheduleTimeout: (callback, delayMs) => { + lifecycle.push(`timeout:${delayMs}`); + scheduledTimeouts.push(delayMs); + if (options.throwScheduleTimeout === true) { + throw new Error('timer callback unavailable'); + } + if (options.stallMonotonicClock !== true) { + monotonicMs += delayMs; + } else if (timeoutCount >= 55) { + throw new Error('test scheduler fail-safe'); + } + timeoutCount += 1; + if (timeoutCount === 1 && options.armHistogram !== false) { + histogramCount += 1; + } else if ( + timeoutCount > 1 && + options.armHistogram !== false && + options.flushHistogram !== false + ) { + histogramCount += 1; + } + callback(); + }, + }; + + return { + histogram, + histogramLifecycle, + lifecycle, + runtime, + scheduledTimeouts, + }; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts index db86f234d..3fa1a8baa 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -1,47 +1,125 @@ import { - monitorEventLoopDelay, - performance, - type IntervalHistogram, -} from 'node:perf_hooks'; + WORKER_PERFORMANCE_INVALID_REASON, + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerPerformanceCapture, + type WorkerPerformanceCaptureOptions, + type WorkerPerformanceCaptureResult, + type WorkerPerformanceExecutionResult, +} from './worker-performance-capture.model'; +import { + disableWorkerPerformanceHistogram, + markEventLoopDelayUnavailable, + waitForHistogramCondition, +} from './worker-performance-capture.histogram'; +import { + beginWorkerPerformanceCapture, + createWorkerPerformanceCaptureResult, + finishWorkerPerformanceMetricBoundaries, +} from './worker-performance-capture.metrics'; +import { + DEFAULT_WORKER_PERFORMANCE_RUNTIME, + safeReadEpochMs, + WORKER_PROFILING_ENV, +} from './worker-performance-capture.runtime'; -const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +export { + WORKER_PERFORMANCE_INVALID_REASON, + WORKER_PERFORMANCE_UNAVAILABLE_REASON, +} from './worker-performance-capture.model'; +export type { + WorkerEventLoopDelayHistogram, + WorkerEventLoopDelayMetrics, + WorkerPerformanceCapture, + WorkerPerformanceCaptureOptions, + WorkerPerformanceCaptureResult, + WorkerPerformanceCaptureRuntime, + WorkerPerformanceExecutionResult, + WorkerPerformanceInvalidReason, + WorkerPerformanceUnavailableReason, +} from './worker-performance-capture.model'; -export interface WorkerPerformanceCaptureResult { - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - }; - eventLoopUtilization: number; -} - -interface WorkerPerformanceCapture { - eventLoopDelay: IntervalHistogram; - eventLoopUtilizationStart: ReturnType< - typeof performance.eventLoopUtilization - >; -} - -export function startWorkerPerformanceCapture(): WorkerPerformanceCapture | null { - if (process.env[WORKER_PROFILING_ENV] !== '1') { +export function startWorkerPerformanceCapture( + options: WorkerPerformanceCaptureOptions = {} +): WorkerPerformanceCapture | null { + const enabled = + options.enabled ?? process.env[WORKER_PROFILING_ENV] === '1'; + if (!enabled) { return null; } - const eventLoopDelay = monitorEventLoopDelay({ resolution: 1 }); - eventLoopDelay.enable(); - return { - eventLoopDelay, - eventLoopUtilizationStart: performance.eventLoopUtilization(), + const runtime = options.runtime ?? DEFAULT_WORKER_PERFORMANCE_RUNTIME; + const capture: WorkerPerformanceCapture = { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + eventLoopUtilizationEnd: null, + eventLoopUtilizationStart: null, + eventLoopUtilizationUnavailableReason: null, + histogramDisabled: false, + histogramFlushedEpochMs: null, + invalidReason: null, + phaseEvents: [], + requestReceivedEpochMs: safeReadEpochMs(runtime), + requestId: + typeof options.requestId === 'string' && + options.requestId.length > 0 + ? options.requestId + : null, + runtime, + threadCpuEnd: null, + threadCpuStart: null, + threadCpuUnavailableReason: + runtime.readThreadCpuUsage === null + ? WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE + : null, + workEndedEpochMs: null, + workStartedEpochMs: null, }; + + try { + capture.eventLoopDelay = runtime.createEventLoopDelayHistogram(); + capture.eventLoopDelay.enable(); + } catch { + capture.eventLoopDelay = null; + capture.eventLoopDelayUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE; + } + + return capture; } export async function armWorkerPerformanceCapture( capture: WorkerPerformanceCapture | null ): Promise { - if (capture) { - await new Promise((resolvePromise) => - setTimeout(resolvePromise, 2) + if (!capture) { + return; + } + + try { + if ( + capture.invalidReason !== null || + capture.eventLoopDelayUnavailableReason !== null + ) { + return; + } + const armed = await waitForHistogramCondition( + capture, + (histogram) => histogram.count > 0 ); + if (!armed && capture.invalidReason === null) { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT + ); + } + } catch { + try { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } catch { + // Instrumentation must never prevent business execution. + } } } @@ -52,17 +130,166 @@ export async function finishWorkerPerformanceCapture( return undefined; } - await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)); - capture.eventLoopDelay.disable(); - const eventLoopUtilization = performance.eventLoopUtilization( - capture.eventLoopUtilizationStart + finishWorkerPerformanceMetricBoundaries(capture); + + if ( + capture.invalidReason === null && + capture.eventLoopDelayUnavailableReason === null && + capture.eventLoopDelay + ) { + try { + const workEndedHistogramCount = capture.eventLoopDelay.count; + const flushed = await waitForHistogramCondition( + capture, + (histogram) => histogram.count > workEndedHistogramCount + ); + if (flushed) { + const disabled = disableWorkerPerformanceHistogram(capture); + if (disabled) { + capture.histogramFlushedEpochMs = safeReadEpochMs( + capture.runtime + ); + } else { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } + } else if ( + capture.invalidReason === null && + capture.eventLoopDelayUnavailableReason === null + ) { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT + ); + } + } catch { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } + } else { + disableWorkerPerformanceHistogram(capture); + } + + return createWorkerPerformanceCaptureResult(capture); +} + +export async function executeWithWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null, + execute: () => Promise, + finish: ( + capture: WorkerPerformanceCapture | null + ) => Promise< + WorkerPerformanceCaptureResult | undefined + > = finishWorkerPerformanceCapture +): Promise> { + try { + beginWorkerPerformanceCapture(capture); + } catch { + // Instrumentation setup must never prevent business execution. + } + let result: TResult; + try { + result = await execute(); + } catch (error) { + const performance = await safelyFinishWorkerPerformanceCapture( + capture, + finish + ); + return { + error, + performance, + result: undefined, + success: false, + }; + } + + const performance = await safelyFinishWorkerPerformanceCapture( + capture, + finish ); return { - eventLoopDelay: { - maxMs: capture.eventLoopDelay.max / 1e6, - p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, - p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, - }, - eventLoopUtilization: eventLoopUtilization.utilization, + error: null, + performance, + result, + success: true, }; } + +async function safelyFinishWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null, + finish: ( + capture: WorkerPerformanceCapture | null + ) => Promise +): Promise { + try { + return await finish(capture); + } catch { + if (!capture) { + return undefined; + } + try { + if (capture.workEndedEpochMs === null) { + finishWorkerPerformanceMetricBoundaries(capture); + } + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + return createWorkerPerformanceCaptureResult(capture); + } catch { + return undefined; + } + } +} + +export function registerDatabaseWorkerPerformanceCapture( + activeCaptures: Set, + capture: WorkerPerformanceCapture | null +): void { + if (!capture) { + return; + } + + if (activeCaptures.size > 0) { + for (const activeCapture of activeCaptures) { + activeCapture.invalidReason = + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS; + disableWorkerPerformanceHistogram(activeCapture); + } + capture.invalidReason = + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS; + disableWorkerPerformanceHistogram(capture); + } + activeCaptures.add(capture); +} + +export function releaseDatabaseWorkerPerformanceCapture( + activeCaptures: Set, + capture: WorkerPerformanceCapture | null +): void { + if (capture) { + activeCaptures.delete(capture); + } +} + +export function stampWorkerPerformanceResponsePostedEpoch( + capture: WorkerPerformanceCapture | null, + performance: WorkerPerformanceCaptureResult | undefined +): WorkerPerformanceCaptureResult | undefined { + if (!capture || !performance) { + return performance; + } + + try { + return { + ...performance, + responsePostedEpochMs: safeReadEpochMs(capture.runtime), + }; + } catch { + return performance; + } +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts new file mode 100644 index 000000000..f10fe3af9 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -0,0 +1,149 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +function readWorkerSource(filename: string): string { + return readFileSync( + join(process.cwd(), 'apps/electron-backend/src/app/workers', filename), + 'utf8' + ); +} + +describe('worker performance integration order', () => { + it('profiles overlapping database requests without serializing their execution', () => { + const source = readWorkerSource('database.worker.ts'); + const handler = source.slice( + source.lastIndexOf("parentPort.on('message'") + ); + + expect(handler).toContain('registerDatabaseWorkerPerformanceCapture'); + expect(handler).toContain('executeWithWorkerPerformanceCapture'); + expect(handler).toContain('releaseDatabaseWorkerPerformanceCapture'); + expect(handler).toContain('finally'); + expect(handler).not.toContain('finishWorkerPerformanceCapture'); + expect( + handler.indexOf('executeWithWorkerPerformanceCapture') + ).toBeLessThan(handler.indexOf('console.error')); + expect( + handler.indexOf('executeWithWorkerPerformanceCapture') + ).toBeLessThan(handler.indexOf('postMessage')); + expect( + handler.match(/stampWorkerPerformanceResponsePostedEpoch/g) + ).toHaveLength(2); + expect(handler).toMatch( + /postMessage\(\{[\s\S]*?performance:\s*stampWorkerPerformanceResponsePostedEpoch\([\s\S]*?execution\.performance[\s\S]*?\),[\s\S]*?\}\)/ + ); + expect(handler).not.toContain('await previous'); + expect(handler).not.toContain('requestQueue'); + }); + + it('keeps playlist response serialization after profiling completion', () => { + const source = readWorkerSource('playlist-refresh.worker.ts'); + const handler = source.slice(source.lastIndexOf('parentPort.on(')); + const executionIndex = handler.indexOf( + 'executeWithWorkerPerformanceCapture' + ); + + expect(executionIndex).toBeGreaterThanOrEqual(0); + expect(handler).not.toContain('finishWorkerPerformanceCapture'); + expect(executionIndex).toBeLessThan( + handler.indexOf('serializeError', executionIndex) + ); + expect(executionIndex).toBeLessThan( + handler.indexOf('postMessage', executionIndex) + ); + expect( + handler.match(/stampWorkerPerformanceResponsePostedEpoch/g) + ).toHaveLength(2); + }); + + it('passes the request capture into the single-playlist upsert boundary', () => { + const source = readWorkerSource('database.worker.ts'); + const upsertCase = source.slice( + source.indexOf("case 'DB_UPSERT_APP_PLAYLIST':"), + source.indexOf("case 'DB_UPSERT_APP_PLAYLISTS':") + ); + + expect(upsertCase).toContain('captureWorkerPerformancePhase'); + expect(upsertCase).toContain('performanceCapture'); + expect(upsertCase).toContain('upsertAppPlaylist'); + }); + + it('passes async read and sync deserialize capture into the single-playlist GET boundary', () => { + const source = readWorkerSource('database.worker.ts'); + const getCase = source.slice( + source.indexOf("case 'DB_GET_APP_PLAYLIST':"), + source.indexOf("case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS':") + ); + + expect(getCase).toContain('captureWorkerPerformancePhaseAsync'); + expect(getCase).toContain('captureWorkerPerformancePhase'); + expect(getCase).toContain('performanceCapture'); + expect(getCase).toContain('getAppPlaylist'); + }); + + it('preserves success and business errors when the finish seam throws once', async () => { + const successHarness = createFakeRuntime(); + const successCapture = startWorkerPerformanceCapture({ + enabled: true, + runtime: successHarness.runtime, + }); + await armWorkerPerformanceCapture(successCapture); + const failingSuccessFinish = jest.fn(async () => { + throw new Error('finish failed'); + }); + + const success = await executeWithWorkerPerformanceCapture( + successCapture, + async () => 'business-result', + failingSuccessFinish + ); + + expect(success).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + expect(failingSuccessFinish).toHaveBeenCalledTimes(1); + + const errorHarness = createFakeRuntime(); + const errorCapture = startWorkerPerformanceCapture({ + enabled: true, + runtime: errorHarness.runtime, + }); + await armWorkerPerformanceCapture(errorCapture); + const businessError = new Error('business failed'); + const failingErrorFinish = jest.fn(async () => { + throw new Error('finish failed'); + }); + + const failure = await executeWithWorkerPerformanceCapture( + errorCapture, + async () => { + throw businessError; + }, + failingErrorFinish + ); + + expect(failure).toMatchObject({ + error: businessError, + result: undefined, + success: false, + }); + expect(failingErrorFinish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts new file mode 100644 index 000000000..f8da24464 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts @@ -0,0 +1,245 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +import { + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { + captureWorkerPerformancePhase, + captureWorkerPerformancePhaseAsync, +} from './worker-performance-phase'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +describe('worker request performance phases', () => { + it('executes directly without phase allocation or metadata work when disabled', () => { + const resultIdentity = { success: true }; + const execute = jest.fn(() => resultIdentity); + const metadata = jest.fn(() => { + throw new Error('metadata must remain untouched'); + }); + + const result = captureWorkerPerformancePhase( + null, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + execute, + metadata + ); + + expect(result).toBe(resultIdentity); + expect(execute).toHaveBeenCalledTimes(1); + expect(metadata).not.toHaveBeenCalled(); + }); + + it('adds ordered request-scoped pairs to the existing performance envelope', async () => { + const harness = createFakeRuntime(); + const monotonicValues = [10, 17, 20, 31]; + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-request-1', + runtime: { + ...harness.runtime, + readMonotonicMs: () => monotonicValues.shift() ?? 31, + }, + }); + const rowIdentity = { payload: '{"playlist":"synthetic"}' }; + + expect( + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + () => rowIdentity, + () => + ({ + byteCount: 24, + itemCount: 100_000, + secret: 'must-not-be-copied', + }) as PerformancePhaseMetadata + ) + ).toBe(rowIdentity); + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + () => undefined, + () => ({ byteCount: 24, itemCount: 1 }) + ); + + const result = await finishWorkerPerformanceCapture(capture); + + expect(result?.phaseEvents).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 110, + metadata: undefined, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + requestId: 'db-request-1', + }, + { + boundary: 'end', + durationMs: 7, + epochMs: 140, + metadata: { byteCount: 24, itemCount: 100_000 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + requestId: 'db-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 145, + metadata: undefined, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + requestId: 'db-request-1', + }, + { + boundary: 'end', + durationMs: 11, + epochMs: 149, + metadata: { byteCount: 24, itemCount: 1 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + requestId: 'db-request-1', + }, + ]); + expect(JSON.stringify(result?.phaseEvents)).not.toContain('secret'); + }); + + it('preserves exact error identity and still closes the phase pair', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-request-error', + runtime: harness.runtime, + }); + const businessError = new Error('sqlite write failed'); + + expect(() => + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + () => { + throw businessError; + }, + () => { + throw new Error('metadata failed'); + } + ) + ).toThrow(businessError); + + const result = await finishWorkerPerformanceCapture(capture); + expect( + result?.phaseEvents?.map(({ boundary, metadata }) => ({ + boundary, + metadata, + })) + ).toEqual([ + { boundary: 'start', metadata: undefined }, + { boundary: 'end', metadata: undefined }, + ]); + }); + + it('returns the exact business promise without metadata work when async capture is disabled', async () => { + const resultIdentity = { rows: 1 }; + const businessPromise = Promise.resolve(resultIdentity); + const execute = jest.fn(() => businessPromise); + const metadata = jest.fn(() => { + throw new Error('metadata must remain untouched'); + }); + + const result = captureWorkerPerformancePhaseAsync( + null, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + execute, + metadata + ); + + expect(result).toBe(businessPromise); + await expect(result).resolves.toBe(resultIdentity); + expect(execute).toHaveBeenCalledTimes(1); + expect(metadata).not.toHaveBeenCalled(); + }); + + it('closes an async phase only after the awaited operation settles', async () => { + const harness = createFakeRuntime(); + const monotonicValues = [10, 27]; + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-get-request', + runtime: { + ...harness.runtime, + readMonotonicMs: () => monotonicValues.shift() ?? 27, + }, + }); + const rows = [{ id: 'synthetic-playlist' }]; + let resolveRows!: (value: typeof rows) => void; + const pendingRows = new Promise((resolve) => { + resolveRows = resolve; + }); + + const result = captureWorkerPerformancePhaseAsync( + capture, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + () => pendingRows, + (value) => ({ itemCount: value.length }) + ); + + expect(capture?.phaseEvents).toHaveLength(1); + resolveRows(rows); + await expect(result).resolves.toBe(rows); + expect(capture?.phaseEvents).toHaveLength(2); + + const performance = await finishWorkerPerformanceCapture(capture); + expect(performance?.phaseEvents).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 110, + metadata: undefined, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + requestId: 'db-get-request', + }, + { + boundary: 'end', + durationMs: 17, + epochMs: 140, + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + requestId: 'db-get-request', + }, + ]); + }); + + it('preserves async rejection identity and closes the phase without metadata', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-get-error', + runtime: harness.runtime, + }); + const businessError = new Error('sqlite read failed'); + + await expect( + captureWorkerPerformancePhaseAsync( + capture, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + () => Promise.reject(businessError), + () => { + throw new Error('metadata must not run for errors'); + } + ) + ).rejects.toBe(businessError); + + const result = await finishWorkerPerformanceCapture(capture); + expect( + result?.phaseEvents?.map(({ boundary, metadata }) => ({ + boundary, + metadata, + })) + ).toEqual([ + { boundary: 'start', metadata: undefined }, + { boundary: 'end', metadata: undefined }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase.ts b/apps/electron-backend/src/app/workers/worker-performance-phase.ts new file mode 100644 index 000000000..85fca3209 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-phase.ts @@ -0,0 +1,172 @@ +import { + PERFORMANCE_PHASE_BOUNDARY, + type AppPlaylistPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import type { WorkerPerformanceCapture } from './worker-performance-capture.model'; +import { safeReadEpochMs } from './worker-performance-capture.runtime'; + +export function captureWorkerPerformancePhase( + capture: WorkerPerformanceCapture | null, + phase: AppPlaylistPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata +): TResult { + if (!capture || !capture.requestId) { + return execute(); + } + + const startMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId: capture.requestId, + }); + + try { + const result = execute(); + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)), + phase, + requestId: capture.requestId, + }); + return result; + } catch (error) { + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId: capture.requestId, + }); + throw error; + } +} + +export function captureWorkerPerformancePhaseAsync( + capture: WorkerPerformanceCapture | null, + phase: AppPlaylistPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata +): Promise { + const requestId = capture?.requestId; + if (!capture || !requestId) { + return execute(); + } + + return captureAsync(capture, requestId, phase, execute, metadata); +} + +async function captureAsync( + capture: WorkerPerformanceCapture, + requestId: string, + phase: AppPlaylistPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata +): Promise { + const startMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId, + }); + + try { + const result = await execute(); + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: sanitizeMetadata( + safelyCreateMetadata(metadata, result) + ), + phase, + requestId, + }); + return result; + } catch (error) { + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId, + }); + throw error; + } +} + +function safeReadMonotonicMs(capture: WorkerPerformanceCapture): number { + try { + const value = capture.runtime.readMonotonicMs(); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +} + +function safeDuration(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function safelyCreateMetadata( + createMetadata: ((result: TResult) => PerformancePhaseMetadata) | undefined, + result: TResult +): PerformancePhaseMetadata | undefined { + if (!createMetadata) { + return undefined; + } + try { + return createMetadata(result); + } catch { + return undefined; + } +} + +function sanitizeMetadata( + metadata: PerformancePhaseMetadata | undefined +): PerformancePhaseMetadata | undefined { + if (!metadata) { + return undefined; + } + + const sanitized: { byteCount?: number; itemCount?: number } = {}; + if (isCount(metadata.byteCount)) { + sanitized.byteCount = metadata.byteCount; + } + if (isCount(metadata.itemCount)) { + sanitized.itemCount = metadata.itemCount; + } + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function isCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function appendPhaseEvent( + capture: WorkerPerformanceCapture, + event: PerformancePhaseEvent +): void { + try { + capture.phaseEvents.push(event); + } catch { + // Phase instrumentation is deliberately fail-neutral. + } +} diff --git a/apps/remote-control-web/project.json b/apps/remote-control-web/project.json index 5819a8bf5..8c65582c8 100644 --- a/apps/remote-control-web/project.json +++ b/apps/remote-control-web/project.json @@ -49,6 +49,11 @@ ], "outputHashing": "all" }, + "electron-performance": { + "optimization": true, + "outputHashing": "all", + "sourceMap": true + }, "development": { "optimization": false, "extractLicenses": false, @@ -63,6 +68,12 @@ "command": "pnpm nx run remote-control-web:build:development" } }, + "build-performance": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run remote-control-web:build:electron-performance" + } + }, "serve": { "continuous": true, "executor": "@angular/build:dev-server", diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 6c899ad57..cf6ce6a58 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -50,6 +50,7 @@ Then in IPTVnator, add a new Stalker portal: | `00:1A:79:00:00:04` | **is-series** | 60% of VOD items have `is_series=1` — tests the Ministra lazy-season flow | | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | | `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list | +| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing | | `` | **auto** | MAC bytes choose the seed for an isolated generated catalog | ## Configuration @@ -85,7 +86,12 @@ All endpoints are served at `GET /portal.php?action=&...` matching the r ## Cover Images -Cover images and logos use [Picsum Photos](https://picsum.photos) (e.g. `https://picsum.photos/seed/{id}/300/200`). These are real images served from a CDN — no local setup required, but an internet connection is needed for images to display. +Generated scenarios use [Picsum Photos](https://picsum.photos) for cover images +and logos, so they need an internet connection to display artwork. The +`marketing-demo` scenario instead uses the committed, screenshot-safe poster +catalog shared with the Xtream mock. Stalker serves those PNGs itself from +`/assets/marketing/poster/.png`, so screenshots remain deterministic and +offline once the repository is checked out. ## Stream URLs diff --git a/apps/stalker-mock-server/jest.config.ts b/apps/stalker-mock-server/jest.config.ts index fd668350c..a37e222d9 100644 --- a/apps/stalker-mock-server/jest.config.ts +++ b/apps/stalker-mock-server/jest.config.ts @@ -12,6 +12,6 @@ export default { '^(\\.{1,2}/.*)\\.js$': '$1', '^@faker-js/faker$': '/src/test-stubs/faker.ts', }, - moduleFileExtensions: ['ts', 'js'], + moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/stalker-mock-server', }; diff --git a/apps/stalker-mock-server/project.json b/apps/stalker-mock-server/project.json index cf9baa8bb..28a7c204e 100644 --- a/apps/stalker-mock-server/project.json +++ b/apps/stalker-mock-server/project.json @@ -9,7 +9,7 @@ "continuous": true, "executor": "nx:run-commands", "options": { - "command": "pnpm tsx apps/stalker-mock-server/src/main.ts", + "command": "pnpm tsx --tsconfig tsconfig.base.json apps/stalker-mock-server/src/main.ts", "cwd": "{workspaceRoot}", "color": true, "env": { @@ -22,7 +22,7 @@ "continuous": true, "executor": "nx:run-commands", "options": { - "command": "pnpm tsx watch apps/stalker-mock-server/src/main.ts", + "command": "pnpm tsx watch --tsconfig tsconfig.base.json apps/stalker-mock-server/src/main.ts", "cwd": "{workspaceRoot}", "color": true, "env": { @@ -44,7 +44,8 @@ ], "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { - "jestConfig": "apps/stalker-mock-server/jest.config.ts" + "jestConfig": "apps/stalker-mock-server/jest.config.ts", + "tsConfig": "apps/stalker-mock-server/tsconfig.spec.json" } } } diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index cd46f306b..dbc8cbaf5 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -1,4 +1,8 @@ import { faker } from '@faker-js/faker'; +import { + MarketingMovieCategoryKey, + POSTER_SHOWCASE_MOVIES, +} from '@iptvnator/shared/marketing-fixtures'; import { ScenarioConfig } from './scenarios.js'; // --------------------------------------------------------------------------- @@ -115,6 +119,8 @@ export interface GeneratedPortalData { channels: Map; // categoryId -> channels radio: Map; // categoryId -> radio stations vod: Map; // categoryId -> items + /** Preserves the provider-neutral fixture order for the "*" VOD listing. */ + vodOrder?: RawVodItem[]; series: Map; // categoryId -> items seasons: Map; // seriesItemId -> seasons epg: Map; // channelId -> programs @@ -146,6 +152,58 @@ function logoUrl(seed: string): string { return `https://picsum.photos/seed/logo-${seed}/100/100`; } +const STALKER_MARKETING_VOD_CATEGORIES: RawCategory[] = [ + { id: '2901', title: 'Action & Mystery', alias: 'action_mystery' }, + { id: '2902', title: 'Cosmic & Future Worlds', alias: 'future_worlds' }, + { id: '2903', title: 'Family & Comedy', alias: 'family_comedy' }, + { + id: '2904', + title: 'Documentary & Drama', + alias: 'documentary_drama', + }, +]; + +const STALKER_MARKETING_VOD_CATEGORY_IDS: Record< + MarketingMovieCategoryKey, + string +> = { + 'action-mystery': '2901', + 'future-fantasy': '2902', + 'family-comedy': '2903', + 'drama-documentary': '2904', +}; + +function generateMarketingVodItems(): RawVodItem[] { + const assetBaseUrl = '/assets/marketing/poster'; + + return POSTER_SHOWCASE_MOVIES.map((movie, index) => { + const id = String(29_000 + index); + const posterUrl = `${assetBaseUrl}/${movie.slug}.png`; + const rating = movie.rating.toFixed(1); + + return { + id, + name: movie.name, + o_name: movie.name, + title: movie.name, + cmd: `ffrt4://vod/${id}/index.m3u8`, + screenshot_uri: posterUrl, + cover: posterUrl, + description: `${movie.tagline} ${movie.description}`, + actors: movie.actors, + director: movie.director, + year: String(movie.year), + genre: movie.genre, + genres_str: movie.genre, + rating_imdb: rating, + rating_kinopoisk: rating, + category_id: STALKER_MARKETING_VOD_CATEGORY_IDS[movie.categoryKey], + is_series: 0, + has_files: 1, + }; + }); +} + // --------------------------------------------------------------------------- // Generator // --------------------------------------------------------------------------- @@ -205,20 +263,39 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData } // ------ VOD categories + items ------ - data.vodCategories = generateCategories('vod', config.categoryCount.vod); - let vodIndex = 0; - for (const cat of data.vodCategories) { - const items = generateVodItems( - cat.id, - config.itemsPerCategory, - vodIndex, - config.isSeriesFraction, - config.embeddedSeriesFraction, - config.seasonsPerSeries, - config.episodesPerSeason + if (config.marketingFixture) { + data.vodCategories = STALKER_MARKETING_VOD_CATEGORIES.map( + (category) => ({ ...category }) ); - data.vod.set(cat.id, items); - vodIndex += config.itemsPerCategory; + const marketingVodItems = generateMarketingVodItems(); + data.vodOrder = marketingVodItems; + for (const category of data.vodCategories) { + data.vod.set( + category.id, + marketingVodItems.filter( + (item) => item.category_id === category.id + ) + ); + } + } else { + data.vodCategories = generateCategories( + 'vod', + config.categoryCount.vod + ); + let vodIndex = 0; + for (const cat of data.vodCategories) { + const items = generateVodItems( + cat.id, + config.itemsPerCategory, + vodIndex, + config.isSeriesFraction, + config.embeddedSeriesFraction, + config.seasonsPerSeries, + config.episodesPerSeason + ); + data.vod.set(cat.id, items); + vodIndex += config.itemsPerCategory; + } } // ------ Series categories + items ------ diff --git a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts index 0a60127bd..ea6c7bb26 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts @@ -68,8 +68,12 @@ export function handleGetOrderedList(req: Request, res: Response): void { } else { // vod (default) if (categoryId === '*') { - for (const items of data.vod.values()) { - allItems.push(...items); + if (data.vodOrder) { + allItems.push(...data.vodOrder); + } else { + for (const items of data.vod.values()) { + allItems.push(...items); + } } } else { allItems = data.vod.get(categoryId) ?? []; diff --git a/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts b/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts new file mode 100644 index 000000000..e8055efc9 --- /dev/null +++ b/apps/stalker-mock-server/src/app/marketing-poster-url.spec.ts @@ -0,0 +1,65 @@ +import { + buildRequestOrigin, + resolveMarketingPosterUrls, +} from './marketing-poster-url'; + +type RequestLike = Parameters[0]; + +function request( + protocol: string, + headers: Record +): RequestLike { + return { + protocol, + get: (name: string) => headers[name.toLowerCase()], + }; +} + +describe('marketing poster URLs', () => { + it('uses the forwarded public origin for relative poster paths', () => { + const req = request('http', { + host: 'stalker-mock:3210', + 'x-forwarded-host': 'demo.example:9443', + 'x-forwarded-proto': 'https', + }); + const payload = { + js: { + data: [ + { + cover: '/assets/marketing/poster/black-harbor.png', + screenshot_uri: + '/assets/marketing/poster/black-harbor.png', + }, + ], + }, + }; + + expect( + resolveMarketingPosterUrls(payload, buildRequestOrigin(req)) + ).toEqual({ + js: { + data: [ + { + cover: 'https://demo.example:9443/assets/marketing/poster/black-harbor.png', + screenshot_uri: + 'https://demo.example:9443/assets/marketing/poster/black-harbor.png', + }, + ], + }, + }); + }); + + it('preserves unrelated URLs and values', () => { + const payload = { + cover: 'https://picsum.photos/seed/movie/300/450', + count: 1, + }; + + expect( + resolveMarketingPosterUrls( + payload, + buildRequestOrigin(request('http', { host: 'localhost:3210' })) + ) + ).toEqual(payload); + }); +}); diff --git a/apps/stalker-mock-server/src/app/marketing-poster-url.ts b/apps/stalker-mock-server/src/app/marketing-poster-url.ts new file mode 100644 index 000000000..5ef73e4a9 --- /dev/null +++ b/apps/stalker-mock-server/src/app/marketing-poster-url.ts @@ -0,0 +1,51 @@ +type RequestOriginSource = { + protocol: string; + get(name: string): string | undefined; +}; + +const MARKETING_POSTER_PATH_PREFIX = '/assets/marketing/poster/'; + +export function buildRequestOrigin(request: RequestOriginSource): string { + const protocol = + firstForwardedValue(request.get('x-forwarded-proto')) ?? + request.protocol; + const host = + firstForwardedValue(request.get('x-forwarded-host')) ?? + request.get('host'); + + return host ? `${protocol}://${host}` : ''; +} + +export function resolveMarketingPosterUrls( + payload: unknown, + origin: string +): unknown { + if ( + typeof payload === 'string' && + payload.startsWith(MARKETING_POSTER_PATH_PREFIX) + ) { + return origin ? new URL(payload, origin).href : payload; + } + + if (Array.isArray(payload)) { + return payload.map((item) => resolveMarketingPosterUrls(item, origin)); + } + + if (payload && typeof payload === 'object') { + return Object.fromEntries( + Object.entries(payload).map(([key, value]) => [ + key, + resolveMarketingPosterUrls(value, origin), + ]) + ); + } + + return payload; +} + +function firstForwardedValue(value: string | undefined): string | undefined { + return value + ?.split(',', 1)[0] + ?.trim() + .replace(/^(?:https?):\/\//, ''); +} diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index 74c271dd6..ec1c4b6b8 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -21,6 +21,8 @@ export interface ScenarioConfig { * `get_ordered_list` crawling (legacy portals). */ supportsGetAllChannels?: boolean; + /** Replace generated VOD with the shared screenshot-safe poster catalog. */ + marketingFixture?: true; } /** @@ -107,6 +109,18 @@ export const SCENARIOS: Record = { embeddedSeriesFraction: 0, supportsGetAllChannels: false, }, + '00:1a:79:00:00:07': { + name: 'marketing-demo', + description: 'Screenshot-safe portal with 35 original poster movies', + seed: 7007, + categoryCount: { itv: 4, radio: 4, vod: 4, series: 4 }, + itemsPerCategory: 6, + seasonsPerSeries: 2, + episodesPerSeason: 5, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + marketingFixture: true, + }, }; /** diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 9f1447111..5b43ca6f7 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -1,9 +1,57 @@ import http from 'http'; +import { join } from 'node:path'; +import express, { Response } from 'express'; import { SCENARIOS } from './app/scenarios.js'; +import { + buildRequestOrigin, + resolveMarketingPosterUrls, +} from './app/marketing-poster-url.js'; import { createStalkerMockApp } from './app.js'; const PORT = parseInt(process.env['PORT'] ?? '3210', 10); -const app = createStalkerMockApp(); +const app = express(); +const MARKETING_POSTER_DIRECTORY = join( + process.cwd(), + 'apps/xtream-mock-server/public/marketing/poster' +); + +// Marketing fixtures store deployment-neutral asset paths. Resolve them +// against the public origin of each request, including reverse-proxy headers. +app.use((req, res, next) => { + if ( + !['get_ordered_list', 'favorites'].includes( + String(req.query['action']) + ) + ) { + next(); + return; + } + + const sendJson = res.json.bind(res); + const requestOrigin = buildRequestOrigin(req); + + res.json = ((body: unknown) => + sendJson( + resolveMarketingPosterUrls(body, requestOrigin) + )) as Response['json']; + next(); +}); + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +// Serve the shared screenshot-safe poster catalog directly from this process. +app.use( + '/assets/marketing/poster', + express.static(MARKETING_POSTER_DIRECTORY, { + fallthrough: false, + immutable: true, + maxAge: '1y', + }) +); + +app.use(createStalkerMockApp()); // --------------------------------------------------------------------------- // Start @@ -61,7 +109,7 @@ server.listen(PORT, () => { } console.log(''); console.log( - ' Any other MAC generates deterministic unique data from MAC bytes.' + ' Any other MAC gets an isolated catalog from a MAC-derived seed.' ); console.log(` Utilities:`); console.log(` GET http://localhost:${PORT}/health`); diff --git a/apps/stalker-mock-server/tsconfig.spec.json b/apps/stalker-mock-server/tsconfig.spec.json index 40ded9a0f..1558bd610 100644 --- a/apps/stalker-mock-server/tsconfig.spec.json +++ b/apps/stalker-mock-server/tsconfig.spec.json @@ -7,5 +7,10 @@ "moduleResolution": "node10", "types": ["jest", "node"] }, - "include": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] } diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index fe4335511..d218a0165 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -42,7 +42,6 @@ export default { '^shaka-player$': '/src/test-stubs/shaka-player.js', '^video.js$': '/src/test-stubs/video-js.js', '^rxjs': '/../../node_modules/rxjs/dist/bundles/rxjs.umd.js', - '^uuid$': '/../../node_modules/uuid/wrapper.mjs', }, transform: angularEsmPreset.transform, transformIgnorePatterns: [], diff --git a/apps/web/project.json b/apps/web/project.json index f7b179709..302ea2e37 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -13,7 +13,7 @@ "targets": { "build": { "executor": "@angular/build:application", - "outputs": ["{options.outputPath}"], + "outputs": ["{workspaceRoot}/dist/apps/web"], "options": { "outputPath": { "base": "dist/apps/web", @@ -122,6 +122,27 @@ "extractLicenses": false, "sourceMap": true }, + "electron-performance": { + "baseHref": "./", + "serviceWorker": false, + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false, + "removeSpecialComments": true + }, + "fonts": true + }, + "outputHashing": "all", + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/environment.ts", + "with": "apps/web/src/environments/environment.prod.ts" + } + ] + }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, @@ -138,6 +159,12 @@ "command": "pnpm nx run web:build:electron-e2e" } }, + "build-performance": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run web:build:electron-performance" + } + }, "serve": { "continuous": true, "executor": "@angular/build:dev-server", diff --git a/apps/web/src/app/services/electron.service.performance.spec.ts b/apps/web/src/app/services/electron.service.performance.spec.ts new file mode 100644 index 000000000..a7e10ffe2 --- /dev/null +++ b/apps/web/src/app/services/electron.service.performance.spec.ts @@ -0,0 +1,123 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { SettingsStore } from '@iptvnator/services'; +import { + PLAYLIST_PARSE_BY_URL, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { DialogService } from '@iptvnator/ui/components'; +import { ElectronService } from './electron.service'; + +describe('ElectronService performance phases', () => { + const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + const electronBridge = { + fetchPlaylistByUrl: jest.fn(), + onPlayerError: jest.fn(), + }; + const store = { dispatch: jest.fn() }; + let service: ElectronService; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: electronBridge, + }); + electronBridge.fetchPlaylistByUrl.mockReset(); + electronBridge.onPlayerError.mockReset(); + store.dispatch.mockReset(); + + TestBed.configureTestingModule({ + providers: [ + ElectronService, + { + provide: DialogService, + useValue: { openConfirmDialog: jest.fn() }, + }, + { + provide: MatSnackBar, + useValue: { open: jest.fn() }, + }, + { + provide: SettingsStore, + useValue: { + getTrustOptions: jest.fn(() => ({ + trustedInsecureTlsHosts: [], + trustedPrivateNetworkEpgUrls: [], + })), + }, + }, + { provide: Store, useValue: store }, + { + provide: TranslateService, + useValue: { instant: jest.fn((key: string) => key) }, + }, + ], + }); + service = TestBed.inject(ElectronService); + }); + + afterEach(() => { + delete (globalThis as unknown as Record)[hookSymbol]; + Object.defineProperty(window, 'electron', { + configurable: true, + value: undefined, + }); + }); + + it('marks the initial URL import store dispatch without exposing playlist data', async () => { + const events: RendererPerformancePhaseEvent[] = []; + ( + globalThis as unknown as Record< + symbol, + (event: RendererPerformancePhaseEvent) => void + > + )[hookSymbol] = (event) => events.push(event); + const playlist = { + playlist: { items: [{ name: 'Sensitive channel' }] }, + title: 'Sensitive title', + url: 'https://user:secret@example.test/list.m3u', + } as Playlist; + electronBridge.fetchPlaylistByUrl.mockResolvedValue(playlist); + + await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { + title: playlist.title, + url: playlist.url, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.handleAddingPlaylistByUrl({ + isTemporary: false, + playlist, + }) + ); + expect( + events.map(({ boundary, outcome, phase }) => ({ + boundary, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + outcome: undefined, + phase: 'store.m3u-import-dispatch', + }, + { + boundary: 'end', + outcome: 'success', + phase: 'store.m3u-import-dispatch', + }, + ]); + expect(JSON.stringify(events)).not.toContain('Sensitive'); + expect(JSON.stringify(events)).not.toContain('secret'); + }); +}); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index dab07c825..acb233edb 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -21,6 +21,10 @@ import { XTREAM_RESPONSE, XtreamCodeActions, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { AppConfig } from '../../environments/environment'; import { buildAutoUpdatePlaylistsFeedback } from './auto-update-playlists-feedback'; import { @@ -330,11 +334,15 @@ export class ElectronService extends DataService { this.settingsStore.getTrustOptions() ) .then((result) => { - this.store.dispatch( - PlaylistActions.handleAddingPlaylistByUrl({ - isTemporary: !!payload?.isTemporary, - playlist: result, - }) + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.M3U_IMPORT_DISPATCH, + () => + this.store.dispatch( + PlaylistActions.handleAddingPlaylistByUrl({ + isTemporary: !!payload?.isTemporary, + playlist: result, + }) + ) ); }) .catch((error: unknown) => { diff --git a/apps/web/src/app/settings/settings-app-update.facade.spec.ts b/apps/web/src/app/settings/settings-app-update.facade.spec.ts new file mode 100644 index 000000000..79fa59389 --- /dev/null +++ b/apps/web/src/app/settings/settings-app-update.facade.spec.ts @@ -0,0 +1,270 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { DataService } from '@iptvnator/services'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { ElectronServiceStub } from '../services/electron.service.stub'; +import { SettingsService } from '../services/settings.service'; +import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component'; +import { SettingsAppUpdateFacade } from './settings-app-update.facade'; +import { + createElectronStub, + DEFAULT_APP_UPDATE_STATUS, + MockSettingsService, +} from './test-stubs/settings-test-harness.stub'; + +/** The retry loop is deliberately private; tests drive it directly. */ +interface SettingsAppUpdateFacadePrivateTestApi { + loadStatus(): Promise; + waitForRetry(): Promise; +} + +describe('SettingsAppUpdateFacade', () => { + let facade: SettingsAppUpdateFacade; + let matDialog: MatDialog; + let dataService: DataService; + let translate: TranslateService; + const originalElectron = window.electron; + + /** Lets the facade's async status load settle */ + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + const privateApi = (): SettingsAppUpdateFacadePrivateTestApi => + facade as unknown as SettingsAppUpdateFacadePrivateTestApi; + + beforeEach(() => { + window.electron = createElectronStub(); + + TestBed.configureTestingModule({ + providers: [ + SettingsAppUpdateFacade, + { provide: DataService, useClass: ElectronServiceStub }, + MockProvider(MatDialog, { open: jest.fn() }), + { provide: SettingsService, useClass: MockSettingsService }, + ], + imports: [TranslateModule.forRoot()], + }); + + facade = TestBed.inject(SettingsAppUpdateFacade); + matDialog = TestBed.inject(MatDialog); + dataService = TestBed.inject(DataService); + translate = TestBed.inject(TranslateService); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + it('loads the desktop app update status and subscribes to status pushes', async () => { + const pushedStatus: ElectronBridgeAppUpdateStatus = { + ...DEFAULT_APP_UPDATE_STATUS, + latestVersion: '0.23.0', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, + }; + + facade.init(); + await flush(); + + const statusHandler = ( + window.electron.onAppUpdateStatusChange as jest.Mock + ).mock.calls[0][0] as (status: ElectronBridgeAppUpdateStatus) => void; + statusHandler(pushedStatus); + + expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1); + expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes( + 1 + ); + expect(facade.status()).toEqual(pushedStatus); + }); + + it('stops listening for status pushes once disposed', () => { + const unsubscribe = jest.fn(); + (window.electron.onAppUpdateStatusChange as jest.Mock).mockReturnValue( + unsubscribe + ); + + facade.init(); + facade.dispose(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('retries the initial app update status load when IPC handlers are still starting', async () => { + const retriedStatus: ElectronBridgeAppUpdateStatus = { + ...DEFAULT_APP_UPDATE_STATUS, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, + supportedSelfUpdate: false, + }; + (window.electron.getAppUpdateStatus as jest.Mock) + .mockReset() + .mockRejectedValueOnce(new Error('No handler registered')) + .mockResolvedValueOnce(retriedStatus); + jest.spyOn(privateApi(), 'waitForRetry').mockResolvedValue(undefined); + + await privateApi().loadStatus(); + + expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(2); + expect(facade.status()).toEqual(retriedStatus); + }); + + it('waits for the desktop app update bridge method before loading status', async () => { + const retriedStatus: ElectronBridgeAppUpdateStatus = { + ...DEFAULT_APP_UPDATE_STATUS, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, + supportedSelfUpdate: false, + }; + delete (window.electron as Partial) + .getAppUpdateStatus; + let retryCount = 0; + const getStatus = jest.fn().mockResolvedValue(retriedStatus); + jest.spyOn(privateApi(), 'waitForRetry').mockImplementation( + async () => { + retryCount += 1; + + if (retryCount === 1) { + window.electron.getAppUpdateStatus = getStatus; + } + } + ); + + await privateApi().loadStatus(); + + expect(retryCount).toBe(1); + expect(getStatus).toHaveBeenCalledTimes(1); + expect(facade.status()).toEqual(retriedStatus); + }); + + it('forwards app update actions to the desktop bridge', async () => { + await facade.checkForAppUpdate(); + await facade.downloadAppUpdate(); + await facade.installAppUpdate(); + + expect(window.electron.checkForAppUpdate).toHaveBeenCalledTimes(1); + expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1); + expect(window.electron.installAppUpdate).toHaveBeenCalledTimes(1); + }); + + it('opens the manual release URL from unsupported update status', () => { + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + facade.status.set({ + ...DEFAULT_APP_UPDATE_STATUS, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported, + supportedSelfUpdate: false, + }); + + facade.openManualAppUpdate(); + + expect(openSpy).toHaveBeenCalledWith( + DEFAULT_APP_UPDATE_STATUS.manualDownloadUrl, + '_blank', + 'noreferrer' + ); + }); + + it('opens release notes dialog for the latest update version', () => { + const openSpy = jest.spyOn(matDialog, 'open').mockReturnValue({ + afterClosed: () => of(false), + } as unknown as ReturnType); + facade.status.set({ + ...DEFAULT_APP_UPDATE_STATUS, + latestVersion: '0.23.0', + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available, + }); + + facade.openReleaseNotes(); + + expect(openSpy).toHaveBeenCalledWith( + AppUpdateReleaseNotesDialogComponent, + expect.objectContaining({ + data: { + initialVersion: '0.23.0', + }, + }) + ); + }); + + it('opens release notes dialog for the current version when no update is available', () => { + const openSpy = jest.spyOn(matDialog, 'open').mockReturnValue({ + afterClosed: () => of(false), + } as unknown as ReturnType); + facade.status.set({ + ...DEFAULT_APP_UPDATE_STATUS, + latestVersion: '0.21.0', + release: { + version: '0.21.0', + }, + status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable, + }); + + facade.openReleaseNotes(); + + expect(openSpy).toHaveBeenCalledWith( + AppUpdateReleaseNotesDialogComponent, + expect.objectContaining({ + data: { + initialVersion: DEFAULT_APP_UPDATE_STATUS.currentVersion, + fallbackToLatest: true, + }, + }) + ); + }); + + describe('Version check', () => { + const latestVersion = '1.0.0'; + const currentVersion = '0.1.0'; + + beforeEach(() => { + const settingsService = TestBed.inject(SettingsService); + (settingsService.getAppVersion as jest.Mock).mockReturnValue( + of(latestVersion) + ); + + jest.spyOn(translate, 'instant').mockImplementation((key) => { + if (key === 'SETTINGS.NEW_VERSION_AVAILABLE') { + return 'New version available'; + } + if (key === 'SETTINGS.LATEST_VERSION') { + return 'Latest version installed'; + } + return key; + }); + }); + + it('should return true if version is outdated', () => { + jest.spyOn(dataService, 'getAppVersion').mockReturnValue( + currentVersion + ); + + expect(facade.isCurrentVersionOutdated(latestVersion)).toBe(true); + }); + + it('should update notification message if version is outdated', () => { + jest.spyOn(dataService, 'getAppVersion').mockReturnValue( + currentVersion + ); + + facade.showVersionInformation(latestVersion); + + expect(translate.instant).toHaveBeenCalledWith( + 'SETTINGS.NEW_VERSION_AVAILABLE' + ); + expect(facade.updateMessage()).toBe('New version available: 1.0.0'); + }); + + it('reports the installed version as current when it matches', () => { + jest.spyOn(dataService, 'getAppVersion').mockReturnValue( + latestVersion + ); + + facade.showVersionInformation(latestVersion); + + expect(facade.updateMessage()).toBe('Latest version installed'); + expect(facade.version()).toBe(latestVersion); + }); + }); +}); diff --git a/apps/web/src/app/settings/settings-app-update.facade.ts b/apps/web/src/app/settings/settings-app-update.facade.ts new file mode 100644 index 000000000..05659f6f7 --- /dev/null +++ b/apps/web/src/app/settings/settings-app-update.facade.ts @@ -0,0 +1,218 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { take } from 'rxjs'; +import { SettingsService } from '../services/settings.service'; +import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component'; + +const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 60; +const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250; + +/** + * Owns everything the About section needs to talk about versions: the + * electron-updater status stream and the "is my build outdated" message. + */ +@Injectable() +export class SettingsAppUpdateFacade { + private readonly dataService = inject(DataService); + private readonly matDialog = inject(MatDialog); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsService = inject(SettingsService); + private readonly translate = inject(TranslateService); + + /** Latest updater status, polled once and then pushed by the backend */ + readonly status = signal(null); + + /** Current version of the app */ + readonly version = signal(''); + + /** Update message to show */ + readonly updateMessage = signal(''); + + private unsubscribeStatus: (() => void) | null = null; + + /** Subscribes to status pushes and kicks off the initial status load */ + init(): void { + this.bindStatusEvents(); + void this.loadStatus(); + } + + dispose(): void { + this.unsubscribeStatus?.(); + this.unsubscribeStatus = null; + } + + async checkForAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.checkForAppUpdate) { + return; + } + + this.status.set(await window.electron.checkForAppUpdate()); + } + + async downloadAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.downloadAppUpdate) { + return; + } + + this.status.set(await window.electron.downloadAppUpdate()); + } + + async installAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.installAppUpdate) { + return; + } + + this.status.set(await window.electron.installAppUpdate()); + } + + openManualAppUpdate(): void { + const manualDownloadUrl = this.status()?.manualDownloadUrl; + + if (!manualDownloadUrl) { + return; + } + + window.open(manualDownloadUrl, '_blank', 'noreferrer'); + } + + openReleaseNotes(): void { + const status = this.status(); + const isUpdateRelease = + status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available || + status?.status === + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading || + status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded; + const initialReleaseNotesVersion = isUpdateRelease + ? (status?.latestVersion ?? + status?.release?.version ?? + status?.currentVersion) + : status?.currentVersion; + + this.matDialog.open(AppUpdateReleaseNotesDialogComponent, { + autoFocus: false, + data: { + ...(!isUpdateRelease ? { fallbackToLatest: true } : {}), + initialVersion: initialReleaseNotesVersion, + }, + maxWidth: 'calc(100vw - 32px)', + restoreFocus: true, + width: '720px', + }); + } + + /** + * Checks whether the latest version of the application + * is used and updates the version message in the + * settings UI + */ + checkAppVersion(): void { + this.settingsService + .getAppVersion() + .pipe(take(1)) + .subscribe((version) => this.showVersionInformation(version)); + } + + /** + * Updates the message in settings UI about the used + * version of the app + * @param currentVersion current version of the application + */ + showVersionInformation(currentVersion: string): void { + const isOutdated = this.isCurrentVersionOutdated(currentVersion); + + if (isOutdated) { + this.updateMessage.set( + `${ + this.translate.instant( + 'SETTINGS.NEW_VERSION_AVAILABLE' + ) as string + }: ${currentVersion}` + ); + } else { + this.updateMessage.set( + this.translate.instant('SETTINGS.LATEST_VERSION') + ); + } + } + + /** + * Compares actual with latest version of the + * application + * @param latestVersion latest version + * @returns returns true if an update is available + */ + isCurrentVersionOutdated(latestVersion: string): boolean { + this.version.set(this.dataService.getAppVersion()); + return this.settingsService.isVersionOutdated( + this.version(), + latestVersion + ); + } + + private bindStatusEvents(): void { + if ( + !this.runtime.isElectron || + !window.electron?.onAppUpdateStatusChange + ) { + return; + } + + this.unsubscribeStatus = window.electron.onAppUpdateStatusChange( + (status) => { + this.status.set(status); + } + ); + } + + /** + * The updater IPC handlers can still be registering while the settings + * page mounts, so the first load retries until the bridge answers. + */ + private async loadStatus(): Promise { + if (!this.runtime.isElectron) { + return; + } + + let lastError: unknown; + + for ( + let attempt = 1; + attempt <= APP_UPDATE_STATUS_LOAD_ATTEMPTS; + attempt += 1 + ) { + const electron = window.electron; + + if (electron?.getAppUpdateStatus) { + try { + this.status.set(await electron.getAppUpdateStatus()); + return; + } catch (error) { + lastError = error; + } + } + + if (attempt === APP_UPDATE_STATUS_LOAD_ATTEMPTS) { + console.warn( + 'Failed to load app update status:', + lastError ?? + new Error('Desktop app update bridge is unavailable') + ); + return; + } + + await this.waitForRetry(); + } + } + + private async waitForRetry(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS); + }); + } +} diff --git a/apps/web/src/app/settings/settings-backup-section.component.spec.ts b/apps/web/src/app/settings/settings-backup-section.component.spec.ts new file mode 100644 index 000000000..24bab864c --- /dev/null +++ b/apps/web/src/app/settings/settings-backup-section.component.spec.ts @@ -0,0 +1,50 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { SettingsBackupFacade } from './settings-backup.facade'; +import { SettingsBackupSectionComponent } from './settings-backup-section.component'; + +describe('SettingsBackupSectionComponent', () => { + let fixture: ComponentFixture; + const includeSecrets = signal(false); + + beforeEach(async () => { + includeSecrets.set(false); + await TestBed.configureTestingModule({ + imports: [ + SettingsBackupSectionComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: SettingsBackupFacade, + useValue: { includeSecrets }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsBackupSectionComponent); + fixture.componentRef.setInput('activeSection', 'backup'); + fixture.detectChanges(); + }); + + it('shows redacted-backup guidance and reveals the secret-export warning only when enabled', () => { + const nativeElement = fixture.nativeElement as HTMLElement; + + expect( + nativeElement.querySelector('#backup-redacted-warning')?.textContent + ).toContain('SETTINGS.BACKUP_REDACTED_WARNING'); + expect( + nativeElement.querySelector('#backup-secrets-warning') + ).toBeNull(); + + includeSecrets.set(true); + fixture.detectChanges(); + + expect( + nativeElement.querySelector('#backup-secrets-warning')?.textContent + ).toContain('SETTINGS.BACKUP_SECRETS_WARNING'); + }); +}); diff --git a/apps/web/src/app/settings/settings-backup.facade.spec.ts b/apps/web/src/app/settings/settings-backup.facade.spec.ts new file mode 100644 index 000000000..95fd1a1ae --- /dev/null +++ b/apps/web/src/app/settings/settings-backup.facade.spec.ts @@ -0,0 +1,260 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { + selectAllPlaylistsMeta, + selectIsEpgAvailable, +} from '@iptvnator/m3u-state'; +import { PlaylistBackupService } from '@iptvnator/services'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { SettingsBackupCredentialsDialogComponent } from './settings-backup-credentials-dialog.component'; +import { SettingsBackupFacade } from './settings-backup.facade'; +import { SettingsSnackbarService } from './settings-snackbar.service'; +import { + BACKUP_EXPORT_RESULT, + createElectronStub, + MatSnackBarStub, +} from './test-stubs/settings-test-harness.stub'; + +describe('SettingsBackupFacade', () => { + let facade: SettingsBackupFacade; + let playlistBackupService: PlaylistBackupService; + const originalElectron = window.electron; + + /** The component normally supplies a paint frame; tests skip the wait */ + const noWait = () => Promise.resolve(); + + function configure(): void { + TestBed.configureTestingModule({ + providers: [ + SettingsBackupFacade, + SettingsSnackbarService, + { provide: MatSnackBar, useClass: MatSnackBarStub }, + MockProvider(MatDialog, { open: jest.fn() }), + MockProvider(PlaylistBackupService, { + exportBackup: jest + .fn() + .mockResolvedValue(BACKUP_EXPORT_RESULT), + importBackup: jest.fn(), + }), + provideMockStore({ + selectors: [ + { selector: selectAllPlaylistsMeta, value: [] }, + { selector: selectIsEpgAvailable, value: false }, + ], + }), + ], + imports: [TranslateModule.forRoot()], + }); + + facade = TestBed.inject(SettingsBackupFacade); + playlistBackupService = TestBed.inject(PlaylistBackupService); + } + + beforeEach(() => { + window.electron = createElectronStub(); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + it('shows an export busy state until the backup file has been written', async () => { + configure(); + let resolveExport: (value: typeof BACKUP_EXPORT_RESULT) => void = () => + undefined; + (playlistBackupService.exportBackup as jest.Mock).mockReturnValueOnce( + new Promise((resolve) => { + resolveExport = resolve; + }) + ); + + const exportPromise = facade.exportData(noWait); + + expect(facade.isExportingData()).toBe(true); + + resolveExport(BACKUP_EXPORT_RESULT); + await exportPromise; + + expect(window.electron.saveFileDialog).toHaveBeenCalledWith( + BACKUP_EXPORT_RESULT.defaultFileName, + [ + { + extensions: ['json'], + name: 'JSON', + }, + ] + ); + expect(window.electron.writeFile).toHaveBeenCalledWith( + '/tmp/backup.json', + '{}' + ); + expect(playlistBackupService.exportBackup).toHaveBeenCalledWith({ + includeSecrets: false, + }); + expect(facade.isExportingData()).toBe(false); + }); + + it('includes credentials only after secret export is enabled', async () => { + configure(); + + expect(facade.includeSecrets()).toBe(false); + + facade.includeSecrets.set(true); + await facade.exportData(noWait); + + expect(playlistBackupService.exportBackup).toHaveBeenCalledWith({ + includeSecrets: true, + }); + }); + + it('resolves redacted Xtream credentials through the dialog and reports skipped entries', async () => { + configure(); + const backupJson = JSON.stringify({ + kind: 'iptvnator-playlist-backup', + version: 1, + exportedAt: '2026-04-21T00:00:00.000Z', + includeSecrets: false, + playlists: [], + }); + const file = { + text: jest.fn().mockResolvedValue(backupJson), + } as unknown as File; + const inputClickSpy = jest + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(function (this: HTMLInputElement) { + Object.defineProperty(this, 'files', { + configurable: true, + value: [file], + }); + this.dispatchEvent(new Event('change')); + }); + const matDialog = TestBed.inject(MatDialog); + (matDialog.open as jest.Mock).mockReturnValue({ + afterClosed: () => of(undefined), + }); + ( + playlistBackupService.importBackup as jest.Mock + ).mockImplementationOnce(async (_json, options) => { + const credentials = await options.resolveXtreamCredentials({ + exportedId: 'xtream-redacted', + title: 'Living room', + serverUrl: + 'https://viewer:secret@provider.example:8443/player_api.php?token=private', + }); + + expect(credentials).toBeNull(); + + return { + imported: 0, + merged: 0, + skipped: 1, + failed: 0, + errors: [], + }; + }); + let markImported: () => void = () => undefined; + const importCompleted = new Promise((resolve) => { + markImported = resolve; + }); + const onImported = jest.fn(() => markImported()); + + try { + facade.importData(onImported); + await importCompleted; + + expect(playlistBackupService.importBackup).toHaveBeenCalledWith( + backupJson, + { + resolveXtreamCredentials: expect.any(Function), + } + ); + expect(matDialog.open).toHaveBeenCalledWith( + SettingsBackupCredentialsDialogComponent, + expect.objectContaining({ + data: { + playlistTitle: 'Living room', + serverHost: 'provider.example:8443', + }, + }) + ); + const snackBar = TestBed.inject( + MatSnackBar + ) as unknown as MatSnackBarStub; + expect(snackBar.open).toHaveBeenCalledWith( + expect.stringContaining('1 skipped'), + undefined, + expect.any(Object) + ); + } finally { + inputClickSpy.mockRestore(); + } + }); + + it('falls back to browser backup download when desktop file-save preload is incomplete', async () => { + const saveFileDialog = jest.fn().mockResolvedValue('/tmp/backup.json'); + window.electron = { + platform: 'linux', + saveFileDialog, + } as unknown as typeof window.electron; + configure(); + + const createObjectURL = jest.fn().mockReturnValue('blob:backup'); + const revokeObjectURL = jest.fn(); + const originalCreateObjectURL = window.URL.createObjectURL; + const originalRevokeObjectURL = window.URL.revokeObjectURL; + Object.defineProperty(window.URL, 'createObjectURL', { + configurable: true, + value: createObjectURL, + }); + Object.defineProperty(window.URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectURL, + }); + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(); + + try { + await facade.exportData(noWait); + + expect(saveFileDialog).not.toHaveBeenCalled(); + expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup'); + } finally { + clickSpy.mockRestore(); + Object.defineProperty(window.URL, 'createObjectURL', { + configurable: true, + value: originalCreateObjectURL, + }); + Object.defineProperty(window.URL, 'revokeObjectURL', { + configurable: true, + value: originalRevokeObjectURL, + }); + } + }); + + it('reports a failed export and clears the busy state', async () => { + configure(); + (playlistBackupService.exportBackup as jest.Mock).mockRejectedValueOnce( + new Error('disk full') + ); + jest.spyOn(console, 'error').mockImplementation(); + const snackBar = TestBed.inject( + MatSnackBar + ) as unknown as MatSnackBarStub; + + await facade.exportData(noWait); + + expect(facade.isExportingData()).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'Playlist backup export failed.', + undefined, + expect.objectContaining({ panelClass: ['settings-snackbar'] }) + ); + }); +}); diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts new file mode 100644 index 000000000..cfe738c72 --- /dev/null +++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts @@ -0,0 +1,74 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; + +/** + * Probes the desktop backend for embedded MPV support so the playback + * section can hide options the current build cannot deliver. + */ +@Injectable() +export class SettingsEmbeddedMpvFacade { + private readonly runtime = inject(RuntimeCapabilitiesService); + + readonly support = signal(null); + + readonly supported = computed( + () => this.runtime.isElectron && !!this.support()?.supported + ); + + readonly frameCopyAvailable = computed( + () => this.runtime.isElectron && !!this.support()?.frameCopyAvailable + ); + + readonly frameCopyActive = computed( + () => this.support()?.engine === 'frame-copy' + ); + + async load(): Promise { + if (!this.runtime.isElectron) { + this.support.set({ + supported: false, + platform: 'web', + reason: 'Embedded MPV requires the Electron desktop build.', + }); + return; + } + + if (!window.electron?.getEmbeddedMpvSupport) { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: 'Embedded MPV support is not available in this build.', + }); + return; + } + + try { + this.support.set(await window.electron.getEmbeddedMpvSupport()); + } catch (error) { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Opens the native folder picker for MPV recordings. + * @returns the selected folder, or null when the user cancelled or the + * desktop bridge cannot show the dialog + */ + async selectRecordingFolder(): Promise { + if ( + !this.runtime.isElectron || + !window.electron?.selectEmbeddedMpvRecordingFolder + ) { + return null; + } + + return ( + (await window.electron.selectEmbeddedMpvRecordingFolder()) ?? null + ); + } +} diff --git a/apps/web/src/app/settings/settings-epg.facade.spec.ts b/apps/web/src/app/settings/settings-epg.facade.spec.ts new file mode 100644 index 000000000..dd8683299 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg.facade.spec.ts @@ -0,0 +1,177 @@ +import { TestBed } from '@angular/core/testing'; +import { UntypedFormBuilder } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; +import { DialogService } from '@iptvnator/ui/components'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsService } from '../services/settings.service'; +import { SettingsEpgFacade } from './settings-epg.facade'; +import { SettingsFormFacade } from './settings-form.facade'; +import { SettingsSnackbarService } from './settings-snackbar.service'; +import { + createElectronStub, + createEpgBridgeStub, + MatSnackBarStub, + MockSettingsService, + MockSettingsStore, +} from './test-stubs/settings-test-harness.stub'; + +describe('SettingsEpgFacade', () => { + let facade: SettingsEpgFacade; + let formFacade: SettingsFormFacade; + let epgBridge: Partial; + let epgService: EpgService; + let dialogService: DialogService; + let snackBar: MatSnackBarStub; + let translate: TranslateService; + const originalElectron = window.electron; + + /** The confirm dialog is a mock, so tests trigger its callback directly */ + const confirmImmediately = () => { + (dialogService.openConfirmDialog as jest.Mock).mockImplementation( + ({ onConfirm }: { onConfirm: () => Promise }) => { + void onConfirm(); + } + ); + }; + + beforeEach(() => { + window.electron = createElectronStub(); + epgBridge = createEpgBridgeStub(); + + TestBed.configureTestingModule({ + providers: [ + UntypedFormBuilder, + SettingsEpgFacade, + SettingsFormFacade, + SettingsSnackbarService, + { provide: EpgRuntimeBridgeService, useValue: epgBridge }, + MockProvider(EpgService, { fetchEpg: jest.fn() }), + MockProvider(DialogService, { openConfirmDialog: jest.fn() }), + { provide: MatSnackBar, useClass: MatSnackBarStub }, + { provide: SettingsService, useClass: MockSettingsService }, + { provide: SettingsStore, useClass: MockSettingsStore }, + ], + imports: [TranslateModule.forRoot()], + }); + + facade = TestBed.inject(SettingsEpgFacade); + formFacade = TestBed.inject(SettingsFormFacade); + epgService = TestBed.inject(EpgService); + dialogService = TestBed.inject(DialogService); + snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; + translate = TestBed.inject(TranslateService); + jest.spyOn(translate, 'instant').mockImplementation((key) => key); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + it('should force-fetch EPG for a single URL (bypassing freshness cache)', () => { + const url = 'http://epg-url-here/data.xml'; + + facade.refresh(url); + + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url, { + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + }); + + it('skips the refresh when the URL is empty or EPG is unmanaged', () => { + facade.refresh(''); + epgBridge.supportsDataManagement = false; + facade.refresh('http://epg-url-here/data.xml'); + + expect(epgBridge.forceFetchEpg).not.toHaveBeenCalled(); + }); + + it('force-fetches every configured source, skipping blank entries', () => { + formFacade.setEpgUrls([ + 'http://first.example/guide.xml', + 'http://second.example/guide.xml', + ]); + formFacade.addEpgSource(); + + facade.refreshAll(); + + expect(epgBridge.forceFetchEpg).toHaveBeenCalledTimes(2); + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( + 'http://first.example/guide.xml', + expect.anything() + ); + expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith( + 'http://second.example/guide.xml', + expect.anything() + ); + }); + + it('clears EPG data with a busy state and refreshes all sources on success', async () => { + let resolveClear: () => void = () => undefined; + const clearPromise = new Promise<{ success: boolean }>((resolve) => { + resolveClear = () => resolve({ success: true }); + }); + (epgBridge.clearEpgData as jest.Mock).mockReturnValue(clearPromise); + confirmImmediately(); + const refreshSpy = jest.spyOn(facade, 'refreshAll'); + + facade.clear(); + + expect(facade.isClearing()).toBe(true); + expect(refreshSpy).not.toHaveBeenCalled(); + + resolveClear(); + await clearPromise; + await Promise.resolve(); + + expect(facade.isClearing()).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.EPG_DATA_CLEARED', + undefined, + expect.objectContaining({ panelClass: ['settings-snackbar'] }) + ); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('shows a failure snackbar and skips refresh when clearing EPG data rejects', async () => { + (epgBridge.clearEpgData as jest.Mock).mockRejectedValueOnce( + new Error('boom') + ); + confirmImmediately(); + const refreshSpy = jest.spyOn(facade, 'refreshAll'); + jest.spyOn(console, 'error').mockImplementation(); + + facade.clear(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(facade.isClearing()).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.EPG_DATA_CLEAR_FAILED', + undefined, + expect.objectContaining({ panelClass: ['settings-snackbar'] }) + ); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('re-fetches the saved sources after the settings are stored', () => { + formFacade.setEpgUrls(['http://saved.example/guide.xml']); + + facade.fetchConfiguredEpg(); + + expect(epgService.fetchEpg).toHaveBeenCalledWith([ + 'http://saved.example/guide.xml', + ]); + }); + + it('does not re-fetch when no EPG source is configured', () => { + facade.fetchConfiguredEpg(); + + expect(epgService.fetchEpg).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/settings/settings-epg.facade.ts b/apps/web/src/app/settings/settings-epg.facade.ts new file mode 100644 index 000000000..7f8670e09 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg.facade.ts @@ -0,0 +1,123 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; +import { DialogService } from '@iptvnator/ui/components'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsFormFacade } from './settings-form.facade'; +import { SettingsSnackbarService } from './settings-snackbar.service'; + +/** + * EPG maintenance actions offered by the settings page: force refreshes and + * the destructive "clear all EPG data" flow. + */ +@Injectable() +export class SettingsEpgFacade { + private readonly dialogService = inject(DialogService); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly epgService = inject(EpgService); + private readonly formFacade = inject(SettingsFormFacade); + private readonly settingsSnackbar = inject(SettingsSnackbarService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + readonly isClearing = signal(false); + + /** + * Force-fetch EPG for a single URL, bypassing the 12-hour freshness check. + * The plain fetchEpg would short-circuit on fresh data and click the + * refresh button would be a no-op — that's exactly not what the user + * intends when clicking "Refresh". + */ + refresh(url: string): void { + if (!this.epgBridge.supportsDataManagement || !url) { + return; + } + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); + } + + /** + * Force-fetch every configured EPG URL sequentially. Empty fields are + * skipped. Each URL flows through the normal progress panel so the user + * gets visible per-URL feedback. + */ + refreshAll(): void { + if (!this.epgBridge.supportsDataManagement) return; + const options = this.settingsStore.getTrustOptions(); + this.formFacade.epgUrls.forEach( + (url) => void this.epgBridge.forceFetchEpg(url, options) + ); + } + + /** + * Clears all EPG data from database and immediately re-fetches every + * configured URL so the user isn't left staring at an empty state. + * Tracks progress with `isClearing` so the UI can show a spinner + * and block double-clicks, and surfaces failures via a dedicated snackbar. + */ + clear(): void { + this.dialogService.openConfirmDialog({ + title: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.TITLE'), + message: this.translate.instant( + 'SETTINGS.CLEAR_EPG_DIALOG.MESSAGE' + ), + onConfirm: async (): Promise => { + if ( + !this.epgBridge.supportsDataManagement || + this.isClearing() + ) { + return; + } + + this.isClearing.set(true); + try { + const result = await this.epgBridge.clearEpgData(); + if (result && result.success === false) { + throw new Error('Clear EPG returned success=false'); + } + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEARED') + ); + this.refreshAll(); + } catch (error) { + console.error('Failed to clear EPG data:', error); + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED') + ); + } finally { + this.isClearing.set(false); + } + }, + }); + } + + /** + * Re-fetches the EPG sources that were just saved so the guide reflects + * the new configuration without a restart. + */ + fetchConfiguredEpg(): void { + if (!this.formFacade.supportsEpg) { + return; + } + + const epgUrl = this.formFacade.form.value.epgUrl; + + if (!epgUrl) { + return; + } + + const validEpgUrls = (Array.isArray(epgUrl) ? epgUrl : [epgUrl]).filter( + (url): url is string => typeof url === 'string' && url !== '' + ); + + if (validEpgUrls.length > 0) { + // Fetch all EPG URLs at once + this.epgService.fetchEpg(validEpgUrls); + } + } +} diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts new file mode 100644 index 000000000..a1b4a31a1 --- /dev/null +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -0,0 +1,197 @@ +import { DestroyRef, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormArray, FormBuilder } from '@angular/forms'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + CoverSize, + EpgViewMode, + Language, + Theme, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsService } from '../services/settings.service'; +import { + applyEpgUrlsToFormArray, + createEpgUrlControl, + createSettingsForm, + createSettingsFromFormValue, + SettingsForm, +} from './settings-form.utils'; + +type SettingsFormPatch = Parameters[0]; + +/** + * Owns the settings form: creation, hydration from the store, the small + * mutations the section components trigger, and persisting on submit. + */ +@Injectable() +export class SettingsFormFacade { + private readonly destroyRef = inject(DestroyRef); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly formBuilder = inject(FormBuilder); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsService = inject(SettingsService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + readonly supportsEpg = + this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement; + + /** Settings form object */ + readonly form = createSettingsForm(this.formBuilder, this.supportsEpg); + + /** Form array with epg sources — absent when EPG is unsupported */ + readonly epgUrl = this.form.get('epgUrl') as FormArray; + + /** Trimmed, non-empty EPG source URLs currently in the form */ + get epgUrls(): string[] { + return ((this.epgUrl?.value as string[] | undefined) ?? []) + .map((url) => url?.trim()) + .filter((url): url is string => Boolean(url)); + } + + /** Waits for the persisted settings to be available */ + loadSettings(): Promise { + return this.settingsStore.loadSettings(); + } + + /** + * Sets saved settings from the indexed db store + */ + hydrateFromStore(): void { + const currentSettings = this.settingsStore.getSettings(); + this.form.patchValue(currentSettings); + this.syncDashboardControlsEnabledState( + currentSettings.showDashboard ?? true + ); + + if (this.supportsEpg && currentSettings.epgUrl) { + this.epgUrl.clear(); + this.setEpgUrls(currentSettings.epgUrl); + } + } + + bindDashboardControlsEnabledState(): void { + this.form + .get('showDashboard') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((showDashboard) => + this.syncDashboardControlsEnabledState(showDashboard ?? true) + ); + } + + selectTheme(theme: Theme): void { + if (this.form.value.theme === theme) { + return; + } + + this.patchAndMarkDirty({ theme }, 'theme'); + this.settingsService.changeTheme(theme); + } + + selectCoverSize(coverSize: CoverSize): void { + if (this.form.value.coverSize === coverSize) { + return; + } + + this.patchAndMarkDirty({ coverSize }, 'coverSize'); + void this.settingsStore.updateSettings({ coverSize }); + } + + selectEpgViewMode(epgViewMode: EpgViewMode): void { + if (this.form.value.epgViewMode === epgViewMode) { + return; + } + + this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode'); + void this.settingsStore.updateSettings({ epgViewMode }); + } + + setRecordingFolder(recordingFolder: string): void { + this.patchAndMarkDirty({ recordingFolder }, 'recordingFolder'); + } + + /** + * Sets the epg urls to the form array + * @param epgUrls urls of the EPG sources + */ + setEpgUrls(epgUrls: string[] | string): void { + applyEpgUrlsToFormArray(this.epgUrl, epgUrls); + } + + /** + * Initializes new entry in form array for EPG URL + */ + addEpgSource(): void { + this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl()); + } + + /** + * Removes entry from form array for EPG URL + * @param index index of the item to remove + */ + removeEpgSource(index: number): void { + this.epgUrl.removeAt(index); + this.form.markAsDirty(); + } + + /** + * Persists the form to the settings store and mirrors the result to the + * desktop backend. + * @param onSaved runs after the store write, before the backend is + * notified, so the UI confirmation is not delayed by IPC + */ + async save(onSaved: () => void): Promise { + const settings = createSettingsFromFormValue( + this.form, + this.settingsStore.getSettings() + ); + + await this.settingsStore.updateSettings(settings); + onSaved(); + + if (!window.electron) { + return; + } + + window.electron.updateSettings(settings); + + if (this.runtime.supportsExternalPlayerPathSettings) { + window.electron.setMpvPlayerPath(settings.mpvPlayerPath); + window.electron.setVlcPlayerPath(settings.vlcPlayerPath); + } + } + + /** Applies the saved language/theme and resets the dirty state */ + applySavedSettings(): void { + this.form.markAsPristine(); + this.translate.use(this.form.value.language ?? Language.ENGLISH); + this.settingsService.changeTheme( + this.form.value.theme ?? Theme.SystemTheme + ); + } + + private patchAndMarkDirty( + value: SettingsFormPatch, + controlName: string + ): void { + this.form.patchValue(value); + this.form.get(controlName)?.markAsDirty(); + this.form.markAsDirty(); + } + + private syncDashboardControlsEnabledState(showDashboard: boolean): void { + const dashboardRails = this.form.get('dashboardRails'); + if (!dashboardRails) { + return; + } + + if (showDashboard) { + dashboardRails.enable({ emitEvent: false }); + } else { + dashboardRails.disable({ emitEvent: false }); + } + } +} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 5add3d301..8df774bc1 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -100,6 +100,33 @@ export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [ }, ]; +export interface SettingsPlayerAvailability { + supportsEmbeddedMpv: boolean; + supportsManagedExternalPlayers: boolean; +} + +/** + * Built-in web players are always offered; the OS-backed ones only show up + * when the current runtime can actually launch them. + */ +export function buildSettingsPlayerOptions({ + supportsEmbeddedMpv, + supportsManagedExternalPlayers, +}: SettingsPlayerAvailability): SettingsPlayerOption[] { + return [ + ...SETTINGS_EMBEDDED_PLAYER_OPTIONS, + ...(supportsEmbeddedMpv + ? [ + { + id: VideoPlayer.EmbeddedMpv, + labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV', + }, + ] + : []), + ...(supportsManagedExternalPlayers ? SETTINGS_OS_PLAYER_OPTIONS : []), + ]; +} + export interface SettingsSectionVisibility { supportsEpg: boolean; supportsRemoteControl: boolean; diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts new file mode 100644 index 000000000..e9b3f8a5c --- /dev/null +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts @@ -0,0 +1,244 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { + PlaylistActions, + selectAllPlaylistsMeta, + selectIsEpgAvailable, +} from '@iptvnator/m3u-state'; +import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { Store } from '@ngrx/store'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade'; +import { SettingsSnackbarService } from './settings-snackbar.service'; +import { + createDialogRef, + createElectronStub, + createPlaylistMeta, + MatSnackBarStub, +} from './test-stubs/settings-test-harness.stub'; + +describe('SettingsPlaylistResetFacade', () => { + let facade: SettingsPlaylistResetFacade; + let matDialog: MatDialog; + let databaseService: DatabaseService; + let playlistsService: PlaylistsService; + let store: Store; + let mockStore: MockStore; + let snackBar: MatSnackBarStub; + let translate: TranslateService; + const originalElectron = window.electron; + + /** The component normally supplies a paint frame; tests skip the wait */ + const noWait = () => Promise.resolve(); + + function setPlaylists(playlists: PlaylistMeta[]): void { + mockStore.overrideSelector(selectAllPlaylistsMeta, playlists); + mockStore.refreshState(); + } + + beforeEach(() => { + window.electron = createElectronStub(); + + TestBed.configureTestingModule({ + providers: [ + SettingsPlaylistResetFacade, + SettingsSnackbarService, + MockProvider(MatDialog, { open: jest.fn() }), + { provide: MatSnackBar, useClass: MatSnackBarStub }, + MockProvider(DatabaseService, { + createOperationId: jest + .fn() + .mockReturnValue('delete-all-op'), + deleteAllPlaylists: jest.fn().mockResolvedValue(true), + }), + MockProvider(PlaylistsService, { + removeAll: jest.fn().mockReturnValue(of(undefined)), + }), + provideMockStore({ + selectors: [ + { selector: selectAllPlaylistsMeta, value: [] }, + { selector: selectIsEpgAvailable, value: false }, + ], + }), + ], + imports: [TranslateModule.forRoot()], + }); + + facade = TestBed.inject(SettingsPlaylistResetFacade); + matDialog = TestBed.inject(MatDialog); + databaseService = TestBed.inject(DatabaseService); + playlistsService = TestBed.inject(PlaylistsService); + store = TestBed.inject(Store); + mockStore = TestBed.inject(MockStore); + snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; + translate = TestBed.inject(TranslateService); + jest.spyOn(translate, 'instant').mockImplementation( + (key: string, params?: Record) => + key === 'SETTINGS.REMOVE_ALL_PROGRESS' + ? `${params?.current}/${params?.total}` + : key + ); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + it('refuses the global wipe when there are no playlists', () => { + setPlaylists([]); + + expect(facade.deleteSummary().total).toBe(0); + expect(facade.canRemoveAll()).toBe(false); + + facade.confirmAndRemoveAll(noWait); + + expect(matDialog.open).not.toHaveBeenCalled(); + }); + + it('opens the dedicated delete-all dialog with the current playlist type summary', () => { + setPlaylists([ + createPlaylistMeta({ _id: 'm3u-1' }), + createPlaylistMeta({ + _id: 'xtream-1', + serverUrl: 'http://xtream.example', + }), + createPlaylistMeta({ + _id: 'stalker-1', + macAddress: '00:11:22:33:44:55', + }), + ]); + const openSpy = jest + .spyOn(matDialog, 'open') + .mockReturnValue(createDialogRef(false)); + + facade.confirmAndRemoveAll(noWait); + + expect(facade.deleteSummary()).toEqual({ + total: 3, + m3u: 1, + xtream: 1, + stalker: 1, + }); + expect(openSpy).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: { + summary: { + total: 3, + m3u: 1, + xtream: 1, + stalker: 1, + }, + }, + }) + ); + }); + + it('tracks Electron delete-all progress and dispatches store cleanup after success', async () => { + setPlaylists([ + createPlaylistMeta({ _id: 'm3u-1' }), + createPlaylistMeta({ + _id: 'xtream-1', + serverUrl: 'http://xtream.example', + }), + ]); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + jest.spyOn(matDialog, 'open').mockReturnValue(createDialogRef(true)); + + let resolveDelete: (value: boolean) => void = () => undefined; + (databaseService.deleteAllPlaylists as jest.Mock).mockImplementation( + ({ + onEvent, + }: { + onEvent?: (event: { + operation: string; + status: string; + current?: number; + total?: number; + }) => void; + }) => { + onEvent?.({ + operation: 'delete-all-playlists', + status: 'progress', + current: 3, + total: 7, + }); + + return new Promise((resolve) => { + resolveDelete = resolve; + }); + } + ); + + facade.confirmAndRemoveAll(noWait); + await Promise.resolve(); + + expect(facade.isRemovingAllPlaylists()).toBe(true); + expect(facade.removeAllProgressLabel()).toBe('3/7'); + expect(databaseService.deleteAllPlaylists).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'delete-all-op', + onEvent: expect.any(Function), + }) + ); + + resolveDelete(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(facade.isRemovingAllPlaylists()).toBe(false); + expect(facade.removeAllProgress()).toBeNull(); + expect(dispatchSpy).toHaveBeenCalledWith( + PlaylistActions.removeAllPlaylists() + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.PLAYLISTS_REMOVED', + undefined, + { + duration: 2000, + horizontalPosition: 'center', + panelClass: ['settings-snackbar'], + verticalPosition: 'bottom', + } + ); + }); + + it('falls back to PlaylistsService.removeAll outside Electron', async () => { + window.electron = undefined as unknown as typeof window.electron; + setPlaylists([createPlaylistMeta({ _id: 'browser-m3u' })]); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + jest.spyOn(matDialog, 'open').mockReturnValue(createDialogRef(true)); + + facade.confirmAndRemoveAll(noWait); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(playlistsService.removeAll).toHaveBeenCalled(); + expect(databaseService.deleteAllPlaylists).not.toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledWith( + PlaylistActions.removeAllPlaylists() + ); + }); + + it('surfaces a failure snackbar when the wipe reports no success', async () => { + setPlaylists([createPlaylistMeta({ _id: 'm3u-1' })]); + (databaseService.deleteAllPlaylists as jest.Mock).mockResolvedValue( + false + ); + jest.spyOn(matDialog, 'open').mockReturnValue(createDialogRef(true)); + jest.spyOn(console, 'error').mockImplementation(); + + facade.confirmAndRemoveAll(noWait); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(facade.isRemovingAllPlaylists()).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.PLAYLISTS_REMOVE_FAILED', + undefined, + expect.objectContaining({ panelClass: ['settings-snackbar'] }) + ); + }); +}); diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.ts index 2a427760a..8ec1a1fa8 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.ts @@ -1,19 +1,30 @@ -import { inject, Injectable, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom } from 'rxjs'; -import { PlaylistActions } from '@iptvnator/m3u-state'; +import { firstValueFrom, take } from 'rxjs'; +import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { DatabaseService, DbOperationEvent, PlaylistsService, RuntimeCapabilitiesService, } from '@iptvnator/services'; +import { + SettingsDeleteAllPlaylistsDialogComponent, + SettingsDeleteAllPlaylistsDialogData, +} from './settings-delete-all-playlists-dialog.component'; +import { + buildRemoveAllProgressLabel, + buildSettingsPlaylistDeleteSummary, +} from './settings-playlist-summary.utils'; +import { SettingsPlaylistDeleteSummary } from './settings.models'; import { SettingsSnackbarService } from './settings-snackbar.service'; @Injectable() export class SettingsPlaylistResetFacade { private readonly databaseService = inject(DatabaseService); + private readonly matDialog = inject(MatDialog); private readonly playlistsService = inject(PlaylistsService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsSnackbar = inject(SettingsSnackbarService); @@ -23,7 +34,59 @@ export class SettingsPlaylistResetFacade { readonly isRemovingAllPlaylists = signal(false); readonly removeAllProgress = signal(null); - async removeAllConfirmed( + private readonly playlists = this.store.selectSignal( + selectAllPlaylistsMeta + ); + + readonly deleteSummary = computed(() => + buildSettingsPlaylistDeleteSummary(this.playlists()) + ); + + readonly canRemoveAll = computed( + () => !this.isRemovingAllPlaylists() && this.deleteSummary().total > 0 + ); + + readonly removeAllProgressLabel = computed(() => + buildRemoveAllProgressLabel({ + isRemovingAllPlaylists: this.isRemovingAllPlaylists(), + progress: this.removeAllProgress(), + translate: (key, params) => this.translate.instant(key, params), + }) + ); + + /** + * Asks for confirmation in a dedicated dialog before wiping every + * playlist, so the summary of what is about to be lost is visible. + */ + confirmAndRemoveAll(waitForUiFeedbackFrame: () => Promise): void { + if (!this.canRemoveAll()) { + return; + } + + this.matDialog + .open< + SettingsDeleteAllPlaylistsDialogComponent, + SettingsDeleteAllPlaylistsDialogData, + boolean + >(SettingsDeleteAllPlaylistsDialogComponent, { + autoFocus: false, + data: { + summary: this.deleteSummary(), + }, + maxWidth: 'calc(100vw - 32px)', + restoreFocus: true, + width: '460px', + }) + .afterClosed() + .pipe(take(1)) + .subscribe((confirmed) => { + if (confirmed) { + void this.removeAllConfirmed(waitForUiFeedbackFrame); + } + }); + } + + private async removeAllConfirmed( waitForUiFeedbackFrame: () => Promise ): Promise { if (this.isRemovingAllPlaylists()) { diff --git a/apps/web/src/app/settings/settings-remote-control.facade.ts b/apps/web/src/app/settings/settings-remote-control.facade.ts new file mode 100644 index 000000000..6796510cd --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control.facade.ts @@ -0,0 +1,37 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; + +/** + * Backs the remote-control section: the reachable LAN addresses and which of + * them currently shows its pairing QR code. + */ +@Injectable() +export class SettingsRemoteControlFacade { + private readonly runtime = inject(RuntimeCapabilitiesService); + + /** Local IP addresses for remote control URL display */ + readonly localIpAddresses = signal([]); + + /** Currently visible QR code IP (null = none visible) */ + readonly visibleQrCodeIp = signal(null); + + /** + * Fetches local IP addresses for remote control URL display + */ + async fetchLocalIpAddresses(): Promise { + if ( + this.runtime.supportsRemoteControl && + window.electron?.getLocalIpAddresses + ) { + const addresses = await window.electron.getLocalIpAddresses(); + this.localIpAddresses.set(addresses); + } + } + + /** + * Toggle QR code visibility for a given IP address + */ + toggleQrCode(ip: string): void { + this.visibleQrCodeIp.update((visible) => (visible === ip ? null : ip)); + } +} diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts b/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts new file mode 100644 index 000000000..f2c402094 --- /dev/null +++ b/apps/web/src/app/settings/settings-section-scroll.directive.spec.ts @@ -0,0 +1,107 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { SettingsComponent } from './settings.component'; +import { SettingsSectionScrollDirective } from './settings-section-scroll.directive'; +import { + configureSettingsComponentTestBed, + createElectronStub, + createEpgBridgeStub, + stubSettingsSideEffects, +} from './test-stubs/settings-test-harness.stub'; + +interface SettingsSectionScrollDirectiveTestApi { + getScrollRoot(): HTMLElement | null; +} + +/** + * The directive only makes sense against a real settings page, so this is an + * integration spec: it renders the component and drives the section + * navigation the workspace shell would trigger. + */ +describe('SettingsSectionScrollDirective', () => { + let fixture: ComponentFixture; + let epgBridge: Partial; + const originalElectron = window.electron; + + beforeEach(waitForAsync(() => { + epgBridge = createEpgBridgeStub(); + configureSettingsComponentTestBed(epgBridge); + })); + + beforeEach(() => { + window.electron = createElectronStub(); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + it('should scroll the selected navigation target within the workspace viewport', async () => { + jest.useFakeTimers(); + + try { + fixture = TestBed.createComponent(SettingsComponent); + const component = fixture.componentInstance; + const settingsContext = TestBed.inject(SettingsContextService); + const originalGetElementById = + document.getElementById.bind(document); + const scrollTo = jest.fn(); + const scrollRoot = { + scrollTop: 96, + clientHeight: 885, + scrollHeight: 2469, + getBoundingClientRect: () => + ({ + top: 56, + }) as DOMRect, + scrollTo, + } as unknown as HTMLElement; + + stubSettingsSideEffects(component); + fixture.detectChanges(); + const scrollDirective = fixture.debugElement + .query(By.directive(SettingsSectionScrollDirective)) + .injector.get(SettingsSectionScrollDirective); + jest.spyOn( + scrollDirective as unknown as SettingsSectionScrollDirectiveTestApi, + 'getScrollRoot' + ).mockReturnValue(scrollRoot); + + const getElementByIdSpy = jest + .spyOn(document, 'getElementById') + .mockImplementation((id: string) => { + if (id === 'about') { + return { + getBoundingClientRect: () => + ({ + top: 2050, + height: 159, + }) as DOMRect, + } as HTMLElement; + } + + return originalGetElementById(id); + }); + + await fixture.whenStable(); + fixture.detectChanges(); + settingsContext.navigateToSection('about'); + fixture.detectChanges(); + + expect(getElementByIdSpy).toHaveBeenCalledWith('about'); + expect(scrollTo).toHaveBeenCalledWith({ + behavior: 'smooth', + top: 1488, + }); + expect(settingsContext.pendingScrollTarget()).toBe('about'); + + jest.advanceTimersByTime(600); + + expect(settingsContext.pendingScrollTarget()).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts new file mode 100644 index 000000000..c11db5c82 --- /dev/null +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -0,0 +1,350 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { + Language, + StartupBehavior, + Theme, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsComponent } from './settings.component'; +import { + configureSettingsComponentTestBed, + createElectronStub, + createEpgBridgeStub, + DEFAULT_DASHBOARD_RAILS, + DEFAULT_SETTINGS, + MatSnackBarStub, + MockSettingsStore, + stubSettingsSideEffects, +} from './test-stubs/settings-test-harness.stub'; + +/** + * Everything the rendered settings form does: hydration from the store, the + * section outputs that write straight through, and the submit path. + */ +describe('SettingsComponent form', () => { + let component: SettingsComponent; + let fixture: ComponentFixture; + let settingsStore: MockSettingsStore; + let translate: TranslateService; + let snackBar: MatSnackBarStub; + let epgBridge: Partial; + const originalElectron = window.electron; + + beforeEach(waitForAsync(() => { + epgBridge = createEpgBridgeStub(); + configureSettingsComponentTestBed(epgBridge); + })); + + beforeEach(() => { + window.electron = createElectronStub(); + + fixture = TestBed.createComponent(SettingsComponent); + settingsStore = TestBed.inject( + SettingsStore + ) as unknown as MockSettingsStore; + translate = TestBed.inject(TranslateService); + snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; + + component = fixture.componentInstance; + stubSettingsSideEffects(component); + fixture.detectChanges(); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + describe('Get and set settings on component init', () => { + const settings = { + language: Language.GERMAN, + player: VideoPlayer.Html5Player, + theme: Theme.DarkTheme, + }; + + it('should init default settings if previous config was not saved', async () => { + await component.ngOnInit(); + + expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS); + }); + + it('should get and apply custom settings', () => { + settingsStore._setSettings({ + ...DEFAULT_SETTINGS, + ...settings, + }); + + component.form.hydrateFromStore(); + + expect(component.settingsForm.value).toEqual({ + ...DEFAULT_SETTINGS, + ...settings, + }); + }); + + it('hydrates the shared web controls setting from the settings store', () => { + settingsStore._setSettings({ + webPlayerSharedControls: true, + }); + + component.form.hydrateFromStore(); + + expect( + component.settingsForm.get('webPlayerSharedControls')?.value + ).toBe(true); + }); + }); + + describe('Section outputs', () => { + it('updates the selected theme through the general section and marks the form dirty', () => { + const darkThemeButton = ( + fixture.nativeElement as HTMLElement + ).querySelector('[data-test-id="DARK_THEME"]') as HTMLButtonElement; + + darkThemeButton.click(); + fixture.detectChanges(); + + expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme); + expect(component.settingsForm.dirty).toBeTruthy(); + }); + + it('updates cover size through the general section output', () => { + const largeCoverButton = ( + fixture.nativeElement as HTMLElement + ).querySelector( + '[data-test-id="cover-size-large"]' + ) as HTMLButtonElement; + + largeCoverButton.click(); + fixture.detectChanges(); + + expect(component.settingsForm.value.coverSize).toBe('large'); + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + coverSize: 'large', + }); + }); + + it('updates the EPG view mode through the epg section output', () => { + const listButton = ( + fixture.nativeElement as HTMLElement + ).querySelector( + '[data-test-id="epg-view-mode-list"]' + ) as HTMLButtonElement; + + listButton.click(); + fixture.detectChanges(); + + expect(component.settingsForm.value.epgViewMode).toBe('list'); + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + epgViewMode: 'list', + }); + }); + }); + + describe('Dashboard controls', () => { + it('renders dashboard controls with the expected defaults', () => { + const nativeElement = fixture.nativeElement as HTMLElement; + + expect( + nativeElement.querySelector( + '[data-test-id="toggle-show-dashboard"]' + ) + ).not.toBeNull(); + expect( + nativeElement.querySelector( + '[data-test-id="toggle-dashboard-hero"]' + ) + ).not.toBeNull(); + expect( + nativeElement.querySelector( + '[data-test-id="toggle-dashboard-rail-live-favorites"]' + ) + ).not.toBeNull(); + expect( + nativeElement.querySelector( + '[data-test-id="toggle-dashboard-rail-recently-watched-live"]' + ) + ).not.toBeNull(); + expect( + nativeElement.querySelector( + '[data-test-id="toggle-dashboard-rail-favorite-movies-and-series"]' + ) + ).not.toBeNull(); + expect(component.settingsForm.value.showDashboard).toBe(true); + expect(component.settingsForm.value.dashboardRails).toEqual( + DEFAULT_DASHBOARD_RAILS + ); + expect(component.settingsForm.value.startupBehavior).toBe( + StartupBehavior.FirstView + ); + }); + + it('disables dashboard surface controls when the dashboard is off', async () => { + const showDashboard = component.settingsForm.get('showDashboard'); + const dashboardRails = component.settingsForm.get('dashboardRails'); + + showDashboard?.setValue(false); + await fixture.whenStable(); + + expect(dashboardRails?.disabled).toBe(true); + expect( + component.settingsForm.get('dashboardRails.hero')?.disabled + ).toBe(true); + expect( + component.settingsForm.get('dashboardRails.continueWatching') + ?.disabled + ).toBe(true); + + showDashboard?.setValue(true); + await fixture.whenStable(); + + expect(dashboardRails?.enabled).toBe(true); + expect( + component.settingsForm.get('dashboardRails.hero')?.enabled + ).toBe(true); + }); + }); + + describe('Saving', () => { + it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => { + jest.spyOn(translate, 'instant').mockReturnValue('Settings saved'); + + component.applyChangedSettings(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Settings saved', + undefined, + { + duration: 2000, + horizontalPosition: 'center', + panelClass: ['settings-snackbar'], + verticalPosition: 'bottom', + } + ); + }); + + it('should save settings on submit', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + const updateSettings = jest.spyOn( + window.electron, + 'updateSettings' + ); + + component.onSubmit(); + await fixture.whenStable(); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + expect(updateSettings).toHaveBeenCalledWith({ + ...component.settingsForm.value, + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + }); + + it('saves the shared web controls setting on submit', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + component.settingsForm + .get('webPlayerSharedControls') + ?.setValue(true); + + component.onSubmit(); + await fixture.whenStable(); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + webPlayerSharedControls: true, + }) + ); + }); + + it('clears external player paths in Electron when saved as empty', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + const setMpvPlayerPath = jest.spyOn( + window.electron, + 'setMpvPlayerPath' + ); + const setVlcPlayerPath = jest.spyOn( + window.electron, + 'setVlcPlayerPath' + ); + + component.settingsForm.patchValue({ + mpvPlayerPath: '', + vlcPlayerPath: '', + }); + + component.onSubmit(); + await fixture.whenStable(); + + expect(setMpvPlayerPath).toHaveBeenCalledWith(''); + expect(setVlcPlayerPath).toHaveBeenCalledWith(''); + }); + + it('saves external player command-line arguments with the settings payload', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + const updateSettings = jest.spyOn( + window.electron, + 'updateSettings' + ); + + component.settingsForm.patchValue({ + mpvPlayerArguments: '--screen=1\n--geometry=1280x720', + vlcPlayerArguments: '--qt-fullscreen-screennumber=1', + }); + + component.onSubmit(); + await fixture.whenStable(); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + mpvPlayerArguments: '--screen=1\n--geometry=1280x720', + vlcPlayerArguments: '--qt-fullscreen-screennumber=1', + }) + ); + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + mpvPlayerArguments: '--screen=1\n--geometry=1280x720', + vlcPlayerArguments: '--qt-fullscreen-screennumber=1', + }) + ); + }); + + it('preserves saved EPG settings when saving from the web settings form', async () => { + fixture.destroy(); + window.electron = undefined as unknown as typeof window.electron; + + settingsStore._setSettings({ + epgUrl: ['https://example.com/guide.xml'], + preferUploadedEpgOverXtream: true, + }); + settingsStore.updateSettings.mockResolvedValue(undefined); + + const webFixture = TestBed.createComponent(SettingsComponent); + const webComponent = webFixture.componentInstance; + stubSettingsSideEffects(webComponent); + webFixture.detectChanges(); + await webFixture.whenStable(); + + webComponent.settingsForm.patchValue({ theme: Theme.DarkTheme }); + webComponent.onSubmit(); + await webFixture.whenStable(); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + epgUrl: ['https://example.com/guide.xml'], + preferUploadedEpgOverXtream: true, + theme: Theme.DarkTheme, + }) + ); + + webFixture.destroy(); + }); + }); +}); diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 0ef375d3d..0bdbf2bd0 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -38,8 +38,8 @@ [themeOptions]="themeOptions" [coverSizeOptions]="coverSizeOptions" [startupBehaviorOptions]="startupBehaviorOptions" - (selectTheme)="selectTheme($event)" - (selectCoverSize)="selectCoverSize($event)" + (selectTheme)="form.selectTheme($event)" + (selectCoverSize)="form.selectCoverSize($event)" /> @@ -59,15 +59,15 @@ } @@ -80,9 +80,9 @@ } @@ -94,33 +94,33 @@