test(perf): launch J2 without SQL counting and stamp mock requests in sub-ms

- runLaunchJourney takes the launch instrumentation; only J1 turns on the
  main-process counters and IPTVNATOR_PERF_COUNT_SQL, so J2's click is not
  measured under the hook that wraps every SQLite statement. The flags are
  built in journey-launch-environment.ts, which the SQL opt-in guard now
  expects, and a launch record without main counters is rejected.
- The mock ledger stamps arrivals with performance.timeOrigin +
  performance.now(), the same sub-millisecond epoch as the renderer's
  click, so a request later in the click's millisecond is not counted
  before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 08:58:37 +02:00
1 parent 058df6f08e
commit a28b26b550
10 files changed
+103 -22

No files matched your search

@@ -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<T>(
templateDirectory: string,
timeoutMs: number,
instrumentation: JourneyLaunchInstrumentation,
continueJourney: (session: LaunchJourneySession) => Promise<T>
): Promise<{
readonly continuation: T;
@@ -156,17 +164,9 @@ export async function runLaunchJourney<T>(
);
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<T>(
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');
}
@@ -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,
@@ -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',
});
});
@@ -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<string, string> {
return {
...(instrumentation.mainCounters
? { IPTVNATOR_PERF_CAPTURE: '1', IPTVNATOR_PERF_COUNT_SQL: '1' }
: {}),
IPTVNATOR_TRACE_IPC: '1',
};
}
@@ -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'),
]);
});
@@ -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();
@@ -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,
@@ -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 }),
@@ -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');
}
+8 -3
View File
@@ -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