From 5533ac0fc123b7be8ce6f7753b95a7e9bfe0a34d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 22:57:27 +0200 Subject: [PATCH 01/11] fix(perf): preflight profile artifact capacity --- .../m3u-refresh-cancellation.benchmark.ts | 5 +- .../performance-artifact-preflight.spec.ts | 124 ++++++++++++++++++ .../performance-artifact-preflight.ts | 76 +++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index ce4cd5e0b..311cc4ba8 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -35,6 +35,7 @@ import { createCancellationBenchmarkSummary, createCancellationIterationResult, } from './m3u-refresh-cancellation-report'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; import { installMainCapture, readMainCaptureStatus, @@ -147,10 +148,11 @@ async function runIteration( server: SyntheticServer ): Promise { 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; @@ -363,6 +365,7 @@ async function resolveConfiguration(): Promise { } const outputDirectory = join(outputRoot, variant); await assertMissing(outputDirectory); + await assertPerformanceArtifactCapacity(outputDirectory); await mkdir(outputDirectory, { recursive: true }); return Object.freeze({ electronVersion: electronPackage.version, diff --git a/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts new file mode 100644 index 000000000..4ca080932 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.spec.ts @@ -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` + ); + } +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts new file mode 100644 index 000000000..7c0433854 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-artifact-preflight.ts @@ -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; +} + +export interface PerformanceArtifactCapacity { + readonly availableBytes: bigint; + readonly checkedPath: string; + readonly minimumBytes: bigint; +} + +async function readFileSystemCapacity( + path: string +): Promise { + 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 { + 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; + } + } +} From d1e268f1ddd59de7e5aca87d57987f7f2437cc2a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 22:57:48 +0200 Subject: [PATCH 02/11] chore(electron): add perf-only worker heap probe --- .../database-worker-post-gc-heap.spec.ts | 303 ++++++++++++++++++ .../workers/database-worker-post-gc-heap.ts | 124 +++++++ 2 files changed, 427 insertions(+) create mode 100644 apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts create mode 100644 apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts diff --git a/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts new file mode 100644 index 000000000..0ce33a94e --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.spec.ts @@ -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; + messages: DatabaseWorkerPostGcHeapResult[]; + port: MessagePort; + postMessage: jest.Mock; +}; + +function createFakeResponsePort(): FakeResponsePort { + const messages: DatabaseWorkerPostGcHeapResult[] = []; + const close = jest.fn(); + const postMessage = jest.fn( + (message) => { + messages.push(message); + } + ); + + return { + close, + messages, + port: { + close, + postMessage, + } as unknown as MessagePort, + postMessage, + }; +} + +function handleWithFakePort(isIdle: boolean): { + gc: jest.Mock | 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) + : 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()); + mockedGetHeapStatistics.mockReset(); + mockedGetHeapStatistics.mockReturnValue({ + used_heap_size: 42_000, + } as ReturnType); + }); + + 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; + }); + 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); + + 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); + }); +}); diff --git a/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts new file mode 100644 index 000000000..fa95bc390 --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-post-gc-heap.ts @@ -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; + 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); +} From 24551650436188369bf8e5a11f78d3c36ee46fb8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 23:00:16 +0200 Subject: [PATCH 03/11] chore(electron): route the worker heap probe --- .../src/app/workers/database.worker.ts | 12 +++++ .../worker-performance-cancellation.spec.ts | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 2310d3398..fc709c959 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -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) { diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts index 4cdd550b7..58b8c836b 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -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(); + 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)['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(); + }); +}); From e27685dc5dbfdaf72dcdd91538ef3df0114930da Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 23:03:31 +0200 Subject: [PATCH 04/11] fix(perf): model exact renderer RSS capture --- .../renderer-process-rss-capture.spec.ts | 280 ++++++++++++++++++ .../renderer-process-rss-capture.ts | 199 +++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts diff --git a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts new file mode 100644 index 000000000..0d491b233 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts @@ -0,0 +1,280 @@ +/* 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 { + captureRendererProcessRssSample, + createRendererProcessRssCapture, + type RendererProcessRssCapture, + type RendererProcessRssUnavailableReason, +} from './renderer-process-rss-capture'; + +const RENDERER_PID = 42; +const RENDERER_CREATION_TIME = 1_721_234_567_890; + +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 + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts new file mode 100644 index 000000000..bc2a09b8f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts @@ -0,0 +1,199 @@ +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; +} + +type JsonRecord = Record; + +const KIBIBYTE_BYTES = 1_024; +const MAX_WORKING_SET_KIB = Math.floor( + Number.MAX_SAFE_INTEGER / KIBIBYTE_BYTES +); + +function readRecord(value: unknown): JsonRecord | null { + return typeof value === 'object' && value !== null + ? (value as JsonRecord) + : null; +} + +function isValidOsPid(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function findPidCandidates( + processMetrics: readonly unknown[], + pid: number +): JsonRecord[] { + return processMetrics + .map((metric) => readRecord(metric)) + .filter( + (metric): metric is JsonRecord => + metric !== null && metric['pid'] === pid + ); +} + +function 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 = 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, + }; +} + +function 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, + }); +} + +function invalidateCapture( + capture: RendererProcessRssCapture, + unavailableReason: RendererProcessRssUnavailableReason, + missingSampleCount = capture.missingSampleCount +): RendererProcessRssCapture { + return unavailableCapture(unavailableReason, { + identity: capture.identity, + missingSampleCount, + validSampleCount: capture.validSampleCount, + }); +} + +export function createRendererProcessRssCapture( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] +): RendererProcessRssCapture { + if (!isValidOsPid(webContentsOsPid)) { + return unavailableCapture('renderer-os-pid-invalid'); + } + + const candidates = findPidCandidates(processMetrics, webContentsOsPid); + if (candidates.length === 0) { + return unavailableCapture('renderer-process-metric-missing-at-start', { + missingSampleCount: 1, + }); + } + if (candidates.length !== 1) { + return unavailableCapture('renderer-process-metric-ambiguous'); + } + + const metric = readMetric(candidates[0] as JsonRecord); + if (typeof metric === 'string') { + return unavailableCapture(metric); + } + + return Object.freeze({ + identity: Object.freeze({ + creationTime: metric.creationTime, + pid: webContentsOsPid, + }), + missingSampleCount: 0, + peakRssBytes: metric.rssBytes, + unavailableReason: null, + validSampleCount: 1, + }); +} + +export function captureRendererProcessRssSample( + capture: RendererProcessRssCapture, + webContentsOsPid: unknown, + processMetrics: readonly unknown[] +): RendererProcessRssCapture { + if (capture.unavailableReason !== null) { + return capture; + } + if (!isValidOsPid(webContentsOsPid)) { + return invalidateCapture(capture, 'renderer-os-pid-invalid'); + } + + const identity = capture.identity; + if (identity === null || webContentsOsPid !== identity.pid) { + return invalidateCapture(capture, 'renderer-process-identity-changed'); + } + + const candidates = findPidCandidates(processMetrics, webContentsOsPid); + if (candidates.length === 0) { + return invalidateCapture( + capture, + 'renderer-process-metric-missing-during-capture', + capture.missingSampleCount + 1 + ); + } + if (candidates.length !== 1) { + return invalidateCapture(capture, 'renderer-process-metric-ambiguous'); + } + + const metric = readMetric(candidates[0] as JsonRecord); + if (typeof metric === 'string') { + return invalidateCapture(capture, metric); + } + if (metric.creationTime !== identity.creationTime) { + return 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, + }); +} From 5aab81e2cb8e5b3a78be253aa01196118323bd8d Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 00:15:06 +0200 Subject: [PATCH 05/11] refactor(perf): expose serializable renderer RSS capture --- .../renderer-process-rss-capture.spec.ts | 29 ++ .../renderer-process-rss-capture.ts | 368 +++++++++++------- 2 files changed, 247 insertions(+), 150 deletions(-) diff --git a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts index 0d491b233..ec335bc73 100644 --- a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import * as rendererProcessRssCaptureModule from './renderer-process-rss-capture'; import { captureRendererProcessRssSample, createRendererProcessRssCapture, @@ -12,6 +13,34 @@ import { 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 + )['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; diff --git a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts index bc2a09b8f..3e18a76c8 100644 --- a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts @@ -21,132 +21,237 @@ export interface RendererProcessRssCapture { readonly validSampleCount: number; } -type JsonRecord = Record; - -const KIBIBYTE_BYTES = 1_024; -const MAX_WORKING_SET_KIB = Math.floor( - Number.MAX_SAFE_INTEGER / KIBIBYTE_BYTES -); - -function readRecord(value: unknown): JsonRecord | null { - return typeof value === 'object' && value !== null - ? (value as JsonRecord) - : null; +export interface RendererProcessRssCaptureApi { + readonly create: ( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ) => RendererProcessRssCapture; + readonly sample: ( + capture: RendererProcessRssCapture, + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ) => RendererProcessRssCapture; } -function isValidOsPid(value: unknown): value is number { - return Number.isSafeInteger(value) && Number(value) > 0; -} +export function createRendererProcessRssCaptureApi(): RendererProcessRssCaptureApi { + type JsonRecord = Record; -function findPidCandidates( - processMetrics: readonly unknown[], - pid: number -): JsonRecord[] { - return processMetrics - .map((metric) => readRecord(metric)) - .filter( - (metric): metric is JsonRecord => - metric !== null && metric['pid'] === pid - ); -} + const KIBIBYTE_BYTES = 1_024; + const MAX_WORKING_SET_KIB = Math.floor( + Number.MAX_SAFE_INTEGER / KIBIBYTE_BYTES + ); -function readMetric(metric: JsonRecord): - | { - readonly creationTime: number; - readonly rssBytes: number; - } - | RendererProcessRssUnavailableReason { - if (metric['type'] !== 'Tab' && metric['type'] !== 'Renderer') { - return 'renderer-process-metric-type-invalid'; - } + const helpers = { + readRecord(value: unknown): JsonRecord | null { + return typeof value === 'object' && value !== null + ? (value as JsonRecord) + : null; + }, - const creationTime = metric['creationTime']; - if ( - typeof creationTime !== 'number' || - !Number.isFinite(creationTime) || - creationTime < 0 - ) { - return 'renderer-process-metric-creation-time-invalid'; - } + isValidOsPid(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; + }, - const memory = 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'; - } + findPidCandidates( + processMetrics: readonly unknown[], + pid: number + ): JsonRecord[] { + return processMetrics + .map((metric) => helpers.readRecord(metric)) + .filter( + (metric): metric is JsonRecord => + metric !== null && metric['pid'] === pid + ); + }, - return { - creationTime, - rssBytes: Number(workingSetSize) * KIBIBYTE_BYTES, + 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, + }); + }, }; -} -function 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, + create: helpers.create, + sample: helpers.sample, }); } -function invalidateCapture( - capture: RendererProcessRssCapture, - unavailableReason: RendererProcessRssUnavailableReason, - missingSampleCount = capture.missingSampleCount -): RendererProcessRssCapture { - return unavailableCapture(unavailableReason, { - identity: capture.identity, - missingSampleCount, - validSampleCount: capture.validSampleCount, - }); -} +const rendererProcessRssCaptureApi = createRendererProcessRssCaptureApi(); export function createRendererProcessRssCapture( webContentsOsPid: unknown, processMetrics: readonly unknown[] ): RendererProcessRssCapture { - if (!isValidOsPid(webContentsOsPid)) { - return unavailableCapture('renderer-os-pid-invalid'); - } - - const candidates = findPidCandidates(processMetrics, webContentsOsPid); - if (candidates.length === 0) { - return unavailableCapture('renderer-process-metric-missing-at-start', { - missingSampleCount: 1, - }); - } - if (candidates.length !== 1) { - return unavailableCapture('renderer-process-metric-ambiguous'); - } - - const metric = readMetric(candidates[0] as JsonRecord); - if (typeof metric === 'string') { - return unavailableCapture(metric); - } - - return Object.freeze({ - identity: Object.freeze({ - creationTime: metric.creationTime, - pid: webContentsOsPid, - }), - missingSampleCount: 0, - peakRssBytes: metric.rssBytes, - unavailableReason: null, - validSampleCount: 1, - }); + return rendererProcessRssCaptureApi.create( + webContentsOsPid, + processMetrics + ); } export function captureRendererProcessRssSample( @@ -154,46 +259,9 @@ export function captureRendererProcessRssSample( webContentsOsPid: unknown, processMetrics: readonly unknown[] ): RendererProcessRssCapture { - if (capture.unavailableReason !== null) { - return capture; - } - if (!isValidOsPid(webContentsOsPid)) { - return invalidateCapture(capture, 'renderer-os-pid-invalid'); - } - - const identity = capture.identity; - if (identity === null || webContentsOsPid !== identity.pid) { - return invalidateCapture(capture, 'renderer-process-identity-changed'); - } - - const candidates = findPidCandidates(processMetrics, webContentsOsPid); - if (candidates.length === 0) { - return invalidateCapture( - capture, - 'renderer-process-metric-missing-during-capture', - capture.missingSampleCount + 1 - ); - } - if (candidates.length !== 1) { - return invalidateCapture(capture, 'renderer-process-metric-ambiguous'); - } - - const metric = readMetric(candidates[0] as JsonRecord); - if (typeof metric === 'string') { - return invalidateCapture(capture, metric); - } - if (metric.creationTime !== identity.creationTime) { - return 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 rendererProcessRssCaptureApi.sample( + capture, + webContentsOsPid, + processMetrics + ); } From 918c8f2ded6baf5e42fff75892a30ce37adecf3c Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 00:29:41 +0200 Subject: [PATCH 06/11] fix(perf): scope renderer RSS to exact window --- .../m3u-refresh-cancellation-contract.ts | 14 +- .../m3u-refresh-cancellation-report.ts | 11 +- .../m3u-refresh-cancellation.benchmark.ts | 25 ++ .../performance/m3u-refresh-main-capture.ts | 100 +++--- .../renderer-window-rss-report.spec.ts | 102 ++++++ .../renderer-window-rss-session.spec.ts | 323 ++++++++++++++++++ .../renderer-window-rss-session.ts | 174 ++++++++++ .../worker-request-performance.spec.ts | 41 ++- docs/architecture/m3u-playlist-module.md | 12 +- 9 files changed, 744 insertions(+), 58 deletions(-) create mode 100644 apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index 344767515..daf609acb 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -1,3 +1,5 @@ +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; + export const PERFORMANCE_ITERATION_KIND = { DIAGNOSTIC: 'diagnostic', MEASURED: 'measured', @@ -100,11 +102,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[]; } diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index 7a8e4c220..9eece7fc6 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -280,10 +280,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,7 +299,8 @@ export function createCancellationBenchmarkSummary( ) ), unresponsiveEvents: measured.reduce( - (sum, iteration) => sum + iteration.main.unresponsiveEvents, + (sum, iteration) => + sum + iteration.main.rendererWindow.unresponsiveEvents, 0 ), visibleTotalMs: summarizeNumbers( diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index 311cc4ba8..d78ad9f73 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -43,6 +43,7 @@ import { 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, @@ -193,9 +194,11 @@ async function runIteration( const diagnostic = definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; + const rendererWindowIdentity = await resolveRendererWindowIdentity(app); await startMainCapture(app.electronApp, { diagnostic, outputDirectory: iterationDirectory, + rendererWindowIdentity, }); const renderer = await startRendererCapture(app.mainWindow, { diagnostic, @@ -238,6 +241,28 @@ async function runIteration( } } +async function resolveRendererWindowIdentity( + app: LaunchedElectronApp +): Promise { + 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 { await page.getByRole('button', { name: 'Add playlist' }).first().click(); const dialog = page.locator('mat-dialog-container').last(); diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index f61fa12b4..9d8a6323f 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -12,12 +12,24 @@ import { selectMainCaptureGeneration, type MainCaptureGenerationTransport, } from './worker-request-performance'; +import { + createRendererProcessRssCaptureApi, + type RendererProcessRssCaptureApi, +} from './renderer-process-rss-capture'; +import { + createRendererWindowRssSessionApi, + type RendererWindowIdentity, + type RendererWindowRssSession, + type RendererWindowRssSessionApi, + type RendererWindowRssSessionMetrics, +} from './renderer-window-rss-session'; const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; export interface MainCaptureStartOptions { readonly diagnostic: boolean; readonly outputDirectory: string; + readonly rendererWindowIdentity: RendererWindowIdentity; } export interface MainCaptureStatus { @@ -37,6 +49,10 @@ export async function installMainCapture( const captureStateKeys = { databaseRequestIdentityStateKey: DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + rendererProcessRssApiFactorySource: + createRendererProcessRssCaptureApi.toString(), + rendererWindowRssSessionApiFactorySource: + createRendererWindowRssSessionApi.toString(), stateKey: MAIN_CAPTURE_STATE_KEY, }; await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { @@ -127,6 +143,20 @@ export async function installMainCapture( const databaseRequestIdentityCapture = target[ input.databaseRequestIdentityStateKey ] as DatabaseRequestIdentityCaptureApi; + const restoreFactory = (source: string): T => { + const factory = new Function( + `"use strict"; return (${source});` + )() as () => T; + return factory(); + }; + const rendererProcessRssApi = + restoreFactory( + input.rendererProcessRssApiFactorySource + ); + const rendererWindowRssSessionApi = + restoreFactory( + input.rendererWindowRssSessionApiFactorySource + ); const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; @@ -183,16 +213,9 @@ export async function installMainCapture( outputDirectory: '', postGcHeap: null as number | null, postGcRss: null as number | null, - rendererPeakRss: 0, - responsiveEvents: 0, + rendererWindowSession: null as RendererWindowRssSession | null, sampleTimer: null as NodeJS.Timeout | null, timeline: [] as TimelineRecord[], - unresponsiveEvents: 0, - windowListeners: [] as { - responsive: () => void; - unresponsive: () => void; - window: Electron.BrowserWindow; - }[], }; const nowEpochMs = (): number => @@ -644,32 +667,7 @@ export async function installMainCapture( const memory = process.memoryUsage(); state.mainPeakHeap = Math.max(state.mainPeakHeap, memory.heapUsed); state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss); - for (const metric of app.getAppMetrics()) { - const type = String(metric.type).toLowerCase(); - if (type.includes('tab') || type.includes('renderer')) { - state.rendererPeakRss = Math.max( - state.rendererPeakRss, - Number(metric.memory?.workingSetSize ?? 0) * 1024 - ); - } - } - }; - const attachWindowListeners = (): void => { - for (const window of BrowserWindow.getAllWindows()) { - const unresponsive = () => { - state.unresponsiveEvents += 1; - }; - const responsive = () => { - state.responsiveEvents += 1; - }; - window.on('unresponsive', unresponsive); - window.on('responsive', responsive); - state.windowListeners.push({ - responsive, - unresponsive, - window, - }); - } + state.rendererWindowSession?.sample(); }; const api = { @@ -705,6 +703,19 @@ export async function installMainCapture( ).length, }), start: async (options: MainCaptureStartOptions): Promise => { + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + const rendererWindowSession = + rendererWindowRssSessionApi.create({ + browserWindowFromId: (browserWindowId) => + BrowserWindow.fromId(browserWindowId), + browserWindowId: + options.rendererWindowIdentity.browserWindowId, + getAppMetrics: () => app.getAppMetrics(), + rendererRssApi: rendererProcessRssApi, + webContentsId: + options.rendererWindowIdentity.webContentsId, + }); state.captureGeneration += 1; state.active = true; databaseRequestIdentityCapture.start(); @@ -715,11 +726,9 @@ export async function installMainCapture( state.timeline = []; state.mainPeakHeap = 0; state.mainPeakRss = 0; - state.rendererPeakRss = 0; state.postGcHeap = null; state.postGcRss = null; - state.unresponsiveEvents = 0; - state.responsiveEvents = 0; + state.rendererWindowSession = rendererWindowSession; state.cpuStart = process.cpuUsage(); state.eventLoopStart = perfHooks.performance.eventLoopUtilization(); @@ -727,7 +736,6 @@ export async function installMainCapture( resolution: 1, }); state.eventLoopDelay.enable(); - attachWindowListeners(); sampleMain(); state.sampleTimer = setInterval(sampleMain, 20); if (state.diagnostic) { @@ -753,6 +761,13 @@ export async function installMainCapture( } state.eventLoopDelay?.disable(); sampleMain(); + const rendererWindow: RendererWindowRssSessionMetrics | null = + state.rendererWindowSession?.snapshot() ?? null; + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + if (rendererWindow === null) { + throw new Error('renderer-window-session-missing'); + } const cpu = process.cpuUsage(state.cpuStart ?? undefined); const eluEnd = perfHooks.performance.eventLoopUtilization(); const elu = perfHooks.performance.eventLoopUtilization( @@ -815,11 +830,6 @@ export async function installMainCapture( } session.disconnect(); state.inspectorSession = null; - for (const listener of state.windowListeners) { - listener.window.off('unresponsive', listener.unresponsive); - listener.window.off('responsive', listener.responsive); - } - state.windowListeners = []; state.active = false; databaseRequestIdentityCapture.stop(); @@ -893,12 +903,10 @@ export async function installMainCapture( postGcHeapUsedBytes: state.postGcHeap, postGcRssBytes: state.postGcRss, }, - rendererPeakRssBytes: state.rendererPeakRss, - responsiveEvents: state.responsiveEvents, + rendererWindow, rssScope: 'electron-main-process-including-worker-threads-and-native-memory', timeline: state.timeline, - unresponsiveEvents: state.unresponsiveEvents, }, workers, }; diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts new file mode 100644 index 000000000..e25251704 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts @@ -0,0 +1,102 @@ +/* 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, +} 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 +): 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: 'measured', + } 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 + ), + measuredIteration( + { + identity: null, + missingSampleCount: 1, + peakRssBytes: null, + unavailableReason: + 'renderer-process-metric-missing-at-start', + validSampleCount: 0, + }, + 3, + 4 + ), + ] + ); + + 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); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts new file mode 100644 index 000000000..3aa641215 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts @@ -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 { + 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(); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts new file mode 100644 index 000000000..bef34fc32 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts @@ -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 }); +} diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts index 72a3ae522..a1cfb2a64 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -130,9 +130,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'], @@ -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), diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 09aef8f12..a6bb2cd23 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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,13 @@ 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. 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 @@ -811,7 +817,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. From 1fe6f30e64e178fce4cb295efedc6730a24680bb Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 00:33:17 +0200 Subject: [PATCH 07/11] chore(perf): prepare database post-GC capture --- .../database-worker-post-gc-selection.spec.ts | 145 ++++++++++++++++++ .../database-worker-post-gc-selection.ts | 70 +++++++++ .../m3u-refresh-cancellation.benchmark.ts | 1 + .../performance-build-config.spec.ts | 13 ++ 4 files changed, 229 insertions(+) create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.ts diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.spec.ts new file mode 100644 index 000000000..89559182f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.spec.ts @@ -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 { + readonly selected: T | null; + readonly unavailableReason: SelectionReason | null; +} + +type Selector = ( + records: readonly T[], + activeGeneration: number +) => Selection; + +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 { + return { + captureGeneration: 7, + finalized: true, + kind: 'database.worker', + ordinal, + pendingCount: 0, + sampleTimer: null, + ...overrides, + }; +} + +async function restoreSerializableSelector(): Promise { + 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', + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.ts new file mode 100644 index 000000000..149e4bada --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-selection.ts @@ -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 { + readonly selected: T | null; + readonly unavailableReason: DatabaseWorkerPostGcUnavailableReason | null; +} + +export interface DatabaseWorkerPostGcSelectionApi { + select( + records: readonly T[], + activeGeneration: number + ): DatabaseWorkerPostGcSelection; +} + +export function createDatabaseWorkerPostGcSelectionApi(): DatabaseWorkerPostGcSelectionApi { + const helpers = { + select( + records: readonly T[], + activeGeneration: number + ): DatabaseWorkerPostGcSelection { + 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 }); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index d78ad9f73..cf0858f9b 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -163,6 +163,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')}`, diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index c854a5568..07a20c4d6 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -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/); +}); From b7ddb5e90aaa7fb53c9cd305fe109fb3b5ed7d22 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 00:39:38 +0200 Subject: [PATCH 08/11] chore(perf): add database worker heap probe transport --- .../database-worker-post-gc-probe.spec.ts | 446 ++++++++++++++++++ .../database-worker-post-gc-probe.ts | 242 ++++++++++ 2 files changed, 688 insertions(+) create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.ts diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts new file mode 100644 index 000000000..59efb9084 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts @@ -0,0 +1,446 @@ +/* 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; +} + +interface ProbeModule { + createDatabaseWorkerPostGcProbeApi?: () => ProbeApi; +} + +const probeModulePromise = import( + new URL('./database-worker-post-gc-probe.ts', import.meta.url).href +) + .then((module) => module as ProbeModule) + .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 { + 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 { + 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 reasons: readonly 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]); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.ts new file mode 100644 index 000000000..7e11645c6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.ts @@ -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; +} + +export function createDatabaseWorkerPostGcProbeApi(): DatabaseWorkerPostGcProbeApi { + type JsonRecord = Record; + + 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 + ); + }, + 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 { + let channel: ReturnType; + 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 }); +} From d3fdbaa2efee925dc6a22b95acaad3f733e05822 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 00:39:42 +0200 Subject: [PATCH 09/11] fix(perf): aggregate every worker isolate --- .../database-worker-post-gc-report.spec.ts | 107 ++++++++++++++++++ .../m3u-refresh-cancellation-report.ts | 11 +- 2 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts new file mode 100644 index 000000000..bdcb900ac --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts @@ -0,0 +1,107 @@ +/* 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.equal( + ( + summary.iterations[0]?.main.workers[2] as WorkerCaptureMetrics & { + readonly postGcHeapUnavailableReason: string | null; + } + )?.postGcHeapUnavailableReason, + 'post-gc-probe-invalid-response' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index 9eece7fc6..5d5542024 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -57,12 +57,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, From 554eecfee00c412f93ec0a1326acf7bb2001f36c Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 02:15:55 +0200 Subject: [PATCH 10/11] fix(perf): finalize exact worker capture safely --- AGENTS.md | 2 +- CLAUDE.md | 9 +- .../src/database-worker-post-gc.e2e.ts | 122 +++ .../database-worker-post-gc-cutoff.spec.ts | 116 +++ .../database-worker-post-gc-cutoff.ts | 74 ++ ...tabase-worker-post-gc-finalization.spec.ts | 392 +++++++++ .../database-worker-post-gc-finalization.ts | 90 ++ .../database-worker-post-gc-probe.spec.ts | 35 +- .../database-worker-post-gc-report.spec.ts | 24 + .../database-worker-post-gc-validity.spec.ts | 392 +++++++++ .../database-worker-post-gc-validity.ts | 141 ++++ ...diagnostic-playlist-worker-capture.spec.ts | 193 +++++ .../diagnostic-playlist-worker-capture.ts | 72 ++ .../m3u-refresh-cancellation-contract.ts | 64 +- .../m3u-refresh-cancellation-report.ts | 5 + .../m3u-refresh-cancellation.benchmark.ts | 59 +- .../performance/m3u-refresh-main-capture.ts | 768 ++++++++++++++---- .../worker-request-performance.spec.ts | 49 +- .../performance/worker-request-performance.ts | 41 + .../worker-termination-generation.spec.ts | 102 +++ .../worker-termination-generation.ts | 24 + docs/architecture/m3u-playlist-module.md | 55 +- docs/architecture/sqlite-db-worker.md | 37 +- 23 files changed, 2685 insertions(+), 181 deletions(-) create mode 100644 apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.ts create mode 100644 apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-termination-generation.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-termination-generation.ts diff --git a/AGENTS.md b/AGENTS.md index c1b428902..2e731d993 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,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 diff --git a/CLAUDE.md b/CLAUDE.md index bd872ab79..bda15edcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -353,10 +353,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**: diff --git a/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts b/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts new file mode 100644 index 000000000..14c2dc93e --- /dev/null +++ b/apps/electron-backend-e2e/src/database-worker-post-gc.e2e.ts @@ -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 { + 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 { + 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(); + } +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.spec.ts new file mode 100644 index 000000000..631081fd4 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.spec.ts @@ -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 { + 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/); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.ts new file mode 100644 index 000000000..997963bdb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-cutoff.ts @@ -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); +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts new file mode 100644 index 000000000..bcd3ac969 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts @@ -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; + readonly probePostGc: () => Promise; + readonly reportAncillaryFailure: ( + stage: AncillaryFailureStage, + error: unknown + ) => void; + readonly stopProfile: () => Promise; + readonly stopSampling: () => void; + readonly takeHeapSnapshot?: () => Promise; +} + +interface FinalizationApi { + finalize(input: FinalizationInput): Promise; +} + +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; + readonly resolve: () => void; +} { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function restoreSerializableApi(): Promise { + 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'/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.ts new file mode 100644 index 000000000..53ff8a226 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.ts @@ -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; + readonly probePostGc: () => Promise; + readonly reportAncillaryFailure: ( + stage: DatabaseWorkerPostGcAncillaryFailureStage, + error: unknown + ) => void; + readonly stopProfile: () => Promise; + readonly stopSampling: () => void; + readonly takeHeapSnapshot?: () => Promise; +} + +export interface DatabaseWorkerPostGcFinalizationApi { + finalize( + input: DatabaseWorkerPostGcFinalizationInput + ): Promise; +} + +export function createDatabaseWorkerPostGcFinalizationApi(): DatabaseWorkerPostGcFinalizationApi { + const finalizations = new WeakMap< + object, + Promise + >(); + + const helpers = { + async run( + input: DatabaseWorkerPostGcFinalizationInput + ): Promise { + 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 { + 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 }); +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts index 59efb9084..7ddf4e4cf 100644 --- a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-probe.spec.ts @@ -63,11 +63,25 @@ interface ProbeModule { createDatabaseWorkerPostGcProbeApi?: () => ProbeApi; } +interface ProductionWorkerPostGcModule { + DATABASE_WORKER_POST_GC_HEAP_UNAVAILABLE_REASON?: Readonly< + Record + >; +} + 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; @@ -231,12 +245,21 @@ test('serializes the factory and sends the exact one-shot worker request with it test('preserves every coherent worker-side unavailable result', async () => { const api = await restoreSerializableApi(); - const reasons: readonly WorkerUnavailableReason[] = [ - 'capture-failed', - 'gc-unavailable', - 'profiling-disabled', - 'worker-busy', - ]; + 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([ + 'capture-failed', + 'gc-unavailable', + 'profiling-disabled', + 'worker-busy', + ]) + ); for (const unavailableReason of reasons) { const harness = createProbeHarness(); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts index bdcb900ac..83b221927 100644 --- a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-report.spec.ts @@ -96,6 +96,30 @@ test('summary includes every database isolate instead of silently taking the fir 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 & { diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.spec.ts new file mode 100644 index 000000000..c26a4163d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.spec.ts @@ -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/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.ts new file mode 100644 index 000000000..43345e718 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-validity.ts @@ -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( + 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, + }); +} diff --git a/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.spec.ts b/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.spec.ts new file mode 100644 index 000000000..f8fdc7434 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.spec.ts @@ -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; +} + +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' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.ts b/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.ts new file mode 100644 index 000000000..855931ed4 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/diagnostic-playlist-worker-capture.ts @@ -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 { + 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'); + } +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index daf609acb..ca5b02a0b 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -17,6 +17,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; @@ -73,7 +107,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; @@ -82,16 +116,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; @@ -199,6 +233,27 @@ 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 CancellationBenchmarkSummary { readonly cancellationEffectRate: number; readonly iterations: readonly CancellationIterationResult[]; @@ -252,4 +307,7 @@ export interface CancellationBenchmarkSummary { readonly unresponsiveEvents: number; readonly visibleTotalMs: NumericDistribution; }; + readonly validity: { + readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity; + }; } diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index 5d5542024..a384d27de 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -19,6 +19,7 @@ import { nonNegativeDifference, summarizeNumbers, } from './performance-statistics'; +import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity'; export function createCancellationIterationResult(input: { readonly kind: CancellationIterationResult['kind']; @@ -306,6 +307,10 @@ export function createCancellationBenchmarkSummary( measured.map((iteration) => iteration.phases.visibleTotalMs) ), }), + validity: Object.freeze({ + databaseWorkerPostGc: + assessDatabaseWorkerPostGcValidity(iterations), + }), }); } diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index cf0858f9b..56b8a1a04 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -35,10 +35,12 @@ 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'; @@ -135,6 +137,33 @@ export async function runM3uRefreshCancellationBenchmark(): Promise { 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.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, @@ -179,6 +208,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(); @@ -195,12 +230,20 @@ async function runIteration( const diagnostic = definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; - const rendererWindowIdentity = await resolveRendererWindowIdentity(app); - 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, @@ -287,11 +330,17 @@ async function seedPlaylist(page: Page, playlistUrl: string): Promise { async function waitForSeedSettlement(app: LaunchedElectronApp): Promise { 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); } diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index 9d8a6323f..0931ac31a 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -7,11 +7,36 @@ import { type DatabaseRequestIdentity, type DatabaseRequestIdentityCaptureApi, } from './database-request-identity-capture'; -import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import { + createDatabaseWorkerPostGcCutoffApi, + type DatabaseWorkerPostGcCutoffApi, +} from './database-worker-post-gc-cutoff'; +import { + createDatabaseWorkerPostGcFinalizationApi, + type DatabaseWorkerPostGcAncillaryFailureStage, + type DatabaseWorkerPostGcFinalizationApi, +} from './database-worker-post-gc-finalization'; +import { + createDatabaseWorkerPostGcProbeApi, + type DatabaseWorkerPostGcProbeApi, +} from './database-worker-post-gc-probe'; +import { + createDatabaseWorkerPostGcSelectionApi, + type DatabaseWorkerPostGcSelectionApi, + type DatabaseWorkerPostGcUnavailableReason, +} from './database-worker-post-gc-selection'; +import type { + MainCaptureMetrics, + WorkerPostGcHeapUnavailableReason, +} from './m3u-refresh-cancellation-contract'; import { selectMainCaptureGeneration, type MainCaptureGenerationTransport, } from './worker-request-performance'; +import { + createWorkerTerminationGenerationApi, + type WorkerTerminationGenerationApi, +} from './worker-termination-generation'; import { createRendererProcessRssCaptureApi, type RendererProcessRssCaptureApi, @@ -42,6 +67,21 @@ export interface MainCaptureStatus { readonly playlistResponsesSucceeded: number; } +export interface MainCaptureRolloverResult { + readonly completedCapture: MainCaptureMetrics; + readonly nextCaptureStarted: boolean; + readonly nextCaptureUnavailableReason: string | null; +} + +interface MainCaptureRolloverStatus { + readonly nextCaptureStarted: boolean; + readonly nextCaptureUnavailableReason: string | null; +} + +type MainCaptureStopTransport = MainCaptureGenerationTransport & { + readonly rollover: MainCaptureRolloverStatus | null; +}; + export async function installMainCapture( electronApp: ElectronApplication ): Promise { @@ -49,11 +89,21 @@ export async function installMainCapture( const captureStateKeys = { databaseRequestIdentityStateKey: DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + databaseWorkerPostGcCutoffApiFactorySource: + createDatabaseWorkerPostGcCutoffApi.toString(), + databaseWorkerPostGcFinalizationApiFactorySource: + createDatabaseWorkerPostGcFinalizationApi.toString(), + databaseWorkerPostGcProbeApiFactorySource: + createDatabaseWorkerPostGcProbeApi.toString(), + databaseWorkerPostGcSelectionApiFactorySource: + createDatabaseWorkerPostGcSelectionApi.toString(), rendererProcessRssApiFactorySource: createRendererProcessRssCaptureApi.toString(), rendererWindowRssSessionApiFactorySource: createRendererWindowRssSessionApi.toString(), stateKey: MAIN_CAPTURE_STATE_KEY, + workerTerminationGenerationApiFactorySource: + createWorkerTerminationGenerationApi.toString(), }; await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { type JsonRecord = Record; @@ -110,17 +160,25 @@ export async function installMainCapture( eluStart: WorkerElu | null; externalPeak: number; finalized: boolean; + finalizationKey: object; + finalizationTimedOut: boolean; finalizing: Promise | null; heapPeak: number; kind: 'database.worker' | 'playlist-refresh.worker'; operationId: string | null; + ordinal: number; + pendingCount: number; playlistId: string | null; + postGcHeapUnavailableReason: WorkerPostGcHeapUnavailableReason | null; postGcHeapUsed: number | null; profileHandle: Promise | null; + profileCaptureKey: object; profilePath: string | null; + profileResult: unknown | null; + resolvedProfileHandle: CpuProfileHandle | null; requestPerformance: WorkerRequestPerformance[]; responseEpochMs: number | null; - sampleBusy: boolean; + samplePromise: Promise | null; sampleTimer: NodeJS.Timeout | null; snapshotPath: string | null; terminatedEpochMs: number | null; @@ -149,6 +207,22 @@ export async function installMainCapture( )() as () => T; return factory(); }; + const databaseWorkerPostGcCutoffApi = + restoreFactory( + input.databaseWorkerPostGcCutoffApiFactorySource + ); + const databaseWorkerPostGcFinalizationApi = + restoreFactory( + input.databaseWorkerPostGcFinalizationApiFactorySource + ); + const databaseWorkerPostGcProbeApi = + restoreFactory( + input.databaseWorkerPostGcProbeApiFactorySource + ); + const databaseWorkerPostGcSelectionApi = + restoreFactory( + input.databaseWorkerPostGcSelectionApiFactorySource + ); const rendererProcessRssApi = restoreFactory( input.rendererProcessRssApiFactorySource @@ -157,6 +231,10 @@ export async function installMainCapture( restoreFactory( input.rendererWindowRssSessionApiFactorySource ); + const workerTerminationGenerationApi = + restoreFactory( + input.workerTerminationGenerationApiFactorySource + ); const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; @@ -183,6 +261,7 @@ export async function installMainCapture( const originalPostMessage = WorkerClass.prototype.postMessage; const originalTerminate = WorkerClass.prototype.terminate; const records = new Map(); + let nextWorkerOrdinal = 1; const operationWorkers = new Map(); const dbRequests = new Map< string, @@ -215,6 +294,7 @@ export async function installMainCapture( postGcRss: null as number | null, rendererWindowSession: null as RendererWindowRssSession | null, sampleTimer: null as NodeJS.Timeout | null, + stopping: false, timeline: [] as TimelineRecord[], }; @@ -223,7 +303,7 @@ export async function installMainCapture( const recordTimeline = ( record: Omit ): void => { - if (state.active) { + if (state.active || state.stopping) { state.timeline.push({ epochMs: nowEpochMs(), ...record }); } }; @@ -270,22 +350,31 @@ export async function installMainCapture( eluStart: null, externalPeak: 0, finalized: false, + finalizationKey: {}, + finalizationTimedOut: false, finalizing: null, heapPeak: 0, kind, operationId: null, + ordinal: nextWorkerOrdinal, + pendingCount: 0, playlistId: null, + postGcHeapUnavailableReason: 'post-gc-probe-not-run', postGcHeapUsed: null, profileHandle: null, + profileCaptureKey: {}, profilePath: null, + profileResult: null, + resolvedProfileHandle: null, requestPerformance: [], responseEpochMs: null, - sampleBusy: false, + samplePromise: null, sampleTimer: null, snapshotPath: null, terminatedEpochMs: null, worker, }; + nextWorkerOrdinal += 1; records.set(worker, record); worker.on('message', (incoming) => { if (typeof incoming !== 'object' || incoming === null) { @@ -336,6 +425,10 @@ export async function installMainCapture( ) { const request = dbRequests.get(message['requestId']); if (request) { + request.record.pendingCount = Math.max( + 0, + request.record.pendingCount - 1 + ); record.requestPerformance.push({ identity: request.identity, operation: request.operation, @@ -362,39 +455,44 @@ export async function installMainCapture( }); return record; }; - const sampleWorker = async (record: WorkerRecord): Promise => { - if (record.sampleBusy || record.finalized) { - return; + const sampleWorker = (record: WorkerRecord): Promise => { + if (record.finalized) { + return Promise.resolve(); } - record.sampleBusy = true; - try { - const stats = await record.worker.getHeapStatistics?.(); - if (stats) { - record.heapPeak = Math.max( - record.heapPeak, - Number(stats.used_heap_size ?? 0) - ); - record.externalPeak = Math.max( - record.externalPeak, - Number(stats.external_memory ?? 0) - ); - } - const cpu = await record.worker.cpuUsage?.(); - if (cpu) { - record.cpuFirst ??= cpu; - record.cpuLast = cpu; - } - const elu = record.worker.performance?.eventLoopUtilization( - record.eluStart ?? undefined - ); - if (elu) { - record.elu = elu.utilization; - } - } catch { - // A one-shot worker may terminate between sampling calls. - } finally { - record.sampleBusy = false; + if (record.samplePromise) { + return record.samplePromise; } + record.samplePromise = (async () => { + try { + const stats = await record.worker.getHeapStatistics?.(); + if (stats) { + record.heapPeak = Math.max( + record.heapPeak, + Number(stats.used_heap_size ?? 0) + ); + record.externalPeak = Math.max( + record.externalPeak, + Number(stats.external_memory ?? 0) + ); + } + const cpu = await record.worker.cpuUsage?.(); + if (cpu) { + record.cpuFirst ??= cpu; + record.cpuLast = cpu; + } + const elu = record.worker.performance?.eventLoopUtilization( + record.eluStart ?? undefined + ); + if (elu) { + record.elu = elu.utilization; + } + } catch { + // A one-shot worker may terminate between sampling calls. + } + })().finally(() => { + record.samplePromise = null; + }); + return record.samplePromise; }; const resetWorkerForCapture = (record: WorkerRecord): void => { if (record.sampleTimer) { @@ -408,16 +506,23 @@ export async function installMainCapture( record.eluStart = null; record.externalPeak = 0; record.finalized = false; + record.finalizationKey = {}; + record.finalizationTimedOut = false; record.finalizing = null; record.heapPeak = 0; record.operationId = null; + record.pendingCount = 0; record.playlistId = null; + record.postGcHeapUnavailableReason = 'post-gc-probe-not-run'; record.postGcHeapUsed = null; record.profileHandle = null; + record.profileCaptureKey = {}; record.profilePath = null; + record.profileResult = null; + record.resolvedProfileHandle = null; record.requestPerformance = []; record.responseEpochMs = null; - record.sampleBusy = false; + record.samplePromise = null; record.sampleTimer = null; record.snapshotPath = null; record.terminatedEpochMs = null; @@ -446,58 +551,242 @@ export async function installMainCapture( ) { record.profilePath = path.join( state.outputDirectory, - `${record.kind}.cpuprofile` + `${record.kind}-${record.ordinal}.cpuprofile` + ); + const profileHandle = record.worker.startCpuProfile(); + const profileCaptureKey = record.profileCaptureKey; + record.profileHandle = profileHandle; + void profileHandle.then( + (handle) => { + if ( + record.profileCaptureKey === profileCaptureKey && + record.profileHandle === profileHandle + ) { + record.resolvedProfileHandle = handle; + } + }, + () => undefined ); - record.profileHandle = record.worker.startCpuProfile(); } }; - const finalizeWorker = (record: WorkerRecord): Promise => { - record.finalizing ??= (async () => { - if (record.sampleTimer) { - clearInterval(record.sampleTimer); - record.sampleTimer = null; + const stopWorkerSampling = (record: WorkerRecord): void => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + record.sampleTimer = null; + } + }; + const joinFinalWorkerSample = async ( + record: WorkerRecord + ): Promise => { + if (record.samplePromise) { + await record.samplePromise; + } + await sampleWorker(record); + }; + const stopWorkerProfile = async ( + record: WorkerRecord, + waitForHandle = true + ): Promise => { + const profileCaptureKey = record.profileCaptureKey; + const profileHandle = record.profileHandle; + if (!profileHandle || !record.profilePath) { + return; + } + const handle = + record.resolvedProfileHandle ?? + (waitForHandle ? await profileHandle : null); + if (!handle) { + throw new Error( + 'cpu-profile-handle-not-ready-before-worker-termination' + ); + } + const profileResult = await handle.stop(); + if ( + record.profileCaptureKey !== profileCaptureKey || + record.finalizationTimedOut + ) { + return; + } + record.profileResult = profileResult; + }; + const writeWorkerProfile = (record: WorkerRecord): void => { + if (!record.profileHandle || !record.profilePath) { + return; + } + if (record.profileResult === null) { + throw new Error('worker-cpu-profile-result-missing'); + } + fs.writeFileSync( + record.profilePath, + JSON.stringify(normalizeWorkerCpuProfile(record.profileResult)) + ); + record.profileHandle = null; + record.profileResult = null; + record.resolvedProfileHandle = null; + }; + const flushWorkerProfiles = (workerRecords: WorkerRecord[]): void => { + for (const record of workerRecords) { + try { + writeWorkerProfile(record); + } catch (error: unknown) { + reportWorkerArtifactFailure(record, 'profile-write', error); } - await sampleWorker(record); - if (record.profileHandle && record.profilePath) { - const handle = await record.profileHandle; - const profile = await handle.stop(); - fs.writeFileSync( - record.profilePath, - JSON.stringify(normalizeWorkerCpuProfile(profile)) - ); - } - if ( - state.diagnostic && - typeof record.worker.getHeapSnapshot === 'function' - ) { - record.snapshotPath = path.join( - state.outputDirectory, - `${record.kind}.heapsnapshot` - ); - const snapshot = await record.worker.getHeapSnapshot(); - await streamPromises.pipeline( - snapshot, - fs.createWriteStream(record.snapshotPath) - ); - const postSnapshot = - await record.worker.getHeapStatistics?.(); - record.postGcHeapUsed = Number( - postSnapshot?.used_heap_size ?? 0 - ); - } - record.finalized = true; - })().catch((error: unknown) => { - recordTimeline({ - type: `worker-profile-error:${ - error instanceof Error - ? error.message.slice(0, 160) - : String(error).slice(0, 160) - }`, - }); - record.finalized = true; + } + }; + const takeWorkerHeapSnapshot = async ( + record: WorkerRecord + ): Promise => { + if (typeof record.worker.getHeapSnapshot !== 'function') { + return; + } + record.snapshotPath = path.join( + state.outputDirectory, + `${record.kind}-${record.ordinal}.heapsnapshot` + ); + const snapshot = await record.worker.getHeapSnapshot(); + await streamPromises.pipeline( + snapshot, + fs.createWriteStream(record.snapshotPath) + ); + }; + const reportWorkerArtifactFailure = ( + record: WorkerRecord, + stage: DatabaseWorkerPostGcAncillaryFailureStage, + error: unknown + ): void => { + if (stage === 'heap-snapshot') { + record.snapshotPath = null; + } else if (stage === 'profile-stop' || stage === 'profile-write') { + record.profilePath = null; + record.profileResult = null; + record.profileHandle = null; + record.resolvedProfileHandle = null; + } + recordTimeline({ + type: `worker-artifact-error:${stage}:${ + error instanceof Error + ? error.message.slice(0, 160) + : String(error).slice(0, 160) + }`, }); + }; + const finalizeDatabaseWorker = ( + record: WorkerRecord, + selectionUnavailableReason: DatabaseWorkerPostGcUnavailableReason | null + ): Promise => { + record.finalizing ??= databaseWorkerPostGcFinalizationApi + .finalize({ + finalizationKey: record.finalizationKey, + joinFinalSample: () => joinFinalWorkerSample(record), + probePostGc: () => + selectionUnavailableReason === null + ? databaseWorkerPostGcProbeApi.probe({ + createMessageChannel: () => + new workerThreads.MessageChannel(), + worker: { + postMessage( + message: unknown, + transferList: readonly unknown[] + ): void { + record.worker.postMessage( + message, + transferList as readonly WorkerTransferable[] + ); + }, + }, + }) + : Promise.resolve({ + postGcHeapUsedBytes: null, + unavailableReason: selectionUnavailableReason, + }), + reportAncillaryFailure: (stage, error) => + reportWorkerArtifactFailure(record, stage, error), + stopProfile: () => stopWorkerProfile(record), + stopSampling: () => stopWorkerSampling(record), + ...(state.diagnostic + ? { + takeHeapSnapshot: () => + takeWorkerHeapSnapshot(record), + } + : {}), + }) + .then((outcome) => { + record.postGcHeapUsed = outcome.postGcHeapUsedBytes; + record.postGcHeapUnavailableReason = + outcome.unavailableReason; + record.finalized = true; + }) + .catch((error: unknown) => { + record.postGcHeapUsed = null; + record.postGcHeapUnavailableReason = 'capture-failed'; + reportWorkerArtifactFailure(record, 'post-gc-probe', error); + record.finalized = true; + }); return record.finalizing; }; + const finalizeTerminatingWorker = ( + record: WorkerRecord, + waitForProfileHandle: boolean + ): Promise => { + if (record.finalizing) { + return record.finalizing; + } + const finalizationKey = record.finalizationKey; + record.finalizing = (async () => { + stopWorkerSampling(record); + record.postGcHeapUsed = null; + record.postGcHeapUnavailableReason = + 'worker-force-terminated-before-gc'; + try { + await stopWorkerProfile(record, waitForProfileHandle); + } catch (error: unknown) { + if ( + record.finalizationKey === finalizationKey && + !record.finalizationTimedOut + ) { + reportWorkerArtifactFailure( + record, + 'profile-stop', + error + ); + } + } + if (record.finalizationKey === finalizationKey) { + record.finalized = true; + } + })(); + return record.finalizing; + }; + const waitForTerminatingWorkerFinalization = async ( + record: WorkerRecord + ): Promise => { + if (!record.finalizing || record.finalizationTimedOut) { + return; + } + let timeout: NodeJS.Timeout | null = null; + let timedOut = false; + await Promise.race([ + record.finalizing, + new Promise((resolve) => { + timeout = setTimeout(() => { + timedOut = true; + resolve(); + }, 5_000); + }), + ]); + if (timeout) { + clearTimeout(timeout); + } + if (timedOut) { + record.finalizationTimedOut = true; + record.profileCaptureKey = {}; + reportWorkerArtifactFailure( + record, + 'profile-stop', + new Error('worker-profile-finalization-timeout') + ); + } + }; WorkerClass.prototype.postMessage = function ( this: InstrumentedWorker, @@ -509,6 +798,32 @@ export async function installMainCapture( if (value['type'] === 'request') { const kind = classifyRequest(value); if (kind) { + const requestDisposition = + kind === 'database.worker' + ? databaseWorkerPostGcCutoffApi.observeDatabaseRequest() + : null; + if (requestDisposition === 'after-cutoff') { + state.timeline.push({ + epochMs: nowEpochMs(), + operation: + typeof value['operation'] === 'string' + ? value['operation'] + : undefined, + playlistId: + readDatabasePlaylistId(value) ?? undefined, + requestId: + typeof value['requestId'] === 'string' + ? value['requestId'] + : undefined, + type: 'db-request-after-capture-cutoff', + }); + originalPostMessage.call( + this, + message, + transferList + ); + return; + } const record = createWorkerRecord(this, kind); startWorker(record); if ( @@ -551,6 +866,7 @@ export async function installMainCapture( operationId: payloadOperationId, operationIdUnavailableReason: null, }; + record.pendingCount += 1; dbRequests.set(value['requestId'], { identity, operation: value['operation'], @@ -590,7 +906,17 @@ export async function installMainCapture( if (!record || !isCurrentCaptureRecord(record)) { return originalTerminate.call(this); } + const terminationGeneration = record.captureGeneration; const markTerminated = (code: number): number => { + if ( + !workerTerminationGenerationApi.isCurrent({ + capturedGeneration: terminationGeneration, + currentGeneration: state.captureGeneration, + recordGeneration: record.captureGeneration, + }) + ) { + return code; + } record.terminatedEpochMs = nowEpochMs(); record.finalized = true; recordTimeline({ @@ -600,16 +926,17 @@ export async function installMainCapture( }); return code; }; - if (!state.diagnostic) { - if (record.sampleTimer) { - clearInterval(record.sampleTimer); - record.sampleTimer = null; - } - return originalTerminate.call(this).then(markTerminated); + if (state.diagnostic) { + const diagnosticTermination = (async (): Promise => { + void finalizeTerminatingWorker(record, true); + await waitForTerminatingWorkerFinalization(record); + return originalTerminate.call(this); + })(); + return diagnosticTermination.then(markTerminated); } - return finalizeWorker(record) - .then(() => originalTerminate.call(this)) - .then(markTerminated); + const termination = originalTerminate.call(this); + void finalizeTerminatingWorker(record, false); + return termination.then(markTerminated); }; const inspectorPost = ( @@ -669,6 +996,59 @@ export async function installMainCapture( state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss); state.rendererWindowSession?.sample(); }; + const startCapture = async ( + options: MainCaptureStartOptions + ): Promise => { + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + const rendererWindowSession = rendererWindowRssSessionApi.create({ + browserWindowFromId: (browserWindowId) => + BrowserWindow.fromId(browserWindowId), + browserWindowId: options.rendererWindowIdentity.browserWindowId, + getAppMetrics: () => app.getAppMetrics(), + rendererRssApi: rendererProcessRssApi, + webContentsId: options.rendererWindowIdentity.webContentsId, + }); + state.stopping = false; + state.captureGeneration += 1; + state.active = true; + databaseRequestIdentityCapture.start(); + dbRequests.clear(); + operationWorkers.clear(); + state.diagnostic = options.diagnostic; + state.outputDirectory = options.outputDirectory; + state.timeline = []; + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + state.mainProfilePath = null; + state.mainSnapshotPath = null; + state.postGcHeap = null; + state.postGcRss = null; + state.rendererWindowSession = rendererWindowSession; + state.cpuStart = process.cpuUsage(); + state.eventLoopStart = perfHooks.performance.eventLoopUtilization(); + state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ + resolution: 1, + }); + state.eventLoopDelay.enable(); + sampleMain(); + state.sampleTimer = setInterval(sampleMain, 20); + if (state.diagnostic) { + const session = new inspector.Session(); + session.connect(); + state.inspectorSession = session; + await inspectorPost(session, 'Profiler.enable'); + await inspectorPost(session, 'Profiler.start'); + state.mainProfilePath = path.join( + state.outputDirectory, + 'main.cpuprofile' + ); + state.mainSnapshotPath = path.join( + state.outputDirectory, + 'main.heapsnapshot' + ); + } + }; const api = { status: (): MainCaptureStatus => ({ @@ -703,62 +1083,27 @@ export async function installMainCapture( ).length, }), start: async (options: MainCaptureStartOptions): Promise => { - state.rendererWindowSession?.detach(); - state.rendererWindowSession = null; - const rendererWindowSession = - rendererWindowRssSessionApi.create({ - browserWindowFromId: (browserWindowId) => - BrowserWindow.fromId(browserWindowId), - browserWindowId: - options.rendererWindowIdentity.browserWindowId, - getAppMetrics: () => app.getAppMetrics(), - rendererRssApi: rendererProcessRssApi, - webContentsId: - options.rendererWindowIdentity.webContentsId, - }); - state.captureGeneration += 1; - state.active = true; - databaseRequestIdentityCapture.start(); - dbRequests.clear(); - operationWorkers.clear(); - state.diagnostic = options.diagnostic; - state.outputDirectory = options.outputDirectory; - state.timeline = []; - state.mainPeakHeap = 0; - state.mainPeakRss = 0; - state.postGcHeap = null; - state.postGcRss = null; - state.rendererWindowSession = rendererWindowSession; - state.cpuStart = process.cpuUsage(); - state.eventLoopStart = - perfHooks.performance.eventLoopUtilization(); - state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ - resolution: 1, - }); - state.eventLoopDelay.enable(); - sampleMain(); - state.sampleTimer = setInterval(sampleMain, 20); - if (state.diagnostic) { - const session = new inspector.Session(); - session.connect(); - state.inspectorSession = session; - await inspectorPost(session, 'Profiler.enable'); - await inspectorPost(session, 'Profiler.start'); - state.mainProfilePath = path.join( - state.outputDirectory, - 'main.cpuprofile' - ); - state.mainSnapshotPath = path.join( - state.outputDirectory, - 'main.heapsnapshot' - ); - } + databaseWorkerPostGcCutoffApi.beginCapture(); + await startCapture(options); }, - stop: async (): Promise => { + stop: async ( + nextOptions?: MainCaptureStartOptions + ): Promise => { + databaseWorkerPostGcCutoffApi.beginStop(); + state.stopping = true; + state.active = false; + databaseRequestIdentityCapture.stop(); if (state.sampleTimer) { clearInterval(state.sampleTimer); state.sampleTimer = null; } + const currentWorkerRecords = [...records.values()].filter( + (record) => + record.captureGeneration === state.captureGeneration + ); + for (const record of currentWorkerRecords) { + stopWorkerSampling(record); + } state.eventLoopDelay?.disable(); sampleMain(); const rendererWindow: RendererWindowRssSessionMetrics | null = @@ -782,15 +1127,6 @@ export async function installMainCapture( ? null : elu.utilization; const delay = state.eventLoopDelay; - const databaseRecords = [...records.values()].filter( - (record) => - record.captureGeneration === state.captureGeneration && - record.kind === 'database.worker' && - record.sampleTimer !== null - ); - await Promise.all( - databaseRecords.map((record) => finalizeWorker(record)) - ); const session = state.inspectorSession ?? new inspector.Session(); if (!state.inspectorSession) { @@ -806,6 +1142,41 @@ export async function installMainCapture( JSON.stringify(result['profile']) ); } + await Promise.all( + currentWorkerRecords + .filter( + (record) => + record.kind === 'playlist-refresh.worker' && + record.finalizing !== null + ) + .map((record) => + waitForTerminatingWorkerFinalization(record) + ) + ); + const databaseSelection = + databaseWorkerPostGcSelectionApi.select( + currentWorkerRecords, + state.captureGeneration + ); + const currentDatabaseRecords = currentWorkerRecords.filter( + (record) => record.kind === 'database.worker' + ); + if (databaseSelection.selected) { + await finalizeDatabaseWorker( + databaseSelection.selected, + null + ); + } else if (databaseSelection.unavailableReason !== null) { + await Promise.all( + currentDatabaseRecords.map((record) => + finalizeDatabaseWorker( + record, + databaseSelection.unavailableReason + ) + ) + ); + } + flushWorkerProfiles(currentWorkerRecords); await inspectorPost(session, 'HeapProfiler.enable'); await inspectorPost(session, 'HeapProfiler.collectGarbage'); const postGc = process.memoryUsage(); @@ -830,15 +1201,20 @@ export async function installMainCapture( } session.disconnect(); state.inspectorSession = null; - state.active = false; - databaseRequestIdentityCapture.stop(); + const cutoff = databaseWorkerPostGcCutoffApi.snapshot(); + if (cutoff.lateRequestCount > 0) { + for (const record of currentDatabaseRecords) { + record.postGcHeapUsed = null; + record.postGcHeapUnavailableReason = + 'database-worker-activity-after-cutoff'; + } + } - const workers = [...records.values()] + const workers = currentWorkerRecords .filter( (record) => - record.kind === 'playlist-refresh.worker' || - record.heapPeak > 0 || - record.requestPerformance.length > 0 + record.kind === 'database.worker' || + record.kind === 'playlist-refresh.worker' ) .map((record) => ({ captureGeneration: record.captureGeneration, @@ -856,9 +1232,12 @@ export async function installMainCapture( eventLoopUtilization: record.elu, kind: record.kind, operationId: record.operationId, + ordinal: record.ordinal, peakExternalBytes: record.externalPeak, peakHeapUsedBytes: record.heapPeak, playlistId: record.playlistId, + postGcHeapUnavailableReason: + record.postGcHeapUnavailableReason, postGcHeapUsedBytes: record.postGcHeapUsed, profilePath: record.profilePath, responseEpochMs: record.responseEpochMs, @@ -877,7 +1256,7 @@ export async function installMainCapture( success: request.success, })), })); - return { + const transport: MainCaptureGenerationTransport = { captureGeneration: state.captureGeneration, metrics: { cpuProfilePath: state.mainProfilePath, @@ -910,6 +1289,52 @@ export async function installMainCapture( }, workers, }; + let rollover: MainCaptureRolloverStatus | null = null; + if (nextOptions) { + const databaseRecord = + currentDatabaseRecords.length === 1 + ? currentDatabaseRecords[0] + : null; + const nextCaptureUnavailableReason = + cutoff.lateRequestCount > 0 + ? 'database-worker-activity-after-cutoff' + : dbRequests.size > 0 + ? 'database-worker-not-idle' + : currentDatabaseRecords.length === 0 + ? 'database-worker-missing' + : currentDatabaseRecords.length > 1 + ? 'multiple-database-workers' + : !databaseRecord || + !Number.isSafeInteger( + databaseRecord.postGcHeapUsed + ) || + Number(databaseRecord.postGcHeapUsed) < + 0 || + databaseRecord.postGcHeapUnavailableReason !== + null + ? (databaseRecord?.postGcHeapUnavailableReason ?? + 'post-gc-capture-invalid') + : null; + if (nextCaptureUnavailableReason === null) { + databaseWorkerPostGcCutoffApi.rolloverCapture(); + await startCapture(nextOptions); + rollover = { + nextCaptureStarted: true, + nextCaptureUnavailableReason: null, + }; + } else { + databaseWorkerPostGcCutoffApi.finishStop(); + state.stopping = false; + rollover = { + nextCaptureStarted: false, + nextCaptureUnavailableReason, + }; + } + } else { + databaseWorkerPostGcCutoffApi.finishStop(); + state.stopping = false; + } + return { ...transport, rollover }; }, }; target[input.stateKey] = api; @@ -944,6 +1369,33 @@ export async function readMainCaptureStatus( }, MAIN_CAPTURE_STATE_KEY); } +export async function rolloverMainCapture( + electronApp: ElectronApplication, + options: MainCaptureStartOptions +): Promise { + const transport = await electronApp.evaluate( + async (_electron, input) => { + const target = globalThis as unknown as Record; + const api = target[input.stateKey] as { + stop( + nextOptions?: MainCaptureStartOptions + ): Promise; + }; + return api.stop(input.options); + }, + { options, stateKey: MAIN_CAPTURE_STATE_KEY } + ); + if (transport.rollover === null) { + throw new Error('main-capture-rollover-status-missing'); + } + return Object.freeze({ + completedCapture: selectMainCaptureGeneration(transport), + nextCaptureStarted: transport.rollover.nextCaptureStarted, + nextCaptureUnavailableReason: + transport.rollover.nextCaptureUnavailableReason, + }); +} + export async function stopMainCapture( electronApp: ElectronApplication ): Promise { @@ -951,7 +1403,7 @@ export async function stopMainCapture( async (_electron, stateKey) => { const target = globalThis as unknown as Record; const api = target[stateKey] as { - stop(): Promise; + stop(): Promise; }; return api.stop(); }, diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts index a1cfb2a64..c96ec7f03 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -12,6 +12,7 @@ import { type WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { + type MainCaptureGenerationTransport, normalizeWorkerRequestPerformanceOutcome, selectMainCaptureGeneration, } from './worker-request-performance'; @@ -217,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/); @@ -294,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)) diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts index edf157282..d348cd71c 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -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( + Object.values(WORKER_POST_GC_HEAP_UNAVAILABLE_REASON) +); function isRecord(input: unknown): input is Record { 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 +): 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 + ); return { ...worker.metrics, + ...postGcHeap, eventLoopDelay: onlyRequest?.eventLoopDelay ?? null, eventLoopDelayUnavailableReason: onlyRequest ? (onlyRequest.eventLoopDelayUnavailableReason ?? diff --git a/apps/electron-backend-e2e/src/performance/worker-termination-generation.spec.ts b/apps/electron-backend-e2e/src/performance/worker-termination-generation.spec.ts new file mode 100644 index 000000000..f1ac4d93a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-termination-generation.spec.ts @@ -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' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-termination-generation.ts b/apps/electron-backend-e2e/src/performance/worker-termination-generation.ts new file mode 100644 index 000000000..68f77efbd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-termination-generation.ts @@ -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 + ); + }, + }); +} diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index a6bb2cd23..6456e626c 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -137,8 +137,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 diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 52bdbb860..fc18d14d2 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -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: @@ -564,7 +598,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`${sql.placeholder('position')}` }) .where( and( From 2fda6cea076fcb0c36eaab4de9e0b3da897d4406 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 02:27:57 +0200 Subject: [PATCH 11/11] fix(perf): reject incomplete renderer RSS samples --- .../m3u-refresh-cancellation-contract.ts | 22 ++++++- .../m3u-refresh-cancellation-report.ts | 46 +++++++++++++ .../m3u-refresh-cancellation.benchmark.ts | 7 ++ .../renderer-window-rss-report.spec.ts | 65 +++++++++++++++++-- docs/architecture/m3u-playlist-module.md | 5 +- 5 files changed, 136 insertions(+), 9 deletions(-) diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index ca5b02a0b..217dd1ca3 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -1,4 +1,7 @@ -import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; +import type { + RendererProcessRssCapture, + RendererProcessRssUnavailableReason, +} from './renderer-process-rss-capture'; export const PERFORMANCE_ITERATION_KIND = { DIAGNOSTIC: 'diagnostic', @@ -254,6 +257,22 @@ export interface DatabaseWorkerPostGcValidity { 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[]; @@ -309,5 +328,6 @@ export interface CancellationBenchmarkSummary { }; readonly validity: { readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity; + readonly rendererRss: RendererRssValidity; }; } diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index a384d27de..6311fbd06 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -9,6 +9,7 @@ import type { NumericDistribution, PerformanceWorkerKind, RendererCaptureMetrics, + RendererRssValidity, WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { @@ -310,10 +311,55 @@ export function createCancellationBenchmarkSummary( 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, + }); +} + function derivePhases( main: MainCaptureMetrics, renderer: RendererCaptureMetrics diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index 56b8a1a04..243803989 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -157,6 +157,13 @@ export async function runM3uRefreshCancellationBenchmark(): Promise { `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( diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts index e25251704..a6f0c2b21 100644 --- a/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts @@ -1,5 +1,6 @@ /* 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 { @@ -13,7 +14,8 @@ import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; function measuredIteration( rss: RendererProcessRssCapture, responsiveEvents: number, - unresponsiveEvents: number + unresponsiveEvents: number, + runId: string ): CancellationIterationResult { return { cancellationEffectObserved: true, @@ -51,7 +53,7 @@ function measuredIteration( longTasksMs: [], }, }, - runId: 'measured', + runId, } as unknown as CancellationIterationResult; } @@ -71,19 +73,24 @@ test('summary uses only exact target-window RSS and scoped responsiveness events validSampleCount: 3, }, 1, - 2 + 2, + 'run-01' ), measuredIteration( { - identity: null, + identity: { + creationTime: 1_721_234_567_890, + pid: 43, + }, missingSampleCount: 1, peakRssBytes: null, unavailableReason: - 'renderer-process-metric-missing-at-start', - validSampleCount: 0, + 'renderer-process-metric-missing-during-capture', + validSampleCount: 2, }, 3, - 4 + 4, + 'run-02' ), ] ); @@ -99,4 +106,48 @@ test('summary uses only exact target-window RSS and scoped responsiveness events }); 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/); }); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 6456e626c..df6d257a0 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -129,7 +129,10 @@ 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. Each worker response retains a raw +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