mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
chore(stalker): merge master compatibility updates
This commit is contained in:
commit
9163f2d515
317 files changed
+38962
-5017
No files matched your search
+13
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: feature
|
||||
area: i18n
|
||||
issues: [1192]
|
||||
---
|
||||
|
||||
IPTVnator speaks Hungarian, its 19th language — contributed by @htibcsike.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Prepare Docker metadata
|
||||
id: docker-meta
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -- <why> */`; 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
|
||||
|
||||
@@ -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 -- <why> */`; 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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<void> {
|
||||
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<RendererWindowIdentity> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<string, unknown>)[
|
||||
stateKey
|
||||
] as DatabaseRequestIdentityCaptureApi;
|
||||
api.start();
|
||||
return { api, ipcMain };
|
||||
}
|
||||
|
||||
function marker(overrides: Record<string, unknown> = {}) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (target[stateKey] !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markerChannel = 'IPTVNATOR_PERF_CAPTURE_MARKER';
|
||||
const markerMethodToOperation: Readonly<Record<string, string>> = {
|
||||
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<void> {
|
||||
await electronApp.evaluate(
|
||||
installDatabaseRequestIdentityCaptureInMain,
|
||||
DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY
|
||||
);
|
||||
}
|
||||
@@ -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[,}]/);
|
||||
});
|
||||
+121
@@ -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<string, unknown>
|
||||
) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<CutoffApi> {
|
||||
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/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
+392
@@ -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<void>;
|
||||
readonly probePostGc: () => Promise<PostGcOutcome>;
|
||||
readonly reportAncillaryFailure: (
|
||||
stage: AncillaryFailureStage,
|
||||
error: unknown
|
||||
) => void;
|
||||
readonly stopProfile: () => Promise<void>;
|
||||
readonly stopSampling: () => void;
|
||||
readonly takeHeapSnapshot?: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface FinalizationApi {
|
||||
finalize(input: FinalizationInput): Promise<PostGcOutcome>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
readonly resolve: () => void;
|
||||
} {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function restoreSerializableApi(): Promise<FinalizationApi> {
|
||||
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'/
|
||||
);
|
||||
});
|
||||
@@ -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<void>;
|
||||
readonly probePostGc: () => Promise<DatabaseWorkerPostGcFinalizationOutcome>;
|
||||
readonly reportAncillaryFailure: (
|
||||
stage: DatabaseWorkerPostGcAncillaryFailureStage,
|
||||
error: unknown
|
||||
) => void;
|
||||
readonly stopProfile: () => Promise<void>;
|
||||
readonly stopSampling: () => void;
|
||||
readonly takeHeapSnapshot?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DatabaseWorkerPostGcFinalizationApi {
|
||||
finalize(
|
||||
input: DatabaseWorkerPostGcFinalizationInput
|
||||
): Promise<DatabaseWorkerPostGcFinalizationOutcome>;
|
||||
}
|
||||
|
||||
export function createDatabaseWorkerPostGcFinalizationApi(): DatabaseWorkerPostGcFinalizationApi {
|
||||
const finalizations = new WeakMap<
|
||||
object,
|
||||
Promise<DatabaseWorkerPostGcFinalizationOutcome>
|
||||
>();
|
||||
|
||||
const helpers = {
|
||||
async run(
|
||||
input: DatabaseWorkerPostGcFinalizationInput
|
||||
): Promise<DatabaseWorkerPostGcFinalizationOutcome> {
|
||||
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<DatabaseWorkerPostGcFinalizationOutcome> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<ProbeResult>;
|
||||
}
|
||||
|
||||
interface ProbeModule {
|
||||
createDatabaseWorkerPostGcProbeApi?: () => ProbeApi;
|
||||
}
|
||||
|
||||
interface ProductionWorkerPostGcModule {
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON?: Readonly<
|
||||
Record<string, WorkerUnavailableReason>
|
||||
>;
|
||||
}
|
||||
|
||||
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<ProbeApi> {
|
||||
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<string, number> {
|
||||
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<WorkerUnavailableReason>([
|
||||
'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]);
|
||||
});
|
||||
@@ -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<DatabaseWorkerPostGcProbeResult>;
|
||||
}
|
||||
|
||||
export function createDatabaseWorkerPostGcProbeApi(): DatabaseWorkerPostGcProbeApi {
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<typeof globalThis.setTimeout>
|
||||
);
|
||||
},
|
||||
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<DatabaseWorkerPostGcProbeResult> {
|
||||
let channel: ReturnType<typeof input.createMessageChannel>;
|
||||
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 });
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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<T> {
|
||||
readonly selected: T | null;
|
||||
readonly unavailableReason: SelectionReason | null;
|
||||
}
|
||||
|
||||
type Selector = <T extends TestWorkerRecord>(
|
||||
records: readonly T[],
|
||||
activeGeneration: number
|
||||
) => Selection<T>;
|
||||
|
||||
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> = {}
|
||||
): TestWorkerRecord {
|
||||
return {
|
||||
captureGeneration: 7,
|
||||
finalized: true,
|
||||
kind: 'database.worker',
|
||||
ordinal,
|
||||
pendingCount: 0,
|
||||
sampleTimer: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreSerializableSelector(): Promise<Selector> {
|
||||
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',
|
||||
});
|
||||
});
|
||||
@@ -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<T> {
|
||||
readonly selected: T | null;
|
||||
readonly unavailableReason: DatabaseWorkerPostGcUnavailableReason | null;
|
||||
}
|
||||
|
||||
export interface DatabaseWorkerPostGcSelectionApi {
|
||||
select<T extends DatabaseWorkerPostGcSelectionRecord>(
|
||||
records: readonly T[],
|
||||
activeGeneration: number
|
||||
): DatabaseWorkerPostGcSelection<T>;
|
||||
}
|
||||
|
||||
export function createDatabaseWorkerPostGcSelectionApi(): DatabaseWorkerPostGcSelectionApi {
|
||||
const helpers = {
|
||||
select<T extends DatabaseWorkerPostGcSelectionRecord>(
|
||||
records: readonly T[],
|
||||
activeGeneration: number
|
||||
): DatabaseWorkerPostGcSelection<T> {
|
||||
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 });
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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<string>(
|
||||
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,
|
||||
});
|
||||
}
|
||||
+127
@@ -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<WorkerRequestPerformanceMetrics>
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
+197
@@ -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<WorkerRequestPerformanceMetrics['eventLoopDelay']> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<number>();
|
||||
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<string, unknown>)['children'] instanceof Array
|
||||
? (
|
||||
(node as Record<string, unknown>)[
|
||||
'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<string, unknown> & { 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
|
||||
);
|
||||
}
|
||||
+234
@@ -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<void> {
|
||||
const artifacts: Readonly<Record<string, unknown>> = {
|
||||
'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<void>
|
||||
): Promise<void> {
|
||||
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/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+394
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`)
|
||||
);
|
||||
}
|
||||
@@ -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]),
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<M3uImportBenchmarkConfiguration> {
|
||||
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<void> {
|
||||
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<RendererWindowIdentity> {
|
||||
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<void> {
|
||||
const server = createTcpServer();
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.once('error', rejectPromise);
|
||||
server.listen(
|
||||
{ exclusive: true, host: '127.0.0.1', port },
|
||||
resolvePromise
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) =>
|
||||
error ? rejectPromise(error) : resolvePromise()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertRendererCdpTarget(
|
||||
page: Page,
|
||||
port: number
|
||||
): Promise<void> {
|
||||
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<string, unknown>)['type'] ===
|
||||
'page' &&
|
||||
(target as Record<string, unknown>)['url'] ===
|
||||
page.url()
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function writeM3uImportJson(
|
||||
path: string,
|
||||
value: unknown
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly onTraceComplete: () => void;
|
||||
readonly onTraceData: (data: TraceData) => void;
|
||||
readonly traceComplete: Promise<void>;
|
||||
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<void> | null = null;
|
||||
let traceFirstEvent = true;
|
||||
let completeTrace: (() => void) | null = null;
|
||||
const traceComplete = new Promise<void>((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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
disposeRendererDiagnosticListeners(session, artifacts);
|
||||
await artifacts.finishTraceOutput();
|
||||
}
|
||||
|
||||
export async function takeRendererHeapSnapshot(
|
||||
session: CDPSession,
|
||||
path: string | null
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
+313
@@ -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<never>(() => 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<void> {
|
||||
this.detachCount += 1;
|
||||
if (this.options.hangDetach) {
|
||||
return new Promise<never>(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async send(method: string): Promise<Record<string, unknown>> {
|
||||
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<string, PropertyDescriptor | undefined>();
|
||||
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<string, unknown>)[
|
||||
M3U_IMPORT_RENDERER_STATE_KEY
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createFakePage(
|
||||
browser: ReturnType<typeof installFakeProbeEnvironment>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
recordM3uImportHeartbeat?: (
|
||||
page: unknown,
|
||||
deadlineEpochMs: number
|
||||
) => Promise<void>;
|
||||
stopM3uImportRendererProbe?: (page: unknown) => Promise<ImportProbe>;
|
||||
}
|
||||
|
||||
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<string, PropertyDescriptor | undefined>();
|
||||
const frames = new Map<number, (timestamp: number) => 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<string, unknown>)[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);
|
||||
});
|
||||
@@ -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<void>;
|
||||
stop(): Promise<M3uImportRendererCaptureMetrics>;
|
||||
triggerImport(): Promise<number>;
|
||||
waitForTerminal(): Promise<void>;
|
||||
}
|
||||
|
||||
interface HeapUsageResult {
|
||||
readonly usedSize: number;
|
||||
}
|
||||
|
||||
export async function startM3uImportRendererCapture(
|
||||
page: Page,
|
||||
options: M3uImportRendererCaptureStartOptions
|
||||
): Promise<RunningM3uImportRendererCapture> {
|
||||
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<void> | null = null;
|
||||
const sampleHeap = (): Promise<void> => {
|
||||
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<void> | null = null;
|
||||
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
let stopped = false;
|
||||
let backgroundStopPromise: Promise<void> | null = null;
|
||||
let probeStopPromise: Promise<M3uImportRendererProbeMetrics> | null = null;
|
||||
let sessionDetachPromise: Promise<void> | null = null;
|
||||
let abortPromise: Promise<void> | null = null;
|
||||
let stopPromise: Promise<M3uImportRendererCaptureMetrics> | 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<void> => {
|
||||
backgroundStopPromise ??= (async () => {
|
||||
stopSchedulingBackgroundWork();
|
||||
await heartbeatInFlight;
|
||||
await heapSamplePromise;
|
||||
await sampleHeap();
|
||||
})();
|
||||
return backgroundStopPromise;
|
||||
};
|
||||
const stopProbe = (): Promise<M3uImportRendererProbeMetrics> => {
|
||||
probeStopPromise ??= stopM3uImportRendererProbe(page);
|
||||
return probeStopPromise;
|
||||
};
|
||||
const detachSession = (): Promise<void> => {
|
||||
sessionDetachPromise ??= session.detach();
|
||||
return sessionDetachPromise;
|
||||
};
|
||||
const abortResources = (): Promise<void> => {
|
||||
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<M3uImportRendererCaptureMetrics> => {
|
||||
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<unknown>);
|
||||
}
|
||||
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<unknown>): 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
return page.evaluate((stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
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<void> {
|
||||
await page.evaluate(
|
||||
({ deadlineEpochMs, stateKey }) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
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<void> {
|
||||
await page
|
||||
.waitForFunction(
|
||||
(stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
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<M3uImportRendererProbeMetrics> {
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<M3uImportIterationResult> {
|
||||
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<void> {
|
||||
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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AppPlaylistPerformancePhase>[];
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<CancellationIterationResult> {
|
||||
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<RendererWindowIdentity> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async function waitForSeedSettlement(app: LaunchedElectronApp): Promise<void> {
|
||||
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<BenchmarkConfiguration> {
|
||||
}
|
||||
const outputDirectory = join(outputRoot, variant);
|
||||
await assertMissing(outputDirectory);
|
||||
await assertPerformanceArtifactCapacity(outputDirectory);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
return Object.freeze({
|
||||
electronVersion: electronPackage.version,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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<FileSystemCapacity>;
|
||||
}
|
||||
|
||||
export interface PerformanceArtifactCapacity {
|
||||
readonly availableBytes: bigint;
|
||||
readonly checkedPath: string;
|
||||
readonly minimumBytes: bigint;
|
||||
}
|
||||
|
||||
async function readFileSystemCapacity(
|
||||
path: string
|
||||
): Promise<FileSystemCapacity> {
|
||||
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<PerformanceArtifactCapacity> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, Record<string, unknown>>;
|
||||
dependsOn?: unknown;
|
||||
executor?: unknown;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ProjectConfiguration {
|
||||
targets: Record<string, TargetConfiguration>;
|
||||
}
|
||||
|
||||
interface NxGraphTask {
|
||||
outputs?: string[];
|
||||
target: {
|
||||
project: string;
|
||||
target: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NxGraph {
|
||||
tasks: {
|
||||
tasks: Record<string, NxGraphTask>;
|
||||
};
|
||||
}
|
||||
|
||||
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, unknown> | string
|
||||
>;
|
||||
const rendererBuild = dependencies.find(
|
||||
(dependency): dependency is Record<string, unknown> =>
|
||||
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/);
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, ParsedPerformancePhaseEvent[]>();
|
||||
|
||||
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<string, unknown> {
|
||||
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');
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, ParsedRendererPerformancePhaseEvent[]>();
|
||||
|
||||
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<string, unknown> {
|
||||
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');
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
)['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
|
||||
);
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -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['main']['rendererWindow']['rss']> = {}
|
||||
): 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\(/);
|
||||
});
|
||||
@@ -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<RendererProcessRssCapture['unavailableReason'], null>
|
||||
| '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,
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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<RendererWindowRssSessionFactory> {
|
||||
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();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<void>;
|
||||
prime(): Promise<void>;
|
||||
requestCount(): number;
|
||||
}
|
||||
|
||||
export async function startSyntheticM3uServer(
|
||||
fixture: SyntheticM3uFixture
|
||||
): Promise<SyntheticM3uServer> {
|
||||
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<void> | 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<void> {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
rejectPromise(error);
|
||||
return;
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
Loaded 100 of 317 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user