mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #1280 from 4gray/agent/perf-exact-process-memory
fix(perf): make process memory captures comparison-safe
This commit is contained in:
38 files changed
+5828
-222
No files matched your search
@@ -86,7 +86,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
- `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 preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses, plus 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
|
||||
|
||||
@@ -142,7 +142,7 @@ Useful narrower flags:
|
||||
- `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_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses, plus 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
|
||||
@@ -357,10 +357,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**:
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
RendererProcessRssCapture,
|
||||
RendererProcessRssUnavailableReason,
|
||||
} from './renderer-process-rss-capture';
|
||||
|
||||
export const PERFORMANCE_ITERATION_KIND = {
|
||||
DIAGNOSTIC: 'diagnostic',
|
||||
MEASURED: 'measured',
|
||||
@@ -15,6 +20,40 @@ 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 interface NumericDistribution {
|
||||
readonly count: number;
|
||||
readonly max: number | null;
|
||||
@@ -71,7 +110,7 @@ export interface WorkerRequestPerformanceMetrics {
|
||||
readonly workStartedEpochMs: number | null;
|
||||
}
|
||||
|
||||
export interface WorkerCaptureMetrics {
|
||||
export type WorkerCaptureMetrics = {
|
||||
readonly cancelPostedEpochMs: number | null;
|
||||
readonly cpuSystemMicros: number | null;
|
||||
readonly cpuUserMicros: number | null;
|
||||
@@ -80,16 +119,16 @@ export interface WorkerCaptureMetrics {
|
||||
readonly eventLoopUtilization: number | null;
|
||||
readonly kind: PerformanceWorkerKind;
|
||||
readonly operationId: string | null;
|
||||
readonly ordinal: number;
|
||||
readonly peakExternalBytes: number;
|
||||
readonly peakHeapUsedBytes: number;
|
||||
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;
|
||||
@@ -100,11 +139,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[];
|
||||
}
|
||||
|
||||
@@ -191,6 +236,43 @@ 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 InvalidRendererRssMeasuredRun {
|
||||
readonly missingSampleCount: number;
|
||||
readonly reason:
|
||||
RendererProcessRssUnavailableReason | '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 interface CancellationBenchmarkSummary {
|
||||
readonly cancellationEffectRate: number;
|
||||
readonly iterations: readonly CancellationIterationResult[];
|
||||
@@ -244,4 +326,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,
|
||||
RendererRssValidity,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
nonNegativeDifference,
|
||||
summarizeNumbers,
|
||||
} from './performance-statistics';
|
||||
import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity';
|
||||
|
||||
export function createCancellationIterationResult(input: {
|
||||
readonly kind: CancellationIterationResult['kind'];
|
||||
@@ -57,12 +59,11 @@ 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 workerRequestMetric = (
|
||||
kind: PerformanceWorkerKind,
|
||||
@@ -280,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(
|
||||
@@ -295,13 +300,63 @@ 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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function assessRendererRssValidity(
|
||||
iterations: readonly CancellationIterationResult[]
|
||||
): RendererRssValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +199,7 @@ 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')}`,
|
||||
@@ -175,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();
|
||||
@@ -191,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,
|
||||
@@ -236,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();
|
||||
@@ -259,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);
|
||||
}
|
||||
|
||||
@@ -363,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,3 +257,16 @@ test('the cancellation benchmark enables preload performance capture', () => {
|
||||
|
||||
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/);
|
||||
});
|
||||
@@ -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,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 });
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
type MainCaptureGenerationTransport,
|
||||
normalizeWorkerRequestPerformanceOutcome,
|
||||
selectMainCaptureGeneration,
|
||||
} from './worker-request-performance';
|
||||
@@ -130,9 +131,24 @@ function measuredIteration(
|
||||
postGcHeapUsedBytes: null,
|
||||
postGcRssBytes: null,
|
||||
},
|
||||
rendererPeakRssBytes: 0,
|
||||
responsiveEvents: 0,
|
||||
unresponsiveEvents: 0,
|
||||
rendererWindow: {
|
||||
responsiveEvents: 0,
|
||||
rss: {
|
||||
identity: {
|
||||
creationTime: 1_721_234_567_890,
|
||||
pid: 42,
|
||||
},
|
||||
missingSampleCount: 0,
|
||||
peakRssBytes: 0,
|
||||
unavailableReason: null,
|
||||
validSampleCount: 1,
|
||||
},
|
||||
unresponsiveEvents: 0,
|
||||
windowIdentity: {
|
||||
browserWindowId: 7,
|
||||
webContentsId: 11,
|
||||
},
|
||||
},
|
||||
workers,
|
||||
} as CancellationIterationResult['main'],
|
||||
phases: {} as CancellationIterationResult['phases'],
|
||||
@@ -202,7 +218,6 @@ test('main capture retains raw request identity, timestamps, metrics, and reason
|
||||
);
|
||||
|
||||
assert.match(source, /requestPerformance: \[\]/);
|
||||
assert.match(source, /record\.requestPerformance\.length > 0/);
|
||||
assert.match(source, /record\.requestPerformance\.map/);
|
||||
assert.match(source, /operationId: request\.identity\.operationId/);
|
||||
assert.match(source, /identity: request\.identity/);
|
||||
@@ -214,6 +229,26 @@ test('main capture retains raw request identity, timestamps, metrics, and reason
|
||||
);
|
||||
});
|
||||
|
||||
test('benchmark resolves one exact BrowserWindow and main capture never scans all windows', () => {
|
||||
const benchmarkSource = readFileSync(
|
||||
new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const mainCaptureSource = readFileSync(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
benchmarkSource,
|
||||
/electronApp\.browserWindow\(\s*app\.mainWindow\s*\)/
|
||||
);
|
||||
assert.match(benchmarkSource, /rendererWindowIdentity/);
|
||||
assert.doesNotMatch(mainCaptureSource, /BrowserWindow\.getAllWindows/);
|
||||
assert.doesNotMatch(mainCaptureSource, /type\.includes\('renderer'\)/);
|
||||
assert.doesNotMatch(mainCaptureSource, /type\.includes\('tab'\)/);
|
||||
});
|
||||
|
||||
test('capture-generation selection excludes a pre-start seed worker', () => {
|
||||
const seed = playlistWorker(
|
||||
requestPerformance('seed-request', 999, 999),
|
||||
@@ -259,6 +294,53 @@ test('capture-generation selection excludes a pre-start seed worker', () => {
|
||||
assert.equal(selected.workers[0]?.terminatedEpochMs, 2_100);
|
||||
});
|
||||
|
||||
test('fails closed for incoherent worker post-GC metrics at the Electron boundary', () => {
|
||||
const invalidOutcomes = [
|
||||
{
|
||||
postGcHeapUnavailableReason: 'gc-unavailable',
|
||||
postGcHeapUsedBytes: 100,
|
||||
},
|
||||
{
|
||||
postGcHeapUnavailableReason: null,
|
||||
postGcHeapUsedBytes: null,
|
||||
},
|
||||
{
|
||||
postGcHeapUnavailableReason: null,
|
||||
postGcHeapUsedBytes: -1,
|
||||
},
|
||||
{
|
||||
postGcHeapUnavailableReason: 'unknown-reason',
|
||||
postGcHeapUsedBytes: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const outcome of invalidOutcomes) {
|
||||
const selected = selectMainCaptureGeneration({
|
||||
captureGeneration: 3,
|
||||
metrics: measuredIteration([]).main,
|
||||
workers: [
|
||||
{
|
||||
captureGeneration: 3,
|
||||
metrics: {
|
||||
...databaseWorker([]),
|
||||
...outcome,
|
||||
},
|
||||
requests: [],
|
||||
},
|
||||
],
|
||||
} as unknown as MainCaptureGenerationTransport);
|
||||
const worker = selected.workers[0] as WorkerCaptureMetrics & {
|
||||
readonly postGcHeapUnavailableReason: string | null;
|
||||
};
|
||||
|
||||
assert.equal(worker.postGcHeapUsedBytes, null);
|
||||
assert.equal(
|
||||
worker.postGcHeapUnavailableReason,
|
||||
'post-gc-capture-invalid'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('raw outcomes retain missing and malformed captures while summaries exclude their null metrics', () => {
|
||||
const valid = normalizeWorkerRequestPerformanceOutcome(
|
||||
requestTransport(requestPerformance('request-1', 5, 100))
|
||||
|
||||
@@ -2,8 +2,10 @@ import type {
|
||||
EventLoopDelayMetrics,
|
||||
MainCaptureMetrics,
|
||||
WorkerCaptureMetrics,
|
||||
WorkerPostGcHeapCapture,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import { WORKER_POST_GC_HEAP_UNAVAILABLE_REASON } from './m3u-refresh-cancellation-contract';
|
||||
|
||||
export interface WorkerRequestPerformanceOutcomeTransport {
|
||||
readonly operation: string | null;
|
||||
@@ -62,6 +64,9 @@ const THREAD_CPU_REASONS = new Set([
|
||||
'thread-cpu-usage-invalid',
|
||||
'thread-cpu-usage-unavailable',
|
||||
]);
|
||||
const WORKER_POST_GC_HEAP_UNAVAILABLE_REASONS = new Set<string>(
|
||||
Object.values(WORKER_POST_GC_HEAP_UNAVAILABLE_REASON)
|
||||
);
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === 'object' && input !== null && !Array.isArray(input);
|
||||
@@ -84,6 +89,38 @@ function isNullableReason(
|
||||
return value === null || (typeof value === 'string' && allowed.has(value));
|
||||
}
|
||||
|
||||
function normalizeWorkerPostGcHeapCapture(
|
||||
input: Record<string, unknown>
|
||||
): WorkerPostGcHeapCapture {
|
||||
const postGcHeapUnavailableReason = input['postGcHeapUnavailableReason'];
|
||||
const postGcHeapUsedBytes = input['postGcHeapUsedBytes'];
|
||||
if (
|
||||
Number.isSafeInteger(postGcHeapUsedBytes) &&
|
||||
Number(postGcHeapUsedBytes) >= 0 &&
|
||||
postGcHeapUnavailableReason === null
|
||||
) {
|
||||
return {
|
||||
postGcHeapUnavailableReason: null,
|
||||
postGcHeapUsedBytes: Number(postGcHeapUsedBytes),
|
||||
};
|
||||
}
|
||||
if (
|
||||
postGcHeapUsedBytes === null &&
|
||||
typeof postGcHeapUnavailableReason === 'string' &&
|
||||
WORKER_POST_GC_HEAP_UNAVAILABLE_REASONS.has(postGcHeapUnavailableReason)
|
||||
) {
|
||||
return {
|
||||
postGcHeapUnavailableReason,
|
||||
postGcHeapUsedBytes: null,
|
||||
} as WorkerPostGcHeapCapture;
|
||||
}
|
||||
return {
|
||||
postGcHeapUnavailableReason:
|
||||
WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.INVALID_CAPTURE,
|
||||
postGcHeapUsedBytes: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEventLoopDelay(
|
||||
input: unknown
|
||||
): EventLoopDelayMetrics | null | undefined {
|
||||
@@ -280,8 +317,12 @@ export function selectMainCaptureGeneration(
|
||||
);
|
||||
const onlyRequest =
|
||||
requests.length === 1 ? (requests[0] ?? null) : null;
|
||||
const postGcHeap = normalizeWorkerPostGcHeapCapture(
|
||||
worker.metrics as unknown as Record<string, unknown>
|
||||
);
|
||||
return {
|
||||
...worker.metrics,
|
||||
...postGcHeap,
|
||||
eventLoopDelay: onlyRequest?.eventLoopDelay ?? null,
|
||||
eventLoopDelayUnavailableReason: onlyRequest
|
||||
? (onlyRequest.eventLoopDelayUnavailableReason ??
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* 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 WorkerTerminationGenerationApi {
|
||||
isCurrent(input: {
|
||||
readonly capturedGeneration: number | null;
|
||||
readonly currentGeneration: number;
|
||||
readonly recordGeneration: number | null;
|
||||
}): boolean;
|
||||
}
|
||||
|
||||
interface WorkerTerminationGenerationModule {
|
||||
createWorkerTerminationGenerationApi?: () => WorkerTerminationGenerationApi;
|
||||
}
|
||||
|
||||
const modulePromise = import(
|
||||
new URL('./worker-termination-generation.ts', import.meta.url).href
|
||||
)
|
||||
.then((module) => module as WorkerTerminationGenerationModule)
|
||||
.catch(() => null);
|
||||
|
||||
test('rejects a delayed termination completion after capture rollover or record reuse', async () => {
|
||||
const module = await modulePromise;
|
||||
assert.ok(module, 'worker termination generation guard must exist');
|
||||
const api = module.createWorkerTerminationGenerationApi?.();
|
||||
assert.ok(api);
|
||||
|
||||
assert.equal(
|
||||
api.isCurrent({
|
||||
capturedGeneration: 4,
|
||||
currentGeneration: 4,
|
||||
recordGeneration: 4,
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
api.isCurrent({
|
||||
capturedGeneration: 4,
|
||||
currentGeneration: 5,
|
||||
recordGeneration: 4,
|
||||
}),
|
||||
false,
|
||||
'a late completion must not write into the next global capture generation'
|
||||
);
|
||||
assert.equal(
|
||||
api.isCurrent({
|
||||
capturedGeneration: 4,
|
||||
currentGeneration: 5,
|
||||
recordGeneration: 5,
|
||||
}),
|
||||
false,
|
||||
'a reused record must not be finalized by the prior termination promise'
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed for missing or invalid generation identities', async () => {
|
||||
const module = await modulePromise;
|
||||
assert.ok(module);
|
||||
const api = module.createWorkerTerminationGenerationApi?.();
|
||||
assert.ok(api);
|
||||
|
||||
for (const capturedGeneration of [null, 0, -1, 1.5, Number.NaN]) {
|
||||
assert.equal(
|
||||
api.isCurrent({
|
||||
capturedGeneration,
|
||||
currentGeneration: 1,
|
||||
recordGeneration: 1,
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Electron termination callback applies the generation guard before mutating capture state', () => {
|
||||
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 terminationGeneration = terminateBlock.indexOf(
|
||||
'const terminationGeneration = record.captureGeneration'
|
||||
);
|
||||
const generationGuard = terminateBlock.indexOf(
|
||||
'workerTerminationGenerationApi.isCurrent'
|
||||
);
|
||||
const terminationMutation = terminateBlock.indexOf(
|
||||
'record.terminatedEpochMs = nowEpochMs()'
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
terminationGeneration >= 0 &&
|
||||
terminationGeneration < generationGuard &&
|
||||
generationGuard < terminationMutation,
|
||||
'late termination completion must be generation-gated before mutating the record or timeline'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface WorkerTerminationGenerationInput {
|
||||
readonly capturedGeneration: number | null;
|
||||
readonly currentGeneration: number;
|
||||
readonly recordGeneration: number | null;
|
||||
}
|
||||
|
||||
export interface WorkerTerminationGenerationApi {
|
||||
isCurrent(input: WorkerTerminationGenerationInput): boolean;
|
||||
}
|
||||
|
||||
export function createWorkerTerminationGenerationApi(): WorkerTerminationGenerationApi {
|
||||
const isCaptureGeneration = (value: number | null): value is number =>
|
||||
Number.isSafeInteger(value) && Number(value) > 0;
|
||||
|
||||
return Object.freeze({
|
||||
isCurrent(input: WorkerTerminationGenerationInput): boolean {
|
||||
return (
|
||||
isCaptureGeneration(input.capturedGeneration) &&
|
||||
input.capturedGeneration === input.currentGeneration &&
|
||||
input.capturedGeneration === input.recordGeneration
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { once } from 'node:events';
|
||||
import { getHeapStatistics } from 'node:v8';
|
||||
import { MessageChannel, type MessagePort } from 'node:worker_threads';
|
||||
import {
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON,
|
||||
handleDatabaseWorkerPostGcHeapRequest,
|
||||
isDatabaseWorkerPostGcHeapRequest,
|
||||
type DatabaseWorkerPostGcHeapRequest,
|
||||
type DatabaseWorkerPostGcHeapResult,
|
||||
} from './database-worker-post-gc-heap';
|
||||
|
||||
jest.mock('node:v8', () => ({
|
||||
getHeapStatistics: jest.fn(),
|
||||
}));
|
||||
|
||||
const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
|
||||
const mockedGetHeapStatistics = getHeapStatistics as jest.MockedFunction<
|
||||
typeof getHeapStatistics
|
||||
>;
|
||||
|
||||
type FakeResponsePort = {
|
||||
close: jest.Mock<void, []>;
|
||||
messages: DatabaseWorkerPostGcHeapResult[];
|
||||
port: MessagePort;
|
||||
postMessage: jest.Mock<void, [DatabaseWorkerPostGcHeapResult]>;
|
||||
};
|
||||
|
||||
function createFakeResponsePort(): FakeResponsePort {
|
||||
const messages: DatabaseWorkerPostGcHeapResult[] = [];
|
||||
const close = jest.fn<void, []>();
|
||||
const postMessage = jest.fn<void, [DatabaseWorkerPostGcHeapResult]>(
|
||||
(message) => {
|
||||
messages.push(message);
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
close,
|
||||
messages,
|
||||
port: {
|
||||
close,
|
||||
postMessage,
|
||||
} as unknown as MessagePort,
|
||||
postMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function handleWithFakePort(isIdle: boolean): {
|
||||
gc: jest.Mock<void, []> | null;
|
||||
responsePort: FakeResponsePort;
|
||||
} {
|
||||
const responsePort = createFakeResponsePort();
|
||||
const gcCandidate = Reflect.get(globalThis, 'gc');
|
||||
const request: DatabaseWorkerPostGcHeapRequest = {
|
||||
type: 'performance:collect-post-gc-heap',
|
||||
responsePort: responsePort.port,
|
||||
};
|
||||
|
||||
handleDatabaseWorkerPostGcHeapRequest(request, isIdle);
|
||||
|
||||
return {
|
||||
gc:
|
||||
typeof gcCandidate === 'function'
|
||||
? (gcCandidate as unknown as jest.Mock<void, []>)
|
||||
: null,
|
||||
responsePort,
|
||||
};
|
||||
}
|
||||
|
||||
describe('database worker post-GC heap capture', () => {
|
||||
const originalProfilingValue = process.env[PROFILING_ENV];
|
||||
const originalGc = Reflect.get(globalThis, 'gc');
|
||||
|
||||
beforeEach(() => {
|
||||
process.env[PROFILING_ENV] = '1';
|
||||
Reflect.set(globalThis, 'gc', jest.fn<void, []>());
|
||||
mockedGetHeapStatistics.mockReset();
|
||||
mockedGetHeapStatistics.mockReturnValue({
|
||||
used_heap_size: 42_000,
|
||||
} as ReturnType<typeof getHeapStatistics>);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalProfilingValue === undefined) {
|
||||
delete process.env[PROFILING_ENV];
|
||||
} else {
|
||||
process.env[PROFILING_ENV] = originalProfilingValue;
|
||||
}
|
||||
|
||||
if (originalGc === undefined) {
|
||||
Reflect.deleteProperty(globalThis, 'gc');
|
||||
} else {
|
||||
Reflect.set(globalThis, 'gc', originalGc);
|
||||
}
|
||||
});
|
||||
|
||||
it('recognizes only the exact request with a real MessagePort', () => {
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
|
||||
try {
|
||||
expect(
|
||||
isDatabaseWorkerPostGcHeapRequest({
|
||||
type: 'performance:collect-post-gc-heap',
|
||||
responsePort: port2,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(isDatabaseWorkerPostGcHeapRequest(null)).toBe(false);
|
||||
expect(
|
||||
isDatabaseWorkerPostGcHeapRequest({
|
||||
type: 'performance:collect-post-gc-heap',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDatabaseWorkerPostGcHeapRequest({
|
||||
type: 'performance:collect-post-gc-heap',
|
||||
responsePort: {
|
||||
close: jest.fn(),
|
||||
postMessage: jest.fn(),
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDatabaseWorkerPostGcHeapRequest({
|
||||
type: 'request',
|
||||
responsePort: port2,
|
||||
})
|
||||
).toBe(false);
|
||||
} finally {
|
||||
port1.close();
|
||||
port2.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('forces GC before reading and returning the worker V8 heap', async () => {
|
||||
const lifecycle: string[] = [];
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
Reflect.set(
|
||||
globalThis,
|
||||
'gc',
|
||||
jest.fn(() => lifecycle.push('gc'))
|
||||
);
|
||||
mockedGetHeapStatistics.mockImplementation(() => {
|
||||
lifecycle.push('heap');
|
||||
return {
|
||||
used_heap_size: 73_728,
|
||||
} as ReturnType<typeof getHeapStatistics>;
|
||||
});
|
||||
const responsePromise = once(port1, 'message') as Promise<
|
||||
[DatabaseWorkerPostGcHeapResult]
|
||||
>;
|
||||
const peerClosedPromise = once(port1, 'close');
|
||||
|
||||
handleDatabaseWorkerPostGcHeapRequest(
|
||||
{
|
||||
type: 'performance:collect-post-gc-heap',
|
||||
responsePort: port2,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual([
|
||||
{
|
||||
type: 'performance:post-gc-heap-result',
|
||||
postGcHeapUsedBytes: 73_728,
|
||||
unavailableReason: null,
|
||||
},
|
||||
]);
|
||||
await peerClosedPromise;
|
||||
expect(lifecycle).toEqual(['gc', 'heap']);
|
||||
});
|
||||
|
||||
it('fails closed without profiling opt-in', () => {
|
||||
delete process.env[PROFILING_ENV];
|
||||
|
||||
const { gc, responsePort } = handleWithFakePort(true);
|
||||
|
||||
expect(gc).not.toHaveBeenCalled();
|
||||
expect(mockedGetHeapStatistics).not.toHaveBeenCalled();
|
||||
expect(responsePort.messages).toEqual([
|
||||
{
|
||||
type: 'performance:post-gc-heap-result',
|
||||
postGcHeapUsedBytes: null,
|
||||
unavailableReason:
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.PROFILING_DISABLED,
|
||||
},
|
||||
]);
|
||||
expect(responsePort.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(responsePort.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not perturb an active database operation', () => {
|
||||
const { gc, responsePort } = handleWithFakePort(false);
|
||||
|
||||
expect(gc).not.toHaveBeenCalled();
|
||||
expect(mockedGetHeapStatistics).not.toHaveBeenCalled();
|
||||
expect(responsePort.messages).toEqual([
|
||||
{
|
||||
type: 'performance:post-gc-heap-result',
|
||||
postGcHeapUsedBytes: null,
|
||||
unavailableReason:
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.WORKER_BUSY,
|
||||
},
|
||||
]);
|
||||
expect(responsePort.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(responsePort.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports an unavailable exposed-GC runtime without reading heap statistics', () => {
|
||||
Reflect.deleteProperty(globalThis, 'gc');
|
||||
|
||||
const { responsePort } = handleWithFakePort(true);
|
||||
|
||||
expect(mockedGetHeapStatistics).not.toHaveBeenCalled();
|
||||
expect(responsePort.messages).toEqual([
|
||||
{
|
||||
type: 'performance:post-gc-heap-result',
|
||||
postGcHeapUsedBytes: null,
|
||||
unavailableReason:
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.GC_UNAVAILABLE,
|
||||
},
|
||||
]);
|
||||
expect(responsePort.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(responsePort.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['negative', -1],
|
||||
['fractional', 1.5],
|
||||
['NaN', Number.NaN],
|
||||
['infinite', Number.POSITIVE_INFINITY],
|
||||
['unsafe', Number.MAX_SAFE_INTEGER + 1],
|
||||
])('rejects a %s heap measurement', (_label, usedHeapSize) => {
|
||||
mockedGetHeapStatistics.mockReturnValue({
|
||||
used_heap_size: usedHeapSize,
|
||||
} as ReturnType<typeof getHeapStatistics>);
|
||||
|
||||
const { gc, responsePort } = handleWithFakePort(true);
|
||||
|
||||
expect(gc).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGetHeapStatistics).toHaveBeenCalledTimes(1);
|
||||
expect(responsePort.messages).toEqual([
|
||||
{
|
||||
type: 'performance:post-gc-heap-result',
|
||||
postGcHeapUsedBytes: null,
|
||||
unavailableReason:
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED,
|
||||
},
|
||||
]);
|
||||
expect(responsePort.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(responsePort.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['gc', 'heap'] as const)(
|
||||
'reports capture failure when %s collection throws',
|
||||
(failurePoint) => {
|
||||
if (failurePoint === 'gc') {
|
||||
Reflect.set(
|
||||
globalThis,
|
||||
'gc',
|
||||
jest.fn(() => {
|
||||
throw new Error('gc failed');
|
||||
})
|
||||
);
|
||||
} else {
|
||||
mockedGetHeapStatistics.mockImplementation(() => {
|
||||
throw new Error('heap failed');
|
||||
});
|
||||
}
|
||||
|
||||
const { responsePort } = handleWithFakePort(true);
|
||||
|
||||
expect(responsePort.messages).toEqual([
|
||||
{
|
||||
type: 'performance:post-gc-heap-result',
|
||||
postGcHeapUsedBytes: null,
|
||||
unavailableReason:
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED,
|
||||
},
|
||||
]);
|
||||
expect(responsePort.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(responsePort.close).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('still closes the one-shot port when posting the result fails', () => {
|
||||
const responsePort = createFakeResponsePort();
|
||||
responsePort.postMessage.mockImplementation(() => {
|
||||
throw new Error('port closed');
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
handleDatabaseWorkerPostGcHeapRequest(
|
||||
{
|
||||
type: 'performance:collect-post-gc-heap',
|
||||
responsePort: responsePort.port,
|
||||
},
|
||||
true
|
||||
)
|
||||
).not.toThrow();
|
||||
expect(responsePort.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(responsePort.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { getHeapStatistics } from 'node:v8';
|
||||
import { MessagePort } from 'node:worker_threads';
|
||||
import { WORKER_PROFILING_ENV } from './worker-performance-capture.runtime';
|
||||
|
||||
export const DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE =
|
||||
'performance:collect-post-gc-heap' as const;
|
||||
export const DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE =
|
||||
'performance:post-gc-heap-result' as const;
|
||||
|
||||
export const DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON = {
|
||||
CAPTURE_FAILED: 'capture-failed',
|
||||
GC_UNAVAILABLE: 'gc-unavailable',
|
||||
PROFILING_DISABLED: 'profiling-disabled',
|
||||
WORKER_BUSY: 'worker-busy',
|
||||
} as const;
|
||||
|
||||
export type DatabaseWorkerPostGcHeapUnavailableReason =
|
||||
(typeof DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON)[keyof typeof DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON];
|
||||
|
||||
export interface DatabaseWorkerPostGcHeapRequest {
|
||||
readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE;
|
||||
readonly responsePort: MessagePort;
|
||||
}
|
||||
|
||||
export type DatabaseWorkerPostGcHeapResult =
|
||||
| {
|
||||
readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE;
|
||||
readonly postGcHeapUsedBytes: number;
|
||||
readonly unavailableReason: null;
|
||||
}
|
||||
| {
|
||||
readonly type: typeof DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE;
|
||||
readonly postGcHeapUsedBytes: null;
|
||||
readonly unavailableReason: DatabaseWorkerPostGcHeapUnavailableReason;
|
||||
};
|
||||
|
||||
export function isDatabaseWorkerPostGcHeapRequest(
|
||||
message: unknown
|
||||
): message is DatabaseWorkerPostGcHeapRequest {
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message === null ||
|
||||
Array.isArray(message)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = message as Record<string, unknown>;
|
||||
return (
|
||||
candidate['type'] === DATABASE_WORKER_POST_GC_HEAP_REQUEST_TYPE &&
|
||||
candidate['responsePort'] instanceof MessagePort
|
||||
);
|
||||
}
|
||||
|
||||
function unavailableResult(
|
||||
unavailableReason: DatabaseWorkerPostGcHeapUnavailableReason
|
||||
): DatabaseWorkerPostGcHeapResult {
|
||||
return {
|
||||
type: DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE,
|
||||
postGcHeapUsedBytes: null,
|
||||
unavailableReason,
|
||||
};
|
||||
}
|
||||
|
||||
function postResult(
|
||||
responsePort: MessagePort,
|
||||
result: DatabaseWorkerPostGcHeapResult
|
||||
): void {
|
||||
try {
|
||||
responsePort.postMessage(result);
|
||||
} catch {
|
||||
// Development-only profiling transport must not affect worker behavior.
|
||||
} finally {
|
||||
try {
|
||||
responsePort.close();
|
||||
} catch {
|
||||
// The one-shot port may already be closed by its peer.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleDatabaseWorkerPostGcHeapRequest(
|
||||
message: DatabaseWorkerPostGcHeapRequest,
|
||||
isIdle: boolean
|
||||
): void {
|
||||
let result: DatabaseWorkerPostGcHeapResult;
|
||||
|
||||
if (process.env[WORKER_PROFILING_ENV] !== '1') {
|
||||
result = unavailableResult(
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.PROFILING_DISABLED
|
||||
);
|
||||
} else if (!isIdle) {
|
||||
result = unavailableResult(
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.WORKER_BUSY
|
||||
);
|
||||
} else if (typeof globalThis.gc !== 'function') {
|
||||
result = unavailableResult(
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.GC_UNAVAILABLE
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
globalThis.gc();
|
||||
const postGcHeapUsedBytes = getHeapStatistics().used_heap_size;
|
||||
|
||||
result =
|
||||
Number.isSafeInteger(postGcHeapUsedBytes) &&
|
||||
postGcHeapUsedBytes >= 0
|
||||
? {
|
||||
type: DATABASE_WORKER_POST_GC_HEAP_RESULT_TYPE,
|
||||
postGcHeapUsedBytes,
|
||||
unavailableReason: null,
|
||||
}
|
||||
: unavailableResult(
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED
|
||||
);
|
||||
} catch {
|
||||
result = unavailableResult(
|
||||
DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON.CAPTURE_FAILED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
postResult(message.responsePort, result);
|
||||
}
|
||||
@@ -96,6 +96,10 @@ import {
|
||||
startWorkerPerformanceCapture,
|
||||
type WorkerPerformanceCapture,
|
||||
} from './worker-performance-capture';
|
||||
import {
|
||||
handleDatabaseWorkerPostGcHeapRequest,
|
||||
isDatabaseWorkerPostGcHeapRequest,
|
||||
} from './database-worker-post-gc-heap';
|
||||
|
||||
const loggerLabel = '[DB Worker]';
|
||||
const batchDelayMs = Number.parseInt(
|
||||
@@ -989,6 +993,14 @@ async function executeRequest(
|
||||
}
|
||||
|
||||
parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
|
||||
if (isDatabaseWorkerPostGcHeapRequest(message)) {
|
||||
handleDatabaseWorkerPostGcHeapRequest(
|
||||
message,
|
||||
activePerformanceCaptures.size === 0
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'cancel') {
|
||||
const activeOperation = activeOperations.get(message.operationId);
|
||||
if (activeOperation) {
|
||||
|
||||
@@ -311,3 +311,51 @@ describe('worker cancellation while performance capture arms', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('database worker performance control messages', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('handles a post-GC probe without dispatching a database request', async () => {
|
||||
const armGate = createArmGate();
|
||||
armGate.release();
|
||||
const port = createParentPortHarness<unknown, DbWorkerMessage>();
|
||||
const getWorkerDatabase = jest.fn().mockResolvedValue({});
|
||||
const handlePostGcHeapRequest = jest.fn();
|
||||
const responsePort = { marker: 'response-port' };
|
||||
mockPerformanceCapture(armGate);
|
||||
jest.doMock('worker_threads', () => ({
|
||||
parentPort: port.parentPort,
|
||||
}));
|
||||
jest.doMock('./database.worker-connection', () => ({
|
||||
closeWorkerDatabase: jest.fn(),
|
||||
getWorkerDatabase,
|
||||
}));
|
||||
jest.doMock('./database-worker-post-gc-heap', () => ({
|
||||
handleDatabaseWorkerPostGcHeapRequest: handlePostGcHeapRequest,
|
||||
isDatabaseWorkerPostGcHeapRequest: jest.fn(
|
||||
(message: unknown) =>
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
(message as Record<string, unknown>)['type'] ===
|
||||
'performance:collect-post-gc-heap'
|
||||
),
|
||||
}));
|
||||
|
||||
await import('./database.worker');
|
||||
port.postMessage.mockClear();
|
||||
const handleMessage = port.getMessageHandler();
|
||||
const message = {
|
||||
type: 'performance:collect-post-gc-heap',
|
||||
responsePort,
|
||||
};
|
||||
|
||||
await handleMessage(message);
|
||||
|
||||
expect(handlePostGcHeapRequest).toHaveBeenCalledWith(message, true);
|
||||
expect(getWorkerDatabase).not.toHaveBeenCalled();
|
||||
expect(port.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ All four parse call sites (Electron `playlist-source.ts` import, `playlist-refre
|
||||
|
||||
- **`radio` attribute** — `item.radio` (string, `'true'` triggers the radio player, EPG suppression, and external-player gating app-wide). Upstream does not have this field; it must survive every upstream sync.
|
||||
- **Pipe stripping** — `item.url` is cut at the first `|`; `|User-Agent=` / `|Referer=` params still land in `item.http`. Upstream 0.15.0 stopped stripping, but iptvnator consumes `item.url` verbatim in hls.js/mpv/vlc, catch-up URL building, and url-keyed favorites.
|
||||
- **`#KODIPROP` lines before `#EXTINF` are preserved** (since `v0.15.2-iptvnator.2`) — Kodi property lines apply to the *next* list entry, so ones placed above the `#EXTINF` are buffered and attached to that item's `raw` in file order (case-insensitive prefix); other stray `#` lines outside an open item are still dropped. The DASH + ClearKey feature extracts `inputstream.adaptive.license_*` config from `item.raw`, so this delta must survive every upstream sync.
|
||||
- **`#KODIPROP` lines before `#EXTINF` are preserved** (since `v0.15.2-iptvnator.2`) — Kodi property lines apply to the _next_ list entry, so ones placed above the `#EXTINF` are buffered and attached to that item's `raw` in file order (case-insensitive prefix); other stray `#` lines outside an open item are still dropped. The DASH + ClearKey feature extracts `inputstream.adaptive.license_*` config from `item.raw`, so this delta must survive every upstream sync.
|
||||
|
||||
There is intentionally **no URL validation** (upstream removed it in 0.15.0): any non-empty non-`#` line after `#EXTINF` becomes the item URL. This is what fixes issue #1189 (Pluto TV JWT URLs longer than validator's 2084-char IE-era limit used to be rejected, and the stalled item index collapsed the whole playlist into one channel). `#` comment lines and unknown directives are appended to `item.raw` and never treated as URLs.
|
||||
|
||||
@@ -123,7 +123,16 @@ The target reserves and verifies CDP port 9222, freezes renderer long-task,
|
||||
frame-gap, and heartbeat probes before forced post-GC heap collection, and
|
||||
builds the Electron main process, renderer, and workers with optimized,
|
||||
source-mapped performance configurations before enabling opt-in worker
|
||||
profiling. Each worker response retains a raw
|
||||
profiling. Renderer RSS is scoped to the Playwright page's exact
|
||||
`BrowserWindow` and `webContents`: every sample matches `getOSProcessId()` to
|
||||
one exact `app.getAppMetrics()` PID and creation time, while responsive and
|
||||
unresponsive events come only from that window. Identity changes, missing or
|
||||
ambiguous process metrics, and invalid working-set values fail closed with a
|
||||
raw reason and nullable RSS; summaries exclude unavailable RSS instead of
|
||||
reporting zero. Formal runs also list every invalid measured capture under
|
||||
`summary.validity.rendererRss` and fail after persisting the raw summary unless
|
||||
all measured iterations contribute one valid exact-window RSS value. Each
|
||||
worker response retains a raw
|
||||
request-scoped record containing request/operation identity, received/work/flush
|
||||
timestamps, thread CPU, event-loop utilization, event-loop delay, and fixed
|
||||
unavailability or invalid reasons. Missing or malformed profiling metadata
|
||||
@@ -131,8 +140,59 @@ still produces an outcome with its request identity, nullable metrics, and a
|
||||
fixed capture-unavailable reason. A worker terminated before it can flush the
|
||||
capture reports the metric as unavailable rather than zero.
|
||||
|
||||
The harness excludes workers created by pre-capture seed setup through an exact
|
||||
capture-generation marker. Since the production M3U database payload
|
||||
Database-worker post-GC heap is not inferred from a heap snapshot. The harness
|
||||
selects exactly one current-generation database worker independently of its
|
||||
sampling timer, waits for any in-flight sample plus one final sample, stops the
|
||||
worker CPU profile, sends an explicit-GC one-shot probe over a transferred
|
||||
`MessagePort`, and takes an optional diagnostic snapshot only afterward.
|
||||
Capture stop first closes a synchronous request cutoff and stops the main CPU
|
||||
profile, so worker profile writes and heap snapshots cannot appear as
|
||||
application stacks in `main.cpuprofile`. A database request observed after that
|
||||
cutoff cannot restart worker sampling or profiling; it records
|
||||
`database-worker-activity-after-cutoff` and invalidates the post-GC value.
|
||||
Worker artifacts include a stable isolate ordinal in their filename so an
|
||||
invalid multiple-worker capture cannot overwrite another isolate's profile.
|
||||
The raw heap and unavailability reason form a strict XOR; playlist workers
|
||||
terminated during cancellation report `worker-force-terminated-before-gc`
|
||||
instead of a snapshot-derived heap. Warm-up and measured cancellation invoke
|
||||
the real worker termination before best-effort finalization so profiling cannot
|
||||
delay headline acknowledgement metrics. The separate diagnostic iteration
|
||||
stops its CPU profile before termination; its timings are excluded from
|
||||
headline distributions. That pre-termination drain is bounded; timeout
|
||||
invalidates the profile and worker termination still proceeds. Late
|
||||
termination completions are generation-gated so they cannot mutate a reused
|
||||
record or append timeline events after an atomic capture rollover. The
|
||||
benchmark fails closed unless the diagnostic iteration observed cancellation,
|
||||
its profile is inside the iteration directory and parses with non-empty nodes
|
||||
and samples, and the dying worker has no heap snapshot.
|
||||
|
||||
Headline worker memory distributions include every matching isolate and
|
||||
exclude only nullable unavailable values. This aggregation is not used as a
|
||||
validity shortcut. Runs that reach database persistence must independently
|
||||
contain exactly one database worker with a coherent numeric post-GC heap.
|
||||
Parsing cancellation happens before the renderer dispatches persistence, so a
|
||||
run with no database request is explicitly `N/A:
|
||||
operation-cancelled-before-database-phase`, not a missing capture. Any database
|
||||
activity in a run that observed the cancellation effect is unrelated
|
||||
contamination and invalidates the run. Duplicate, busy, late, timed-out, or
|
||||
malformed captures are listed under
|
||||
`summary.validity.databaseWorkerPostGc` and invalidate the benchmark. The
|
||||
benchmark writes `manifest.json` and `summary.json` first, then fails the formal
|
||||
run so the raw evidence remains available without supporting a before/after
|
||||
claim. A formal cancellation run also requires the cancellation effect in every
|
||||
measured iteration. Database-worker values are comparable only when every
|
||||
measured iteration reaches that phase and has one valid capture.
|
||||
|
||||
Seed setup runs inside its own capture generation. The harness requires an
|
||||
observed seed database request, a completed playlist upsert, and no pending
|
||||
request, then persists that generation as `seed-main-capture.json`. Main
|
||||
capture finalization validates one idle database worker with a coherent
|
||||
explicit-GC result and atomically rolls the clean cutoff into the measured
|
||||
generation before yielding. A request before rollover remains late and rejects
|
||||
the transition; a request after rollover belongs to the measured generation.
|
||||
This prevents seed writes or an unobserved stop/start gap from crossing into
|
||||
measured main RSS, worker peaks, or profiles; generation selection also
|
||||
excludes the seed worker record. Since the production M3U database payload
|
||||
deliberately omits the refresh operation ID, the benchmark correlates only a
|
||||
valid preload `start` marker to the exact database operation and playlist. A
|
||||
missing or ambiguous marker leaves the raw operation ID `null` with a fixed
|
||||
@@ -811,7 +871,7 @@ player in settings.
|
||||
1. The playlist parser fork does not interpret `#KODIPROP:` lines, but
|
||||
preserves them in `item.raw` for **both** layouts: unknown lines between
|
||||
`#EXTINF` and the stream URL are kept as before, and since parser pin
|
||||
`v0.15.2-iptvnator.2` `#KODIPROP` lines placed *before* the `#EXTINF` are
|
||||
`v0.15.2-iptvnator.2` `#KODIPROP` lines placed _before_ the `#EXTINF` are
|
||||
buffered and attached to the **next** entry's `raw` in file order (Kodi
|
||||
semantics, case-insensitive prefix). Other stray `#` lines outside an open
|
||||
item are still dropped, matching upstream.
|
||||
|
||||
@@ -147,6 +147,40 @@ profiling queue. If captures overlap, every overlapping response carries
|
||||
CPU, ELU, and event-loop-delay values are `null`. This avoids assigning shared
|
||||
worker activity to one request while preserving normal worker concurrency.
|
||||
|
||||
### Opt-in post-GC heap capture
|
||||
|
||||
The same `IPTVNATOR_PERF_WORKER_PROFILING=1` opt-in enables a development/test
|
||||
one-shot `performance:collect-post-gc-heap` request. The benchmark transfers a
|
||||
dedicated `MessagePort`; the database worker accepts the request only while no
|
||||
request performance capture is active, calls exposed `globalThis.gc()`, reads
|
||||
its own V8 isolate through `v8.getHeapStatistics()`, posts one result, and
|
||||
closes the port. Production launches do not expose GC or send this request.
|
||||
|
||||
The response is a strict XOR: either a non-negative
|
||||
`postGcHeapUsedBytes` with a `null` reason, or a `null` heap with a fixed
|
||||
unavailability reason. Disabled profiling, a busy worker, unavailable GC, and
|
||||
capture failure all fail closed without changing the database operation or
|
||||
terminating the worker. The performance launcher supplies
|
||||
`--js-flags=--expose-gc` to Electron itself; worker `execArgv` remains
|
||||
untouched.
|
||||
|
||||
The main-process benchmark selects exactly one current-generation database
|
||||
worker, waits for its final sampling call, stops its CPU profile, performs the
|
||||
explicit-GC probe, and only then takes an optional diagnostic heap snapshot.
|
||||
Profile or snapshot failure cannot overwrite an already captured post-GC
|
||||
value. Capture stop closes a synchronous cutoff before awaiting profile or
|
||||
snapshot work. A database request after that cutoff cannot restart sampling;
|
||||
it records `database-worker-activity-after-cutoff` and invalidates the result.
|
||||
Missing, multiple, busy, timed-out, or malformed worker captures remain raw
|
||||
nullable outcomes and make a database-applicable measured run invalid for
|
||||
comparison. A scenario cancelled before its database phase reports the worker
|
||||
metric as not applicable rather than manufacturing an idle database request.
|
||||
Before a measured generation, the M3U benchmark persists and validates its seed
|
||||
capture, then atomically rolls a clean stopping cutoff into the next active
|
||||
generation. This closes the otherwise unobservable gap between separate
|
||||
stop/start calls: pre-rollover requests remain late and fatal, while
|
||||
post-rollover requests are attributed to the new generation.
|
||||
|
||||
### Progress event contract
|
||||
|
||||
The worker now emits request-scoped events with:
|
||||
@@ -568,7 +602,8 @@ executed inside a synchronous transaction callback:
|
||||
```ts
|
||||
// favorites is playlist-scoped: filter by (contentId, playlistId), otherwise
|
||||
// a same-contentId favorite in another playlist gets rewritten too.
|
||||
const stmt = db.update(schema.favorites)
|
||||
const stmt = db
|
||||
.update(schema.favorites)
|
||||
.set({ position: sql<number>`${sql.placeholder('position')}` })
|
||||
.where(
|
||||
and(
|
||||
|
||||
Reference in new issue
Block a user