diff --git a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts index 24fd41e27..b69633e70 100644 --- a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts @@ -32,6 +32,10 @@ import { assertJourneyMainCounters, readJourneyMainCounters, } from '../performance/journey-main-counters'; +import { + journeyLaunchEnvironment, + type JourneyLaunchInstrumentation, +} from '../performance/journey-launch-environment'; import { createLaunchJourneyProbeOptions, installJourneyRendererProbe, @@ -129,6 +133,7 @@ export async function measureLaunchJourney( const { launch } = await runLaunchJourney( templateDirectory, timeoutMs, + { mainCounters: true }, async () => undefined ); return launch; @@ -142,10 +147,13 @@ export async function measureLaunchJourney( * therefore in place before the renderer runs any script, and the probe, * capture and gate records still prove it. `continueJourney` runs in the * same process after J1's counters are final, before the app is closed. + * Without `instrumentation.mainCounters` the main-process counters and SQL + * counting stay off and `launch.mainCounters` is null. */ export async function runLaunchJourney( templateDirectory: string, timeoutMs: number, + instrumentation: JourneyLaunchInstrumentation, continueJourney: (session: LaunchJourneySession) => Promise ): Promise<{ readonly continuation: T; @@ -156,17 +164,9 @@ export async function runLaunchJourney( ); try { await cp(templateDirectory, dataDirectory, { recursive: true }); - // IPTVNATOR_PERF_CAPTURE turns on the main-process counters and - // their read handler, IPTVNATOR_PERF_COUNT_SQL the SQL statement - // count behind main.sqlStatementsBeforeReadyToShow; only this journey - // sets it. See journey-main-counters.ts. const env = buildElectronLaunchEnvironment( dataDirectory, - launchOptions({ - IPTVNATOR_PERF_CAPTURE: '1', - IPTVNATOR_PERF_COUNT_SQL: '1', - IPTVNATOR_TRACE_IPC: '1', - }) + launchOptions(journeyLaunchEnvironment(instrumentation)) ); const args = buildElectronLaunchArgs([ '-r', @@ -229,13 +229,15 @@ export async function runLaunchJourney( 10_000 ); // Read after the probe finished, so both frozen counters exist. - const mainCounters = assertJourneyMainCounters( - await readJourneyMainCounters( - electronApp, - JOURNEY_RENDERER_GATE_KEY - ), - gate - ); + const mainCounters = instrumentation.mainCounters + ? assertJourneyMainCounters( + await readJourneyMainCounters( + electronApp, + JOURNEY_RENDERER_GATE_KEY + ), + gate + ) + : null; if (ipc.installedEpochMs > renderer.installed.epochMs) { throw new Error('journey-main-ipc-capture-installed-late'); } diff --git a/apps/electron-backend-e2e/src/journeys/open-source.journey.ts b/apps/electron-backend-e2e/src/journeys/open-source.journey.ts index 075ab78bb..499e1e016 100644 --- a/apps/electron-backend-e2e/src/journeys/open-source.journey.ts +++ b/apps/electron-backend-e2e/src/journeys/open-source.journey.ts @@ -50,6 +50,9 @@ test('J2 open a source', async () => { const { continuation, launch } = await runLaunchJourney( templateDirectory, JOURNEY_ITERATION_TIMEOUT_MS, + // J2 does not read J1's main-process counters, so their + // SQL instrumentation stays off during the click. + { mainCounters: false }, (session) => measureOpenSourceJourney( session, diff --git a/apps/electron-backend-e2e/src/performance/journey-launch-environment.spec.ts b/apps/electron-backend-e2e/src/performance/journey-launch-environment.spec.ts new file mode 100644 index 000000000..7dde1bfde --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-launch-environment.spec.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { journeyLaunchEnvironment } from './journey-launch-environment'; + +test('J1 launches with the main-process counters and SQL counting', () => { + assert.deepEqual(journeyLaunchEnvironment({ mainCounters: true }), { + IPTVNATOR_PERF_CAPTURE: '1', + IPTVNATOR_PERF_COUNT_SQL: '1', + IPTVNATOR_TRACE_IPC: '1', + }); +}); + +test('a journey that does not read the main counters launches with the IPC trace only', () => { + assert.deepEqual(journeyLaunchEnvironment({ mainCounters: false }), { + IPTVNATOR_TRACE_IPC: '1', + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-launch-environment.ts b/apps/electron-backend-e2e/src/performance/journey-launch-environment.ts new file mode 100644 index 000000000..acd9f58af --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-launch-environment.ts @@ -0,0 +1,26 @@ +/** + * Instrumentation flags a journey launch sets on the Electron process. + * + * Every journey needs the renderer-API trace (`IPTVNATOR_TRACE_IPC`) for its + * IPC counters. Only J1 records the main-process counters: + * `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and + * `IPTVNATOR_PERF_COUNT_SQL` wraps every main-thread and worker SQLite + * statement to count it (see journey-main-counters.ts). A journey that + * continues from the launch without reading them (J2) leaves both off, so + * its latency and workload are not measured under that extra + * instrumentation. + */ +export interface JourneyLaunchInstrumentation { + readonly mainCounters: boolean; +} + +export function journeyLaunchEnvironment( + instrumentation: JourneyLaunchInstrumentation +): Record { + return { + ...(instrumentation.mainCounters + ? { IPTVNATOR_PERF_CAPTURE: '1', IPTVNATOR_PERF_COUNT_SQL: '1' } + : {}), + IPTVNATOR_TRACE_IPC: '1', + }; +} diff --git a/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts b/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts index 34684c01d..51caeb3ae 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts @@ -143,5 +143,10 @@ test('only the launch journey opts into SQL statement counting', () => { ) ) .map((file) => relative(sourceRoot, join(sourceRoot, file))); - assert.deepEqual(optedIn, [join('journeys', 'launch-journey-app.ts')]); + // The journey launch builds its flags in one place, and only the + // launch journey asks for the main counters there (J2 launches with + // `mainCounters: false`; see journey-launch-environment.spec.ts). + assert.deepEqual(optedIn, [ + join('performance', 'journey-launch-environment.ts'), + ]); }); diff --git a/apps/electron-backend-e2e/src/performance/journey-mock-request-ledger.spec.ts b/apps/electron-backend-e2e/src/performance/journey-mock-request-ledger.spec.ts index fe6efa696..13723485c 100644 --- a/apps/electron-backend-e2e/src/performance/journey-mock-request-ledger.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-mock-request-ledger.spec.ts @@ -87,6 +87,7 @@ test('forwards every request to the mock and records it from a mark', async () = ]); const mark = ledger.mark(); assert.equal(mark, 1); + const beforeSecond = performance.timeOrigin + performance.now(); const second = await fetch( `${ledger.origin}/player_api.php?username=u&password=p&action=get_account_info` ); @@ -103,6 +104,10 @@ test('forwards every request to the mock and records it from a mark', async () = ); assert.equal(since[0]?.method, 'GET'); assert.equal(since[0]?.sequence, 1); + // Stamped on the same high-resolution epoch as the renderer's click. + const stamped = since[0]?.epochMs ?? 0; + assert.ok(stamped >= beforeSecond); + assert.ok(stamped <= performance.timeOrigin + performance.now()); assert.ok(!JSON.stringify(ledger.since(0)).includes('password')); } finally { await ledger.close(); diff --git a/apps/electron-backend-e2e/src/performance/journey-mock-request-ledger.ts b/apps/electron-backend-e2e/src/performance/journey-mock-request-ledger.ts index 7336e5708..f63a679a4 100644 --- a/apps/electron-backend-e2e/src/performance/journey-mock-request-ledger.ts +++ b/apps/electron-backend-e2e/src/performance/journey-mock-request-ledger.ts @@ -23,6 +23,12 @@ import type { AddressInfo } from 'node:net'; * never stored. */ export interface JourneyMockRequest { + /** + * Arrival as a sub-millisecond epoch (`performance.timeOrigin + + * performance.now()`), the same form as the renderer's click stamp. With + * `Date.now()` a request later in the click's millisecond would compare + * as earlier than the fractional click and be counted before it. + */ readonly epochMs: number; readonly method: string; readonly route: string; @@ -94,7 +100,7 @@ export async function startJourneyMockRequestLedger( active -= 1; }); requests.push({ - epochMs: Date.now(), + epochMs: performance.timeOrigin + performance.now(), method: incoming.method ?? 'GET', route: describeJourneyMockRoute(incoming.url), sequence: requests.length, diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts index e1df24260..168b59348 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts @@ -155,6 +155,13 @@ test('rejects measurements whose clocks or probes are inconsistent', () => { }), /incomplete-probe/ ); + // A launch that ran without the main-process counters (as J2's do) is + // not a J1 measurement. + assert.throws( + () => + toLaunchIterationRecord(0, false, { ...base, mainCounters: null }), + /main-counters-missing/ + ); assert.throws( () => toLaunchIterationRecord(0, false, { ...base, spawnEpochMs: 2_700 }), diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts index b2ce47508..2ebae4c26 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts @@ -45,7 +45,8 @@ export interface LaunchJourneyMeasurement { readonly electronVersion: string; readonly gate: JourneyRendererGateState; readonly ipc: JourneyMainIpcCaptureState; - readonly mainCounters: JourneyMainCountersState; + /** Null when the launch ran without the main-process counters (J2). */ + readonly mainCounters: JourneyMainCountersState | null; readonly pid: number; readonly renderer: JourneyRendererProbeState; readonly spawnEpochMs: number; @@ -61,6 +62,9 @@ export function toLaunchIterationRecord( measurement: LaunchJourneyMeasurement ): JourneyIterationRecord { const { ipc, mainCounters, renderer, spawnEpochMs } = measurement; + if (mainCounters === null) { + throw new Error('launch-journey-record-main-counters-missing'); + } if (renderer.terminal === null || renderer.navigation === null) { throw new Error('launch-journey-record-incomplete-probe'); } diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index c6b6fc15e..b7ea4dc41 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -120,8 +120,9 @@ 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. SQL statements are counted only with `IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement -execution: the journey sets both, while the M3U, refresh and Xtream -benchmarks run with the capture flag alone and keep measuring unwrapped +execution: the launch journey sets both (the flags are built in +`journey-launch-environment.ts`), while J2's launches and the M3U, refresh +and Xtream benchmarks do not set the SQL flag and keep measuring unwrapped statements. A harness test fails if any other source sets the SQL flag. After the renderer probe completes, `journey-main-counters.ts` calls the handler through `electronApp.evaluate` and the gate's tap. @@ -247,7 +248,11 @@ running app to `measureOpenSourceJourney` in `src/journeys/open-source-journey-app.ts`. The click therefore happens after J1's terminal condition and its counters are final, and the two journeys never overlap. One warm-up and five measured iterations, as for J1; the J1 numbers -of these launches are not reported again. +of these launches are not reported again. J2 does not read J1's +main-process counters, so its launches run without `IPTVNATOR_PERF_CAPTURE` +and `IPTVNATOR_PERF_COUNT_SQL` (`runLaunchJourney` with +`mainCounters: false`). The click is not measured under the SQL hook that +wraps every statement. **Start.** The click on the dashboard card of the Xtream portal (`dashboard-recent-sources-rail-card` with the portal's name; the probe also