test(performance): prevent renderer heartbeat omission (#1308)

* test(performance): normalize sub-ms IPC clock skew

* test(performance): prevent heartbeat coordinated omission
This commit is contained in:
4gray authored and GitHub committed 2026-07-29 13:58:44 +02:00
1 parent deae0a2a4d
commit b1f77c678e
14 files changed
+707 -150

No files matched your search

@@ -18,10 +18,6 @@ interface ImportProbe {
}
interface RendererCaptureModule {
advanceM3uImportHeartbeatDeadline?: (
deadlineEpochMs: number,
nowEpochMs: number
) => number;
assertCompleteM3uImportRendererProbe?: (probe: ImportProbe) => void;
startM3uImportRendererCapture?: unknown;
}
@@ -32,7 +28,7 @@ interface RendererProbeModule {
recordM3uImportHeartbeat?: (
page: unknown,
deadlineEpochMs: number
) => Promise<void>;
) => Promise<number>;
stopM3uImportRendererProbe?: (page: unknown) => Promise<ImportProbe>;
}
@@ -248,12 +244,14 @@ test('clips drained long tasks and heartbeat samples to the operation window', a
);
browser.setNow(250);
await module.recordM3uImportHeartbeat?.(browser.page, 90);
await module.recordM3uImportHeartbeat?.(browser.page, 150);
await module.recordM3uImportHeartbeat?.(browser.page, 210);
const nextDeadline = await module.recordM3uImportHeartbeat?.(
browser.page,
150
);
const metrics = await module.stopM3uImportRendererProbe?.(browser.page);
assert.deepEqual(metrics?.heartbeatDelaysMs, [50]);
assert.equal(nextDeadline, 250);
assert.deepEqual(metrics?.heartbeatDelaysMs, [50, 0]);
assert.deepEqual(metrics?.longTasksMs, [20, 10]);
} finally {
browser.cleanup();
@@ -287,17 +285,6 @@ test('records the terminal animation-frame gap exactly once', async () => {
}
});
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);
@@ -377,6 +364,15 @@ test('fails closed when any terminal proof or the probe itself is missing', asyn
test('clears the harness heap sampler even when the renderer probe is missing', () => {
const source = readSource(captureUrl);
assert.ok(source);
const stopBackgroundWork = source.indexOf('const stopBackgroundWork');
const stopScheduling = source.indexOf(
'stopSchedulingBackgroundWork()',
stopBackgroundWork
);
const finalHeartbeat = source.indexOf(
'await flushHeartbeat()',
stopScheduling
);
const backgroundStop = source.indexOf(
'await stopBackgroundWork()',
source.indexOf('const stopCapture')
@@ -384,6 +380,9 @@ test('clears the harness heap sampler even when the renderer probe is missing',
const probeStop = source.indexOf('probe = await stopProbe()', backgroundStop);
assert.match(source, /clearInterval\(heapSampleTimer\)/);
assert.ok(stopScheduling >= 0);
assert.ok(finalHeartbeat > stopScheduling);
assert.ok(backgroundStop >= 0);
assert.ok(probeStop > backgroundStop);
assert.match(source, /assertFixedGridRendererHeartbeatCoverage\(/);
});
@@ -19,8 +19,10 @@ import {
waitForM3uImportTerminal,
} from './m3u-import-renderer-probe';
import { summarizeNumbers } from './performance-statistics';
const HEARTBEAT_INTERVAL_MS = 50;
import {
assertFixedGridRendererHeartbeatCoverage,
RENDERER_HEARTBEAT_INTERVAL_MS,
} from './renderer-heartbeat-fixed-grid';
export interface M3uImportRendererCaptureStartOptions {
readonly diagnostic: boolean;
@@ -156,30 +158,49 @@ export async function startM3uImportRendererCapture(
heartbeatTimer = setTimeout(
() => {
heartbeatTimer = null;
if (stopped) {
return;
}
heartbeatInFlight = recordM3uImportHeartbeat(page, deadline)
.then((nextDeadlineEpochMs) => {
heartbeatDeadlineEpochMs = nextDeadlineEpochMs;
})
.catch((error: unknown) => {
heartbeatError ??= error;
})
.finally(() => {
heartbeatInFlight = null;
if (stopped) {
const gridClosed =
heartbeatDeadlineEpochMs === deadline;
if (stopped || heartbeatError !== null || gridClosed)
return;
}
heartbeatDeadlineEpochMs =
advanceM3uImportHeartbeatDeadline(
deadline,
Date.now()
);
scheduleHeartbeat();
});
},
Math.max(0, deadline - Date.now())
);
};
const flushHeartbeat = async (): Promise<void> => {
await heartbeatInFlight;
if (
heartbeatError !== null ||
heartbeatDeadlineEpochMs === null
) {
return;
}
try {
heartbeatDeadlineEpochMs = await recordM3uImportHeartbeat(
page,
heartbeatDeadlineEpochMs
);
} catch (error) {
heartbeatError ??= error;
}
};
const stopBackgroundWork = (): Promise<void> => {
backgroundStopPromise ??= (async () => {
stopSchedulingBackgroundWork();
await heartbeatInFlight;
await flushHeartbeat();
await heapSamplePromise;
await sampleHeap();
})();
@@ -243,6 +264,11 @@ export async function startM3uImportRendererCapture(
throwCaptureError(heartbeatError, 'Renderer heartbeat failed');
throwCaptureError(probeError, 'Renderer probe stop failed');
assertCompleteM3uImportRendererProbe(probe);
assertFixedGridRendererHeartbeatCoverage(
probe.heartbeatDelaysMs,
probe.operationStartEpochMs,
probe.terminalEpochMs
);
return Object.freeze({
cpuProfilePath: artifacts.cpuProfilePath,
frameGap: summarizeNumbers(probe.frameGapsMs),
@@ -292,7 +318,7 @@ export async function startM3uImportRendererCapture(
triggerImport: async () => {
const operationStartEpochMs = await triggerM3uImport(page);
heartbeatDeadlineEpochMs =
operationStartEpochMs + HEARTBEAT_INTERVAL_MS;
operationStartEpochMs + RENDERER_HEARTBEAT_INTERVAL_MS;
scheduleHeartbeat();
return operationStartEpochMs;
},
@@ -300,19 +326,6 @@ export async function startM3uImportRendererCapture(
});
}
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 & {
@@ -0,0 +1,227 @@
/* 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, 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('waits for an in-flight heartbeat and flushes its next deadline once', async () => {
const browser = installFakeBrowser();
const outputDirectory = await mkdtemp(
join(tmpdir(), 'iptvnator-renderer-heartbeat-stop-')
);
const session = new FakeCdpSession();
const timerToken = Object.freeze({});
const descriptors = snapshotGlobals([
'setTimeout',
'clearTimeout',
'document',
'HTMLButtonElement',
]);
let scheduledHeartbeat: (() => void) | null = null;
let releaseHeartbeat!: () => void;
const heartbeatGate = new Promise<void>((resolve) => {
releaseHeartbeat = resolve;
});
const deliveredDeadlines: number[] = [];
class FakeButton {
readonly disabled = false;
readonly textContent = 'Add playlist';
click(): undefined {
return undefined;
}
}
const button = new FakeButton();
replaceGlobal('HTMLButtonElement', FakeButton);
replaceGlobal('document', {
querySelector: () => null,
querySelectorAll: () => [
{ querySelectorAll: () => [button] },
],
});
replaceGlobal('setTimeout', (callback: () => void) => {
scheduledHeartbeat = callback;
return timerToken;
});
replaceGlobal('clearTimeout', () => undefined);
const page = createFakePage(
browser,
session,
async (callback, argument) => {
const heartbeat = argument as unknown as {
firstDeadlineEpochMs?: number;
};
const result = await browser.page.evaluate(callback, argument);
if (typeof heartbeat.firstDeadlineEpochMs === 'number') {
deliveredDeadlines.push(heartbeat.firstDeadlineEpochMs);
if (deliveredDeadlines.length === 1) {
await heartbeatGate;
}
}
return result;
}
);
try {
const capture = await startM3uImportRendererCapture(page as never, {
diagnostic: false,
outputDirectory,
playlistTitle: 'Synthetic',
playlistUrl:
'http://127.0.0.1:43210/synthetic-performance.m3u',
});
browser.setNow(100);
await capture.triggerImport();
browser.setNow(150);
assert.ok(scheduledHeartbeat);
scheduledHeartbeat();
Object.assign(browser.readState() as object, {
firstChannelVisibleEpochMs: 220,
playlistId: 'synthetic-id',
routeReadyEpochMs: 200,
terminalEpochMs: 251,
uiPaintedEpochMs: 251,
});
browser.setNow(300);
const stopPromise = capture.stop();
await Promise.resolve();
releaseHeartbeat();
const metrics = await stopPromise;
assert.deepEqual(deliveredDeadlines, [150, 200]);
assert.deepEqual(metrics.probe.heartbeatDelaysMs, [0, 51, 1]);
} finally {
restoreGlobals(descriptors);
browser.cleanup();
await rm(outputDirectory, { force: true, recursive: true });
}
});
class FakeCdpSession extends EventEmitter {
async detach(): Promise<void> {
return undefined;
}
async send(method: string): Promise<Record<string, unknown>> {
return method === 'Runtime.getHeapUsage'
? { usedSize: 1_024 }
: {};
}
}
function installFakeBrowser() {
const descriptors = snapshotGlobals([
'performance',
'PerformanceObserver',
'cancelAnimationFrame',
'requestAnimationFrame',
'location',
'document',
]);
let nextFrameId = 1;
let nowMs = 0;
class FakePerformanceObserver {
static readonly supportedEntryTypes = ['longtask'];
disconnect(): undefined {
return undefined;
}
observe(): undefined {
return undefined;
}
takeRecords(): PerformanceEntry[] {
return [];
}
}
replaceGlobal('performance', { now: () => nowMs, timeOrigin: 0 });
replaceGlobal('PerformanceObserver', FakePerformanceObserver);
replaceGlobal('cancelAnimationFrame', () => undefined);
replaceGlobal('requestAnimationFrame', () => nextFrameId++);
replaceGlobal('location', { pathname: '/' });
replaceGlobal('document', { querySelector: () => null });
return {
cleanup: () => {
restoreGlobals(descriptors);
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
],
setNow: (value: number) => {
nowMs = value;
},
};
}
function createFakePage(
browser: ReturnType<typeof installFakeBrowser>,
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 snapshotGlobals(
keys: readonly string[]
): Map<string, PropertyDescriptor | undefined> {
return new Map(
keys.map((key) => [
key,
Object.getOwnPropertyDescriptor(globalThis, key),
])
);
}
function replaceGlobal(key: string, value: unknown): void {
Object.defineProperty(globalThis, key, {
configurable: true,
value,
writable: true,
});
}
function restoreGlobals(
descriptors: ReadonlyMap<string, PropertyDescriptor | undefined>
): void {
for (const [key, descriptor] of descriptors) {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
} else {
Reflect.deleteProperty(globalThis, key);
}
}
}
@@ -5,6 +5,10 @@ import {
type RendererPerformancePhaseHook,
} from '@iptvnator/shared/logging';
import {
recordFixedGridRendererHeartbeats,
} from './renderer-heartbeat-fixed-grid';
export const M3U_IMPORT_RENDERER_STATE_KEY =
'__iptvnatorM3uImportRendererCapture';
@@ -267,37 +271,12 @@ export async function triggerM3uImport(page: Page): Promise<number> {
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 }
): Promise<number> {
return recordFixedGridRendererHeartbeats(
page,
M3U_IMPORT_RENDERER_STATE_KEY,
deadlineEpochMs,
'm3u-import-renderer-probe-unavailable'
);
}
@@ -0,0 +1,115 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
import assert from 'node:assert/strict';
import test from 'node:test';
interface HeartbeatModule {
assertFixedGridRendererHeartbeatCoverage?: (
heartbeatDelaysMs: readonly number[],
operationStartEpochMs: number,
terminalEpochMs: number
) => void;
recordFixedGridRendererHeartbeats?: (
page: unknown,
stateKey: string,
firstDeadlineEpochMs: number,
unavailableError: string
) => Promise<number>;
}
const moduleUrl = new URL(
'./renderer-heartbeat-fixed-grid.ts',
import.meta.url
);
const modulePromise = import(moduleUrl.href)
.then((module) => module as HeartbeatModule)
.catch(() => null);
test('fails closed when a fixed-grid renderer heartbeat deadline is omitted', async () => {
const module = await modulePromise;
assert.ok(module?.assertFixedGridRendererHeartbeatCoverage);
assert.doesNotThrow(() =>
module.assertFixedGridRendererHeartbeatCoverage?.(
[101, 51, 1],
100,
251
)
);
assert.throws(
() =>
module.assertFixedGridRendererHeartbeatCoverage?.(
[101, 1],
100,
251
),
/renderer-heartbeat-grid-incomplete:2\/3/
);
assert.throws(
() =>
module.assertFixedGridRendererHeartbeatCoverage?.(
[101, 51, 1, 0],
100,
251
),
/renderer-heartbeat-grid-incomplete:4\/3/
);
});
test('keeps the first deadline unrecorded when the operation ends before it', async () => {
const module = await modulePromise;
assert.ok(module?.recordFixedGridRendererHeartbeats);
const stateKey = '__fixedGridShortOperation';
const target = globalThis as unknown as Record<string, unknown>;
const performanceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'performance'
);
target[stateKey] = {
heartbeatDelaysMs: [],
operationStartEpochMs: 100,
terminalEpochMs: 149,
};
Object.defineProperty(globalThis, 'performance', {
configurable: true,
value: { now: () => 200, timeOrigin: 0 },
});
const page = {
evaluate: async (
callback: (argument: unknown) => unknown,
argument: unknown
) => callback(argument),
};
try {
const nextDeadline =
await module.recordFixedGridRendererHeartbeats(
page,
stateKey,
150,
'synthetic-unavailable'
);
assert.equal(nextDeadline, 150);
assert.deepEqual(
(
target[stateKey] as {
heartbeatDelaysMs: number[];
}
).heartbeatDelaysMs,
[]
);
} finally {
delete target[stateKey];
restoreGlobal('performance', performanceDescriptor);
}
});
function restoreGlobal(
key: string,
descriptor: PropertyDescriptor | undefined
): void {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
} else {
Reflect.deleteProperty(globalThis, key);
}
}
@@ -0,0 +1,97 @@
import type { Page } from '@playwright/test';
export const RENDERER_HEARTBEAT_INTERVAL_MS = 50;
interface RendererHeartbeatState {
readonly heartbeatDelaysMs: number[];
readonly operationStartEpochMs: number;
readonly terminalEpochMs: number | null;
}
export async function recordFixedGridRendererHeartbeats(
page: Page,
stateKey: string,
firstDeadlineEpochMs: number,
unavailableError: string
): Promise<number> {
return page.evaluate(
({
firstDeadlineEpochMs,
intervalMs,
stateKey,
unavailableError,
}) => {
const state = (globalThis as unknown as Record<string, unknown>)[
stateKey
] as RendererHeartbeatState | undefined;
if (
!state ||
!Number.isFinite(firstDeadlineEpochMs) ||
state.operationStartEpochMs <= 0 ||
firstDeadlineEpochMs < state.operationStartEpochMs
) {
throw new Error(unavailableError);
}
const receivedEpochMs =
performance.timeOrigin + performance.now();
const effectiveReceivedEpochMs = Math.max(
receivedEpochMs,
firstDeadlineEpochMs
);
const captureEpochMs =
state.terminalEpochMs === null
? effectiveReceivedEpochMs
: Math.min(
effectiveReceivedEpochMs,
state.terminalEpochMs
);
if (captureEpochMs < firstDeadlineEpochMs) {
return firstDeadlineEpochMs;
}
const elapsedDeadlineCount =
Math.floor(
(captureEpochMs - firstDeadlineEpochMs) / intervalMs
) + 1;
for (let index = 0; index < elapsedDeadlineCount; index += 1) {
const deadlineEpochMs =
firstDeadlineEpochMs + index * intervalMs;
state.heartbeatDelaysMs.push(
Math.max(0, captureEpochMs - deadlineEpochMs)
);
}
return (
firstDeadlineEpochMs +
elapsedDeadlineCount * intervalMs
);
},
{
firstDeadlineEpochMs,
intervalMs: RENDERER_HEARTBEAT_INTERVAL_MS,
stateKey,
unavailableError,
}
);
}
export function assertFixedGridRendererHeartbeatCoverage(
heartbeatDelaysMs: readonly number[],
operationStartEpochMs: number,
terminalEpochMs: number
): void {
if (
!Number.isFinite(operationStartEpochMs) ||
!Number.isFinite(terminalEpochMs) ||
terminalEpochMs < operationStartEpochMs
) {
throw new Error('renderer-heartbeat-grid-window-invalid');
}
const expectedCount = Math.floor(
(terminalEpochMs - operationStartEpochMs) /
RENDERER_HEARTBEAT_INTERVAL_MS
);
if (heartbeatDelaysMs.length !== expectedCount) {
throw new Error(
`renderer-heartbeat-grid-incomplete:${heartbeatDelaysMs.length}/${expectedCount}`
);
}
}
@@ -35,6 +35,7 @@ const LEGACY_METHODS = Object.freeze({
DB_GET_APP_PLAYLIST: 'dbGetAppPlaylist',
DB_UPSERT_APP_PLAYLIST: 'dbUpsertAppPlaylist',
});
const LEGACY_RESPONSE_CLOCK_SKEW_TOLERANCE_MS = 1;
export function deriveXtreamPhaseCapture(
scenarioId: XtreamPhaseAttributionInput['scenarioId'],
@@ -263,6 +264,10 @@ function legacyIpcPair(
const requestTimeline = oneTimeline(main, request, 'db-request');
const success = oneTimeline(main, request, 'preload-performance-success');
const responseStartEpochMs = requireEpoch(request.responseEpochMs);
const responseEndEpochMs = normalizeLegacyResponseEndEpochMs(
requireEpoch(success.sourceEpochMs),
responseStartEpochMs
);
const correlationId = xtreamIpcProducerIdentityKey(
XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD,
ipcCallId
@@ -281,7 +286,7 @@ function legacyIpcPair(
{
boundary: 'response',
correlationId,
endEpochMs: requireEpoch(success.sourceEpochMs),
endEpochMs: responseEndEpochMs,
ipcCallId,
method,
outcome: 'success',
@@ -291,6 +296,20 @@ function legacyIpcPair(
];
}
function normalizeLegacyResponseEndEpochMs(
endEpochMs: number,
startEpochMs: number
): number {
if (endEpochMs >= startEpochMs) return endEpochMs;
if (
startEpochMs - endEpochMs >=
LEGACY_RESPONSE_CLOCK_SKEW_TOLERANCE_MS
) {
invalid();
}
return startEpochMs;
}
function oneTimeline(
main: XtreamMainCaptureResult,
request: WorkerRequestPerformanceMetrics,
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract';
import { assembleXtreamRawIteration } from './xtream-iteration-assembler';
import { createXtreamAssemblerFixture } from './xtream-iteration-assembler.fixture';
describe('Xtream legacy IPC clock skew', () => {
it('normalizes sub-millisecond response skew and rejects larger reversals', () => {
const input = createXtreamAssemblerFixture(
XTREAM_SCENARIO_ID.REFRESH_LARGE
);
const request = input.mainCapture.requests.find(
({ operation }) => operation === 'DB_UPSERT_APP_PLAYLIST'
);
assert.equal(typeof request?.responseEpochMs, 'number');
const updateSuccessEpoch = (sourceEpochMs: number) => ({
...input,
mainCapture: {
...input.mainCapture,
capture: {
...input.mainCapture.capture,
timeline: input.mainCapture.capture.timeline.map((record) =>
record.type === 'preload-performance-success' &&
record.ipcCallId === request?.ipcCallId &&
record.operation === request.operation
? { ...record, sourceEpochMs }
: record
),
},
},
});
const normalized = assembleXtreamRawIteration(
updateSuccessEpoch(Number(request?.responseEpochMs) - 0.25)
);
const response = normalized.phaseCapture.ipcSpans.find(
({ boundary, method }) =>
boundary === 'response' &&
method === 'dbUpsertAppPlaylist'
);
assert.ok(response);
assert.equal(response.endEpochMs, response.startEpochMs);
assert.throws(
() =>
assembleXtreamRawIteration(
updateSuccessEpoch(Number(request?.responseEpochMs) - 1)
),
/xtream-iteration-assembly-invalid/
);
});
});
@@ -3,10 +3,6 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
interface CaptureModule {
advanceXtreamRendererHeartbeatDeadline?: (
deadlineEpochMs: number,
nowEpochMs: number
) => number;
selectXtreamRendererTarget?: (session: FakeSession) => Promise<string>;
startXtreamRendererCapture?: unknown;
}
@@ -122,7 +118,7 @@ test('arms the renderer without sampling heap or starting diagnostics before the
assert.ok(diagnosticStart > operationBoundary);
assert.ok(heapTimerStart > operationBoundary);
assert.equal(source.match(/await sampleHeap\(\)/g)?.length, 1);
assert.match(source, /HEARTBEAT_INTERVAL_MS\s*=\s*50/);
assert.match(source, /RENDERER_HEARTBEAT_INTERVAL_MS/);
assert.match(source, /Runtime\.getHeapUsage/);
assert.match(source, /HeapProfiler\.collectGarbage/);
assert.match(source, /createRendererArtifactCapture/);
@@ -202,6 +198,10 @@ test('cuts off scheduling and diagnostics before forced GC and snapshot work', (
const source = readFileSync(captureUrl, 'utf8');
const stopCapture = source.indexOf('const stopCapture');
const stopScheduling = source.indexOf('stopScheduling()', stopCapture);
const finalHeartbeat = source.indexOf(
'await flushHeartbeat()',
stopScheduling
);
const diagnosticStop = source.indexOf(
'await stopRendererDiagnosticCapture(session, artifacts)',
stopCapture
@@ -217,9 +217,11 @@ test('cuts off scheduling and diagnostics before forced GC and snapshot work', (
assert.ok(stopCapture >= 0);
assert.ok(stopScheduling > stopCapture);
assert.ok(diagnosticStop > stopScheduling);
assert.ok(finalHeartbeat > stopScheduling);
assert.ok(diagnosticStop > finalHeartbeat);
assert.ok(forcedGc > diagnosticStop);
assert.ok(heapSnapshot > forcedGc);
assert.match(source, /assertFixedGridRendererHeartbeatCoverage\(/);
});
test('persists only error counts and cleans up listeners/session idempotently', () => {
@@ -232,11 +234,3 @@ test('persists only error counts and cleans up listeners/session idempotently',
assert.match(source, /page\.off\('pageerror'/);
assert.match(source, /disposePromise/);
});
test('uses deadline-based heartbeat catch-up without accumulating drift', async () => {
const module = await modulePromise;
assert.ok(module?.advanceXtreamRendererHeartbeatDeadline);
assert.equal(module.advanceXtreamRendererHeartbeatDeadline(150, 149), 200);
assert.equal(module.advanceXtreamRendererHeartbeatDeadline(150, 251), 300);
});
@@ -30,8 +30,10 @@ import {
recordXtreamRendererConsoleError,
type XtreamRendererConsoleErrorBreakdown,
} from './xtream-renderer-console-errors';
const HEARTBEAT_INTERVAL_MS = 50;
import {
assertFixedGridRendererHeartbeatCoverage,
RENDERER_HEARTBEAT_INTERVAL_MS,
} from './renderer-heartbeat-fixed-grid';
export interface XtreamRendererCaptureOptions {
readonly diagnostic: boolean;
@@ -210,27 +212,46 @@ export async function startXtreamRendererCapture(
heartbeatTimer = setTimeout(
() => {
heartbeatTimer = null;
if (stopped) return;
heartbeatInFlight = recordXtreamRendererHeartbeat(
page,
deadline
)
.then((nextDeadlineEpochMs) => {
heartbeatDeadlineEpochMs = nextDeadlineEpochMs;
})
.catch((error: unknown) => {
heartbeatError ??= error;
})
.finally(() => {
heartbeatInFlight = null;
if (stopped) return;
heartbeatDeadlineEpochMs =
advanceXtreamRendererHeartbeatDeadline(
deadline,
Date.now()
);
const gridClosed =
heartbeatDeadlineEpochMs === deadline;
if (stopped || heartbeatError !== null || gridClosed)
return;
scheduleHeartbeat();
});
},
Math.max(0, deadline - Date.now())
);
};
const flushHeartbeat = async (): Promise<void> => {
await heartbeatInFlight;
if (
heartbeatError !== null ||
heartbeatDeadlineEpochMs === null
) {
return;
}
try {
heartbeatDeadlineEpochMs = await recordXtreamRendererHeartbeat(
page,
heartbeatDeadlineEpochMs
);
} catch (error) {
heartbeatError ??= error;
}
};
const stopCapture = async (): Promise<XtreamRendererCaptureMetrics> => {
try {
stopScheduling();
@@ -238,10 +259,10 @@ export async function startXtreamRendererCapture(
if (measurementStartEpochMs === null) {
throw new Error('xtream-renderer-operation-not-started');
}
await flushHeartbeat();
if (options.diagnostic) {
await stopRendererDiagnosticCapture(session, artifacts);
}
await heartbeatInFlight;
await heapInFlight;
const probe = await stopXtreamRendererProbe(page);
await session.send('HeapProfiler.enable');
@@ -260,6 +281,11 @@ export async function startXtreamRendererCapture(
throwIfError(heapError, 'xtream-renderer-heap-sampling-failed');
throwIfError(heartbeatError, 'xtream-renderer-heartbeat-failed');
assertCompleteXtreamRendererProbe(probe);
assertFixedGridRendererHeartbeatCoverage(
probe.heartbeatDelaysMs,
probe.operationStartEpochMs,
probe.terminalEpochMs
);
assertRendererMeasurementWindow({
captureCutoffEpochMs,
measurementStartEpochMs,
@@ -328,7 +354,8 @@ export async function startXtreamRendererCapture(
await sampleHeap();
throwIfError(heapError, 'xtream-renderer-heap-sampling-failed');
heapTimer = setInterval(() => void sampleHeap(), 20);
heartbeatDeadlineEpochMs = startedAt + HEARTBEAT_INTERVAL_MS;
heartbeatDeadlineEpochMs =
startedAt + RENDERER_HEARTBEAT_INTERVAL_MS;
scheduleHeartbeat();
return startedAt;
},
@@ -348,19 +375,6 @@ export async function startXtreamRendererCapture(
});
}
export function advanceXtreamRendererHeartbeatDeadline(
deadlineEpochMs: number,
nowEpochMs: number
): number {
const next = deadlineEpochMs + HEARTBEAT_INTERVAL_MS;
if (next > nowEpochMs) return next;
return (
next +
(Math.floor((nowEpochMs - next) / HEARTBEAT_INTERVAL_MS) + 1) *
HEARTBEAT_INTERVAL_MS
);
}
function removePageErrorListeners(
page: Page,
onConsole: (message: ConsoleMessage) => void,
@@ -7,6 +7,9 @@ import {
XTREAM_RENDERER_STOPPED_KEY,
type XtreamRendererProbeMetrics,
} from './xtream-renderer-probe-contract';
import {
recordFixedGridRendererHeartbeats,
} from './renderer-heartbeat-fixed-grid';
export async function beginXtreamRendererOperation(
page: Page
@@ -33,37 +36,12 @@ export async function beginXtreamRendererOperation(
export async function recordXtreamRendererHeartbeat(
page: Page,
deadlineEpochMs: number
): Promise<void> {
await page.evaluate(
({ deadlineEpochMs, stateKey }) => {
const state = (globalThis as unknown as Record<string, unknown>)[
stateKey
] as
| {
heartbeatDelaysMs: number[];
operationStartEpochMs: number;
terminalEpochMs: number | null;
}
| undefined;
if (!state || state.operationStartEpochMs <= 0) {
throw new Error('xtream-renderer-probe-unavailable');
}
if (
deadlineEpochMs >= state.operationStartEpochMs &&
(state.terminalEpochMs === null ||
deadlineEpochMs <= state.terminalEpochMs)
) {
const received = performance.timeOrigin + performance.now();
state.heartbeatDelaysMs.push(
Math.max(
0,
Math.min(received, state.terminalEpochMs ?? received) -
deadlineEpochMs
)
);
}
},
{ deadlineEpochMs, stateKey: XTREAM_RENDERER_STATE_KEY }
): Promise<number> {
return recordFixedGridRendererHeartbeats(
page,
XTREAM_RENDERER_STATE_KEY,
deadlineEpochMs,
'xtream-renderer-probe-unavailable'
);
}
@@ -26,7 +26,7 @@ interface ProbeModule {
recordXtreamRendererHeartbeat?: (
page: unknown,
deadlineEpochMs: number
) => Promise<void>;
) => Promise<number>;
stopXtreamRendererProbe?: (page: unknown) => Promise<unknown>;
waitForXtreamRendererTerminal?: (page: unknown) => Promise<void>;
}
@@ -81,6 +81,52 @@ test('requires a final store marker, DOM gate, and two rendered frames', () => {
assert.match(source, />=\s*2/);
});
test('records every elapsed fixed-grid heartbeat deadline in one renderer delivery', async () => {
const module = await modulePromise;
assert.ok(module?.recordXtreamRendererHeartbeat);
assert.ok(module.XTREAM_RENDERER_STATE_KEY);
const performanceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'performance'
);
const target = globalThis as unknown as Record<string, unknown>;
const stateKey = module.XTREAM_RENDERER_STATE_KEY;
const page = {
evaluate: async (
callback: (argument: unknown) => unknown,
argument: unknown
) => callback(argument),
};
target[stateKey] = {
heartbeatDelaysMs: [],
operationStartEpochMs: 100,
terminalEpochMs: null,
};
Object.defineProperty(globalThis, 'performance', {
configurable: true,
value: { now: () => 251, timeOrigin: 0 },
});
try {
const nextDeadline = await module.recordXtreamRendererHeartbeat(
page,
150
);
assert.equal(nextDeadline, 300);
assert.deepEqual(
(
target[stateKey] as {
heartbeatDelaysMs: number[];
}
).heartbeatDelaysMs,
[101, 51, 1]
);
} finally {
delete target[stateKey];
restoreGlobal('performance', performanceDescriptor);
}
});
test('rejects incomplete and internally inconsistent terminal evidence', async () => {
const module = await modulePromise;
assert.ok(module?.assertCompleteXtreamRendererProbe);
@@ -208,3 +254,14 @@ function backgroundRefreshState(identity: {
terminalEpochMs: null,
};
}
function restoreGlobal(
key: string,
descriptor: PropertyDescriptor | undefined
): void {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
} else {
Reflect.deleteProperty(globalThis, key);
}
}
+10 -5
View File
@@ -128,11 +128,16 @@ therefore `N/A` with
Instrumentation is development-only, opt-in, fail-neutral, and count-only. It
must not scan or log playlist payloads to generate metadata. Renderer
long-task, frame-gap, and heartbeat samples are clipped to the measured
operation boundary. Main capture stops only after the upsert and route-reload
GET responses plus both asynchronous preload success markers have arrived, so
return-clone attribution cannot race capture shutdown. Formal comparison fails
closed after writing raw results if exact-window renderer RSS, database-worker
peak heap/external samples, or the database worker's explicit post-GC heap is
operation boundary. The external heartbeat uses a fixed 50 ms deadline grid:
each renderer delivery records every elapsed deadline, and successful shutdown
performs one terminal-clipped drain after any in-flight delivery. The capture
fails closed unless the final sample count matches the complete operation
grid, preventing coordinated omission when an import blocks several ticks.
Main capture stops only after the upsert and route-reload GET responses plus
both asynchronous preload success markers have arrived, so return-clone
attribution cannot race capture shutdown. Formal comparison fails closed after
writing raw results if exact-window renderer RSS, database-worker peak
heap/external samples, or the database worker's explicit post-GC heap is
missing or incoherent. Both initial-import database requests in every measured
run must also contain coherent event-loop delay, event-loop utilization, and
thread-CPU metrics; the validity record exposes the exact expected and valid
+7
View File
@@ -488,6 +488,13 @@ installing main capture. Its result is discarded: this pre-arm only guarantees
that the exact worker can be profiled and happens before seed or measured
capture.
The renderer heartbeat is sampled on a fixed 50 ms deadline grid. One CDP
delivery records every elapsed grid point, and successful capture shutdown
waits for an in-flight delivery before performing one terminal-clipped drain.
The iteration fails closed if the resulting sample count does not cover the
complete operation window; this avoids coordinated omission when renderer work
delays multiple heartbeat deadlines.
Diagnostic profiles are evidence envelopes, not comparison totals. Starting
Chromium tracing and the CPU profilers can add a short pre-trigger setup segment
that contains no application workload; the raw boundary timestamps preserve