mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
perf(m3u): stop cancelled refresh workers (#1268)
This commit is contained in:
1 parent
7e8c2ccce1
commit
f147d4fe37
28 files changed
+3205
-89
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user