From f147d4fe37804bf46528a2e7fa65364f80aab8c0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:25:51 +0200 Subject: [PATCH] perf(m3u): stop cancelled refresh workers (#1268) --- .changes/m3u-refresh-cancellation.md | 8 + AGENTS.md | 1 + CLAUDE.md | 3 +- .../playwright.performance.config.ts | 13 + apps/electron-backend-e2e/project.json | 10 + .../src/electron-test-fixtures.ts | 3 +- .../m3u-refresh-cancellation.performance.ts | 8 + .../m3u-refresh-cancellation-contract.ts | 219 +++++ .../m3u-refresh-cancellation-report.ts | 414 +++++++++ .../m3u-refresh-cancellation.benchmark.ts | 543 +++++++++++ .../performance/m3u-refresh-main-capture.ts | 871 ++++++++++++++++++ .../m3u-refresh-renderer-capture.ts | 508 ++++++++++ .../src/performance/performance-statistics.ts | 64 ++ .../src/performance/synthetic-m3u.ts | 40 + .../src/app/api/main.preload.spec.ts | 23 + .../src/app/events/playlist.events.spec.ts | 107 ++- .../src/app/events/playlist.events.ts | 172 ++-- .../src/app/workers/database-worker.types.ts | 3 + .../src/app/workers/database.worker.ts | 19 +- .../app/workers/playlist-refresh.worker.ts | 13 + .../workers/playlist-refresh.worker.types.ts | 7 +- .../worker-performance-capture.spec.ts | 48 + .../app/workers/worker-performance-capture.ts | 68 ++ docs/architecture/m3u-playlist-module.md | 42 +- .../src/lib/playlist-refresh.service.spec.ts | 49 + .../src/lib/playlist-refresh.service.ts | 11 +- .../src/lib/electron-api.interface.ts | 5 +- .../src/lib/playlist-refresh.interface.ts | 22 + 28 files changed, 3205 insertions(+), 89 deletions(-) create mode 100644 .changes/m3u-refresh-cancellation.md create mode 100644 apps/electron-backend-e2e/playwright.performance.config.ts create mode 100644 apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-statistics.ts create mode 100644 apps/electron-backend-e2e/src/performance/synthetic-m3u.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.ts create mode 100644 libs/services/src/lib/playlist-refresh.service.spec.ts diff --git a/.changes/m3u-refresh-cancellation.md b/.changes/m3u-refresh-cancellation.md new file mode 100644 index 000000000..6029dad22 --- /dev/null +++ b/.changes/m3u-refresh-cancellation.md @@ -0,0 +1,8 @@ +--- +type: perf +area: m3u +--- + +Cancelling a large M3U refresh now stops its background worker before parsed +channels can be copied or saved, keeping the interface responsive and leaving +the existing playlist unchanged. diff --git a/AGENTS.md b/AGENTS.md index aa1215c03..cdadea77b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ 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_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it 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 9b5b422fd..a8bfaedc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,6 +141,7 @@ 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_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it 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 @@ -615,7 +616,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts` - Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`) -- Playlist refresh: `playlist-refresh.worker.ts` +- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`) ### Key Features diff --git a/apps/electron-backend-e2e/playwright.performance.config.ts b/apps/electron-backend-e2e/playwright.performance.config.ts new file mode 100644 index 000000000..58a6ba03b --- /dev/null +++ b/apps/electron-backend-e2e/playwright.performance.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + fullyParallel: false, + reporter: [['list']], + testDir: './src', + testMatch: '**/*.performance.ts', + timeout: 30 * 60 * 1_000, + use: { + testIdAttribute: 'data-test-id', + }, + workers: 1, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index cdaa4ec01..1918e34fa 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,16 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "benchmark-m3u-refresh-cancellation": { + "dependsOn": ["electron-backend:build-e2e"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.performance.config.ts" + } + }, "packaged-frame-copy-smoke": { "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 8b4198ab4..df1f1f025 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -75,6 +75,7 @@ type ElectronFixtures = { }; export type LaunchElectronAppOptions = { + args?: readonly string[]; env?: Record; }; @@ -142,7 +143,7 @@ export async function launchElectronApp( } assertPackagedRendererBuildIsElectronSafe(); - const args = [electronMainPath]; + const args = [...(options.args ?? []), electronMainPath]; if (process.platform === 'linux' && process.env['CI']) { args.unshift('--no-sandbox', '--disable-gpu'); diff --git a/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts b/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts new file mode 100644 index 000000000..6ff9b5f6c --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-refresh-cancellation.performance.ts @@ -0,0 +1,8 @@ +import { test } from '@playwright/test'; + +import { runM3uRefreshCancellationBenchmark } from './performance/m3u-refresh-cancellation.benchmark'; + +// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle. +test('profiles cancellation of a 100k M3U refresh', async () => { + await runM3uRefreshCancellationBenchmark(); +}); 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 new file mode 100644 index 000000000..f0abcd22f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -0,0 +1,219 @@ +export const PERFORMANCE_ITERATION_KIND = { + DIAGNOSTIC: 'diagnostic', + MEASURED: 'measured', + WARMUP: 'warmup', +} as const; + +export type PerformanceIterationKind = + (typeof PERFORMANCE_ITERATION_KIND)[keyof typeof PERFORMANCE_ITERATION_KIND]; + +export const PERFORMANCE_WORKER_KIND = { + DATABASE: 'database.worker', + PLAYLIST_REFRESH: 'playlist-refresh.worker', +} as const; + +export type PerformanceWorkerKind = + (typeof PERFORMANCE_WORKER_KIND)[keyof typeof PERFORMANCE_WORKER_KIND]; + +export interface NumericDistribution { + readonly count: number; + readonly max: number | null; + readonly mean: number | null; + readonly median: number | null; + readonly min: number | null; + readonly p95: number | null; + readonly p99: number | null; +} + +export interface EventLoopDelayMetrics { + readonly maxMs: number; + readonly p95Ms: number; + readonly p99Ms: number; +} + +export interface ProcessMemoryMetrics { + readonly peakHeapUsedBytes: number; + readonly peakRssBytes: number; + readonly postGcHeapUsedBytes: number | null; + readonly postGcRssBytes: number | null; +} + +export interface MainTimelineRecord { + readonly epochMs: number; + readonly operation?: string; + readonly operationId?: string; + readonly playlistId?: string; + readonly requestId?: string; + readonly success?: boolean; + readonly type: string; +} + +export interface WorkerCaptureMetrics { + readonly cancelPostedEpochMs: number | null; + readonly cpuSystemMicros: number | null; + readonly cpuUserMicros: number | null; + readonly eventLoopDelay: EventLoopDelayMetrics | null; + readonly eventLoopDelayUnavailableReason: string | null; + readonly eventLoopUtilization: number | null; + readonly kind: PerformanceWorkerKind; + readonly operationId: string | null; + readonly peakExternalBytes: number; + readonly peakHeapUsedBytes: number; + readonly playlistId: string | null; + readonly postGcHeapUsedBytes: number | null; + readonly profilePath: string | null; + readonly responseEpochMs: number | null; + readonly snapshotPath: string | null; + readonly terminatedEpochMs: number | null; +} + +export interface MainCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly cpuSystemMicros: number; + readonly cpuUserMicros: number; + readonly eventLoopDelay: EventLoopDelayMetrics; + readonly eventLoopUtilization: number | null; + readonly eventLoopUtilizationUnavailableReason: string | null; + readonly heapSnapshotPath: string | null; + readonly memory: ProcessMemoryMetrics; + readonly rendererPeakRssBytes: number; + readonly rssScope: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly timeline: readonly MainTimelineRecord[]; + readonly unresponsiveEvents: number; + readonly responsiveEvents: number; + readonly workers: readonly WorkerCaptureMetrics[]; +} + +export interface PlaylistRefreshProbeEvent { + readonly operationId: string; + readonly phase: string | null; + readonly receivedEpochMs: number; + readonly status: string; +} + +export interface RendererProbeMetrics { + readonly cancelButtonFound: boolean; + readonly cancelClickEpochMs: number | null; + readonly events: readonly PlaylistRefreshProbeEvent[]; + readonly frameGapsMs: readonly number[]; + readonly heartbeatDelaysMs: readonly number[]; + readonly longTasksMs: readonly number[]; + readonly operationStartEpochMs: number; + readonly terminalEpochMs: number | null; + readonly uiPaintedEpochMs: number | null; + readonly uiPhaseEpochMs: Readonly>; + readonly uiSettledEpochMs: number | null; +} + +export interface RendererCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly frameGap: NumericDistribution; + readonly heapSnapshotPath: string | null; + readonly heartbeatDelay: NumericDistribution; + readonly longTask: NumericDistribution; + readonly peakHeapUsedBytes: number; + readonly postGcHeapUsedBytes: number | null; + readonly probe: RendererProbeMetrics; + readonly tracePath: string | null; +} + +export interface CancellationPhaseMetrics { + readonly cancelAcknowledgementLatencyMs: number | null; + readonly cancelToDurableTerminalMs: number | null; + readonly cancelToWorkerTerminatedMs: number | null; + readonly cancelTransportLatencyMs: number | null; + readonly dataFetchMs: number | null; + readonly dbCommitProxyMs: number | null; + readonly ipcStoreDispatchProxyMs: number | null; + readonly parseNormalizeCloneProxyMs: number | null; + readonly persistencePreparationProxyMs: number | null; + readonly totalMs: number | null; + readonly uiSettlementToPaintMs: number | null; + readonly visibleTotalMs: number | null; +} + +export interface CancellationIterationResult { + readonly cancellationEffectObserved: boolean; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly phases: CancellationPhaseMetrics; + readonly renderer: RendererCaptureMetrics; + readonly runId: string; +} + +export interface CancellationBenchmarkManifest { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly fixtureSha256: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly memoryAccounting: { + readonly mainRss: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly rendererRss: 'separate-renderer-process'; + readonly workerJsHeap: 'separate-v8-isolate'; + readonly workerRss: 'unavailable-per-thread'; + }; + readonly scenario: 'm3u-refresh-cancel'; + readonly syntheticSeed: number; + readonly runtime: { + readonly arch: string; + readonly electronVersion: string; + readonly harnessNodeVersion: string; + readonly platform: string; + }; + readonly variant: string; + readonly warmupRuns: number; +} + +export interface CancellationBenchmarkSummary { + readonly cancellationEffectRate: number; + readonly iterations: readonly CancellationIterationResult[]; + readonly manifest: CancellationBenchmarkManifest; + readonly measured: { + readonly cancelAcknowledgementLatencyMs: NumericDistribution; + readonly cancelToDurableTerminalMs: NumericDistribution; + readonly cancelToWorkerTerminatedMs: NumericDistribution; + readonly cancelTransportLatencyMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerEventLoopUtilization: NumericDistribution; + readonly databaseWorkerExternalPeakBytes: NumericDistribution; + readonly databaseWorkerHeapPeakBytes: NumericDistribution; + readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly dataFetchMs: NumericDistribution; + readonly dbCommitProxyMs: NumericDistribution; + readonly ipcStoreDispatchProxyMs: 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 parseNormalizeCloneProxyMs: NumericDistribution; + readonly persistencePreparationProxyMs: NumericDistribution; + readonly playlistWorkerEventLoopUtilization: NumericDistribution; + readonly playlistWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly playlistWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly playlistWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly playlistWorkerExternalPeakBytes: NumericDistribution; + readonly playlistWorkerHeapPeakBytes: NumericDistribution; + readonly playlistWorkerPostGcHeapBytes: NumericDistribution; + readonly rendererFrameGapMs: NumericDistribution; + readonly rendererHeartbeatDelayMs: NumericDistribution; + readonly rendererHeapPeakBytes: NumericDistribution; + readonly rendererHeapPostGcBytes: NumericDistribution; + readonly rendererLongTaskCount: number; + readonly rendererLongTaskMs: NumericDistribution; + readonly rendererRssPeakBytes: NumericDistribution; + readonly responsiveEvents: number; + readonly totalMs: NumericDistribution; + readonly uiSettlementToPaintMs: NumericDistribution; + readonly unresponsiveEvents: number; + readonly visibleTotalMs: NumericDistribution; + }; +} 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 new file mode 100644 index 000000000..2f063cd0a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -0,0 +1,414 @@ +/* eslint-disable max-lines -- Summary distributions and correlated phase derivation share one auditable schema mapping. */ +import type { + CancellationBenchmarkManifest, + CancellationBenchmarkSummary, + CancellationIterationResult, + CancellationPhaseMetrics, + MainCaptureMetrics, + MainTimelineRecord, + NumericDistribution, + PerformanceWorkerKind, + RendererCaptureMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; +import { + nonNegativeDifference, + summarizeNumbers, +} from './performance-statistics'; + +export function createCancellationIterationResult(input: { + readonly kind: CancellationIterationResult['kind']; + readonly main: MainCaptureMetrics; + readonly renderer: RendererCaptureMetrics; + readonly runId: string; +}): CancellationIterationResult { + const { main, renderer } = input; + const phases = derivePhases(main, renderer); + const cancellationEffectObserved = renderer.probe.events.some( + (event) => event.status === 'cancelled' + ); + + return Object.freeze({ + cancellationEffectObserved, + kind: input.kind, + main, + phases, + renderer, + runId: input.runId, + }); +} + +export function createCancellationBenchmarkSummary( + manifest: CancellationBenchmarkManifest, + iterations: readonly CancellationIterationResult[] +): CancellationBenchmarkSummary { + const measured = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + const flattenRenderer = ( + select: (iteration: CancellationIterationResult) => readonly number[] + ): number[] => measured.flatMap((iteration) => [...select(iteration)]); + const workerMetric = ( + kind: PerformanceWorkerKind, + select: (worker: MainCaptureMetrics['workers'][number]) => number | null + ): NumericDistribution => + summarizeNumbers( + measured.map((iteration) => { + const worker = iteration.main.workers.find( + (candidate) => candidate.kind === kind + ); + return worker ? select(worker) : null; + }) + ); + const workerEventLoopDelayMetric = ( + kind: PerformanceWorkerKind, + percentile: keyof NonNullable< + MainCaptureMetrics['workers'][number]['eventLoopDelay'] + > + ): NumericDistribution => + workerMetric( + kind, + (worker) => worker.eventLoopDelay?.[percentile] ?? null + ); + + return Object.freeze({ + cancellationEffectRate: + measured.length === 0 + ? 0 + : measured.filter( + (iteration) => iteration.cancellationEffectObserved + ).length / measured.length, + iterations: Object.freeze([...iterations]), + manifest, + measured: Object.freeze({ + cancelAcknowledgementLatencyMs: summarizeNumbers( + measured.map( + (iteration) => + iteration.phases.cancelAcknowledgementLatencyMs + ) + ), + cancelToDurableTerminalMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelToDurableTerminalMs + ) + ), + cancelToWorkerTerminatedMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelToWorkerTerminatedMs + ) + ), + cancelTransportLatencyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.cancelTransportLatencyMs + ) + ), + databaseWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'maxMs' + ), + databaseWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p95Ms' + ), + databaseWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p99Ms' + ), + databaseWorkerEventLoopUtilization: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.eventLoopUtilization + ), + databaseWorkerExternalPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.peakExternalBytes + ), + databaseWorkerHeapPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.peakHeapUsedBytes + ), + databaseWorkerPostGcHeapBytes: workerMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (worker) => worker.postGcHeapUsedBytes + ), + dataFetchMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.dataFetchMs) + ), + dbCommitProxyMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.dbCommitProxyMs) + ), + ipcStoreDispatchProxyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.ipcStoreDispatchProxyMs + ) + ), + mainEventLoopDelayMaxMs: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.maxMs) + ), + mainEventLoopDelayP95Ms: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.p95Ms) + ), + mainEventLoopDelayP99Ms: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopDelay.p99Ms) + ), + mainEventLoopUtilization: summarizeNumbers( + measured.map((iteration) => iteration.main.eventLoopUtilization) + ), + mainHeapPeakBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.peakHeapUsedBytes + ) + ), + mainHeapPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.postGcHeapUsedBytes + ) + ), + mainRssPeakBytes: summarizeNumbers( + measured.map((iteration) => iteration.main.memory.peakRssBytes) + ), + mainRssPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.main.memory.postGcRssBytes + ) + ), + parseNormalizeCloneProxyMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.parseNormalizeCloneProxyMs + ) + ), + persistencePreparationProxyMs: summarizeNumbers( + measured.map( + (iteration) => + iteration.phases.persistencePreparationProxyMs + ) + ), + playlistWorkerEventLoopUtilization: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.eventLoopUtilization + ), + playlistWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'maxMs' + ), + playlistWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p95Ms' + ), + playlistWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p99Ms' + ), + playlistWorkerExternalPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.peakExternalBytes + ), + playlistWorkerHeapPeakBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.peakHeapUsedBytes + ), + playlistWorkerPostGcHeapBytes: workerMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (worker) => worker.postGcHeapUsedBytes + ), + rendererFrameGapMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.frameGapsMs + ) + ), + rendererHeartbeatDelayMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.heartbeatDelaysMs + ) + ), + rendererHeapPeakBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.renderer.peakHeapUsedBytes + ) + ), + rendererHeapPostGcBytes: summarizeNumbers( + measured.map( + (iteration) => iteration.renderer.postGcHeapUsedBytes + ) + ), + rendererLongTaskCount: measured.reduce( + (sum, iteration) => + sum + iteration.renderer.probe.longTasksMs.length, + 0 + ), + rendererLongTaskMs: summarizeNumbers( + flattenRenderer( + (iteration) => iteration.renderer.probe.longTasksMs + ) + ), + rendererRssPeakBytes: summarizeNumbers( + measured.map((iteration) => iteration.main.rendererPeakRssBytes) + ), + responsiveEvents: measured.reduce( + (sum, iteration) => sum + iteration.main.responsiveEvents, + 0 + ), + totalMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.totalMs) + ), + uiSettlementToPaintMs: summarizeNumbers( + measured.map( + (iteration) => iteration.phases.uiSettlementToPaintMs + ) + ), + unresponsiveEvents: measured.reduce( + (sum, iteration) => sum + iteration.main.unresponsiveEvents, + 0 + ), + visibleTotalMs: summarizeNumbers( + measured.map((iteration) => iteration.phases.visibleTotalMs) + ), + }), + }); +} + +function derivePhases( + main: MainCaptureMetrics, + renderer: RendererCaptureMetrics +): CancellationPhaseMetrics { + const started = findTimeline( + main.timeline, + 'playlist-event:started:fetching' + ); + const parsing = findTimeline( + main.timeline, + 'playlist-event:progress:parsing' + ); + const response = findTimeline(main.timeline, 'playlist-response'); + const playlistRequest = findTimeline(main.timeline, 'playlist-request'); + const playlistId = playlistRequest?.playlistId; + const dbGetRequest = findPlaylistDbRecord( + main.timeline, + 'db-request', + response?.epochMs, + 'DB_GET_APP_PLAYLIST', + playlistId + ); + const dbGetResponse = findResponse(main.timeline, dbGetRequest); + const dbUpsertRequest = findPlaylistDbRecord( + main.timeline, + 'db-request', + dbGetResponse?.epochMs ?? dbGetRequest?.epochMs ?? response?.epochMs, + 'DB_UPSERT_APP_PLAYLIST', + playlistId + ); + const dbUpsertResponse = findResponse(main.timeline, dbUpsertRequest); + const playlistWorker = main.workers.find( + (worker) => worker.kind === 'playlist-refresh.worker' + ); + const cancelClick = renderer.probe.cancelClickEpochMs; + const uiPainted = + renderer.probe.uiPaintedEpochMs ?? renderer.probe.terminalEpochMs; + const cancelledEvent = renderer.probe.events.find( + (event) => event.status === 'cancelled' + ); + const terminal = maxEpoch( + uiPainted, + dbUpsertResponse?.epochMs, + cancelledEvent?.receivedEpochMs, + response?.epochMs, + playlistWorker?.terminatedEpochMs + ); + + return Object.freeze({ + cancelAcknowledgementLatencyMs: nonNegativeDifference( + cancelledEvent?.receivedEpochMs, + cancelClick + ), + cancelToDurableTerminalMs: nonNegativeDifference(terminal, cancelClick), + cancelToWorkerTerminatedMs: nonNegativeDifference( + playlistWorker?.terminatedEpochMs, + cancelClick + ), + cancelTransportLatencyMs: nonNegativeDifference( + playlistWorker?.cancelPostedEpochMs, + cancelClick + ), + dataFetchMs: nonNegativeDifference(parsing?.epochMs, started?.epochMs), + dbCommitProxyMs: nonNegativeDifference( + dbUpsertResponse?.epochMs, + dbUpsertRequest?.epochMs + ), + ipcStoreDispatchProxyMs: nonNegativeDifference( + dbGetRequest?.epochMs, + response?.epochMs + ), + parseNormalizeCloneProxyMs: nonNegativeDifference( + response?.epochMs, + parsing?.epochMs + ), + persistencePreparationProxyMs: nonNegativeDifference( + dbUpsertRequest?.epochMs, + dbGetResponse?.epochMs + ), + totalMs: nonNegativeDifference( + terminal, + renderer.probe.operationStartEpochMs + ), + uiSettlementToPaintMs: nonNegativeDifference( + uiPainted, + renderer.probe.uiSettledEpochMs + ), + visibleTotalMs: nonNegativeDifference( + uiPainted, + renderer.probe.operationStartEpochMs + ), + }); +} + +function findTimeline( + timeline: readonly MainTimelineRecord[], + type: string +): MainTimelineRecord | undefined { + return timeline.find((record) => record.type === type); +} + +function findPlaylistDbRecord( + timeline: readonly MainTimelineRecord[], + type: string, + afterEpochMs: number | undefined, + operation: string, + playlistId: string | undefined +): MainTimelineRecord | undefined { + if (afterEpochMs === undefined) { + return undefined; + } + return timeline.find( + (record) => + record.type === type && + record.epochMs >= afterEpochMs && + record.operation === operation && + (playlistId === undefined || record.playlistId === playlistId) + ); +} + +function findResponse( + timeline: readonly MainTimelineRecord[], + request: MainTimelineRecord | undefined +): MainTimelineRecord | undefined { + if (request?.requestId === undefined) { + return undefined; + } + return timeline.find( + (record) => + record.type === 'db-response' && + record.requestId === request.requestId + ); +} + +function maxEpoch( + ...values: readonly (number | null | undefined)[] +): number | null { + const finite = values.filter( + (value): value is number => + typeof value === 'number' && Number.isFinite(value) + ); + return finite.length === 0 ? null : Math.max(...finite); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts new file mode 100644 index 000000000..fcfd83d1f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -0,0 +1,543 @@ +/* eslint-disable max-lines -- Benchmark lifecycle, local fixture server, and artifact safeguards stay auditable in one entry point. */ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { createServer as createTcpServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { expect, type ConsoleMessage, type Page } from '@playwright/test'; + +import { + closeElectronApp, + launchElectronApp, + openSources, + sourceRowByTitle, + waitForM3uCatalog, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import type { + CancellationBenchmarkManifest, + CancellationIterationResult, + PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + createCancellationBenchmarkSummary, + createCancellationIterationResult, +} from './m3u-refresh-cancellation-report'; +import { + installMainCapture, + readMainCaptureStatus, + startMainCapture, + stopMainCapture, +} from './m3u-refresh-main-capture'; +import { startRendererCapture } from './m3u-refresh-renderer-capture'; +import { + createSyntheticM3uFixture, + SYNTHETIC_M3U_CHANNEL_COUNT, + SYNTHETIC_M3U_SEED, + type SyntheticM3uFixture, +} from './synthetic-m3u'; + +const SCENARIO = 'm3u-refresh-cancel'; +const SOURCE_TITLE = 'Synthetic M3U cancellation 100k'; +const RESOURCE_PATH = '/synthetic-performance.m3u'; +const DEFAULT_MEASURED_RUNS = 5; +const DEFAULT_WARMUP_RUNS = 1; +const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +const RENDERER_CDP_PORT = 9222; + +interface SyntheticServer { + readonly resourceUrl: string; + close(): Promise; + serveLargeFixture(): void; + serveSeedFixture(): void; +} + +interface BenchmarkConfiguration { + readonly electronVersion: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly outputDirectory: string; + readonly variant: string; + readonly warmupRuns: number; + readonly workspaceRoot: string; +} + +interface IterationDefinition { + readonly kind: PerformanceIterationKind; + readonly runId: string; +} + +export async function runM3uRefreshCancellationBenchmark(): Promise { + const config = await resolveConfiguration(); + const fixture = createSyntheticM3uFixture(); + const server = await startSyntheticServer(fixture); + const iterations: CancellationIterationResult[] = []; + + try { + for (const definition of createIterationDefinitions(config)) { + console.log( + `[performance] ${config.variant}/${definition.runId} starting` + ); + const result = await runIteration(config, definition, server); + iterations.push(result); + console.log( + `[performance] ${definition.runId} total=${ + result.phases.totalMs?.toFixed(3) ?? 'n/a' + }ms cancelEffect=${result.cancellationEffectObserved}` + ); + } + } finally { + await server.close(); + } + + const manifest: CancellationBenchmarkManifest = Object.freeze({ + channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + fixtureBytes: fixture.bytes, + fixtureSha256: fixture.sha256, + gitCommit: config.gitCommit, + gitDiffSha256: config.gitDiffSha256, + gitDirty: config.gitDirty, + measuredRuns: config.measuredRuns, + memoryAccounting: Object.freeze({ + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + workerRss: 'unavailable-per-thread', + }), + scenario: SCENARIO, + syntheticSeed: SYNTHETIC_M3U_SEED, + runtime: Object.freeze({ + arch: process.arch, + electronVersion: config.electronVersion, + harnessNodeVersion: process.versions.node, + platform: process.platform, + }), + variant: config.variant, + warmupRuns: config.warmupRuns, + }); + const summary = createCancellationBenchmarkSummary(manifest, iterations); + await writeJson(join(config.outputDirectory, 'manifest.json'), manifest); + await writeJson(join(config.outputDirectory, 'summary.json'), summary); + console.log( + `[performance] completed: ${join( + config.outputDirectory, + 'summary.json' + )}` + ); +} + +async function runIteration( + config: BenchmarkConfiguration, + definition: IterationDefinition, + server: SyntheticServer +): Promise { + const iterationDirectory = join(config.outputDirectory, definition.runId); + const dataDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-performance-') + ); + await mkdir(iterationDirectory, { recursive: false }); + let app: LaunchedElectronApp | null = null; + let rendererConsoleListener: ((message: ConsoleMessage) => void) | null = + null; + + try { + await assertTcpPortAvailable(RENDERER_CDP_PORT); + server.serveSeedFixture(); + app = await launchElectronApp(dataDirectory, { + args: [ + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${RENDERER_CDP_PORT}`, + `--user-data-dir=${join(dataDirectory, 'user-data')}`, + ], + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + IPTVNATOR_TRACE_RENDERER_CONSOLE: + process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', + }, + }); + await assertRendererCdpTarget(app.mainWindow); + app.mainWindow.setDefaultTimeout(120_000); + await installMainCapture(app.electronApp); + await seedPlaylist(app.mainWindow, server.resourceUrl); + await waitForSeedSettlement(app); + server.serveLargeFixture(); + const rendererRefreshErrors: string[] = []; + rendererConsoleListener = (message) => { + if ( + message.type() === 'error' && + message.text().includes('Error refreshing playlist:') + ) { + rendererRefreshErrors.push(message.text()); + } + }; + app.mainWindow.on('console', rendererConsoleListener); + + const diagnostic = + definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; + await startMainCapture(app.electronApp, { + diagnostic, + outputDirectory: iterationDirectory, + }); + const renderer = await startRendererCapture(app.mainWindow, { + diagnostic, + outputDirectory: iterationDirectory, + sourceTitle: SOURCE_TITLE, + }); + + await renderer.markOperationStart(); + const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first(); + await row.locator('.refresh-btn').click(); + await renderer.waitForCancelClick(); + await Promise.all([ + renderer.waitForTerminal(), + waitForOperationSettlement(app), + ]); + expect(rendererRefreshErrors).toEqual([]); + + const [rendererMetrics, mainMetrics] = await Promise.all([ + renderer.stop(), + stopMainCapture(app.electronApp), + ]); + const result = createCancellationIterationResult({ + kind: definition.kind, + main: mainMetrics, + renderer: rendererMetrics, + runId: definition.runId, + }); + await writeJson(join(iterationDirectory, 'result.json'), result); + return result; + } finally { + if (app) { + if (rendererConsoleListener) { + app.mainWindow.off('console', rendererConsoleListener); + } + await closeElectronApp(app).catch((error: unknown) => { + console.error('[performance] Electron cleanup failed', error); + }); + } + await rm(dataDirectory, { force: true, recursive: true }); + } +} + +async function seedPlaylist(page: Page, playlistUrl: string): Promise { + await page.getByRole('button', { name: 'Add playlist' }).first().click(); + const dialog = page.locator('mat-dialog-container').last(); + await expect( + dialog.getByRole('heading', { name: 'Add playlist' }) + ).toBeVisible(); + await dialog + .getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' }) + .fill(playlistUrl); + await dialog + .getByRole('textbox', { name: 'Playlist title' }) + .fill(SOURCE_TITLE); + await dialog.getByRole('button', { name: 'Add playlist' }).last().click(); + await expect(dialog).toBeHidden(); + await waitForM3uCatalog(page); + await openSources(page); + await expect(sourceRowByTitle(page, SOURCE_TITLE).first()).toBeVisible(); + await waitForTwoAnimationFrames(page); +} + +async function waitForSeedSettlement(app: LaunchedElectronApp): Promise { + await expect + .poll( + async () => + (await readMainCaptureStatus(app.electronApp)).databasePending, + { timeout: 120_000 } + ) + .toBe(0); + await waitForTwoAnimationFrames(app.mainWindow); +} + +async function waitForOperationSettlement( + app: LaunchedElectronApp +): Promise { + await expect + .poll( + async () => { + const status = await readMainCaptureStatus(app.electronApp); + if (status.playlistResponsesSucceeded > 0) { + return ( + status.databaseUpsertsCompleted > 0 && + status.databasePending === 0 + ); + } + return ( + status.playlistTerminated > 0 && + (status.playlistResponses === 0 || + status.playlistResponsesFailed > 0) + ); + }, + { timeout: 120_000 } + ) + .toBe(true); +} + +async function waitForTwoAnimationFrames(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolvePromise) => { + requestAnimationFrame(() => + requestAnimationFrame(() => resolvePromise()) + ); + }) + ); +} + +function createIterationDefinitions( + config: BenchmarkConfiguration +): readonly IterationDefinition[] { + const definitions: IterationDefinition[] = []; + for (let index = 0; index < config.warmupRuns; index += 1) { + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: `warmup-${String(index + 1).padStart(2, '0')}`, + }); + } + for (let index = 0; index < config.measuredRuns; index += 1) { + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: `run-${String(index + 1).padStart(2, '0')}`, + }); + } + definitions.push({ + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }); + return Object.freeze(definitions); +} + +async function resolveConfiguration(): Promise { + const workspaceRoot = resolve(__dirname, '../../../..'); + const performanceRoot = join(workspaceRoot, 'dist', 'performance'); + const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR']; + if (!requestedRoot) { + throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required'); + } + const outputRoot = resolve(requestedRoot); + if ( + !isAbsolute(requestedRoot) || + !isStrictDescendant(performanceRoot, outputRoot) + ) { + throw new Error( + 'Performance output must be an absolute path below dist/performance' + ); + } + const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline'; + if (!OUTPUT_VARIANT_PATTERN.test(variant)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1'; + const sourceState = readGitSourceState(workspaceRoot); + if (!smoke && sourceState.dirty) { + throw new Error( + 'Formal performance runs require a clean Git worktree; commit or stash changes first' + ); + } + 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'); + } + const outputDirectory = join(outputRoot, variant); + await assertMissing(outputDirectory); + await mkdir(outputDirectory, { recursive: true }); + return Object.freeze({ + electronVersion: electronPackage.version, + gitCommit: sourceState.commit, + gitDiffSha256: sourceState.diffSha256, + gitDirty: sourceState.dirty, + measuredRuns: smoke ? 1 : DEFAULT_MEASURED_RUNS, + outputDirectory, + variant, + warmupRuns: DEFAULT_WARMUP_RUNS, + workspaceRoot, + }); +} + +function readGitSourceState(workspaceRoot: string): { + readonly commit: string; + readonly diffSha256: string; + readonly dirty: boolean; +} { + const git = (args: readonly string[]): string => + execFileSync('git', [...args], { + cwd: workspaceRoot, + encoding: 'utf8', + }); + const commit = git(['rev-parse', 'HEAD']).trim(); + const status = git(['status', '--porcelain=v1', '--untracked-files=all']); + const trackedDiff = git(['diff', '--binary', 'HEAD']); + const stagedDiff = git(['diff', '--binary', '--cached', 'HEAD']); + const diffSha256 = createHash('sha256') + .update(status) + .update('\0') + .update(trackedDiff) + .update('\0') + .update(stagedDiff) + .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 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() + ); + }); +} + +async function assertRendererCdpTarget(page: Page): Promise { + await expect + .poll( + async () => { + try { + const response = await fetch( + `http://127.0.0.1:${RENDERER_CDP_PORT}/json/list` + ); + if (!response.ok) { + return false; + } + const targets = (await response.json()) as unknown; + 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); +} + +async function assertMissing(path: string): Promise { + try { + await access(path); + } catch { + return; + } + throw new Error(`Performance output already exists: ${path}`); +} + +async function startSyntheticServer( + fixture: SyntheticM3uFixture +): Promise { + const seedBody = createSeedPlaylistBody(); + let body = seedBody; + const server = createServer((request, response) => { + if ( + request.method !== 'GET' || + new URL(request.url ?? '/', 'http://127.0.0.1').pathname !== + RESOURCE_PATH + ) { + response.writeHead(404, { 'Content-Length': '0' }); + response.end(); + return; + } + const bytes = Buffer.byteLength(body, 'utf8'); + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Length': String(bytes), + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + }); + response.end(body); + }); + server.listen({ exclusive: true, host: '127.0.0.1', port: 0 }); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Synthetic M3U server did not bind IPv4 loopback'); + } + return Object.freeze({ + close: () => closeServer(server), + resourceUrl: `http://127.0.0.1:${address.port}${RESOURCE_PATH}`, + serveLargeFixture: () => { + body = fixture.body; + }, + serveSeedFixture: () => { + body = seedBody; + }, + }); +} + +function createSeedPlaylistBody(): string { + const lines = ['#EXTM3U']; + for (let index = 1; index <= 100; index += 1) { + lines.push( + `#EXTINF:-1 group-title="Synthetic Seed",Synthetic Seed ${String( + index + ).padStart(3, '0')}` + ); + lines.push(`http://127.0.0.1/seed/${index}`); + } + return `${lines.join('\n')}\n`; +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => + error ? rejectPromise(error) : resolvePromise() + ); + }); +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} 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 new file mode 100644 index 000000000..fd9435445 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -0,0 +1,871 @@ +/* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ +import type { ElectronApplication } from '@playwright/test'; + +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; + +const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; + +export interface MainCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; +} + +export interface MainCaptureStatus { + readonly databasePending: number; + readonly databaseRequests: number; + readonly databaseUpsertsCompleted: number; + readonly playlistTerminated: number; + readonly playlistResponsesFailed: number; + readonly playlistResponses: number; + readonly playlistResponsesSucceeded: number; +} + +export async function installMainCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => { + type JsonRecord = Record; + type WorkerTransferable = import('node:worker_threads').Transferable; + interface CpuProfileHandle { + stop(): Promise; + } + interface WorkerHeapStatistics { + external_memory?: number; + used_heap_size?: number; + } + interface WorkerCpuUsage { + system: number; + user: number; + } + interface WorkerElu { + active: number; + idle: number; + utilization: number; + } + interface InstrumentedWorker { + cpuUsage?(): Promise; + getHeapSnapshot?(): Promise; + getHeapStatistics?(): Promise; + on(event: 'message', listener: (message: unknown) => void): this; + performance?: { + eventLoopUtilization( + utilization1?: WorkerElu, + utilization2?: WorkerElu + ): WorkerElu; + }; + postMessage( + message: unknown, + transferList?: readonly WorkerTransferable[] + ): void; + startCpuProfile?(): Promise; + terminate(): Promise; + } + interface WorkerRecord { + cancelPostedEpochMs: number | null; + cpuFirst: WorkerCpuUsage | null; + cpuLast: WorkerCpuUsage | null; + elu: number | null; + eluStart: WorkerElu | null; + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + } | null; + externalPeak: number; + finalized: boolean; + finalizing: Promise | null; + heapPeak: number; + kind: 'database.worker' | 'playlist-refresh.worker'; + operationId: string | null; + playlistId: string | null; + postGcHeapUsed: number | null; + profileHandle: Promise | null; + profilePath: string | null; + responseEpochMs: number | null; + sampleBusy: boolean; + sampleTimer: NodeJS.Timeout | null; + snapshotPath: string | null; + terminatedEpochMs: number | null; + worker: InstrumentedWorker; + } + interface TimelineRecord { + readonly epochMs: number; + readonly operation?: string; + readonly operationId?: string; + readonly playlistId?: string; + readonly requestId?: string; + readonly success?: boolean; + readonly type: string; + } + + const target = globalThis as unknown as Record; + if (target[stateKey] !== undefined) { + return; + } + + const runtimeProcess = process as typeof process & { + getBuiltinModule(id: string): unknown; + }; + const fs = runtimeProcess.getBuiltinModule( + 'node:fs' + ) as typeof import('node:fs'); + const inspector = runtimeProcess.getBuiltinModule( + 'node:inspector' + ) as typeof import('node:inspector'); + const path = runtimeProcess.getBuiltinModule( + 'node:path' + ) as typeof import('node:path'); + const perfHooks = runtimeProcess.getBuiltinModule( + 'node:perf_hooks' + ) as typeof import('node:perf_hooks'); + const streamPromises = runtimeProcess.getBuiltinModule( + 'node:stream/promises' + ) as typeof import('node:stream/promises'); + const workerThreads = runtimeProcess.getBuiltinModule( + 'node:worker_threads' + ) as typeof import('node:worker_threads'); + const WorkerClass = workerThreads.Worker; + const originalPostMessage = WorkerClass.prototype.postMessage; + const originalTerminate = WorkerClass.prototype.terminate; + const records = new Map(); + const operationWorkers = new Map(); + const dbRequests = new Map< + string, + { + operation: string; + playlistId: string | null; + record: WorkerRecord; + } + >(); + + const state = { + active: false, + cpuStart: null as NodeJS.CpuUsage | null, + diagnostic: false, + eventLoopDelay: null as ReturnType< + typeof perfHooks.monitorEventLoopDelay + > | null, + eventLoopStart: null as ReturnType< + typeof perfHooks.performance.eventLoopUtilization + > | null, + inspectorSession: null as import('node:inspector').Session | null, + mainPeakHeap: 0, + mainPeakRss: 0, + mainProfilePath: null as string | null, + mainSnapshotPath: null as string | null, + outputDirectory: '', + postGcHeap: null as number | null, + postGcRss: null as number | null, + rendererPeakRss: 0, + responsiveEvents: 0, + sampleTimer: null as NodeJS.Timeout | null, + timeline: [] as TimelineRecord[], + unresponsiveEvents: 0, + windowListeners: [] as { + responsive: () => void; + unresponsive: () => void; + window: Electron.BrowserWindow; + }[], + }; + + const nowEpochMs = (): number => + perfHooks.performance.timeOrigin + perfHooks.performance.now(); + const recordTimeline = ( + record: Omit + ): void => { + if (state.active) { + state.timeline.push({ epochMs: nowEpochMs(), ...record }); + } + }; + const classifyRequest = ( + message: JsonRecord + ): 'database.worker' | 'playlist-refresh.worker' | null => { + if (typeof message['operation'] === 'string') { + return 'database.worker'; + } + const payload = message['payload']; + return typeof payload === 'object' && + payload !== null && + typeof (payload as JsonRecord)['operationId'] === 'string' && + typeof (payload as JsonRecord)['playlistId'] === 'string' + ? 'playlist-refresh.worker' + : null; + }; + const readDatabasePlaylistId = (message: JsonRecord): string | null => { + const payload = message['payload']; + if (typeof payload !== 'object' || payload === null) { + return null; + } + const value = payload as JsonRecord; + const playlistId = value['playlistId'] ?? value['_id']; + return typeof playlistId === 'string' ? playlistId : null; + }; + const createWorkerRecord = ( + worker: InstrumentedWorker, + kind: WorkerRecord['kind'] + ): WorkerRecord => { + const existing = records.get(worker); + if (existing) { + return existing; + } + const record: WorkerRecord = { + cancelPostedEpochMs: null, + cpuFirst: null, + cpuLast: null, + elu: null, + eluStart: null, + eventLoopDelay: null, + externalPeak: 0, + finalized: false, + finalizing: null, + heapPeak: 0, + kind, + operationId: null, + playlistId: null, + postGcHeapUsed: null, + profileHandle: null, + profilePath: null, + responseEpochMs: null, + sampleBusy: false, + sampleTimer: null, + snapshotPath: null, + terminatedEpochMs: null, + worker, + }; + records.set(worker, record); + worker.on('message', (incoming) => { + if (typeof incoming !== 'object' || incoming === null) { + return; + } + const message = incoming as JsonRecord; + const performanceCapture = readWorkerPerformance(message); + if (performanceCapture) { + record.eventLoopDelay = record.eventLoopDelay + ? { + maxMs: Math.max( + record.eventLoopDelay.maxMs, + performanceCapture.eventLoopDelay.maxMs + ), + p95Ms: Math.max( + record.eventLoopDelay.p95Ms, + performanceCapture.eventLoopDelay.p95Ms + ), + p99Ms: Math.max( + record.eventLoopDelay.p99Ms, + performanceCapture.eventLoopDelay.p99Ms + ), + } + : performanceCapture.eventLoopDelay; + } + if (record.kind === 'playlist-refresh.worker') { + if (message['type'] === 'event') { + const event = message['event'] as + | JsonRecord + | undefined; + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + type: `playlist-event:${String( + event?.['status'] ?? 'unknown' + )}:${String(event?.['phase'] ?? 'none')}`, + }); + } else if (message['type'] === 'response') { + record.responseEpochMs = nowEpochMs(); + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + success: message['success'] === true, + type: 'playlist-response', + }); + } + return; + } + if ( + message['type'] === 'response' && + typeof message['requestId'] === 'string' + ) { + const request = dbRequests.get(message['requestId']); + if (request) { + dbRequests.delete(message['requestId']); + recordTimeline({ + operation: request.operation, + playlistId: request.playlistId ?? undefined, + requestId: message['requestId'], + success: message['success'] === true, + type: 'db-response', + }); + } + } + }); + return record; + }; + const readWorkerPerformance = ( + message: JsonRecord + ): { + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + }; + } | null => { + const performanceCapture = message['performance']; + if ( + typeof performanceCapture !== 'object' || + performanceCapture === null + ) { + return null; + } + const eventLoopDelay = (performanceCapture as JsonRecord)[ + 'eventLoopDelay' + ]; + if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) { + return null; + } + const value = eventLoopDelay as JsonRecord; + const maxMs = value['maxMs']; + const p95Ms = value['p95Ms']; + const p99Ms = value['p99Ms']; + if ( + typeof maxMs !== 'number' || + !Number.isFinite(maxMs) || + typeof p95Ms !== 'number' || + !Number.isFinite(p95Ms) || + typeof p99Ms !== 'number' || + !Number.isFinite(p99Ms) + ) { + return null; + } + return { + eventLoopDelay: { maxMs, p95Ms, p99Ms }, + }; + }; + const sampleWorker = async (record: WorkerRecord): Promise => { + if (record.sampleBusy || record.finalized) { + return; + } + record.sampleBusy = true; + try { + const stats = await record.worker.getHeapStatistics?.(); + if (stats) { + record.heapPeak = Math.max( + record.heapPeak, + Number(stats.used_heap_size ?? 0) + ); + record.externalPeak = Math.max( + record.externalPeak, + Number(stats.external_memory ?? 0) + ); + } + const cpu = await record.worker.cpuUsage?.(); + if (cpu) { + record.cpuFirst ??= cpu; + record.cpuLast = cpu; + } + const elu = record.worker.performance?.eventLoopUtilization( + record.eluStart ?? undefined + ); + if (elu) { + record.elu = elu.utilization; + } + } catch { + // A one-shot worker may terminate between sampling calls. + } finally { + record.sampleBusy = false; + } + }; + const startWorker = (record: WorkerRecord): void => { + if (!state.active || record.sampleTimer !== null) { + return; + } + record.elu = null; + record.eluStart = + record.worker.performance?.eventLoopUtilization() ?? null; + record.eventLoopDelay = null; + void sampleWorker(record); + record.sampleTimer = setInterval( + () => void sampleWorker(record), + 20 + ); + if ( + state.diagnostic && + typeof record.worker.startCpuProfile === 'function' + ) { + record.profilePath = path.join( + state.outputDirectory, + `${record.kind}.cpuprofile` + ); + record.profileHandle = record.worker.startCpuProfile(); + } + }; + const finalizeWorker = (record: WorkerRecord): Promise => { + record.finalizing ??= (async () => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + record.sampleTimer = null; + } + await sampleWorker(record); + if (record.profileHandle && record.profilePath) { + const handle = await record.profileHandle; + const profile = await handle.stop(); + fs.writeFileSync( + record.profilePath, + JSON.stringify(normalizeWorkerCpuProfile(profile)) + ); + } + if ( + state.diagnostic && + typeof record.worker.getHeapSnapshot === 'function' + ) { + record.snapshotPath = path.join( + state.outputDirectory, + `${record.kind}.heapsnapshot` + ); + const snapshot = await record.worker.getHeapSnapshot(); + await streamPromises.pipeline( + snapshot, + fs.createWriteStream(record.snapshotPath) + ); + const postSnapshot = + await record.worker.getHeapStatistics?.(); + record.postGcHeapUsed = Number( + postSnapshot?.used_heap_size ?? 0 + ); + } + record.finalized = true; + })().catch((error: unknown) => { + recordTimeline({ + type: `worker-profile-error:${ + error instanceof Error + ? error.message.slice(0, 160) + : String(error).slice(0, 160) + }`, + }); + record.finalized = true; + }); + return record.finalizing; + }; + + WorkerClass.prototype.postMessage = function ( + this: InstrumentedWorker, + message: unknown, + transferList?: readonly WorkerTransferable[] + ): void { + if (typeof message === 'object' && message !== null) { + const value = message as JsonRecord; + if (value['type'] === 'request') { + const kind = classifyRequest(value); + if (kind) { + const record = createWorkerRecord(this, kind); + if (kind === 'playlist-refresh.worker') { + const payload = value['payload'] as JsonRecord; + record.operationId = String(payload['operationId']); + record.playlistId = String(payload['playlistId']); + operationWorkers.set(record.operationId, record); + recordTimeline({ + operationId: record.operationId, + playlistId: record.playlistId, + type: 'playlist-request', + }); + } else if ( + typeof value['requestId'] === 'string' && + typeof value['operation'] === 'string' + ) { + dbRequests.set(value['requestId'], { + operation: value['operation'], + playlistId: readDatabasePlaylistId(value), + record, + }); + recordTimeline({ + operation: value['operation'], + playlistId: + readDatabasePlaylistId(value) ?? undefined, + requestId: value['requestId'], + type: 'db-request', + }); + } + startWorker(record); + } + } else if ( + value['type'] === 'cancel' && + typeof value['operationId'] === 'string' + ) { + const record = operationWorkers.get(value['operationId']); + if (record) { + record.cancelPostedEpochMs = nowEpochMs(); + recordTimeline({ + operationId: value['operationId'], + type: 'playlist-cancel-posted', + }); + } + } + } + originalPostMessage.call(this, message, transferList); + }; + WorkerClass.prototype.terminate = function ( + this: InstrumentedWorker + ): Promise { + const record = records.get(this); + if (!record || !state.active) { + return originalTerminate.call(this); + } + const markTerminated = (code: number): number => { + record.terminatedEpochMs = nowEpochMs(); + record.finalized = true; + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + type: `${record.kind}-terminated`, + }); + return code; + }; + if (!state.diagnostic) { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + record.sampleTimer = null; + } + return originalTerminate.call(this).then(markTerminated); + } + return finalizeWorker(record) + .then(() => originalTerminate.call(this)) + .then(markTerminated); + }; + + const inspectorPost = ( + session: import('node:inspector').Session, + method: string, + params: JsonRecord = {} + ): Promise => + new Promise((resolve, reject) => { + session.post(method, params, (error, result) => { + if (error) { + reject(error); + } else { + resolve((result ?? {}) as JsonRecord); + } + }); + }); + const normalizeWorkerCpuProfile = (profile: unknown): unknown => { + if (typeof profile !== 'string') { + return profile; + } + try { + return JSON.parse(profile) as unknown; + } catch { + // Node 24's Worker CPU profiler can leave quotes inside + // RegExp pseudo-frame names unescaped. Repair only those + // generated frame names, then require the full JSON to parse. + const fieldPrefix = '"functionName":"RegExp: '; + const fieldSuffix = '","lineNumber":'; + const valuePrefixLength = '"functionName":"'.length; + let cursor = 0; + let repaired = ''; + while (true) { + const fieldStart = profile.indexOf(fieldPrefix, cursor); + if (fieldStart < 0) { + repaired += profile.slice(cursor); + break; + } + const valueStart = fieldStart + valuePrefixLength; + const valueEnd = profile.indexOf(fieldSuffix, valueStart); + if (valueEnd < 0) { + throw new Error( + 'Worker CPU profile has an unterminated RegExp frame' + ); + } + repaired += + profile.slice(cursor, fieldStart) + + '"functionName":' + + JSON.stringify(profile.slice(valueStart, valueEnd)); + cursor = valueEnd + 1; + } + return JSON.parse(repaired) as unknown; + } + }; + const sampleMain = (): void => { + const memory = process.memoryUsage(); + state.mainPeakHeap = Math.max(state.mainPeakHeap, memory.heapUsed); + state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss); + for (const metric of app.getAppMetrics()) { + const type = String(metric.type).toLowerCase(); + if (type.includes('tab') || type.includes('renderer')) { + state.rendererPeakRss = Math.max( + state.rendererPeakRss, + Number(metric.memory?.workingSetSize ?? 0) * 1024 + ); + } + } + }; + const attachWindowListeners = (): void => { + for (const window of BrowserWindow.getAllWindows()) { + const unresponsive = () => { + state.unresponsiveEvents += 1; + }; + const responsive = () => { + state.responsiveEvents += 1; + }; + window.on('unresponsive', unresponsive); + window.on('responsive', responsive); + state.windowListeners.push({ + responsive, + unresponsive, + window, + }); + } + }; + + const api = { + status: (): MainCaptureStatus => ({ + databasePending: dbRequests.size, + databaseRequests: state.timeline.filter( + (entry) => entry['type'] === 'db-request' + ).length, + databaseUpsertsCompleted: state.timeline.filter( + (entry) => + entry['type'] === 'db-response' && + entry['operation'] === 'DB_UPSERT_APP_PLAYLIST' && + entry['success'] === true + ).length, + playlistTerminated: [...records.values()].filter( + (record) => + record.kind === 'playlist-refresh.worker' && + record.terminatedEpochMs !== null + ).length, + playlistResponsesFailed: state.timeline.filter( + (entry) => + entry['type'] === 'playlist-response' && + entry['success'] === false + ).length, + playlistResponses: state.timeline.filter( + (entry) => entry['type'] === 'playlist-response' + ).length, + playlistResponsesSucceeded: state.timeline.filter( + (entry) => + entry['type'] === 'playlist-response' && + entry['success'] === true + ).length, + }), + start: async (options: MainCaptureStartOptions): Promise => { + state.active = true; + state.diagnostic = options.diagnostic; + state.outputDirectory = options.outputDirectory; + state.timeline = []; + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + state.rendererPeakRss = 0; + state.postGcHeap = null; + state.postGcRss = null; + state.unresponsiveEvents = 0; + state.responsiveEvents = 0; + state.cpuStart = process.cpuUsage(); + state.eventLoopStart = + perfHooks.performance.eventLoopUtilization(); + state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ + resolution: 1, + }); + state.eventLoopDelay.enable(); + attachWindowListeners(); + sampleMain(); + state.sampleTimer = setInterval(sampleMain, 20); + if (state.diagnostic) { + const session = new inspector.Session(); + session.connect(); + state.inspectorSession = session; + await inspectorPost(session, 'Profiler.enable'); + await inspectorPost(session, 'Profiler.start'); + state.mainProfilePath = path.join( + state.outputDirectory, + 'main.cpuprofile' + ); + state.mainSnapshotPath = path.join( + state.outputDirectory, + 'main.heapsnapshot' + ); + } + }, + stop: async (): Promise => { + if (state.sampleTimer) { + clearInterval(state.sampleTimer); + state.sampleTimer = null; + } + state.eventLoopDelay?.disable(); + sampleMain(); + const cpu = process.cpuUsage(state.cpuStart ?? undefined); + const eluEnd = perfHooks.performance.eventLoopUtilization(); + const elu = perfHooks.performance.eventLoopUtilization( + state.eventLoopStart ?? undefined + ); + const eventLoopUtilization = + perfHooks.performance.nodeTiming.loopStart < 0 || + (elu.active === 0 && + elu.idle === 0 && + eluEnd.active === 0 && + eluEnd.idle === 0) + ? null + : elu.utilization; + const delay = state.eventLoopDelay; + const databaseRecords = [...records.values()].filter( + (record) => + record.kind === 'database.worker' && + record.sampleTimer !== null + ); + await Promise.all( + databaseRecords.map((record) => finalizeWorker(record)) + ); + const session = + state.inspectorSession ?? new inspector.Session(); + if (!state.inspectorSession) { + session.connect(); + } + if (state.diagnostic && state.mainProfilePath) { + const result = await inspectorPost( + session, + 'Profiler.stop' + ); + fs.writeFileSync( + state.mainProfilePath, + JSON.stringify(result['profile']) + ); + } + await inspectorPost(session, 'HeapProfiler.enable'); + await inspectorPost(session, 'HeapProfiler.collectGarbage'); + const postGc = process.memoryUsage(); + state.postGcHeap = postGc.heapUsed; + state.postGcRss = postGc.rss; + if (state.diagnostic && state.mainSnapshotPath) { + const output = fs.createWriteStream(state.mainSnapshotPath); + const onChunk = (message: { params: { chunk: string } }) => + output.write(message.params.chunk); + session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + await inspectorPost( + session, + 'HeapProfiler.takeHeapSnapshot', + { reportProgress: false } + ); + session.off('HeapProfiler.addHeapSnapshotChunk', onChunk); + output.end(); + await new Promise((resolve, reject) => { + output.once('finish', resolve); + output.once('error', reject); + }); + } + session.disconnect(); + state.inspectorSession = null; + for (const listener of state.windowListeners) { + listener.window.off('unresponsive', listener.unresponsive); + listener.window.off('responsive', listener.responsive); + } + state.windowListeners = []; + state.active = false; + + const workers = [...records.values()] + .filter( + (record) => + record.kind === 'playlist-refresh.worker' || + record.heapPeak > 0 + ) + .map((record) => ({ + cancelPostedEpochMs: record.cancelPostedEpochMs, + cpuSystemMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.system - record.cpuFirst.system + : null, + cpuUserMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.user - record.cpuFirst.user + : null, + eventLoopDelay: record.eventLoopDelay, + eventLoopDelayUnavailableReason: + record.eventLoopDelay === null + ? record.terminatedEpochMs !== null + ? 'worker-terminated-before-profile-flush' + : 'worker-self-profile-result-unavailable' + : null, + eventLoopUtilization: record.elu, + kind: record.kind, + operationId: record.operationId, + peakExternalBytes: record.externalPeak, + peakHeapUsedBytes: record.heapPeak, + playlistId: record.playlistId, + postGcHeapUsedBytes: record.postGcHeapUsed, + profilePath: record.profilePath, + responseEpochMs: record.responseEpochMs, + snapshotPath: record.snapshotPath, + terminatedEpochMs: record.terminatedEpochMs, + })); + return { + cpuProfilePath: state.mainProfilePath, + cpuSystemMicros: cpu.system, + cpuUserMicros: cpu.user, + eventLoopDelay: { + maxMs: Number(delay?.max ?? 0) / 1e6, + p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, + p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + }, + // Electron's Chromium-owned main message pump currently + // leaves Node's libuv ELU counters at zero. Preserve that + // as unavailable instead of reporting a misleading 0%. + eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + eventLoopUtilization === null + ? 'electron-main-embedded-event-loop' + : null, + heapSnapshotPath: state.mainSnapshotPath, + memory: { + peakHeapUsedBytes: state.mainPeakHeap, + peakRssBytes: state.mainPeakRss, + postGcHeapUsedBytes: state.postGcHeap, + postGcRssBytes: state.postGcRss, + }, + rendererPeakRssBytes: state.rendererPeakRss, + responsiveEvents: state.responsiveEvents, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: state.timeline, + unresponsiveEvents: state.unresponsiveEvents, + workers, + }; + }, + }; + target[stateKey] = api; + }, MAIN_CAPTURE_STATE_KEY); +} + +export async function startMainCapture( + electronApp: ElectronApplication, + options: MainCaptureStartOptions +): Promise { + await electronApp.evaluate( + async (_electron, input) => { + const target = globalThis as unknown as Record; + const api = target[input.stateKey] as { + start(options: MainCaptureStartOptions): Promise; + }; + await api.start(input.options); + }, + { options, stateKey: MAIN_CAPTURE_STATE_KEY } + ); +} + +export async function readMainCaptureStatus( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + status(): MainCaptureStatus; + }; + return api.status(); + }, MAIN_CAPTURE_STATE_KEY); +} + +export async function stopMainCapture( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + stop(): Promise; + }; + return api.stop(); + }, MAIN_CAPTURE_STATE_KEY); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts new file mode 100644 index 000000000..a759db490 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -0,0 +1,508 @@ +/* eslint-disable max-lines -- Renderer CDP and injected-page lifecycle are kept together so raw artifact boundaries stay auditable. */ +import { once } from 'node:events'; +import { createWriteStream } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { CDPSession, Page } from '@playwright/test'; + +import type { + RendererCaptureMetrics, + RendererProbeMetrics, +} from './m3u-refresh-cancellation-contract'; +import { summarizeNumbers } from './performance-statistics'; + +const RENDERER_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshRendererCapture'; +const HEARTBEAT_INTERVAL_MS = 50; + +export interface RendererCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; + readonly sourceTitle: string; +} + +export interface RunningRendererCapture { + readonly session: CDPSession; + markOperationStart(): Promise; + stop(): Promise; + waitForCancelClick(): Promise; + waitForTerminal(): Promise; +} + +interface HeapUsageResult { + readonly usedSize: number; +} + +interface TraceData { + readonly value: readonly unknown[]; +} + +export async function startRendererCapture( + page: Page, + options: RendererCaptureStartOptions +): Promise { + const session = await page.context().newCDPSession(page); + await session.send('Runtime.enable'); + await session.send('Performance.enable'); + await installRendererProbe(page, options.sourceTitle); + + const heapSamples: number[] = []; + let heapSampleBusy = false; + let heapSampleError: unknown = null; + const sampleHeap = async (): Promise => { + if (heapSampleBusy) { + return; + } + heapSampleBusy = true; + try { + const result = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (Number.isFinite(result.usedSize)) { + heapSamples.push(result.usedSize); + } + } catch (error) { + heapSampleError ??= error; + } finally { + heapSampleBusy = false; + } + }; + await sampleHeap(); + throwHeapSampleError(heapSampleError); + const heapSampleTimer = setInterval(() => void sampleHeap(), 20); + + const cpuProfilePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.cpuprofile') + : null; + const heapSnapshotPath = options.diagnostic + ? join(options.outputDirectory, 'renderer.heapsnapshot') + : null; + const tracePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.trace.json') + : null; + let traceOutput: ReturnType | null = null; + let traceFirstEvent = true; + let resolveTraceComplete: (() => void) | null = null; + const traceComplete = new Promise((resolve) => { + resolveTraceComplete = resolve; + }); + const onTraceData = (data: TraceData): void => { + if (!traceOutput) { + return; + } + for (const event of data.value) { + traceOutput.write( + `${traceFirstEvent ? '' : ','}${JSON.stringify(event)}` + ); + traceFirstEvent = false; + } + }; + const onTraceComplete = (): void => { + resolveTraceComplete?.(); + resolveTraceComplete = null; + }; + + if (options.diagnostic) { + await session.send('Profiler.enable'); + await session.send('Profiler.start'); + traceOutput = createWriteStream( + requireArtifactPath(tracePath, 'renderer trace') + ); + traceOutput.write('{"traceEvents":['); + session.on('Tracing.dataCollected', onTraceData); + session.on('Tracing.tracingComplete', 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', + }); + } + + let stopped = false; + return Object.freeze({ + session, + markOperationStart: () => markRendererOperationStart(page), + stop: async () => { + if (stopped) { + throw new Error('Renderer capture was already stopped'); + } + stopped = true; + const probe = await stopRendererProbe(page); + clearInterval(heapSampleTimer); + await sampleHeap(); + throwHeapSampleError(heapSampleError); + + if (options.diagnostic) { + const cpuProfile = await session.send('Profiler.stop'); + await writeFile( + requireArtifactPath(cpuProfilePath, 'renderer CPU profile'), + JSON.stringify(cpuProfile['profile']), + 'utf8' + ); + await session.send('Tracing.end'); + await traceComplete; + session.off('Tracing.dataCollected', onTraceData); + session.off('Tracing.tracingComplete', onTraceComplete); + traceOutput?.write(']}'); + traceOutput?.end(); + if (traceOutput) { + await once(traceOutput, 'finish'); + } + } + + await session.send('HeapProfiler.enable'); + await session.send('HeapProfiler.collectGarbage'); + const postGc = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + + if (options.diagnostic) { + const output = createWriteStream( + requireArtifactPath( + heapSnapshotPath, + 'renderer heap snapshot' + ) + ); + const onSnapshotChunk = (message: { + readonly chunk: string; + }): void => { + output.write(message.chunk); + }; + session.on( + 'HeapProfiler.addHeapSnapshotChunk', + onSnapshotChunk + ); + await session.send('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + }); + session.off( + 'HeapProfiler.addHeapSnapshotChunk', + onSnapshotChunk + ); + output.end(); + await once(output, 'finish'); + } + + await session.detach(); + return Object.freeze({ + cpuProfilePath, + frameGap: summarizeNumbers(probe.frameGapsMs), + heapSnapshotPath, + heartbeatDelay: summarizeNumbers(probe.heartbeatDelaysMs), + longTask: summarizeNumbers(probe.longTasksMs), + peakHeapUsedBytes: Math.max(0, ...heapSamples), + postGcHeapUsedBytes: postGc.usedSize, + probe, + tracePath, + }); + }, + waitForCancelClick: () => + page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record< + string, + unknown + >; + const state = target[stateKey] as + | { cancelClickEpochMs: number | null } + | undefined; + return typeof state?.cancelClickEpochMs === 'number'; + }, + RENDERER_CAPTURE_STATE_KEY, + { timeout: 60_000 } + ) + .then(() => undefined), + waitForTerminal: () => + page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record< + string, + unknown + >; + const state = target[stateKey] as + | { uiPaintedEpochMs: number | null } + | undefined; + return typeof state?.uiPaintedEpochMs === 'number'; + }, + RENDERER_CAPTURE_STATE_KEY, + { timeout: 120_000 } + ) + .then(() => undefined), + }); +} + +function throwHeapSampleError(error: unknown): void { + if (error instanceof Error) { + throw error; + } + if (error !== null) { + throw new Error(`Renderer heap sampling failed: ${String(error)}`); + } +} + +function requireArtifactPath(value: string | null, label: string): string { + if (value === null) { + throw new Error(`${label} path is unavailable`); + } + return value; +} + +async function installRendererProbe( + page: Page, + sourceTitle: string +): Promise { + await page.evaluate( + ({ heartbeatIntervalMs, sourceTitle: title, stateKey }) => { + interface PlaylistRefreshEvent { + readonly operationId: string; + readonly phase?: string; + readonly status: string; + } + interface ElectronApi { + onPlaylistRefreshEvent( + callback: (event: PlaylistRefreshEvent) => void + ): (() => void) | undefined; + } + interface ProbeState { + cancelButtonFound: boolean; + cancelClickEpochMs: number | null; + events: { + operationId: string; + phase: string | null; + receivedEpochMs: number; + status: string; + }[]; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + heartbeatTimer: number; + lastFrameAt: number | null; + lastHeartbeatAt: number; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + mutationObserver: MutationObserver; + operationId: string | null; + operationStartEpochMs: number; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + uiPhaseEpochMs: Record; + uiSettledEpochMs: number | null; + unsubscribe: () => void; + } + const target = globalThis as unknown as Record; + const epoch = (): number => + performance.timeOrigin + performance.now(); + const sourceRow = (): Element | undefined => + Array.from(document.querySelectorAll('app-playlist-item')).find( + (element) => element.textContent?.includes(title) + ); + const state = { + cancelButtonFound: false, + cancelClickEpochMs: null, + events: [], + frameGapsMs: [], + frameRequestId: 0, + heartbeatDelaysMs: [], + heartbeatTimer: 0, + lastFrameAt: null, + lastHeartbeatAt: performance.now(), + longTaskObserver: null, + longTasksMs: [], + mutationObserver: null, + operationId: null, + operationStartEpochMs: 0, + terminalEpochMs: null, + uiPaintFrameRequestId: null, + uiPaintedEpochMs: null, + uiPhaseEpochMs: Object.create(null) as Record, + uiSettledEpochMs: null, + unsubscribe: () => undefined, + } as unknown as ProbeState; + const inspectUi = (): void => { + const row = sourceRow(); + const message = + row + ?.querySelector('.busy-state__message') + ?.textContent?.trim() + .toLowerCase() ?? ''; + for (const phase of ['fetch', 'pars', 'sav']) { + if ( + message.includes(phase) && + state.uiPhaseEpochMs[phase] === undefined + ) { + state.uiPhaseEpochMs[phase] = epoch(); + } + } + if ( + state.cancelClickEpochMs !== null && + state.uiSettledEpochMs === null && + row !== undefined && + row.querySelector('.busy-state__message') === null && + row.querySelector('.action-spinner') === null && + row.querySelector('.cancel-btn') === null + ) { + state.uiSettledEpochMs = epoch(); + state.uiPaintFrameRequestId = requestAnimationFrame(() => { + state.uiPaintFrameRequestId = requestAnimationFrame( + () => { + state.uiPaintedEpochMs = epoch(); + state.terminalEpochMs = state.uiPaintedEpochMs; + state.uiPaintFrameRequestId = null; + } + ); + }); + } + }; + const frame = (timestamp: number): void => { + if (state.lastFrameAt !== null) { + state.frameGapsMs.push(timestamp - state.lastFrameAt); + } + state.lastFrameAt = timestamp; + state.frameRequestId = requestAnimationFrame(frame); + }; + state.frameRequestId = requestAnimationFrame(frame); + state.heartbeatTimer = window.setInterval(() => { + const now = performance.now(); + state.heartbeatDelaysMs.push( + Math.max( + 0, + now - state.lastHeartbeatAt - heartbeatIntervalMs + ) + ); + state.lastHeartbeatAt = now; + }, heartbeatIntervalMs); + if ( + typeof PerformanceObserver === 'function' && + PerformanceObserver.supportedEntryTypes.includes('longtask') + ) { + state.longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + state.longTasksMs.push(entry.duration); + } + }); + state.longTaskObserver.observe({ entryTypes: ['longtask'] }); + } + state.mutationObserver = new MutationObserver(inspectUi); + state.mutationObserver.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + const electron = ( + globalThis as unknown as { electron?: ElectronApi } + ).electron; + if (!electron?.onPlaylistRefreshEvent) { + throw new Error('Playlist refresh event bridge is unavailable'); + } + const unsubscribe = electron.onPlaylistRefreshEvent((event) => { + const receivedEpochMs = epoch(); + if (state.operationId === null && event.status === 'started') { + state.operationId = event.operationId; + } + if (state.operationId !== event.operationId) { + return; + } + state.events.push({ + operationId: event.operationId, + phase: event.phase ?? null, + receivedEpochMs, + status: event.status, + }); + if ( + event.status === 'progress' && + event.phase === 'parsing' && + state.cancelClickEpochMs === null + ) { + const button = sourceRow()?.querySelector('.cancel-btn'); + state.cancelButtonFound = + button instanceof HTMLButtonElement; + if (button instanceof HTMLButtonElement) { + state.cancelClickEpochMs = epoch(); + button.click(); + } + } + }); + state.unsubscribe = + typeof unsubscribe === 'function' + ? unsubscribe + : () => undefined; + target[stateKey] = state; + inspectUi(); + }, + { + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + sourceTitle, + stateKey: RENDERER_CAPTURE_STATE_KEY, + } + ); +} + +async function markRendererOperationStart(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as { operationStartEpochMs: number }; + state.operationStartEpochMs = + performance.timeOrigin + performance.now(); + return state.operationStartEpochMs; + }, RENDERER_CAPTURE_STATE_KEY); +} + +async function stopRendererProbe(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as { + cancelButtonFound: boolean; + cancelClickEpochMs: number | null; + events: RendererProbeMetrics['events']; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + heartbeatTimer: number; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + mutationObserver: MutationObserver; + operationStartEpochMs: number; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + uiPhaseEpochMs: Record; + uiSettledEpochMs: number | null; + unsubscribe: () => void; + }; + cancelAnimationFrame(state.frameRequestId); + if (state.uiPaintFrameRequestId !== null) { + cancelAnimationFrame(state.uiPaintFrameRequestId); + } + clearInterval(state.heartbeatTimer); + for (const entry of state.longTaskObserver?.takeRecords() ?? []) { + state.longTasksMs.push(entry.duration); + } + state.longTaskObserver?.disconnect(); + state.mutationObserver.disconnect(); + state.unsubscribe(); + const result: RendererProbeMetrics = { + cancelButtonFound: state.cancelButtonFound, + cancelClickEpochMs: state.cancelClickEpochMs, + events: [...state.events], + frameGapsMs: [...state.frameGapsMs], + heartbeatDelaysMs: [...state.heartbeatDelaysMs], + longTasksMs: [...state.longTasksMs], + operationStartEpochMs: state.operationStartEpochMs, + terminalEpochMs: state.terminalEpochMs, + uiPaintedEpochMs: state.uiPaintedEpochMs, + uiPhaseEpochMs: { ...state.uiPhaseEpochMs }, + uiSettledEpochMs: state.uiSettledEpochMs, + }; + delete target[stateKey]; + return result; + }, RENDERER_CAPTURE_STATE_KEY); +} diff --git a/apps/electron-backend-e2e/src/performance/performance-statistics.ts b/apps/electron-backend-e2e/src/performance/performance-statistics.ts new file mode 100644 index 000000000..ab7d53140 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-statistics.ts @@ -0,0 +1,64 @@ +import type { NumericDistribution } from './m3u-refresh-cancellation-contract'; + +export function summarizeNumbers( + values: readonly (number | null | undefined)[] +): NumericDistribution { + const samples = values + .filter( + (value): value is number => value !== null && value !== undefined + ) + .filter((value) => Number.isFinite(value) && value >= 0) + .sort((left, right) => left - right); + + if (samples.length === 0) { + return Object.freeze({ + count: 0, + max: null, + mean: null, + median: null, + min: null, + p95: null, + p99: null, + }); + } + + const total = samples.reduce((sum, value) => sum + value, 0); + return Object.freeze({ + count: samples.length, + max: samples.at(-1) ?? null, + mean: total / samples.length, + median: percentile(samples, 50), + min: samples[0] ?? null, + p95: percentile(samples, 95), + p99: percentile(samples, 99), + }); +} + +export function nonNegativeDifference( + endEpochMs: number | null | undefined, + startEpochMs: number | null | undefined +): number | null { + if ( + endEpochMs === null || + endEpochMs === undefined || + startEpochMs === null || + startEpochMs === undefined + ) { + return null; + } + const difference = endEpochMs - startEpochMs; + return Number.isFinite(difference) && difference >= 0 ? difference : null; +} + +function percentile(sortedValues: readonly number[], rank: number): number { + if (sortedValues.length === 1) { + return sortedValues[0] ?? 0; + } + + const position = ((sortedValues.length - 1) * rank) / 100; + const lowerIndex = Math.floor(position); + const upperIndex = Math.ceil(position); + const lower = sortedValues[lowerIndex] ?? 0; + const upper = sortedValues[upperIndex] ?? lower; + return lower + (upper - lower) * (position - lowerIndex); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts new file mode 100644 index 000000000..14e65387d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; + +export const SYNTHETIC_M3U_SEED = 240_724; +export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000; + +export interface SyntheticM3uFixture { + readonly body: string; + readonly bytes: number; + readonly channelCount: number; + readonly sha256: string; +} + +export function createSyntheticM3uFixture(): SyntheticM3uFixture { + const lines = new Array(1 + SYNTHETIC_M3U_CHANNEL_COUNT * 2); + lines[0] = '#EXTM3U'; + + for (let offset = 0; offset < SYNTHETIC_M3U_CHANNEL_COUNT; offset += 1) { + const index = offset + 1; + const group = (offset % 100) + 1; + const lineOffset = 1 + offset * 2; + const stableIndex = String(index).padStart(6, '0'); + + lines[lineOffset] = `#EXTINF:-1 group-title="Synthetic Group ${String( + group + ).padStart( + 3, + '0' + )}",Synthetic Channel ${SYNTHETIC_M3U_SEED}-${stableIndex}`; + lines[lineOffset + 1] = + `http://127.0.0.1/stream/${SYNTHETIC_M3U_SEED}/${index}`; + } + + const body = `${lines.join('\n')}\n`; + return Object.freeze({ + body, + bytes: Buffer.byteLength(body, 'utf8'), + channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + sha256: createHash('sha256').update(body, 'utf8').digest('hex'), + }); +} diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index aaf54a94e..54d2ddc9f 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -220,6 +220,29 @@ describe('main preload DB IPC contract', () => { ); }); + it('preserves a structured playlist cancellation result across the context bridge', async () => { + const api = getExposedApi(); + const payload = { + operationId: 'playlist-refresh-cancelled', + playlistId: 'playlist-1', + title: 'Large playlist', + url: 'http://127.0.0.1/large.m3u', + }; + const cancelledResult = { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + } as const; + mockIpcRenderer.invoke.mockResolvedValueOnce(cancelledResult); + + await expect(api.refreshPlaylist(payload)).resolves.toEqual( + cancelledResult + ); + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + 'PLAYLIST:REFRESH', + payload + ); + }); + it('keeps the legacy save-content progress bridge scoped to progress events', () => { const api = getExposedApi(); const callback = jest.fn(); 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 d25d11f9b..f1300f5a6 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -527,7 +527,94 @@ describe('playlist IPC events', () => { expect(worker.terminate).toHaveBeenCalled(); }); - it('routes refresh cancellation to the active worker and converts worker error responses to Error instances', async () => { + it('settles cancellation without waiting for a CPU-bound refresh worker', async () => { + const ipcEvent = createIpcEvent(); + const payload: PlaylistRefreshPayload = { + operationId: 'refresh-busy', + playlistId: 'playlist-busy', + title: 'Busy playlist', + filePath: '/playlists/busy.m3u', + }; + const refreshPromise = getHandler(PLAYLIST_REFRESH)(ipcEvent, payload); + const worker = mockWorkerInstances[0]; + let outcome: + | { error: unknown; status: 'rejected' } + | { status: 'resolved'; value: unknown } + | undefined; + void refreshPromise.then( + (value) => { + outcome = { status: 'resolved', value }; + }, + (error: unknown) => { + outcome = { error, status: 'rejected' }; + } + ); + + worker.emit('message', { type: 'ready' }); + worker.emit('message', { + event: { + operationId: payload.operationId, + phase: 'parsing', + playlistId: payload.playlistId, + status: 'progress', + } satisfies PlaylistRefreshEvent, + type: 'event', + }); + + let finishTermination: ((exitCode: number) => void) | undefined; + worker.terminate.mockImplementationOnce( + () => + new Promise((resolveTermination) => { + finishTermination = resolveTermination; + }) + ); + const cancelPromise = getHandler(PLAYLIST_CANCEL_REFRESH)( + createIpcEvent(), + payload.operationId + ); + await Promise.resolve(); + + expect(worker.removeAllListeners).toHaveBeenCalledTimes(1); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(worker.postMessage).toHaveBeenLastCalledWith({ + operationId: payload.operationId, + type: 'cancel', + }); + expect(outcome).toBeUndefined(); + expect(ipcEvent.sender.send).not.toHaveBeenCalledWith( + PLAYLIST_REFRESH_EVENT, + expect.objectContaining({ status: 'cancelled' }) + ); + + finishTermination?.(1); + await expect(cancelPromise).resolves.toEqual({ success: true }); + await Promise.resolve(); + + expect(outcome).toEqual({ + status: 'resolved', + value: { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + }, + }); + expect(ipcEvent.sender.send).toHaveBeenLastCalledWith( + PLAYLIST_REFRESH_EVENT, + { + operationId: payload.operationId, + phase: 'parsing', + playlistId: payload.playlistId, + status: 'cancelled', + } + ); + await expect( + getHandler(PLAYLIST_CANCEL_REFRESH)( + createIpcEvent(), + payload.operationId + ) + ).resolves.toEqual({ success: false }); + }); + + it('converts playlist refresh worker error responses to Error instances', async () => { const payload: PlaylistRefreshPayload = { operationId: 'refresh-error', playlistId: 'playlist-error', @@ -540,17 +627,6 @@ describe('playlist IPC events', () => { ); const worker = mockWorkerInstances[0]; - expect( - await getHandler(PLAYLIST_CANCEL_REFRESH)( - createIpcEvent(), - 'refresh-error' - ) - ).toEqual({ success: true }); - expect(worker.postMessage).toHaveBeenCalledWith({ - operationId: 'refresh-error', - type: 'cancel', - }); - const rejectedRefresh = expect(refreshPromise).rejects.toMatchObject({ message: 'Refresh failed', name: 'PlaylistRefreshFailure', @@ -568,13 +644,6 @@ describe('playlist IPC events', () => { }); await rejectedRefresh; - - expect( - await getHandler(PLAYLIST_CANCEL_REFRESH)( - createIpcEvent(), - 'refresh-error' - ) - ).toEqual({ success: false }); }); it('returns save dialog paths and writes files through the filesystem handler', async () => { diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index d177c7987..ddd273d5a 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -11,9 +11,11 @@ import { AUTO_UPDATE_PLAYLISTS, PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, + PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE, PLAYLIST_REFRESH_EVENT, ElectronBridgeTrustOptions, Playlist, + PlaylistRefreshCancelledResult, PlaylistRefreshEvent, PlaylistRefreshPayload, summarizeAutoUpdateOutcomes, @@ -40,10 +42,7 @@ export default class PlaylistEvents { const playlistWriteAuthorizer = new PlaylistWriteAuthorizer(); type ActivePlaylistRefresh = { - reject: (reason?: unknown) => void; - resolve: (value: Playlist) => void; - sender: WebContents; - worker: Worker; + cancel: () => Promise; }; const activePlaylistRefreshes = new Map(); @@ -170,76 +169,126 @@ ipcMain.handle( async (event, payload: PlaylistRefreshPayload) => { const worker = resolvePlaylistRefreshWorker(); - return await new Promise((resolve, reject) => { - const cleanup = async (): Promise => { - activePlaylistRefreshes.delete(payload.operationId); - worker.removeAllListeners(); - await worker.terminate().catch(() => undefined); - }; + return await new Promise( + (resolve, reject) => { + let cleanupPromise: Promise | null = null; + let lastPhase: PlaylistRefreshEvent['phase'] = payload.url + ? 'fetching' + : 'reading-file'; + let settled = false; - activePlaylistRefreshes.set(payload.operationId, { - worker, - sender: event.sender, - resolve, - reject, - }); + const cleanup = (): Promise => { + cleanupPromise ??= (async () => { + activePlaylistRefreshes.delete(payload.operationId); + worker.removeAllListeners(); + await worker.terminate().catch(() => undefined); + })(); + return cleanupPromise; + }; - worker.on( - 'message', - async (message: PlaylistRefreshWorkerMessage) => { - if (message.type === 'ready') { - worker.postMessage({ - type: 'request', - payload, - }); + const cancel = async (): Promise => { + if (settled) { return; } + settled = true; - if (message.type === 'event') { - emitPlaylistRefreshEvent(event.sender, message.event); - return; + try { + worker.postMessage({ + type: 'cancel', + operationId: payload.operationId, + }); + } catch { + // Termination below is authoritative even if cooperative + // cancellation cannot be delivered. } await cleanup(); + emitPlaylistRefreshEvent(event.sender, { + operationId: payload.operationId, + playlistId: payload.playlistId, + phase: lastPhase, + status: 'cancelled', + }); + resolve({ + operationId: payload.operationId, + type: PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE, + }); + }; - const response = - message as PlaylistRefreshWorkerResponseMessage; - if (response.success && response.result) { - resolve(response.result); + const activeRefresh: ActivePlaylistRefresh = { cancel }; + activePlaylistRefreshes.set(payload.operationId, activeRefresh); + + worker.on( + 'message', + async (message: PlaylistRefreshWorkerMessage) => { + if (settled) { + return; + } + + if (message.type === 'ready') { + worker.postMessage({ + type: 'request', + payload, + }); + return; + } + + if (message.type === 'event') { + lastPhase = message.event.phase ?? lastPhase; + emitPlaylistRefreshEvent( + event.sender, + message.event + ); + return; + } + + settled = true; + await cleanup(); + + const response = + message as PlaylistRefreshWorkerResponseMessage; + if (response.success && response.result) { + resolve(response.result); + return; + } + + reject( + createPlaylistRefreshError( + response.error ?? { + message: + 'Playlist refresh worker request failed', + } + ) + ); + } + ); + + worker.on('error', async (error) => { + if (settled) { + return; + } + settled = true; + await cleanup(); + reject(error); + }); + + worker.on('exit', async (code) => { + if (settled) { return; } + settled = true; + await cleanup(); reject( - createPlaylistRefreshError( - response.error ?? { - message: - 'Playlist refresh worker request failed', - } + new Error( + code === 0 + ? 'Playlist refresh worker exited unexpectedly' + : `Playlist refresh worker stopped with exit code ${code}` ) ); - } - ); - - worker.on('error', async (error) => { - await cleanup(); - reject(error); - }); - - worker.on('exit', async (code) => { - if (!activePlaylistRefreshes.has(payload.operationId)) { - return; - } - - await cleanup(); - reject( - new Error( - code === 0 - ? 'Playlist refresh worker exited unexpectedly' - : `Playlist refresh worker stopped with exit code ${code}` - ) - ); - }); - }); + }); + } + ); } ); @@ -251,10 +300,7 @@ ipcMain.handle( return { success: false }; } - activeRefresh.worker.postMessage({ - type: 'cancel', - operationId, - }); + await activeRefresh.cancel(); return { success: true }; } diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 15d8783b3..d312ae21a 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -1,3 +1,5 @@ +import type { WorkerPerformanceCaptureResult } from './worker-performance-capture'; + export const DB_WORKER_OPERATIONS = [ 'DB_HAS_CATEGORIES', 'DB_GET_CATEGORIES', @@ -138,6 +140,7 @@ export interface DbWorkerResponseMessage { success: boolean; result?: TResult; error?: SerializedWorkerError; + performance?: WorkerPerformanceCaptureResult; } export type DbWorkerIncomingMessage = diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index e702baa59..b3b4d968a 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -88,6 +88,11 @@ import { deleteXtreamContent, restoreXtreamUserData, } from '../database/operations/xtream.operations'; +import { + armWorkerPerformanceCapture, + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; const loggerLabel = '[DB Worker]'; const batchDelayMs = Number.parseInt( @@ -778,7 +783,11 @@ async function executeRequest(message: DbWorkerRequestMessage) { case 'DB_REORDER_GLOBAL_FAVORITES': { const payload = message.payload as { - updates: { content_id: number; playlist_id: string; position: number }[]; + updates: { + content_id: number; + playlist_id: string; + position: number; + }[]; }; return reorderGlobalFavorites(db, payload.updates); } @@ -920,13 +929,18 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { return; } + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); try { const result = await executeRequest(message); + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: true, result, + performance, }); } catch (error) { console.error( @@ -934,11 +948,14 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { `Error handling ${message.operation}:`, error ); + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: false, error: serializeError(error), + 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 37f9f0588..790d6ac55 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -22,6 +22,11 @@ import { } from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; +import { + armWorkerPerformanceCapture, + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; type ActiveRefreshState = { cancelled: boolean; @@ -186,12 +191,17 @@ parentPort.on( return; } + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); try { const result = await executeRefresh(message.payload); + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', success: true, result, + performance, }); } catch (error) { const payload = message.payload; @@ -206,10 +216,13 @@ parentPort.on( }); } + const performance = + await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', success: false, error: serializeError(error), + performance, }); } } diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts index 05c7e2a80..601b9e62c 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts @@ -1,4 +1,8 @@ -import type { PlaylistRefreshEvent, PlaylistRefreshPayload } from '@iptvnator/shared/interfaces'; +import type { + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from '@iptvnator/shared/interfaces'; +import type { WorkerPerformanceCaptureResult } from './worker-performance-capture'; export interface PlaylistRefreshWorkerRequestMessage { type: 'request'; @@ -28,6 +32,7 @@ export interface PlaylistRefreshWorkerResponseMessage { message: string; stack?: string; }; + performance?: WorkerPerformanceCaptureResult; } export type PlaylistRefreshWorkerIncomingMessage = 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 new file mode 100644 index 000000000..2801935c3 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -0,0 +1,48 @@ +import { + armWorkerPerformanceCapture, + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +describe('worker performance capture', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + + afterEach(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('is disabled unless explicitly opted in', () => { + delete process.env[PROFILING_ENV]; + + expect(startWorkerPerformanceCapture()).toBeNull(); + }); + + it('reports finite worker event-loop metrics when opted in', async () => { + process.env[PROFILING_ENV] = '1'; + + const capture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(capture); + const blockStartedAt = performance.now(); + while (performance.now() - blockStartedAt < 20) { + // Deliberately block the loop so the histogram contract is tested. + } + const result = await finishWorkerPerformanceCapture(capture); + + expect(result).toEqual({ + eventLoopDelay: { + maxMs: expect.any(Number), + p95Ms: expect.any(Number), + p99Ms: expect.any(Number), + }, + eventLoopUtilization: expect.any(Number), + }); + expect(result?.eventLoopUtilization).toBeGreaterThanOrEqual(0); + expect(result?.eventLoopDelay.maxMs).toBeGreaterThan(10); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts new file mode 100644 index 000000000..db86f234d --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -0,0 +1,68 @@ +import { + monitorEventLoopDelay, + performance, + type IntervalHistogram, +} from 'node:perf_hooks'; + +const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +export interface WorkerPerformanceCaptureResult { + eventLoopDelay: { + maxMs: number; + p95Ms: number; + p99Ms: number; + }; + eventLoopUtilization: number; +} + +interface WorkerPerformanceCapture { + eventLoopDelay: IntervalHistogram; + eventLoopUtilizationStart: ReturnType< + typeof performance.eventLoopUtilization + >; +} + +export function startWorkerPerformanceCapture(): WorkerPerformanceCapture | null { + if (process.env[WORKER_PROFILING_ENV] !== '1') { + return null; + } + + const eventLoopDelay = monitorEventLoopDelay({ resolution: 1 }); + eventLoopDelay.enable(); + return { + eventLoopDelay, + eventLoopUtilizationStart: performance.eventLoopUtilization(), + }; +} + +export async function armWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): Promise { + if (capture) { + await new Promise((resolvePromise) => + setTimeout(resolvePromise, 2) + ); + } +} + +export async function finishWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): Promise { + if (!capture) { + return undefined; + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)); + capture.eventLoopDelay.disable(); + const eventLoopUtilization = performance.eventLoopUtilization( + capture.eventLoopUtilizationStart + ); + return { + eventLoopDelay: { + maxMs: capture.eventLoopDelay.max / 1e6, + p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, + p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, + }, + eventLoopUtilization: eventLoopUtilization.utilization, + }; +} diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index a376abbd3..48a4a468b 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -62,7 +62,16 @@ Two paths re-download an M3U playlist from its original source: - **Explicit refresh** — `PLAYLIST_REFRESH` runs in `playlist-refresh.worker.ts`, reports progress through `PLAYLIST_REFRESH_EVENT`, and is cancellable via - `PLAYLIST_CANCEL_REFRESH`. + `PLAYLIST_CANCEL_REFRESH`. Cancellation is owned by the main process: it first + sends the cooperative cancel message, then terminates the one-shot worker + without waiting for its event loop. The cancel IPC resolves only after the + worker has stopped, the correlated `cancelled` event has been emitted with the + last known phase, and a structured cancellation result is ready. That result + crosses both Electron IPC and the context bridge unchanged; + `PlaylistRefreshService` converts it into a renderer-local `AbortError`. + Relying on an error created in main or preload would lose its `name` at one of + those serialization boundaries. A cancelled refresh must not update the + renderer store or reach SQLite. - **Startup auto-update** — after `loadPlaylistsSuccess`, `AppComponent` sends `AUTO_UPDATE_PLAYLISTS` for every playlist with `autoRefresh === true`. The main process fulfils it in `playlist-auto-update.ts` on top of `playlist-source.ts`. @@ -87,6 +96,37 @@ dead source must never stall startup (issue #931): so refresh logging goes through `redactSensitiveData()` from `@iptvnator/shared/logging`. +### Refresh Cancellation Performance Regression + +The Electron E2E project includes a deterministic 100,000-channel cancellation +benchmark. It uses only a loopback synthetic M3U server, performs one warm-up, +five measured runs, and one diagnostic run, and writes summaries plus raw +profiles below the gitignored `dist/performance/` directory: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-refresh-cancel" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=after \ +pnpm nx run electron-backend-e2e:benchmark-m3u-refresh-cancellation +``` + +The output path must be an absolute, previously unused descendant of +`dist/performance/`. A formal run fails on a dirty worktree and records the +commit, source-state hash, OS/architecture, Node, Electron, and fixture identity +in its manifest. Commit the harness first and capture `baseline` from that clean +commit; commit the production change separately, rebuild, and capture `after` +with the same harness and machine. Set `IPTVNATOR_PERF_SMOKE=1` for one measured +run during harness development; smoke runs may be dirty and must not support +before/after claims. + +The target reserves and verifies CDP port 9222, freezes renderer long-task, +frame-gap, and heartbeat probes before forced post-GC heap collection, and +enables opt-in worker profiling. Worker event-loop delay is read from a +request-scoped `node:perf_hooks` capture; a worker terminated before it can flush +the capture reports the metric as unavailable rather than zero. Diagnostic CPU +profiles, heap snapshots, and Chromium traces are excluded from the five-run +headline distributions. + ### Reporting The Auto-Update Result Because auto-update isolates failures, it must also report them — otherwise a diff --git a/libs/services/src/lib/playlist-refresh.service.spec.ts b/libs/services/src/lib/playlist-refresh.service.spec.ts new file mode 100644 index 000000000..920db2940 --- /dev/null +++ b/libs/services/src/lib/playlist-refresh.service.spec.ts @@ -0,0 +1,49 @@ +import type { + ElectronBridgeApi, + PlaylistRefreshPayload, +} from '@iptvnator/shared/interfaces'; + +import { PlaylistRefreshService } from './playlist-refresh.service'; + +describe('PlaylistRefreshService', () => { + const originalElectron = window.electron; + const payload: PlaylistRefreshPayload = { + operationId: 'playlist-refresh-cancelled', + playlistId: 'playlist-1', + title: 'Large playlist', + url: 'http://127.0.0.1/large.m3u', + }; + + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: originalElectron, + writable: true, + }); + }); + + it('creates a renderer-local AbortError from a cancellation result', async () => { + const unsubscribe = jest.fn(); + const electron = { + onPlaylistRefreshEvent: jest.fn(() => unsubscribe), + refreshPlaylist: jest.fn().mockResolvedValue({ + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + }), + } as unknown as ElectronBridgeApi; + Object.defineProperty(window, 'electron', { + configurable: true, + value: electron, + writable: true, + }); + + await expect( + new PlaylistRefreshService().refreshPlaylist(payload) + ).rejects.toMatchObject({ + message: + 'Playlist refresh "playlist-refresh-cancelled" was cancelled', + name: 'AbortError', + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/services/src/lib/playlist-refresh.service.ts b/libs/services/src/lib/playlist-refresh.service.ts index d4ab49d79..f73f36ab7 100644 --- a/libs/services/src/lib/playlist-refresh.service.ts +++ b/libs/services/src/lib/playlist-refresh.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { + isPlaylistRefreshCancelledResult, Playlist, PlaylistRefreshEvent, PlaylistRefreshPayload, @@ -30,7 +31,15 @@ export class PlaylistRefreshService { }); try { - return await window.electron.refreshPlaylist(payload); + const result = await window.electron.refreshPlaylist(payload); + if (isPlaylistRefreshCancelledResult(result)) { + const error = new Error( + `Playlist refresh "${result.operationId}" was cancelled` + ); + error.name = 'AbortError'; + throw error; + } + return result; } finally { unsubscribe?.(); } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index b36355207..08214323f 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -21,6 +21,7 @@ import { XtreamBackupRecentlyViewedItem, } from './playlist-backup.interface'; import { + PlaylistRefreshCancelledResult, PlaylistRefreshEvent, PlaylistRefreshPayload, } from './playlist-refresh.interface'; @@ -671,7 +672,9 @@ export interface ElectronBridgeApi { url: string, method?: 'GET' | 'HEAD' ) => Promise; - refreshPlaylist: (payload: PlaylistRefreshPayload) => Promise; + refreshPlaylist: ( + payload: PlaylistRefreshPayload + ) => Promise; cancelPlaylistRefresh: ( operationId: string ) => Promise; diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts index b02ee028f..6a153f952 100644 --- a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -27,3 +27,25 @@ export interface PlaylistRefreshPayload { url?: string; trustedInsecureTlsHosts?: string[]; } + +export const PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE = + 'playlist-refresh-cancelled' as const; + +export interface PlaylistRefreshCancelledResult { + operationId: string; + type: typeof PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE; +} + +export function isPlaylistRefreshCancelledResult( + value: unknown +): value is PlaylistRefreshCancelledResult { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Record; + return ( + candidate['type'] === PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE && + typeof candidate['operationId'] === 'string' + ); +}