test(performance): count startup phases and SQL statements for the J1 launch journey

Implements plan item A2. With IPTVNATOR_PERF_CAPTURE=1 the main process
keeps named counters and registers a main-only performance:read-counters
IPC handler; without the flag nothing is counted and the handler does not
exist.

- debug-trace.ts owns the registry; traceStartupPhase replaces the
  trace('startup', ...) sites and counts main.startupPhases.
- The database worker counts executed statements through better-sqlite3's
  Statement prototype (the verbose callback expands every statement and
  made bulk inserts 2-4x slower) and posts the count over its message
  port, flushed before every other worker message. The main-thread shared
  connection is counted through a new connection observer in the shared
  database library.
- The first main window freezes main.modulesRegisteredBeforeWindow at
  creation and main.sqlStatementsBeforeReadyToShow at ready-to-show.
- The journey gate drops the ready-to-show that Electron emits for the
  about:blank detour, so the app sees the real document's first paint,
  and taps the counters handler; the J1 record reads both counters after
  the renderer probe completes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 18:48:29 +02:00
1 parent 9d02f90dfe
commit 37ac72b951
38 files changed
+1817 -73

No files matched your search

+54 -7
View File
@@ -63,7 +63,8 @@ longer in the DOM, and a source card has a non-empty client rect. Counters are
frozen at that microtask checkpoint, so bridge calls and mutations issued
later in the same task are included and everything after it is not.
Three test-side pieces are injected; production code is not changed:
Three test-side pieces are injected. The app itself only contributes the
main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
- `journey-renderer-gate.cjs` is loaded into the main process with `-r`, the
mechanism Playwright uses for its own loader. Playwright resolves
@@ -72,7 +73,15 @@ Three test-side pieces are injected; production code is not changed:
registered afterwards would race the first document. The gate makes the
first `loadFile` navigate to `about:blank` and holds the real load until
the test releases it. A 15 s safety timeout releases it on its own and the
iteration is then invalid.
iteration is then invalid. Electron emits `ready-to-show` for the first
paint of a hidden window, and `about:blank` paints too, so the gate drops
that event while the window shows `about:blank`; otherwise the app would
show a blank window and freeze its `ready-to-show` counter before its own
document exists. Electron emits the event again for the real document's
first paint because the window is still hidden, which is the moment
production sees. The gate also keeps the listener the app registers with
`ipcMain.handle('performance:read-counters')`, so the test can call it from
the main process.
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
`about:blank` page, so it runs at the start of the real document. It
records that it ran while the document was still `loading` with zero
@@ -92,12 +101,54 @@ Three test-side pieces are injected; production code is not changed:
| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. |
| `renderer.longTasks` | `longtask` entries over 50 ms up to that same cutoff, which includes the task that rendered the card. The count depends on machine speed, so it is evidence until a run shows it is stable on the CI runner. |
#### Main-process counters
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
`apps/electron-backend/src/app/services/debug-trace.ts` keeps named counters
in the main process (`services/performance-counters.ts`) and `main.ts`
registers the `performance:read-counters` IPC handler. Without the flag
nothing is counted, no listener is attached and the handler does not exist;
the preload never exposes the channel. After the renderer probe completes,
`journey-main-counters.ts` calls the handler through `electronApp.evaluate`
and the gate's tap.
| Counter | Source |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `main.modulesRegisteredBeforeWindow` | `main.startupPhases`, one per `traceStartupPhase` call (the phases printed as `[startup]` trace lines), frozen right after the first main window is constructed. |
| `main.sqlStatementsBeforeReadyToShow` | `main.sqlStatements`, frozen at the first main window's `ready-to-show`. It counts the statements of the main-thread connection (`sql-main`, schema creation and migrations) and of the database worker, which posts its count over its message port ([DB worker](sqlite-db-worker.md)). |
Main-thread statements are counted synchronously. The worker flushes its
count before every other message it posts, so every worker statement whose
response the main process has handled is included. The worker count is
ordered against the worker's responses, not against wall-clock: statements
whose count is still in flight when `ready-to-show` is dispatched are not.
One call of `run`, `get`, `all`, `iterate` or `exec` that returns normally is
one statement; on the launch workloads this matches the number of SQL trace
lines exactly.
`main.sqlStatementsBeforeReadyToShow` is not yet deterministic. The main
thread runs the shared connection's schema creation and migrations (about 90
statements on the J1 profile) in one synchronous block after the load event,
and `ready-to-show` is dispatched after it. The stale-download and
stale-recording recovery that follows (one statement each) races the event,
so iterations differ by two and the summary marks the counter
`stable: false`. The database worker runs no statement before the first
paint.
Each frozen counter carries its epoch. The record refuses an iteration whose
window counter was frozen after the gate saw the first load, or whose
`ready-to-show` counter was frozen before the gate released the real
document. Running totals at read time are kept under
`evidence.mainCountersAtRead`, the freeze epochs under
`evidence.epochs.mainWindowCreated` and `evidence.epochs.mainReadyToShow`, and
the number of dropped blank `ready-to-show` events under
`evidence.rendererGateReadyToShowHeldOnBlank`.
Counters are exact: the summary carries the value shared by every measured
iteration. When iterations disagree, the summary reports the maximum and marks
the counter `stable: false` under `counterStability`; such a counter is not
promoted to a guardrail until it is deterministic.
Two counters from the plan are listed under `unavailable` with the reason
One counter from the plan is listed under `unavailable` with the reason
instead of being faked:
- `renderer.cdTicksToFirstCard`: the `electron-performance` build optimizes
@@ -105,10 +156,6 @@ instead of being faked:
`window.ng` and `ɵsetProfiler` is unavailable. The probe checks this at the
terminal moment and the record refuses a build where the hook exists but was
not counted.
- `main.sqlStatementsBeforeReadyToShow`: SQL statements are only visible as
worker-thread trace lines on stdout, which Node forwards asynchronously, so
they cannot be ordered against `ready-to-show`. Plan item A2 adds a channel
that can be counted.
### Wall-clock
+18
View File
@@ -444,6 +444,24 @@ first, this receipt remains distinct from the later authoritative
exposing the pending request. Disabled profiling performs no receipt clock or
transport work, and `DatabaseWorkerClient.cancel()` remains fire-and-return.
With `IPTVNATOR_PERF_CAPTURE=1`, the worker connection also counts the SQL
statements it executes and posts `performance-sql-statements` messages that
carry only a positive count, never SQL text or bound values. Counts are
coalesced per microtask and flushed before every other worker message, so a
response never overtakes the statements that produced it.
`DatabaseWorkerClient` adds them to the main-process `main.sqlStatements`
counter and settles nothing. Statements are counted by wrapping the
execution methods of better-sqlite3's `Statement` prototype and the
connection's `exec`, not through the `verbose` callback: a callback makes
better-sqlite3 expand every statement's SQL, which made bulk inserts two to
four times slower. A call that throws is not counted, which matches the SQL
trace for statements that fail before execution. The main process counts its
own shared connection the same way (`services/main-sql-statement-count.ts`,
through the shared library's connection observer). Without the flag both
connections are opened unchanged. See
`workers/database-worker-sql-statement-count.ts` and
[performance journeys](performance-journeys.md).
## Renderer Contract
The preload bridge keeps the existing database methods but adds scoped worker
+1 -1
View File
@@ -30,7 +30,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm 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_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, 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_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, 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. It also keeps count-only main-process counters (startup phases, database worker SQL statements, and their values at main-window creation and `ready-to-show`) and registers the main-only `performance:read-counters` IPC handler, which the preload does not expose; see [performance journeys](../architecture/performance-journeys.md)
- `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 plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, 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
- `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=<dir>` relocates it. The startup trace reports the outcome as `compile-cache`