perf(m3u): stop cancelled refresh workers (#1268)

This commit is contained in:
4gray authored and GitHub committed 2026-07-26 09:25:51 +02:00
1 parent 7e8c2ccce1
commit f147d4fe37
28 files changed
+3205 -89

No files matched your search

+8
View File
@@ -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.
+1
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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,
});
+10
View File
@@ -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",
@@ -75,6 +75,7 @@ type ElectronFixtures = {
};
export type LaunchElectronAppOptions = {
args?: readonly string[];
env?: Record<string, string | undefined>;
};
@@ -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');
@@ -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();
});
@@ -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<Record<string, number>>;
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;
};
}
@@ -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);
}
@@ -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<void>;
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<void> {
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<CancellationIterationResult> {
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<void> {
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<void> {
await expect
.poll(
async () =>
(await readMainCaptureStatus(app.electronApp)).databasePending,
{ timeout: 120_000 }
)
.toBe(0);
await waitForTwoAnimationFrames(app.mainWindow);
}
async function waitForOperationSettlement(
app: LaunchedElectronApp
): Promise<void> {
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<void> {
await page.evaluate(
() =>
new Promise<void>((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<BenchmarkConfiguration> {
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<void> {
const server = createTcpServer();
await new Promise<void>((resolvePromise, rejectPromise) => {
server.once('error', rejectPromise);
server.listen(
{ exclusive: true, host: '127.0.0.1', port },
resolvePromise
);
});
await new Promise<void>((resolvePromise, rejectPromise) => {
server.close((error) =>
error ? rejectPromise(error) : resolvePromise()
);
});
}
async function assertRendererCdpTarget(page: Page): Promise<void> {
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<string, unknown>)['type'] ===
'page' &&
(target as Record<string, unknown>)['url'] ===
page.url()
)
);
} catch {
return false;
}
},
{ timeout: 10_000 }
)
.toBe(true);
}
async function assertMissing(path: string): Promise<void> {
try {
await access(path);
} catch {
return;
}
throw new Error(`Performance output already exists: ${path}`);
}
async function startSyntheticServer(
fixture: SyntheticM3uFixture
): Promise<SyntheticServer> {
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<void> {
if (!server.listening) {
return;
}
await new Promise<void>((resolvePromise, rejectPromise) => {
server.close((error) =>
error ? rejectPromise(error) : resolvePromise()
);
});
}
async function writeJson(path: string, value: unknown): Promise<void> {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
@@ -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<void> {
await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => {
type JsonRecord = Record<string, unknown>;
type WorkerTransferable = import('node:worker_threads').Transferable;
interface CpuProfileHandle {
stop(): Promise<unknown>;
}
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<WorkerCpuUsage>;
getHeapSnapshot?(): Promise<NodeJS.ReadableStream>;
getHeapStatistics?(): Promise<WorkerHeapStatistics>;
on(event: 'message', listener: (message: unknown) => void): this;
performance?: {
eventLoopUtilization(
utilization1?: WorkerElu,
utilization2?: WorkerElu
): WorkerElu;
};
postMessage(
message: unknown,
transferList?: readonly WorkerTransferable[]
): void;
startCpuProfile?(): Promise<CpuProfileHandle>;
terminate(): Promise<number>;
}
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<void> | null;
heapPeak: number;
kind: 'database.worker' | 'playlist-refresh.worker';
operationId: string | null;
playlistId: string | null;
postGcHeapUsed: number | null;
profileHandle: Promise<CpuProfileHandle> | 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<string, unknown>;
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<InstrumentedWorker, WorkerRecord>();
const operationWorkers = new Map<string, WorkerRecord>();
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<TimelineRecord, 'epochMs'>
): 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<void> => {
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<void> => {
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<number> {
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<JsonRecord> =>
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<void> => {
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<MainCaptureMetrics> => {
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<void>((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<void> {
await electronApp.evaluate(
async (_electron, input) => {
const target = globalThis as unknown as Record<string, unknown>;
const api = target[input.stateKey] as {
start(options: MainCaptureStartOptions): Promise<void>;
};
await api.start(input.options);
},
{ options, stateKey: MAIN_CAPTURE_STATE_KEY }
);
}
export async function readMainCaptureStatus(
electronApp: ElectronApplication
): Promise<MainCaptureStatus> {
return electronApp.evaluate(async (_electron, stateKey) => {
const target = globalThis as unknown as Record<string, unknown>;
const api = target[stateKey] as {
status(): MainCaptureStatus;
};
return api.status();
}, MAIN_CAPTURE_STATE_KEY);
}
export async function stopMainCapture(
electronApp: ElectronApplication
): Promise<MainCaptureMetrics> {
return electronApp.evaluate(async (_electron, stateKey) => {
const target = globalThis as unknown as Record<string, unknown>;
const api = target[stateKey] as {
stop(): Promise<MainCaptureMetrics>;
};
return api.stop();
}, MAIN_CAPTURE_STATE_KEY);
}
@@ -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<number>;
stop(): Promise<RendererCaptureMetrics>;
waitForCancelClick(): Promise<void>;
waitForTerminal(): Promise<void>;
}
interface HeapUsageResult {
readonly usedSize: number;
}
interface TraceData {
readonly value: readonly unknown[];
}
export async function startRendererCapture(
page: Page,
options: RendererCaptureStartOptions
): Promise<RunningRendererCapture> {
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<void> => {
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<typeof createWriteStream> | null = null;
let traceFirstEvent = true;
let resolveTraceComplete: (() => void) | null = null;
const traceComplete = new Promise<void>((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<void> {
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<string, number>;
uiSettledEpochMs: number | null;
unsubscribe: () => void;
}
const target = globalThis as unknown as Record<string, unknown>;
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<string, number>,
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<number> {
return page.evaluate((stateKey) => {
const target = globalThis as unknown as Record<string, unknown>;
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<RendererProbeMetrics> {
return page.evaluate((stateKey) => {
const target = globalThis as unknown as Record<string, unknown>;
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<string, number>;
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);
}
@@ -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);
}
@@ -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<string>(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'),
});
}
@@ -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();
@@ -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<number>((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 () => {
@@ -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<void>;
};
const activePlaylistRefreshes = new Map<string, ActivePlaylistRefresh>();
@@ -170,76 +169,126 @@ ipcMain.handle(
async (event, payload: PlaylistRefreshPayload) => {
const worker = resolvePlaylistRefreshWorker();
return await new Promise<Playlist>((resolve, reject) => {
const cleanup = async (): Promise<void> => {
activePlaylistRefreshes.delete(payload.operationId);
worker.removeAllListeners();
await worker.terminate().catch(() => undefined);
};
return await new Promise<Playlist | PlaylistRefreshCancelledResult>(
(resolve, reject) => {
let cleanupPromise: Promise<void> | 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<void> => {
cleanupPromise ??= (async () => {
activePlaylistRefreshes.delete(payload.operationId);
worker.removeAllListeners();
await worker.terminate().catch(() => undefined);
})();
return cleanupPromise;
};
worker.on(
'message',
async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
if (message.type === 'ready') {
worker.postMessage({
type: 'request',
payload,
});
const cancel = async (): Promise<void> => {
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<Playlist>;
if (response.success && response.result) {
resolve(response.result);
const activeRefresh: ActivePlaylistRefresh = { cancel };
activePlaylistRefreshes.set(payload.operationId, activeRefresh);
worker.on(
'message',
async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
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<Playlist>;
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 };
}
@@ -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<TResult = unknown> {
success: boolean;
result?: TResult;
error?: SerializedWorkerError;
performance?: WorkerPerformanceCaptureResult;
}
export type DbWorkerIncomingMessage =
@@ -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,
});
}
});
@@ -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,
});
}
}
@@ -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<TResult = unknown> {
message: string;
stack?: string;
};
performance?: WorkerPerformanceCaptureResult;
}
export type PlaylistRefreshWorkerIncomingMessage =
@@ -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);
});
});
@@ -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<void> {
if (capture) {
await new Promise<void>((resolvePromise) =>
setTimeout(resolvePromise, 2)
);
}
}
export async function finishWorkerPerformanceCapture(
capture: WorkerPerformanceCapture | null
): Promise<WorkerPerformanceCaptureResult | undefined> {
if (!capture) {
return undefined;
}
await new Promise<void>((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,
};
}
+41 -1
View File
@@ -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
@@ -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);
});
});
@@ -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?.();
}
@@ -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<ElectronBridgeXtreamProbeResult>;
refreshPlaylist: (payload: PlaylistRefreshPayload) => Promise<Playlist>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
) => Promise<Playlist | PlaylistRefreshCancelledResult>;
cancelPlaylistRefresh: (
operationId: string
) => Promise<ElectronBridgeResult>;
@@ -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<string, unknown>;
return (
candidate['type'] === PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE &&
typeof candidate['operationId'] === 'string'
);
}