diff --git a/AGENTS.md b/AGENTS.md index 51d6fc03b..cdf03a35e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,8 +85,8 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `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, 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 + - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset + - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and 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 49a9446cf..3bc0945f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,8 +141,8 @@ Useful narrower flags: - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `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, 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 +- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset +- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and 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/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 9c1f45538..ee68cd410 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -29,6 +29,16 @@ "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts" } }, + "benchmark-m3u-import": { + "dependsOn": ["electron-backend:build-performance"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts" + } + }, "packaged-frame-copy-smoke": { "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/m3u-import.performance.ts b/apps/electron-backend-e2e/src/m3u-import.performance.ts new file mode 100644 index 000000000..d1ba60ab4 --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-import.performance.ts @@ -0,0 +1,8 @@ +import { test } from '@playwright/test'; + +import { runM3uImportBenchmark } from './performance/m3u-import.benchmark'; + +// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle. +test('profiles initial M3U imports at 10k, 50k, and 100k', async () => { + await runM3uImportBenchmark(); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts index 2e9672718..4c3eac434 100644 --- a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts @@ -52,11 +52,125 @@ test('correlates the real preload marker to a DB request whose payload omits ope }); assert.deepEqual(identity, { + ipcCallId: 2, operationId: 'refresh-operation-1', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); }); +test('retains the initial-import upsert marker when its operationId is intentionally uncorrelated', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 7, + method: 'dbUpsertAppPlaylist', + operationId: null, + sourceEpochMs: 1_077.25, + }) + ); + + const identity = api.matchDatabaseRequest({ + operation: 'DB_UPSERT_APP_PLAYLIST', + payload: { _id: 'playlist-1' }, + requestId: 'initial-import-upsert', + type: 'request', + }); + + assert.deepEqual(identity, { + ipcCallId: 7, + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-uncorrelated', + sourceEpochMs: 1_077.25, + }); +}); + +test('captures exact successful preload completion markers for DB return-clone attribution', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 7, + method: 'dbUpsertAppPlaylist', + operationId: null, + phase: 'success', + sourceEpochMs: 140.25, + }) + ); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 8, + operationId: null, + phase: 'success', + sourceEpochMs: 175.5, + }) + ); + + assert.equal(api.successMarkerCount(), 2); + assert.deepEqual(api.takeSuccessMarkers(), [ + { + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + operationId: null, + playlistId: 'playlist-1', + sourceEpochMs: 140.25, + }, + { + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + operationId: null, + playlistId: 'playlist-1', + sourceEpochMs: 175.5, + }, + ]); + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); +}); + +test('fails closed for malformed or invalid successful preload markers and resets completions', () => { + const { api, ipcMain } = createCapture(); + for (const invalid of [ + { invalidReason: 'out-of-order' }, + { correlationState: 'invalid' }, + { ipcCallId: 0 }, + { playlistId: '' }, + { sourceEpochMs: Number.NaN }, + { method: 'refreshPlaylist' }, + { correlationState: 'correlated', operationId: null }, + ]) { + ipcMain.emit( + CHANNEL, + {}, + marker({ + phase: 'success', + ...invalid, + }) + ); + } + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); + + ipcMain.emit( + CHANNEL, + {}, + marker({ + phase: 'success', + }) + ); + api.stop(); + api.start(); + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); +}); + test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => { const { api, ipcMain } = createCapture(); const identity = api.matchDatabaseRequest({ @@ -73,8 +187,10 @@ test('resolves a response-retained request identity when the marker arrives late ipcMain.emit(CHANNEL, {}, marker()); assert.deepEqual(responseOutcome.identity, { + ipcCallId: 2, operationId: 'refresh-operation-1', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); api.stop(); @@ -87,8 +203,10 @@ test('resolves a response-retained request identity when the marker arrives late type: 'request', }); assert.deepEqual(afterReset, { + ipcCallId: null, operationId: null, operationIdUnavailableReason: 'preload-performance-marker-missing', + sourceEpochMs: null, }); }); @@ -101,8 +219,10 @@ test('fails closed for missing and ambiguous preload markers', () => { type: 'request', }); assert.deepEqual(missing, { + ipcCallId: null, operationId: null, operationIdUnavailableReason: 'preload-performance-marker-missing', + sourceEpochMs: null, }); const ambiguousCapture = createCapture(); @@ -122,8 +242,10 @@ test('fails closed for missing and ambiguous preload markers', () => { type: 'request', }); assert.deepEqual(ambiguous, { + ipcCallId: null, operationId: null, operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + sourceEpochMs: null, }); }); @@ -191,19 +313,25 @@ test('quarantines a key after response-before-marker ambiguity', () => { }); const ambiguousIdentity = { + ipcCallId: null, operationId: null, operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + sourceEpochMs: null, }; assert.deepEqual(requestA, ambiguousIdentity); assert.deepEqual(requestB, ambiguousIdentity); assert.deepEqual(requestC, ambiguousIdentity); assert.deepEqual(otherPlaylist, { + ipcCallId: 4, operationId: 'other-playlist-operation', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); assert.deepEqual(otherOperation, { + ipcCallId: 5, operationId: 'other-db-operation', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); api.stop(); @@ -223,7 +351,9 @@ test('quarantines a key after response-before-marker ambiguity', () => { type: 'request', }); assert.deepEqual(freshCaptureIdentity, { + ipcCallId: 6, operationId: 'fresh-capture-operation', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); }); diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts index a0912d3e3..2de76e7e3 100644 --- a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts @@ -4,14 +4,26 @@ export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshDatabaseRequestIdentityCapture'; export interface DatabaseRequestIdentity { + ipcCallId: number | null; operationId: string | null; operationIdUnavailableReason: string | null; + sourceEpochMs: number | null; +} + +export interface PreloadDatabaseSuccessMarker { + ipcCallId: number; + operation: 'DB_GET_APP_PLAYLIST' | 'DB_UPSERT_APP_PLAYLIST'; + operationId: string | null; + playlistId: string; + sourceEpochMs: number; } export interface DatabaseRequestIdentityCaptureApi { matchDatabaseRequest(message: unknown): DatabaseRequestIdentity; start(): void; stop(): void; + successMarkerCount(): number; + takeSuccessMarkers(): readonly PreloadDatabaseSuccessMarker[]; } interface DatabaseRequestIdentityElectron { @@ -31,8 +43,10 @@ export function installDatabaseRequestIdentityCaptureInMain( interface PendingMarker { readonly ipcCallId: number; readonly operation: string; - readonly operationId: string; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; readonly playlistId: string; + readonly sourceEpochMs: number; } interface PendingRequest { readonly identity: DatabaseRequestIdentity; @@ -58,6 +72,7 @@ export function installDatabaseRequestIdentityCaptureInMain( let pendingMarkers: PendingMarker[] = []; let pendingRequests: PendingRequest[] = []; let quarantinedKeys: QuarantinedKey[] = []; + let successMarkers: PreloadDatabaseSuccessMarker[] = []; const readRecord = (value: unknown): JsonRecord | null => typeof value === 'object' && value !== null @@ -88,11 +103,37 @@ export function installDatabaseRequestIdentityCaptureInMain( candidate.playlistId === playlistId; const markAmbiguous = (requests: PendingRequest[]): void => { for (const request of requests) { + request.identity.ipcCallId = null; request.identity.operationId = null; request.identity.operationIdUnavailableReason = 'preload-performance-marker-ambiguous'; + request.identity.sourceEpochMs = null; } }; + const unavailableIdentity = (reason: string): DatabaseRequestIdentity => ({ + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: reason, + sourceEpochMs: null, + }); + const identityFromMarker = ( + marker: PendingMarker + ): DatabaseRequestIdentity => ({ + ipcCallId: marker.ipcCallId, + operationId: marker.operationId, + operationIdUnavailableReason: marker.operationIdUnavailableReason, + sourceEpochMs: marker.sourceEpochMs, + }); + const applyMarker = ( + identity: DatabaseRequestIdentity, + marker: PendingMarker + ): void => { + identity.ipcCallId = marker.ipcCallId; + identity.operationId = marker.operationId; + identity.operationIdUnavailableReason = + marker.operationIdUnavailableReason; + identity.sourceEpochMs = marker.sourceEpochMs; + }; const isQuarantined = (operation: string, playlistId: string): boolean => quarantinedKeys.some((key) => matches(key, operation, playlistId)); const quarantine = (operation: string, playlistId: string): void => { @@ -116,32 +157,86 @@ export function installDatabaseRequestIdentityCaptureInMain( return; } const marker = readRecord(input); + const correlationState = marker?.['correlationState']; + const operation = + typeof marker?.['method'] === 'string' + ? markerMethodToOperation[marker['method']] + : undefined; + const operationId = marker?.['operationId']; + const playlistId = marker?.['playlistId']; + const ipcCallId = marker?.['ipcCallId']; + const sourceEpochMs = marker?.['sourceEpochMs']; + const validMarkerIdentity = + operation !== undefined && + typeof playlistId === 'string' && + playlistId.length > 0 && + Number.isSafeInteger(ipcCallId) && + Number(ipcCallId) >= 1 && + typeof sourceEpochMs === 'number' && + Number.isFinite(sourceEpochMs) && + sourceEpochMs >= 0; + if ( + marker?.['phase'] === 'success' && + (correlationState === 'correlated' || + correlationState === 'uncorrelated') && + marker['invalidReason'] === null && + validMarkerIdentity && + (correlationState !== 'correlated' || + (typeof operationId === 'string' && + operationId.length > 0)) + ) { + successMarkers.push({ + ipcCallId: Number(ipcCallId), + operation: operation as PreloadDatabaseSuccessMarker['operation'], + operationId: + correlationState === 'correlated' + ? String(operationId) + : null, + playlistId, + sourceEpochMs, + }); + return; + } if ( !marker || marker['phase'] !== 'start' || - marker['correlationState'] !== 'correlated' || + (correlationState !== 'correlated' && + correlationState !== 'uncorrelated') || marker['invalidReason'] !== null ) { return; } - const operation = - typeof marker['method'] === 'string' - ? markerMethodToOperation[marker['method']] - : undefined; - const operationId = marker['operationId']; - const playlistId = marker['playlistId']; - const ipcCallId = marker['ipcCallId']; + const correlatedOperationId = + correlationState === 'correlated' && + typeof operationId === 'string' && + operationId.length > 0 + ? operationId + : null; if ( operation === undefined || - typeof operationId !== 'string' || - operationId.length === 0 || + (correlationState === 'correlated' && + correlatedOperationId === null) || typeof playlistId !== 'string' || playlistId.length === 0 || !Number.isSafeInteger(ipcCallId) || - Number(ipcCallId) < 1 + Number(ipcCallId) < 1 || + typeof sourceEpochMs !== 'number' || + !Number.isFinite(sourceEpochMs) || + sourceEpochMs < 0 ) { return; } + const pendingMarker: PendingMarker = { + ipcCallId: Number(ipcCallId), + operation, + operationId: correlatedOperationId, + operationIdUnavailableReason: + correlationState === 'correlated' + ? null + : 'preload-performance-marker-uncorrelated', + playlistId, + sourceEpochMs, + }; if (isQuarantined(operation, playlistId)) { return; @@ -154,8 +249,7 @@ export function installDatabaseRequestIdentityCaptureInMain( if (!request) { return; } - request.identity.operationId = operationId; - request.identity.operationIdUnavailableReason = null; + applyMarker(request.identity, pendingMarker); pendingRequests = pendingRequests.filter( (candidate) => candidate !== request ); @@ -166,12 +260,7 @@ export function installDatabaseRequestIdentityCaptureInMain( return; } - pendingMarkers.push({ - ipcCallId: Number(ipcCallId), - operation, - operationId, - playlistId, - }); + pendingMarkers.push(pendingMarker); pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId); }); @@ -189,19 +278,15 @@ export function installDatabaseRequestIdentityCaptureInMain( operation !== 'DB_UPSERT_APP_PLAYLIST') || playlistId === null ) { - return { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-not-applicable', - }; + return unavailableIdentity( + 'preload-performance-marker-not-applicable' + ); } if (isQuarantined(operation, playlistId)) { - return { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-ambiguous', - }; + return unavailableIdentity( + 'preload-performance-marker-ambiguous' + ); } const markerCandidates = pendingMarkers.filter((marker) => matches(marker, operation, playlistId) @@ -209,34 +294,25 @@ export function installDatabaseRequestIdentityCaptureInMain( if (markerCandidates.length === 1) { const marker = markerCandidates[0]; if (!marker) { - return { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-missing', - }; + return unavailableIdentity( + 'preload-performance-marker-missing' + ); } pendingMarkers = pendingMarkers.filter( (candidate) => candidate !== marker ); - return { - operationId: marker.operationId, - operationIdUnavailableReason: null, - }; + return identityFromMarker(marker); } if (markerCandidates.length > 1) { quarantine(operation, playlistId); - return { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-ambiguous', - }; + return unavailableIdentity( + 'preload-performance-marker-ambiguous' + ); } - const identity: DatabaseRequestIdentity = { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-missing', - }; + const identity = unavailableIdentity( + 'preload-performance-marker-missing' + ); pendingRequests.push({ identity, operation, @@ -248,6 +324,7 @@ export function installDatabaseRequestIdentityCaptureInMain( pendingMarkers = []; pendingRequests = []; quarantinedKeys = []; + successMarkers = []; active = true; }, stop: () => { @@ -255,6 +332,13 @@ export function installDatabaseRequestIdentityCaptureInMain( pendingMarkers = []; pendingRequests = []; quarantinedKeys = []; + successMarkers = []; + }, + successMarkerCount: () => successMarkers.length, + takeSuccessMarkers: () => { + const captured = Object.freeze([...successMarkers]); + successMarkers = []; + return captured; }, }; target[stateKey] = api; diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts new file mode 100644 index 000000000..37b19a714 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts @@ -0,0 +1,98 @@ +/* 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'; + +const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' +); + +function section(startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.ok(start >= 0, `missing start marker: ${startMarker}`); + assert.ok(end > start, `missing end marker: ${endMarker}`); + return source.slice(start, end); +} + +test('counts only finite nonnegative worker heap and external-memory samples per capture generation', () => { + const workerRecord = section( + 'interface WorkerRecord', + 'interface TimelineRecord' + ); + const createRecord = section( + 'const record: WorkerRecord = {', + 'nextWorkerOrdinal += 1' + ); + const sampleWorker = section( + 'const sampleWorker =', + 'const resetWorkerForCapture' + ); + const resetWorker = section( + 'const resetWorkerForCapture', + 'const startWorker' + ); + + assert.match(workerRecord, /externalMemorySampleCount: number/); + assert.match(workerRecord, /heapUsedSampleCount: number/); + assert.match(createRecord, /externalMemorySampleCount: 0/); + assert.match(createRecord, /heapUsedSampleCount: 0/); + assert.match(resetWorker, /record\.externalMemorySampleCount = 0/); + assert.match(resetWorker, /record\.heapUsedSampleCount = 0/); + + assert.match(sampleWorker, /const heapUsedSample = stats\.used_heap_size/); + assert.match( + sampleWorker, + /Number\.isFinite\(heapUsedSample\)[\s\S]*heapUsedSample >= 0/ + ); + assert.match(sampleWorker, /record\.heapUsedSampleCount \+= 1/); + assert.match( + sampleWorker, + /const externalMemorySample = stats\.external_memory/ + ); + assert.match( + sampleWorker, + /Number\.isFinite\(externalMemorySample\)[\s\S]*externalMemorySample >= 0/ + ); + assert.match(sampleWorker, /record\.externalMemorySampleCount \+= 1/); + assert.doesNotMatch(sampleWorker, /used_heap_size \?\? 0/); + assert.doesNotMatch(sampleWorker, /external_memory \?\? 0/); +}); + +test('raw worker metrics distinguish missing samples from measured zero-byte peaks', () => { + const transport = section( + 'const workers = currentWorkerRecords', + 'const transport: MainCaptureGenerationTransport' + ); + + assert.match( + transport, + /externalMemorySampleCount:\s*record\.externalMemorySampleCount/ + ); + assert.match( + transport, + /heapUsedSampleCount:\s*record\.heapUsedSampleCount/ + ); + assert.match( + transport, + /peakExternalBytes:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*record\.externalPeak[\s\S]*:\s*null/ + ); + assert.match( + transport, + /peakHeapUsedBytes:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*record\.heapPeak[\s\S]*:\s*null/ + ); + assert.match( + transport, + /peakExternalUnavailableReason:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-external-memory-samples-missing'/ + ); + assert.match( + transport, + /peakHeapUsedUnavailableReason:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-heap-used-samples-missing'/ + ); + assert.doesNotMatch( + transport, + /peakExternalBytes: record\.externalPeak[,}]/ + ); + assert.doesNotMatch(transport, /peakHeapUsedBytes: record\.heapPeak[,}]/); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts new file mode 100644 index 000000000..ffe7d9fb2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assessDatabaseWorkerPeakMemoryValidity, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON, +} from './database-worker-peak-memory-validity'; + +describe('database worker peak-memory validity', () => { + it('requires successful heap and external-memory samples in every measured run', () => { + const validity = assessDatabaseWorkerPeakMemoryValidity([ + iteration('warmup', 'warmup', worker(0, 0)), + iteration('run-01', 'measured', worker(3, 4)), + iteration('diagnostic', 'diagnostic', worker(0, 0)), + ]); + + assert.deepEqual(validity, { + invalidMeasuredRuns: [], + measuredRunCount: 1, + validForComparison: true, + validMeasuredRunCount: 1, + }); + }); + + it('fails closed for absent samples instead of accepting initialized zero peaks', () => { + const missingCounts = assessDatabaseWorkerPeakMemoryValidity([ + iteration('run-01', 'measured', { + kind: 'database.worker', + peakExternalBytes: 0, + peakHeapUsedBytes: 0, + }), + iteration('run-02', 'measured', worker(0, 1)), + iteration('run-03', 'measured', worker(1, 0)), + ]); + + assert.deepEqual(missingCounts.invalidMeasuredRuns, [ + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING, + runId: 'run-01', + }, + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING, + runId: 'run-02', + }, + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING, + runId: 'run-03', + }, + ]); + assert.equal(missingCounts.validForComparison, false); + assert.equal(missingCounts.validMeasuredRunCount, 0); + }); + + it('reports stable worker-cardinality and malformed-peak reasons', () => { + const validity = assessDatabaseWorkerPeakMemoryValidity([ + { kind: 'measured', main: { workers: [] }, runId: 'missing' }, + { + kind: 'measured', + main: { workers: [worker(1, 1), worker(1, 1)] }, + runId: 'multiple', + }, + iteration('heap-invalid', 'measured', { + ...worker(1, 1), + peakHeapUsedBytes: Number.NaN, + }), + iteration('external-invalid', 'measured', { + ...worker(1, 1), + peakExternalBytes: -1, + }), + iteration('heap-reason-incoherent', 'measured', { + ...worker(1, 1), + peakHeapUsedUnavailableReason: + 'worker-heap-used-samples-missing', + }), + iteration('external-reason-incoherent', 'measured', { + ...worker(1, 1), + peakExternalUnavailableReason: + 'worker-external-memory-samples-missing', + }), + ]); + + assert.deepEqual( + validity.invalidMeasuredRuns.map(({ reason }) => reason), + [ + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID, + ] + ); + }); +}); + +function iteration( + runId: string, + kind: string, + databaseWorker: Record +) { + return { + kind, + main: { workers: [databaseWorker] }, + runId, + }; +} + +function worker( + heapUsedSampleCount: number, + externalMemorySampleCount: number +) { + return { + externalMemorySampleCount, + heapUsedSampleCount, + kind: 'database.worker', + peakExternalBytes: 600, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: 1_500, + peakHeapUsedUnavailableReason: null, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts new file mode 100644 index 000000000..09f9bf36a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts @@ -0,0 +1,114 @@ +export const DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON = { + EXTERNAL_MEMORY_PEAK_INVALID: + 'database-worker-external-memory-peak-invalid', + EXTERNAL_MEMORY_SAMPLES_MISSING: + 'database-worker-external-memory-samples-missing', + HEAP_PEAK_INVALID: 'database-worker-heap-peak-invalid', + HEAP_SAMPLES_MISSING: 'database-worker-heap-samples-missing', + MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers', + DATABASE_WORKER_MISSING: 'database-worker-missing', +} as const; + +export type DatabaseWorkerPeakMemoryInvalidReason = + (typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON)[keyof typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON]; + +export interface DatabaseWorkerPeakMemoryValidity { + readonly invalidMeasuredRuns: readonly { + readonly reason: DatabaseWorkerPeakMemoryInvalidReason; + readonly runId: string; + }[]; + readonly measuredRunCount: number; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +interface PeakMemoryWorker { + readonly externalMemorySampleCount?: unknown; + readonly heapUsedSampleCount?: unknown; + readonly kind: string; + readonly peakExternalBytes: unknown; + readonly peakExternalUnavailableReason?: unknown; + readonly peakHeapUsedBytes: unknown; + readonly peakHeapUsedUnavailableReason?: unknown; +} + +interface PeakMemoryIteration { + readonly kind: string; + readonly main: { + readonly workers: readonly PeakMemoryWorker[]; + }; + readonly runId: string; +} + +export function assessDatabaseWorkerPeakMemoryValidity( + iterations: readonly PeakMemoryIteration[] +): DatabaseWorkerPeakMemoryValidity { + const measured = iterations.filter( + (iteration) => iteration.kind === 'measured' + ); + const invalidMeasuredRuns: { + reason: DatabaseWorkerPeakMemoryInvalidReason; + runId: string; + }[] = []; + + for (const iteration of measured) { + const workers = iteration.main.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const reason = assessWorkers(workers); + if (reason !== null) { + invalidMeasuredRuns.push({ reason, runId: iteration.runId }); + } + } + + return Object.freeze({ + invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns), + measuredRunCount: measured.length, + validForComparison: + measured.length > 0 && invalidMeasuredRuns.length === 0, + validMeasuredRunCount: measured.length - invalidMeasuredRuns.length, + }); +} + +function assessWorkers( + workers: readonly PeakMemoryWorker[] +): DatabaseWorkerPeakMemoryInvalidReason | null { + if (workers.length === 0) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING; + } + if (workers.length !== 1) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS; + } + + const worker = workers[0]; + if (!worker) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING; + } + if (!isPositiveSampleCount(worker.heapUsedSampleCount)) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING; + } + if ( + !isPeak(worker.peakHeapUsedBytes) || + worker.peakHeapUsedUnavailableReason !== null + ) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID; + } + if (!isPositiveSampleCount(worker.externalMemorySampleCount)) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING; + } + if ( + !isPeak(worker.peakExternalBytes) || + worker.peakExternalUnavailableReason !== null + ) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID; + } + return null; +} + +function isPositiveSampleCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isPeak(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts new file mode 100644 index 000000000..0b88917c6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assessDatabaseWorkerRequestMetricsValidity, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON, +} from './database-worker-request-metrics-validity'; +import { createCompleteTestMainCapture } from './m3u-import-report.test-helpers'; +import type { + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +describe('database worker request-metrics validity', () => { + it('requires exactly two complete request samples in every measured run', () => { + const worker = completeWorker(); + const validity = assessDatabaseWorkerRequestMetricsValidity([ + iteration('warmup', 'warmup', [worker]), + iteration('run-01', 'measured', [worker]), + iteration('run-02', 'measured', [worker]), + iteration('diagnostic', 'diagnostic', [worker]), + ]); + + assert.deepEqual(validity, { + expectedRequestCount: 4, + invalidRequests: [], + measuredRunCount: 2, + validForComparison: true, + validMeasuredRunCount: 2, + validRequestCount: 4, + }); + }); + + it('fails each measured run with missing ELD, ELU, CPU, or raw capture metrics', () => { + const validity = assessDatabaseWorkerRequestMetricsValidity([ + invalidRequestIteration('run-eld', { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + histogramFlushedEpochMs: null, + }), + invalidRequestIteration('run-elu', { + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'event-loop-utilization-unavailable', + }), + invalidRequestIteration('run-cpu', { + threadCpuSystemMicros: null, + threadCpuUnavailableReason: 'thread-cpu-usage-unavailable', + threadCpuUserMicros: null, + }), + invalidRequestIteration('run-capture', { + performanceCaptureUnavailableReason: + 'worker-performance-capture-invalid', + }), + ]); + + assert.equal(validity.expectedRequestCount, 8); + assert.equal(validity.validRequestCount, 4); + assert.equal(validity.validMeasuredRunCount, 0); + assert.equal(validity.validForComparison, false); + assert.deepEqual( + validity.invalidRequests.map(({ reason }) => reason), + [ + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID, + ] + ); + }); + + it('fails closed for missing, multiple, and wrong request sets', () => { + const worker = completeWorker(); + const validity = assessDatabaseWorkerRequestMetricsValidity([ + iteration('missing', 'measured', []), + iteration('multiple', 'measured', [worker, worker]), + iteration('wrong-requests', 'measured', [ + { + ...worker, + requests: worker.requests.slice(0, 1), + }, + ]), + ]); + + assert.equal(validity.expectedRequestCount, 6); + assert.equal(validity.validRequestCount, 0); + assert.equal(validity.validForComparison, false); + assert.deepEqual( + validity.invalidRequests.map(({ reason }) => reason), + [ + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID, + ] + ); + }); +}); + +function completeWorker(): WorkerCaptureMetrics { + const worker = createCompleteTestMainCapture().workers[0]; + assert.ok(worker); + return worker; +} + +function invalidRequestIteration( + runId: string, + updates: Partial +) { + const worker = completeWorker(); + const get = worker.requests[1]; + assert.ok(get); + return iteration(runId, 'measured', [ + { + ...worker, + requests: [worker.requests[0], { ...get, ...updates }], + }, + ]); +} + +function iteration( + runId: string, + kind: string, + workers: readonly WorkerCaptureMetrics[] +) { + return { kind, main: { workers }, runId }; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts new file mode 100644 index 000000000..5a924854b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts @@ -0,0 +1,197 @@ +import type { + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; + +export const DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON = { + DATABASE_WORKER_MISSING: 'database-worker-missing', + EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid', + EVENT_LOOP_UTILIZATION_INVALID: 'event-loop-utilization-invalid', + MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers', + REQUEST_SET_INVALID: 'initial-import-request-set-invalid', + THREAD_CPU_INVALID: 'thread-cpu-invalid', + WORKER_CAPTURE_INVALID: 'worker-performance-capture-invalid', +} as const; + +export type DatabaseWorkerRequestMetricsInvalidReason = + (typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON)[keyof typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON]; + +export interface DatabaseWorkerRequestMetricsValidity { + readonly expectedRequestCount: number; + readonly invalidRequests: readonly { + readonly operation: string | null; + readonly reason: DatabaseWorkerRequestMetricsInvalidReason; + readonly runId: string; + }[]; + readonly measuredRunCount: number; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; + readonly validRequestCount: number; +} + +interface RequestMetricsIteration { + readonly kind: string; + readonly main: { + readonly workers: readonly WorkerCaptureMetrics[]; + }; + readonly runId: string; +} + +export function assessDatabaseWorkerRequestMetricsValidity( + iterations: readonly RequestMetricsIteration[] +): DatabaseWorkerRequestMetricsValidity { + const measured = iterations.filter( + (iteration) => + iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + const invalidRequests: { + operation: string | null; + reason: DatabaseWorkerRequestMetricsInvalidReason; + runId: string; + }[] = []; + let validMeasuredRunCount = 0; + let validRequestCount = 0; + + for (const iteration of measured) { + const workers = iteration.main.workers.filter( + (worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE + ); + if (workers.length === 0) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING, + runId: iteration.runId, + }); + continue; + } + const worker = workers.length === 1 ? workers[0] : null; + if (!worker) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + runId: iteration.runId, + }); + continue; + } + const requests = requireInitialImportRequestPair(worker); + if (requests === null) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID, + runId: iteration.runId, + }); + continue; + } + + let runIsValid = true; + for (const request of requests) { + const reason = assessRequest(request); + if (reason === null) { + validRequestCount += 1; + } else { + runIsValid = false; + invalidRequests.push({ + operation: request.operation, + reason, + runId: iteration.runId, + }); + } + } + if (runIsValid) { + validMeasuredRunCount += 1; + } + } + + const expectedRequestCount = measured.length * 2; + return Object.freeze({ + expectedRequestCount, + invalidRequests: Object.freeze(invalidRequests), + measuredRunCount: measured.length, + validForComparison: + measured.length > 0 && + validMeasuredRunCount === measured.length && + validRequestCount === expectedRequestCount, + validMeasuredRunCount, + validRequestCount, + }); +} + +function requireInitialImportRequestPair( + worker: WorkerCaptureMetrics +): readonly [ + WorkerRequestPerformanceMetrics, + WorkerRequestPerformanceMetrics, +] | null { + const upserts = worker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success + ); + const gets = worker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && request.success + ); + return worker.requests.length === 2 && + upserts.length === 1 && + upserts[0] && + gets.length === 1 && + gets[0] + ? [upserts[0], gets[0]] + : null; +} + +function assessRequest( + request: WorkerRequestPerformanceMetrics +): DatabaseWorkerRequestMetricsInvalidReason | null { + if ( + request.performanceCaptureUnavailableReason !== null || + request.invalidReason !== null + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID; + } + if ( + request.eventLoopDelayUnavailableReason !== null || + request.histogramFlushedEpochMs === null || + !isEventLoopDelay(request.eventLoopDelay) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID; + } + if ( + request.eventLoopUtilizationUnavailableReason !== null || + !isUtilization(request.eventLoopUtilization) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID; + } + if ( + request.threadCpuUnavailableReason !== null || + !isFiniteNonNegative(request.threadCpuSystemMicros) || + !isFiniteNonNegative(request.threadCpuUserMicros) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID; + } + return null; +} + +function isEventLoopDelay( + value: WorkerRequestPerformanceMetrics['eventLoopDelay'] +): value is NonNullable { + return ( + value !== null && + isFiniteNonNegative(value.maxMs) && + isFiniteNonNegative(value.p95Ms) && + isFiniteNonNegative(value.p99Ms) && + value.p95Ms <= value.p99Ms && + value.p99Ms <= value.maxMs + ); +} + +function isUtilization(value: number | null): value is number { + return isFiniteNonNegative(value) && value <= 1; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts new file mode 100644 index 000000000..b162bdafa --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts @@ -0,0 +1,185 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isCpuProfile(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const startTime = value['startTime']; + const endTime = value['endTime']; + const nodes = value['nodes']; + const samples = value['samples']; + const timeDeltas = value['timeDeltas']; + if ( + !isNonNegativeSafeInteger(startTime) || + !isNonNegativeSafeInteger(endTime) || + endTime <= startTime || + !Array.isArray(nodes) || + nodes.length === 0 || + !Array.isArray(samples) || + samples.length === 0 || + !Array.isArray(timeDeltas) || + timeDeltas.length !== samples.length + ) { + return false; + } + + const nodeIds = new Set(); + for (const node of nodes) { + if (!isCpuProfileNode(node) || nodeIds.has(node['id'])) { + return false; + } + nodeIds.add(node['id']); + } + if ( + nodes.some((node) => + (node as Record)['children'] instanceof Array + ? ( + (node as Record)[ + 'children' + ] as unknown[] + ).some( + (childId) => + !isNonNegativeSafeInteger(childId) || + !nodeIds.has(childId) + ) + : false + ) || + samples.some( + (sampleId) => + !isNonNegativeSafeInteger(sampleId) || + !nodeIds.has(sampleId) + ) + ) { + return false; + } + + const duration = endTime - startTime; + let elapsed = 0; + for (const delta of timeDeltas) { + if (!Number.isSafeInteger(delta)) { + return false; + } + elapsed += delta as number; + if ( + !Number.isSafeInteger(elapsed) || + elapsed < 0 || + elapsed > duration + ) { + return false; + } + } + return elapsed > 0; +} + +export function isHeapSnapshot(value: unknown): boolean { + if ( + !isRecord(value) || + !isRecord(value['snapshot']) || + !isRecord(value['snapshot']['meta']) + ) { + return false; + } + const meta = value['snapshot']['meta']; + const nodeFields = meta['node_fields']; + const nodeTypes = meta['node_types']; + const edgeFields = meta['edge_fields']; + const edgeTypes = meta['edge_types']; + const nodes = value['nodes']; + const edges = value['edges']; + const strings = value['strings']; + return ( + isNonEmptyStringArray(nodeFields) && + isHeapTypeTable(nodeTypes, nodeFields.length) && + isNonEmptyStringArray(edgeFields) && + isHeapTypeTable(edgeTypes, edgeFields.length) && + Array.isArray(nodes) && + nodes.length > 0 && + nodes.length % nodeFields.length === 0 && + Array.isArray(edges) && + edges.length > 0 && + edges.length % edgeFields.length === 0 && + Array.isArray(strings) && + strings.length > 0 + ); +} + +export function isRendererTrace(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const events = value['traceEvents']; + return ( + Array.isArray(events) && + events.length > 0 && + events.every( + (event) => + isRecord(event) && + isNonEmptyString(event['name']) && + isNonEmptyString(event['ph']) && + isNonNegativeFiniteNumber(event['ts']) + ) + ); +} + +function isHeapTypeTable(value: unknown, fieldCount: number): boolean { + return ( + Array.isArray(value) && + value.length === fieldCount && + value.every( + (entry) => + isNonEmptyString(entry) || isNonEmptyStringArray(entry) + ) + ); +} + +function isCpuProfileNode( + value: unknown +): value is Record & { readonly id: number } { + if ( + !isRecord(value) || + !isNonNegativeSafeInteger(value['id']) || + !isRecord(value['callFrame']) + ) { + return false; + } + const callFrame = value['callFrame']; + const scriptId = callFrame['scriptId']; + const children = value['children']; + const hitCount = value['hitCount']; + return ( + typeof callFrame['functionName'] === 'string' && + (typeof scriptId === 'string' || + isNonNegativeSafeInteger(scriptId)) && + typeof callFrame['url'] === 'string' && + Number.isSafeInteger(callFrame['lineNumber']) && + Number.isSafeInteger(callFrame['columnNumber']) && + (children === undefined || + (Array.isArray(children) && + children.every(isNonNegativeSafeInteger))) && + (hitCount === undefined || isNonNegativeSafeInteger(hitCount)) + ); +} + +function isNonEmptyStringArray(value: unknown): value is readonly string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every(isNonEmptyString) + ); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function isNonNegativeFiniteNumber(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value >= 0 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts new file mode 100644 index 000000000..8a67af360 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts @@ -0,0 +1,234 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +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 InitialImportDiagnosticCapture, + validateInitialImportDiagnosticArtifacts, +} from './initial-import-diagnostic-artifacts'; +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; +const HEAP_META = { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], +}; +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { meta: HEAP_META }, + strings: ['', 'root'], +}; +const TRACE = { + traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }], +}; + +function capture(directory: string): InitialImportDiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: 4, + profilePath: join( + directory, + 'database.worker-4.cpuprofile' + ), + snapshotPath: join( + directory, + 'database.worker-4.heapsnapshot' + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function writeArtifacts(directory: string): Promise { + const artifacts: Readonly> = { + 'database.worker-4.cpuprofile': CPU_PROFILE, + 'database.worker-4.heapsnapshot': HEAP_SNAPSHOT, + 'main.cpuprofile': CPU_PROFILE, + 'main.heapsnapshot': HEAP_SNAPSHOT, + 'renderer.cpuprofile': CPU_PROFILE, + 'renderer.heapsnapshot': HEAP_SNAPSHOT, + 'renderer.trace.json': TRACE, + }; + await Promise.all( + Object.entries(artifacts).map(([filename, value]) => + writeFile(join(directory, filename), JSON.stringify(value)) + ) + ); +} + +async function withArtifacts( + run: (directory: string) => Promise +): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-content-') + ); + try { + await writeArtifacts(directory); + await run(directory); + } finally { + await rm(directory, { force: true, recursive: true }); + } +} + +test('accepts bounded signed CPU time deltas emitted by Chromium', async () => { + await withArtifacts(async (directory) => { + await writeFile( + join(directory, 'renderer.cpuprofile'), + JSON.stringify({ + ...CPU_PROFILE, + endTime: 5_000, + samples: [1, 1, 1], + timeDeltas: [1_000, -100, 1_000], + }) + ); + await assert.doesNotReject(() => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ) + ); + }); +}); + +test('rejects CPU profiles without useful bounded samples and stack references', async () => { + await withArtifacts(async (directory) => { + const invalidProfiles = [ + { ...CPU_PROFILE, samples: [], timeDeltas: [] }, + { ...CPU_PROFILE, timeDeltas: undefined }, + { ...CPU_PROFILE, timeDeltas: [] }, + { + ...CPU_PROFILE, + endTime: CPU_PROFILE.startTime, + }, + { + ...CPU_PROFILE, + samples: [2], + }, + { + ...CPU_PROFILE, + timeDeltas: [-1], + }, + { + ...CPU_PROFILE, + timeDeltas: [2_001], + }, + { + ...CPU_PROFILE, + nodes: [ + ...CPU_PROFILE.nodes, + { ...CPU_PROFILE.nodes[0], id: 1 }, + ], + }, + { + ...CPU_PROFILE, + nodes: [{ id: 1 }], + }, + ]; + for (const profile of invalidProfiles) { + await writeFile( + join(directory, 'main.cpuprofile'), + JSON.stringify(profile) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:main-cpu-profile/ + ); + } + }); +}); + +test('rejects empty or malformed heap tables and invalid field strides', async () => { + await withArtifacts(async (directory) => { + const invalidSnapshots = [ + { snapshot: {}, nodes: [], edges: [], strings: [] }, + { ...HEAP_SNAPSHOT, nodes: [] }, + { ...HEAP_SNAPSHOT, edges: [] }, + { ...HEAP_SNAPSHOT, strings: [] }, + { + ...HEAP_SNAPSHOT, + snapshot: { + meta: { ...HEAP_META, node_types: undefined }, + }, + }, + { ...HEAP_SNAPSHOT, nodes: [...HEAP_SNAPSHOT.nodes, 0] }, + { ...HEAP_SNAPSHOT, edges: [...HEAP_SNAPSHOT.edges, 0] }, + ]; + for (const snapshot of invalidSnapshots) { + await writeFile( + join(directory, 'main.heapsnapshot'), + JSON.stringify(snapshot) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:main-heap-snapshot/ + ); + } + }); +}); + +test('rejects empty traces and malformed trace events', async () => { + await withArtifacts(async (directory) => { + const invalidTraces = [ + { traceEvents: [] }, + { traceEvents: [null] }, + { traceEvents: [{ name: '', ph: 'X', ts: 1 }] }, + { traceEvents: [{ name: 'initial-import', ph: '', ts: 1 }] }, + { + traceEvents: [ + { name: 'initial-import', ph: 'X', ts: 'invalid' }, + ], + }, + ]; + for (const trace of invalidTraces) { + await writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(trace) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:renderer-trace/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts new file mode 100644 index 000000000..1270709c3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts @@ -0,0 +1,394 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, symlink, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; + +interface DiagnosticCapture { + readonly main: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly workers: readonly { + readonly kind: string; + readonly ordinal: number; + readonly profilePath: string | null; + readonly snapshotPath: string | null; + }[]; + }; + readonly renderer: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly tracePath: string | null; + }; +} + +interface DiagnosticArtifactsModule { + validateInitialImportDiagnosticArtifacts?: ( + capture: DiagnosticCapture, + iterationDirectory: string + ) => Promise; +} + +const diagnosticArtifactsModulePromise = import( + new URL('./initial-import-diagnostic-artifacts.ts', import.meta.url).href +) + .then((module) => module as DiagnosticArtifactsModule) + .catch(() => null); + +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { + meta: { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], + }, + }, + strings: ['', 'root'], +}; +const RENDERER_TRACE = { + traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }], +}; + +function capture(directory: string, databaseOrdinal = 4): DiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: databaseOrdinal, + profilePath: join( + directory, + `database.worker-${databaseOrdinal}.cpuprofile` + ), + snapshotPath: join( + directory, + `database.worker-${databaseOrdinal}.heapsnapshot` + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function writeValidArtifacts( + directory: string, + databaseOrdinal = 4 +): Promise { + await Promise.all([ + writeFile( + join(directory, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, 'renderer.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, `database.worker-${databaseOrdinal}.cpuprofile`), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, `database.worker-${databaseOrdinal}.heapsnapshot`), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'main.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'renderer.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ), + ]); +} + +test('accepts the complete parseable initial-import diagnostic artifact set', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module, 'initial-import diagnostic validator must exist'); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-diagnostic-') + ); + + try { + await writeValidArtifacts(directory); + await assert.doesNotReject(() => + validate?.(capture(directory), directory) + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('fails closed for malformed or missing required process artifacts', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-invalid-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile(join(directory, 'renderer.trace.json'), '{'); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-invalid:renderer-trace/ + ); + + await writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ); + const missingMainProfile: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: null, + }, + }; + await assert.rejects( + () => validate?.(missingMainProfile, directory), + /initial-import-diagnostic-artifact-missing:main-cpu-profile/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('rejects escaped paths and symlinks instead of reading outside the iteration directory', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-contained-') + ); + const outsideDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-outside-') + ); + + try { + await writeValidArtifacts(directory); + const escapedCapture: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: join(outsideDirectory, 'main.cpuprofile'), + }, + }; + await writeFile( + join(outsideDirectory, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(escapedCapture, directory), + /initial-import-diagnostic-artifact-path-invalid:main-cpu-profile/ + ); + + const rendererProfilePath = join(directory, 'renderer.cpuprofile'); + await unlink(rendererProfilePath); + await symlink( + join(outsideDirectory, 'main.cpuprofile'), + rendererProfilePath + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-path-invalid:renderer-cpu-profile/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + await rm(outsideDirectory, { force: true, recursive: true }); + } +}); + +test('requires exactly one database profile and rejects every playlist-worker profile', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-workers-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile( + join(directory, 'database.worker-9.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-database-profile-cardinality-invalid/ + ); + + await unlink(join(directory, 'database.worker-9.cpuprofile')); + await writeFile( + join(directory, 'playlist-refresh.worker-2.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-playlist-profile-unexpected/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('requires a structurally valid database worker heap snapshot', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-') + ); + + try { + await writeValidArtifacts(directory); + const missingSnapshot: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + workers: [ + { + ...capture(directory).main.workers[0], + snapshotPath: null, + }, + ], + }, + }; + await assert.rejects( + () => validate?.(missingSnapshot, directory), + /initial-import-diagnostic-artifact-missing:database-heap-snapshot/ + ); + + await writeFile( + join(directory, 'database.worker-4.heapsnapshot'), + JSON.stringify({ snapshot: {}, nodes: [], edges: [] }) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-invalid:database-heap-snapshot/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('rejects escaped and symlinked database worker heap snapshots', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-contained-') + ); + const outsideDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-outside-') + ); + + try { + await writeValidArtifacts(directory); + const outsideSnapshotPath = join( + outsideDirectory, + 'database.worker-4.heapsnapshot' + ); + await writeFile(outsideSnapshotPath, JSON.stringify(HEAP_SNAPSHOT)); + const escapedSnapshot: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + workers: [ + { + ...capture(directory).main.workers[0], + snapshotPath: outsideSnapshotPath, + }, + ], + }, + }; + await assert.rejects( + () => validate?.(escapedSnapshot, directory), + /initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/ + ); + + const snapshotPath = join(directory, 'database.worker-4.heapsnapshot'); + await unlink(snapshotPath); + await symlink(outsideSnapshotPath, snapshotPath); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + await rm(outsideDirectory, { force: true, recursive: true }); + } +}); + +test('rejects extra database snapshots and every playlist-worker snapshot', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-count-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile( + join(directory, 'database.worker-9.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-database-snapshot-cardinality-invalid/ + ); + + await unlink(join(directory, 'database.worker-9.heapsnapshot')); + await writeFile( + join(directory, 'playlist-refresh.worker-2.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-playlist-snapshot-unexpected/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts new file mode 100644 index 000000000..f9f5cbdb3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts @@ -0,0 +1,262 @@ +import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { + isCpuProfile, + isHeapSnapshot, + isRendererTrace, +} from './initial-import-diagnostic-artifact-content-validation'; + +interface DiagnosticWorkerArtifact { + readonly kind: string; + readonly ordinal: number; + readonly profilePath: string | null; + readonly snapshotPath: string | null; +} + +export interface InitialImportDiagnosticCapture { + readonly main: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly workers: readonly DiagnosticWorkerArtifact[]; + }; + readonly renderer: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly tracePath: string | null; + }; +} + +type ArtifactLabel = + | 'database-cpu-profile' + | 'database-heap-snapshot' + | 'main-cpu-profile' + | 'main-heap-snapshot' + | 'renderer-cpu-profile' + | 'renderer-heap-snapshot' + | 'renderer-trace'; + +interface ArtifactDescription { + readonly filename: string; + readonly label: ArtifactLabel; + readonly path: string | null; + readonly validate: (value: unknown) => boolean; +} + +export async function validateInitialImportDiagnosticArtifacts( + capture: InitialImportDiagnosticCapture, + iterationDirectory: string +): Promise { + const directory = resolve(iterationDirectory); + let realDirectory: string; + try { + realDirectory = await realpath(directory); + } catch { + throw new Error( + 'initial-import-diagnostic-artifact-path-invalid:iteration-directory' + ); + } + + const databaseWorker = requireExactDatabaseWorker(capture.main.workers); + await assertWorkerArtifactCardinality(directory); + + const artifacts: readonly ArtifactDescription[] = [ + { + filename: 'main.cpuprofile', + label: 'main-cpu-profile', + path: capture.main.cpuProfilePath, + validate: isCpuProfile, + }, + { + filename: 'renderer.cpuprofile', + label: 'renderer-cpu-profile', + path: capture.renderer.cpuProfilePath, + validate: isCpuProfile, + }, + { + filename: 'renderer.trace.json', + label: 'renderer-trace', + path: capture.renderer.tracePath, + validate: isRendererTrace, + }, + { + filename: 'main.heapsnapshot', + label: 'main-heap-snapshot', + path: capture.main.heapSnapshotPath, + validate: isHeapSnapshot, + }, + { + filename: 'renderer.heapsnapshot', + label: 'renderer-heap-snapshot', + path: capture.renderer.heapSnapshotPath, + validate: isHeapSnapshot, + }, + { + filename: `database.worker-${databaseWorker.ordinal}.cpuprofile`, + label: 'database-cpu-profile', + path: databaseWorker.profilePath, + validate: isCpuProfile, + }, + { + filename: `database.worker-${databaseWorker.ordinal}.heapsnapshot`, + label: 'database-heap-snapshot', + path: databaseWorker.snapshotPath, + validate: isHeapSnapshot, + }, + ]; + + for (const artifact of artifacts) { + await validateJsonArtifact(directory, realDirectory, artifact); + } +} + +function requireExactDatabaseWorker( + workers: readonly DiagnosticWorkerArtifact[] +): DiagnosticWorkerArtifact { + if ( + workers.some( + (worker) => + worker.kind === 'playlist-refresh.worker' && + worker.profilePath !== null + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-profile-unexpected' + ); + } + if ( + workers.some( + (worker) => + worker.kind === 'playlist-refresh.worker' && + worker.snapshotPath !== null + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-snapshot-unexpected' + ); + } + const databaseWorkers = workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const worker = databaseWorkers[0]; + if ( + databaseWorkers.length !== 1 || + !worker || + !Number.isSafeInteger(worker.ordinal) || + worker.ordinal < 0 + ) { + throw new Error( + 'initial-import-diagnostic-database-profile-cardinality-invalid' + ); + } + return worker; +} + +async function assertWorkerArtifactCardinality( + directory: string +): Promise { + let entries: readonly string[]; + try { + entries = await readdir(directory); + } catch { + throw new Error( + 'initial-import-diagnostic-artifact-path-invalid:iteration-directory' + ); + } + if ( + entries.some((entry) => + /^playlist-refresh\.worker-\d+\.cpuprofile$/u.test(entry) + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-profile-unexpected' + ); + } + if ( + entries.filter((entry) => + /^database\.worker-\d+\.cpuprofile$/u.test(entry) + ).length !== 1 + ) { + throw new Error( + 'initial-import-diagnostic-database-profile-cardinality-invalid' + ); + } + if ( + entries.some((entry) => + /^playlist-refresh\.worker-\d+\.heapsnapshot$/u.test(entry) + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-snapshot-unexpected' + ); + } + if ( + entries.filter((entry) => + /^database\.worker-\d+\.heapsnapshot$/u.test(entry) + ).length !== 1 + ) { + throw new Error( + 'initial-import-diagnostic-database-snapshot-cardinality-invalid' + ); + } +} + +async function validateJsonArtifact( + directory: string, + realDirectory: string, + artifact: ArtifactDescription +): Promise { + if (artifact.path === null) { + throw new Error( + `initial-import-diagnostic-artifact-missing:${artifact.label}` + ); + } + const expectedPath = join(directory, artifact.filename); + if (resolve(artifact.path) !== expectedPath) { + throw new Error( + `initial-import-diagnostic-artifact-path-invalid:${artifact.label}` + ); + } + + let value: unknown; + try { + const stats = await lstat(expectedPath); + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('unsafe-artifact-type'); + } + const realArtifactPath = await realpath(expectedPath); + if (!isContained(realDirectory, realArtifactPath)) { + throw new Error('escaped-artifact'); + } + value = JSON.parse(await readFile(realArtifactPath, 'utf8')) as unknown; + } catch (error) { + if ( + error instanceof Error && + (error.message === 'unsafe-artifact-type' || + error.message === 'escaped-artifact') + ) { + throw new Error( + `initial-import-diagnostic-artifact-path-invalid:${artifact.label}` + ); + } + throw new Error( + `initial-import-diagnostic-artifact-invalid:${artifact.label}` + ); + } + + if (!artifact.validate(value)) { + throw new Error( + `initial-import-diagnostic-artifact-invalid:${artifact.label}` + ); + } +} + +function isContained(directory: string, artifactPath: string): boolean { + const relativePath = relative(directory, artifactPath); + return ( + relativePath.length > 0 && + !isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`) + ); +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts new file mode 100644 index 000000000..1746c9335 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts @@ -0,0 +1,19 @@ +export const CPU_PROFILE = Object.freeze({ + endTime: 3_000, + nodes: Object.freeze([ + Object.freeze({ + callFrame: Object.freeze({ + columnNumber: -1, + functionName: '(root)', + lineNumber: -1, + scriptId: '0', + url: '', + }), + hitCount: 1, + id: 1, + }), + ]), + samples: Object.freeze([1]), + startTime: 1_000, + timeDeltas: Object.freeze([1_000]), +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts new file mode 100644 index 000000000..21c4aadc4 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + assertSyntheticM3uImportUrl, + createM3uImportIterationDefinitions, + listM3uImportScenarios, + M3U_IMPORT_SCENARIO_ID, +} from './m3u-import-benchmark-contract'; + +describe('formal M3U import benchmark contract', () => { + it('defines the exact 10k, 50k, and 100k synthetic scenarios', () => { + assert.deepEqual(listM3uImportScenarios(), [ + { + channelCount: 10_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K, + }, + { + channelCount: 50_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K, + }, + { + channelCount: 100_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K, + }, + ]); + }); + + it('schedules one warm-up, five measured runs, and one diagnostic per size', () => { + const definitions = createM3uImportIterationDefinitions(false); + + assert.equal(definitions.length, 21); + for (const scenario of listM3uImportScenarios()) { + const scenarioRuns = definitions.filter( + (definition) => definition.scenarioId === scenario.id + ); + assert.deepEqual( + scenarioRuns.map(({ kind, runId }) => ({ kind, runId })), + [ + { + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: 'warmup-01', + }, + ...Array.from({ length: 5 }, (_, index) => ({ + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: `run-${String(index + 1).padStart(2, '0')}`, + })), + { + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }, + ] + ); + assert.ok( + scenarioRuns.every( + (definition) => + definition.channelCount === scenario.channelCount + ) + ); + } + }); + + it('keeps fixture sizes unchanged while smoke mode reduces measured runs to one', () => { + const definitions = createM3uImportIterationDefinitions(true); + + assert.equal(definitions.length, 9); + for (const scenario of listM3uImportScenarios()) { + assert.deepEqual( + definitions + .filter( + (definition) => definition.scenarioId === scenario.id + ) + .map(({ kind, runId }) => ({ kind, runId })), + [ + { + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: 'warmup-01', + }, + { + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: 'run-01', + }, + { + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }, + ] + ); + } + }); + + it('accepts only the exact credential-free loopback fixture URL', () => { + assert.doesNotThrow(() => + assertSyntheticM3uImportUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u' + ) + ); + + for (const unsafe of [ + 'https://127.0.0.1:43210/synthetic-performance.m3u', + 'http://localhost:43210/synthetic-performance.m3u', + 'http://127.0.0.1/synthetic-performance.m3u', + 'http://user:pass@127.0.0.1:43210/synthetic-performance.m3u', + 'http://127.0.0.1:43210/other.m3u', + 'http://127.0.0.1:43210/synthetic-performance.m3u?token=secret', + 'http://127.0.0.1:43210/synthetic-performance.m3u#fragment', + ]) { + assert.throws( + () => assertSyntheticM3uImportUrl(unsafe), + /exact synthetic HTTP loopback URL/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts new file mode 100644 index 000000000..6740be17f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts @@ -0,0 +1,118 @@ +import { + PERFORMANCE_ITERATION_KIND, + type PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import { SYNTHETIC_M3U_RESOURCE_PATH } from './synthetic-m3u-server'; +import type { SyntheticM3uChannelCount } from './synthetic-m3u'; + +export const M3U_IMPORT_SCENARIO_ID = { + IMPORT_10K: 'm3u-import-10k', + IMPORT_50K: 'm3u-import-50k', + IMPORT_100K: 'm3u-import-100k', +} as const; + +export type M3uImportScenarioId = + (typeof M3U_IMPORT_SCENARIO_ID)[keyof typeof M3U_IMPORT_SCENARIO_ID]; + +export interface M3uImportScenario { + readonly channelCount: SyntheticM3uChannelCount; + readonly id: M3uImportScenarioId; +} + +export interface M3uImportIterationDefinition { + readonly channelCount: SyntheticM3uChannelCount; + readonly kind: PerformanceIterationKind; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +} + +const SCENARIOS: readonly M3uImportScenario[] = Object.freeze([ + Object.freeze({ + channelCount: 10_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K, + }), + Object.freeze({ + channelCount: 50_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K, + }), + Object.freeze({ + channelCount: 100_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K, + }), +]); + +export function listM3uImportScenarios(): readonly M3uImportScenario[] { + return SCENARIOS; +} + +export function createM3uImportIterationDefinitions( + smoke: boolean +): readonly M3uImportIterationDefinition[] { + const measuredRuns = smoke ? 1 : 5; + const definitions: M3uImportIterationDefinition[] = []; + + for (const scenario of SCENARIOS) { + definitions.push( + iteration(scenario, PERFORMANCE_ITERATION_KIND.WARMUP, 'warmup-01') + ); + for (let index = 1; index <= measuredRuns; index += 1) { + definitions.push( + iteration( + scenario, + PERFORMANCE_ITERATION_KIND.MEASURED, + `run-${String(index).padStart(2, '0')}` + ) + ); + } + definitions.push( + iteration( + scenario, + PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + 'diagnostic' + ) + ); + } + + return Object.freeze(definitions); +} + +export function assertSyntheticM3uImportUrl(value: string): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw invalidSyntheticUrl(); + } + + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + url.port.length === 0 || + url.username.length > 0 || + url.password.length > 0 || + url.pathname !== SYNTHETIC_M3U_RESOURCE_PATH || + url.search.length > 0 || + url.hash.length > 0 + ) { + throw invalidSyntheticUrl(); + } +} + +function iteration( + scenario: M3uImportScenario, + kind: PerformanceIterationKind, + runId: string +): M3uImportIterationDefinition { + return Object.freeze({ + channelCount: scenario.channelCount, + kind, + runId, + scenarioId: scenario.id, + }); +} + +function invalidSyntheticUrl(): Error { + return new Error( + 'M3U performance fixture must use the exact synthetic HTTP loopback URL' + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts new file mode 100644 index 000000000..f1dcd053a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +import { + assertM3uImportOutputPathHasNoSymlinks, + assertM3uImportSourceState, + M3U_IMPORT_RENDERER_CDP_PORT, + resolveM3uImportRendererCdpPort, + resolveM3uImportOutputLayout, +} from './m3u-import-benchmark-support'; +import { assertM3uImportRequestDelta } from './m3u-import.benchmark'; + +describe('initial M3U import benchmark lifecycle', () => { + it('keeps requested output and variant strictly below dist/performance', () => { + const workspaceRoot = resolve('/workspace/iptvnator'); + const outputRoot = resolve( + workspaceRoot, + 'dist/performance/20260727T040000Z-m3u-import' + ); + assert.deepEqual( + resolveM3uImportOutputLayout(workspaceRoot, outputRoot, 'baseline'), + { + outputRoot, + variantDirectory: resolve(outputRoot, 'baseline'), + } + ); + + for (const requestedRoot of [ + 'dist/performance/run', + resolve(workspaceRoot, 'dist/performance'), + resolve(workspaceRoot, 'dist/performance-sibling/run'), + resolve(workspaceRoot, '../outside'), + ]) { + assert.throws( + () => + resolveM3uImportOutputLayout( + workspaceRoot, + requestedRoot, + 'baseline' + ), + /absolute path below dist\/performance/ + ); + } + assert.throws( + () => + resolveM3uImportOutputLayout( + workspaceRoot, + outputRoot, + '../escape' + ), + /variant is invalid/i + ); + }); + + it('requires a clean source tree for formal runs but permits dirty smoke evidence', () => { + const dirtyState = { + commit: 'a'.repeat(40), + diffSha256: 'b'.repeat(64), + dirty: true, + }; + + assert.throws( + () => assertM3uImportSourceState(false, dirtyState), + /Formal performance runs require a clean Git worktree/ + ); + assert.doesNotThrow(() => assertM3uImportSourceState(true, dirtyState)); + assert.doesNotThrow(() => + assertM3uImportSourceState(false, { + ...dirtyState, + dirty: false, + }) + ); + }); + + it('rejects an existing output ancestor symlink before artifact creation', async () => { + const fixtureRoot = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-output-path-') + ); + const repositoryRoot = join(fixtureRoot, 'repository'); + const performanceRoot = join( + repositoryRoot, + 'dist', + 'performance' + ); + const outside = join(fixtureRoot, 'outside'); + await mkdir(performanceRoot, { recursive: true }); + await mkdir(outside); + await symlink(outside, join(performanceRoot, 'redirect')); + + try { + await assert.rejects( + assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot, + join(performanceRoot, 'redirect', 'run') + ), + /Performance output path contains a symbolic link/ + ); + await assert.doesNotReject( + assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot, + join(performanceRoot, 'ordinary', 'run') + ) + ); + } finally { + await rm(fixtureRoot, { force: true, recursive: true }); + } + }); + + it('keeps formal CDP capture on 9222 while allowing an isolated smoke port', () => { + assert.equal( + resolveM3uImportRendererCdpPort(false, undefined), + M3U_IMPORT_RENDERER_CDP_PORT + ); + assert.equal( + resolveM3uImportRendererCdpPort(false, '9222'), + M3U_IMPORT_RENDERER_CDP_PORT + ); + assert.equal(resolveM3uImportRendererCdpPort(true, '9322'), 9322); + + assert.throws( + () => resolveM3uImportRendererCdpPort(false, '9322'), + /Formal performance runs require CDP port 9222/ + ); + for (const invalid of ['0', '1023', '65536', '9222.5', 'not-a-port']) { + assert.throws( + () => resolveM3uImportRendererCdpPort(true, invalid), + /IPTVNATOR_PERF_CDP_PORT is invalid/ + ); + } + }); + + it('fails closed unless one and only one app GET occurs', () => { + assert.doesNotThrow(() => assertM3uImportRequestDelta(3, 4)); + assert.throws( + () => assertM3uImportRequestDelta(3, 3), + /exactly one application GET/ + ); + assert.throws( + () => assertM3uImportRequestDelta(3, 5), + /exactly one application GET/ + ); + }); + + it('waits for the exact upsert plus route-reload GET sequence', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /status\.databaseRequests\s*===\s*2/); + assert.match(source, /status\.databaseUpsertsCompleted\s*===\s*1/); + assert.match(source, /status\.databaseGetsCompleted\s*===\s*1/); + assert.match(source, /status\.databasePending\s*===\s*0/); + assert.match(source, /status\.preloadSuccessMarkers\s*===\s*2/); + }); + + it('always disposes renderer capture before closing Electron on iteration failure', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + const runIteration = source.indexOf('async function runIteration'); + const finallyBlock = source.indexOf('} finally {', runIteration); + const rendererDispose = source.indexOf( + 'await renderer.dispose()', + finallyBlock + ); + const appClose = source.indexOf('await closeElectronApp(app)', finallyBlock); + + assert.ok(finallyBlock > runIteration); + assert.ok(rendererDispose > finallyBlock); + assert.ok(appClose > rendererDispose); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts new file mode 100644 index 000000000..c153a3581 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts @@ -0,0 +1,300 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer as createTcpServer } from 'node:net'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { expect, type Page } from '@playwright/test'; + +import { + type LaunchedElectronApp, + workspaceRoot, +} from '../electron-test-fixtures'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; +import type { RendererWindowIdentity } from './renderer-window-rss-session'; + +const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +export const M3U_IMPORT_RENDERER_CDP_PORT = 9222; + +export interface M3uImportGitSourceState { + readonly commit: string; + readonly diffSha256: string; + readonly dirty: boolean; +} + +export interface M3uImportOutputLayout { + readonly outputRoot: string; + readonly variantDirectory: string; +} + +export interface M3uImportBenchmarkConfiguration extends M3uImportOutputLayout { + readonly electronVersion: string; + readonly measuredRuns: number; + readonly rendererCdpPort: number; + readonly smoke: boolean; + readonly sourceState: M3uImportGitSourceState; + readonly variant: string; +} + +export async function resolveM3uImportBenchmarkConfiguration(): Promise { + const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR']; + if (!requestedRoot) { + throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required'); + } + const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline'; + const layout = resolveM3uImportOutputLayout( + workspaceRoot, + requestedRoot, + variant + ); + const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1'; + const rendererCdpPort = resolveM3uImportRendererCdpPort( + smoke, + process.env['IPTVNATOR_PERF_CDP_PORT'] + ); + const sourceState = readGitSourceState(workspaceRoot); + assertM3uImportSourceState(smoke, sourceState); + const electronPackage = JSON.parse( + await readFile( + join(workspaceRoot, 'node_modules', 'electron', 'package.json'), + 'utf8' + ) + ) as { version?: unknown }; + if (typeof electronPackage.version !== 'string') { + throw new Error('Unable to resolve the Electron runtime version'); + } + await assertM3uImportOutputPathHasNoSymlinks( + workspaceRoot, + layout.outputRoot + ); + await assertMissing(layout.variantDirectory); + await assertPerformanceArtifactCapacity(layout.variantDirectory); + await mkdir(layout.variantDirectory, { recursive: true }); + return Object.freeze({ + ...layout, + electronVersion: electronPackage.version, + measuredRuns: smoke ? 1 : 5, + rendererCdpPort, + smoke, + sourceState, + variant, + }); +} + +export function resolveM3uImportOutputLayout( + repositoryRoot: string, + requestedRoot: string, + variant: string +): M3uImportOutputLayout { + const performanceRoot = resolve(repositoryRoot, 'dist', 'performance'); + const outputRoot = resolve(requestedRoot); + if ( + !isAbsolute(requestedRoot) || + !isStrictDescendant(performanceRoot, outputRoot) + ) { + throw new Error( + 'Performance output must be an absolute path below dist/performance' + ); + } + if (!OUTPUT_VARIANT_PATTERN.test(variant)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + const variantDirectory = resolve(outputRoot, variant); + if (!isStrictDescendant(outputRoot, variantDirectory)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + return Object.freeze({ outputRoot, variantDirectory }); +} + +export async function assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot: string, + outputRoot: string +): Promise { + const root = resolve(repositoryRoot); + const output = resolve(outputRoot); + if (!isStrictDescendant(root, output)) { + throw new Error('Performance output path escapes the repository'); + } + const components = relative(root, output).split(sep); + let current = root; + + for (const component of ['', ...components]) { + current = component === '' ? current : join(current, component); + try { + const stats = await lstat(current); + if (stats.isSymbolicLink()) { + throw new Error( + `Performance output path contains a symbolic link: ${current}` + ); + } + } catch (error) { + if (isMissingPath(error)) { + return; + } + throw error; + } + } +} + +export function assertM3uImportSourceState( + smoke: boolean, + state: M3uImportGitSourceState +): void { + if (!smoke && state.dirty) { + throw new Error( + 'Formal performance runs require a clean Git worktree; commit or stash changes first' + ); + } +} + +export function resolveM3uImportRendererCdpPort( + smoke: boolean, + requestedPort: string | undefined +): number { + const port = + requestedPort === undefined + ? M3U_IMPORT_RENDERER_CDP_PORT + : Number(requestedPort); + if ( + (requestedPort !== undefined && !/^[1-9]\d*$/.test(requestedPort)) || + !Number.isSafeInteger(port) || + port < 1024 || + port > 65_535 + ) { + throw new Error('IPTVNATOR_PERF_CDP_PORT is invalid'); + } + if (!smoke && port !== M3U_IMPORT_RENDERER_CDP_PORT) { + throw new Error( + `Formal performance runs require CDP port ${M3U_IMPORT_RENDERER_CDP_PORT}` + ); + } + return port; +} + +export async function resolveRendererWindowIdentity( + app: LaunchedElectronApp +): Promise { + const handle = await app.electronApp.browserWindow(app.mainWindow); + try { + return await handle.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 handle.dispose(); + } +} + +export async function assertTcpPortAvailable(port: number): Promise { + const server = createTcpServer(); + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen( + { exclusive: true, host: '127.0.0.1', port }, + resolvePromise + ); + }); + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => + error ? rejectPromise(error) : resolvePromise() + ); + }); +} + +export async function assertRendererCdpTarget( + page: Page, + port: number +): Promise { + await expect + .poll( + async () => { + try { + const response = await fetch( + `http://127.0.0.1:${port}/json/list` + ); + const targets = response.ok + ? ((await response.json()) as unknown) + : null; + return ( + Array.isArray(targets) && + targets.some( + (target) => + typeof target === 'object' && + target !== null && + (target as Record)['type'] === + 'page' && + (target as Record)['url'] === + page.url() + ) + ); + } catch { + return false; + } + }, + { timeout: 10_000 } + ) + .toBe(true); +} + +export async function writeM3uImportJson( + path: string, + value: unknown +): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function readGitSourceState(repositoryRoot: string): M3uImportGitSourceState { + const git = (args: readonly string[]): string => + execFileSync('git', [...args], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + const commit = git(['rev-parse', 'HEAD']).trim(); + const status = git(['status', '--porcelain=v1', '--untracked-files=all']); + const diffSha256 = createHash('sha256') + .update(status) + .update('\0') + .update(git(['diff', '--binary', 'HEAD'])) + .update('\0') + .update(git(['diff', '--binary', '--cached', 'HEAD'])) + .digest('hex'); + return Object.freeze({ + commit, + diffSha256, + dirty: status.trim().length > 0, + }); +} + +function isStrictDescendant(parent: string, candidate: string): boolean { + const pathFromParent = relative(resolve(parent), resolve(candidate)); + return ( + pathFromParent.length > 0 && + pathFromParent !== '..' && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent) + ); +} + +async function assertMissing(path: string): Promise { + try { + await access(path); + } catch { + return; + } + throw new Error(`Performance output already exists: ${path}`); +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts b/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts new file mode 100644 index 000000000..29e17edb6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts @@ -0,0 +1,250 @@ +import type { + MainTimelineRecord, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +type InitialImportDatabaseOperation = + | 'DB_GET_APP_PLAYLIST' + | 'DB_UPSERT_APP_PLAYLIST'; + +export interface M3uImportIpcAttribution { + readonly databaseWorkerToMainCloneProxyMs: number; + readonly ipcStructuredCloneProxyMs: number; + readonly mainToDatabaseWorkerCloneProxyMs: number; + readonly mainToRendererCloneProxyMs: number; + readonly rendererToMainCloneProxyMs: number; +} + +interface RequestBoundaryAttribution { + readonly mainToDatabaseWorkerMs: number; + readonly mainToRendererMs: number; + readonly rendererToMainMs: number; + readonly successEpochMs: number; + readonly workerToMainMs: number; +} + +export function deriveM3uImportIpcAttribution(input: { + readonly get: WorkerRequestPerformanceMetrics; + readonly getReadStartedEpochMs: number; + readonly importDispatchEndedEpochMs: number; + readonly importDispatchStartedEpochMs: number; + readonly normalizeEndedEpochMs: number; + readonly playlistId: string; + readonly publishChannelsStartedEpochMs: number; + readonly serializeStartedEpochMs: number; + readonly timeline: readonly MainTimelineRecord[]; + readonly upsert: WorkerRequestPerformanceMetrics; +}): M3uImportIpcAttribution { + requireOrdered( + input.normalizeEndedEpochMs, + input.importDispatchStartedEpochMs + ); + requireOrdered( + input.importDispatchStartedEpochMs, + input.importDispatchEndedEpochMs + ); + + const upsert = deriveRequestBoundaryAttribution({ + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: input.playlistId, + request: input.upsert, + timeline: input.timeline, + workPhaseStartedEpochMs: input.serializeStartedEpochMs, + }); + requireOrdered(input.importDispatchEndedEpochMs, requireSource(input.upsert)); + + const get = deriveRequestBoundaryAttribution({ + operation: 'DB_GET_APP_PLAYLIST', + playlistId: input.playlistId, + request: input.get, + timeline: input.timeline, + workPhaseStartedEpochMs: input.getReadStartedEpochMs, + }); + requireOrdered(upsert.successEpochMs, requireSource(input.get)); + requireOrdered( + get.successEpochMs, + input.publishChannelsStartedEpochMs + ); + + const initialMainToRendererMs = difference( + input.importDispatchStartedEpochMs, + input.normalizeEndedEpochMs + ); + const mainToRendererCloneProxyMs = + initialMainToRendererMs + + upsert.mainToRendererMs + + get.mainToRendererMs; + const rendererToMainCloneProxyMs = + upsert.rendererToMainMs + get.rendererToMainMs; + const mainToDatabaseWorkerCloneProxyMs = + upsert.mainToDatabaseWorkerMs + get.mainToDatabaseWorkerMs; + const databaseWorkerToMainCloneProxyMs = + upsert.workerToMainMs + get.workerToMainMs; + + return Object.freeze({ + databaseWorkerToMainCloneProxyMs, + ipcStructuredCloneProxyMs: + mainToRendererCloneProxyMs + + rendererToMainCloneProxyMs + + mainToDatabaseWorkerCloneProxyMs + + databaseWorkerToMainCloneProxyMs, + mainToDatabaseWorkerCloneProxyMs, + mainToRendererCloneProxyMs, + rendererToMainCloneProxyMs, + }); +} + +function deriveRequestBoundaryAttribution(input: { + readonly operation: InitialImportDatabaseOperation; + readonly playlistId: string; + readonly request: WorkerRequestPerformanceMetrics; + readonly timeline: readonly MainTimelineRecord[]; + readonly workPhaseStartedEpochMs: number; +}): RequestBoundaryAttribution { + requireInitialImportPreloadIdentity(input.request, input.operation); + const requestTimeline = requireTimelineRecord( + input.timeline, + 'db-request', + input.request, + input.operation, + input.playlistId + ); + requireTimelineRecord( + input.timeline, + 'db-response', + input.request, + input.operation, + input.playlistId + ); + const successTimeline = requireTimelineRecord( + input.timeline, + 'preload-performance-success', + input.request, + input.operation, + input.playlistId + ); + const sourceEpochMs = requireSource(input.request); + const requestReceivedEpochMs = requireTimestamp( + input.request.requestReceivedEpochMs + ); + const workStartedEpochMs = requireTimestamp( + input.request.workStartedEpochMs + ); + const workEndedEpochMs = requireTimestamp(input.request.workEndedEpochMs); + const responsePostedEpochMs = requireTimestamp( + input.request.responsePostedEpochMs + ); + const responseEpochMs = requireTimestamp(input.request.responseEpochMs); + const successEpochMs = requireTimestamp(successTimeline.sourceEpochMs); + + requireOrdered(sourceEpochMs, requestTimeline.epochMs); + requireOrdered(requestTimeline.epochMs, requestReceivedEpochMs); + requireOrdered(requestReceivedEpochMs, workStartedEpochMs); + requireOrdered(workStartedEpochMs, input.workPhaseStartedEpochMs); + requireOrdered(input.workPhaseStartedEpochMs, workEndedEpochMs); + requireOrdered(workEndedEpochMs, responsePostedEpochMs); + requireOrdered(responsePostedEpochMs, responseEpochMs); + requireOrdered(responseEpochMs, successEpochMs); + + return { + mainToDatabaseWorkerMs: difference( + requestReceivedEpochMs, + requestTimeline.epochMs + ), + mainToRendererMs: difference(successEpochMs, responseEpochMs), + rendererToMainMs: difference(requestTimeline.epochMs, sourceEpochMs), + successEpochMs, + workerToMainMs: difference(responseEpochMs, responsePostedEpochMs), + }; +} + +function requireTimelineRecord( + timeline: readonly MainTimelineRecord[], + type: 'db-request' | 'db-response' | 'preload-performance-success', + request: WorkerRequestPerformanceMetrics, + operation: InitialImportDatabaseOperation, + playlistId: string +): MainTimelineRecord { + if (!request.requestId || !Number.isSafeInteger(request.ipcCallId)) { + throw new Error('database-request-identity-missing'); + } + const matches = timeline.filter((record) => { + if ( + record.type !== type || + record.operation !== operation || + record.playlistId !== playlistId + ) { + return false; + } + if (type === 'preload-performance-success') { + return ( + record.ipcCallId === request.ipcCallId && + record.sourceEpochMs !== undefined && + record.success === true + ); + } + return ( + record.requestId === request.requestId && + (type !== 'db-response' || record.success === true) + ); + }); + if (matches.length !== 1 || !matches[0]) { + throw new Error(`database-${type}-timeline-correlation`); + } + const record = matches[0]; + if ( + type === 'db-request' && + (record.ipcCallId !== request.ipcCallId || + record.sourceEpochMs !== request.sourceEpochMs) + ) { + throw new Error('database-request-timeline-identity'); + } + return record; +} + +function requireInitialImportPreloadIdentity( + request: WorkerRequestPerformanceMetrics, + operation: InitialImportDatabaseOperation +): void { + if ( + request.operation !== operation || + !request.success || + request.performanceCaptureUnavailableReason !== null || + !Number.isSafeInteger(request.ipcCallId) || + Number(request.ipcCallId) < 1 || + typeof request.sourceEpochMs !== 'number' || + !Number.isFinite(request.sourceEpochMs) || + request.sourceEpochMs < 0 || + request.operationId !== null || + request.operationIdUnavailableReason !== + 'preload-performance-marker-uncorrelated' + ) { + throw new Error('database-request-preload-identity'); + } +} + +function requireSource(request: WorkerRequestPerformanceMetrics): number { + return requireTimestamp(request.sourceEpochMs); +} + +function requireTimestamp(value: number | null | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error('database-request-timestamp-missing'); + } + return value; +} + +function requireOrdered(startEpochMs: number, endEpochMs: number): void { + if ( + !Number.isFinite(startEpochMs) || + !Number.isFinite(endEpochMs) || + endEpochMs < startEpochMs + ) { + throw new Error('phase-order'); + } +} + +function difference(endEpochMs: number, startEpochMs: number): number { + requireOrdered(startEpochMs, endEpochMs); + return endEpochMs - startEpochMs; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts new file mode 100644 index 000000000..fa8f198cb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts @@ -0,0 +1,181 @@ +import { once } from 'node:events'; +import { createWriteStream } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { CDPSession } from '@playwright/test'; + +interface TraceData { + readonly value: readonly unknown[]; +} + +export interface RendererArtifactCapture { + beginTraceOutput(): void; + readonly cpuProfilePath: string | null; + finishTraceOutput(): Promise; + readonly heapSnapshotPath: string | null; + readonly onTraceComplete: () => void; + readonly onTraceData: (data: TraceData) => void; + readonly traceComplete: Promise; + readonly tracePath: string | null; +} + +export function createRendererArtifactCapture(options: { + readonly diagnostic: boolean; + readonly outputDirectory: string; +}): RendererArtifactCapture { + const tracePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.trace.json') + : null; + const traceOutput = tracePath ? createWriteStream(tracePath) : null; + let traceOutputOpened = false; + let traceOutputFinish: Promise | null = null; + let traceFirstEvent = true; + let completeTrace: (() => void) | null = null; + const traceComplete = new Promise((resolve) => { + completeTrace = resolve; + }); + return { + beginTraceOutput: () => { + if ( + traceOutput && + !traceOutputOpened && + traceOutputFinish === null + ) { + traceOutput.write('{"traceEvents":['); + traceOutputOpened = true; + } + }, + cpuProfilePath: options.diagnostic + ? join(options.outputDirectory, 'renderer.cpuprofile') + : null, + finishTraceOutput: () => { + traceOutputFinish ??= (async () => { + if (!traceOutput) { + return; + } + if (!traceOutputOpened) { + traceOutput.write('{"traceEvents":['); + traceOutputOpened = true; + } + if (!traceOutput.writableEnded) { + traceOutput.write(']}'); + traceOutput.end(); + } + if (!traceOutput.writableFinished) { + await once(traceOutput, 'finish'); + } + })(); + return traceOutputFinish; + }, + heapSnapshotPath: options.diagnostic + ? join(options.outputDirectory, 'renderer.heapsnapshot') + : null, + onTraceComplete: () => { + completeTrace?.(); + completeTrace = null; + }, + onTraceData: (data) => { + if (!traceOutputOpened || traceOutput?.writableEnded) { + return; + } + for (const event of data.value) { + traceOutput?.write( + `${traceFirstEvent ? '' : ','}${JSON.stringify(event)}` + ); + traceFirstEvent = false; + } + }, + traceComplete, + tracePath, + }; +} + +export async function startRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + await session.send('Profiler.enable'); + await session.send('Profiler.start'); + artifacts.beginTraceOutput(); + session.on('Tracing.dataCollected', artifacts.onTraceData); + session.on('Tracing.tracingComplete', artifacts.onTraceComplete); + await session.send('Tracing.start', { + categories: [ + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-v8.cpu_profiler', + 'v8', + ].join(','), + options: 'sampling-frequency=1000', + transferMode: 'ReportEvents', + }); +} + +export async function stopRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + try { + const cpuProfile = await session.send('Profiler.stop'); + await writeFile( + requirePath(artifacts.cpuProfilePath, 'renderer CPU profile'), + JSON.stringify(cpuProfile['profile']), + 'utf8' + ); + await session.send('Tracing.end'); + await artifacts.traceComplete; + } finally { + disposeRendererDiagnosticListeners(session, artifacts); + await artifacts.finishTraceOutput(); + } +} + +export async function disposeRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + disposeRendererDiagnosticListeners(session, artifacts); + await artifacts.finishTraceOutput(); +} + +export async function takeRendererHeapSnapshot( + session: CDPSession, + path: string | null +): Promise { + const output = createWriteStream( + requirePath(path, 'renderer heap snapshot') + ); + const onChunk = ({ chunk }: { readonly chunk: string }): void => { + output.write(chunk); + }; + session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + try { + await session.send('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + }); + } finally { + session.off('HeapProfiler.addHeapSnapshotChunk', onChunk); + output.end(); + if (!output.writableFinished) { + await once(output, 'finish'); + } + } +} + +function disposeRendererDiagnosticListeners( + session: CDPSession, + artifacts: RendererArtifactCapture +): void { + session.off('Tracing.dataCollected', artifacts.onTraceData); + session.off('Tracing.tracingComplete', artifacts.onTraceComplete); + artifacts.onTraceComplete(); +} + +function requirePath(value: string | null, label: string): string { + if (value === null) { + throw new Error(`${label} path is unavailable`); + } + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts new file mode 100644 index 000000000..d2121babd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts @@ -0,0 +1,313 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based lifecycle test. */ +import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { startM3uImportRendererCapture } from './m3u-import-renderer-capture'; +import { M3U_IMPORT_RENDERER_STATE_KEY } from './m3u-import-renderer-probe'; + +test('idempotently disposes failed-iteration timers, probe, trace stream, listeners, and CDP session', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-dispose-') + ); + const session = new FakeCdpSession(); + const control = { + click: async () => undefined, + fill: async () => undefined, + first: () => control, + getByRole: () => control, + isDisabled: async () => false, + last: () => control, + waitFor: async () => undefined, + }; + const page = { + ...browser.page, + context: () => ({ + newCDPSession: async () => session, + }), + getByRole: () => control, + locator: () => control, + }; + + try { + const capture = await startM3uImportRendererCapture( + page as never, + { + diagnostic: true, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + } + ); + assert.equal(session.listenerCount('Tracing.dataCollected'), 1); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 1); + + await capture.dispose(); + await capture.dispose(); + + assert.equal(session.detachCount, 1); + assert.equal(session.listenerCount('Tracing.dataCollected'), 0); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 0); + assert.equal(browser.readState(), undefined); + const heapSamplesAfterDispose = session.heapUsageCalls; + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(session.heapUsageCalls, heapSamplesAfterDispose); + assert.deepEqual( + JSON.parse( + await readFile( + join(outputDirectory, 'renderer.trace.json'), + 'utf8' + ) + ), + { traceEvents: [] } + ); + } finally { + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +test('failed-iteration disposal does not await a wedged renderer or CDP detach', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-wedged-') + ); + const session = new FakeCdpSession({ hangDetach: true }); + let evaluateCalls = 0; + const page = createFakePage(browser, session, async (callback, argument) => { + evaluateCalls += 1; + if (evaluateCalls > 1) { + return new Promise(() => undefined); + } + return browser.page.evaluate(callback, argument); + }); + + try { + const capture = await startM3uImportRendererCapture(page as never, { + diagnostic: false, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + }); + const outcome = await Promise.race([ + capture.dispose().then(() => 'disposed'), + new Promise<'timeout'>((resolve) => + setTimeout(() => resolve('timeout'), 150) + ), + ]); + + assert.equal(outcome, 'disposed'); + assert.equal(session.detachCount, 1); + const heapSamplesAfterDispose = session.heapUsageCalls; + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(session.heapUsageCalls, heapSamplesAfterDispose); + } finally { + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +test('rolls back partially started capture when Chromium tracing rejects', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-startup-') + ); + const session = new FakeCdpSession({ failMethod: 'Tracing.start' }); + const intervalToken = Object.freeze({}); + const setIntervalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'setInterval' + ); + const clearIntervalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'clearInterval' + ); + let clearIntervalCalls = 0; + Object.defineProperty(globalThis, 'setInterval', { + configurable: true, + value: () => intervalToken, + writable: true, + }); + Object.defineProperty(globalThis, 'clearInterval', { + configurable: true, + value: (token: unknown) => { + if (token === intervalToken) { + clearIntervalCalls += 1; + } + }, + writable: true, + }); + + try { + await assert.rejects( + startM3uImportRendererCapture( + createFakePage(browser, session) as never, + { + diagnostic: true, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + } + ), + /synthetic Tracing\.start failure/ + ); + + assert.equal(clearIntervalCalls, 1); + assert.equal(session.detachCount, 1); + assert.equal(session.listenerCount('Tracing.dataCollected'), 0); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 0); + assert.equal(browser.readState(), undefined); + assert.deepEqual( + JSON.parse( + await readFile( + join(outputDirectory, 'renderer.trace.json'), + 'utf8' + ) + ), + { traceEvents: [] } + ); + } finally { + restoreGlobal('setInterval', setIntervalDescriptor); + restoreGlobal('clearInterval', clearIntervalDescriptor); + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +class FakeCdpSession extends EventEmitter { + detachCount = 0; + heapUsageCalls = 0; + + constructor( + private readonly options: { + readonly failMethod?: string; + readonly hangDetach?: boolean; + } = {} + ) { + super(); + } + + async detach(): Promise { + this.detachCount += 1; + if (this.options.hangDetach) { + return new Promise(() => undefined); + } + } + + async send(method: string): Promise> { + if (method === this.options.failMethod) { + throw new Error(`synthetic ${method} failure`); + } + if (method === 'Runtime.getHeapUsage') { + this.heapUsageCalls += 1; + return { usedSize: 1_024 }; + } + return {}; + } +} + +function installFakeProbeEnvironment() { + const descriptors = new Map(); + let nextFrameId = 1; + const replaceGlobal = (key: string, value: unknown): void => { + descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); + }; + class FakePerformanceObserver { + static readonly supportedEntryTypes = ['longtask']; + + disconnect(): undefined { + return undefined; + } + observe(): undefined { + return undefined; + } + takeRecords(): PerformanceEntry[] { + return []; + } + } + replaceGlobal('performance', { now: () => 0, timeOrigin: 0 }); + replaceGlobal('PerformanceObserver', FakePerformanceObserver); + replaceGlobal('cancelAnimationFrame', () => undefined); + replaceGlobal('requestAnimationFrame', () => nextFrameId++); + replaceGlobal('location', { pathname: '/' }); + replaceGlobal('document', { querySelector: () => null }); + + return { + cleanup: () => { + for (const [key, descriptor] of descriptors) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + Reflect.deleteProperty( + globalThis, + Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY) + ); + Reflect.deleteProperty( + globalThis, + M3U_IMPORT_RENDERER_STATE_KEY + ); + }, + page: { + evaluate: async ( + callback: (argument: never) => unknown, + argument: never + ) => callback(argument), + }, + readState: () => + (globalThis as Record)[ + M3U_IMPORT_RENDERER_STATE_KEY + ], + }; +} + +function createFakePage( + browser: ReturnType, + session: FakeCdpSession, + evaluate = browser.page.evaluate +) { + const control = { + click: async () => undefined, + fill: async () => undefined, + first: () => control, + getByRole: () => control, + isDisabled: async () => false, + last: () => control, + waitFor: async () => undefined, + }; + return { + ...browser.page, + context: () => ({ + newCDPSession: async () => session, + }), + evaluate, + getByRole: () => control, + locator: () => control, + }; +} + +function restoreGlobal( + key: 'clearInterval' | 'setInterval', + descriptor: PropertyDescriptor | undefined +): void { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts new file mode 100644 index 000000000..a409caf69 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts @@ -0,0 +1,389 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface ImportProbe { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: readonly number[]; + heartbeatDelaysMs: readonly number[]; + longTasksMs: readonly number[]; + operationStartEpochMs: number; + performancePhaseEvents: readonly unknown[]; + playlistId: string | null; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintedEpochMs: number | null; +} + +interface RendererCaptureModule { + advanceM3uImportHeartbeatDeadline?: ( + deadlineEpochMs: number, + nowEpochMs: number + ) => number; + assertCompleteM3uImportRendererProbe?: (probe: ImportProbe) => void; + startM3uImportRendererCapture?: unknown; +} + +interface RendererProbeModule { + M3U_IMPORT_RENDERER_STATE_KEY?: string; + installM3uImportRendererProbe?: (page: unknown) => Promise; + recordM3uImportHeartbeat?: ( + page: unknown, + deadlineEpochMs: number + ) => Promise; + stopM3uImportRendererProbe?: (page: unknown) => Promise; +} + +interface FakeProbeState { + frameGapsMs: number[]; + heartbeatDelaysMs: number[]; + operationStartEpochMs: number; + operationStartMonotonicMs: number; + terminalEpochMs: number | null; +} + +const captureUrl = new URL('./m3u-import-renderer-capture.ts', import.meta.url); +const artifactsUrl = new URL( + './m3u-import-renderer-artifacts.ts', + import.meta.url +); +const probeUrl = new URL('./m3u-import-renderer-probe.ts', import.meta.url); +const captureModulePromise = import(captureUrl.href) + .then((module) => module as RendererCaptureModule) + .catch(() => null); +const probeModulePromise = import(probeUrl.href) + .then((module) => module as unknown as RendererProbeModule) + .catch(() => null); + +function installFakeBrowserEnvironment() { + const descriptors = new Map(); + const frames = new Map void>(); + let nextFrameId = 1; + let nowMs = 0; + let observerRecords: PerformanceEntry[] | null = null; + + const replaceGlobal = (key: string, value: unknown): void => { + descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); + }; + class FakePerformanceObserver { + static readonly supportedEntryTypes = ['longtask']; + + constructor() { + observerRecords = []; + } + + disconnect(): undefined { + return undefined; + } + observe(): undefined { + return undefined; + } + takeRecords(): PerformanceEntry[] { + return observerRecords?.splice(0) ?? []; + } + } + replaceGlobal('performance', { now: () => nowMs, timeOrigin: 0 }); + replaceGlobal('PerformanceObserver', FakePerformanceObserver); + replaceGlobal('cancelAnimationFrame', (requestId: number) => { + frames.delete(requestId); + }); + replaceGlobal( + 'requestAnimationFrame', + (callback: (timestamp: number) => void) => { + const requestId = nextFrameId++; + frames.set(requestId, callback); + return requestId; + } + ); + replaceGlobal('location', { pathname: '/' }); + replaceGlobal('document', { querySelector: () => null }); + + return { + cleanup: () => { + for (const [key, descriptor] of descriptors) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + Reflect.deleteProperty( + globalThis, + Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY) + ); + }, + getObserver: () => observerRecords, + page: { + evaluate: async ( + callback: (argument: unknown) => unknown, + argument: unknown + ) => callback(argument), + }, + readState: (stateKey: string) => + (globalThis as Record)[stateKey] as FakeProbeState, + runFrame: (timestamp: number) => { + nowMs = timestamp; + for (const [requestId, callback] of [...frames]) { + if (frames.delete(requestId)) { + callback(timestamp); + } + } + }, + setNow: (value: number) => { + nowMs = value; + }, + showTerminal: () => { + ( + globalThis as unknown as { + location: { pathname: string }; + } + ).location.pathname = + '/Users/test/iptvnator/dist/apps/web/workspace/playlists/synthetic-id/all'; + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { querySelector: () => ({}) }, + writable: true, + }); + }, + }; +} + +function readSource(url: URL): string | null { + try { + return readFileSync(url, 'utf8'); + } catch { + return null; + } +} + +function completeProbe(): ImportProbe { + return { + firstChannelVisibleEpochMs: 130, + frameGapsMs: [16, 48], + heartbeatDelaysMs: [0, 12], + longTasksMs: [72], + operationStartEpochMs: 100, + performancePhaseEvents: [], + playlistId: 'synthetic-id', + routeReadyEpochMs: 120, + terminalEpochMs: 150, + uiPaintedEpochMs: 150, + }; +} + +test('exports the formal M3U import renderer collector', async () => { + const module = await captureModulePromise; + + assert.ok(module, 'M3U import renderer capture module must exist'); + assert.equal(typeof module.startM3uImportRendererCapture, 'function'); + assert.equal( + typeof module.assertCompleteM3uImportRendererProbe, + 'function' + ); +}); + +test('prepares the URL dialog before starting any measured capture', () => { + const source = readSource(captureUrl); + assert.ok(source, 'M3U import renderer capture source must exist'); + + const dialogPreparation = source.indexOf( + 'await prepareM3uImportDialog(page, options)' + ); + const cdpSession = source.indexOf('newCDPSession(page)'); + const initialHeapSample = source.indexOf('await sampleHeap()'); + + assert.ok(dialogPreparation >= 0); + assert.ok(cdpSession > dialogPreparation); + assert.ok(initialHeapSample > cdpSession); +}); + +test('captures heap samples in every run and raw CPU, trace, and heap artifacts only in diagnostics', () => { + const source = readSource(captureUrl); + const artifactSource = readSource(artifactsUrl); + assert.ok(source); + assert.ok(artifactSource); + + assert.match(source, /setInterval\(\(\) => void sampleHeap\(\), 20\)/); + assert.match(source, /Runtime\.getHeapUsage/); + assert.match(source, /HeapProfiler\.collectGarbage/); + assert.match( + artifactSource, + /cpuProfilePath: options\.diagnostic[\s\S]*renderer\.cpuprofile/ + ); + assert.match(artifactSource, /Profiler\.start/); + assert.match(artifactSource, /Tracing\.start/); + assert.match(artifactSource, /HeapProfiler\.takeHeapSnapshot/); +}); + +test('clips drained long tasks and heartbeat samples to the operation window', async () => { + const module = await probeModulePromise; + assert.ok(module); + assert.equal(typeof module.installM3uImportRendererProbe, 'function'); + assert.equal(typeof module.recordM3uImportHeartbeat, 'function'); + assert.equal(typeof module.stopM3uImportRendererProbe, 'function'); + assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string'); + const browser = installFakeBrowserEnvironment(); + + try { + await module.installM3uImportRendererProbe?.(browser.page); + const state = browser.readState( + module.M3U_IMPORT_RENDERER_STATE_KEY ?? '' + ); + state.operationStartEpochMs = 100; + state.operationStartMonotonicMs = 100; + state.terminalEpochMs = 200; + const observerRecords = browser.getObserver(); + assert.ok(observerRecords); + observerRecords.push( + { duration: 40, startTime: 80 } as PerformanceEntry, + { duration: 30, startTime: 190 } as PerformanceEntry, + { duration: 30, startTime: 210 } as PerformanceEntry + ); + + browser.setNow(250); + await module.recordM3uImportHeartbeat?.(browser.page, 90); + await module.recordM3uImportHeartbeat?.(browser.page, 150); + await module.recordM3uImportHeartbeat?.(browser.page, 210); + const metrics = await module.stopM3uImportRendererProbe?.(browser.page); + + assert.deepEqual(metrics?.heartbeatDelaysMs, [50]); + assert.deepEqual(metrics?.longTasksMs, [20, 10]); + } finally { + browser.cleanup(); + } +}); + +test('records the terminal animation-frame gap exactly once', async () => { + const module = await probeModulePromise; + assert.ok(module?.installM3uImportRendererProbe); + assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string'); + const browser = installFakeBrowserEnvironment(); + + try { + await module.installM3uImportRendererProbe(browser.page); + browser.runFrame(80); + const state = browser.readState( + module.M3U_IMPORT_RENDERER_STATE_KEY ?? '' + ); + state.operationStartEpochMs = 100; + state.operationStartMonotonicMs = 100; + browser.showTerminal(); + browser.runFrame(120); + browser.runFrame(140); + browser.runFrame(160); + + assert.equal(state.terminalEpochMs, 160); + assert.deepEqual(state.frameGapsMs, [20, 20, 20]); + await module.stopM3uImportRendererProbe?.(browser.page); + } finally { + browser.cleanup(); + } +}); + +test('advances the external heartbeat on its fixed 50ms deadline grid', async () => { + const module = await captureModulePromise; + assert.ok(module); + const advance = module.advanceM3uImportHeartbeatDeadline; + assert.equal(typeof advance, 'function'); + + assert.equal(advance?.(1_000, 1_005), 1_050); + assert.equal(advance?.(1_000, 1_060), 1_100); + assert.equal(advance?.(1_000, 1_200), 1_250); +}); + +test('installs the production renderer phase hook and returns its raw events', () => { + const source = readSource(probeUrl); + assert.ok(source); + + assert.match(source, /RENDERER_PERFORMANCE_PHASE_HOOK_KEY/); + assert.match(source, /Symbol\.for\(hookKey\)/); + assert.match(source, /performancePhaseEvents\.push\(event\)/); + assert.match( + source, + /performancePhaseEvents: \[\.\.\.state\.performancePhaseEvents\]/ + ); + assert.match(source, /delete target\[hookSymbol\]/); +}); + +test('timestamps the click immediately before final Add and requires route, first item, and two animation frames', () => { + const source = readSource(probeUrl); + assert.ok(source); + + const operationStart = source.indexOf( + 'state.operationStartMonotonicMs = performance.now()' + ); + const click = source.indexOf('button.click()', operationStart); + assert.ok(operationStart >= 0); + assert.ok(click > operationStart); + assert.ok(source.includes('/\\/workspace\\/playlists\\/([^/]+)\\/all$/')); + assert.match(source, /\[data-test-id="channel-item"\]/); + const firstPaintFrame = source.indexOf( + 'state.uiPaintFrameRequestId = requestAnimationFrame' + ); + const secondPaintFrame = source.indexOf( + 'state.uiPaintFrameRequestId = requestAnimationFrame', + firstPaintFrame + 1 + ); + assert.ok(firstPaintFrame >= 0); + assert.ok(secondPaintFrame > firstPaintFrame); + for (const phase of [ + 'operationStartEpochMs', + 'routeReadyEpochMs', + 'firstChannelVisibleEpochMs', + 'uiPaintedEpochMs', + 'terminalEpochMs', + ]) { + assert.match(source, new RegExp(phase)); + } +}); + +test('fails closed when any terminal proof or the probe itself is missing', async () => { + const module = await captureModulePromise; + assert.ok(module); + const assertComplete = module.assertCompleteM3uImportRendererProbe; + assert.equal(typeof assertComplete, 'function'); + + assert.doesNotThrow(() => assertComplete?.(completeProbe())); + for (const field of [ + 'playlistId', + 'routeReadyEpochMs', + 'firstChannelVisibleEpochMs', + 'uiPaintedEpochMs', + 'terminalEpochMs', + ] as const) { + const incomplete = { ...completeProbe(), [field]: null }; + assert.throws( + () => assertComplete?.(incomplete), + /m3u-import-renderer-probe-incomplete/ + ); + } + assert.throws( + () => + assertComplete?.({ + ...completeProbe(), + operationStartEpochMs: 0, + }), + /m3u-import-renderer-probe-incomplete/ + ); +}); + +test('clears the harness heap sampler even when the renderer probe is missing', () => { + const source = readSource(captureUrl); + assert.ok(source); + const backgroundStop = source.indexOf( + 'await stopBackgroundWork()', + source.indexOf('const stopCapture') + ); + const probeStop = source.indexOf('probe = await stopProbe()', backgroundStop); + + assert.match(source, /clearInterval\(heapSampleTimer\)/); + assert.ok(backgroundStop >= 0); + assert.ok(probeStop > backgroundStop); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts new file mode 100644 index 000000000..db5bd80bf --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts @@ -0,0 +1,366 @@ +import type { CDPSession, Page } from '@playwright/test'; + +import type { NumericDistribution } from './m3u-refresh-cancellation-contract'; +import { + createRendererArtifactCapture, + disposeRendererDiagnosticCapture, + type RendererArtifactCapture, + startRendererDiagnosticCapture, + stopRendererDiagnosticCapture, + takeRendererHeapSnapshot, +} from './m3u-import-renderer-artifacts'; +import { + installM3uImportRendererProbe, + type M3uImportRendererProbeMetrics, + prepareM3uImportDialog, + recordM3uImportHeartbeat, + stopM3uImportRendererProbe, + triggerM3uImport, + waitForM3uImportTerminal, +} from './m3u-import-renderer-probe'; +import { summarizeNumbers } from './performance-statistics'; + +const HEARTBEAT_INTERVAL_MS = 50; + +export interface M3uImportRendererCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; + readonly playlistTitle: string; + readonly playlistUrl: string; +} + +export interface M3uImportRendererPhaseTimestamps { + readonly firstChannelVisibleEpochMs: number; + readonly operationStartEpochMs: number; + readonly routeReadyEpochMs: number; + readonly terminalEpochMs: number; + readonly uiPaintedEpochMs: number; +} + +export interface M3uImportRendererCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly frameGap: NumericDistribution; + readonly heapSnapshotPath: string | null; + readonly heartbeatDelay: NumericDistribution; + readonly longTask: NumericDistribution; + readonly peakHeapUsedBytes: number; + readonly phaseTimestamps: M3uImportRendererPhaseTimestamps; + readonly postGcHeapUsedBytes: number; + readonly probe: M3uImportRendererProbeMetrics; + readonly tracePath: string | null; +} + +export interface RunningM3uImportRendererCapture { + readonly session: CDPSession; + dispose(): Promise; + stop(): Promise; + triggerImport(): Promise; + waitForTerminal(): Promise; +} + +interface HeapUsageResult { + readonly usedSize: number; +} + +export async function startM3uImportRendererCapture( + page: Page, + options: M3uImportRendererCaptureStartOptions +): Promise { + await prepareM3uImportDialog(page, options); + const session = await page.context().newCDPSession(page); + let probeInstalled = false; + let heapSampleTimer: NodeJS.Timeout | null = null; + let artifacts: RendererArtifactCapture | null = null; + const heapSamples: number[] = []; + let heapSampleError: unknown = null; + let heapSamplePromise: Promise | null = null; + const sampleHeap = (): Promise => { + heapSamplePromise ??= (async () => { + try { + const sample = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (Number.isFinite(sample.usedSize)) { + heapSamples.push(sample.usedSize); + } + } catch (error) { + heapSampleError ??= error; + } finally { + heapSamplePromise = null; + } + })(); + return heapSamplePromise; + }; + + try { + await session.send('Runtime.enable'); + await session.send('Performance.enable'); + await installM3uImportRendererProbe(page); + probeInstalled = true; + await sampleHeap(); + throwCaptureError(heapSampleError, 'Renderer heap sampling failed'); + heapSampleTimer = setInterval(() => void sampleHeap(), 20); + artifacts = createRendererArtifactCapture(options); + if (options.diagnostic) { + await startRendererDiagnosticCapture(session, artifacts); + } + } catch (error) { + if (heapSampleTimer) { + clearInterval(heapSampleTimer); + heapSampleTimer = null; + } + if (artifacts && options.diagnostic) { + await disposeRendererDiagnosticCapture( + session, + artifacts + ).catch(() => undefined); + } + if (probeInstalled) { + continueBestEffort(() => stopM3uImportRendererProbe(page)); + } + continueBestEffort(() => session.detach()); + throw error; + } + if (!artifacts || !heapSampleTimer) { + throw new Error('M3U import renderer capture initialization failed'); + } + + let heartbeatDeadlineEpochMs: number | null = null; + let heartbeatError: unknown = null; + let heartbeatInFlight: Promise | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; + let stopped = false; + let backgroundStopPromise: Promise | null = null; + let probeStopPromise: Promise | null = null; + let sessionDetachPromise: Promise | null = null; + let abortPromise: Promise | null = null; + let stopPromise: Promise | null = null; + let stopRequested = false; + let disposeRequested = false; + const stopSchedulingBackgroundWork = (): void => { + stopped = true; + if (heartbeatTimer) { + clearTimeout(heartbeatTimer); + heartbeatTimer = null; + } + if (heapSampleTimer) { + clearInterval(heapSampleTimer); + heapSampleTimer = null; + } + }; + const scheduleHeartbeat = (): void => { + if (stopped || heartbeatDeadlineEpochMs === null) { + return; + } + const deadline = heartbeatDeadlineEpochMs; + heartbeatTimer = setTimeout( + () => { + heartbeatTimer = null; + heartbeatInFlight = recordM3uImportHeartbeat(page, deadline) + .catch((error: unknown) => { + heartbeatError ??= error; + }) + .finally(() => { + heartbeatInFlight = null; + if (stopped) { + return; + } + heartbeatDeadlineEpochMs = + advanceM3uImportHeartbeatDeadline( + deadline, + Date.now() + ); + scheduleHeartbeat(); + }); + }, + Math.max(0, deadline - Date.now()) + ); + }; + const stopBackgroundWork = (): Promise => { + backgroundStopPromise ??= (async () => { + stopSchedulingBackgroundWork(); + await heartbeatInFlight; + await heapSamplePromise; + await sampleHeap(); + })(); + return backgroundStopPromise; + }; + const stopProbe = (): Promise => { + probeStopPromise ??= stopM3uImportRendererProbe(page); + return probeStopPromise; + }; + const detachSession = (): Promise => { + sessionDetachPromise ??= session.detach(); + return sessionDetachPromise; + }; + const abortResources = (): Promise => { + abortPromise ??= (async () => { + stopSchedulingBackgroundWork(); + continueBestEffort(() => heartbeatInFlight ?? Promise.resolve()); + continueBestEffort(() => heapSamplePromise ?? Promise.resolve()); + continueBestEffort(stopProbe); + if (options.diagnostic) { + await disposeRendererDiagnosticCapture( + session, + artifacts + ).catch(() => undefined); + } + continueBestEffort(detachSession); + })(); + return abortPromise; + }; + const stopCapture = + async (): Promise => { + try { + await stopBackgroundWork(); + let probe: M3uImportRendererProbeMetrics | null = null; + let probeError: unknown = null; + try { + probe = await stopProbe(); + } catch (error) { + probeError = error; + } + + if (options.diagnostic) { + await stopRendererDiagnosticCapture(session, artifacts); + } + await session.send('HeapProfiler.enable'); + await session.send('HeapProfiler.collectGarbage'); + const postGc = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (options.diagnostic) { + await takeRendererHeapSnapshot( + session, + artifacts.heapSnapshotPath + ); + } + await detachSession(); + throwCaptureError( + heapSampleError, + 'Renderer heap sampling failed' + ); + throwCaptureError(heartbeatError, 'Renderer heartbeat failed'); + throwCaptureError(probeError, 'Renderer probe stop failed'); + assertCompleteM3uImportRendererProbe(probe); + return Object.freeze({ + cpuProfilePath: artifacts.cpuProfilePath, + frameGap: summarizeNumbers(probe.frameGapsMs), + heapSnapshotPath: artifacts.heapSnapshotPath, + heartbeatDelay: summarizeNumbers( + probe.heartbeatDelaysMs + ), + longTask: summarizeNumbers(probe.longTasksMs), + peakHeapUsedBytes: Math.max(0, ...heapSamples), + phaseTimestamps: Object.freeze({ + firstChannelVisibleEpochMs: + probe.firstChannelVisibleEpochMs, + operationStartEpochMs: probe.operationStartEpochMs, + routeReadyEpochMs: probe.routeReadyEpochMs, + terminalEpochMs: probe.terminalEpochMs, + uiPaintedEpochMs: probe.uiPaintedEpochMs, + }), + postGcHeapUsedBytes: postGc.usedSize, + probe, + tracePath: artifacts.tracePath, + }); + } catch (error) { + await abortResources(); + throw error; + } + }; + + return Object.freeze({ + dispose: async () => { + disposeRequested = true; + if (stopPromise) { + continueBestEffort(() => stopPromise as Promise); + } + await abortResources(); + }, + session, + stop: () => { + if (stopRequested || disposeRequested) { + return Promise.reject( + new Error('M3U import renderer capture already stopped') + ); + } + stopRequested = true; + stopPromise = stopCapture(); + return stopPromise; + }, + triggerImport: async () => { + const operationStartEpochMs = await triggerM3uImport(page); + heartbeatDeadlineEpochMs = + operationStartEpochMs + HEARTBEAT_INTERVAL_MS; + scheduleHeartbeat(); + return operationStartEpochMs; + }, + waitForTerminal: () => waitForM3uImportTerminal(page), + }); +} + +export function advanceM3uImportHeartbeatDeadline( + deadlineEpochMs: number, + nowEpochMs: number +): number { + const nextDeadline = deadlineEpochMs + HEARTBEAT_INTERVAL_MS; + if (nextDeadline > nowEpochMs) { + return nextDeadline; + } + const missedIntervals = + Math.floor((nowEpochMs - nextDeadline) / HEARTBEAT_INTERVAL_MS) + 1; + return nextDeadline + missedIntervals * HEARTBEAT_INTERVAL_MS; +} + +export function assertCompleteM3uImportRendererProbe( + probe: M3uImportRendererProbeMetrics | null | undefined +): asserts probe is M3uImportRendererProbeMetrics & { + readonly firstChannelVisibleEpochMs: number; + readonly playlistId: string; + readonly routeReadyEpochMs: number; + readonly terminalEpochMs: number; + readonly uiPaintedEpochMs: number; +} { + if ( + !probe || + typeof probe.playlistId !== 'string' || + probe.playlistId.length === 0 || + !isPositiveFinite(probe.operationStartEpochMs) || + !isPositiveFinite(probe.routeReadyEpochMs) || + !isPositiveFinite(probe.firstChannelVisibleEpochMs) || + !isPositiveFinite(probe.uiPaintedEpochMs) || + !isPositiveFinite(probe.terminalEpochMs) + ) { + throw new Error('m3u-import-renderer-probe-incomplete'); + } + if ( + probe.routeReadyEpochMs < probe.operationStartEpochMs || + probe.firstChannelVisibleEpochMs < probe.routeReadyEpochMs || + probe.uiPaintedEpochMs < probe.firstChannelVisibleEpochMs || + probe.terminalEpochMs < probe.uiPaintedEpochMs + ) { + throw new Error('m3u-import-renderer-probe-incomplete'); + } +} + +function throwCaptureError(error: unknown, label: string): void { + if (error instanceof Error) { + throw error; + } + if (error !== null) { + throw new Error(`${label}: ${String(error)}`); + } +} + +function continueBestEffort(work: () => Promise): void { + try { + void work().catch(() => undefined); + } catch { + // The owning Electron app is closed immediately after abort cleanup. + } +} + +function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts new file mode 100644 index 000000000..599abb88a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts @@ -0,0 +1,378 @@ +import type { Page } from '@playwright/test'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, + type RendererPerformancePhaseHook, +} from '@iptvnator/shared/logging'; + +export const M3U_IMPORT_RENDERER_STATE_KEY = + '__iptvnatorM3uImportRendererCapture'; + +export interface M3uImportDialogOptions { + readonly playlistTitle: string; + readonly playlistUrl: string; +} + +export interface M3uImportRendererProbeMetrics { + readonly firstChannelVisibleEpochMs: number | null; + readonly frameGapsMs: readonly number[]; + readonly heartbeatDelaysMs: readonly number[]; + readonly longTasksMs: readonly number[]; + readonly operationStartEpochMs: number; + readonly performancePhaseEvents: readonly RendererPerformancePhaseEvent[]; + readonly playlistId: string | null; + readonly routeReadyEpochMs: number | null; + readonly terminalEpochMs: number | null; + readonly uiPaintedEpochMs: number | null; +} + +export async function prepareM3uImportDialog( + page: Page, + options: M3uImportDialogOptions +): Promise { + await page.getByRole('button', { name: 'Add playlist' }).first().click(); + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + await dialog + .getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' }) + .fill(options.playlistUrl); + await dialog + .getByRole('textbox', { name: 'Playlist title' }) + .fill(options.playlistTitle); + const submit = dialog.getByRole('button', { name: 'Add playlist' }).last(); + await submit.waitFor({ state: 'visible' }); + if (await submit.isDisabled()) { + throw new Error('m3u-import-final-add-disabled'); + } +} + +export async function installM3uImportRendererProbe(page: Page): Promise { + await page.evaluate( + ({ hookKey, stateKey }) => { + interface ProbeState { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + lastFrameAt: number | null; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + operationStartEpochMs: number; + operationStartMonotonicMs: number; + performancePhaseEvents: RendererPerformancePhaseEvent[]; + playlistId: string | null; + recordFrameGap(timestamp: number): void; + recordLongTask(entry: PerformanceEntry): void; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + } + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const hookSymbol = Symbol.for(hookKey); + if ( + target[stateKey] !== undefined || + target[hookSymbol] !== undefined + ) { + throw new Error('m3u-import-renderer-probe-already-installed'); + } + const epoch = (): number => + performance.timeOrigin + performance.now(); + const state: ProbeState = { + firstChannelVisibleEpochMs: null, + frameGapsMs: [], + frameRequestId: 0, + heartbeatDelaysMs: [], + lastFrameAt: null, + longTaskObserver: null, + longTasksMs: [], + operationStartEpochMs: 0, + operationStartMonotonicMs: 0, + performancePhaseEvents: [], + playlistId: null, + recordFrameGap: (timestamp) => { + const previousFrameAt = state.lastFrameAt; + state.lastFrameAt = timestamp; + if ( + previousFrameAt === null || + state.operationStartEpochMs <= 0 || + state.terminalEpochMs !== null + ) { + return; + } + const measuredFrameStart = Math.max( + previousFrameAt, + state.operationStartMonotonicMs + ); + if (timestamp > measuredFrameStart) { + state.frameGapsMs.push(timestamp - measuredFrameStart); + } + }, + recordLongTask: (entry) => { + const entryEpochMs = + performance.timeOrigin + entry.startTime; + const measuredStartEpochMs = Math.max( + entryEpochMs, + state.operationStartEpochMs + ); + const measuredEndEpochMs = Math.min( + entryEpochMs + entry.duration, + state.terminalEpochMs ?? Number.POSITIVE_INFINITY + ); + if ( + state.operationStartEpochMs > 0 && + measuredEndEpochMs > measuredStartEpochMs + ) { + state.longTasksMs.push( + measuredEndEpochMs - measuredStartEpochMs + ); + } + }, + routeReadyEpochMs: null, + terminalEpochMs: null, + uiPaintFrameRequestId: null, + uiPaintedEpochMs: null, + }; + const performancePhaseHook: RendererPerformancePhaseHook = ( + event + ) => { + state.performancePhaseEvents.push(event); + }; + target[hookSymbol] = performancePhaseHook; + const terminalTarget = (): string | null => { + const route = location.pathname.match( + /\/workspace\/playlists\/([^/]+)\/all$/ + ); + if ( + !route || + !document.querySelector('[data-test-id="channel-item"]') + ) { + return null; + } + return route[1] ?? null; + }; + const inspectTerminal = (): void => { + if ( + state.operationStartEpochMs <= 0 || + state.terminalEpochMs !== null + ) { + return; + } + const route = location.pathname.match( + /\/workspace\/playlists\/([^/]+)\/all$/ + ); + if (!route) { + return; + } + state.routeReadyEpochMs ??= epoch(); + if (!document.querySelector('[data-test-id="channel-item"]')) { + return; + } + state.firstChannelVisibleEpochMs ??= epoch(); + if (state.uiPaintFrameRequestId !== null) { + return; + } + state.uiPaintFrameRequestId = requestAnimationFrame(() => { + state.uiPaintFrameRequestId = requestAnimationFrame( + (terminalFrameAt) => { + state.uiPaintFrameRequestId = null; + const playlistId = terminalTarget(); + if (playlistId === null) { + return; + } + state.recordFrameGap(terminalFrameAt); + state.playlistId = playlistId; + state.uiPaintedEpochMs = epoch(); + state.terminalEpochMs = state.uiPaintedEpochMs; + cancelAnimationFrame(state.frameRequestId); + state.frameRequestId = 0; + for (const entry of state.longTaskObserver?.takeRecords() ?? + []) { + state.recordLongTask(entry); + } + state.longTaskObserver?.disconnect(); + state.longTaskObserver = null; + } + ); + }); + }; + const frame = (timestamp: number): void => { + state.recordFrameGap(timestamp); + if (state.operationStartEpochMs > 0) { + inspectTerminal(); + } + state.frameRequestId = + state.terminalEpochMs === null + ? requestAnimationFrame(frame) + : 0; + }; + state.frameRequestId = requestAnimationFrame(frame); + if ( + typeof PerformanceObserver === 'function' && + PerformanceObserver.supportedEntryTypes.includes('longtask') + ) { + state.longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + state.recordLongTask(entry); + } + }); + state.longTaskObserver.observe({ entryTypes: ['longtask'] }); + } + target[stateKey] = state; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + stateKey: M3U_IMPORT_RENDERER_STATE_KEY, + } + ); +} + +export async function triggerM3uImport(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + | { + operationStartEpochMs: number; + operationStartMonotonicMs: number; + } + | undefined; + if (!state || state.operationStartEpochMs > 0) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + const dialogs = Array.from( + document.querySelectorAll('mat-dialog-container') + ); + const dialog = dialogs.at(-1); + const button = Array.from(dialog?.querySelectorAll('button') ?? []) + .reverse() + .find( + (candidate) => + candidate.textContent?.trim() === 'Add playlist' && + !candidate.disabled + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('m3u-import-final-add-unavailable'); + } + state.operationStartMonotonicMs = performance.now(); + state.operationStartEpochMs = + performance.timeOrigin + state.operationStartMonotonicMs; + button.click(); + return state.operationStartEpochMs; + }, M3U_IMPORT_RENDERER_STATE_KEY); +} + +export async function recordM3uImportHeartbeat( + page: Page, + deadlineEpochMs: number +): Promise { + await page.evaluate( + ({ deadlineEpochMs, stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + | { + heartbeatDelaysMs: number[]; + operationStartEpochMs: number; + terminalEpochMs: number | null; + } + | undefined; + if (!state || state.operationStartEpochMs <= 0) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + if ( + deadlineEpochMs >= state.operationStartEpochMs && + (state.terminalEpochMs === null || + deadlineEpochMs <= state.terminalEpochMs) + ) { + const receivedEpochMs = + performance.timeOrigin + performance.now(); + const measuredEpochMs = + state.terminalEpochMs === null + ? receivedEpochMs + : Math.min(receivedEpochMs, state.terminalEpochMs); + state.heartbeatDelaysMs.push( + Math.max(0, measuredEpochMs - deadlineEpochMs) + ); + } + }, + { deadlineEpochMs, stateKey: M3U_IMPORT_RENDERER_STATE_KEY } + ); +} + +export async function waitForM3uImportTerminal(page: Page): Promise { + await page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + { terminalEpochMs: number | null } | undefined; + return typeof state?.terminalEpochMs === 'number'; + }, + M3U_IMPORT_RENDERER_STATE_KEY, + { timeout: 120_000 } + ) + .then(() => undefined); +} + +export async function stopM3uImportRendererProbe( + page: Page +): Promise { + return page.evaluate( + ({ hookKey, stateKey }) => { + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const hookSymbol = Symbol.for(hookKey); + const state = target[stateKey] as + | { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + operationStartEpochMs: number; + performancePhaseEvents: RendererPerformancePhaseEvent[]; + playlistId: string | null; + recordLongTask(entry: PerformanceEntry): void; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + } + | undefined; + if (!state) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + cancelAnimationFrame(state.frameRequestId); + if (state.uiPaintFrameRequestId !== null) { + cancelAnimationFrame(state.uiPaintFrameRequestId); + } + for (const entry of state.longTaskObserver?.takeRecords() ?? []) { + state.recordLongTask(entry); + } + state.longTaskObserver?.disconnect(); + delete target[stateKey]; + delete target[hookSymbol]; + return { + firstChannelVisibleEpochMs: state.firstChannelVisibleEpochMs, + frameGapsMs: [...state.frameGapsMs], + heartbeatDelaysMs: [...state.heartbeatDelaysMs], + longTasksMs: [...state.longTasksMs], + operationStartEpochMs: state.operationStartEpochMs, + performancePhaseEvents: [...state.performancePhaseEvents], + playlistId: state.playlistId, + routeReadyEpochMs: state.routeReadyEpochMs, + terminalEpochMs: state.terminalEpochMs, + uiPaintedEpochMs: state.uiPaintedEpochMs, + }; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + stateKey: M3U_IMPORT_RENDERER_STATE_KEY, + } + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts new file mode 100644 index 000000000..951030aed --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import { deriveM3uImportPhaseMetrics } from './m3u-import-report'; +import { + createTestMainCapture, + createTestRendererCapture, + TEST_M3U_CHANNEL_COUNT, + TEST_M3U_FIXTURE_BYTES, +} from './m3u-import-report.test-helpers'; + +describe('formal M3U import phase report', () => { + it('attributes every required phase without presenting indexes as zero work', () => { + const phases = deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: createTestMainCapture(), + renderer: createTestRendererCapture(), + }); + + assert.deepEqual(phases, { + angularRenderingMs: 10, + dataAcquireMs: 20, + databaseWorkerToMainCloneProxyMs: 12, + indexAndCommitMs: null, + indexAndCommitUnavailableReason: + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write', + ipcStructuredCloneProxyMs: 44, + mainToDatabaseWorkerCloneProxyMs: 2, + mainToRendererCloneProxyMs: 28, + m3uParsingMs: 40, + normalizationMs: 10, + playlistDeserializationMs: 12, + playlistSerializationMs: 20, + rendererToMainCloneProxyMs: 2, + sqliteReadMs: 5, + sqliteWriteMs: 30, + storeImportDispatchMs: 2, + storePublishChannelsMs: 10, + storeUpdateMs: 12, + totalMs: 215, + }); + assert.equal( + phases.ipcStructuredCloneProxyMs, + phases.mainToRendererCloneProxyMs + + phases.rendererToMainCloneProxyMs + + phases.mainToDatabaseWorkerCloneProxyMs + + phases.databaseWorkerToMainCloneProxyMs + ); + }); + + it('fails closed when any structured-clone boundary is missing, ambiguous, or out of order', () => { + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + const upsert = databaseWorker?.requests[0]; + assert.ok(databaseWorker); + assert.ok(upsert); + + for (const invalidMain of [ + { + ...main, + timeline: main.timeline.filter( + (event) => event.type !== 'db-request' + ), + }, + { + ...main, + timeline: [ + ...main.timeline, + main.timeline.find((event) => event.type === 'db-request'), + ].filter( + (event): event is (typeof main.timeline)[number] => + event !== undefined + ), + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + sourceEpochMs: 1080, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + requestReceivedEpochMs: 1078, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + ipcCallId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + sourceEpochMs: null, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + workStartedEpochMs: 1079, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + ]) { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: invalidMain, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + } + }); + + it('fails closed when a required phase, worker identity, or fixture count is ambiguous', () => { + const missingNormalize = createTestMainCapture(); + const missingTimeline = missingNormalize.timeline.filter( + (event) => event.phase !== 'normalize' + ); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...missingNormalize, + timeline: missingTimeline, + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...createTestMainCapture(), + workers: [ + ...createTestMainCapture().workers, + { + ...createTestMainCapture().workers[0], + ordinal: 2, + }, + ], + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + const wrongBytes = createTestMainCapture(); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES + 1, + main: wrongBytes, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + }); + + it('requires the route-reload GET and both worker-to-renderer return paths', () => { + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + const upsert = databaseWorker?.requests[0]; + const get = databaseWorker?.requests[1]; + assert.ok(databaseWorker); + assert.ok(upsert); + assert.ok(get); + + const invalidCaptures: MainCaptureMetrics[] = [ + { + ...main, + workers: [ + { + ...databaseWorker, + requests: databaseWorker.requests.slice(0, 1), + }, + ], + }, + { + ...main, + timeline: main.timeline.filter( + (event) => + !( + event.type === 'preload-performance-success' && + event.operation === 'DB_GET_APP_PLAYLIST' + ) + ), + }, + { + ...main, + timeline: main.timeline.filter( + (event) => + !( + event.type === 'preload-performance-success' && + event.operation === 'DB_UPSERT_APP_PLAYLIST' + ) + ), + }, + { + ...main, + timeline: main.timeline.map((event) => + event.type === 'preload-performance-success' && + event.operation === 'DB_GET_APP_PLAYLIST' + ? { + ...event, + sourceEpochMs: 1196, + } + : event + ), + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + upsert, + { + ...get, + responsePostedEpochMs: 1175, + }, + ], + }, + ], + }, + ]; + + for (const invalidMain of invalidCaptures) { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: invalidMain, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + } + }); + + it('rejects playlist workers and cross-playlist database writes', () => { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...createTestMainCapture(), + workers: [ + ...createTestMainCapture().workers, + { + kind: 'playlist-refresh.worker', + requests: [], + }, + ], + } as MainCaptureMetrics, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + assert.ok(databaseWorker); + const upsert = databaseWorker.requests[0]; + assert.ok(upsert); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + playlistId: 'other-playlist', + }, + ], + }, + ], + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts new file mode 100644 index 000000000..e00b7e637 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts @@ -0,0 +1,458 @@ +/* eslint-disable max-lines -- The deterministic fixture keeps the full production-shaped import sequence visible. */ +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import type { M3uImportBenchmarkManifest } from './m3u-import-summary'; + +export const TEST_M3U_CHANNEL_COUNT = 10_000; +export const TEST_M3U_FIXTURE_BYTES = 1_024_000; + +export function createTestMainCapture(): MainCaptureMetrics { + return { + timeline: [ + mainPhase('start', 'data.acquire', 1001, null), + mainPhase('end', 'data.acquire', 1021, 20, { + byteCount: TEST_M3U_FIXTURE_BYTES, + }), + mainPhase('start', 'parse.m3u', 1021, null), + mainPhase('end', 'parse.m3u', 1061, 40, { + itemCount: TEST_M3U_CHANNEL_COUNT, + }), + mainPhase('start', 'normalize', 1061, null), + mainPhase('end', 'normalize', 1071, 10, { + itemCount: TEST_M3U_CHANNEL_COUNT, + }), + { + epochMs: 1080, + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-1', + sourceEpochMs: 1079, + type: 'db-request', + }, + { + epochMs: 1137, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-1', + success: true, + type: 'db-response', + }, + { + epochMs: 1140, + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + sourceEpochMs: 1140, + success: true, + type: 'preload-performance-success', + }, + { + epochMs: 1142, + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-2', + sourceEpochMs: 1141, + type: 'db-request', + }, + { + epochMs: 1174, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-2', + success: true, + type: 'db-response', + }, + { + epochMs: 1194, + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + sourceEpochMs: 1194, + success: true, + type: 'preload-performance-success', + }, + ], + workers: [ + { + kind: 'database.worker', + requests: [ + { + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-uncorrelated', + performanceCaptureUnavailableReason: null, + phaseEvents: [ + workerPhase( + 'start', + 'serialize.playlist', + 1082, + null, + 'db-request-1' + ), + workerPhase( + 'end', + 'serialize.playlist', + 1102, + 20, + 'db-request-1', + { + itemCount: TEST_M3U_CHANNEL_COUNT, + } + ), + workerPhase( + 'start', + 'sqlite.write', + 1102, + null, + 'db-request-1' + ), + workerPhase( + 'end', + 'sqlite.write', + 1132, + 30, + 'db-request-1', + { + itemCount: 1, + } + ), + ], + playlistId: 'playlist-1', + requestId: 'db-request-1', + requestReceivedEpochMs: 1081, + responseEpochMs: 1137, + responsePostedEpochMs: 1135, + sourceEpochMs: 1079, + success: true, + workEndedEpochMs: 1133, + workStartedEpochMs: 1082, + }, + { + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-uncorrelated', + performanceCaptureUnavailableReason: null, + phaseEvents: [ + workerPhase( + 'start', + 'sqlite.read', + 1144, + null, + 'db-request-2' + ), + workerPhase( + 'end', + 'sqlite.read', + 1149, + 5, + 'db-request-2', + { + itemCount: 1, + } + ), + workerPhase( + 'start', + 'deserialize.playlist', + 1149, + null, + 'db-request-2' + ), + workerPhase( + 'end', + 'deserialize.playlist', + 1161, + 12, + 'db-request-2', + { + itemCount: TEST_M3U_CHANNEL_COUNT, + } + ), + ], + playlistId: 'playlist-1', + requestId: 'db-request-2', + requestReceivedEpochMs: 1143, + responseEpochMs: 1174, + responsePostedEpochMs: 1164, + sourceEpochMs: 1141, + success: true, + workEndedEpochMs: 1162, + workStartedEpochMs: 1144, + }, + ], + }, + ], + } as unknown as MainCaptureMetrics; +} + +export function createTestRendererCapture(): M3uImportRendererCaptureMetrics { + return { + phaseTimestamps: { + firstChannelVisibleEpochMs: 1210, + operationStartEpochMs: 1000, + routeReadyEpochMs: 1140, + terminalEpochMs: 1215, + uiPaintedEpochMs: 1215, + }, + probe: { + performancePhaseEvents: [ + rendererPhase( + 'start', + 'store.m3u-import-dispatch', + 'renderer-1', + 1076, + 76 + ), + rendererPhase( + 'end', + 'store.m3u-import-dispatch', + 'renderer-1', + 1078, + 78 + ), + rendererPhase( + 'start', + 'store.m3u-publish-channels', + 'renderer-2', + 1195, + 195, + TEST_M3U_CHANNEL_COUNT + ), + rendererPhase( + 'end', + 'store.m3u-publish-channels', + 'renderer-2', + 1205, + 205, + TEST_M3U_CHANNEL_COUNT + ), + ], + playlistId: 'playlist-1', + }, + } as unknown as M3uImportRendererCaptureMetrics; +} + +export function createCompleteTestMainCapture( + overrides: { mainRss?: number; rendererRss?: number } = {} +): MainCaptureMetrics { + const base = createTestMainCapture(); + const databaseWorker = base.workers[0]; + const upsert = databaseWorker?.requests[0]; + const get = databaseWorker?.requests[1]; + if (!databaseWorker || !upsert || !get) { + throw new Error('invalid-test-main-capture'); + } + return { + ...base, + cpuProfilePath: null, + cpuSystemMicros: 20, + cpuUserMicros: 30, + eventLoopDelay: { maxMs: 3, p95Ms: 1, p99Ms: 2 }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'electron-main-embedded-event-loop', + heapSnapshotPath: null, + memory: { + peakHeapUsedBytes: 2_000, + peakRssBytes: overrides.mainRss ?? 5_000, + postGcHeapUsedBytes: 1_000, + postGcRssBytes: 4_500, + }, + rendererWindow: { + responsiveEvents: 0, + rss: { + identity: { creationTime: 100, pid: 42 }, + missingSampleCount: 0, + peakRssBytes: overrides.rendererRss ?? 4_000, + unavailableReason: null, + validSampleCount: 5, + }, + unresponsiveEvents: 0, + windowIdentity: { + browserWindowId: 1, + webContentsId: 2, + }, + }, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + workers: [ + { + ...databaseWorker, + externalMemorySampleCount: 4, + heapUsedSampleCount: 4, + peakExternalBytes: 600, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: 1_500, + peakHeapUsedUnavailableReason: null, + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: 900, + requests: [ + { + ...upsert, + eventLoopDelay: { maxMs: 6, p95Ms: 4, p99Ms: 5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.8, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1134, + invalidReason: null, + threadCpuSystemMicros: 40, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 50, + }, + { + ...get, + eventLoopDelay: { maxMs: 9, p95Ms: 7, p99Ms: 8 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.9, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1163, + invalidReason: null, + threadCpuSystemMicros: 60, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 70, + }, + ], + }, + ], + }; +} + +export function createCompleteTestRendererCapture( + overrides: { rendererHeap?: number } = {} +): M3uImportRendererCaptureMetrics { + const base = createTestRendererCapture(); + return { + ...base, + cpuProfilePath: null, + frameGap: distribution([16, 40]), + heapSnapshotPath: null, + heartbeatDelay: distribution([0, 5]), + longTask: distribution([60]), + peakHeapUsedBytes: overrides.rendererHeap ?? 3_000, + postGcHeapUsedBytes: 1_100, + probe: { + ...base.probe, + firstChannelVisibleEpochMs: 1210, + frameGapsMs: [16, 40], + heartbeatDelaysMs: [0, 5], + longTasksMs: [60], + operationStartEpochMs: 1000, + routeReadyEpochMs: 1140, + terminalEpochMs: 1215, + uiPaintedEpochMs: 1215, + }, + tracePath: null, + }; +} + +export function createTestM3uImportManifest(): M3uImportBenchmarkManifest { + return { + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + fixtureSha256: 'fixture-sha', + gitCommit: 'commit', + gitDiffSha256: 'diff', + gitDirty: false, + measuredRuns: 1, + memoryAccounting: { + databaseWorkerRss: 'unavailable-per-worker-thread', + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + }, + runtime: { + arch: 'arm64', + cdpPort: 9222, + electronVersion: '1', + harnessNodeVersion: '1', + platform: 'darwin', + }, + scenario: 'm3u-import-10k', + syntheticSeed: 240_724, + variant: 'baseline', + warmupRuns: 1, + }; +} + +function mainPhase( + boundary: 'end' | 'start', + phase: string, + epochMs: number, + durationMs: number | null, + metadata: { byteCount?: number; itemCount?: number } = {} +) { + return { + boundary, + durationMs, + epochMs, + ...metadata, + phase, + requestId: 'main-request-1', + type: 'm3u-import-phase', + }; +} + +function workerPhase( + boundary: 'end' | 'start', + phase: + | 'deserialize.playlist' + | 'serialize.playlist' + | 'sqlite.read' + | 'sqlite.write', + epochMs: number, + durationMs: number | null, + requestId: string, + metadata?: { byteCount?: number; itemCount?: number } +) { + return { + boundary, + durationMs, + epochMs, + ...(metadata ? { metadata } : {}), + phase, + requestId, + }; +} + +function rendererPhase( + boundary: 'end' | 'start', + phase: 'store.m3u-import-dispatch' | 'store.m3u-publish-channels', + phaseId: string, + epochMs: number, + monotonicMs: number, + items?: number +) { + return { + boundary, + epochMs, + ...(items === undefined ? {} : { metadata: { items } }), + monotonicMs, + ...(boundary === 'end' ? { outcome: 'success' as const } : {}), + phase, + phaseId, + schemaVersion: 1 as const, + }; +} + +function distribution(values: readonly number[]) { + const min = Math.min(...values); + const max = Math.max(...values); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + return { + count: values.length, + max, + mean, + median: mean, + min, + p95: values.length === 1 ? max : min + (max - min) * 0.95, + p99: values.length === 1 ? max : min + (max - min) * 0.99, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.ts new file mode 100644 index 000000000..9fb2fe9a3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.ts @@ -0,0 +1,367 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE, +} from '@iptvnator/shared/interfaces'; + +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { + MainCaptureMetrics, + MainTimelineRecord, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { deriveM3uImportIpcAttribution } from './m3u-import-ipc-attribution'; +import { + pairPerformancePhaseEvents, + requirePerformancePhase, + type PairedPerformancePhase, +} from './performance-phase-events'; +import { + pairRendererPerformancePhaseEvents, + requireRendererPerformancePhase, +} from './renderer-performance-phase-events'; + +export const M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON = + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write'; + +export interface M3uImportPhaseMetrics { + readonly angularRenderingMs: number; + readonly dataAcquireMs: number; + readonly databaseWorkerToMainCloneProxyMs: number; + readonly indexAndCommitMs: null; + readonly indexAndCommitUnavailableReason: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON; + readonly ipcStructuredCloneProxyMs: number; + readonly mainToDatabaseWorkerCloneProxyMs: number; + readonly mainToRendererCloneProxyMs: number; + readonly m3uParsingMs: number; + readonly normalizationMs: number; + readonly playlistDeserializationMs: number; + readonly playlistSerializationMs: number; + readonly rendererToMainCloneProxyMs: number; + readonly sqliteReadMs: number; + readonly sqliteWriteMs: number; + readonly storeImportDispatchMs: number; + readonly storePublishChannelsMs: number; + readonly storeUpdateMs: number; + readonly totalMs: number; +} + +export interface M3uImportPhaseDerivationInput { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly main: MainCaptureMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; +} + +export function deriveM3uImportPhaseMetrics( + input: M3uImportPhaseDerivationInput +): M3uImportPhaseMetrics { + try { + return deriveM3uImportPhaseMetricsStrict(input); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`m3u-import-phase-attribution-invalid:${detail}`); + } +} + +function deriveM3uImportPhaseMetricsStrict( + input: M3uImportPhaseDerivationInput +): M3uImportPhaseMetrics { + requirePositiveSafeCount(input.channelCount, 'channel-count'); + requirePositiveSafeCount(input.fixtureBytes, 'fixture-bytes'); + + const mainPhases = pairPerformancePhaseEvents( + input.main.timeline + .filter((event) => event.type === 'm3u-import-phase') + .map(toPerformancePhaseEvent) + ); + if (mainPhases.length !== 3) { + throw new Error('main-phase-cardinality'); + } + const mainRequestIds = new Set(mainPhases.map((phase) => phase.requestId)); + if (mainRequestIds.size !== 1) { + throw new Error('main-request-correlation'); + } + const mainRequestId = mainPhases[0]?.requestId; + if (!mainRequestId) { + throw new Error('main-request-missing'); + } + const dataAcquire = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE + ); + const parseM3u = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U + ); + const normalize = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE + ); + requireMetadataCount( + dataAcquire, + 'byteCount', + input.fixtureBytes, + 'data-acquire-bytes' + ); + requireMetadataCount( + parseM3u, + 'itemCount', + input.channelCount, + 'parse-items' + ); + requireMetadataCount( + normalize, + 'itemCount', + input.channelCount, + 'normalize-items' + ); + requireOrdered(dataAcquire.endedEpochMs, parseM3u.startedEpochMs); + requireOrdered(parseM3u.endedEpochMs, normalize.startedEpochMs); + + const rendererEvents = ( + input.renderer.probe as typeof input.renderer.probe & { + readonly performancePhaseEvents?: readonly unknown[]; + } + ).performancePhaseEvents; + if (!rendererEvents) { + throw new Error('renderer-phase-events-missing'); + } + const rendererPhases = pairRendererPerformancePhaseEvents(rendererEvents); + if (rendererPhases.length !== 2) { + throw new Error('renderer-phase-cardinality'); + } + const importDispatch = requireRendererPerformancePhase( + rendererPhases, + 'store.m3u-import-dispatch' + ); + const publishChannels = requireRendererPerformancePhase( + rendererPhases, + 'store.m3u-publish-channels' + ); + requireRendererItems( + publishChannels.metadata?.items, + input.channelCount, + 'publish-channels-items' + ); + + const playlistId = input.renderer.probe.playlistId; + if (typeof playlistId !== 'string' || playlistId.length === 0) { + throw new Error('renderer-playlist-id-missing'); + } + const databaseWorkers = input.main.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + if ( + databaseWorkers.length !== 1 || + input.main.workers.some( + (worker) => worker.kind === 'playlist-refresh.worker' + ) + ) { + throw new Error('worker-cardinality'); + } + const databaseWorker = databaseWorkers[0]; + if (!databaseWorker) { + throw new Error('database-worker-missing'); + } + const upserts = databaseWorker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && + request.playlistId === playlistId && + request.success + ); + if (upserts.length !== 1 || !upserts[0]) { + throw new Error('database-upsert-correlation'); + } + const upsert = upserts[0]; + const gets = databaseWorker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && + request.playlistId === playlistId && + request.success + ); + if ( + gets.length !== 1 || + !gets[0] || + databaseWorker.requests.length !== 2 + ) { + throw new Error('database-get-correlation'); + } + const get = gets[0]; + const databasePhases = pairPerformancePhaseEvents(upsert.phaseEvents); + if ( + databasePhases.length !== 2 || + !upsert.requestId || + databasePhases.some((phase) => phase.requestId !== upsert.requestId) + ) { + throw new Error('database-phase-cardinality'); + } + const serializePlaylist = requireDatabasePhase( + databasePhases, + upsert, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST + ); + const sqliteWrite = requireDatabasePhase( + databasePhases, + upsert, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE + ); + requireMetadataCount( + serializePlaylist, + 'itemCount', + input.channelCount, + 'serialization-items' + ); + requireMetadataCount(sqliteWrite, 'itemCount', 1, 'sqlite-row-count'); + const getPhases = pairPerformancePhaseEvents(get.phaseEvents); + if ( + getPhases.length !== 2 || + !get.requestId || + getPhases.some((phase) => phase.requestId !== get.requestId) + ) { + throw new Error('database-get-phase-cardinality'); + } + const sqliteRead = requireDatabasePhase( + getPhases, + get, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ + ); + const deserializePlaylist = requireDatabasePhase( + getPhases, + get, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST + ); + requireMetadataCount(sqliteRead, 'itemCount', 1, 'sqlite-read-row-count'); + requireMetadataCount( + deserializePlaylist, + 'itemCount', + input.channelCount, + 'deserialization-items' + ); + + const operationStart = input.renderer.phaseTimestamps.operationStartEpochMs; + const uiPainted = input.renderer.phaseTimestamps.uiPaintedEpochMs; + const terminal = input.renderer.phaseTimestamps.terminalEpochMs; + requireOrdered(operationStart, dataAcquire.startedEpochMs); + requireOrdered(serializePlaylist.endedEpochMs, sqliteWrite.startedEpochMs); + requireOrdered(sqliteWrite.endedEpochMs, sqliteRead.startedEpochMs); + requireOrdered(sqliteRead.endedEpochMs, deserializePlaylist.startedEpochMs); + requireOrdered( + deserializePlaylist.endedEpochMs, + publishChannels.startedEpochMs + ); + requireOrdered(publishChannels.endedEpochMs, uiPainted); + requireOrdered(uiPainted, terminal); + + const ipc = deriveM3uImportIpcAttribution({ + get, + getReadStartedEpochMs: sqliteRead.startedEpochMs, + importDispatchEndedEpochMs: importDispatch.endedEpochMs, + importDispatchStartedEpochMs: importDispatch.startedEpochMs, + normalizeEndedEpochMs: normalize.endedEpochMs, + playlistId, + publishChannelsStartedEpochMs: publishChannels.startedEpochMs, + serializeStartedEpochMs: serializePlaylist.startedEpochMs, + timeline: input.main.timeline, + upsert, + }); + const angularRenderingMs = difference( + uiPainted, + publishChannels.endedEpochMs + ); + const totalMs = difference(terminal, operationStart); + + return Object.freeze({ + angularRenderingMs, + dataAcquireMs: dataAcquire.durationMs, + indexAndCommitMs: null, + indexAndCommitUnavailableReason: + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + ...ipc, + m3uParsingMs: parseM3u.durationMs, + normalizationMs: normalize.durationMs, + playlistDeserializationMs: deserializePlaylist.durationMs, + playlistSerializationMs: serializePlaylist.durationMs, + sqliteReadMs: sqliteRead.durationMs, + sqliteWriteMs: sqliteWrite.durationMs, + storeImportDispatchMs: importDispatch.durationMs, + storePublishChannelsMs: publishChannels.durationMs, + storeUpdateMs: importDispatch.durationMs + publishChannels.durationMs, + totalMs, + }); +} + +function toPerformancePhaseEvent(event: MainTimelineRecord): unknown { + const metadata = { + ...(event.byteCount === undefined + ? {} + : { byteCount: event.byteCount }), + ...(event.itemCount === undefined + ? {} + : { itemCount: event.itemCount }), + }; + return { + boundary: event.boundary, + durationMs: event.durationMs, + epochMs: event.epochMs, + ...(Object.keys(metadata).length === 0 ? {} : { metadata }), + phase: event.phase, + requestId: event.requestId, + }; +} + +function requireDatabasePhase( + phases: readonly PairedPerformancePhase[], + request: WorkerRequestPerformanceMetrics, + phase: string +): PairedPerformancePhase { + if (!request.requestId) { + throw new Error('database-request-id-missing'); + } + return requirePerformancePhase(phases, request.requestId, phase); +} + +function requireMetadataCount( + phase: PairedPerformancePhase, + key: 'byteCount' | 'itemCount', + expected: number, + label: string +): void { + if (phase.metadata?.[key] !== expected) { + throw new Error(label); + } +} + +function requireRendererItems( + value: number | undefined, + expected: number, + label: string +): void { + if (value !== expected) { + throw new Error(label); + } +} + +function requirePositiveSafeCount(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(label); + } +} + +function requireOrdered(startEpochMs: number, endEpochMs: number): void { + if ( + !Number.isFinite(startEpochMs) || + !Number.isFinite(endEpochMs) || + endEpochMs < startEpochMs + ) { + throw new Error('phase-order'); + } +} + +function difference(endEpochMs: number, startEpochMs: number): number { + requireOrdered(startEpochMs, endEpochMs); + return endEpochMs - startEpochMs; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts new file mode 100644 index 000000000..6f843f8b2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts @@ -0,0 +1,131 @@ +import type { DatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity'; +import type { DatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity'; +import type { M3uImportScenarioId } from './m3u-import-benchmark-contract'; +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import { + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + type M3uImportPhaseMetrics, +} from './m3u-import-report'; +import type { + DatabaseWorkerPostGcValidity, + MainCaptureMetrics, + NumericDistribution, + PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import type { RendererRssValidity } from './renderer-rss-validity'; + +export interface M3uImportIterationResult { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly phases: M3uImportPhaseMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +} + +export interface M3uImportBenchmarkManifest { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly fixtureSha256: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly memoryAccounting: { + readonly databaseWorkerRss: 'unavailable-per-worker-thread'; + readonly mainRss: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable'; + readonly rendererRss: 'separate-renderer-process'; + readonly workerJsHeap: 'separate-v8-isolate'; + }; + readonly runtime: { + readonly arch: string; + readonly cdpPort: number; + readonly electronVersion: string; + readonly harnessNodeVersion: string; + readonly platform: string; + }; + readonly scenario: M3uImportScenarioId; + readonly syntheticSeed: number; + readonly variant: string; + readonly warmupRuns: number; +} + +export interface M3uImportMeasuredSummary { + readonly angularRenderingMs: NumericDistribution; + readonly dataAcquireMs: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerGetEventLoopUtilization: NumericDistribution; + readonly databaseWorkerGetThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerGetThreadCpuUserMicros: NumericDistribution; + readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerEventLoopUtilization: NumericDistribution; + readonly databaseWorkerExternalPeakBytes: NumericDistribution; + readonly databaseWorkerHeapPeakBytes: NumericDistribution; + readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly databaseWorkerThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerThreadCpuUserMicros: NumericDistribution; + readonly databaseWorkerToMainCloneProxyMs: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerUpsertEventLoopUtilization: NumericDistribution; + readonly databaseWorkerUpsertThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerUpsertThreadCpuUserMicros: NumericDistribution; + readonly ipcStructuredCloneProxyMs: NumericDistribution; + readonly mainToDatabaseWorkerCloneProxyMs: NumericDistribution; + readonly mainToRendererCloneProxyMs: NumericDistribution; + readonly m3uParsingMs: NumericDistribution; + readonly mainEventLoopDelayMaxMs: NumericDistribution; + readonly mainEventLoopDelayP95Ms: NumericDistribution; + readonly mainEventLoopDelayP99Ms: NumericDistribution; + readonly mainEventLoopUtilization: NumericDistribution; + readonly mainHeapPeakBytes: NumericDistribution; + readonly mainHeapPostGcBytes: NumericDistribution; + readonly mainRssPeakBytes: NumericDistribution; + readonly mainRssPostGcBytes: NumericDistribution; + readonly normalizationMs: NumericDistribution; + readonly playlistDeserializationMs: NumericDistribution; + readonly playlistSerializationMs: NumericDistribution; + readonly rendererFrameGapMs: NumericDistribution; + readonly rendererHeartbeatDelayMs: NumericDistribution; + readonly rendererHeapPeakBytes: NumericDistribution; + readonly rendererHeapPostGcBytes: NumericDistribution; + readonly rendererLongTaskCount: number; + readonly rendererLongTaskMs: NumericDistribution; + readonly rendererRssPeakBytes: NumericDistribution; + readonly rendererToMainCloneProxyMs: NumericDistribution; + readonly responsiveEvents: number; + readonly sqliteReadMs: NumericDistribution; + readonly sqliteWriteMs: NumericDistribution; + readonly storeImportDispatchMs: NumericDistribution; + readonly storePublishChannelsMs: NumericDistribution; + readonly storeUpdateMs: NumericDistribution; + readonly totalMs: NumericDistribution; + readonly unresponsiveEvents: number; +} + +export interface M3uImportBenchmarkSummary { + readonly iterations: readonly M3uImportIterationResult[]; + readonly manifest: M3uImportBenchmarkManifest; + readonly measured: M3uImportMeasuredSummary; + readonly notApplicable: { + readonly cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation'; + readonly databaseWorkerRss: 'unavailable-per-worker-thread'; + readonly indexAndCommitMs: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON; + readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable'; + readonly playlistRefreshWorker: 'not-used-by-initial-import'; + }; + readonly validity: { + readonly databaseWorkerPeakMemory: DatabaseWorkerPeakMemoryValidity; + readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity; + readonly databaseWorkerRequestMetrics: DatabaseWorkerRequestMetricsValidity; + readonly rendererRss: RendererRssValidity; + }; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts new file mode 100644 index 000000000..558b33365 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { it } from 'node:test'; + +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + createM3uImportBenchmarkSummary, + createM3uImportIterationResult, + type M3uImportBenchmarkSummary, +} from './m3u-import-summary'; +import { assertM3uImportComparisonValidity } from './m3u-import.benchmark'; +import { + createCompleteTestMainCapture, + createCompleteTestRendererCapture, + createTestM3uImportManifest, + TEST_M3U_CHANNEL_COUNT, + TEST_M3U_FIXTURE_BYTES, +} from './m3u-import-report.test-helpers'; + +it('keeps summary aggregation below the hard line limit without a suppression', () => { + const source = readFileSync( + resolve(process.cwd(), 'src/performance/m3u-import-summary.ts'), + 'utf8' + ); + + assert.doesNotMatch(source, /eslint-disable max-lines/); + assert.ok(source.split(/\r?\n/).length <= 400); +}); + +it('summarizes measured runs only while preserving per-process memory boundaries', () => { + const warmup = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'warmup-01', + scenarioId: 'm3u-import-10k', + }); + const measured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const diagnostic = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + main: createCompleteTestMainCapture({ + mainRss: 9_999, + rendererRss: 8_888, + }), + renderer: createCompleteTestRendererCapture({ + rendererHeap: 7_777, + }), + runId: 'diagnostic', + scenarioId: 'm3u-import-10k', + }); + const summary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, measured, diagnostic] + ); + + assert.equal(summary.measured.totalMs.count, 1); + assert.equal(summary.measured.totalMs.mean, 215); + assert.equal(summary.measured.ipcStructuredCloneProxyMs.mean, 44); + assert.equal(summary.measured.mainToDatabaseWorkerCloneProxyMs.mean, 2); + assert.equal(summary.measured.databaseWorkerToMainCloneProxyMs.mean, 12); + assert.equal(summary.measured.mainToRendererCloneProxyMs.mean, 28); + assert.equal(summary.measured.rendererToMainCloneProxyMs.mean, 2); + assert.equal(summary.measured.sqliteReadMs.mean, 5); + assert.equal(summary.measured.playlistDeserializationMs.mean, 12); + assert.equal(summary.measured.databaseWorkerThreadCpuSystemMicros.mean, 100); + assert.equal(summary.measured.databaseWorkerThreadCpuUserMicros.mean, 120); + assert.equal( + summary.measured.databaseWorkerUpsertEventLoopDelayP95Ms.mean, + 4 + ); + assert.equal( + summary.measured.databaseWorkerGetEventLoopDelayP95Ms.mean, + 7 + ); + assert.equal(summary.measured.mainRssPeakBytes.mean, 5_000); + assert.equal(summary.measured.mainHeapPeakBytes.mean, 2_000); + assert.equal(summary.measured.rendererRssPeakBytes.mean, 4_000); + assert.equal(summary.measured.rendererHeapPeakBytes.mean, 3_000); + assert.equal(summary.measured.databaseWorkerHeapPeakBytes.mean, 1_500); + assert.equal(summary.measured.databaseWorkerPostGcHeapBytes.mean, 900); + assert.equal(summary.measured.rendererLongTaskCount, 1); + assert.equal(summary.measured.unresponsiveEvents, 0); + assert.deepEqual(summary.notApplicable, { + cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation', + databaseWorkerRss: 'unavailable-per-worker-thread', + indexAndCommitMs: + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + playlistRefreshWorker: 'not-used-by-initial-import', + }); + assert.equal(summary.validity.rendererRss.validForComparison, true); + assert.equal( + summary.validity.databaseWorkerPeakMemory.validForComparison, + true + ); + assert.equal( + summary.validity.databaseWorkerPostGc.validForComparison, + true + ); + assert.doesNotThrow(() => assertM3uImportComparisonValidity(summary)); + + const databaseWorker = measured.main.workers[0]; + assert.ok(databaseWorker); + const invalidMeasured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + ...measured.main, + workers: [ + { + ...databaseWorker, + externalMemorySampleCount: 0, + heapUsedSampleCount: 0, + peakExternalBytes: null, + peakExternalUnavailableReason: + 'worker-external-memory-samples-missing', + peakHeapUsedBytes: null, + peakHeapUsedUnavailableReason: + 'worker-heap-used-samples-missing', + }, + ], + }, + renderer: measured.renderer, + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const invalidPeakSummary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, invalidMeasured, diagnostic] + ); + assert.equal( + invalidPeakSummary.validity.databaseWorkerPeakMemory.validForComparison, + false + ); + assert.equal( + invalidPeakSummary.measured.databaseWorkerHeapPeakBytes.count, + 0 + ); + assert.throws( + () => + assertM3uImportComparisonValidity( + invalidPeakSummary as M3uImportBenchmarkSummary + ), + /Database worker peak-memory capture is invalid/ + ); + assert.throws( + () => + createM3uImportBenchmarkSummary(createTestM3uImportManifest(), [ + measured, + diagnostic, + ]), + /m3u-import-summary-schedule-invalid/ + ); +}); + +it('fails comparison when any measured worker request omits mandatory event-loop or CPU metrics', () => { + const completeMain = createCompleteTestMainCapture(); + const databaseWorker = completeMain.workers[0]; + const get = databaseWorker?.requests[1]; + assert.ok(databaseWorker); + assert.ok(get); + const invalidMain = { + ...completeMain, + workers: [ + { + ...databaseWorker, + requests: [ + databaseWorker.requests[0], + { + ...get, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + histogramFlushedEpochMs: null, + }, + ], + }, + ], + }; + const warmup = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'warmup-01', + scenarioId: 'm3u-import-10k', + }); + const measured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: invalidMain, + renderer: createCompleteTestRendererCapture(), + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const diagnostic = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'diagnostic', + scenarioId: 'm3u-import-10k', + }); + const summary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, measured, diagnostic] + ); + + assert.equal( + summary.validity.databaseWorkerRequestMetrics.validForComparison, + false + ); + assert.equal( + summary.validity.databaseWorkerRequestMetrics.expectedRequestCount, + 2 + ); + assert.equal( + summary.validity.databaseWorkerRequestMetrics.validRequestCount, + 1 + ); + assert.equal( + summary.measured.databaseWorkerGetEventLoopDelayP95Ms.count, + 0 + ); + assert.throws( + () => assertM3uImportComparisonValidity(summary), + /Database worker request metrics are invalid/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts new file mode 100644 index 000000000..5a81e0a87 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts @@ -0,0 +1,382 @@ +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { M3uImportScenarioId } from './m3u-import-benchmark-contract'; +import type { + MainCaptureMetrics, + NumericDistribution, + PerformanceIterationKind, + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; +import { + deriveM3uImportPhaseMetrics, + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, +} from './m3u-import-report'; +import { summarizeNumbers } from './performance-statistics'; +import { assessDatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity'; +import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity'; +import { assessDatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity'; +import { assessRendererRssValidity } from './renderer-rss-validity'; +import type { + M3uImportBenchmarkManifest, + M3uImportBenchmarkSummary, + M3uImportIterationResult, +} from './m3u-import-summary-contract'; + +export type { + M3uImportBenchmarkManifest, + M3uImportBenchmarkSummary, + M3uImportIterationResult, + M3uImportMeasuredSummary, +} from './m3u-import-summary-contract'; + +export function createM3uImportIterationResult(input: { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +}): M3uImportIterationResult { + return Object.freeze({ + channelCount: input.channelCount, + fixtureBytes: input.fixtureBytes, + kind: input.kind, + main: input.main, + phases: deriveM3uImportPhaseMetrics(input), + renderer: input.renderer, + runId: input.runId, + scenarioId: input.scenarioId, + }); +} + +export function createM3uImportBenchmarkSummary( + manifest: M3uImportBenchmarkManifest, + iterations: readonly M3uImportIterationResult[] +): M3uImportBenchmarkSummary { + validateIterationSchedule(manifest, iterations); + const measured = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + validateMeasuredIterations(manifest, measured); + const values = ( + select: (iteration: M3uImportIterationResult) => number | null + ): NumericDistribution => summarizeNumbers(measured.map(select)); + const rendererSamples = ( + select: ( + probe: M3uImportRendererCaptureMetrics['probe'] + ) => readonly number[] + ): NumericDistribution => + summarizeNumbers( + measured.flatMap((iteration) => [ + ...select(iteration.renderer.probe), + ]) + ); + const databaseWorkers = measured.map(requireDatabaseWorker); + const databaseRequestPairs = databaseWorkers.map( + requireInitialImportRequests + ); + const databaseUpserts = databaseRequestPairs.map((pair) => pair.upsert); + const databaseGets = databaseRequestPairs.map((pair) => pair.get); + const databaseRequests = databaseRequestPairs.flatMap((pair) => [ + pair.upsert, + pair.get, + ]); + + return Object.freeze({ + iterations: Object.freeze([...iterations]), + manifest, + measured: Object.freeze({ + angularRenderingMs: values( + (iteration) => iteration.phases.angularRenderingMs + ), + dataAcquireMs: values( + (iteration) => iteration.phases.dataAcquireMs + ), + databaseWorkerGetEventLoopDelayMaxMs: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerGetEventLoopDelayP95Ms: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerGetEventLoopDelayP99Ms: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerGetEventLoopUtilization: summarizeNumbers( + databaseGets.map((request) => request.eventLoopUtilization) + ), + databaseWorkerGetThreadCpuSystemMicros: summarizeNumbers( + databaseGets.map((request) => request.threadCpuSystemMicros) + ), + databaseWorkerGetThreadCpuUserMicros: summarizeNumbers( + databaseGets.map((request) => request.threadCpuUserMicros) + ), + databaseWorkerEventLoopDelayMaxMs: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerEventLoopDelayP95Ms: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerEventLoopDelayP99Ms: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerEventLoopUtilization: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopUtilization) + ), + databaseWorkerExternalPeakBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.peakExternalBytes) + ), + databaseWorkerHeapPeakBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.peakHeapUsedBytes) + ), + databaseWorkerPostGcHeapBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.postGcHeapUsedBytes) + ), + databaseWorkerThreadCpuSystemMicros: summarizeNumbers( + databaseRequestPairs.map((pair) => + sumRequestMetric( + pair.upsert.threadCpuSystemMicros, + pair.get.threadCpuSystemMicros + ) + ) + ), + databaseWorkerThreadCpuUserMicros: summarizeNumbers( + databaseRequestPairs.map((pair) => + sumRequestMetric( + pair.upsert.threadCpuUserMicros, + pair.get.threadCpuUserMicros + ) + ) + ), + databaseWorkerToMainCloneProxyMs: values( + (iteration) => + iteration.phases.databaseWorkerToMainCloneProxyMs + ), + databaseWorkerUpsertEventLoopDelayMaxMs: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerUpsertEventLoopDelayP95Ms: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerUpsertEventLoopDelayP99Ms: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerUpsertEventLoopUtilization: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopUtilization) + ), + databaseWorkerUpsertThreadCpuSystemMicros: summarizeNumbers( + databaseUpserts.map( + (request) => request.threadCpuSystemMicros + ) + ), + databaseWorkerUpsertThreadCpuUserMicros: summarizeNumbers( + databaseUpserts.map((request) => request.threadCpuUserMicros) + ), + ipcStructuredCloneProxyMs: values( + (iteration) => iteration.phases.ipcStructuredCloneProxyMs + ), + mainToDatabaseWorkerCloneProxyMs: values( + (iteration) => iteration.phases.mainToDatabaseWorkerCloneProxyMs + ), + mainToRendererCloneProxyMs: values( + (iteration) => iteration.phases.mainToRendererCloneProxyMs + ), + m3uParsingMs: values((iteration) => iteration.phases.m3uParsingMs), + mainEventLoopDelayMaxMs: values( + (iteration) => iteration.main.eventLoopDelay.maxMs + ), + mainEventLoopDelayP95Ms: values( + (iteration) => iteration.main.eventLoopDelay.p95Ms + ), + mainEventLoopDelayP99Ms: values( + (iteration) => iteration.main.eventLoopDelay.p99Ms + ), + mainEventLoopUtilization: values( + (iteration) => iteration.main.eventLoopUtilization + ), + mainHeapPeakBytes: values( + (iteration) => iteration.main.memory.peakHeapUsedBytes + ), + mainHeapPostGcBytes: values( + (iteration) => iteration.main.memory.postGcHeapUsedBytes + ), + mainRssPeakBytes: values( + (iteration) => iteration.main.memory.peakRssBytes + ), + mainRssPostGcBytes: values( + (iteration) => iteration.main.memory.postGcRssBytes + ), + normalizationMs: values( + (iteration) => iteration.phases.normalizationMs + ), + playlistDeserializationMs: values( + (iteration) => iteration.phases.playlistDeserializationMs + ), + playlistSerializationMs: values( + (iteration) => iteration.phases.playlistSerializationMs + ), + rendererFrameGapMs: rendererSamples((probe) => probe.frameGapsMs), + rendererHeartbeatDelayMs: rendererSamples( + (probe) => probe.heartbeatDelaysMs + ), + rendererHeapPeakBytes: values( + (iteration) => iteration.renderer.peakHeapUsedBytes + ), + rendererHeapPostGcBytes: values( + (iteration) => iteration.renderer.postGcHeapUsedBytes + ), + rendererLongTaskCount: measured.reduce( + (total, iteration) => + total + iteration.renderer.probe.longTasksMs.length, + 0 + ), + rendererLongTaskMs: rendererSamples((probe) => probe.longTasksMs), + rendererRssPeakBytes: values( + (iteration) => iteration.main.rendererWindow.rss.peakRssBytes + ), + rendererToMainCloneProxyMs: values( + (iteration) => iteration.phases.rendererToMainCloneProxyMs + ), + responsiveEvents: measured.reduce( + (total, iteration) => + total + iteration.main.rendererWindow.responsiveEvents, + 0 + ), + sqliteReadMs: values((iteration) => iteration.phases.sqliteReadMs), + sqliteWriteMs: values( + (iteration) => iteration.phases.sqliteWriteMs + ), + storeImportDispatchMs: values( + (iteration) => iteration.phases.storeImportDispatchMs + ), + storePublishChannelsMs: values( + (iteration) => iteration.phases.storePublishChannelsMs + ), + storeUpdateMs: values( + (iteration) => iteration.phases.storeUpdateMs + ), + totalMs: values((iteration) => iteration.phases.totalMs), + unresponsiveEvents: measured.reduce( + (total, iteration) => + total + iteration.main.rendererWindow.unresponsiveEvents, + 0 + ), + }), + notApplicable: Object.freeze({ + cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation', + databaseWorkerRss: 'unavailable-per-worker-thread', + indexAndCommitMs: M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + playlistRefreshWorker: 'not-used-by-initial-import', + }), + validity: Object.freeze({ + databaseWorkerPeakMemory: + assessDatabaseWorkerPeakMemoryValidity(iterations), + databaseWorkerPostGc: + assessDatabaseWorkerPostGcValidity(iterations), + databaseWorkerRequestMetrics: + assessDatabaseWorkerRequestMetricsValidity(iterations), + rendererRss: assessRendererRssValidity(iterations), + }), + }); +} + +function validateIterationSchedule( + manifest: M3uImportBenchmarkManifest, + iterations: readonly M3uImportIterationResult[] +): void { + const warmupCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.WARMUP + ).length; + const measuredCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ).length; + const diagnosticCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC + ).length; + if ( + warmupCount !== manifest.warmupRuns || + measuredCount !== manifest.measuredRuns || + diagnosticCount !== 1 || + iterations.length !== warmupCount + measuredCount + diagnosticCount || + new Set(iterations.map((iteration) => iteration.runId)).size !== + iterations.length || + iterations.some( + (iteration) => + iteration.channelCount !== manifest.channelCount || + iteration.fixtureBytes !== manifest.fixtureBytes || + iteration.scenarioId !== manifest.scenario + ) + ) { + throw new Error('m3u-import-summary-schedule-invalid'); + } +} + +function validateMeasuredIterations( + manifest: M3uImportBenchmarkManifest, + measured: readonly M3uImportIterationResult[] +): void { + if ( + measured.length !== manifest.measuredRuns || + new Set(measured.map((iteration) => iteration.runId)).size !== + measured.length || + measured.some( + (iteration) => + iteration.channelCount !== manifest.channelCount || + iteration.fixtureBytes !== manifest.fixtureBytes || + iteration.scenarioId !== manifest.scenario + ) + ) { + throw new Error('m3u-import-summary-measured-runs-invalid'); + } +} + +function requireDatabaseWorker( + iteration: M3uImportIterationResult +): WorkerCaptureMetrics { + const databaseWorkers = iteration.main.workers.filter( + (worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE + ); + if (databaseWorkers.length !== 1 || !databaseWorkers[0]) { + throw new Error('m3u-import-summary-database-worker-invalid'); + } + return databaseWorkers[0]; +} + +function requireInitialImportRequests( + worker: WorkerCaptureMetrics +): { + readonly get: WorkerRequestPerformanceMetrics; + readonly upsert: WorkerRequestPerformanceMetrics; +} { + const upserts = worker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success + ); + const gets = worker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && request.success + ); + if ( + worker.requests.length !== 2 || + upserts.length !== 1 || + !upserts[0] || + gets.length !== 1 || + !gets[0] + ) { + throw new Error('m3u-import-summary-database-request-invalid'); + } + return { get: gets[0], upsert: upserts[0] }; +} + +function sumRequestMetric( + first: number | null, + second: number | null +): number | null { + return first === null || second === null ? null : first + second; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts new file mode 100644 index 000000000..dab027bc9 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts @@ -0,0 +1,341 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + closeElectronApp, + expect, + launchElectronApp, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import { validateInitialImportDiagnosticArtifacts } from './initial-import-diagnostic-artifacts'; +import { + assertRendererCdpTarget, + assertTcpPortAvailable, + type M3uImportBenchmarkConfiguration, + resolveM3uImportBenchmarkConfiguration, + resolveRendererWindowIdentity, + writeM3uImportJson as writeJson, +} from './m3u-import-benchmark-support'; +import { + assertSyntheticM3uImportUrl, + createM3uImportIterationDefinitions, + listM3uImportScenarios, + type M3uImportIterationDefinition, + type M3uImportScenario, +} from './m3u-import-benchmark-contract'; +import { + createM3uImportBenchmarkSummary, + createM3uImportIterationResult, + type M3uImportBenchmarkManifest, + type M3uImportBenchmarkSummary, + type M3uImportIterationResult, +} from './m3u-import-summary'; +import { + startM3uImportRendererCapture, + type RunningM3uImportRendererCapture, +} from './m3u-import-renderer-capture'; +import { + installMainCapture, + readMainCaptureStatus, + startMainCapture, + stopMainCapture, +} from './m3u-refresh-main-capture'; +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; +import { + startSyntheticM3uServer, + type SyntheticM3uServer, +} from './synthetic-m3u-server'; +import { + createSyntheticM3uFixture, + SYNTHETIC_M3U_SEED, + type SyntheticM3uFixture, +} from './synthetic-m3u'; + +export async function runM3uImportBenchmark(): Promise { + const config = await resolveM3uImportBenchmarkConfiguration(); + const definitions = createM3uImportIterationDefinitions(config.smoke); + + for (const scenario of listM3uImportScenarios()) { + await runScenario( + config, + scenario, + definitions.filter( + (definition) => definition.scenarioId === scenario.id + ) + ); + } +} + +async function runScenario( + config: M3uImportBenchmarkConfiguration, + scenario: M3uImportScenario, + definitions: readonly M3uImportIterationDefinition[] +): Promise { + const fixture = createSyntheticM3uFixture(scenario.channelCount); + const server = await startSyntheticM3uServer(fixture); + const scenarioDirectory = join(config.variantDirectory, scenario.id); + const iterations: M3uImportIterationResult[] = []; + + try { + assertSyntheticM3uImportUrl(server.resourceUrl); + await server.prime(); + await assertPerformanceArtifactCapacity(scenarioDirectory); + await mkdir(scenarioDirectory, { recursive: false }); + for (const definition of definitions) { + console.log( + `[performance] ${config.variant}/${scenario.id}/${definition.runId} starting` + ); + const result = await runIteration( + config, + definition, + fixture, + server, + scenarioDirectory + ); + iterations.push(result); + console.log( + `[performance] ${definition.runId} total=${result.phases.totalMs.toFixed(3)}ms` + ); + } + } finally { + await server.close(); + } + + if (server.requestCount() !== definitions.length + 1) { + throw new Error('Synthetic M3U server request total is invalid'); + } + const manifest = createManifest(config, scenario, fixture); + const summary = createM3uImportBenchmarkSummary(manifest, iterations); + await writeJson(join(scenarioDirectory, 'manifest.json'), manifest); + await writeJson(join(scenarioDirectory, 'summary.json'), summary); + const diagnostic = requireDiagnosticIteration(iterations); + await validateInitialImportDiagnosticArtifacts( + { main: diagnostic.main, renderer: diagnostic.renderer }, + join(scenarioDirectory, diagnostic.runId) + ); + assertM3uImportComparisonValidity(summary); + console.log( + `[performance] completed: ${join(scenarioDirectory, 'summary.json')}` + ); +} + +export function assertM3uImportComparisonValidity( + summary: M3uImportBenchmarkSummary +): void { + if (!summary.validity.rendererRss.validForComparison) { + throw new Error( + `Renderer RSS capture is invalid for comparison: ${JSON.stringify( + summary.validity.rendererRss.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerPostGc.validForComparison) { + throw new Error( + `Database worker post-GC capture is invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerPostGc.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerPeakMemory.validForComparison) { + throw new Error( + `Database worker peak-memory capture is invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerPeakMemory.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerRequestMetrics.validForComparison) { + throw new Error( + `Database worker request metrics are invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerRequestMetrics.invalidRequests + )}` + ); + } +} + +async function runIteration( + config: M3uImportBenchmarkConfiguration, + definition: M3uImportIterationDefinition, + fixture: SyntheticM3uFixture, + server: SyntheticM3uServer, + scenarioDirectory: string +): Promise { + const iterationDirectory = join(scenarioDirectory, definition.runId); + await assertPerformanceArtifactCapacity(iterationDirectory); + await mkdir(iterationDirectory, { recursive: false }); + const dataDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-import-performance-') + ); + let app: LaunchedElectronApp | null = null; + let renderer: RunningM3uImportRendererCapture | null = null; + + try { + await assertTcpPortAvailable(config.rendererCdpPort); + app = await launchElectronApp(dataDirectory, { + args: [ + '--js-flags=--expose-gc', + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${config.rendererCdpPort}`, + `--user-data-dir=${join(dataDirectory, 'user-data')}`, + ], + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + IPTVNATOR_TRACE_RENDERER_CONSOLE: + process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', + }, + }); + app.mainWindow.setDefaultTimeout(120_000); + await assertRendererCdpTarget( + app.mainWindow, + config.rendererCdpPort + ); + await installMainCapture(app.electronApp); + const rendererWindowIdentity = await resolveRendererWindowIdentity(app); + const requestsBefore = server.requestCount(); + const diagnostic = + definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; + renderer = await startM3uImportRendererCapture(app.mainWindow, { + diagnostic, + outputDirectory: iterationDirectory, + playlistTitle: `Synthetic M3U import ${definition.channelCount}`, + playlistUrl: server.resourceUrl, + }); + + await startMainCapture(app.electronApp, { + diagnostic, + outputDirectory: iterationDirectory, + rendererWindowIdentity, + }); + await renderer.triggerImport(); + await renderer.waitForTerminal(); + await waitForDatabaseSettlement(app); + assertM3uImportRequestDelta(requestsBefore, server.requestCount()); + + const [rendererMetrics, mainMetrics] = await Promise.all([ + renderer.stop(), + stopMainCapture(app.electronApp), + ]); + await Promise.all([ + writeJson( + join(iterationDirectory, 'main-capture.json'), + mainMetrics + ), + writeJson( + join(iterationDirectory, 'renderer-capture.json'), + rendererMetrics + ), + ]); + const result = createM3uImportIterationResult({ + channelCount: definition.channelCount, + fixtureBytes: fixture.bytes, + kind: definition.kind, + main: mainMetrics, + renderer: rendererMetrics, + runId: definition.runId, + scenarioId: definition.scenarioId, + }); + await writeJson(join(iterationDirectory, 'result.json'), result); + return result; + } finally { + if (renderer) { + await renderer.dispose().catch((error: unknown) => { + console.error( + '[performance] Renderer capture cleanup failed', + error + ); + }); + } + if (app) { + await closeElectronApp(app).catch((error: unknown) => { + console.error('[performance] Electron cleanup failed', error); + }); + } + await rm(dataDirectory, { force: true, recursive: true }); + } +} + +async function waitForDatabaseSettlement( + app: LaunchedElectronApp +): Promise { + await expect + .poll( + async () => { + const status = await readMainCaptureStatus(app.electronApp); + return ( + status.databaseRequests === 2 && + status.databaseUpsertsCompleted === 1 && + status.databaseGetsCompleted === 1 && + status.databasePending === 0 && + status.preloadSuccessMarkers === 2 + ); + }, + { timeout: 120_000 } + ) + .toBe(true); +} + +function createManifest( + config: M3uImportBenchmarkConfiguration, + scenario: M3uImportScenario, + fixture: SyntheticM3uFixture +): M3uImportBenchmarkManifest { + return Object.freeze({ + channelCount: fixture.channelCount, + fixtureBytes: fixture.bytes, + fixtureSha256: fixture.sha256, + gitCommit: config.sourceState.commit, + gitDiffSha256: config.sourceState.diffSha256, + gitDirty: config.sourceState.dirty, + measuredRuns: config.measuredRuns, + memoryAccounting: Object.freeze({ + databaseWorkerRss: 'unavailable-per-worker-thread', + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + }), + runtime: Object.freeze({ + arch: process.arch, + cdpPort: config.rendererCdpPort, + electronVersion: config.electronVersion, + harnessNodeVersion: process.versions.node, + platform: process.platform, + }), + scenario: scenario.id, + syntheticSeed: SYNTHETIC_M3U_SEED, + variant: config.variant, + warmupRuns: 1, + }); +} + +function requireDiagnosticIteration( + iterations: readonly M3uImportIterationResult[] +): M3uImportIterationResult { + const diagnostic = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC + ); + if (diagnostic.length !== 1 || !diagnostic[0]) { + throw new Error('M3U import diagnostic iteration is missing'); + } + return diagnostic[0]; +} + +export function assertM3uImportRequestDelta( + before: number, + after: number +): void { + const oneRequest = + Number.isSafeInteger(before) && + Number.isSafeInteger(after) && + after === before + 1; + if (!oneRequest) { + throw new Error( + 'Synthetic M3U import must issue exactly one application GET' + ); + } +} 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 217dd1ca3..a89f95dc8 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,7 +1,9 @@ +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; +import type { RendererRssValidity } from './renderer-rss-validity'; import type { - RendererProcessRssCapture, - RendererProcessRssUnavailableReason, -} from './renderer-process-rss-capture'; + AppPlaylistPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; export const PERFORMANCE_ITERATION_KIND = { DIAGNOSTIC: 'diagnostic', @@ -54,6 +56,12 @@ export type WorkerPostGcHeapCapture = readonly postGcHeapUsedBytes: null; }; +export const WORKER_PEAK_MEMORY_UNAVAILABLE_REASON = { + EXTERNAL_MEMORY_SAMPLES_MISSING: + 'worker-external-memory-samples-missing', + HEAP_USED_SAMPLES_MISSING: 'worker-heap-used-samples-missing', +} as const; + export interface NumericDistribution { readonly count: number; readonly max: number | null; @@ -78,12 +86,19 @@ export interface ProcessMemoryMetrics { } export interface MainTimelineRecord { + readonly boundary?: 'end' | 'start'; + readonly byteCount?: number; + readonly durationMs?: number | null; readonly epochMs: number; + readonly ipcCallId?: number; + readonly itemCount?: number; readonly operation?: string; readonly operationId?: string; + readonly phase?: string; readonly playlistId?: string; readonly requestId?: string; readonly success?: boolean; + readonly sourceEpochMs?: number; readonly type: string; } @@ -93,15 +108,19 @@ export interface WorkerRequestPerformanceMetrics { readonly eventLoopUtilization: number | null; readonly eventLoopUtilizationUnavailableReason: string | null; readonly histogramFlushedEpochMs: number | null; + readonly ipcCallId: number | null; readonly invalidReason: string | null; readonly operation: string | null; readonly operationId: string | null; readonly operationIdUnavailableReason: string | null; readonly performanceCaptureUnavailableReason: string | null; + readonly phaseEvents: readonly PerformancePhaseEvent[]; readonly playlistId: string | null; readonly requestId: string | null; readonly requestReceivedEpochMs: number | null; readonly responseEpochMs: number; + readonly responsePostedEpochMs: number | null; + readonly sourceEpochMs: number | null; readonly success: boolean; readonly threadCpuSystemMicros: number | null; readonly threadCpuUnavailableReason: string | null; @@ -117,11 +136,19 @@ export type WorkerCaptureMetrics = { readonly eventLoopDelay: EventLoopDelayMetrics | null; readonly eventLoopDelayUnavailableReason: string | null; readonly eventLoopUtilization: number | null; + readonly externalMemorySampleCount: number; + readonly heapUsedSampleCount: number; readonly kind: PerformanceWorkerKind; readonly operationId: string | null; readonly ordinal: number; - readonly peakExternalBytes: number; - readonly peakHeapUsedBytes: number; + readonly peakExternalBytes: number | null; + readonly peakExternalUnavailableReason: + | typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING + | null; + readonly peakHeapUsedBytes: number | null; + readonly peakHeapUsedUnavailableReason: + | typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.HEAP_USED_SAMPLES_MISSING + | null; readonly playlistId: string | null; readonly profilePath: string | null; readonly requests: readonly WorkerRequestPerformanceMetrics[]; @@ -257,22 +284,6 @@ 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[]; 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 6311fbd06..f95d075e6 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,7 +9,6 @@ import type { NumericDistribution, PerformanceWorkerKind, RendererCaptureMetrics, - RendererRssValidity, WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { @@ -21,6 +20,7 @@ import { summarizeNumbers, } from './performance-statistics'; import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity'; +import { assessRendererRssValidity } from './renderer-rss-validity'; export function createCancellationIterationResult(input: { readonly kind: CancellationIterationResult['kind']; @@ -316,50 +316,6 @@ export function createCancellationBenchmarkSummary( }); } -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-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index 0931ac31a..2c4536f8e 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 @@ -1,5 +1,9 @@ /* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ import type { ElectronApplication } from '@playwright/test'; +import { + M3U_IMPORT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, +} from '@iptvnator/shared/interfaces'; import { DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, @@ -58,6 +62,7 @@ export interface MainCaptureStartOptions { } export interface MainCaptureStatus { + readonly databaseGetsCompleted: number; readonly databasePending: number; readonly databaseRequests: number; readonly databaseUpsertsCompleted: number; @@ -65,6 +70,7 @@ export interface MainCaptureStatus { readonly playlistResponsesFailed: number; readonly playlistResponses: number; readonly playlistResponsesSucceeded: number; + readonly preloadSuccessMarkers: number; } export interface MainCaptureRolloverResult { @@ -101,6 +107,9 @@ export async function installMainCapture( createRendererProcessRssCaptureApi.toString(), rendererWindowRssSessionApiFactorySource: createRendererWindowRssSessionApi.toString(), + m3uImportPerformancePhaseEventChannel: + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, + m3uImportPerformancePhases: Object.values(M3U_IMPORT_PERFORMANCE_PHASE), stateKey: MAIN_CAPTURE_STATE_KEY, workerTerminationGenerationApiFactorySource: createWorkerTerminationGenerationApi.toString(), @@ -158,12 +167,14 @@ export async function installMainCapture( cpuLast: WorkerCpuUsage | null; elu: number | null; eluStart: WorkerElu | null; + externalMemorySampleCount: number; externalPeak: number; finalized: boolean; finalizationKey: object; finalizationTimedOut: boolean; finalizing: Promise | null; heapPeak: number; + heapUsedSampleCount: number; kind: 'database.worker' | 'playlist-refresh.worker'; operationId: string | null; ordinal: number; @@ -185,12 +196,19 @@ export async function installMainCapture( worker: InstrumentedWorker; } interface TimelineRecord { + readonly boundary?: 'end' | 'start'; + readonly byteCount?: number; + readonly durationMs?: number | null; readonly epochMs: number; + readonly ipcCallId?: number; + readonly itemCount?: number; readonly operation?: string; readonly operationId?: string; + readonly phase?: string; readonly playlistId?: string; readonly requestId?: string; readonly success?: boolean; + readonly sourceEpochMs?: number; readonly type: string; } @@ -245,6 +263,9 @@ export async function installMainCapture( const inspector = runtimeProcess.getBuiltinModule( 'node:inspector' ) as typeof import('node:inspector'); + const diagnosticsChannel = runtimeProcess.getBuiltinModule( + 'node:diagnostics_channel' + ) as typeof import('node:diagnostics_channel'); const path = runtimeProcess.getBuiltinModule( 'node:path' ) as typeof import('node:path'); @@ -307,6 +328,60 @@ export async function installMainCapture( state.timeline.push({ epochMs: nowEpochMs(), ...record }); } }; + const m3uImportPhases = new Set( + input.m3uImportPerformancePhases + ); + diagnosticsChannel + .channel(input.m3uImportPerformancePhaseEventChannel) + .subscribe((incoming: unknown) => { + if (!state.active && !state.stopping) { + return; + } + if (typeof incoming !== 'object' || incoming === null) { + return; + } + const event = incoming as JsonRecord; + const boundary = event['boundary']; + const durationMs = event['durationMs']; + const epochMs = event['epochMs']; + const metadata = + typeof event['metadata'] === 'object' && + event['metadata'] !== null + ? (event['metadata'] as JsonRecord) + : null; + const phase = event['phase']; + const requestId = event['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + typeof epochMs !== 'number' || + !Number.isFinite(epochMs) || + typeof phase !== 'string' || + !m3uImportPhases.has(phase) || + typeof requestId !== 'string' || + requestId.length === 0 || + (boundary === 'start' && durationMs !== null) || + (boundary === 'end' && + (typeof durationMs !== 'number' || + !Number.isFinite(durationMs) || + durationMs < 0)) + ) { + return; + } + const byteCount = metadata?.['byteCount']; + const itemCount = metadata?.['itemCount']; + state.timeline.push({ + boundary, + ...(isCount(byteCount) ? { byteCount } : {}), + durationMs: durationMs as number | null, + epochMs, + ...(isCount(itemCount) ? { itemCount } : {}), + phase, + requestId, + type: 'm3u-import-phase', + }); + }); + const isCount = (value: unknown): value is number => + Number.isSafeInteger(value) && Number(value) >= 0; const classifyRequest = ( message: JsonRecord ): 'database.worker' | 'playlist-refresh.worker' | null => { @@ -348,12 +423,14 @@ export async function installMainCapture( cpuLast: null, elu: null, eluStart: null, + externalMemorySampleCount: 0, externalPeak: 0, finalized: false, finalizationKey: {}, finalizationTimedOut: false, finalizing: null, heapPeak: 0, + heapUsedSampleCount: 0, kind, operationId: null, ordinal: nextWorkerOrdinal, @@ -400,8 +477,10 @@ export async function installMainCapture( record.responseEpochMs = responseEpochMs; record.requestPerformance.push({ identity: { + ipcCallId: null, operationId: record.operationId, operationIdUnavailableReason: null, + sourceEpochMs: null, }, operation: null, performanceCapture: message['performance'] ?? null, @@ -466,14 +545,30 @@ export async function installMainCapture( 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 heapUsedSample = stats.used_heap_size; + if ( + typeof heapUsedSample === 'number' && + Number.isFinite(heapUsedSample) && + heapUsedSample >= 0 + ) { + record.heapUsedSampleCount += 1; + record.heapPeak = Math.max( + record.heapPeak, + heapUsedSample + ); + } + const externalMemorySample = stats.external_memory; + if ( + typeof externalMemorySample === 'number' && + Number.isFinite(externalMemorySample) && + externalMemorySample >= 0 + ) { + record.externalMemorySampleCount += 1; + record.externalPeak = Math.max( + record.externalPeak, + externalMemorySample + ); + } } const cpu = await record.worker.cpuUsage?.(); if (cpu) { @@ -504,12 +599,14 @@ export async function installMainCapture( record.cpuLast = null; record.elu = null; record.eluStart = null; + record.externalMemorySampleCount = 0; record.externalPeak = 0; record.finalized = false; record.finalizationKey = {}; record.finalizationTimedOut = false; record.finalizing = null; record.heapPeak = 0; + record.heapUsedSampleCount = 0; record.operationId = null; record.pendingCount = 0; record.playlistId = null; @@ -863,8 +960,10 @@ export async function installMainCapture( value ) : { + ipcCallId: null, operationId: payloadOperationId, operationIdUnavailableReason: null, + sourceEpochMs: null, }; record.pendingCount += 1; dbRequests.set(value['requestId'], { @@ -874,11 +973,14 @@ export async function installMainCapture( record, }); recordTimeline({ + ipcCallId: identity.ipcCallId ?? undefined, operation: value['operation'], operationId: identity.operationId ?? undefined, playlistId: readDatabasePlaylistId(value) ?? undefined, requestId: value['requestId'], + sourceEpochMs: + identity.sourceEpochMs ?? undefined, type: 'db-request', }); } @@ -1052,6 +1154,12 @@ export async function installMainCapture( const api = { status: (): MainCaptureStatus => ({ + databaseGetsCompleted: state.timeline.filter( + (entry) => + entry['type'] === 'db-response' && + entry['operation'] === 'DB_GET_APP_PLAYLIST' && + entry['success'] === true + ).length, databasePending: dbRequests.size, databaseRequests: state.timeline.filter( (entry) => entry['type'] === 'db-request' @@ -1081,6 +1189,8 @@ export async function installMainCapture( entry['type'] === 'playlist-response' && entry['success'] === true ).length, + preloadSuccessMarkers: + databaseRequestIdentityCapture.successMarkerCount(), }), start: async (options: MainCaptureStartOptions): Promise => { databaseWorkerPostGcCutoffApi.beginCapture(); @@ -1092,6 +1202,18 @@ export async function installMainCapture( databaseWorkerPostGcCutoffApi.beginStop(); state.stopping = true; state.active = false; + for (const marker of databaseRequestIdentityCapture.takeSuccessMarkers()) { + state.timeline.push({ + epochMs: marker.sourceEpochMs, + ipcCallId: marker.ipcCallId, + operation: marker.operation, + operationId: marker.operationId ?? undefined, + playlistId: marker.playlistId, + sourceEpochMs: marker.sourceEpochMs, + success: true, + type: 'preload-performance-success', + }); + } databaseRequestIdentityCapture.stop(); if (state.sampleTimer) { clearInterval(state.sampleTimer); @@ -1230,11 +1352,28 @@ export async function installMainCapture( ? record.cpuLast.user - record.cpuFirst.user : null, eventLoopUtilization: record.elu, + externalMemorySampleCount: + record.externalMemorySampleCount, + heapUsedSampleCount: record.heapUsedSampleCount, kind: record.kind, operationId: record.operationId, ordinal: record.ordinal, - peakExternalBytes: record.externalPeak, - peakHeapUsedBytes: record.heapPeak, + peakExternalBytes: + record.externalMemorySampleCount > 0 + ? record.externalPeak + : null, + peakExternalUnavailableReason: + record.externalMemorySampleCount > 0 + ? null + : 'worker-external-memory-samples-missing', + peakHeapUsedBytes: + record.heapUsedSampleCount > 0 + ? record.heapPeak + : null, + peakHeapUsedUnavailableReason: + record.heapUsedSampleCount > 0 + ? null + : 'worker-heap-used-samples-missing', playlistId: record.playlistId, postGcHeapUnavailableReason: record.postGcHeapUnavailableReason, @@ -1245,6 +1384,7 @@ export async function installMainCapture( terminatedEpochMs: record.terminatedEpochMs, }, requests: record.requestPerformance.map((request) => ({ + ipcCallId: request.identity.ipcCallId, operation: request.operation, operationId: request.identity.operationId, operationIdUnavailableReason: @@ -1253,6 +1393,7 @@ export async function installMainCapture( playlistId: request.playlistId, requestId: request.requestId, responseEpochMs: request.responseEpochMs, + sourceEpochMs: request.identity.sourceEpochMs, success: request.success, })), })); 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 07a20c4d6..34f157d5a 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 @@ -270,3 +270,34 @@ test('the cancellation benchmark exposes GC to Electron worker isolates', () => assert.match(source, /'--js-flags=--expose-gc'/); assert.doesNotMatch(source, /execArgv/); }); + +test('the initial M3U import benchmark uses the production-equivalent performance build', () => { + const target = e2eProject.targets['benchmark-m3u-import']; + + assert.ok(target, 'electron-backend-e2e must define benchmark-m3u-import'); + assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']); +}); + +test('the initial M3U import benchmark command is pinned to its Playwright test file', () => { + const target = e2eProject.targets['benchmark-m3u-import']; + + assert.equal( + target.options?.['command'], + 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts' + ); +}); + +test('the initial M3U import benchmark enables opt-in capture and worker GC', () => { + const source = readFileSync( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/); + assert.match(source, /IPTVNATOR_PERF_WORKER_PROFILING:\s*'1'/); + assert.match(source, /'--js-flags=--expose-gc'/); + assert.doesNotMatch(source, /execArgv/); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts new file mode 100644 index 000000000..dd471db9b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + pairPerformancePhaseEvents, + requirePerformancePhase, +} from './performance-phase-events'; + +describe('performance phase event pairing', () => { + it('pairs request-scoped boundaries and preserves end metadata', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'data.acquire', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12.5, + epochMs: 113, + metadata: { byteCount: 2048, itemCount: 10 }, + phase: 'data.acquire', + requestId: 'request-1', + }, + ]); + + assert.deepEqual(phases, [ + { + durationMs: 12.5, + endedEpochMs: 113, + metadata: { byteCount: 2048, itemCount: 10 }, + phase: 'data.acquire', + requestId: 'request-1', + startedEpochMs: 100, + }, + ]); + assert.deepEqual( + requirePerformancePhase(phases, 'request-1', 'data.acquire'), + phases[0] + ); + }); + + it('pairs interleaved requests without cross-correlating them', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'serialize.playlist', + requestId: 'request-a', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 101, + phase: 'serialize.playlist', + requestId: 'request-b', + }, + { + boundary: 'end', + durationMs: 3, + epochMs: 104, + phase: 'serialize.playlist', + requestId: 'request-b', + }, + { + boundary: 'end', + durationMs: 8, + epochMs: 108, + phase: 'serialize.playlist', + requestId: 'request-a', + }, + ]); + + assert.deepEqual( + phases.map(({ durationMs, requestId }) => ({ + durationMs, + requestId, + })), + [ + { durationMs: 8, requestId: 'request-a' }, + { durationMs: 3, requestId: 'request-b' }, + ] + ); + }); + + it('fails closed for orphaned, duplicate, malformed, or reversed boundaries', () => { + const validStart = { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'normalize', + requestId: 'request-1', + } as const; + const validEnd = { + boundary: 'end', + durationMs: 2, + epochMs: 102, + phase: 'normalize', + requestId: 'request-1', + } as const; + + for (const invalid of [ + [validStart], + [validStart, validStart, validEnd], + [validStart, { ...validEnd, durationMs: null }], + [validStart, { ...validEnd, epochMs: 99 }], + [{ ...validStart, durationMs: 0 }, validEnd], + [ + validStart, + { + ...validEnd, + metadata: { byteCount: -1 }, + }, + ], + ]) { + assert.throws( + () => pairPerformancePhaseEvents(invalid), + /performance-phase-events-invalid/ + ); + } + }); + + it('requires one exact request and phase match', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'normalize', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 2, + epochMs: 102, + phase: 'normalize', + requestId: 'request-1', + }, + ]); + + assert.throws( + () => requirePerformancePhase(phases, 'request-2', 'normalize'), + /performance-phase-missing:request-2:normalize/ + ); + assert.throws( + () => + requirePerformancePhase( + [...phases, ...phases], + 'request-1', + 'normalize' + ), + /performance-phase-ambiguous:request-1:normalize/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts new file mode 100644 index 000000000..15468b018 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts @@ -0,0 +1,166 @@ +import type { PerformancePhaseMetadata } from '@iptvnator/shared/interfaces'; + +export interface PairedPerformancePhase { + readonly durationMs: number; + readonly endedEpochMs: number; + readonly metadata: PerformancePhaseMetadata | null; + readonly phase: string; + readonly requestId: string; + readonly startedEpochMs: number; +} + +interface ParsedPerformancePhaseEvent { + readonly boundary: 'end' | 'start'; + readonly durationMs: number | null; + readonly epochMs: number; + readonly metadata: PerformancePhaseMetadata | null; + readonly phase: string; + readonly requestId: string; +} + +export function pairPerformancePhaseEvents( + input: readonly unknown[] +): readonly PairedPerformancePhase[] { + const grouped = new Map(); + + for (const value of input) { + const event = parsePerformancePhaseEvent(value); + const key = `${event.requestId}\u0000${event.phase}`; + const events = grouped.get(key) ?? []; + events.push(event); + if (events.length > 2) { + throw invalidPhaseEvents(); + } + grouped.set(key, events); + } + + const paired: PairedPerformancePhase[] = []; + for (const events of grouped.values()) { + const start = events.find((event) => event.boundary === 'start'); + const end = events.find((event) => event.boundary === 'end'); + if ( + events.length !== 2 || + !start || + !end || + start.durationMs !== null || + start.metadata !== null || + end.durationMs === null || + end.epochMs < start.epochMs + ) { + throw invalidPhaseEvents(); + } + paired.push( + Object.freeze({ + durationMs: end.durationMs, + endedEpochMs: end.epochMs, + metadata: end.metadata, + phase: start.phase, + requestId: start.requestId, + startedEpochMs: start.epochMs, + }) + ); + } + + paired.sort( + (left, right) => + left.startedEpochMs - right.startedEpochMs || + left.endedEpochMs - right.endedEpochMs || + left.requestId.localeCompare(right.requestId) || + left.phase.localeCompare(right.phase) + ); + return Object.freeze(paired); +} + +export function requirePerformancePhase( + phases: readonly PairedPerformancePhase[], + requestId: string, + phase: string +): PairedPerformancePhase { + const matches = phases.filter( + (candidate) => + candidate.requestId === requestId && candidate.phase === phase + ); + if (matches.length === 0) { + throw new Error(`performance-phase-missing:${requestId}:${phase}`); + } + if (matches.length !== 1 || !matches[0]) { + throw new Error(`performance-phase-ambiguous:${requestId}:${phase}`); + } + return matches[0]; +} + +function parsePerformancePhaseEvent( + value: unknown +): ParsedPerformancePhaseEvent { + if (!isRecord(value)) { + throw invalidPhaseEvents(); + } + const boundary = value['boundary']; + const durationMs = value['durationMs']; + const epochMs = value['epochMs']; + const phase = value['phase']; + const requestId = value['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + (durationMs !== null && !isFiniteNonNegativeNumber(durationMs)) || + !isFiniteNonNegativeNumber(epochMs) || + typeof phase !== 'string' || + phase.length === 0 || + typeof requestId !== 'string' || + requestId.length === 0 + ) { + throw invalidPhaseEvents(); + } + + return { + boundary, + durationMs, + epochMs, + metadata: parseMetadata(value['metadata']), + phase, + requestId, + }; +} + +function parseMetadata(value: unknown): PerformancePhaseMetadata | null { + if (value === undefined) { + return null; + } + if (!isRecord(value)) { + throw invalidPhaseEvents(); + } + const keys = Object.keys(value); + if ( + keys.some((key) => key !== 'byteCount' && key !== 'itemCount') || + keys.some((key) => !isSafeCount(value[key])) + ) { + throw invalidPhaseEvents(); + } + return Object.freeze({ + ...(value['byteCount'] === undefined + ? {} + : { byteCount: Number(value['byteCount']) }), + ...(value['itemCount'] === undefined + ? {} + : { itemCount: Number(value['itemCount']) }), + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isSafeCount(value: unknown): boolean { + return ( + value === undefined || + (Number.isSafeInteger(value) && Number(value) >= 0) + ); +} + +function invalidPhaseEvents(): Error { + return new Error('performance-phase-events-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts new file mode 100644 index 000000000..10c078015 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + pairRendererPerformancePhaseEvents, + requireRendererPerformancePhase, +} from './renderer-performance-phase-events'; + +describe('renderer performance phase event pairing', () => { + it('pairs a successful renderer phase using monotonic duration', () => { + const phases = pairRendererPerformancePhaseEvents([ + { + boundary: 'start', + epochMs: 100, + metadata: { items: 10_000 }, + monotonicMs: 20, + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + schemaVersion: 1, + }, + { + boundary: 'end', + epochMs: 106, + metadata: { items: 10_000 }, + monotonicMs: 25.5, + outcome: 'success', + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + schemaVersion: 1, + }, + ]); + + assert.deepEqual(phases, [ + { + durationMs: 5.5, + endedEpochMs: 106, + metadata: { items: 10_000 }, + outcome: 'success', + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + startedEpochMs: 100, + }, + ]); + assert.deepEqual( + requireRendererPerformancePhase( + phases, + 'store.m3u-publish-channels' + ), + phases[0] + ); + }); + + it('fails closed for inconsistent identity, metadata, outcome, and clocks', () => { + const start = { + boundary: 'start', + epochMs: 100, + metadata: { items: 50_000 }, + monotonicMs: 20, + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + schemaVersion: 1, + } as const; + const end = { + boundary: 'end', + epochMs: 102, + metadata: { items: 50_000 }, + monotonicMs: 22, + outcome: 'success', + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + schemaVersion: 1, + } as const; + + for (const invalid of [ + [start], + [start, start, end], + [start, { ...end, phase: 'store.m3u-publish-channels' }], + [start, { ...end, metadata: { items: 10_000 } }], + [{ ...start, outcome: 'success' }, end], + [start, { ...end, outcome: undefined }], + [start, { ...end, epochMs: 99 }], + [start, { ...end, monotonicMs: 19 }], + [start, { ...end, metadata: { items: -1 } }], + ]) { + assert.throws( + () => pairRendererPerformancePhaseEvents(invalid), + /renderer-performance-phase-events-invalid/ + ); + } + }); + + it('requires exactly one successful event for a named phase', () => { + const successful = { + durationMs: 2, + endedEpochMs: 102, + metadata: { items: 10_000 }, + outcome: 'success', + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + startedEpochMs: 100, + } as const; + const failed = { + ...successful, + outcome: 'error', + phaseId: 'phase-2', + } as const; + + assert.throws( + () => + requireRendererPerformancePhase( + [successful], + 'store.m3u-publish-channels' + ), + /renderer-performance-phase-missing:store.m3u-publish-channels/ + ); + assert.throws( + () => + requireRendererPerformancePhase( + [successful, successful], + 'store.m3u-import-dispatch' + ), + /renderer-performance-phase-ambiguous:store.m3u-import-dispatch/ + ); + assert.throws( + () => + requireRendererPerformancePhase( + [failed], + 'store.m3u-import-dispatch' + ), + /renderer-performance-phase-failed:store.m3u-import-dispatch/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts new file mode 100644 index 000000000..23cb976e8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts @@ -0,0 +1,163 @@ +export interface PairedRendererPerformancePhase { + readonly durationMs: number; + readonly endedEpochMs: number; + readonly metadata: Readonly<{ items: number }> | null; + readonly outcome: 'error' | 'success'; + readonly phase: string; + readonly phaseId: string; + readonly startedEpochMs: number; +} + +interface ParsedRendererPerformancePhaseEvent { + readonly boundary: 'end' | 'start'; + readonly epochMs: number; + readonly metadata: Readonly<{ items: number }> | null; + readonly monotonicMs: number; + readonly outcome: 'error' | 'success' | null; + readonly phase: string; + readonly phaseId: string; +} + +export function pairRendererPerformancePhaseEvents( + input: readonly unknown[] +): readonly PairedRendererPerformancePhase[] { + const grouped = new Map(); + + for (const value of input) { + const event = parseRendererPerformancePhaseEvent(value); + const events = grouped.get(event.phaseId) ?? []; + events.push(event); + if (events.length > 2) { + throw invalidRendererEvents(); + } + grouped.set(event.phaseId, events); + } + + const paired: PairedRendererPerformancePhase[] = []; + for (const events of grouped.values()) { + const start = events.find((event) => event.boundary === 'start'); + const end = events.find((event) => event.boundary === 'end'); + if ( + events.length !== 2 || + !start || + !end || + start.outcome !== null || + end.outcome === null || + start.phase !== end.phase || + !sameMetadata(start.metadata, end.metadata) || + end.epochMs < start.epochMs || + end.monotonicMs < start.monotonicMs + ) { + throw invalidRendererEvents(); + } + paired.push( + Object.freeze({ + durationMs: end.monotonicMs - start.monotonicMs, + endedEpochMs: end.epochMs, + metadata: end.metadata, + outcome: end.outcome, + phase: end.phase, + phaseId: end.phaseId, + startedEpochMs: start.epochMs, + }) + ); + } + + paired.sort( + (left, right) => + left.startedEpochMs - right.startedEpochMs || + left.endedEpochMs - right.endedEpochMs || + left.phaseId.localeCompare(right.phaseId) + ); + return Object.freeze(paired); +} + +export function requireRendererPerformancePhase( + phases: readonly PairedRendererPerformancePhase[], + phase: string +): PairedRendererPerformancePhase { + const matches = phases.filter((candidate) => candidate.phase === phase); + if (matches.length === 0) { + throw new Error(`renderer-performance-phase-missing:${phase}`); + } + if (matches.length !== 1 || !matches[0]) { + throw new Error(`renderer-performance-phase-ambiguous:${phase}`); + } + if (matches[0].outcome !== 'success') { + throw new Error(`renderer-performance-phase-failed:${phase}`); + } + return matches[0]; +} + +function parseRendererPerformancePhaseEvent( + value: unknown +): ParsedRendererPerformancePhaseEvent { + if (!isRecord(value) || value['schemaVersion'] !== 1) { + throw invalidRendererEvents(); + } + const boundary = value['boundary']; + const epochMs = value['epochMs']; + const monotonicMs = value['monotonicMs']; + const outcome = value['outcome']; + const phase = value['phase']; + const phaseId = value['phaseId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + !isFiniteNonNegativeNumber(epochMs) || + !isFiniteNonNegativeNumber(monotonicMs) || + typeof phase !== 'string' || + phase.length === 0 || + typeof phaseId !== 'string' || + phaseId.length === 0 || + (boundary === 'start' && outcome !== undefined) || + (boundary === 'end' && outcome !== 'success' && outcome !== 'error') + ) { + throw invalidRendererEvents(); + } + + return { + boundary, + epochMs, + metadata: parseMetadata(value['metadata']), + monotonicMs, + outcome: outcome === 'success' || outcome === 'error' ? outcome : null, + phase, + phaseId, + }; +} + +function parseMetadata(value: unknown): Readonly<{ items: number }> | null { + if (value === undefined) { + return null; + } + if ( + !isRecord(value) || + Object.keys(value).length !== 1 || + !Number.isSafeInteger(value['items']) || + Number(value['items']) < 0 + ) { + throw invalidRendererEvents(); + } + return Object.freeze({ items: Number(value['items']) }); +} + +function sameMetadata( + left: Readonly<{ items: number }> | null, + right: Readonly<{ items: number }> | null +): boolean { + return left === null || right === null + ? left === right + : left.items === right.items; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function invalidRendererEvents(): Error { + return new Error('renderer-performance-phase-events-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts new file mode 100644 index 000000000..2e94cee01 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts @@ -0,0 +1,172 @@ +/* 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 TestIteration { + readonly kind: 'diagnostic' | 'measured' | 'warmup'; + readonly main: { + readonly rendererWindow: { + readonly rss: { + readonly identity: { + readonly creationTime: number; + readonly pid: number; + } | null; + readonly missingSampleCount: number; + readonly peakRssBytes: number | null; + readonly unavailableReason: string | null; + readonly validSampleCount: number; + }; + }; + }; + readonly runId: string; +} + +interface RendererRssValidity { + readonly invalidMeasuredRuns: readonly { + readonly missingSampleCount: number; + readonly reason: string; + readonly runId: string; + readonly validSampleCount: number; + }[]; + readonly measuredRunCount: number; + readonly validForBenchmark: boolean; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +interface RendererRssValidityModule { + assessRendererRssValidity?: ( + iterations: readonly TestIteration[] + ) => RendererRssValidity; +} + +const validityModulePromise = import( + new URL('./renderer-rss-validity.ts', import.meta.url).href +) + .then((module) => module as RendererRssValidityModule) + .catch(() => null); + +function iteration( + runId: string, + kind: TestIteration['kind'], + input: Partial = {} +): TestIteration { + return { + kind, + main: { + rendererWindow: { + rss: { + identity: { + creationTime: 1_721_234_567_890, + pid: 42, + }, + missingSampleCount: 0, + peakRssBytes: 4_096, + unavailableReason: null, + validSampleCount: 3, + ...input, + }, + }, + }, + runId, + }; +} + +test('validates only measured renderer RSS runs and preserves the exact summary shape', async () => { + const module = await validityModulePromise; + assert.ok(module, 'generic renderer RSS validity helper must exist'); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + const result = assess([ + iteration('warmup-invalid', 'warmup', { identity: null }), + iteration('run-01', 'measured'), + iteration('diagnostic-invalid', 'diagnostic', { + unavailableReason: 'renderer-process-gone', + }), + ]); + + assert.deepEqual(result, { + invalidMeasuredRuns: [], + measuredRunCount: 1, + validForBenchmark: true, + validForComparison: true, + validMeasuredRunCount: 1, + }); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.invalidMeasuredRuns)); +}); + +test('fails every incomplete measured capture without losing its raw reason and counts', async () => { + const module = await validityModulePromise; + assert.ok(module); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + const result = assess([ + iteration('run-no-identity', 'measured', { identity: null }), + iteration('run-missing-sample', 'measured', { + missingSampleCount: 1, + peakRssBytes: null, + unavailableReason: 'renderer-process-metric-missing-during-capture', + validSampleCount: 2, + }), + iteration('run-zero-peak', 'measured', { peakRssBytes: 0 }), + ]); + + assert.deepEqual(result, { + invalidMeasuredRuns: [ + { + missingSampleCount: 0, + reason: 'renderer-rss-capture-invalid', + runId: 'run-no-identity', + validSampleCount: 3, + }, + { + missingSampleCount: 1, + reason: 'renderer-process-metric-missing-during-capture', + runId: 'run-missing-sample', + validSampleCount: 2, + }, + { + missingSampleCount: 0, + reason: 'renderer-rss-capture-invalid', + runId: 'run-zero-peak', + validSampleCount: 3, + }, + ], + measuredRunCount: 3, + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); +}); + +test('requires at least one measured run', async () => { + const module = await validityModulePromise; + assert.ok(module); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + assert.deepEqual(assess([iteration('warmup-01', 'warmup')]), { + invalidMeasuredRuns: [], + measuredRunCount: 0, + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); +}); + +test('the cancellation report delegates renderer RSS validity to the generic helper', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation-report.ts', import.meta.url), + 'utf8' + ); + + assert.match( + source, + /import \{ assessRendererRssValidity \} from '\.\/renderer-rss-validity';/ + ); + assert.doesNotMatch(source, /function assessRendererRssValidity\(/); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts new file mode 100644 index 000000000..ccab61639 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts @@ -0,0 +1,72 @@ +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; + +export interface RendererRssValidityIteration { + readonly kind: string; + readonly main: { + readonly rendererWindow: { + readonly rss: RendererProcessRssCapture; + }; + }; + readonly runId: string; +} + +export interface InvalidRendererRssMeasuredRun { + readonly missingSampleCount: number; + readonly reason: + | Exclude + | '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 function assessRendererRssValidity( + iterations: readonly RendererRssValidityIteration[] +): RendererRssValidity { + const measured = iterations.filter( + (iteration) => 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, + }); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts new file mode 100644 index 000000000..2d6e87a49 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts @@ -0,0 +1,49 @@ +/* eslint-disable playwright/expect-expect -- Node assertions define the loopback-only fixture server contract. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createSyntheticM3uFixture } from './synthetic-m3u'; +import { startSyntheticM3uServer } from './synthetic-m3u-server'; + +test('serves and primes only the deterministic fixture on ephemeral IPv4 loopback', async () => { + const fixture = createSyntheticM3uFixture(10_000); + const server = await startSyntheticM3uServer(fixture); + + try { + const url = new URL(server.resourceUrl); + assert.equal(url.protocol, 'http:'); + assert.equal(url.hostname, '127.0.0.1'); + assert.notEqual(url.port, ''); + assert.equal(url.username, ''); + assert.equal(url.password, ''); + assert.equal(server.requestCount(), 0); + + await server.prime(); + assert.equal(server.requestCount(), 1); + + const response = await fetch(server.resourceUrl); + assert.equal(response.status, 200); + assert.equal( + response.headers.get('content-length'), + String(fixture.bytes) + ); + assert.equal(await response.text(), fixture.body); + assert.equal(server.requestCount(), 2); + + const missing = await fetch(`${server.origin}/not-the-fixture.m3u`); + assert.equal(missing.status, 404); + assert.equal(server.requestCount(), 2); + } finally { + await server.close(); + } +}); + +test('closes idempotently and rejects priming after close', async () => { + const server = await startSyntheticM3uServer( + createSyntheticM3uFixture(10_000) + ); + + await server.close(); + await server.close(); + await assert.rejects(server.prime(), /closed/i); +}); diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts new file mode 100644 index 000000000..269c27f45 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts @@ -0,0 +1,114 @@ +import { once } from 'node:events'; +import { createServer, type Server } from 'node:http'; + +import type { SyntheticM3uFixture } from './synthetic-m3u'; + +export const SYNTHETIC_M3U_RESOURCE_PATH = '/synthetic-performance.m3u'; + +export interface SyntheticM3uServer { + readonly origin: string; + readonly resourceUrl: string; + close(): Promise; + prime(): Promise; + requestCount(): number; +} + +export async function startSyntheticM3uServer( + fixture: SyntheticM3uFixture +): Promise { + let successfulRequestCount = 0; + const server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + if ( + request.method !== 'GET' || + requestUrl.pathname !== SYNTHETIC_M3U_RESOURCE_PATH || + requestUrl.search.length > 0 + ) { + response.writeHead(404, { + 'Content-Length': '0', + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end(); + return; + } + + successfulRequestCount += 1; + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Length': String(fixture.bytes), + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + }); + response.end(fixture.body); + }); + server.listen({ + exclusive: true, + host: '127.0.0.1', + port: 0, + }); + try { + await once(server, 'listening'); + } catch (error: unknown) { + await closeServer(server).catch(() => undefined); + throw error; + } + + const address = server.address(); + if ( + address === null || + typeof address === 'string' || + address.address !== '127.0.0.1' || + !Number.isSafeInteger(address.port) || + address.port <= 0 + ) { + await closeServer(server); + throw new Error( + 'Synthetic M3U server did not bind exact IPv4 loopback' + ); + } + + const origin = `http://127.0.0.1:${address.port}`; + const resourceUrl = `${origin}${SYNTHETIC_M3U_RESOURCE_PATH}`; + let closePromise: Promise | null = null; + + return Object.freeze({ + origin, + resourceUrl, + close: () => { + closePromise ??= closeServer(server); + return closePromise; + }, + prime: async () => { + if (closePromise !== null) { + throw new Error('Synthetic M3U server is closed'); + } + const response = await fetch(resourceUrl); + const body = await response.arrayBuffer(); + if ( + response.status !== 200 || + Number(response.headers.get('content-length')) !== + fixture.bytes || + body.byteLength !== fixture.bytes + ) { + throw new Error( + 'Synthetic M3U server priming response was invalid' + ); + } + }, + requestCount: () => successfulRequestCount, + }); +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts new file mode 100644 index 000000000..094f4f80d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { describe, it } from 'node:test'; + +import { + createSyntheticM3uFixture, + SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS, + SYNTHETIC_M3U_SEED, +} from './synthetic-m3u'; + +const GOLDEN_SHA256 = { + 10_000: '1d151ab2a878db8818f2bbfbdd689007cbe390182cdd1520c316bd163b7e7db3', + 50_000: '4a5336a5d1c66b8b706752784815617a012499cb7a231ab35c88df9bd55ee78a', + 100_000: '2ffe912f61ea106ba8de3414b0a927cdaa3264ac637d44c4b219f199eb94c353', +} as const; + +describe('synthetic M3U performance fixture', () => { + it('generates each supported catalog deterministically with exact metadata', () => { + assert.deepEqual( + SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS, + [10_000, 50_000, 100_000] + ); + assert.equal(SYNTHETIC_M3U_SEED, 240_724); + + for (const channelCount of SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS) { + const fixture = createSyntheticM3uFixture(channelCount); + const lines = fixture.body.trimEnd().split('\n'); + + assert.equal(fixture.channelCount, channelCount); + assert.equal( + fixture.bytes, + Buffer.byteLength(fixture.body, 'utf8') + ); + assert.equal( + fixture.sha256, + createHash('sha256').update(fixture.body, 'utf8').digest('hex') + ); + assert.equal(fixture.sha256, GOLDEN_SHA256[channelCount]); + assert.equal(lines[0], '#EXTM3U'); + assert.equal(lines.length, 1 + channelCount * 2); + assert.equal( + lines.filter((line) => line.startsWith('#EXTINF:')).length, + channelCount + ); + assert.doesNotMatch(fixture.body, /\b(?:tvg-|logo|provider)\b/i); + assertOnlyLoopbackUrls(fixture.body); + } + }); + + it('rejects unsupported channel counts', () => { + assert.throws( + () => createSyntheticM3uFixture(9_999), + /Unsupported synthetic M3U channel count/ + ); + }); +}); + +function assertOnlyLoopbackUrls(body: string): void { + const urls = body.match(/https?:\/\/[^\s"]+/g) ?? []; + + for (const rawUrl of urls) { + const url = new URL(rawUrl); + assert.equal(url.protocol, 'http:'); + assert.equal(url.hostname, '127.0.0.1'); + assert.equal(url.username, ''); + assert.equal(url.password, ''); + } +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts index 14e65387d..1cdf8d18b 100644 --- a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts @@ -2,19 +2,30 @@ import { createHash } from 'node:crypto'; export const SYNTHETIC_M3U_SEED = 240_724; export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000; +export const SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS = [ + 10_000, + 50_000, + SYNTHETIC_M3U_CHANNEL_COUNT, +] as const; + +export type SyntheticM3uChannelCount = + (typeof SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS)[number]; export interface SyntheticM3uFixture { readonly body: string; readonly bytes: number; - readonly channelCount: number; + readonly channelCount: SyntheticM3uChannelCount; readonly sha256: string; } -export function createSyntheticM3uFixture(): SyntheticM3uFixture { - const lines = new Array(1 + SYNTHETIC_M3U_CHANNEL_COUNT * 2); +export function createSyntheticM3uFixture( + channelCount: number = SYNTHETIC_M3U_CHANNEL_COUNT +): SyntheticM3uFixture { + assertSupportedChannelCount(channelCount); + const lines = new Array(1 + channelCount * 2); lines[0] = '#EXTM3U'; - for (let offset = 0; offset < SYNTHETIC_M3U_CHANNEL_COUNT; offset += 1) { + for (let offset = 0; offset < channelCount; offset += 1) { const index = offset + 1; const group = (offset % 100) + 1; const lineOffset = 1 + offset * 2; @@ -34,7 +45,21 @@ export function createSyntheticM3uFixture(): SyntheticM3uFixture { return Object.freeze({ body, bytes: Buffer.byteLength(body, 'utf8'), - channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + channelCount, sha256: createHash('sha256').update(body, 'utf8').digest('hex'), }); } + +function assertSupportedChannelCount( + channelCount: number +): asserts channelCount is SyntheticM3uChannelCount { + if ( + !SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS.some( + (supported) => supported === channelCount + ) + ) { + throw new Error( + `Unsupported synthetic M3U channel count: ${channelCount}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts new file mode 100644 index 000000000..1423da627 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts @@ -0,0 +1,149 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +const PHASE_SEQUENCES = { + DB_GET_APP_PLAYLIST: [ + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + ], + DB_UPSERT_APP_PLAYLIST: [ + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + ], +} as const satisfies Readonly< + Record +>; +const EVENT_KEYS = new Set([ + 'boundary', + 'durationMs', + 'epochMs', + 'metadata', + 'phase', + 'requestId', +]); + +export function parseWorkerPerformancePhaseEvents( + operation: string | null, + input: unknown, + workStartedEpochMs: number, + workEndedEpochMs: number +): readonly PerformancePhaseEvent[] | null { + const phaseSequence = getPhaseSequence(operation); + if (phaseSequence.length === 0) { + return input === undefined || + (Array.isArray(input) && input.length === 0) + ? [] + : null; + } + if ( + !Array.isArray(input) || + input.length !== phaseSequence.length * 2 + ) { + return null; + } + + const parsed: PerformancePhaseEvent[] = + []; + let lastEpochMs = workStartedEpochMs; + for (let index = 0; index < input.length; index += 1) { + const raw = input[index]; + if ( + !isRecord(raw) || + Object.keys(raw).some((key) => !EVENT_KEYS.has(key)) + ) { + return null; + } + + const boundary = raw['boundary']; + const durationMs = raw['durationMs']; + const epochMs = raw['epochMs']; + const metadata = parseMetadata(raw['metadata']); + const phase = raw['phase']; + const requestId = raw['requestId']; + if ( + boundary !== (index % 2 === 0 ? 'start' : 'end') || + phase !== phaseSequence[Math.floor(index / 2)] || + typeof requestId !== 'string' || + requestId.length === 0 || + !isFiniteNonNegativeNumber(epochMs) || + epochMs < lastEpochMs || + epochMs > workEndedEpochMs || + (boundary === 'start' && + (durationMs !== null || metadata !== undefined)) || + (boundary === 'end' && !isFiniteNonNegativeNumber(durationMs)) || + metadata === null || + (parsed.length > 0 && parsed[0]?.requestId !== requestId) + ) { + return null; + } + + parsed.push({ + boundary: boundary as 'end' | 'start', + durationMs: durationMs as number | null, + epochMs, + ...(metadata === undefined ? {} : { metadata }), + phase: phase as AppPlaylistPerformancePhase, + requestId, + }); + lastEpochMs = epochMs; + } + return parsed; +} + +function getPhaseSequence( + operation: string | null +): readonly AppPlaylistPerformancePhase[] { + if (operation === 'DB_GET_APP_PLAYLIST') { + return PHASE_SEQUENCES.DB_GET_APP_PLAYLIST; + } + if (operation === 'DB_UPSERT_APP_PLAYLIST') { + return PHASE_SEQUENCES.DB_UPSERT_APP_PLAYLIST; + } + return []; +} + +function parseMetadata( + input: unknown +): PerformancePhaseMetadata | undefined | null { + if (input === undefined) { + return undefined; + } + if ( + !isRecord(input) || + Object.keys(input).some( + (key) => key !== 'byteCount' && key !== 'itemCount' + ) + ) { + return null; + } + + const byteCount = input['byteCount']; + const itemCount = input['itemCount']; + if ( + (byteCount !== undefined && !isSafeCount(byteCount)) || + (itemCount !== undefined && !isSafeCount(itemCount)) + ) { + return null; + } + return { + ...(byteCount === undefined ? {} : { byteCount: Number(byteCount) }), + ...(itemCount === undefined ? {} : { itemCount: Number(itemCount) }), + }; +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isSafeCount(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts index e87e3c345..59bc4ecc6 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts @@ -13,6 +13,7 @@ const validCapture = { histogramFlushedEpochMs: 1_050, invalidReason: null, requestReceivedEpochMs: 1_000, + responsePostedEpochMs: 1_055, threadCpuSystemMicros: 50, threadCpuUnavailableReason: null, threadCpuUserMicros: 100, @@ -20,19 +21,28 @@ const validCapture = { workStartedEpochMs: 1_010, } as const; -function normalize(performanceCapture: unknown) { +function normalizeForOperation( + operation: string, + performanceCapture: unknown +) { return normalizeWorkerRequestPerformanceOutcome({ - operation: 'DB_GET_APP_PLAYLIST', + ipcCallId: 2, + operation, operationId: 'operation-42', operationIdUnavailableReason: null, performanceCapture, playlistId: 'playlist-1', requestId: 'request-1', responseEpochMs: 1_060, + sourceEpochMs: 990, success: true, }); } +function normalize(performanceCapture: unknown) { + return normalizeForOperation('DB_GET_CONTENT', performanceCapture); +} + function assertClosedCapture( capture: WorkerRequestPerformanceMetrics, reason: string @@ -47,6 +57,7 @@ function assertClosedCapture( capture.histogramFlushedEpochMs, capture.invalidReason, capture.requestReceivedEpochMs, + capture.responsePostedEpochMs, capture.threadCpuSystemMicros, capture.threadCpuUnavailableReason, capture.threadCpuUserMicros, @@ -55,6 +66,7 @@ function assertClosedCapture( ]) { assert.equal(value, null); } + assert.deepEqual(capture.phaseEvents, []); } test('absent captures are missing while present non-record captures are invalid', () => { @@ -118,6 +130,9 @@ test('malformed nested fields and cross-field invariants fail the whole capture histogramFlushedEpochMs: null, }, { ...validCapture, histogramFlushedEpochMs: 1_039 }, + { ...validCapture, responsePostedEpochMs: undefined }, + { ...validCapture, responsePostedEpochMs: 1_039 }, + { ...validCapture, responsePostedEpochMs: 1_061 }, { ...validCapture, workStartedEpochMs: 999 }, { ...validCapture, @@ -170,3 +185,143 @@ test('coherent metric-unavailable and invalid worker results remain valid raw ca assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null); assert.equal(legitimateInvalid.invalidReason, invalidReason); }); + +test('requires the exact operation-specific worker phase sequence', () => { + const upsertPhaseEvents = [ + { + boundary: 'start', + durationMs: null, + epochMs: 1_015, + phase: 'serialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 5, + epochMs: 1_020, + metadata: { byteCount: 2048, itemCount: 10_000 }, + phase: 'serialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 1_021, + phase: 'sqlite.write', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12, + epochMs: 1_033, + metadata: { byteCount: 2048, itemCount: 1 }, + phase: 'sqlite.write', + requestId: 'request-1', + }, + ] as const; + const getPhaseEvents = [ + { + boundary: 'start', + durationMs: null, + epochMs: 1_015, + phase: 'sqlite.read', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 5, + epochMs: 1_020, + metadata: { itemCount: 1 }, + phase: 'sqlite.read', + requestId: 'request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 1_021, + phase: 'deserialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12, + epochMs: 1_033, + metadata: { itemCount: 10_000 }, + phase: 'deserialize.playlist', + requestId: 'request-1', + }, + ] as const; + + assert.deepEqual( + normalizeForOperation('DB_UPSERT_APP_PLAYLIST', { + ...validCapture, + phaseEvents: upsertPhaseEvents, + }).phaseEvents, + upsertPhaseEvents + ); + assert.deepEqual( + normalizeForOperation('DB_GET_APP_PLAYLIST', { + ...validCapture, + phaseEvents: getPhaseEvents, + }).phaseEvents, + getPhaseEvents + ); + + for (const invalidPhaseEvents of [ + getPhaseEvents.slice(0, 2), + getPhaseEvents.slice(1), + [...getPhaseEvents].reverse(), + getPhaseEvents.map((event) => ({ + ...event, + requestId: 'other-request', + })), + [ + getPhaseEvents[0], + { + ...getPhaseEvents[1], + metadata: { itemCount: 1, secret: 'not allowed' }, + }, + ], + [ + getPhaseEvents[0], + { + ...getPhaseEvents[1], + phase: 'arbitrary.phase', + }, + ], + ]) { + assertClosedCapture( + normalizeForOperation('DB_GET_APP_PLAYLIST', { + ...validCapture, + phaseEvents: invalidPhaseEvents, + }), + 'worker-performance-capture-invalid' + ); + } + + for (const [operation, phaseEvents] of [ + ['DB_GET_APP_PLAYLIST', upsertPhaseEvents], + ['DB_UPSERT_APP_PLAYLIST', getPhaseEvents], + ['DB_GET_CONTENT', getPhaseEvents], + ] as const) { + assertClosedCapture( + normalizeForOperation(operation, { + ...validCapture, + phaseEvents, + }), + 'worker-performance-capture-invalid' + ); + } + + for (const operation of [ + 'DB_GET_APP_PLAYLIST', + 'DB_UPSERT_APP_PLAYLIST', + ]) { + assertClosedCapture( + normalizeForOperation(operation, validCapture), + 'worker-performance-capture-invalid' + ); + } + + assert.deepEqual(normalize(validCapture).phaseEvents, []); +}); 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 c96ec7f03..e5149e442 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 @@ -35,9 +35,10 @@ function requestPerformance( eventLoopDelayUnavailableReason: null, eventLoopUtilization: p95Ms / 100, eventLoopUtilizationUnavailableReason: null, - histogramFlushedEpochMs: 1_050 + p95Ms, + histogramFlushedEpochMs: 1_050, invalidReason: null, requestReceivedEpochMs: 1_000, + responsePostedEpochMs: 1_055, threadCpuSystemMicros: threadCpuUserMicros / 2, threadCpuUnavailableReason: null, threadCpuUserMicros, @@ -53,13 +54,15 @@ function requestTransport( requestId = 'request-1' ) { return { - operation: 'DB_GET_APP_PLAYLIST', + ipcCallId: 7, + operation: 'DB_GET_CONTENT', operationId: 'operation-42', operationIdUnavailableReason: null, performanceCapture, playlistId: 'playlist-1', requestId, responseEpochMs: 1_060, + sourceEpochMs: 990, success: true, } as const; } @@ -77,6 +80,7 @@ function assertClosedCapture( capture.histogramFlushedEpochMs, capture.invalidReason, capture.requestReceivedEpochMs, + capture.responsePostedEpochMs, capture.threadCpuSystemMicros, capture.threadCpuUnavailableReason, capture.threadCpuUserMicros, @@ -220,8 +224,10 @@ test('main capture retains raw request identity, timestamps, metrics, and reason assert.match(source, /requestPerformance: \[\]/); assert.match(source, /record\.requestPerformance\.map/); assert.match(source, /operationId: request\.identity\.operationId/); + assert.match(source, /ipcCallId: request\.identity\.ipcCallId/); assert.match(source, /identity: request\.identity/); assert.match(source, /performanceCapture:\s+message\['performance'\]/); + assert.match(source, /sourceEpochMs: request\.identity\.sourceEpochMs/); assert.match(source, /captureGeneration: state\.captureGeneration/); assert.doesNotMatch( source, @@ -229,6 +235,13 @@ test('main capture retains raw request identity, timestamps, metrics, and reason ); }); +test('normalized raw request metrics retain the exact preload IPC call identity', () => { + const capture = requestPerformance('request-1', 5, 100); + + assert.equal(capture.ipcCallId, 7); + assert.equal(capture.sourceEpochMs, 990); +}); + 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), 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 d348cd71c..4a5cc00ac 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -6,8 +6,10 @@ import type { WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { WORKER_POST_GC_HEAP_UNAVAILABLE_REASON } from './m3u-refresh-cancellation-contract'; +import { parseWorkerPerformancePhaseEvents } from './worker-performance-phase-events'; export interface WorkerRequestPerformanceOutcomeTransport { + readonly ipcCallId: number | null; readonly operation: string | null; readonly operationId: string | null; readonly operationIdUnavailableReason: string | null; @@ -15,6 +17,7 @@ export interface WorkerRequestPerformanceOutcomeTransport { readonly playlistId: string | null; readonly requestId: string | null; readonly responseEpochMs: number; + readonly sourceEpochMs: number | null; readonly success: boolean; } @@ -39,7 +42,9 @@ type ParsedWorkerPerformanceCapture = Pick< | 'eventLoopUtilizationUnavailableReason' | 'histogramFlushedEpochMs' | 'invalidReason' + | 'phaseEvents' | 'requestReceivedEpochMs' + | 'responsePostedEpochMs' | 'threadCpuSystemMicros' | 'threadCpuUnavailableReason' | 'threadCpuUserMicros' @@ -146,7 +151,8 @@ function parseEventLoopDelay( } function parseWorkerPerformanceCapture( - input: unknown + input: unknown, + operation: string | null ): ParsedWorkerPerformanceCapture | null { if (!isRecord(input)) { return null; @@ -161,6 +167,7 @@ function parseWorkerPerformanceCapture( const histogramFlushedEpochMs = input['histogramFlushedEpochMs']; const invalidReason = input['invalidReason']; const requestReceivedEpochMs = input['requestReceivedEpochMs']; + const responsePostedEpochMs = input['responsePostedEpochMs']; const threadCpuSystemMicros = input['threadCpuSystemMicros']; const threadCpuUnavailableReason = input['threadCpuUnavailableReason']; const threadCpuUserMicros = input['threadCpuUserMicros']; @@ -182,6 +189,7 @@ function parseWorkerPerformanceCapture( !isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) || !isNullableReason(invalidReason, INVALID_REASONS) || !isFiniteNonNegativeNumber(requestReceivedEpochMs) || + !isFiniteNonNegativeNumber(responsePostedEpochMs) || !isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) || !isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) || !isNullableFiniteNonNegativeNumber(threadCpuUserMicros) || @@ -191,11 +199,23 @@ function parseWorkerPerformanceCapture( return null; } + const phaseEvents = parseWorkerPerformancePhaseEvents( + operation, + input['phaseEvents'], + workStartedEpochMs, + workEndedEpochMs + ); + if (phaseEvents === null) { + return null; + } + const timestampsAreOrdered = requestReceivedEpochMs <= workStartedEpochMs && workStartedEpochMs <= workEndedEpochMs && + workEndedEpochMs <= responsePostedEpochMs && (histogramFlushedEpochMs === null || - workEndedEpochMs <= histogramFlushedEpochMs); + (workEndedEpochMs <= histogramFlushedEpochMs && + histogramFlushedEpochMs <= responsePostedEpochMs)); const eventLoopDelayIsCoherent = eventLoopDelay === null ? eventLoopDelayUnavailableReason !== null && @@ -241,7 +261,9 @@ function parseWorkerPerformanceCapture( eventLoopUtilizationUnavailableReason, histogramFlushedEpochMs, invalidReason, + phaseEvents, requestReceivedEpochMs, + responsePostedEpochMs, threadCpuSystemMicros, threadCpuUnavailableReason, threadCpuUserMicros, @@ -254,12 +276,14 @@ export function normalizeWorkerRequestPerformanceOutcome( input: WorkerRequestPerformanceOutcomeTransport ): WorkerRequestPerformanceMetrics { const identity = { + ipcCallId: input.ipcCallId, operation: input.operation, operationId: input.operationId, operationIdUnavailableReason: input.operationIdUnavailableReason, playlistId: input.playlistId, requestId: input.requestId, responseEpochMs: input.responseEpochMs, + sourceEpochMs: input.sourceEpochMs, success: input.success, }; const unavailable = ( @@ -275,7 +299,9 @@ export function normalizeWorkerRequestPerformanceOutcome( invalidReason: null, ...identity, performanceCaptureUnavailableReason: reason, + phaseEvents: [], requestReceivedEpochMs: null, + responsePostedEpochMs: null, threadCpuSystemMicros: null, threadCpuUnavailableReason: null, threadCpuUserMicros: null, @@ -291,11 +317,27 @@ export function normalizeWorkerRequestPerformanceOutcome( } const performanceCapture = parseWorkerPerformanceCapture( - input.performanceCapture + input.performanceCapture, + input.operation ); if (!performanceCapture) { return unavailable('worker-performance-capture-invalid'); } + if ( + !isFiniteNonNegativeNumber(input.responseEpochMs) || + performanceCapture.responsePostedEpochMs > input.responseEpochMs + ) { + return unavailable('worker-performance-capture-invalid'); + } + if ( + performanceCapture.phaseEvents.length > 0 && + (input.requestId === null || + performanceCapture.phaseEvents.some( + (event) => event.requestId !== input.requestId + )) + ) { + return unavailable('worker-performance-capture-invalid'); + } return { ...performanceCapture, diff --git a/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts new file mode 100644 index 000000000..d4b6d49b8 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts @@ -0,0 +1,123 @@ +import type { + AppPlaylistGetPerformancePhase, + PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, +} from '@iptvnator/shared/interfaces'; + +import type { AppDatabase } from '../database.types'; +import { getAppPlaylist } from './playlist.operations'; + +type TestGetPhaseCapture = { + captureAsync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +}; + +function createDatabaseMock(rows: unknown[]) { + const limit = jest.fn().mockResolvedValue(rows); + const where = jest.fn(() => ({ limit })); + const from = jest.fn(() => ({ where })); + const select = jest.fn(() => ({ from })); + + return { + db: { select } as unknown as AppDatabase, + from, + limit, + select, + where, + }; +} + +const playlistRow = { + id: 'synthetic-playlist', + name: 'Synthetic playlist', + type: 'm3u-url', + payload: JSON.stringify({ + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + title: 'Synthetic playlist', + }), + favorites: null, + recentlyViewed: null, +} as never; + +describe('app playlist GET performance boundaries', () => { + it('keeps the default read and parse path unchanged', async () => { + const { db, limit, select } = createDatabaseMock([playlistRow]); + + await expect( + getAppPlaylist(db, 'synthetic-playlist') + ).resolves.toEqual( + expect.objectContaining({ + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + }) + ); + expect(select).toHaveBeenCalledTimes(1); + expect(limit).toHaveBeenCalledWith(1); + }); + + it('separates the awaited SQLite read from playlist deserialization', async () => { + const { db, limit } = createDatabaseMock([playlistRow]); + const phases: Array<{ + metadata: PerformancePhaseMetadata | undefined; + phase: AppPlaylistGetPerformancePhase; + }> = []; + const capture: TestGetPhaseCapture = { + async captureAsync(phase, execute, metadata) { + const result = await execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + captureSync(phase, execute, metadata) { + const result = execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + }; + const getWithCapture = getAppPlaylist as unknown as ( + database: AppDatabase, + playlistId: string, + phaseCapture: TestGetPhaseCapture + ) => Promise | null>; + + await expect( + getWithCapture(db, 'synthetic-playlist', capture) + ).resolves.toEqual( + expect.objectContaining({ + _id: 'synthetic-playlist', + }) + ); + + expect(phases).toEqual([ + { + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + }, + { + metadata: { itemCount: 2 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + }, + ]); + expect(limit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts new file mode 100644 index 000000000..278d2bfb6 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts @@ -0,0 +1,101 @@ +import { + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistUpsertPerformancePhase, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +import type { AppDatabase } from '../database.types'; +import { + type AppPlaylistUpsertPhaseCapture, + upsertAppPlaylist, +} from './playlist.operations'; + +function createDatabaseMock(runImplementation: () => unknown = () => ({})) { + const run = jest.fn(runImplementation); + const then = jest.fn( + ( + onFulfilled: (value: unknown) => unknown, + onRejected: (error: unknown) => unknown + ) => Promise.resolve().then(run).then(onFulfilled, onRejected) + ); + const onConflictDoUpdate = jest.fn(() => ({ run, then })); + const values = jest.fn(() => ({ onConflictDoUpdate })); + const insert = jest.fn(() => ({ values })); + + return { + db: { insert } as unknown as AppDatabase, + insert, + onConflictDoUpdate, + run, + values, + }; +} + +describe('app playlist upsert performance boundaries', () => { + const playlist = { + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + title: 'Synthetic playlist', + type: 'm3u-url', + url: 'http://127.0.0.1:43210/synthetic-performance.m3u', + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('keeps the default path free of phase hooks and executes one autocommit run', async () => { + const { db, run } = createDatabaseMock(); + + await expect(upsertAppPlaylist(db, playlist)).resolves.toEqual({ + success: true, + }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('separates row/JSON serialization from SQLite without rescanning the payload for metadata', async () => { + const { db, run } = createDatabaseMock(); + const byteLength = jest.spyOn(Buffer, 'byteLength'); + const phases: Array<{ + metadata: PerformancePhaseMetadata | undefined; + phase: AppPlaylistUpsertPerformancePhase; + }> = []; + const capture: AppPlaylistUpsertPhaseCapture = { + async captureAsync(phase, execute, metadata) { + const result = await execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + captureSync(phase, execute, metadata) { + const result = execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + }; + + await expect(upsertAppPlaylist(db, playlist, capture)).resolves.toEqual( + { success: true } + ); + + expect(phases).toEqual([ + { + metadata: { itemCount: 2 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + }, + { + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + }, + ]); + expect(byteLength).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 5b6333dc9..c8f5843db 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -1,6 +1,13 @@ import { eq, inArray } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistGetPerformancePhase, + type AppPlaylistUpsertPerformancePhase, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { checkpointOperation, @@ -19,6 +26,32 @@ const PLAYLIST_TYPES = { const DEFAULT_BATCH_SIZE = 100; +export interface AppPlaylistUpsertPhaseCapture { + captureAsync: ( + phase: AppPlaylistUpsertPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistUpsertPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +} + +export interface AppPlaylistGetPhaseCapture { + captureAsync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +} + type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES]; const PLAYLIST_TYPE_VALUES = new Set( Object.values(PLAYLIST_TYPES) @@ -196,6 +229,21 @@ function buildPlaylistRow( }; } +function getPlaylistItemCount( + playlist: Record +): number | undefined { + try { + const value = playlist.playlist; + if (typeof value !== 'object' || value === null) { + return undefined; + } + const items = Reflect.get(value, 'items'); + return Array.isArray(items) ? items.length : undefined; + } catch { + return undefined; + } +} + export function parseAppPlaylist( row: schema.Playlist ): Record { @@ -300,17 +348,42 @@ export async function createPlaylist( export async function upsertAppPlaylist( db: AppDatabase, - playlist: Record + playlist: Record, + capturePhase?: AppPlaylistUpsertPhaseCapture ): Promise<{ success: boolean }> { - const row = buildPlaylistRow(playlist); + const row = capturePhase + ? capturePhase.captureSync( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + () => buildPlaylistRow(playlist), + () => ({ + itemCount: getPlaylistItemCount(playlist), + }) + ) + : buildPlaylistRow(playlist); if (!row) { throw new Error('Playlist ID is required for upsert'); } - await db.insert(schema.playlists).values(row).onConflictDoUpdate({ - target: schema.playlists.id, - set: row, - }); + const write = async () => { + await db + .insert(schema.playlists) + .values(row) + .onConflictDoUpdate({ + target: schema.playlists.id, + set: row, + }); + }; + if (capturePhase) { + await capturePhase.captureAsync( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + write, + () => ({ + itemCount: 1, + }) + ); + } else { + await write(); + } return { success: true }; } @@ -392,14 +465,38 @@ export async function getAppPlaylistMetas(db: AppDatabase) { ); } -export async function getAppPlaylist(db: AppDatabase, playlistId: string) { - const rows = await db - .select() - .from(schema.playlists) - .where(eq(schema.playlists.id, playlistId)) - .limit(1); +export async function getAppPlaylist( + db: AppDatabase, + playlistId: string, + capturePhase?: AppPlaylistGetPhaseCapture +) { + const read = () => + db + .select() + .from(schema.playlists) + .where(eq(schema.playlists.id, playlistId)) + .limit(1); + const rows = capturePhase + ? await capturePhase.captureAsync( + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + read, + (value) => ({ itemCount: value.length }) + ) + : await read(); + const deserialize = () => + rows[0] ? parseAppPlaylist(rows[0]) : null; - return rows[0] ? parseAppPlaylist(rows[0]) : null; + return capturePhase + ? capturePhase.captureSync( + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + deserialize, + (value) => ({ + itemCount: value + ? (getPlaylistItemCount(value) ?? 0) + : 0, + }) + ) + : deserialize(); } export async function getAppPlaylistFavoriteChannels( diff --git a/apps/electron-backend/src/app/events/playlist-import-performance.ts b/apps/electron-backend/src/app/events/playlist-import-performance.ts new file mode 100644 index 000000000..f5c6dc82d --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-import-performance.ts @@ -0,0 +1,215 @@ +import { + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, + PERFORMANCE_PHASE_BOUNDARY, + type M3uImportPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import { randomUUID } from 'node:crypto'; +import { channel, type Channel } from 'node:diagnostics_channel'; +import { performance } from 'node:perf_hooks'; + +export interface M3uImportPerformanceCaptureRuntime { + readonly createRequestId: () => string; + readonly readEpochMs: () => number; + readonly readMonotonicMs: () => number; +} + +export interface M3uImportPerformanceCapture { + measure( + phase: M3uImportPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): TResult; + measureAsync( + phase: M3uImportPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): Promise; +} + +interface CaptureOptions { + readonly emit?: ( + event: PerformancePhaseEvent + ) => void; + readonly enabled: boolean; + readonly requestId?: string; + readonly runtime?: M3uImportPerformanceCaptureRuntime; +} + +interface PhaseStart { + readonly monotonicMs: number; + readonly phase: M3uImportPerformancePhase; +} + +const DEFAULT_RUNTIME: M3uImportPerformanceCaptureRuntime = { + createRequestId: () => randomUUID(), + readEpochMs: () => performance.timeOrigin + performance.now(), + readMonotonicMs: () => performance.now(), +}; + +let performanceChannel: Channel | null = null; +let fallbackRequestSequence = 0; + +export function publishM3uImportPerformancePhaseEvent( + event: PerformancePhaseEvent +): void { + try { + performanceChannel ??= channel( + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + performanceChannel.publish(event); + } catch { + // Development-only instrumentation must never affect playlist import. + } +} + +export function createM3uImportPerformanceCapture( + options: CaptureOptions +): M3uImportPerformanceCapture | null { + if (!options.enabled) { + return null; + } + + const runtime = options.runtime ?? DEFAULT_RUNTIME; + const requestId = + normalizeRequestId(options.requestId) ?? createRequestId(runtime); + const emit = options.emit ?? publishM3uImportPerformancePhaseEvent; + + const begin = (phase: M3uImportPerformancePhase): PhaseStart => { + const monotonicMs = safeReadMonotonicMs(runtime); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(runtime), + metadata: undefined, + phase, + requestId, + }); + return { monotonicMs, phase }; + }; + + const end = ( + start: PhaseStart, + createMetadata?: () => PerformancePhaseMetadata + ): void => { + const endedMonotonicMs = safeReadMonotonicMs(runtime); + const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs); + const epochMs = safeReadEpochMs(runtime); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: Number.isFinite(durationMs) ? durationMs : 0, + epochMs, + metadata: sanitizeMetadata(safelyCreateMetadata(createMetadata)), + phase: start.phase, + requestId, + }); + }; + + return { + measure: (phase, execute, metadata) => { + const start = begin(phase); + try { + const result = execute(); + end(start, metadata ? () => metadata(result) : undefined); + return result; + } catch (error) { + end(start); + throw error; + } + }, + measureAsync: async (phase, execute, metadata) => { + const start = begin(phase); + try { + const result = await execute(); + end(start, metadata ? () => metadata(result) : undefined); + return result; + } catch (error) { + end(start); + throw error; + } + }, + }; +} + +function createRequestId(runtime: M3uImportPerformanceCaptureRuntime): string { + try { + const requestId = normalizeRequestId(runtime.createRequestId()); + if (requestId) { + return requestId; + } + } catch { + // Fall through to a process-local, credential-free identifier. + } + fallbackRequestSequence += 1; + return `m3u-import-${Date.now()}-${fallbackRequestSequence}`; +} + +function normalizeRequestId(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function safeReadEpochMs(runtime: M3uImportPerformanceCaptureRuntime): number { + try { + const value = runtime.readEpochMs(); + return Number.isFinite(value) ? value : Date.now(); + } catch { + return Date.now(); + } +} + +function safeReadMonotonicMs( + runtime: M3uImportPerformanceCaptureRuntime +): number { + try { + const value = runtime.readMonotonicMs(); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +} + +function safelyCreateMetadata( + createMetadata: (() => PerformancePhaseMetadata) | undefined +): PerformancePhaseMetadata | undefined { + if (!createMetadata) { + return undefined; + } + try { + return createMetadata(); + } catch { + return undefined; + } +} + +function sanitizeMetadata( + metadata: PerformancePhaseMetadata | undefined +): PerformancePhaseMetadata | undefined { + if (!metadata) { + return undefined; + } + + const sanitized: PerformancePhaseMetadata = {}; + if (isCount(metadata.byteCount)) { + (sanitized as { byteCount?: number }).byteCount = metadata.byteCount; + } + if (isCount(metadata.itemCount)) { + (sanitized as { itemCount?: number }).itemCount = metadata.itemCount; + } + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function isCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function safeEmit( + emit: (event: PerformancePhaseEvent) => void, + event: PerformancePhaseEvent +): void { + try { + emit(event); + } catch { + // Instrumentation delivery is deliberately fail-neutral. + } +} diff --git a/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts b/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts new file mode 100644 index 000000000..b3ac6cd25 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts @@ -0,0 +1,312 @@ +import { + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE, + type PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + +import { + createM3uImportPerformanceCapture, + type M3uImportPerformanceCaptureRuntime, +} from './playlist-import-performance'; +import { fetchPlaylistFromUrl } from './playlist-source'; + +const mockCreatePlaylistObject = jest.fn(); +const mockGetFilenameFromUrl = jest.fn(); +const mockParse = jest.fn(); +const mockRequestWithValidatedRedirects = jest.fn(); + +jest.mock('@iptvnator/shared/m3u-utils', () => ({ + createPlaylistObject: (...args: unknown[]) => + mockCreatePlaylistObject(...args), + getFilenameFromUrl: (...args: unknown[]) => mockGetFilenameFromUrl(...args), +})); + +jest.mock('iptv-playlist-parser', () => ({ + parse: (...args: unknown[]) => mockParse(...args), +})); + +jest.mock('../util/secure-https', () => ({ + createPlaylistAgentFactory: jest.fn(() => jest.fn()), +})); + +jest.mock('../util/validated-axios', () => ({ + requestWithValidatedRedirects: (...args: unknown[]) => + mockRequestWithValidatedRedirects(...args), +})); + +type ImportPhaseEvent = PerformancePhaseEvent< + (typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE] +>; + +function createRuntime(): M3uImportPerformanceCaptureRuntime { + const epochValues = [100, 110, 120, 130, 140, 150]; + const monotonicValues = [10, 14, 20, 23, 30, 36]; + + return { + createRequestId: jest.fn(() => 'm3u-request-1'), + readEpochMs: jest.fn(() => epochValues.shift() ?? 150), + readMonotonicMs: jest.fn(() => monotonicValues.shift() ?? 36), + }; +} + +describe('URL playlist import performance phases', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + mockCreatePlaylistObject.mockReset(); + mockGetFilenameFromUrl.mockReset(); + mockParse.mockReset(); + mockRequestWithValidatedRedirects.mockReset(); + }); + + it('does not allocate phase state or read clocks while capture is disabled', async () => { + const runtime = { + createRequestId: jest.fn(() => { + throw new Error('request id must stay untouched'); + }), + readEpochMs: jest.fn(() => { + throw new Error('epoch clock must stay untouched'); + }), + readMonotonicMs: jest.fn(() => { + throw new Error('monotonic clock must stay untouched'); + }), + }; + const emit = jest.fn(); + const playlist = { playlist: { items: [] }, title: 'Synthetic' }; + + mockRequestWithValidatedRedirects.mockResolvedValue({ + data: '#EXTM3U', + }); + mockParse.mockReturnValue({ items: [] }); + mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + + const capture = createM3uImportPerformanceCapture({ + emit, + enabled: false, + runtime, + }); + const result = await fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u', + undefined, + { performanceCapture: capture } + ); + + expect(capture).toBeNull(); + expect(result).toBe(playlist); + expect(emit).not.toHaveBeenCalled(); + expect(runtime.createRequestId).not.toHaveBeenCalled(); + expect(runtime.readEpochMs).not.toHaveBeenCalled(); + expect(runtime.readMonotonicMs).not.toHaveBeenCalled(); + }); + + it('emits ordered epoch-aligned pairs with durations and only byte/item metadata', async () => { + const events: ImportPhaseEvent[] = []; + const body = '#EXTM3U\n#EXTINF:-1,One\nhttps://stream.test/1'; + const bodyBytes = Buffer.byteLength(body, 'utf8'); + const parsed = { items: [{ name: 'One' }, { name: 'Two' }] }; + const playlist = { + playlist: { items: parsed.items }, + title: 'Synthetic', + }; + + mockRequestWithValidatedRedirects.mockResolvedValue({ + data: body, + headers: { + 'content-length': String(bodyBytes), + }, + }); + mockParse.mockReturnValue(parsed); + mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + + const capture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-1', + runtime: createRuntime(), + }); + const byteLength = jest.spyOn(Buffer, 'byteLength'); + const result = await fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u', + undefined, + { performanceCapture: capture } + ); + + expect(result).toBe(playlist); + expect(byteLength).not.toHaveBeenCalled(); + expect( + events.map( + ({ + boundary, + durationMs, + epochMs, + metadata, + phase, + requestId, + }) => ({ + boundary, + durationMs, + epochMs, + metadata, + phase, + requestId, + }) + ) + ).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 4, + epochMs: 110, + metadata: { byteCount: bodyBytes }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestId: 'm3u-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 120, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 3, + epochMs: 130, + metadata: { itemCount: 2 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestId: 'm3u-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 140, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 6, + epochMs: 150, + metadata: { itemCount: 2 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestId: 'm3u-request-1', + }, + ]); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain('127.0.0.1'); + expect(serialized).not.toContain('stream.test'); + expect(serialized).not.toContain('Synthetic'); + expect(serialized).not.toContain('username'); + expect(serialized).not.toContain('password'); + expect(serialized).not.toContain( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE + ); + }); + + it('preserves exact results and errors when event or metadata hooks fail', async () => { + const resultIdentity = { result: 'same-object' }; + const capture = createM3uImportPerformanceCapture({ + emit: () => { + throw new Error('event sink failed'); + }, + enabled: true, + requestId: 'm3u-request-2', + runtime: createRuntime(), + }); + + expect( + capture?.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + () => resultIdentity, + () => { + throw new Error('metadata failed'); + } + ) + ).toBe(resultIdentity); + + const businessError = new Error('download failed'); + const events: ImportPhaseEvent[] = []; + mockRequestWithValidatedRedirects.mockRejectedValue(businessError); + const errorCapture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-3', + runtime: createRuntime(), + }); + + await expect( + fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/failure.m3u', + undefined, + { + performanceCapture: errorCapture, + } + ) + ).rejects.toBe(businessError); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + }, + { + boundary: 'end', + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + }, + ]); + }); + + it('samples the end boundary before creating phase metadata', () => { + const events: ImportPhaseEvent[] = []; + let epochMs = 100; + let monotonicMs = 10; + const resultIdentity = { itemCount: 2 }; + const capture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-boundary', + runtime: { + createRequestId: jest.fn(() => 'unused-request-id'), + readEpochMs: jest.fn(() => epochMs), + readMonotonicMs: jest.fn(() => monotonicMs), + }, + }); + + const result = capture?.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + () => { + epochMs = 110; + monotonicMs = 20; + return resultIdentity; + }, + ({ itemCount }) => { + epochMs = 1_110; + monotonicMs = 1_020; + return { itemCount }; + } + ); + + expect(result).toBe(resultIdentity); + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + boundary: 'end', + durationMs: 10, + epochMs: 110, + metadata: { itemCount: 2 }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts index a55b9a96d..e147ae806 100644 --- a/apps/electron-backend/src/app/events/playlist-source.ts +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -13,8 +13,11 @@ import { isInvalidTlsCertificateError, } from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +import type { M3uImportPerformanceCapture } from './playlist-import-performance'; +import { M3U_IMPORT_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; export interface PlaylistFetchOptions { + performanceCapture?: M3uImportPerformanceCapture | null; trustedInsecureTlsHosts?: readonly string[]; } @@ -26,14 +29,67 @@ export interface PlaylistFetchOptions { */ export const PLAYLIST_FETCH_TIMEOUT_MS = 30000; +function countPlaylistItems(value: unknown): number | undefined { + try { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const items = Reflect.get(value, 'items'); + return Array.isArray(items) ? items.length : undefined; + } catch { + return undefined; + } +} + +function countNormalizedPlaylistItems(value: unknown): number | undefined { + try { + if (typeof value !== 'object' || value === null) { + return undefined; + } + return countPlaylistItems(Reflect.get(value, 'playlist')); + } catch { + return undefined; + } +} + +function readResponseContentLength(response: unknown): number | undefined { + try { + if (typeof response !== 'object' || response === null) { + return undefined; + } + const headers = Reflect.get(response, 'headers'); + if (typeof headers !== 'object' || headers === null) { + return undefined; + } + const directValue = Reflect.get(headers, 'content-length'); + const getHeader = Reflect.get(headers, 'get'); + const value = + directValue ?? + (typeof getHeader === 'function' + ? Reflect.apply(getHeader, headers, ['content-length']) + : undefined); + const numericValue = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim().length > 0 + ? Number(value) + : Number.NaN; + return Number.isSafeInteger(numericValue) && numericValue >= 0 + ? numericValue + : undefined; + } catch { + return undefined; + } +} + export async function fetchPlaylistFromUrl( url: string, title?: string, options: PlaylistFetchOptions = {} ): Promise { - let result; - try { - result = await requestWithValidatedRedirects( + const performanceCapture = options.performanceCapture; + const request = () => + requestWithValidatedRedirects( url, { agentFactory: createPlaylistAgentFactory({ @@ -44,6 +100,17 @@ export async function fetchPlaylistFromUrl( }, { allowPrivateNetworks: true } ); + let result; + try { + result = performanceCapture + ? await performanceCapture.measureAsync( + M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + request, + (response) => ({ + byteCount: readResponseContentLength(response), + }) + ) + : await request(); } catch (error) { if (isInvalidTlsCertificateError(error)) { throw createInvalidTlsCertificateError( @@ -53,19 +120,38 @@ export async function fetchPlaylistFromUrl( throw error; } - const parsedPlaylist = parse(result.data); - const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : ''; - const playlistName = - !extractedName || extractedName === 'Untitled playlist' - ? 'Imported from URL' - : extractedName; + const parsedPlaylist = performanceCapture + ? performanceCapture.measure( + M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + () => parse(result.data), + (parsed) => ({ itemCount: countPlaylistItems(parsed) }) + ) + : parse(result.data); + const normalize = (): Playlist => { + const extractedName = + url && url.length > 1 ? getFilenameFromUrl(url) : ''; + const playlistName = + !extractedName || extractedName === 'Untitled playlist' + ? 'Imported from URL' + : extractedName; - return createPlaylistObject( - title ?? playlistName, - parsedPlaylist, - url, - 'URL' - ); + return createPlaylistObject( + title ?? playlistName, + parsedPlaylist, + url, + 'URL' + ); + }; + + return performanceCapture + ? performanceCapture.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + normalize, + (playlist) => ({ + itemCount: countNormalizedPlaylistItems(playlist), + }) + ) + : normalize(); } export async function fetchPlaylistFromFile( diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts index f1300f5a6..0b12a65b1 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -6,10 +6,13 @@ import type { } from '@iptvnator/shared/interfaces'; import { AUTO_UPDATE_PLAYLISTS, + M3U_IMPORT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, PLAYLIST_REFRESH_EVENT, } from '@iptvnator/shared/interfaces'; +import { channel } from 'node:diagnostics_channel'; import { resolve } from 'node:path'; type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise; @@ -41,6 +44,8 @@ const mockCreatePlaylistObject = jest.fn(); const mockGetFilenameFromUrl = jest.fn(); const mockResolveWorkerRuntimeBootstrap = jest.fn(); const mockWorkerInstances: MockWorker[] = []; +const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE'; +const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV]; jest.mock('electron', () => ({ app: { @@ -169,6 +174,11 @@ describe('playlist IPC events', () => { }); afterEach(() => { + if (originalPerformanceCaptureValue === undefined) { + delete process.env[PERF_CAPTURE_ENV]; + } else { + process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue; + } consoleErrorSpy.mockRestore(); consoleLogSpy.mockRestore(); consoleWarnSpy.mockRestore(); @@ -210,6 +220,101 @@ describe('playlist IPC events', () => { expect(result).toEqual(playlist); }); + it('publishes request-scoped URL import phases only through the opt-in internal channel', async () => { + process.env[PERF_CAPTURE_ENV] = '1'; + const body = '#EXTM3U\n#EXTINF:-1,News\nhttps://stream.test/news'; + const parsedPlaylist = { items: [{ name: 'News' }] }; + const playlist = createPlaylist({ + title: 'synthetic-performance.m3u', + url: 'http://127.0.0.1:43210/synthetic-performance.m3u', + }); + const events: unknown[] = []; + const performanceChannel = channel( + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => events.push(event); + + mockAxiosGet.mockResolvedValue({ + data: body, + headers: { + 'content-length': String(Buffer.byteLength(body, 'utf8')), + }, + }); + mockParse.mockReturnValue(parsedPlaylist); + mockGetFilenameFromUrl.mockReturnValue('synthetic-performance.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + performanceChannel.subscribe(subscriber); + + try { + await expect( + getHandler('fetch-playlist-by-url')( + createIpcEvent(), + 'http://127.0.0.1:43210/synthetic-performance.m3u' + ) + ).resolves.toBe(playlist); + } finally { + performanceChannel.unsubscribe(subscriber); + } + + expect( + events.map((event) => { + const marker = event as { + boundary: string; + metadata?: { byteCount?: number; itemCount?: number }; + phase: string; + requestId: string; + }; + return { + boundary: marker.boundary, + metadata: marker.metadata, + phase: marker.phase, + requestIdType: typeof marker.requestId, + }; + }) + ).toEqual([ + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { byteCount: Buffer.byteLength(body, 'utf8') }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestIdType: 'string', + }, + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestIdType: 'string', + }, + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestIdType: 'string', + }, + ]); + expect( + new Set(events.map((event) => (event as any).requestId)).size + ).toBe(1); + expect(JSON.stringify(events)).not.toContain('stream.test'); + }); + it('returns null when the open playlist dialog is cancelled', async () => { mockShowOpenDialog.mockResolvedValue({ canceled: true, diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index ddd273d5a..71ea32af1 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -32,6 +32,8 @@ import { fetchPlaylistFromUrl, } from './playlist-source'; import { PlaylistWriteAuthorizer } from './playlist-write-authorization'; +import { createM3uImportPerformanceCapture } from './playlist-import-performance'; +import { isPerformanceCaptureEnabled } from '../services/debug-trace'; export default class PlaylistEvents { static bootstrapPlaylistEvents(): Electron.IpcMain { @@ -95,7 +97,11 @@ ipcMain.handle( options?: ElectronBridgeTrustOptions ) => { try { + const performanceCapture = createM3uImportPerformanceCapture({ + enabled: isPerformanceCaptureEnabled(), + }); return await fetchPlaylistFromUrl(url, title, { + performanceCapture, trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts, }); } catch (error) { diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index fc709c959..61ca7f54d 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -64,6 +64,8 @@ import { getAppState, getPlaylist, setAppState, + type AppPlaylistGetPhaseCapture, + type AppPlaylistUpsertPhaseCapture, updatePlaylist, upsertAppPlaylist, upsertAppPlaylists, @@ -93,9 +95,14 @@ import { executeWithWorkerPerformanceCapture, registerDatabaseWorkerPerformanceCapture, releaseDatabaseWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, type WorkerPerformanceCapture, } from './worker-performance-capture'; +import { + captureWorkerPerformancePhase, + captureWorkerPerformancePhaseAsync, +} from './worker-performance-phase'; import { handleDatabaseWorkerPostGcHeapRequest, isDatabaseWorkerPostGcHeapRequest, @@ -349,7 +356,8 @@ function releasePreRegisteredOperation( async function executeRequest( message: DbWorkerRequestMessage, - preRegisteredState?: ActiveOperationState + preRegisteredState?: ActiveOperationState, + performanceCapture: WorkerPerformanceCapture | null = null ) { const db = await getWorkerDatabase(); @@ -430,11 +438,7 @@ async function executeRequest( kind?: 'all' | 'vod' | 'series'; limit?: number; playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url'; + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; }; return getGlobalRecentlyAdded( db, @@ -575,9 +579,29 @@ async function executeRequest( } case 'DB_UPSERT_APP_PLAYLIST': { + const capturePhase: AppPlaylistUpsertPhaseCapture | undefined = + performanceCapture + ? { + captureAsync: (phase, execute, metadata) => + captureWorkerPerformancePhaseAsync( + performanceCapture, + phase, + execute, + metadata + ), + captureSync: (phase, execute, metadata) => + captureWorkerPerformancePhase( + performanceCapture, + phase, + execute, + metadata + ), + } + : undefined; return upsertAppPlaylist( db, - message.payload as Record + message.payload as Record, + capturePhase ); } @@ -596,7 +620,26 @@ async function executeRequest( case 'DB_GET_APP_PLAYLIST': { const payload = message.payload as { playlistId: string }; - return getAppPlaylist(db, payload.playlistId); + const capturePhase: AppPlaylistGetPhaseCapture | undefined = + performanceCapture + ? { + captureAsync: (phase, execute, metadata) => + captureWorkerPerformancePhaseAsync( + performanceCapture, + phase, + execute, + metadata + ), + captureSync: (phase, execute, metadata) => + captureWorkerPerformancePhase( + performanceCapture, + phase, + execute, + metadata + ), + } + : undefined; + return getAppPlaylist(db, payload.playlistId, capturePhase); } case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS': { @@ -1013,7 +1056,9 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { let performanceCapture: WorkerPerformanceCapture | null = null; const execution = await (async () => { try { - performanceCapture = startWorkerPerformanceCapture(); + performanceCapture = startWorkerPerformanceCapture({ + requestId: message.requestId, + }); registerDatabaseWorkerPerformanceCapture( activePerformanceCaptures, performanceCapture @@ -1021,7 +1066,12 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { await armWorkerPerformanceCapture(performanceCapture); return await executeWithWorkerPerformanceCapture( performanceCapture, - () => executeRequest(message, preRegisteredOperation?.state) + () => + executeRequest( + message, + preRegisteredOperation?.state, + performanceCapture + ) ); } finally { releaseDatabaseWorkerPerformanceCapture( @@ -1038,7 +1088,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { requestId: message.requestId, success: true, result: execution.result, - performance: execution.performance, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } else { console.error( @@ -1051,7 +1104,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { requestId: message.requestId, success: false, error: serializeError(execution.error), - performance: execution.performance, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index f6062de87..a541d8baf 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -25,6 +25,7 @@ import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; import { armWorkerPerformanceCapture, executeWithWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, } from './worker-performance-capture'; @@ -229,7 +230,10 @@ parentPort.on( type: 'response', success: true, result: execution.result, - performance: execution.performance, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } else { const error = execution.error; @@ -237,7 +241,10 @@ parentPort.on( type: 'response', success: false, error: serializeError(error), - performance: execution.performance, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } } finally { 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 58b8c836b..eadd2617f 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 @@ -82,6 +82,9 @@ function mockPerformanceCapture( ), registerDatabaseWorkerPerformanceCapture: jest.fn(), releaseDatabaseWorkerPerformanceCapture: jest.fn(), + stampWorkerPerformanceResponsePostedEpoch: jest.fn( + (_capture: unknown, performance: unknown) => performance + ), startWorkerPerformanceCapture: jest.fn(() => ({})), })); } diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts index 4d1fb3a1f..4f1e95ca4 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts @@ -177,6 +177,7 @@ function createFallbackResult( WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, histogramFlushedEpochMs: null, invalidReason, + ...copyPhaseEvents(capture), requestReceivedEpochMs: capture.requestReceivedEpochMs, threadCpuSystemMicros: null, threadCpuUnavailableReason: @@ -213,6 +214,7 @@ export function createWorkerPerformanceCaptureResult( capture.eventLoopUtilizationUnavailableReason, histogramFlushedEpochMs: capture.histogramFlushedEpochMs, invalidReason: capture.invalidReason, + ...copyPhaseEvents(capture), requestReceivedEpochMs: capture.requestReceivedEpochMs, ...threadCpu, threadCpuUnavailableReason: capture.threadCpuUnavailableReason, @@ -226,6 +228,18 @@ export function createWorkerPerformanceCaptureResult( } } +function copyPhaseEvents( + capture: WorkerPerformanceCapture +): Pick { + try { + return capture.phaseEvents.length > 0 + ? { phaseEvents: Object.freeze([...capture.phaseEvents]) } + : {}; + } catch { + return {}; + } +} + export function finishWorkerPerformanceMetricBoundaries( capture: WorkerPerformanceCapture ): void { diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts index f86576eda..dff916076 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts @@ -1,4 +1,8 @@ import type { EventLoopUtilization } from 'node:perf_hooks'; +import type { + AppPlaylistPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; export const WORKER_PERFORMANCE_INVALID_REASON = { OVERLAPPING_DATABASE_WORKER_REQUESTS: @@ -46,7 +50,9 @@ export interface WorkerPerformanceCaptureResult { eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; histogramFlushedEpochMs: number | null; invalidReason: WorkerPerformanceInvalidReason | null; + phaseEvents?: readonly PerformancePhaseEvent[]; requestReceivedEpochMs: number; + responsePostedEpochMs?: number; threadCpuSystemMicros: number | null; threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; threadCpuUserMicros: number | null; @@ -65,6 +71,7 @@ export interface WorkerPerformanceCaptureRuntime { export interface WorkerPerformanceCaptureOptions { enabled?: boolean; + requestId?: string; runtime?: WorkerPerformanceCaptureRuntime; } @@ -84,7 +91,9 @@ export interface WorkerPerformanceCapture { histogramDisabled: boolean; histogramFlushedEpochMs: number | null; invalidReason: WorkerPerformanceInvalidReason | null; + phaseEvents: PerformancePhaseEvent[]; requestReceivedEpochMs: number; + requestId: string | null; runtime: WorkerPerformanceCaptureRuntime; threadCpuEnd: NodeJS.CpuUsage | null; threadCpuStart: NodeJS.CpuUsage | null; diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts index 7bebd4fe9..d6438dec3 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -2,6 +2,7 @@ import { WORKER_PERFORMANCE_UNAVAILABLE_REASON, armWorkerPerformanceCapture, executeWithWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, } from './worker-performance-capture'; import { createFakeRuntime } from './worker-performance-capture.test-harness'; @@ -86,6 +87,34 @@ describe('worker performance capture', () => { expect('reset' in harness.histogram).toBe(false); }); + it('stamps the worker-send boundary only after request profiling has finished', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + const stamped = stampWorkerPerformanceResponsePostedEpoch( + capture, + execution.performance + ); + + expect(stamped).toMatchObject({ + histogramFlushedEpochMs: 145, + responsePostedEpochMs: 149, + workEndedEpochMs: 140, + }); + expect(harness.lifecycle.at(-1)).toBe('epoch:149'); + expect( + stampWorkerPerformanceResponsePostedEpoch(null, undefined) + ).toBeUndefined(); + }); + it('finishes profiling before returning a rejected execution to worker side effects', async () => { const harness = createFakeRuntime(); const capture = startWorkerPerformanceCapture({ diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts index a29d97f0a..3fa1a8baa 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -57,7 +57,13 @@ export function startWorkerPerformanceCapture( histogramDisabled: false, histogramFlushedEpochMs: null, invalidReason: null, + phaseEvents: [], requestReceivedEpochMs: safeReadEpochMs(runtime), + requestId: + typeof options.requestId === 'string' && + options.requestId.length > 0 + ? options.requestId + : null, runtime, threadCpuEnd: null, threadCpuStart: null, @@ -269,3 +275,21 @@ export function releaseDatabaseWorkerPerformanceCapture( activeCaptures.delete(capture); } } + +export function stampWorkerPerformanceResponsePostedEpoch( + capture: WorkerPerformanceCapture | null, + performance: WorkerPerformanceCaptureResult | undefined +): WorkerPerformanceCaptureResult | undefined { + if (!capture || !performance) { + return performance; + } + + try { + return { + ...performance, + responsePostedEpochMs: safeReadEpochMs(capture.runtime), + }; + } catch { + return performance; + } +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts index 6ecaac4bd..f10fe3af9 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -33,6 +33,12 @@ describe('worker performance integration order', () => { expect( handler.indexOf('executeWithWorkerPerformanceCapture') ).toBeLessThan(handler.indexOf('postMessage')); + expect( + handler.match(/stampWorkerPerformanceResponsePostedEpoch/g) + ).toHaveLength(2); + expect(handler).toMatch( + /postMessage\(\{[\s\S]*?performance:\s*stampWorkerPerformanceResponsePostedEpoch\([\s\S]*?execution\.performance[\s\S]*?\),[\s\S]*?\}\)/ + ); expect(handler).not.toContain('await previous'); expect(handler).not.toContain('requestQueue'); }); @@ -52,6 +58,34 @@ describe('worker performance integration order', () => { expect(executionIndex).toBeLessThan( handler.indexOf('postMessage', executionIndex) ); + expect( + handler.match(/stampWorkerPerformanceResponsePostedEpoch/g) + ).toHaveLength(2); + }); + + it('passes the request capture into the single-playlist upsert boundary', () => { + const source = readWorkerSource('database.worker.ts'); + const upsertCase = source.slice( + source.indexOf("case 'DB_UPSERT_APP_PLAYLIST':"), + source.indexOf("case 'DB_UPSERT_APP_PLAYLISTS':") + ); + + expect(upsertCase).toContain('captureWorkerPerformancePhase'); + expect(upsertCase).toContain('performanceCapture'); + expect(upsertCase).toContain('upsertAppPlaylist'); + }); + + it('passes async read and sync deserialize capture into the single-playlist GET boundary', () => { + const source = readWorkerSource('database.worker.ts'); + const getCase = source.slice( + source.indexOf("case 'DB_GET_APP_PLAYLIST':"), + source.indexOf("case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS':") + ); + + expect(getCase).toContain('captureWorkerPerformancePhaseAsync'); + expect(getCase).toContain('captureWorkerPerformancePhase'); + expect(getCase).toContain('performanceCapture'); + expect(getCase).toContain('getAppPlaylist'); }); it('preserves success and business errors when the finish seam throws once', async () => { diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts new file mode 100644 index 000000000..f8da24464 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts @@ -0,0 +1,245 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +import { + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { + captureWorkerPerformancePhase, + captureWorkerPerformancePhaseAsync, +} from './worker-performance-phase'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +describe('worker request performance phases', () => { + it('executes directly without phase allocation or metadata work when disabled', () => { + const resultIdentity = { success: true }; + const execute = jest.fn(() => resultIdentity); + const metadata = jest.fn(() => { + throw new Error('metadata must remain untouched'); + }); + + const result = captureWorkerPerformancePhase( + null, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + execute, + metadata + ); + + expect(result).toBe(resultIdentity); + expect(execute).toHaveBeenCalledTimes(1); + expect(metadata).not.toHaveBeenCalled(); + }); + + it('adds ordered request-scoped pairs to the existing performance envelope', async () => { + const harness = createFakeRuntime(); + const monotonicValues = [10, 17, 20, 31]; + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-request-1', + runtime: { + ...harness.runtime, + readMonotonicMs: () => monotonicValues.shift() ?? 31, + }, + }); + const rowIdentity = { payload: '{"playlist":"synthetic"}' }; + + expect( + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + () => rowIdentity, + () => + ({ + byteCount: 24, + itemCount: 100_000, + secret: 'must-not-be-copied', + }) as PerformancePhaseMetadata + ) + ).toBe(rowIdentity); + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + () => undefined, + () => ({ byteCount: 24, itemCount: 1 }) + ); + + const result = await finishWorkerPerformanceCapture(capture); + + expect(result?.phaseEvents).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 110, + metadata: undefined, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + requestId: 'db-request-1', + }, + { + boundary: 'end', + durationMs: 7, + epochMs: 140, + metadata: { byteCount: 24, itemCount: 100_000 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + requestId: 'db-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 145, + metadata: undefined, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + requestId: 'db-request-1', + }, + { + boundary: 'end', + durationMs: 11, + epochMs: 149, + metadata: { byteCount: 24, itemCount: 1 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + requestId: 'db-request-1', + }, + ]); + expect(JSON.stringify(result?.phaseEvents)).not.toContain('secret'); + }); + + it('preserves exact error identity and still closes the phase pair', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-request-error', + runtime: harness.runtime, + }); + const businessError = new Error('sqlite write failed'); + + expect(() => + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + () => { + throw businessError; + }, + () => { + throw new Error('metadata failed'); + } + ) + ).toThrow(businessError); + + const result = await finishWorkerPerformanceCapture(capture); + expect( + result?.phaseEvents?.map(({ boundary, metadata }) => ({ + boundary, + metadata, + })) + ).toEqual([ + { boundary: 'start', metadata: undefined }, + { boundary: 'end', metadata: undefined }, + ]); + }); + + it('returns the exact business promise without metadata work when async capture is disabled', async () => { + const resultIdentity = { rows: 1 }; + const businessPromise = Promise.resolve(resultIdentity); + const execute = jest.fn(() => businessPromise); + const metadata = jest.fn(() => { + throw new Error('metadata must remain untouched'); + }); + + const result = captureWorkerPerformancePhaseAsync( + null, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + execute, + metadata + ); + + expect(result).toBe(businessPromise); + await expect(result).resolves.toBe(resultIdentity); + expect(execute).toHaveBeenCalledTimes(1); + expect(metadata).not.toHaveBeenCalled(); + }); + + it('closes an async phase only after the awaited operation settles', async () => { + const harness = createFakeRuntime(); + const monotonicValues = [10, 27]; + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-get-request', + runtime: { + ...harness.runtime, + readMonotonicMs: () => monotonicValues.shift() ?? 27, + }, + }); + const rows = [{ id: 'synthetic-playlist' }]; + let resolveRows!: (value: typeof rows) => void; + const pendingRows = new Promise((resolve) => { + resolveRows = resolve; + }); + + const result = captureWorkerPerformancePhaseAsync( + capture, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + () => pendingRows, + (value) => ({ itemCount: value.length }) + ); + + expect(capture?.phaseEvents).toHaveLength(1); + resolveRows(rows); + await expect(result).resolves.toBe(rows); + expect(capture?.phaseEvents).toHaveLength(2); + + const performance = await finishWorkerPerformanceCapture(capture); + expect(performance?.phaseEvents).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 110, + metadata: undefined, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + requestId: 'db-get-request', + }, + { + boundary: 'end', + durationMs: 17, + epochMs: 140, + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + requestId: 'db-get-request', + }, + ]); + }); + + it('preserves async rejection identity and closes the phase without metadata', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-get-error', + runtime: harness.runtime, + }); + const businessError = new Error('sqlite read failed'); + + await expect( + captureWorkerPerformancePhaseAsync( + capture, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + () => Promise.reject(businessError), + () => { + throw new Error('metadata must not run for errors'); + } + ) + ).rejects.toBe(businessError); + + const result = await finishWorkerPerformanceCapture(capture); + expect( + result?.phaseEvents?.map(({ boundary, metadata }) => ({ + boundary, + metadata, + })) + ).toEqual([ + { boundary: 'start', metadata: undefined }, + { boundary: 'end', metadata: undefined }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase.ts b/apps/electron-backend/src/app/workers/worker-performance-phase.ts new file mode 100644 index 000000000..85fca3209 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-phase.ts @@ -0,0 +1,172 @@ +import { + PERFORMANCE_PHASE_BOUNDARY, + type AppPlaylistPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import type { WorkerPerformanceCapture } from './worker-performance-capture.model'; +import { safeReadEpochMs } from './worker-performance-capture.runtime'; + +export function captureWorkerPerformancePhase( + capture: WorkerPerformanceCapture | null, + phase: AppPlaylistPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata +): TResult { + if (!capture || !capture.requestId) { + return execute(); + } + + const startMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId: capture.requestId, + }); + + try { + const result = execute(); + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)), + phase, + requestId: capture.requestId, + }); + return result; + } catch (error) { + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId: capture.requestId, + }); + throw error; + } +} + +export function captureWorkerPerformancePhaseAsync( + capture: WorkerPerformanceCapture | null, + phase: AppPlaylistPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata +): Promise { + const requestId = capture?.requestId; + if (!capture || !requestId) { + return execute(); + } + + return captureAsync(capture, requestId, phase, execute, metadata); +} + +async function captureAsync( + capture: WorkerPerformanceCapture, + requestId: string, + phase: AppPlaylistPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata +): Promise { + const startMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId, + }); + + try { + const result = await execute(); + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: sanitizeMetadata( + safelyCreateMetadata(metadata, result) + ), + phase, + requestId, + }); + return result; + } catch (error) { + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId, + }); + throw error; + } +} + +function safeReadMonotonicMs(capture: WorkerPerformanceCapture): number { + try { + const value = capture.runtime.readMonotonicMs(); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +} + +function safeDuration(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function safelyCreateMetadata( + createMetadata: ((result: TResult) => PerformancePhaseMetadata) | undefined, + result: TResult +): PerformancePhaseMetadata | undefined { + if (!createMetadata) { + return undefined; + } + try { + return createMetadata(result); + } catch { + return undefined; + } +} + +function sanitizeMetadata( + metadata: PerformancePhaseMetadata | undefined +): PerformancePhaseMetadata | undefined { + if (!metadata) { + return undefined; + } + + const sanitized: { byteCount?: number; itemCount?: number } = {}; + if (isCount(metadata.byteCount)) { + sanitized.byteCount = metadata.byteCount; + } + if (isCount(metadata.itemCount)) { + sanitized.itemCount = metadata.itemCount; + } + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function isCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function appendPhaseEvent( + capture: WorkerPerformanceCapture, + event: PerformancePhaseEvent +): void { + try { + capture.phaseEvents.push(event); + } catch { + // Phase instrumentation is deliberately fail-neutral. + } +} diff --git a/apps/web/src/app/services/electron.service.performance.spec.ts b/apps/web/src/app/services/electron.service.performance.spec.ts new file mode 100644 index 000000000..a7e10ffe2 --- /dev/null +++ b/apps/web/src/app/services/electron.service.performance.spec.ts @@ -0,0 +1,123 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { SettingsStore } from '@iptvnator/services'; +import { + PLAYLIST_PARSE_BY_URL, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { DialogService } from '@iptvnator/ui/components'; +import { ElectronService } from './electron.service'; + +describe('ElectronService performance phases', () => { + const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + const electronBridge = { + fetchPlaylistByUrl: jest.fn(), + onPlayerError: jest.fn(), + }; + const store = { dispatch: jest.fn() }; + let service: ElectronService; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: electronBridge, + }); + electronBridge.fetchPlaylistByUrl.mockReset(); + electronBridge.onPlayerError.mockReset(); + store.dispatch.mockReset(); + + TestBed.configureTestingModule({ + providers: [ + ElectronService, + { + provide: DialogService, + useValue: { openConfirmDialog: jest.fn() }, + }, + { + provide: MatSnackBar, + useValue: { open: jest.fn() }, + }, + { + provide: SettingsStore, + useValue: { + getTrustOptions: jest.fn(() => ({ + trustedInsecureTlsHosts: [], + trustedPrivateNetworkEpgUrls: [], + })), + }, + }, + { provide: Store, useValue: store }, + { + provide: TranslateService, + useValue: { instant: jest.fn((key: string) => key) }, + }, + ], + }); + service = TestBed.inject(ElectronService); + }); + + afterEach(() => { + delete (globalThis as unknown as Record)[hookSymbol]; + Object.defineProperty(window, 'electron', { + configurable: true, + value: undefined, + }); + }); + + it('marks the initial URL import store dispatch without exposing playlist data', async () => { + const events: RendererPerformancePhaseEvent[] = []; + ( + globalThis as unknown as Record< + symbol, + (event: RendererPerformancePhaseEvent) => void + > + )[hookSymbol] = (event) => events.push(event); + const playlist = { + playlist: { items: [{ name: 'Sensitive channel' }] }, + title: 'Sensitive title', + url: 'https://user:secret@example.test/list.m3u', + } as Playlist; + electronBridge.fetchPlaylistByUrl.mockResolvedValue(playlist); + + await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { + title: playlist.title, + url: playlist.url, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.handleAddingPlaylistByUrl({ + isTemporary: false, + playlist, + }) + ); + expect( + events.map(({ boundary, outcome, phase }) => ({ + boundary, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + outcome: undefined, + phase: 'store.m3u-import-dispatch', + }, + { + boundary: 'end', + outcome: 'success', + phase: 'store.m3u-import-dispatch', + }, + ]); + expect(JSON.stringify(events)).not.toContain('Sensitive'); + expect(JSON.stringify(events)).not.toContain('secret'); + }); +}); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 688e80844..cef656806 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -20,6 +20,10 @@ import { XTREAM_RESPONSE, XtreamCodeActions, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { AppConfig } from '../../environments/environment'; import { buildAutoUpdatePlaylistsFeedback } from './auto-update-playlists-feedback'; import { @@ -316,11 +320,15 @@ export class ElectronService extends DataService { this.settingsStore.getTrustOptions() ) .then((result) => { - this.store.dispatch( - PlaylistActions.handleAddingPlaylistByUrl({ - isTemporary: !!payload?.isTemporary, - playlist: result, - }) + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.M3U_IMPORT_DISPATCH, + () => + this.store.dispatch( + PlaylistActions.handleAddingPlaylistByUrl({ + isTemporary: !!payload?.isTemporary, + playlist: result, + }) + ) ); }) .catch((error: unknown) => { diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index df6d257a0..6c177858f 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -56,6 +56,95 @@ There is intentionally **no URL validation** (upstream removed it in 0.15.0): an The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.contract.spec.ts` (jest maps the module to the real parser source) and by the fork's own test suite. +## Initial URL Import Performance Benchmark (Electron) + +The Electron E2E project has a deterministic initial-import benchmark for +10,000-, 50,000-, and 100,000-channel M3U playlists. It uses only generated +fixtures served by an ephemeral `127.0.0.1` HTTP server; provider URLs, +credentials, and playlist data must never be used. Each formal scenario runs +one warm-up, five measured iterations, and one diagnostic iteration in a fresh +Electron process and data directory: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-import" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=baseline \ +pnpm nx run electron-backend-e2e:benchmark-m3u-import +``` + +Use a new output directory and `IPTVNATOR_PERF_VARIANT=after` for the identical +post-change run. Formal runs require a clean worktree and record the commit, +source-state hash, runtime, and exact fixture identity. A development smoke run +uses one warm-up, one measured iteration, and one diagnostic iteration per +size: + +```bash +IPTVNATOR_PERF_OUTPUT_DIR="$PWD/dist/performance/-m3u-import-smoke" \ +IPTVNATOR_PERF_VARIANT=smoke \ +IPTVNATOR_PERF_SMOKE=1 \ +pnpm nx run electron-backend-e2e:benchmark-m3u-import +``` + +Smoke runs may use a dirty worktree and validate only the harness; they cannot +support a performance claim. If another local application owns port 9222, smoke +only may set `IPTVNATOR_PERF_CDP_PORT` to an unused loopback port; formal runs +fail closed unless CDP uses `127.0.0.1:9222`. Raw captures and JSON results stay +under the gitignored `dist/performance/` tree; preflight rejects a symbolic +link in any existing output-path component before creating artifacts. Headline +distributions contain only the five measured runs: warm-up and diagnostic +profiles are never mixed into them. + +The benchmark attributes these non-additive intervals: + +| Field | Boundary | +| --- | --- | +| `dataAcquireMs` | loopback response acquisition | +| `m3uParsingMs` | parser call | +| `normalizationMs` | parsed-item normalization | +| `mainToRendererCloneProxyMs` | sum of normalized import-result delivery plus the upsert and GET main-response-to-preload-success legs | +| `storeImportDispatchMs` | renderer import dispatch | +| `rendererToMainCloneProxyMs` | sum of the upsert and GET preload-source-to-main-request legs | +| `mainToDatabaseWorkerCloneProxyMs` | sum of the upsert and GET main-request-to-worker-receive legs | +| `playlistSerializationMs` | database-worker playlist JSON serialization | +| `sqliteWriteMs` | SQLite upsert, including its autocommit | +| `sqliteReadMs` | SQLite read of the newly persisted playlist | +| `playlistDeserializationMs` | database-worker `parseAppPlaylist`, including playlist JSON parsing | +| `databaseWorkerToMainCloneProxyMs` | sum of the upsert and GET worker-response-post-to-main-response legs | +| `storePublishChannelsMs` | renderer channel publication | +| `angularRenderingMs` | publication end to the terminal two-frame paint proof | + +`ipcStructuredCloneProxyMs` is the explicit sum of the four directional proxy +fields. Each directional field can combine the applicable initial-result, +upsert, and `DB_GET_APP_PLAYLIST` legs. The worker stamps +`responsePostedEpochMs` after request profiling is finalized and immediately +before each response is posted; the database-worker-to-main proxy ends when +main receives that response. These fields are attribution aids, not an additive +waterfall: scheduler work and gaps can remain inside total wall time, and a +proxy can include response construction, dispatch overhead, and structured +clone work. Initial import does not create indexes; `indexAndCommitMs` is +therefore `N/A` with +`indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write`. + +Instrumentation is development-only, opt-in, fail-neutral, and count-only. It +must not scan or log playlist payloads to generate metadata. Renderer +long-task, frame-gap, and heartbeat samples are clipped to the measured +operation boundary. Main capture stops only after the upsert and route-reload +GET responses plus both asynchronous preload success markers have arrived, so +return-clone attribution cannot race capture shutdown. Formal comparison fails +closed after writing raw results if exact-window renderer RSS, database-worker +peak heap/external samples, or the database worker's explicit post-GC heap is +missing or incoherent. Both initial-import database requests in every measured +run must also contain coherent event-loop delay, event-loop utilization, and +thread-CPU metrics; the validity record exposes the exact expected and valid +request counts rather than silently dropping nullable samples. An iteration +failure idempotently stops renderer timers, closes trace listeners/output, and +starts best-effort probe/session teardown without waiting on a wedged renderer +before Electron is closed. A partial capture-start failure performs the same +rollback before it escapes to the benchmark lifecycle. +Diagnostic artifacts require separate renderer, main, and database-worker CPU +profiles plus renderer/main/database-worker heap snapshots and a Chromium +trace; raw profiles remain ignored. + ## Playlist Refresh And Startup Auto-Update (Electron) Two paths re-download an M3U playlist from its original source: diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 5526d97f9..54f8bc266 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -128,6 +128,8 @@ Each enabled request gets a fresh event-loop-delay histogram and records: - `requestReceivedEpochMs`, `workStartedEpochMs`, `workEndedEpochMs`, and `histogramFlushedEpochMs` +- `responsePostedEpochMs`, sampled after profiling finalization and immediately + before the worker posts the response to main - worker-thread CPU user/system microseconds from `process.threadCpuUsage()` - event-loop utilization across the exact work interval - event-loop-delay max/p95/p99 from the request's own histogram @@ -146,6 +148,46 @@ profiling queue. If captures overlap, every overlapping response carries `invalidReason: "overlapping-database-worker-requests"` and all attributable CPU, ELU, and event-loop-delay values are `null`. This avoids assigning shared worker activity to one request while preserving normal worker concurrency. +`responsePostedEpochMs` remains a separate response boundary: the initial M3U +benchmark subtracts it from main's response receipt to attribute the +database-worker-to-main structured-clone proxy without folding that interval +into worker execution. + +Initial M3U import profiling requires exact operation-specific phase pairs: + +- `DB_UPSERT_APP_PLAYLIST`: `serialize.playlist`, then `sqlite.write`. The + first covers playlist JSON serialization; the second covers the SQLite + upsert and its autocommit. +- `DB_GET_APP_PLAYLIST`: `sqlite.read`, then `deserialize.playlist`. The first + covers the awaited single-row SQLite select; the second covers + `parseAppPlaylist`, including JSON parsing and persisted-field hydration. + +Missing, partial, reordered, or cross-operation phase sequences fail closed. +Markers carry item counts only and do not scan or copy the payload to compute +profiling metadata. The import creates no indexes, so there is no separate +index/transaction-commit phase. Across the upsert and GET requests, the formal +benchmark reports renderer-to-main, main-to-database-worker, +database-worker-to-main, and main-to-renderer structured-clone proxies; their +explicit sum is `ipcStructuredCloneProxyMs`. See +[M3U Playlist Module Architecture](./m3u-playlist-module.md#initial-url-import-performance-benchmark-electron) +for the complete cross-process attribution. + +Formal initial-import comparison also requires both request-scoped captures in +every measured run to have coherent event-loop delay, event-loop utilization, +and worker-thread CPU values with no unavailable or invalid reason. Summary +validity records the exact expected and valid request counts; nullable metrics +remain in raw results but cannot be silently omitted from comparison +distributions. + +The main-process benchmark samples the database worker's V8 +`used_heap_size` and `external_memory` independently. Raw output includes a +valid-sample count for each metric. A peak is numeric only after at least one +finite, non-negative isolate sample; otherwise it is `null` with a fixed +unavailability reason. An initialized zero is never used as evidence of a +successful sample. Formal initial-import comparisons require valid peak +samples from exactly one database worker in every measured run. Worker RSS is +not available per thread and remains part of the separately reported Electron +main-process RSS together with native and SQLite memory. ### Opt-in post-GC heap capture diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts new file mode 100644 index 000000000..ae41228fa --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts @@ -0,0 +1,123 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { PlaylistsService } from '@iptvnator/services'; +import { type Channel, type Playlist } from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { of, Subject } from 'rxjs'; +import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service'; + +const PLAYLIST_ID = 'performance-playlist'; +const PRIMARY_CHANNEL = { + id: 'channel-1', + name: 'Sensitive channel', + url: 'https://stream-secret.example.test/1', +} as Channel; +const NEXT_CHANNEL = { + id: 'channel-2', + name: 'Next sensitive channel', + url: 'https://stream-secret.example.test/2', +} as Channel; + +async function flushEffects(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('M3uWorkspaceRouteSession performance phases', () => { + const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + const store = { dispatch: jest.fn() }; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: { setUserAgent: jest.fn().mockResolvedValue(true) }, + }); + store.dispatch.mockReset(); + TestBed.configureTestingModule({ + providers: [ + M3uWorkspaceRouteSession, + { + provide: PlaylistContextFacade, + useValue: { + syncFromUrl: jest.fn(() => ({ + inWorkspace: true, + playlistId: PLAYLIST_ID, + provider: 'playlists', + section: 'all', + })), + }, + }, + { + provide: PlaylistsService, + useValue: { + getPlaylist: jest.fn(() => + of({ + playlist: { + items: [PRIMARY_CHANNEL, NEXT_CHANNEL], + }, + title: 'Sensitive title', + url: 'https://user:secret@example.test/list.m3u', + } as Playlist) + ), + }, + }, + { + provide: Router, + useValue: { + events: new Subject().asObservable(), + url: `/workspace/playlists/${PLAYLIST_ID}/all`, + }, + }, + { provide: Store, useValue: store }, + ], + }); + }); + + afterEach(() => { + delete (globalThis as unknown as Record)[hookSymbol]; + delete (window as unknown as { electron?: unknown }).electron; + }); + + it('marks channel publication with only the imported item count', async () => { + const events: RendererPerformancePhaseEvent[] = []; + ( + globalThis as unknown as Record< + symbol, + (event: RendererPerformancePhaseEvent) => void + > + )[hookSymbol] = (event) => events.push(event); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect( + events.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + metadata, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + metadata: { items: 2 }, + outcome: undefined, + phase: 'store.m3u-publish-channels', + }, + { + boundary: 'end', + metadata: { items: 2 }, + outcome: 'success', + phase: 'store.m3u-publish-channels', + }, + ]); + expect(JSON.stringify(events)).not.toContain('Sensitive'); + expect(JSON.stringify(events)).not.toContain('secret'); + expect(JSON.stringify(events)).not.toContain(PRIMARY_CHANNEL.url); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index 0db0dcd87..c0bba152b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -9,6 +9,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { filter, firstValueFrom } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; @@ -107,10 +111,16 @@ export class M3uWorkspaceRouteSession { ); }); - this.store.dispatch( - ChannelActions.setChannels({ - channels: playlist.playlist?.items ?? [], - }) + const channels = playlist.playlist?.items ?? []; + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.M3U_PUBLISH_CHANNELS, + () => + this.store.dispatch( + ChannelActions.setChannels({ + channels, + }) + ), + () => ({ items: channels.length }) ); const favorites = (playlist.favorites ?? []).filter( diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index c3aa8bf8c..e8bf168f0 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -19,6 +19,7 @@ export * from './lib/ipc-commands'; export * from './lib/language.enum'; export * from './lib/m3u-favorite-channel.interface'; export * from './lib/parsed-playlist.interface'; +export * from './lib/performance-phase.interface'; export * from './lib/playback-position.interface'; export * from './lib/playlist-auto-update.interface'; export * from './lib/playlist-backup.interface'; diff --git a/libs/shared/interfaces/src/lib/performance-phase.interface.ts b/libs/shared/interfaces/src/lib/performance-phase.interface.ts new file mode 100644 index 000000000..bbca5daf3 --- /dev/null +++ b/libs/shared/interfaces/src/lib/performance-phase.interface.ts @@ -0,0 +1,53 @@ +export const PERFORMANCE_PHASE_BOUNDARY = { + END: 'end', + START: 'start', +} as const; + +export type PerformancePhaseBoundary = + (typeof PERFORMANCE_PHASE_BOUNDARY)[keyof typeof PERFORMANCE_PHASE_BOUNDARY]; + +export const M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL = + 'IPTVNATOR_M3U_IMPORT_PERFORMANCE_PHASE_EVENT'; + +export const M3U_IMPORT_PERFORMANCE_PHASE = { + DATA_ACQUIRE: 'data.acquire', + NORMALIZE: 'normalize', + PARSE_M3U: 'parse.m3u', +} as const; + +export type M3uImportPerformancePhase = + (typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE]; + +export const APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE = { + SERIALIZE_PLAYLIST: 'serialize.playlist', + SQLITE_WRITE: 'sqlite.write', +} as const; + +export type AppPlaylistUpsertPerformancePhase = + (typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE]; + +export const APP_PLAYLIST_GET_PERFORMANCE_PHASE = { + DESERIALIZE_PLAYLIST: 'deserialize.playlist', + SQLITE_READ: 'sqlite.read', +} as const; + +export type AppPlaylistGetPerformancePhase = + (typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE]; + +export type AppPlaylistPerformancePhase = + | AppPlaylistGetPerformancePhase + | AppPlaylistUpsertPerformancePhase; + +export interface PerformancePhaseMetadata { + readonly byteCount?: number; + readonly itemCount?: number; +} + +export interface PerformancePhaseEvent { + readonly boundary: PerformancePhaseBoundary; + readonly durationMs: number | null; + readonly epochMs: number; + readonly metadata?: PerformancePhaseMetadata; + readonly phase: TPhase; + readonly requestId: string; +} diff --git a/libs/shared/logging/src/index.ts b/libs/shared/logging/src/index.ts index 1a61e0b26..99feec49b 100644 --- a/libs/shared/logging/src/index.ts +++ b/libs/shared/logging/src/index.ts @@ -3,3 +3,16 @@ export { redactSensitiveData, } from './lib/redact-sensitive-data'; export type { RedactionOptions } from './lib/redact-sensitive-data'; +export { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, +} from './lib/renderer-performance-phase'; +export type { + RendererPerformancePhase, + RendererPerformancePhaseBoundary, + RendererPerformancePhaseEvent, + RendererPerformancePhaseHook, + RendererPerformancePhaseMetadata, + RendererPerformancePhaseOutcome, +} from './lib/renderer-performance-phase'; diff --git a/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts b/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts new file mode 100644 index 000000000..ded5179e4 --- /dev/null +++ b/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts @@ -0,0 +1,199 @@ +import * as sharedLogging from '../index'; + +type RendererPerformancePhase = + 'store.m3u-import-dispatch' | 'store.m3u-publish-channels'; +type RendererPerformancePhaseOutcome = 'error' | 'success'; + +interface RendererPerformancePhaseEvent { + readonly boundary: 'end' | 'start'; + readonly epochMs: number; + readonly metadata?: Readonly<{ items: number }>; + readonly monotonicMs: number; + readonly outcome?: RendererPerformancePhaseOutcome; + readonly phase: RendererPerformancePhase; + readonly phaseId: string; + readonly schemaVersion: 1; +} + +type RendererPerformancePhaseHook = ( + event: RendererPerformancePhaseEvent +) => void; + +interface RendererPerformancePhaseModule { + readonly measureRendererPerformancePhase?: ( + phase: RendererPerformancePhase, + execute: () => T, + metadata?: () => Readonly<{ items: number }> + ) => T; + readonly RENDERER_PERFORMANCE_PHASE?: Readonly<{ + M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch'; + M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels'; + }>; + readonly RENDERER_PERFORMANCE_PHASE_HOOK_KEY?: string; +} + +const rendererPerformance = sharedLogging as RendererPerformancePhaseModule; +const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1'; +const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY); + +function measure( + phase: RendererPerformancePhase, + execute: () => T, + metadata?: () => Readonly<{ items: number }> +): T { + const measurePhase = rendererPerformance.measureRendererPerformancePhase; + expect(typeof measurePhase).toBe('function'); + if (measurePhase === undefined) { + throw new Error('renderer performance phase helper is unavailable'); + } + return measurePhase(phase, execute, metadata); +} + +function setHook(hook: RendererPerformancePhaseHook | null): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[hookSymbol]; + return; + } + target[hookSymbol] = hook; +} + +describe('renderer performance phases', () => { + afterEach(() => { + setHook(null); + jest.restoreAllMocks(); + }); + + it('does not evaluate metadata while the global opt-in hook is absent', () => { + const metadata = jest.fn(() => ({ items: 3 })); + const resultIdentity = { imported: true }; + + const result = measure( + 'store.m3u-publish-channels', + () => resultIdentity, + metadata + ); + + expect(result).toBe(resultIdentity); + expect(metadata).not.toHaveBeenCalled(); + }); + + it('emits an immutable successful pair with epoch and monotonic timestamps', () => { + const events: RendererPerformancePhaseEvent[] = []; + setHook((event) => events.push(event)); + const resultIdentity = { imported: true }; + + const result = measure( + 'store.m3u-publish-channels', + () => resultIdentity, + () => ({ items: 3 }) + ); + + expect(result).toBe(resultIdentity); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + boundary: 'start', + metadata: { items: 3 }, + phase: 'store.m3u-publish-channels', + schemaVersion: 1, + }); + expect(events[1]).toMatchObject({ + boundary: 'end', + metadata: { items: 3 }, + outcome: 'success', + phase: 'store.m3u-publish-channels', + schemaVersion: 1, + }); + expect(events[1].phaseId).toBe(events[0].phaseId); + expect(events[0].epochMs).toEqual(expect.any(Number)); + expect(events[0].monotonicMs).toEqual(expect.any(Number)); + expect(events[1].epochMs).toBeGreaterThanOrEqual(events[0].epochMs); + expect(events[1].monotonicMs).toBeGreaterThanOrEqual( + events[0].monotonicMs + ); + expect(Object.isFrozen(events[0])).toBe(true); + expect(Object.isFrozen(events[0].metadata)).toBe(true); + expect(events[1].metadata).toBe(events[0].metadata); + }); + + it('emits an error boundary and rethrows the exact product error', () => { + const events: RendererPerformancePhaseEvent[] = []; + setHook((event) => events.push(event)); + const productError = new Error('dispatch failed'); + + let thrown: unknown; + try { + measure('store.m3u-import-dispatch', () => { + throw productError; + }); + } catch (error: unknown) { + thrown = error; + } + + expect(thrown).toBe(productError); + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + boundary: 'end', + outcome: 'error', + phase: 'store.m3u-import-dispatch', + }); + expect(events[1].phaseId).toBe(events[0].phaseId); + }); + + it('keeps product results and errors unchanged when metadata or hooks fail', () => { + setHook(() => { + throw new Error('benchmark hook failed'); + }); + const resultIdentity = { imported: true }; + + expect( + measure( + 'store.m3u-publish-channels', + () => resultIdentity, + () => { + throw new Error('metadata failed'); + } + ) + ).toBe(resultIdentity); + + const productError = new Error('product failed'); + let thrown: unknown; + try { + measure('store.m3u-import-dispatch', () => { + throw productError; + }); + } catch (error: unknown) { + thrown = error; + } + expect(thrown).toBe(productError); + }); + + it('exposes only the fixed phase vocabulary and item-count metadata', () => { + expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE_HOOK_KEY).toBe( + EXPECTED_HOOK_KEY + ); + expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE).toEqual({ + M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch', + M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels', + }); + + const events: RendererPerformancePhaseEvent[] = []; + setHook((event) => events.push(event)); + measure( + 'store.m3u-publish-channels', + () => undefined, + () => + ({ + items: 2, + playlistUrl: 'https://user:secret@example.test/list.m3u', + }) as { items: number } + ); + + expect(events.map((event) => event.metadata)).toEqual([ + { items: 2 }, + { items: 2 }, + ]); + expect(JSON.stringify(events)).not.toContain('secret'); + expect(JSON.stringify(events)).not.toContain('playlistUrl'); + }); +}); diff --git a/libs/shared/logging/src/lib/renderer-performance-phase.ts b/libs/shared/logging/src/lib/renderer-performance-phase.ts new file mode 100644 index 000000000..d9a8b64ae --- /dev/null +++ b/libs/shared/logging/src/lib/renderer-performance-phase.ts @@ -0,0 +1,181 @@ +export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY = + 'iptvnator.renderer.performance-phase-hook.v1'; + +export const RENDERER_PERFORMANCE_PHASE = { + M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch', + M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels', +} as const; + +export type RendererPerformancePhase = + (typeof RENDERER_PERFORMANCE_PHASE)[keyof typeof RENDERER_PERFORMANCE_PHASE]; + +export type RendererPerformancePhaseBoundary = 'end' | 'start'; +export type RendererPerformancePhaseOutcome = 'error' | 'success'; + +export interface RendererPerformancePhaseMetadata { + readonly items: number; +} + +export interface RendererPerformancePhaseEvent { + readonly boundary: RendererPerformancePhaseBoundary; + readonly epochMs: number; + readonly metadata?: Readonly; + readonly monotonicMs: number; + readonly outcome?: RendererPerformancePhaseOutcome; + readonly phase: RendererPerformancePhase; + readonly phaseId: string; + readonly schemaVersion: 1; +} + +export type RendererPerformancePhaseHook = ( + event: RendererPerformancePhaseEvent +) => void; + +interface PhaseStart { + readonly epochMs: number; + readonly metadata?: Readonly; + readonly monotonicMs: number; + readonly phase: RendererPerformancePhase; + readonly phaseId: string; +} + +interface PhaseTimestamp { + readonly epochMs: number; + readonly monotonicMs: number; +} + +const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); +let nextPhaseId = 0; + +export function measureRendererPerformancePhase( + phase: RendererPerformancePhase, + execute: () => T, + createMetadata?: () => RendererPerformancePhaseMetadata +): T { + const hook = readHook(); + if (hook === null) { + return execute(); + } + + const start = beginPhase(phase, createMetadata); + emit(hook, { + boundary: 'start', + epochMs: start.epochMs, + metadata: start.metadata, + monotonicMs: start.monotonicMs, + phase: start.phase, + phaseId: start.phaseId, + schemaVersion: 1, + }); + + try { + const result = execute(); + endPhase(hook, start, 'success'); + return result; + } catch (error) { + endPhase(hook, start, 'error'); + throw error; + } +} + +function beginPhase( + phase: RendererPerformancePhase, + createMetadata: (() => RendererPerformancePhaseMetadata) | undefined +): PhaseStart { + const timestamp = sampleTimestamp(); + return { + ...timestamp, + metadata: readMetadata(createMetadata), + phase, + phaseId: `renderer-performance-${++nextPhaseId}`, + }; +} + +function endPhase( + hook: RendererPerformancePhaseHook, + start: PhaseStart, + outcome: RendererPerformancePhaseOutcome +): void { + const sampled = sampleTimestamp(); + emit(hook, { + boundary: 'end', + epochMs: Math.max(start.epochMs, sampled.epochMs), + metadata: start.metadata, + monotonicMs: Math.max(start.monotonicMs, sampled.monotonicMs), + outcome, + phase: start.phase, + phaseId: start.phaseId, + schemaVersion: 1, + }); +} + +function readHook(): RendererPerformancePhaseHook | null { + try { + const hook = (globalThis as unknown as Record)[ + hookSymbol + ]; + return typeof hook === 'function' + ? (hook as RendererPerformancePhaseHook) + : null; + } catch { + return null; + } +} + +function readMetadata( + createMetadata: (() => RendererPerformancePhaseMetadata) | undefined +): Readonly | undefined { + if (createMetadata === undefined) { + return undefined; + } + + try { + const items = createMetadata().items; + return Number.isSafeInteger(items) && items >= 0 + ? Object.freeze({ items }) + : undefined; + } catch { + return undefined; + } +} + +function emit( + hook: RendererPerformancePhaseHook, + event: RendererPerformancePhaseEvent +): void { + try { + hook(Object.freeze(event)); + } catch { + // Development-only benchmark instrumentation must stay fail-neutral. + } +} + +function sampleTimestamp(): PhaseTimestamp { + const fallbackMs = readDateNow(); + try { + const monotonicMs = globalThis.performance?.now(); + const timeOrigin = globalThis.performance?.timeOrigin; + if (!Number.isFinite(monotonicMs)) { + return { epochMs: fallbackMs, monotonicMs: fallbackMs }; + } + const epochMs = + Number.isFinite(timeOrigin) && timeOrigin !== undefined + ? timeOrigin + monotonicMs + : fallbackMs; + return { + epochMs: Number.isFinite(epochMs) ? epochMs : fallbackMs, + monotonicMs, + }; + } catch { + return { epochMs: fallbackMs, monotonicMs: fallbackMs }; + } +} + +function readDateNow(): number { + try { + const value = Date.now(); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +}