mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
test(performance): add formal M3U import benchmark (#1287)
* test(performance): add formal M3U import benchmark * test(performance): harden formal capture validity * test(performance): address benchmark review feedback
This commit is contained in:
1 parent
26331676f9
commit
24f0dee6f0
80 files changed
+11131
-192
No files matched your search
@@ -85,8 +85,8 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses, plus the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
|
||||
- Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
|
||||
@@ -141,8 +141,8 @@ Useful narrower flags:
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses, plus the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
|
||||
Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
"command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts"
|
||||
}
|
||||
},
|
||||
"benchmark-m3u-import": {
|
||||
"dependsOn": ["electron-backend:build-performance"],
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"parallelism": false,
|
||||
"options": {
|
||||
"cwd": "apps/electron-backend-e2e",
|
||||
"command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts"
|
||||
}
|
||||
},
|
||||
"packaged-frame-copy-smoke": {
|
||||
"dependsOn": ["test-packaged-frame-copy-fixtures"],
|
||||
"executor": "nx:run-commands",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { runM3uImportBenchmark } from './performance/m3u-import.benchmark';
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle.
|
||||
test('profiles initial M3U imports at 10k, 50k, and 100k', async () => {
|
||||
await runM3uImportBenchmark();
|
||||
});
|
||||
@@ -52,11 +52,125 @@ test('correlates the real preload marker to a DB request whose payload omits ope
|
||||
});
|
||||
|
||||
assert.deepEqual(identity, {
|
||||
ipcCallId: 2,
|
||||
operationId: 'refresh-operation-1',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test('retains the initial-import upsert marker when its operationId is intentionally uncorrelated', () => {
|
||||
const { api, ipcMain } = createCapture();
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
correlationState: 'uncorrelated',
|
||||
ipcCallId: 7,
|
||||
method: 'dbUpsertAppPlaylist',
|
||||
operationId: null,
|
||||
sourceEpochMs: 1_077.25,
|
||||
})
|
||||
);
|
||||
|
||||
const identity = api.matchDatabaseRequest({
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
payload: { _id: 'playlist-1' },
|
||||
requestId: 'initial-import-upsert',
|
||||
type: 'request',
|
||||
});
|
||||
|
||||
assert.deepEqual(identity, {
|
||||
ipcCallId: 7,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-uncorrelated',
|
||||
sourceEpochMs: 1_077.25,
|
||||
});
|
||||
});
|
||||
|
||||
test('captures exact successful preload completion markers for DB return-clone attribution', () => {
|
||||
const { api, ipcMain } = createCapture();
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
correlationState: 'uncorrelated',
|
||||
ipcCallId: 7,
|
||||
method: 'dbUpsertAppPlaylist',
|
||||
operationId: null,
|
||||
phase: 'success',
|
||||
sourceEpochMs: 140.25,
|
||||
})
|
||||
);
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
correlationState: 'uncorrelated',
|
||||
ipcCallId: 8,
|
||||
operationId: null,
|
||||
phase: 'success',
|
||||
sourceEpochMs: 175.5,
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(api.successMarkerCount(), 2);
|
||||
assert.deepEqual(api.takeSuccessMarkers(), [
|
||||
{
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
operationId: null,
|
||||
playlistId: 'playlist-1',
|
||||
sourceEpochMs: 140.25,
|
||||
},
|
||||
{
|
||||
ipcCallId: 8,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
operationId: null,
|
||||
playlistId: 'playlist-1',
|
||||
sourceEpochMs: 175.5,
|
||||
},
|
||||
]);
|
||||
assert.equal(api.successMarkerCount(), 0);
|
||||
assert.deepEqual(api.takeSuccessMarkers(), []);
|
||||
});
|
||||
|
||||
test('fails closed for malformed or invalid successful preload markers and resets completions', () => {
|
||||
const { api, ipcMain } = createCapture();
|
||||
for (const invalid of [
|
||||
{ invalidReason: 'out-of-order' },
|
||||
{ correlationState: 'invalid' },
|
||||
{ ipcCallId: 0 },
|
||||
{ playlistId: '' },
|
||||
{ sourceEpochMs: Number.NaN },
|
||||
{ method: 'refreshPlaylist' },
|
||||
{ correlationState: 'correlated', operationId: null },
|
||||
]) {
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
phase: 'success',
|
||||
...invalid,
|
||||
})
|
||||
);
|
||||
}
|
||||
assert.equal(api.successMarkerCount(), 0);
|
||||
assert.deepEqual(api.takeSuccessMarkers(), []);
|
||||
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
phase: 'success',
|
||||
})
|
||||
);
|
||||
api.stop();
|
||||
api.start();
|
||||
assert.equal(api.successMarkerCount(), 0);
|
||||
assert.deepEqual(api.takeSuccessMarkers(), []);
|
||||
});
|
||||
|
||||
test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => {
|
||||
const { api, ipcMain } = createCapture();
|
||||
const identity = api.matchDatabaseRequest({
|
||||
@@ -73,8 +187,10 @@ test('resolves a response-retained request identity when the marker arrives late
|
||||
|
||||
ipcMain.emit(CHANNEL, {}, marker());
|
||||
assert.deepEqual(responseOutcome.identity, {
|
||||
ipcCallId: 2,
|
||||
operationId: 'refresh-operation-1',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
|
||||
api.stop();
|
||||
@@ -87,8 +203,10 @@ test('resolves a response-retained request identity when the marker arrives late
|
||||
type: 'request',
|
||||
});
|
||||
assert.deepEqual(afterReset, {
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-missing',
|
||||
sourceEpochMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,8 +219,10 @@ test('fails closed for missing and ambiguous preload markers', () => {
|
||||
type: 'request',
|
||||
});
|
||||
assert.deepEqual(missing, {
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-missing',
|
||||
sourceEpochMs: null,
|
||||
});
|
||||
|
||||
const ambiguousCapture = createCapture();
|
||||
@@ -122,8 +242,10 @@ test('fails closed for missing and ambiguous preload markers', () => {
|
||||
type: 'request',
|
||||
});
|
||||
assert.deepEqual(ambiguous, {
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-ambiguous',
|
||||
sourceEpochMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,19 +313,25 @@ test('quarantines a key after response-before-marker ambiguity', () => {
|
||||
});
|
||||
|
||||
const ambiguousIdentity = {
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-ambiguous',
|
||||
sourceEpochMs: null,
|
||||
};
|
||||
assert.deepEqual(requestA, ambiguousIdentity);
|
||||
assert.deepEqual(requestB, ambiguousIdentity);
|
||||
assert.deepEqual(requestC, ambiguousIdentity);
|
||||
assert.deepEqual(otherPlaylist, {
|
||||
ipcCallId: 4,
|
||||
operationId: 'other-playlist-operation',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
assert.deepEqual(otherOperation, {
|
||||
ipcCallId: 5,
|
||||
operationId: 'other-db-operation',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
|
||||
api.stop();
|
||||
@@ -223,7 +351,9 @@ test('quarantines a key after response-before-marker ambiguity', () => {
|
||||
type: 'request',
|
||||
});
|
||||
assert.deepEqual(freshCaptureIdentity, {
|
||||
ipcCallId: 6,
|
||||
operationId: 'fresh-capture-operation',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,26 @@ export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY =
|
||||
'__iptvnatorM3uRefreshDatabaseRequestIdentityCapture';
|
||||
|
||||
export interface DatabaseRequestIdentity {
|
||||
ipcCallId: number | null;
|
||||
operationId: string | null;
|
||||
operationIdUnavailableReason: string | null;
|
||||
sourceEpochMs: number | null;
|
||||
}
|
||||
|
||||
export interface PreloadDatabaseSuccessMarker {
|
||||
ipcCallId: number;
|
||||
operation: 'DB_GET_APP_PLAYLIST' | 'DB_UPSERT_APP_PLAYLIST';
|
||||
operationId: string | null;
|
||||
playlistId: string;
|
||||
sourceEpochMs: number;
|
||||
}
|
||||
|
||||
export interface DatabaseRequestIdentityCaptureApi {
|
||||
matchDatabaseRequest(message: unknown): DatabaseRequestIdentity;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
successMarkerCount(): number;
|
||||
takeSuccessMarkers(): readonly PreloadDatabaseSuccessMarker[];
|
||||
}
|
||||
|
||||
interface DatabaseRequestIdentityElectron {
|
||||
@@ -31,8 +43,10 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
interface PendingMarker {
|
||||
readonly ipcCallId: number;
|
||||
readonly operation: string;
|
||||
readonly operationId: string;
|
||||
readonly operationId: string | null;
|
||||
readonly operationIdUnavailableReason: string | null;
|
||||
readonly playlistId: string;
|
||||
readonly sourceEpochMs: number;
|
||||
}
|
||||
interface PendingRequest {
|
||||
readonly identity: DatabaseRequestIdentity;
|
||||
@@ -58,6 +72,7 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
let pendingMarkers: PendingMarker[] = [];
|
||||
let pendingRequests: PendingRequest[] = [];
|
||||
let quarantinedKeys: QuarantinedKey[] = [];
|
||||
let successMarkers: PreloadDatabaseSuccessMarker[] = [];
|
||||
|
||||
const readRecord = (value: unknown): JsonRecord | null =>
|
||||
typeof value === 'object' && value !== null
|
||||
@@ -88,11 +103,37 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
candidate.playlistId === playlistId;
|
||||
const markAmbiguous = (requests: PendingRequest[]): void => {
|
||||
for (const request of requests) {
|
||||
request.identity.ipcCallId = null;
|
||||
request.identity.operationId = null;
|
||||
request.identity.operationIdUnavailableReason =
|
||||
'preload-performance-marker-ambiguous';
|
||||
request.identity.sourceEpochMs = null;
|
||||
}
|
||||
};
|
||||
const unavailableIdentity = (reason: string): DatabaseRequestIdentity => ({
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: reason,
|
||||
sourceEpochMs: null,
|
||||
});
|
||||
const identityFromMarker = (
|
||||
marker: PendingMarker
|
||||
): DatabaseRequestIdentity => ({
|
||||
ipcCallId: marker.ipcCallId,
|
||||
operationId: marker.operationId,
|
||||
operationIdUnavailableReason: marker.operationIdUnavailableReason,
|
||||
sourceEpochMs: marker.sourceEpochMs,
|
||||
});
|
||||
const applyMarker = (
|
||||
identity: DatabaseRequestIdentity,
|
||||
marker: PendingMarker
|
||||
): void => {
|
||||
identity.ipcCallId = marker.ipcCallId;
|
||||
identity.operationId = marker.operationId;
|
||||
identity.operationIdUnavailableReason =
|
||||
marker.operationIdUnavailableReason;
|
||||
identity.sourceEpochMs = marker.sourceEpochMs;
|
||||
};
|
||||
const isQuarantined = (operation: string, playlistId: string): boolean =>
|
||||
quarantinedKeys.some((key) => matches(key, operation, playlistId));
|
||||
const quarantine = (operation: string, playlistId: string): void => {
|
||||
@@ -116,32 +157,86 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
return;
|
||||
}
|
||||
const marker = readRecord(input);
|
||||
const correlationState = marker?.['correlationState'];
|
||||
const operation =
|
||||
typeof marker?.['method'] === 'string'
|
||||
? markerMethodToOperation[marker['method']]
|
||||
: undefined;
|
||||
const operationId = marker?.['operationId'];
|
||||
const playlistId = marker?.['playlistId'];
|
||||
const ipcCallId = marker?.['ipcCallId'];
|
||||
const sourceEpochMs = marker?.['sourceEpochMs'];
|
||||
const validMarkerIdentity =
|
||||
operation !== undefined &&
|
||||
typeof playlistId === 'string' &&
|
||||
playlistId.length > 0 &&
|
||||
Number.isSafeInteger(ipcCallId) &&
|
||||
Number(ipcCallId) >= 1 &&
|
||||
typeof sourceEpochMs === 'number' &&
|
||||
Number.isFinite(sourceEpochMs) &&
|
||||
sourceEpochMs >= 0;
|
||||
if (
|
||||
marker?.['phase'] === 'success' &&
|
||||
(correlationState === 'correlated' ||
|
||||
correlationState === 'uncorrelated') &&
|
||||
marker['invalidReason'] === null &&
|
||||
validMarkerIdentity &&
|
||||
(correlationState !== 'correlated' ||
|
||||
(typeof operationId === 'string' &&
|
||||
operationId.length > 0))
|
||||
) {
|
||||
successMarkers.push({
|
||||
ipcCallId: Number(ipcCallId),
|
||||
operation: operation as PreloadDatabaseSuccessMarker['operation'],
|
||||
operationId:
|
||||
correlationState === 'correlated'
|
||||
? String(operationId)
|
||||
: null,
|
||||
playlistId,
|
||||
sourceEpochMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!marker ||
|
||||
marker['phase'] !== 'start' ||
|
||||
marker['correlationState'] !== 'correlated' ||
|
||||
(correlationState !== 'correlated' &&
|
||||
correlationState !== 'uncorrelated') ||
|
||||
marker['invalidReason'] !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const operation =
|
||||
typeof marker['method'] === 'string'
|
||||
? markerMethodToOperation[marker['method']]
|
||||
: undefined;
|
||||
const operationId = marker['operationId'];
|
||||
const playlistId = marker['playlistId'];
|
||||
const ipcCallId = marker['ipcCallId'];
|
||||
const correlatedOperationId =
|
||||
correlationState === 'correlated' &&
|
||||
typeof operationId === 'string' &&
|
||||
operationId.length > 0
|
||||
? operationId
|
||||
: null;
|
||||
if (
|
||||
operation === undefined ||
|
||||
typeof operationId !== 'string' ||
|
||||
operationId.length === 0 ||
|
||||
(correlationState === 'correlated' &&
|
||||
correlatedOperationId === null) ||
|
||||
typeof playlistId !== 'string' ||
|
||||
playlistId.length === 0 ||
|
||||
!Number.isSafeInteger(ipcCallId) ||
|
||||
Number(ipcCallId) < 1
|
||||
Number(ipcCallId) < 1 ||
|
||||
typeof sourceEpochMs !== 'number' ||
|
||||
!Number.isFinite(sourceEpochMs) ||
|
||||
sourceEpochMs < 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pendingMarker: PendingMarker = {
|
||||
ipcCallId: Number(ipcCallId),
|
||||
operation,
|
||||
operationId: correlatedOperationId,
|
||||
operationIdUnavailableReason:
|
||||
correlationState === 'correlated'
|
||||
? null
|
||||
: 'preload-performance-marker-uncorrelated',
|
||||
playlistId,
|
||||
sourceEpochMs,
|
||||
};
|
||||
|
||||
if (isQuarantined(operation, playlistId)) {
|
||||
return;
|
||||
@@ -154,8 +249,7 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
request.identity.operationId = operationId;
|
||||
request.identity.operationIdUnavailableReason = null;
|
||||
applyMarker(request.identity, pendingMarker);
|
||||
pendingRequests = pendingRequests.filter(
|
||||
(candidate) => candidate !== request
|
||||
);
|
||||
@@ -166,12 +260,7 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
return;
|
||||
}
|
||||
|
||||
pendingMarkers.push({
|
||||
ipcCallId: Number(ipcCallId),
|
||||
operation,
|
||||
operationId,
|
||||
playlistId,
|
||||
});
|
||||
pendingMarkers.push(pendingMarker);
|
||||
pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId);
|
||||
});
|
||||
|
||||
@@ -189,19 +278,15 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
operation !== 'DB_UPSERT_APP_PLAYLIST') ||
|
||||
playlistId === null
|
||||
) {
|
||||
return {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-not-applicable',
|
||||
};
|
||||
return unavailableIdentity(
|
||||
'preload-performance-marker-not-applicable'
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuarantined(operation, playlistId)) {
|
||||
return {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-ambiguous',
|
||||
};
|
||||
return unavailableIdentity(
|
||||
'preload-performance-marker-ambiguous'
|
||||
);
|
||||
}
|
||||
const markerCandidates = pendingMarkers.filter((marker) =>
|
||||
matches(marker, operation, playlistId)
|
||||
@@ -209,34 +294,25 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
if (markerCandidates.length === 1) {
|
||||
const marker = markerCandidates[0];
|
||||
if (!marker) {
|
||||
return {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-missing',
|
||||
};
|
||||
return unavailableIdentity(
|
||||
'preload-performance-marker-missing'
|
||||
);
|
||||
}
|
||||
pendingMarkers = pendingMarkers.filter(
|
||||
(candidate) => candidate !== marker
|
||||
);
|
||||
return {
|
||||
operationId: marker.operationId,
|
||||
operationIdUnavailableReason: null,
|
||||
};
|
||||
return identityFromMarker(marker);
|
||||
}
|
||||
if (markerCandidates.length > 1) {
|
||||
quarantine(operation, playlistId);
|
||||
return {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-ambiguous',
|
||||
};
|
||||
return unavailableIdentity(
|
||||
'preload-performance-marker-ambiguous'
|
||||
);
|
||||
}
|
||||
|
||||
const identity: DatabaseRequestIdentity = {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-missing',
|
||||
};
|
||||
const identity = unavailableIdentity(
|
||||
'preload-performance-marker-missing'
|
||||
);
|
||||
pendingRequests.push({
|
||||
identity,
|
||||
operation,
|
||||
@@ -248,6 +324,7 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
pendingMarkers = [];
|
||||
pendingRequests = [];
|
||||
quarantinedKeys = [];
|
||||
successMarkers = [];
|
||||
active = true;
|
||||
},
|
||||
stop: () => {
|
||||
@@ -255,6 +332,13 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
pendingMarkers = [];
|
||||
pendingRequests = [];
|
||||
quarantinedKeys = [];
|
||||
successMarkers = [];
|
||||
},
|
||||
successMarkerCount: () => successMarkers.length,
|
||||
takeSuccessMarkers: () => {
|
||||
const captured = Object.freeze([...successMarkers]);
|
||||
successMarkers = [];
|
||||
return captured;
|
||||
},
|
||||
};
|
||||
target[stateKey] = api;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
function section(startMarker: string, endMarker: string): string {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start);
|
||||
assert.ok(start >= 0, `missing start marker: ${startMarker}`);
|
||||
assert.ok(end > start, `missing end marker: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('counts only finite nonnegative worker heap and external-memory samples per capture generation', () => {
|
||||
const workerRecord = section(
|
||||
'interface WorkerRecord',
|
||||
'interface TimelineRecord'
|
||||
);
|
||||
const createRecord = section(
|
||||
'const record: WorkerRecord = {',
|
||||
'nextWorkerOrdinal += 1'
|
||||
);
|
||||
const sampleWorker = section(
|
||||
'const sampleWorker =',
|
||||
'const resetWorkerForCapture'
|
||||
);
|
||||
const resetWorker = section(
|
||||
'const resetWorkerForCapture',
|
||||
'const startWorker'
|
||||
);
|
||||
|
||||
assert.match(workerRecord, /externalMemorySampleCount: number/);
|
||||
assert.match(workerRecord, /heapUsedSampleCount: number/);
|
||||
assert.match(createRecord, /externalMemorySampleCount: 0/);
|
||||
assert.match(createRecord, /heapUsedSampleCount: 0/);
|
||||
assert.match(resetWorker, /record\.externalMemorySampleCount = 0/);
|
||||
assert.match(resetWorker, /record\.heapUsedSampleCount = 0/);
|
||||
|
||||
assert.match(sampleWorker, /const heapUsedSample = stats\.used_heap_size/);
|
||||
assert.match(
|
||||
sampleWorker,
|
||||
/Number\.isFinite\(heapUsedSample\)[\s\S]*heapUsedSample >= 0/
|
||||
);
|
||||
assert.match(sampleWorker, /record\.heapUsedSampleCount \+= 1/);
|
||||
assert.match(
|
||||
sampleWorker,
|
||||
/const externalMemorySample = stats\.external_memory/
|
||||
);
|
||||
assert.match(
|
||||
sampleWorker,
|
||||
/Number\.isFinite\(externalMemorySample\)[\s\S]*externalMemorySample >= 0/
|
||||
);
|
||||
assert.match(sampleWorker, /record\.externalMemorySampleCount \+= 1/);
|
||||
assert.doesNotMatch(sampleWorker, /used_heap_size \?\? 0/);
|
||||
assert.doesNotMatch(sampleWorker, /external_memory \?\? 0/);
|
||||
});
|
||||
|
||||
test('raw worker metrics distinguish missing samples from measured zero-byte peaks', () => {
|
||||
const transport = section(
|
||||
'const workers = currentWorkerRecords',
|
||||
'const transport: MainCaptureGenerationTransport'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
transport,
|
||||
/externalMemorySampleCount:\s*record\.externalMemorySampleCount/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/heapUsedSampleCount:\s*record\.heapUsedSampleCount/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/peakExternalBytes:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*record\.externalPeak[\s\S]*:\s*null/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/peakHeapUsedBytes:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*record\.heapPeak[\s\S]*:\s*null/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/peakExternalUnavailableReason:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-external-memory-samples-missing'/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/peakHeapUsedUnavailableReason:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-heap-used-samples-missing'/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
transport,
|
||||
/peakExternalBytes: record\.externalPeak[,}]/
|
||||
);
|
||||
assert.doesNotMatch(transport, /peakHeapUsedBytes: record\.heapPeak[,}]/);
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assessDatabaseWorkerPeakMemoryValidity,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON,
|
||||
} from './database-worker-peak-memory-validity';
|
||||
|
||||
describe('database worker peak-memory validity', () => {
|
||||
it('requires successful heap and external-memory samples in every measured run', () => {
|
||||
const validity = assessDatabaseWorkerPeakMemoryValidity([
|
||||
iteration('warmup', 'warmup', worker(0, 0)),
|
||||
iteration('run-01', 'measured', worker(3, 4)),
|
||||
iteration('diagnostic', 'diagnostic', worker(0, 0)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(validity, {
|
||||
invalidMeasuredRuns: [],
|
||||
measuredRunCount: 1,
|
||||
validForComparison: true,
|
||||
validMeasuredRunCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for absent samples instead of accepting initialized zero peaks', () => {
|
||||
const missingCounts = assessDatabaseWorkerPeakMemoryValidity([
|
||||
iteration('run-01', 'measured', {
|
||||
kind: 'database.worker',
|
||||
peakExternalBytes: 0,
|
||||
peakHeapUsedBytes: 0,
|
||||
}),
|
||||
iteration('run-02', 'measured', worker(0, 1)),
|
||||
iteration('run-03', 'measured', worker(1, 0)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(missingCounts.invalidMeasuredRuns, [
|
||||
{
|
||||
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING,
|
||||
runId: 'run-01',
|
||||
},
|
||||
{
|
||||
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING,
|
||||
runId: 'run-02',
|
||||
},
|
||||
{
|
||||
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING,
|
||||
runId: 'run-03',
|
||||
},
|
||||
]);
|
||||
assert.equal(missingCounts.validForComparison, false);
|
||||
assert.equal(missingCounts.validMeasuredRunCount, 0);
|
||||
});
|
||||
|
||||
it('reports stable worker-cardinality and malformed-peak reasons', () => {
|
||||
const validity = assessDatabaseWorkerPeakMemoryValidity([
|
||||
{ kind: 'measured', main: { workers: [] }, runId: 'missing' },
|
||||
{
|
||||
kind: 'measured',
|
||||
main: { workers: [worker(1, 1), worker(1, 1)] },
|
||||
runId: 'multiple',
|
||||
},
|
||||
iteration('heap-invalid', 'measured', {
|
||||
...worker(1, 1),
|
||||
peakHeapUsedBytes: Number.NaN,
|
||||
}),
|
||||
iteration('external-invalid', 'measured', {
|
||||
...worker(1, 1),
|
||||
peakExternalBytes: -1,
|
||||
}),
|
||||
iteration('heap-reason-incoherent', 'measured', {
|
||||
...worker(1, 1),
|
||||
peakHeapUsedUnavailableReason:
|
||||
'worker-heap-used-samples-missing',
|
||||
}),
|
||||
iteration('external-reason-incoherent', 'measured', {
|
||||
...worker(1, 1),
|
||||
peakExternalUnavailableReason:
|
||||
'worker-external-memory-samples-missing',
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
validity.invalidMeasuredRuns.map(({ reason }) => reason),
|
||||
[
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID,
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function iteration(
|
||||
runId: string,
|
||||
kind: string,
|
||||
databaseWorker: Record<string, unknown>
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
main: { workers: [databaseWorker] },
|
||||
runId,
|
||||
};
|
||||
}
|
||||
|
||||
function worker(
|
||||
heapUsedSampleCount: number,
|
||||
externalMemorySampleCount: number
|
||||
) {
|
||||
return {
|
||||
externalMemorySampleCount,
|
||||
heapUsedSampleCount,
|
||||
kind: 'database.worker',
|
||||
peakExternalBytes: 600,
|
||||
peakExternalUnavailableReason: null,
|
||||
peakHeapUsedBytes: 1_500,
|
||||
peakHeapUsedUnavailableReason: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export const DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON = {
|
||||
EXTERNAL_MEMORY_PEAK_INVALID:
|
||||
'database-worker-external-memory-peak-invalid',
|
||||
EXTERNAL_MEMORY_SAMPLES_MISSING:
|
||||
'database-worker-external-memory-samples-missing',
|
||||
HEAP_PEAK_INVALID: 'database-worker-heap-peak-invalid',
|
||||
HEAP_SAMPLES_MISSING: 'database-worker-heap-samples-missing',
|
||||
MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers',
|
||||
DATABASE_WORKER_MISSING: 'database-worker-missing',
|
||||
} as const;
|
||||
|
||||
export type DatabaseWorkerPeakMemoryInvalidReason =
|
||||
(typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON)[keyof typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON];
|
||||
|
||||
export interface DatabaseWorkerPeakMemoryValidity {
|
||||
readonly invalidMeasuredRuns: readonly {
|
||||
readonly reason: DatabaseWorkerPeakMemoryInvalidReason;
|
||||
readonly runId: string;
|
||||
}[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
interface PeakMemoryWorker {
|
||||
readonly externalMemorySampleCount?: unknown;
|
||||
readonly heapUsedSampleCount?: unknown;
|
||||
readonly kind: string;
|
||||
readonly peakExternalBytes: unknown;
|
||||
readonly peakExternalUnavailableReason?: unknown;
|
||||
readonly peakHeapUsedBytes: unknown;
|
||||
readonly peakHeapUsedUnavailableReason?: unknown;
|
||||
}
|
||||
|
||||
interface PeakMemoryIteration {
|
||||
readonly kind: string;
|
||||
readonly main: {
|
||||
readonly workers: readonly PeakMemoryWorker[];
|
||||
};
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export function assessDatabaseWorkerPeakMemoryValidity(
|
||||
iterations: readonly PeakMemoryIteration[]
|
||||
): DatabaseWorkerPeakMemoryValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === 'measured'
|
||||
);
|
||||
const invalidMeasuredRuns: {
|
||||
reason: DatabaseWorkerPeakMemoryInvalidReason;
|
||||
runId: string;
|
||||
}[] = [];
|
||||
|
||||
for (const iteration of measured) {
|
||||
const workers = iteration.main.workers.filter(
|
||||
(worker) => worker.kind === 'database.worker'
|
||||
);
|
||||
const reason = assessWorkers(workers);
|
||||
if (reason !== null) {
|
||||
invalidMeasuredRuns.push({ reason, runId: iteration.runId });
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns),
|
||||
measuredRunCount: measured.length,
|
||||
validForComparison:
|
||||
measured.length > 0 && invalidMeasuredRuns.length === 0,
|
||||
validMeasuredRunCount: measured.length - invalidMeasuredRuns.length,
|
||||
});
|
||||
}
|
||||
|
||||
function assessWorkers(
|
||||
workers: readonly PeakMemoryWorker[]
|
||||
): DatabaseWorkerPeakMemoryInvalidReason | null {
|
||||
if (workers.length === 0) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING;
|
||||
}
|
||||
if (workers.length !== 1) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS;
|
||||
}
|
||||
|
||||
const worker = workers[0];
|
||||
if (!worker) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING;
|
||||
}
|
||||
if (!isPositiveSampleCount(worker.heapUsedSampleCount)) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING;
|
||||
}
|
||||
if (
|
||||
!isPeak(worker.peakHeapUsedBytes) ||
|
||||
worker.peakHeapUsedUnavailableReason !== null
|
||||
) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID;
|
||||
}
|
||||
if (!isPositiveSampleCount(worker.externalMemorySampleCount)) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING;
|
||||
}
|
||||
if (
|
||||
!isPeak(worker.peakExternalBytes) ||
|
||||
worker.peakExternalUnavailableReason !== null
|
||||
) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPositiveSampleCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
function isPeak(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assessDatabaseWorkerRequestMetricsValidity,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON,
|
||||
} from './database-worker-request-metrics-validity';
|
||||
import { createCompleteTestMainCapture } from './m3u-import-report.test-helpers';
|
||||
import type {
|
||||
WorkerCaptureMetrics,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
|
||||
describe('database worker request-metrics validity', () => {
|
||||
it('requires exactly two complete request samples in every measured run', () => {
|
||||
const worker = completeWorker();
|
||||
const validity = assessDatabaseWorkerRequestMetricsValidity([
|
||||
iteration('warmup', 'warmup', [worker]),
|
||||
iteration('run-01', 'measured', [worker]),
|
||||
iteration('run-02', 'measured', [worker]),
|
||||
iteration('diagnostic', 'diagnostic', [worker]),
|
||||
]);
|
||||
|
||||
assert.deepEqual(validity, {
|
||||
expectedRequestCount: 4,
|
||||
invalidRequests: [],
|
||||
measuredRunCount: 2,
|
||||
validForComparison: true,
|
||||
validMeasuredRunCount: 2,
|
||||
validRequestCount: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails each measured run with missing ELD, ELU, CPU, or raw capture metrics', () => {
|
||||
const validity = assessDatabaseWorkerRequestMetricsValidity([
|
||||
invalidRequestIteration('run-eld', {
|
||||
eventLoopDelay: null,
|
||||
eventLoopDelayUnavailableReason:
|
||||
'event-loop-delay-capture-unavailable',
|
||||
histogramFlushedEpochMs: null,
|
||||
}),
|
||||
invalidRequestIteration('run-elu', {
|
||||
eventLoopUtilization: null,
|
||||
eventLoopUtilizationUnavailableReason:
|
||||
'event-loop-utilization-unavailable',
|
||||
}),
|
||||
invalidRequestIteration('run-cpu', {
|
||||
threadCpuSystemMicros: null,
|
||||
threadCpuUnavailableReason: 'thread-cpu-usage-unavailable',
|
||||
threadCpuUserMicros: null,
|
||||
}),
|
||||
invalidRequestIteration('run-capture', {
|
||||
performanceCaptureUnavailableReason:
|
||||
'worker-performance-capture-invalid',
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(validity.expectedRequestCount, 8);
|
||||
assert.equal(validity.validRequestCount, 4);
|
||||
assert.equal(validity.validMeasuredRunCount, 0);
|
||||
assert.equal(validity.validForComparison, false);
|
||||
assert.deepEqual(
|
||||
validity.invalidRequests.map(({ reason }) => reason),
|
||||
[
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for missing, multiple, and wrong request sets', () => {
|
||||
const worker = completeWorker();
|
||||
const validity = assessDatabaseWorkerRequestMetricsValidity([
|
||||
iteration('missing', 'measured', []),
|
||||
iteration('multiple', 'measured', [worker, worker]),
|
||||
iteration('wrong-requests', 'measured', [
|
||||
{
|
||||
...worker,
|
||||
requests: worker.requests.slice(0, 1),
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
assert.equal(validity.expectedRequestCount, 6);
|
||||
assert.equal(validity.validRequestCount, 0);
|
||||
assert.equal(validity.validForComparison, false);
|
||||
assert.deepEqual(
|
||||
validity.invalidRequests.map(({ reason }) => reason),
|
||||
[
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID,
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function completeWorker(): WorkerCaptureMetrics {
|
||||
const worker = createCompleteTestMainCapture().workers[0];
|
||||
assert.ok(worker);
|
||||
return worker;
|
||||
}
|
||||
|
||||
function invalidRequestIteration(
|
||||
runId: string,
|
||||
updates: Partial<WorkerRequestPerformanceMetrics>
|
||||
) {
|
||||
const worker = completeWorker();
|
||||
const get = worker.requests[1];
|
||||
assert.ok(get);
|
||||
return iteration(runId, 'measured', [
|
||||
{
|
||||
...worker,
|
||||
requests: [worker.requests[0], { ...get, ...updates }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function iteration(
|
||||
runId: string,
|
||||
kind: string,
|
||||
workers: readonly WorkerCaptureMetrics[]
|
||||
) {
|
||||
return { kind, main: { workers }, runId };
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
WorkerCaptureMetrics,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
PERFORMANCE_ITERATION_KIND,
|
||||
PERFORMANCE_WORKER_KIND,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
|
||||
export const DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON = {
|
||||
DATABASE_WORKER_MISSING: 'database-worker-missing',
|
||||
EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid',
|
||||
EVENT_LOOP_UTILIZATION_INVALID: 'event-loop-utilization-invalid',
|
||||
MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers',
|
||||
REQUEST_SET_INVALID: 'initial-import-request-set-invalid',
|
||||
THREAD_CPU_INVALID: 'thread-cpu-invalid',
|
||||
WORKER_CAPTURE_INVALID: 'worker-performance-capture-invalid',
|
||||
} as const;
|
||||
|
||||
export type DatabaseWorkerRequestMetricsInvalidReason =
|
||||
(typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON)[keyof typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON];
|
||||
|
||||
export interface DatabaseWorkerRequestMetricsValidity {
|
||||
readonly expectedRequestCount: number;
|
||||
readonly invalidRequests: readonly {
|
||||
readonly operation: string | null;
|
||||
readonly reason: DatabaseWorkerRequestMetricsInvalidReason;
|
||||
readonly runId: string;
|
||||
}[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
readonly validRequestCount: number;
|
||||
}
|
||||
|
||||
interface RequestMetricsIteration {
|
||||
readonly kind: string;
|
||||
readonly main: {
|
||||
readonly workers: readonly WorkerCaptureMetrics[];
|
||||
};
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export function assessDatabaseWorkerRequestMetricsValidity(
|
||||
iterations: readonly RequestMetricsIteration[]
|
||||
): DatabaseWorkerRequestMetricsValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) =>
|
||||
iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
|
||||
);
|
||||
const invalidRequests: {
|
||||
operation: string | null;
|
||||
reason: DatabaseWorkerRequestMetricsInvalidReason;
|
||||
runId: string;
|
||||
}[] = [];
|
||||
let validMeasuredRunCount = 0;
|
||||
let validRequestCount = 0;
|
||||
|
||||
for (const iteration of measured) {
|
||||
const workers = iteration.main.workers.filter(
|
||||
(worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE
|
||||
);
|
||||
if (workers.length === 0) {
|
||||
invalidRequests.push({
|
||||
operation: null,
|
||||
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING,
|
||||
runId: iteration.runId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const worker = workers.length === 1 ? workers[0] : null;
|
||||
if (!worker) {
|
||||
invalidRequests.push({
|
||||
operation: null,
|
||||
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
|
||||
runId: iteration.runId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const requests = requireInitialImportRequestPair(worker);
|
||||
if (requests === null) {
|
||||
invalidRequests.push({
|
||||
operation: null,
|
||||
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID,
|
||||
runId: iteration.runId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let runIsValid = true;
|
||||
for (const request of requests) {
|
||||
const reason = assessRequest(request);
|
||||
if (reason === null) {
|
||||
validRequestCount += 1;
|
||||
} else {
|
||||
runIsValid = false;
|
||||
invalidRequests.push({
|
||||
operation: request.operation,
|
||||
reason,
|
||||
runId: iteration.runId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (runIsValid) {
|
||||
validMeasuredRunCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const expectedRequestCount = measured.length * 2;
|
||||
return Object.freeze({
|
||||
expectedRequestCount,
|
||||
invalidRequests: Object.freeze(invalidRequests),
|
||||
measuredRunCount: measured.length,
|
||||
validForComparison:
|
||||
measured.length > 0 &&
|
||||
validMeasuredRunCount === measured.length &&
|
||||
validRequestCount === expectedRequestCount,
|
||||
validMeasuredRunCount,
|
||||
validRequestCount,
|
||||
});
|
||||
}
|
||||
|
||||
function requireInitialImportRequestPair(
|
||||
worker: WorkerCaptureMetrics
|
||||
): readonly [
|
||||
WorkerRequestPerformanceMetrics,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
] | null {
|
||||
const upserts = worker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success
|
||||
);
|
||||
const gets = worker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_GET_APP_PLAYLIST' && request.success
|
||||
);
|
||||
return worker.requests.length === 2 &&
|
||||
upserts.length === 1 &&
|
||||
upserts[0] &&
|
||||
gets.length === 1 &&
|
||||
gets[0]
|
||||
? [upserts[0], gets[0]]
|
||||
: null;
|
||||
}
|
||||
|
||||
function assessRequest(
|
||||
request: WorkerRequestPerformanceMetrics
|
||||
): DatabaseWorkerRequestMetricsInvalidReason | null {
|
||||
if (
|
||||
request.performanceCaptureUnavailableReason !== null ||
|
||||
request.invalidReason !== null
|
||||
) {
|
||||
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID;
|
||||
}
|
||||
if (
|
||||
request.eventLoopDelayUnavailableReason !== null ||
|
||||
request.histogramFlushedEpochMs === null ||
|
||||
!isEventLoopDelay(request.eventLoopDelay)
|
||||
) {
|
||||
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID;
|
||||
}
|
||||
if (
|
||||
request.eventLoopUtilizationUnavailableReason !== null ||
|
||||
!isUtilization(request.eventLoopUtilization)
|
||||
) {
|
||||
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID;
|
||||
}
|
||||
if (
|
||||
request.threadCpuUnavailableReason !== null ||
|
||||
!isFiniteNonNegative(request.threadCpuSystemMicros) ||
|
||||
!isFiniteNonNegative(request.threadCpuUserMicros)
|
||||
) {
|
||||
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEventLoopDelay(
|
||||
value: WorkerRequestPerformanceMetrics['eventLoopDelay']
|
||||
): value is NonNullable<WorkerRequestPerformanceMetrics['eventLoopDelay']> {
|
||||
return (
|
||||
value !== null &&
|
||||
isFiniteNonNegative(value.maxMs) &&
|
||||
isFiniteNonNegative(value.p95Ms) &&
|
||||
isFiniteNonNegative(value.p99Ms) &&
|
||||
value.p95Ms <= value.p99Ms &&
|
||||
value.p99Ms <= value.maxMs
|
||||
);
|
||||
}
|
||||
|
||||
function isUtilization(value: number | null): value is number {
|
||||
return isFiniteNonNegative(value) && value <= 1;
|
||||
}
|
||||
|
||||
function isFiniteNonNegative(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isCpuProfile(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const startTime = value['startTime'];
|
||||
const endTime = value['endTime'];
|
||||
const nodes = value['nodes'];
|
||||
const samples = value['samples'];
|
||||
const timeDeltas = value['timeDeltas'];
|
||||
if (
|
||||
!isNonNegativeSafeInteger(startTime) ||
|
||||
!isNonNegativeSafeInteger(endTime) ||
|
||||
endTime <= startTime ||
|
||||
!Array.isArray(nodes) ||
|
||||
nodes.length === 0 ||
|
||||
!Array.isArray(samples) ||
|
||||
samples.length === 0 ||
|
||||
!Array.isArray(timeDeltas) ||
|
||||
timeDeltas.length !== samples.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeIds = new Set<number>();
|
||||
for (const node of nodes) {
|
||||
if (!isCpuProfileNode(node) || nodeIds.has(node['id'])) {
|
||||
return false;
|
||||
}
|
||||
nodeIds.add(node['id']);
|
||||
}
|
||||
if (
|
||||
nodes.some((node) =>
|
||||
(node as Record<string, unknown>)['children'] instanceof Array
|
||||
? (
|
||||
(node as Record<string, unknown>)[
|
||||
'children'
|
||||
] as unknown[]
|
||||
).some(
|
||||
(childId) =>
|
||||
!isNonNegativeSafeInteger(childId) ||
|
||||
!nodeIds.has(childId)
|
||||
)
|
||||
: false
|
||||
) ||
|
||||
samples.some(
|
||||
(sampleId) =>
|
||||
!isNonNegativeSafeInteger(sampleId) ||
|
||||
!nodeIds.has(sampleId)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const duration = endTime - startTime;
|
||||
let elapsed = 0;
|
||||
for (const delta of timeDeltas) {
|
||||
if (!Number.isSafeInteger(delta)) {
|
||||
return false;
|
||||
}
|
||||
elapsed += delta as number;
|
||||
if (
|
||||
!Number.isSafeInteger(elapsed) ||
|
||||
elapsed < 0 ||
|
||||
elapsed > duration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return elapsed > 0;
|
||||
}
|
||||
|
||||
export function isHeapSnapshot(value: unknown): boolean {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!isRecord(value['snapshot']) ||
|
||||
!isRecord(value['snapshot']['meta'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const meta = value['snapshot']['meta'];
|
||||
const nodeFields = meta['node_fields'];
|
||||
const nodeTypes = meta['node_types'];
|
||||
const edgeFields = meta['edge_fields'];
|
||||
const edgeTypes = meta['edge_types'];
|
||||
const nodes = value['nodes'];
|
||||
const edges = value['edges'];
|
||||
const strings = value['strings'];
|
||||
return (
|
||||
isNonEmptyStringArray(nodeFields) &&
|
||||
isHeapTypeTable(nodeTypes, nodeFields.length) &&
|
||||
isNonEmptyStringArray(edgeFields) &&
|
||||
isHeapTypeTable(edgeTypes, edgeFields.length) &&
|
||||
Array.isArray(nodes) &&
|
||||
nodes.length > 0 &&
|
||||
nodes.length % nodeFields.length === 0 &&
|
||||
Array.isArray(edges) &&
|
||||
edges.length > 0 &&
|
||||
edges.length % edgeFields.length === 0 &&
|
||||
Array.isArray(strings) &&
|
||||
strings.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isRendererTrace(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const events = value['traceEvents'];
|
||||
return (
|
||||
Array.isArray(events) &&
|
||||
events.length > 0 &&
|
||||
events.every(
|
||||
(event) =>
|
||||
isRecord(event) &&
|
||||
isNonEmptyString(event['name']) &&
|
||||
isNonEmptyString(event['ph']) &&
|
||||
isNonNegativeFiniteNumber(event['ts'])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isHeapTypeTable(value: unknown, fieldCount: number): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === fieldCount &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
isNonEmptyString(entry) || isNonEmptyStringArray(entry)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isCpuProfileNode(
|
||||
value: unknown
|
||||
): value is Record<string, unknown> & { readonly id: number } {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!isNonNegativeSafeInteger(value['id']) ||
|
||||
!isRecord(value['callFrame'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const callFrame = value['callFrame'];
|
||||
const scriptId = callFrame['scriptId'];
|
||||
const children = value['children'];
|
||||
const hitCount = value['hitCount'];
|
||||
return (
|
||||
typeof callFrame['functionName'] === 'string' &&
|
||||
(typeof scriptId === 'string' ||
|
||||
isNonNegativeSafeInteger(scriptId)) &&
|
||||
typeof callFrame['url'] === 'string' &&
|
||||
Number.isSafeInteger(callFrame['lineNumber']) &&
|
||||
Number.isSafeInteger(callFrame['columnNumber']) &&
|
||||
(children === undefined ||
|
||||
(Array.isArray(children) &&
|
||||
children.every(isNonNegativeSafeInteger))) &&
|
||||
(hitCount === undefined || isNonNegativeSafeInteger(hitCount))
|
||||
);
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is readonly string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(isNonEmptyString)
|
||||
);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
);
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
type InitialImportDiagnosticCapture,
|
||||
validateInitialImportDiagnosticArtifacts,
|
||||
} from './initial-import-diagnostic-artifacts';
|
||||
import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures';
|
||||
const HEAP_META = {
|
||||
edge_fields: ['type', 'name_or_index', 'to_node'],
|
||||
edge_types: [['context'], 'string_or_number', 'node'],
|
||||
node_fields: [
|
||||
'type',
|
||||
'name',
|
||||
'id',
|
||||
'self_size',
|
||||
'edge_count',
|
||||
'detachedness',
|
||||
],
|
||||
node_types: [
|
||||
['hidden'],
|
||||
'string',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
],
|
||||
};
|
||||
const HEAP_SNAPSHOT = {
|
||||
edges: [0, 0, 0],
|
||||
nodes: [0, 0, 1, 0, 1, 0],
|
||||
snapshot: { meta: HEAP_META },
|
||||
strings: ['', 'root'],
|
||||
};
|
||||
const TRACE = {
|
||||
traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }],
|
||||
};
|
||||
|
||||
function capture(directory: string): InitialImportDiagnosticCapture {
|
||||
return {
|
||||
main: {
|
||||
cpuProfilePath: join(directory, 'main.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'main.heapsnapshot'),
|
||||
workers: [
|
||||
{
|
||||
kind: 'database.worker',
|
||||
ordinal: 4,
|
||||
profilePath: join(
|
||||
directory,
|
||||
'database.worker-4.cpuprofile'
|
||||
),
|
||||
snapshotPath: join(
|
||||
directory,
|
||||
'database.worker-4.heapsnapshot'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: {
|
||||
cpuProfilePath: join(directory, 'renderer.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'renderer.heapsnapshot'),
|
||||
tracePath: join(directory, 'renderer.trace.json'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeArtifacts(directory: string): Promise<void> {
|
||||
const artifacts: Readonly<Record<string, unknown>> = {
|
||||
'database.worker-4.cpuprofile': CPU_PROFILE,
|
||||
'database.worker-4.heapsnapshot': HEAP_SNAPSHOT,
|
||||
'main.cpuprofile': CPU_PROFILE,
|
||||
'main.heapsnapshot': HEAP_SNAPSHOT,
|
||||
'renderer.cpuprofile': CPU_PROFILE,
|
||||
'renderer.heapsnapshot': HEAP_SNAPSHOT,
|
||||
'renderer.trace.json': TRACE,
|
||||
};
|
||||
await Promise.all(
|
||||
Object.entries(artifacts).map(([filename, value]) =>
|
||||
writeFile(join(directory, filename), JSON.stringify(value))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function withArtifacts(
|
||||
run: (directory: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-content-')
|
||||
);
|
||||
try {
|
||||
await writeArtifacts(directory);
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('accepts bounded signed CPU time deltas emitted by Chromium', async () => {
|
||||
await withArtifacts(async (directory) => {
|
||||
await writeFile(
|
||||
join(directory, 'renderer.cpuprofile'),
|
||||
JSON.stringify({
|
||||
...CPU_PROFILE,
|
||||
endTime: 5_000,
|
||||
samples: [1, 1, 1],
|
||||
timeDeltas: [1_000, -100, 1_000],
|
||||
})
|
||||
);
|
||||
await assert.doesNotReject(() =>
|
||||
validateInitialImportDiagnosticArtifacts(
|
||||
capture(directory),
|
||||
directory
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects CPU profiles without useful bounded samples and stack references', async () => {
|
||||
await withArtifacts(async (directory) => {
|
||||
const invalidProfiles = [
|
||||
{ ...CPU_PROFILE, samples: [], timeDeltas: [] },
|
||||
{ ...CPU_PROFILE, timeDeltas: undefined },
|
||||
{ ...CPU_PROFILE, timeDeltas: [] },
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
endTime: CPU_PROFILE.startTime,
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
samples: [2],
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
timeDeltas: [-1],
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
timeDeltas: [2_001],
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
nodes: [
|
||||
...CPU_PROFILE.nodes,
|
||||
{ ...CPU_PROFILE.nodes[0], id: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
nodes: [{ id: 1 }],
|
||||
},
|
||||
];
|
||||
for (const profile of invalidProfiles) {
|
||||
await writeFile(
|
||||
join(directory, 'main.cpuprofile'),
|
||||
JSON.stringify(profile)
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateInitialImportDiagnosticArtifacts(
|
||||
capture(directory),
|
||||
directory
|
||||
),
|
||||
/initial-import-diagnostic-artifact-invalid:main-cpu-profile/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects empty or malformed heap tables and invalid field strides', async () => {
|
||||
await withArtifacts(async (directory) => {
|
||||
const invalidSnapshots = [
|
||||
{ snapshot: {}, nodes: [], edges: [], strings: [] },
|
||||
{ ...HEAP_SNAPSHOT, nodes: [] },
|
||||
{ ...HEAP_SNAPSHOT, edges: [] },
|
||||
{ ...HEAP_SNAPSHOT, strings: [] },
|
||||
{
|
||||
...HEAP_SNAPSHOT,
|
||||
snapshot: {
|
||||
meta: { ...HEAP_META, node_types: undefined },
|
||||
},
|
||||
},
|
||||
{ ...HEAP_SNAPSHOT, nodes: [...HEAP_SNAPSHOT.nodes, 0] },
|
||||
{ ...HEAP_SNAPSHOT, edges: [...HEAP_SNAPSHOT.edges, 0] },
|
||||
];
|
||||
for (const snapshot of invalidSnapshots) {
|
||||
await writeFile(
|
||||
join(directory, 'main.heapsnapshot'),
|
||||
JSON.stringify(snapshot)
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateInitialImportDiagnosticArtifacts(
|
||||
capture(directory),
|
||||
directory
|
||||
),
|
||||
/initial-import-diagnostic-artifact-invalid:main-heap-snapshot/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects empty traces and malformed trace events', async () => {
|
||||
await withArtifacts(async (directory) => {
|
||||
const invalidTraces = [
|
||||
{ traceEvents: [] },
|
||||
{ traceEvents: [null] },
|
||||
{ traceEvents: [{ name: '', ph: 'X', ts: 1 }] },
|
||||
{ traceEvents: [{ name: 'initial-import', ph: '', ts: 1 }] },
|
||||
{
|
||||
traceEvents: [
|
||||
{ name: 'initial-import', ph: 'X', ts: 'invalid' },
|
||||
],
|
||||
},
|
||||
];
|
||||
for (const trace of invalidTraces) {
|
||||
await writeFile(
|
||||
join(directory, 'renderer.trace.json'),
|
||||
JSON.stringify(trace)
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateInitialImportDiagnosticArtifacts(
|
||||
capture(directory),
|
||||
directory
|
||||
),
|
||||
/initial-import-diagnostic-artifact-invalid:renderer-trace/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures';
|
||||
|
||||
interface DiagnosticCapture {
|
||||
readonly main: {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly workers: readonly {
|
||||
readonly kind: string;
|
||||
readonly ordinal: number;
|
||||
readonly profilePath: string | null;
|
||||
readonly snapshotPath: string | null;
|
||||
}[];
|
||||
};
|
||||
readonly renderer: {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly tracePath: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface DiagnosticArtifactsModule {
|
||||
validateInitialImportDiagnosticArtifacts?: (
|
||||
capture: DiagnosticCapture,
|
||||
iterationDirectory: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const diagnosticArtifactsModulePromise = import(
|
||||
new URL('./initial-import-diagnostic-artifacts.ts', import.meta.url).href
|
||||
)
|
||||
.then((module) => module as DiagnosticArtifactsModule)
|
||||
.catch(() => null);
|
||||
|
||||
const HEAP_SNAPSHOT = {
|
||||
edges: [0, 0, 0],
|
||||
nodes: [0, 0, 1, 0, 1, 0],
|
||||
snapshot: {
|
||||
meta: {
|
||||
edge_fields: ['type', 'name_or_index', 'to_node'],
|
||||
edge_types: [['context'], 'string_or_number', 'node'],
|
||||
node_fields: [
|
||||
'type',
|
||||
'name',
|
||||
'id',
|
||||
'self_size',
|
||||
'edge_count',
|
||||
'detachedness',
|
||||
],
|
||||
node_types: [
|
||||
['hidden'],
|
||||
'string',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
],
|
||||
},
|
||||
},
|
||||
strings: ['', 'root'],
|
||||
};
|
||||
const RENDERER_TRACE = {
|
||||
traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }],
|
||||
};
|
||||
|
||||
function capture(directory: string, databaseOrdinal = 4): DiagnosticCapture {
|
||||
return {
|
||||
main: {
|
||||
cpuProfilePath: join(directory, 'main.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'main.heapsnapshot'),
|
||||
workers: [
|
||||
{
|
||||
kind: 'database.worker',
|
||||
ordinal: databaseOrdinal,
|
||||
profilePath: join(
|
||||
directory,
|
||||
`database.worker-${databaseOrdinal}.cpuprofile`
|
||||
),
|
||||
snapshotPath: join(
|
||||
directory,
|
||||
`database.worker-${databaseOrdinal}.heapsnapshot`
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: {
|
||||
cpuProfilePath: join(directory, 'renderer.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'renderer.heapsnapshot'),
|
||||
tracePath: join(directory, 'renderer.trace.json'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeValidArtifacts(
|
||||
directory: string,
|
||||
databaseOrdinal = 4
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(directory, 'main.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, 'renderer.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, `database.worker-${databaseOrdinal}.cpuprofile`),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, `database.worker-${databaseOrdinal}.heapsnapshot`),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, 'main.heapsnapshot'),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, 'renderer.heapsnapshot'),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, 'renderer.trace.json'),
|
||||
JSON.stringify(RENDERER_TRACE)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
test('accepts the complete parseable initial-import diagnostic artifact set', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module, 'initial-import diagnostic validator must exist');
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-diagnostic-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
await assert.doesNotReject(() =>
|
||||
validate?.(capture(directory), directory)
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for malformed or missing required process artifacts', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-invalid-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
await writeFile(join(directory, 'renderer.trace.json'), '{');
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-artifact-invalid:renderer-trace/
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(directory, 'renderer.trace.json'),
|
||||
JSON.stringify(RENDERER_TRACE)
|
||||
);
|
||||
const missingMainProfile: DiagnosticCapture = {
|
||||
...capture(directory),
|
||||
main: {
|
||||
...capture(directory).main,
|
||||
cpuProfilePath: null,
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => validate?.(missingMainProfile, directory),
|
||||
/initial-import-diagnostic-artifact-missing:main-cpu-profile/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects escaped paths and symlinks instead of reading outside the iteration directory', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-contained-')
|
||||
);
|
||||
const outsideDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-outside-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
const escapedCapture: DiagnosticCapture = {
|
||||
...capture(directory),
|
||||
main: {
|
||||
...capture(directory).main,
|
||||
cpuProfilePath: join(outsideDirectory, 'main.cpuprofile'),
|
||||
},
|
||||
};
|
||||
await writeFile(
|
||||
join(outsideDirectory, 'main.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(escapedCapture, directory),
|
||||
/initial-import-diagnostic-artifact-path-invalid:main-cpu-profile/
|
||||
);
|
||||
|
||||
const rendererProfilePath = join(directory, 'renderer.cpuprofile');
|
||||
await unlink(rendererProfilePath);
|
||||
await symlink(
|
||||
join(outsideDirectory, 'main.cpuprofile'),
|
||||
rendererProfilePath
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-artifact-path-invalid:renderer-cpu-profile/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
await rm(outsideDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires exactly one database profile and rejects every playlist-worker profile', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-workers-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
await writeFile(
|
||||
join(directory, 'database.worker-9.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-database-profile-cardinality-invalid/
|
||||
);
|
||||
|
||||
await unlink(join(directory, 'database.worker-9.cpuprofile'));
|
||||
await writeFile(
|
||||
join(directory, 'playlist-refresh.worker-2.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-playlist-profile-unexpected/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires a structurally valid database worker heap snapshot', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
const missingSnapshot: DiagnosticCapture = {
|
||||
...capture(directory),
|
||||
main: {
|
||||
...capture(directory).main,
|
||||
workers: [
|
||||
{
|
||||
...capture(directory).main.workers[0],
|
||||
snapshotPath: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => validate?.(missingSnapshot, directory),
|
||||
/initial-import-diagnostic-artifact-missing:database-heap-snapshot/
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(directory, 'database.worker-4.heapsnapshot'),
|
||||
JSON.stringify({ snapshot: {}, nodes: [], edges: [] })
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-artifact-invalid:database-heap-snapshot/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects escaped and symlinked database worker heap snapshots', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-worker-contained-')
|
||||
);
|
||||
const outsideDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-worker-outside-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
const outsideSnapshotPath = join(
|
||||
outsideDirectory,
|
||||
'database.worker-4.heapsnapshot'
|
||||
);
|
||||
await writeFile(outsideSnapshotPath, JSON.stringify(HEAP_SNAPSHOT));
|
||||
const escapedSnapshot: DiagnosticCapture = {
|
||||
...capture(directory),
|
||||
main: {
|
||||
...capture(directory).main,
|
||||
workers: [
|
||||
{
|
||||
...capture(directory).main.workers[0],
|
||||
snapshotPath: outsideSnapshotPath,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => validate?.(escapedSnapshot, directory),
|
||||
/initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/
|
||||
);
|
||||
|
||||
const snapshotPath = join(directory, 'database.worker-4.heapsnapshot');
|
||||
await unlink(snapshotPath);
|
||||
await symlink(outsideSnapshotPath, snapshotPath);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
await rm(outsideDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects extra database snapshots and every playlist-worker snapshot', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-count-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
await writeFile(
|
||||
join(directory, 'database.worker-9.heapsnapshot'),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-database-snapshot-cardinality-invalid/
|
||||
);
|
||||
|
||||
await unlink(join(directory, 'database.worker-9.heapsnapshot'));
|
||||
await writeFile(
|
||||
join(directory, 'playlist-refresh.worker-2.heapsnapshot'),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-playlist-snapshot-unexpected/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
|
||||
import {
|
||||
isCpuProfile,
|
||||
isHeapSnapshot,
|
||||
isRendererTrace,
|
||||
} from './initial-import-diagnostic-artifact-content-validation';
|
||||
|
||||
interface DiagnosticWorkerArtifact {
|
||||
readonly kind: string;
|
||||
readonly ordinal: number;
|
||||
readonly profilePath: string | null;
|
||||
readonly snapshotPath: string | null;
|
||||
}
|
||||
|
||||
export interface InitialImportDiagnosticCapture {
|
||||
readonly main: {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly workers: readonly DiagnosticWorkerArtifact[];
|
||||
};
|
||||
readonly renderer: {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly tracePath: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
type ArtifactLabel =
|
||||
| 'database-cpu-profile'
|
||||
| 'database-heap-snapshot'
|
||||
| 'main-cpu-profile'
|
||||
| 'main-heap-snapshot'
|
||||
| 'renderer-cpu-profile'
|
||||
| 'renderer-heap-snapshot'
|
||||
| 'renderer-trace';
|
||||
|
||||
interface ArtifactDescription {
|
||||
readonly filename: string;
|
||||
readonly label: ArtifactLabel;
|
||||
readonly path: string | null;
|
||||
readonly validate: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
export async function validateInitialImportDiagnosticArtifacts(
|
||||
capture: InitialImportDiagnosticCapture,
|
||||
iterationDirectory: string
|
||||
): Promise<void> {
|
||||
const directory = resolve(iterationDirectory);
|
||||
let realDirectory: string;
|
||||
try {
|
||||
realDirectory = await realpath(directory);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-artifact-path-invalid:iteration-directory'
|
||||
);
|
||||
}
|
||||
|
||||
const databaseWorker = requireExactDatabaseWorker(capture.main.workers);
|
||||
await assertWorkerArtifactCardinality(directory);
|
||||
|
||||
const artifacts: readonly ArtifactDescription[] = [
|
||||
{
|
||||
filename: 'main.cpuprofile',
|
||||
label: 'main-cpu-profile',
|
||||
path: capture.main.cpuProfilePath,
|
||||
validate: isCpuProfile,
|
||||
},
|
||||
{
|
||||
filename: 'renderer.cpuprofile',
|
||||
label: 'renderer-cpu-profile',
|
||||
path: capture.renderer.cpuProfilePath,
|
||||
validate: isCpuProfile,
|
||||
},
|
||||
{
|
||||
filename: 'renderer.trace.json',
|
||||
label: 'renderer-trace',
|
||||
path: capture.renderer.tracePath,
|
||||
validate: isRendererTrace,
|
||||
},
|
||||
{
|
||||
filename: 'main.heapsnapshot',
|
||||
label: 'main-heap-snapshot',
|
||||
path: capture.main.heapSnapshotPath,
|
||||
validate: isHeapSnapshot,
|
||||
},
|
||||
{
|
||||
filename: 'renderer.heapsnapshot',
|
||||
label: 'renderer-heap-snapshot',
|
||||
path: capture.renderer.heapSnapshotPath,
|
||||
validate: isHeapSnapshot,
|
||||
},
|
||||
{
|
||||
filename: `database.worker-${databaseWorker.ordinal}.cpuprofile`,
|
||||
label: 'database-cpu-profile',
|
||||
path: databaseWorker.profilePath,
|
||||
validate: isCpuProfile,
|
||||
},
|
||||
{
|
||||
filename: `database.worker-${databaseWorker.ordinal}.heapsnapshot`,
|
||||
label: 'database-heap-snapshot',
|
||||
path: databaseWorker.snapshotPath,
|
||||
validate: isHeapSnapshot,
|
||||
},
|
||||
];
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
await validateJsonArtifact(directory, realDirectory, artifact);
|
||||
}
|
||||
}
|
||||
|
||||
function requireExactDatabaseWorker(
|
||||
workers: readonly DiagnosticWorkerArtifact[]
|
||||
): DiagnosticWorkerArtifact {
|
||||
if (
|
||||
workers.some(
|
||||
(worker) =>
|
||||
worker.kind === 'playlist-refresh.worker' &&
|
||||
worker.profilePath !== null
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-playlist-profile-unexpected'
|
||||
);
|
||||
}
|
||||
if (
|
||||
workers.some(
|
||||
(worker) =>
|
||||
worker.kind === 'playlist-refresh.worker' &&
|
||||
worker.snapshotPath !== null
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-playlist-snapshot-unexpected'
|
||||
);
|
||||
}
|
||||
const databaseWorkers = workers.filter(
|
||||
(worker) => worker.kind === 'database.worker'
|
||||
);
|
||||
const worker = databaseWorkers[0];
|
||||
if (
|
||||
databaseWorkers.length !== 1 ||
|
||||
!worker ||
|
||||
!Number.isSafeInteger(worker.ordinal) ||
|
||||
worker.ordinal < 0
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-database-profile-cardinality-invalid'
|
||||
);
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
async function assertWorkerArtifactCardinality(
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
let entries: readonly string[];
|
||||
try {
|
||||
entries = await readdir(directory);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-artifact-path-invalid:iteration-directory'
|
||||
);
|
||||
}
|
||||
if (
|
||||
entries.some((entry) =>
|
||||
/^playlist-refresh\.worker-\d+\.cpuprofile$/u.test(entry)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-playlist-profile-unexpected'
|
||||
);
|
||||
}
|
||||
if (
|
||||
entries.filter((entry) =>
|
||||
/^database\.worker-\d+\.cpuprofile$/u.test(entry)
|
||||
).length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-database-profile-cardinality-invalid'
|
||||
);
|
||||
}
|
||||
if (
|
||||
entries.some((entry) =>
|
||||
/^playlist-refresh\.worker-\d+\.heapsnapshot$/u.test(entry)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-playlist-snapshot-unexpected'
|
||||
);
|
||||
}
|
||||
if (
|
||||
entries.filter((entry) =>
|
||||
/^database\.worker-\d+\.heapsnapshot$/u.test(entry)
|
||||
).length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-database-snapshot-cardinality-invalid'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateJsonArtifact(
|
||||
directory: string,
|
||||
realDirectory: string,
|
||||
artifact: ArtifactDescription
|
||||
): Promise<void> {
|
||||
if (artifact.path === null) {
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-missing:${artifact.label}`
|
||||
);
|
||||
}
|
||||
const expectedPath = join(directory, artifact.filename);
|
||||
if (resolve(artifact.path) !== expectedPath) {
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-path-invalid:${artifact.label}`
|
||||
);
|
||||
}
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
const stats = await lstat(expectedPath);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) {
|
||||
throw new Error('unsafe-artifact-type');
|
||||
}
|
||||
const realArtifactPath = await realpath(expectedPath);
|
||||
if (!isContained(realDirectory, realArtifactPath)) {
|
||||
throw new Error('escaped-artifact');
|
||||
}
|
||||
value = JSON.parse(await readFile(realArtifactPath, 'utf8')) as unknown;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message === 'unsafe-artifact-type' ||
|
||||
error.message === 'escaped-artifact')
|
||||
) {
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-path-invalid:${artifact.label}`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-invalid:${artifact.label}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact.validate(value)) {
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-invalid:${artifact.label}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isContained(directory: string, artifactPath: string): boolean {
|
||||
const relativePath = relative(directory, artifactPath);
|
||||
return (
|
||||
relativePath.length > 0 &&
|
||||
!isAbsolute(relativePath) &&
|
||||
relativePath !== '..' &&
|
||||
!relativePath.startsWith(`..${sep}`)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CPU_PROFILE = Object.freeze({
|
||||
endTime: 3_000,
|
||||
nodes: Object.freeze([
|
||||
Object.freeze({
|
||||
callFrame: Object.freeze({
|
||||
columnNumber: -1,
|
||||
functionName: '(root)',
|
||||
lineNumber: -1,
|
||||
scriptId: '0',
|
||||
url: '',
|
||||
}),
|
||||
hitCount: 1,
|
||||
id: 1,
|
||||
}),
|
||||
]),
|
||||
samples: Object.freeze([1]),
|
||||
startTime: 1_000,
|
||||
timeDeltas: Object.freeze([1_000]),
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
assertSyntheticM3uImportUrl,
|
||||
createM3uImportIterationDefinitions,
|
||||
listM3uImportScenarios,
|
||||
M3U_IMPORT_SCENARIO_ID,
|
||||
} from './m3u-import-benchmark-contract';
|
||||
|
||||
describe('formal M3U import benchmark contract', () => {
|
||||
it('defines the exact 10k, 50k, and 100k synthetic scenarios', () => {
|
||||
assert.deepEqual(listM3uImportScenarios(), [
|
||||
{
|
||||
channelCount: 10_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K,
|
||||
},
|
||||
{
|
||||
channelCount: 50_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K,
|
||||
},
|
||||
{
|
||||
channelCount: 100_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('schedules one warm-up, five measured runs, and one diagnostic per size', () => {
|
||||
const definitions = createM3uImportIterationDefinitions(false);
|
||||
|
||||
assert.equal(definitions.length, 21);
|
||||
for (const scenario of listM3uImportScenarios()) {
|
||||
const scenarioRuns = definitions.filter(
|
||||
(definition) => definition.scenarioId === scenario.id
|
||||
);
|
||||
assert.deepEqual(
|
||||
scenarioRuns.map(({ kind, runId }) => ({ kind, runId })),
|
||||
[
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
||||
runId: 'warmup-01',
|
||||
},
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
runId: `run-${String(index + 1).padStart(2, '0')}`,
|
||||
})),
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
runId: 'diagnostic',
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.ok(
|
||||
scenarioRuns.every(
|
||||
(definition) =>
|
||||
definition.channelCount === scenario.channelCount
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps fixture sizes unchanged while smoke mode reduces measured runs to one', () => {
|
||||
const definitions = createM3uImportIterationDefinitions(true);
|
||||
|
||||
assert.equal(definitions.length, 9);
|
||||
for (const scenario of listM3uImportScenarios()) {
|
||||
assert.deepEqual(
|
||||
definitions
|
||||
.filter(
|
||||
(definition) => definition.scenarioId === scenario.id
|
||||
)
|
||||
.map(({ kind, runId }) => ({ kind, runId })),
|
||||
[
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
||||
runId: 'warmup-01',
|
||||
},
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
runId: 'run-01',
|
||||
},
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
runId: 'diagnostic',
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts only the exact credential-free loopback fixture URL', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertSyntheticM3uImportUrl(
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u'
|
||||
)
|
||||
);
|
||||
|
||||
for (const unsafe of [
|
||||
'https://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
'http://localhost:43210/synthetic-performance.m3u',
|
||||
'http://127.0.0.1/synthetic-performance.m3u',
|
||||
'http://user:pass@127.0.0.1:43210/synthetic-performance.m3u',
|
||||
'http://127.0.0.1:43210/other.m3u',
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u?token=secret',
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u#fragment',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertSyntheticM3uImportUrl(unsafe),
|
||||
/exact synthetic HTTP loopback URL/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
PERFORMANCE_ITERATION_KIND,
|
||||
type PerformanceIterationKind,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import { SYNTHETIC_M3U_RESOURCE_PATH } from './synthetic-m3u-server';
|
||||
import type { SyntheticM3uChannelCount } from './synthetic-m3u';
|
||||
|
||||
export const M3U_IMPORT_SCENARIO_ID = {
|
||||
IMPORT_10K: 'm3u-import-10k',
|
||||
IMPORT_50K: 'm3u-import-50k',
|
||||
IMPORT_100K: 'm3u-import-100k',
|
||||
} as const;
|
||||
|
||||
export type M3uImportScenarioId =
|
||||
(typeof M3U_IMPORT_SCENARIO_ID)[keyof typeof M3U_IMPORT_SCENARIO_ID];
|
||||
|
||||
export interface M3uImportScenario {
|
||||
readonly channelCount: SyntheticM3uChannelCount;
|
||||
readonly id: M3uImportScenarioId;
|
||||
}
|
||||
|
||||
export interface M3uImportIterationDefinition {
|
||||
readonly channelCount: SyntheticM3uChannelCount;
|
||||
readonly kind: PerformanceIterationKind;
|
||||
readonly runId: string;
|
||||
readonly scenarioId: M3uImportScenarioId;
|
||||
}
|
||||
|
||||
const SCENARIOS: readonly M3uImportScenario[] = Object.freeze([
|
||||
Object.freeze({
|
||||
channelCount: 10_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K,
|
||||
}),
|
||||
Object.freeze({
|
||||
channelCount: 50_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K,
|
||||
}),
|
||||
Object.freeze({
|
||||
channelCount: 100_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K,
|
||||
}),
|
||||
]);
|
||||
|
||||
export function listM3uImportScenarios(): readonly M3uImportScenario[] {
|
||||
return SCENARIOS;
|
||||
}
|
||||
|
||||
export function createM3uImportIterationDefinitions(
|
||||
smoke: boolean
|
||||
): readonly M3uImportIterationDefinition[] {
|
||||
const measuredRuns = smoke ? 1 : 5;
|
||||
const definitions: M3uImportIterationDefinition[] = [];
|
||||
|
||||
for (const scenario of SCENARIOS) {
|
||||
definitions.push(
|
||||
iteration(scenario, PERFORMANCE_ITERATION_KIND.WARMUP, 'warmup-01')
|
||||
);
|
||||
for (let index = 1; index <= measuredRuns; index += 1) {
|
||||
definitions.push(
|
||||
iteration(
|
||||
scenario,
|
||||
PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
`run-${String(index).padStart(2, '0')}`
|
||||
)
|
||||
);
|
||||
}
|
||||
definitions.push(
|
||||
iteration(
|
||||
scenario,
|
||||
PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
'diagnostic'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze(definitions);
|
||||
}
|
||||
|
||||
export function assertSyntheticM3uImportUrl(value: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw invalidSyntheticUrl();
|
||||
}
|
||||
|
||||
if (
|
||||
url.protocol !== 'http:' ||
|
||||
url.hostname !== '127.0.0.1' ||
|
||||
url.port.length === 0 ||
|
||||
url.username.length > 0 ||
|
||||
url.password.length > 0 ||
|
||||
url.pathname !== SYNTHETIC_M3U_RESOURCE_PATH ||
|
||||
url.search.length > 0 ||
|
||||
url.hash.length > 0
|
||||
) {
|
||||
throw invalidSyntheticUrl();
|
||||
}
|
||||
}
|
||||
|
||||
function iteration(
|
||||
scenario: M3uImportScenario,
|
||||
kind: PerformanceIterationKind,
|
||||
runId: string
|
||||
): M3uImportIterationDefinition {
|
||||
return Object.freeze({
|
||||
channelCount: scenario.channelCount,
|
||||
kind,
|
||||
runId,
|
||||
scenarioId: scenario.id,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidSyntheticUrl(): Error {
|
||||
return new Error(
|
||||
'M3U performance fixture must use the exact synthetic HTTP loopback URL'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assertM3uImportOutputPathHasNoSymlinks,
|
||||
assertM3uImportSourceState,
|
||||
M3U_IMPORT_RENDERER_CDP_PORT,
|
||||
resolveM3uImportRendererCdpPort,
|
||||
resolveM3uImportOutputLayout,
|
||||
} from './m3u-import-benchmark-support';
|
||||
import { assertM3uImportRequestDelta } from './m3u-import.benchmark';
|
||||
|
||||
describe('initial M3U import benchmark lifecycle', () => {
|
||||
it('keeps requested output and variant strictly below dist/performance', () => {
|
||||
const workspaceRoot = resolve('/workspace/iptvnator');
|
||||
const outputRoot = resolve(
|
||||
workspaceRoot,
|
||||
'dist/performance/20260727T040000Z-m3u-import'
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveM3uImportOutputLayout(workspaceRoot, outputRoot, 'baseline'),
|
||||
{
|
||||
outputRoot,
|
||||
variantDirectory: resolve(outputRoot, 'baseline'),
|
||||
}
|
||||
);
|
||||
|
||||
for (const requestedRoot of [
|
||||
'dist/performance/run',
|
||||
resolve(workspaceRoot, 'dist/performance'),
|
||||
resolve(workspaceRoot, 'dist/performance-sibling/run'),
|
||||
resolve(workspaceRoot, '../outside'),
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveM3uImportOutputLayout(
|
||||
workspaceRoot,
|
||||
requestedRoot,
|
||||
'baseline'
|
||||
),
|
||||
/absolute path below dist\/performance/
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveM3uImportOutputLayout(
|
||||
workspaceRoot,
|
||||
outputRoot,
|
||||
'../escape'
|
||||
),
|
||||
/variant is invalid/i
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a clean source tree for formal runs but permits dirty smoke evidence', () => {
|
||||
const dirtyState = {
|
||||
commit: 'a'.repeat(40),
|
||||
diffSha256: 'b'.repeat(64),
|
||||
dirty: true,
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => assertM3uImportSourceState(false, dirtyState),
|
||||
/Formal performance runs require a clean Git worktree/
|
||||
);
|
||||
assert.doesNotThrow(() => assertM3uImportSourceState(true, dirtyState));
|
||||
assert.doesNotThrow(() =>
|
||||
assertM3uImportSourceState(false, {
|
||||
...dirtyState,
|
||||
dirty: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an existing output ancestor symlink before artifact creation', async () => {
|
||||
const fixtureRoot = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-m3u-output-path-')
|
||||
);
|
||||
const repositoryRoot = join(fixtureRoot, 'repository');
|
||||
const performanceRoot = join(
|
||||
repositoryRoot,
|
||||
'dist',
|
||||
'performance'
|
||||
);
|
||||
const outside = join(fixtureRoot, 'outside');
|
||||
await mkdir(performanceRoot, { recursive: true });
|
||||
await mkdir(outside);
|
||||
await symlink(outside, join(performanceRoot, 'redirect'));
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
assertM3uImportOutputPathHasNoSymlinks(
|
||||
repositoryRoot,
|
||||
join(performanceRoot, 'redirect', 'run')
|
||||
),
|
||||
/Performance output path contains a symbolic link/
|
||||
);
|
||||
await assert.doesNotReject(
|
||||
assertM3uImportOutputPathHasNoSymlinks(
|
||||
repositoryRoot,
|
||||
join(performanceRoot, 'ordinary', 'run')
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await rm(fixtureRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps formal CDP capture on 9222 while allowing an isolated smoke port', () => {
|
||||
assert.equal(
|
||||
resolveM3uImportRendererCdpPort(false, undefined),
|
||||
M3U_IMPORT_RENDERER_CDP_PORT
|
||||
);
|
||||
assert.equal(
|
||||
resolveM3uImportRendererCdpPort(false, '9222'),
|
||||
M3U_IMPORT_RENDERER_CDP_PORT
|
||||
);
|
||||
assert.equal(resolveM3uImportRendererCdpPort(true, '9322'), 9322);
|
||||
|
||||
assert.throws(
|
||||
() => resolveM3uImportRendererCdpPort(false, '9322'),
|
||||
/Formal performance runs require CDP port 9222/
|
||||
);
|
||||
for (const invalid of ['0', '1023', '65536', '9222.5', 'not-a-port']) {
|
||||
assert.throws(
|
||||
() => resolveM3uImportRendererCdpPort(true, invalid),
|
||||
/IPTVNATOR_PERF_CDP_PORT is invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed unless one and only one app GET occurs', () => {
|
||||
assert.doesNotThrow(() => assertM3uImportRequestDelta(3, 4));
|
||||
assert.throws(
|
||||
() => assertM3uImportRequestDelta(3, 3),
|
||||
/exactly one application GET/
|
||||
);
|
||||
assert.throws(
|
||||
() => assertM3uImportRequestDelta(3, 5),
|
||||
/exactly one application GET/
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the exact upsert plus route-reload GET sequence', () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'src/performance/m3u-import.benchmark.ts'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(source, /status\.databaseRequests\s*===\s*2/);
|
||||
assert.match(source, /status\.databaseUpsertsCompleted\s*===\s*1/);
|
||||
assert.match(source, /status\.databaseGetsCompleted\s*===\s*1/);
|
||||
assert.match(source, /status\.databasePending\s*===\s*0/);
|
||||
assert.match(source, /status\.preloadSuccessMarkers\s*===\s*2/);
|
||||
});
|
||||
|
||||
it('always disposes renderer capture before closing Electron on iteration failure', () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'src/performance/m3u-import.benchmark.ts'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const runIteration = source.indexOf('async function runIteration');
|
||||
const finallyBlock = source.indexOf('} finally {', runIteration);
|
||||
const rendererDispose = source.indexOf(
|
||||
'await renderer.dispose()',
|
||||
finallyBlock
|
||||
);
|
||||
const appClose = source.indexOf('await closeElectronApp(app)', finallyBlock);
|
||||
|
||||
assert.ok(finallyBlock > runIteration);
|
||||
assert.ok(rendererDispose > finallyBlock);
|
||||
assert.ok(appClose > rendererDispose);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { createServer as createTcpServer } from 'node:net';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
type LaunchedElectronApp,
|
||||
workspaceRoot,
|
||||
} from '../electron-test-fixtures';
|
||||
import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight';
|
||||
import type { RendererWindowIdentity } from './renderer-window-rss-session';
|
||||
|
||||
const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
export const M3U_IMPORT_RENDERER_CDP_PORT = 9222;
|
||||
|
||||
export interface M3uImportGitSourceState {
|
||||
readonly commit: string;
|
||||
readonly diffSha256: string;
|
||||
readonly dirty: boolean;
|
||||
}
|
||||
|
||||
export interface M3uImportOutputLayout {
|
||||
readonly outputRoot: string;
|
||||
readonly variantDirectory: string;
|
||||
}
|
||||
|
||||
export interface M3uImportBenchmarkConfiguration extends M3uImportOutputLayout {
|
||||
readonly electronVersion: string;
|
||||
readonly measuredRuns: number;
|
||||
readonly rendererCdpPort: number;
|
||||
readonly smoke: boolean;
|
||||
readonly sourceState: M3uImportGitSourceState;
|
||||
readonly variant: string;
|
||||
}
|
||||
|
||||
export async function resolveM3uImportBenchmarkConfiguration(): Promise<M3uImportBenchmarkConfiguration> {
|
||||
const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR'];
|
||||
if (!requestedRoot) {
|
||||
throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required');
|
||||
}
|
||||
const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline';
|
||||
const layout = resolveM3uImportOutputLayout(
|
||||
workspaceRoot,
|
||||
requestedRoot,
|
||||
variant
|
||||
);
|
||||
const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1';
|
||||
const rendererCdpPort = resolveM3uImportRendererCdpPort(
|
||||
smoke,
|
||||
process.env['IPTVNATOR_PERF_CDP_PORT']
|
||||
);
|
||||
const sourceState = readGitSourceState(workspaceRoot);
|
||||
assertM3uImportSourceState(smoke, sourceState);
|
||||
const electronPackage = JSON.parse(
|
||||
await readFile(
|
||||
join(workspaceRoot, 'node_modules', 'electron', 'package.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as { version?: unknown };
|
||||
if (typeof electronPackage.version !== 'string') {
|
||||
throw new Error('Unable to resolve the Electron runtime version');
|
||||
}
|
||||
await assertM3uImportOutputPathHasNoSymlinks(
|
||||
workspaceRoot,
|
||||
layout.outputRoot
|
||||
);
|
||||
await assertMissing(layout.variantDirectory);
|
||||
await assertPerformanceArtifactCapacity(layout.variantDirectory);
|
||||
await mkdir(layout.variantDirectory, { recursive: true });
|
||||
return Object.freeze({
|
||||
...layout,
|
||||
electronVersion: electronPackage.version,
|
||||
measuredRuns: smoke ? 1 : 5,
|
||||
rendererCdpPort,
|
||||
smoke,
|
||||
sourceState,
|
||||
variant,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveM3uImportOutputLayout(
|
||||
repositoryRoot: string,
|
||||
requestedRoot: string,
|
||||
variant: string
|
||||
): M3uImportOutputLayout {
|
||||
const performanceRoot = resolve(repositoryRoot, 'dist', 'performance');
|
||||
const outputRoot = resolve(requestedRoot);
|
||||
if (
|
||||
!isAbsolute(requestedRoot) ||
|
||||
!isStrictDescendant(performanceRoot, outputRoot)
|
||||
) {
|
||||
throw new Error(
|
||||
'Performance output must be an absolute path below dist/performance'
|
||||
);
|
||||
}
|
||||
if (!OUTPUT_VARIANT_PATTERN.test(variant)) {
|
||||
throw new Error('IPTVNATOR_PERF_VARIANT is invalid');
|
||||
}
|
||||
const variantDirectory = resolve(outputRoot, variant);
|
||||
if (!isStrictDescendant(outputRoot, variantDirectory)) {
|
||||
throw new Error('IPTVNATOR_PERF_VARIANT is invalid');
|
||||
}
|
||||
return Object.freeze({ outputRoot, variantDirectory });
|
||||
}
|
||||
|
||||
export async function assertM3uImportOutputPathHasNoSymlinks(
|
||||
repositoryRoot: string,
|
||||
outputRoot: string
|
||||
): Promise<void> {
|
||||
const root = resolve(repositoryRoot);
|
||||
const output = resolve(outputRoot);
|
||||
if (!isStrictDescendant(root, output)) {
|
||||
throw new Error('Performance output path escapes the repository');
|
||||
}
|
||||
const components = relative(root, output).split(sep);
|
||||
let current = root;
|
||||
|
||||
for (const component of ['', ...components]) {
|
||||
current = component === '' ? current : join(current, component);
|
||||
try {
|
||||
const stats = await lstat(current);
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Performance output path contains a symbolic link: ${current}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertM3uImportSourceState(
|
||||
smoke: boolean,
|
||||
state: M3uImportGitSourceState
|
||||
): void {
|
||||
if (!smoke && state.dirty) {
|
||||
throw new Error(
|
||||
'Formal performance runs require a clean Git worktree; commit or stash changes first'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveM3uImportRendererCdpPort(
|
||||
smoke: boolean,
|
||||
requestedPort: string | undefined
|
||||
): number {
|
||||
const port =
|
||||
requestedPort === undefined
|
||||
? M3U_IMPORT_RENDERER_CDP_PORT
|
||||
: Number(requestedPort);
|
||||
if (
|
||||
(requestedPort !== undefined && !/^[1-9]\d*$/.test(requestedPort)) ||
|
||||
!Number.isSafeInteger(port) ||
|
||||
port < 1024 ||
|
||||
port > 65_535
|
||||
) {
|
||||
throw new Error('IPTVNATOR_PERF_CDP_PORT is invalid');
|
||||
}
|
||||
if (!smoke && port !== M3U_IMPORT_RENDERER_CDP_PORT) {
|
||||
throw new Error(
|
||||
`Formal performance runs require CDP port ${M3U_IMPORT_RENDERER_CDP_PORT}`
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export async function resolveRendererWindowIdentity(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<RendererWindowIdentity> {
|
||||
const handle = await app.electronApp.browserWindow(app.mainWindow);
|
||||
try {
|
||||
return await handle.evaluate((browserWindow) => {
|
||||
const exactWindow = browserWindow as unknown as {
|
||||
readonly id: number;
|
||||
readonly webContents: { readonly id: number };
|
||||
};
|
||||
return {
|
||||
browserWindowId: exactWindow.id,
|
||||
webContentsId: exactWindow.webContents.id,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
await handle.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertTcpPortAvailable(port: number): Promise<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()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertRendererCdpTarget(
|
||||
page: Page,
|
||||
port: number
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/json/list`
|
||||
);
|
||||
const targets = response.ok
|
||||
? ((await response.json()) as unknown)
|
||||
: null;
|
||||
return (
|
||||
Array.isArray(targets) &&
|
||||
targets.some(
|
||||
(target) =>
|
||||
typeof target === 'object' &&
|
||||
target !== null &&
|
||||
(target as Record<string, unknown>)['type'] ===
|
||||
'page' &&
|
||||
(target as Record<string, unknown>)['url'] ===
|
||||
page.url()
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function writeM3uImportJson(
|
||||
path: string,
|
||||
value: unknown
|
||||
): Promise<void> {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function readGitSourceState(repositoryRoot: string): M3uImportGitSourceState {
|
||||
const git = (args: readonly string[]): string =>
|
||||
execFileSync('git', [...args], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const commit = git(['rev-parse', 'HEAD']).trim();
|
||||
const status = git(['status', '--porcelain=v1', '--untracked-files=all']);
|
||||
const diffSha256 = createHash('sha256')
|
||||
.update(status)
|
||||
.update('\0')
|
||||
.update(git(['diff', '--binary', 'HEAD']))
|
||||
.update('\0')
|
||||
.update(git(['diff', '--binary', '--cached', 'HEAD']))
|
||||
.digest('hex');
|
||||
return Object.freeze({
|
||||
commit,
|
||||
diffSha256,
|
||||
dirty: status.trim().length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, candidate: string): boolean {
|
||||
const pathFromParent = relative(resolve(parent), resolve(candidate));
|
||||
return (
|
||||
pathFromParent.length > 0 &&
|
||||
pathFromParent !== '..' &&
|
||||
!pathFromParent.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(pathFromParent)
|
||||
);
|
||||
}
|
||||
|
||||
async function assertMissing(path: string): Promise<void> {
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Performance output already exists: ${path}`);
|
||||
}
|
||||
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type {
|
||||
MainTimelineRecord,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
|
||||
type InitialImportDatabaseOperation =
|
||||
| 'DB_GET_APP_PLAYLIST'
|
||||
| 'DB_UPSERT_APP_PLAYLIST';
|
||||
|
||||
export interface M3uImportIpcAttribution {
|
||||
readonly databaseWorkerToMainCloneProxyMs: number;
|
||||
readonly ipcStructuredCloneProxyMs: number;
|
||||
readonly mainToDatabaseWorkerCloneProxyMs: number;
|
||||
readonly mainToRendererCloneProxyMs: number;
|
||||
readonly rendererToMainCloneProxyMs: number;
|
||||
}
|
||||
|
||||
interface RequestBoundaryAttribution {
|
||||
readonly mainToDatabaseWorkerMs: number;
|
||||
readonly mainToRendererMs: number;
|
||||
readonly rendererToMainMs: number;
|
||||
readonly successEpochMs: number;
|
||||
readonly workerToMainMs: number;
|
||||
}
|
||||
|
||||
export function deriveM3uImportIpcAttribution(input: {
|
||||
readonly get: WorkerRequestPerformanceMetrics;
|
||||
readonly getReadStartedEpochMs: number;
|
||||
readonly importDispatchEndedEpochMs: number;
|
||||
readonly importDispatchStartedEpochMs: number;
|
||||
readonly normalizeEndedEpochMs: number;
|
||||
readonly playlistId: string;
|
||||
readonly publishChannelsStartedEpochMs: number;
|
||||
readonly serializeStartedEpochMs: number;
|
||||
readonly timeline: readonly MainTimelineRecord[];
|
||||
readonly upsert: WorkerRequestPerformanceMetrics;
|
||||
}): M3uImportIpcAttribution {
|
||||
requireOrdered(
|
||||
input.normalizeEndedEpochMs,
|
||||
input.importDispatchStartedEpochMs
|
||||
);
|
||||
requireOrdered(
|
||||
input.importDispatchStartedEpochMs,
|
||||
input.importDispatchEndedEpochMs
|
||||
);
|
||||
|
||||
const upsert = deriveRequestBoundaryAttribution({
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
playlistId: input.playlistId,
|
||||
request: input.upsert,
|
||||
timeline: input.timeline,
|
||||
workPhaseStartedEpochMs: input.serializeStartedEpochMs,
|
||||
});
|
||||
requireOrdered(input.importDispatchEndedEpochMs, requireSource(input.upsert));
|
||||
|
||||
const get = deriveRequestBoundaryAttribution({
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
playlistId: input.playlistId,
|
||||
request: input.get,
|
||||
timeline: input.timeline,
|
||||
workPhaseStartedEpochMs: input.getReadStartedEpochMs,
|
||||
});
|
||||
requireOrdered(upsert.successEpochMs, requireSource(input.get));
|
||||
requireOrdered(
|
||||
get.successEpochMs,
|
||||
input.publishChannelsStartedEpochMs
|
||||
);
|
||||
|
||||
const initialMainToRendererMs = difference(
|
||||
input.importDispatchStartedEpochMs,
|
||||
input.normalizeEndedEpochMs
|
||||
);
|
||||
const mainToRendererCloneProxyMs =
|
||||
initialMainToRendererMs +
|
||||
upsert.mainToRendererMs +
|
||||
get.mainToRendererMs;
|
||||
const rendererToMainCloneProxyMs =
|
||||
upsert.rendererToMainMs + get.rendererToMainMs;
|
||||
const mainToDatabaseWorkerCloneProxyMs =
|
||||
upsert.mainToDatabaseWorkerMs + get.mainToDatabaseWorkerMs;
|
||||
const databaseWorkerToMainCloneProxyMs =
|
||||
upsert.workerToMainMs + get.workerToMainMs;
|
||||
|
||||
return Object.freeze({
|
||||
databaseWorkerToMainCloneProxyMs,
|
||||
ipcStructuredCloneProxyMs:
|
||||
mainToRendererCloneProxyMs +
|
||||
rendererToMainCloneProxyMs +
|
||||
mainToDatabaseWorkerCloneProxyMs +
|
||||
databaseWorkerToMainCloneProxyMs,
|
||||
mainToDatabaseWorkerCloneProxyMs,
|
||||
mainToRendererCloneProxyMs,
|
||||
rendererToMainCloneProxyMs,
|
||||
});
|
||||
}
|
||||
|
||||
function deriveRequestBoundaryAttribution(input: {
|
||||
readonly operation: InitialImportDatabaseOperation;
|
||||
readonly playlistId: string;
|
||||
readonly request: WorkerRequestPerformanceMetrics;
|
||||
readonly timeline: readonly MainTimelineRecord[];
|
||||
readonly workPhaseStartedEpochMs: number;
|
||||
}): RequestBoundaryAttribution {
|
||||
requireInitialImportPreloadIdentity(input.request, input.operation);
|
||||
const requestTimeline = requireTimelineRecord(
|
||||
input.timeline,
|
||||
'db-request',
|
||||
input.request,
|
||||
input.operation,
|
||||
input.playlistId
|
||||
);
|
||||
requireTimelineRecord(
|
||||
input.timeline,
|
||||
'db-response',
|
||||
input.request,
|
||||
input.operation,
|
||||
input.playlistId
|
||||
);
|
||||
const successTimeline = requireTimelineRecord(
|
||||
input.timeline,
|
||||
'preload-performance-success',
|
||||
input.request,
|
||||
input.operation,
|
||||
input.playlistId
|
||||
);
|
||||
const sourceEpochMs = requireSource(input.request);
|
||||
const requestReceivedEpochMs = requireTimestamp(
|
||||
input.request.requestReceivedEpochMs
|
||||
);
|
||||
const workStartedEpochMs = requireTimestamp(
|
||||
input.request.workStartedEpochMs
|
||||
);
|
||||
const workEndedEpochMs = requireTimestamp(input.request.workEndedEpochMs);
|
||||
const responsePostedEpochMs = requireTimestamp(
|
||||
input.request.responsePostedEpochMs
|
||||
);
|
||||
const responseEpochMs = requireTimestamp(input.request.responseEpochMs);
|
||||
const successEpochMs = requireTimestamp(successTimeline.sourceEpochMs);
|
||||
|
||||
requireOrdered(sourceEpochMs, requestTimeline.epochMs);
|
||||
requireOrdered(requestTimeline.epochMs, requestReceivedEpochMs);
|
||||
requireOrdered(requestReceivedEpochMs, workStartedEpochMs);
|
||||
requireOrdered(workStartedEpochMs, input.workPhaseStartedEpochMs);
|
||||
requireOrdered(input.workPhaseStartedEpochMs, workEndedEpochMs);
|
||||
requireOrdered(workEndedEpochMs, responsePostedEpochMs);
|
||||
requireOrdered(responsePostedEpochMs, responseEpochMs);
|
||||
requireOrdered(responseEpochMs, successEpochMs);
|
||||
|
||||
return {
|
||||
mainToDatabaseWorkerMs: difference(
|
||||
requestReceivedEpochMs,
|
||||
requestTimeline.epochMs
|
||||
),
|
||||
mainToRendererMs: difference(successEpochMs, responseEpochMs),
|
||||
rendererToMainMs: difference(requestTimeline.epochMs, sourceEpochMs),
|
||||
successEpochMs,
|
||||
workerToMainMs: difference(responseEpochMs, responsePostedEpochMs),
|
||||
};
|
||||
}
|
||||
|
||||
function requireTimelineRecord(
|
||||
timeline: readonly MainTimelineRecord[],
|
||||
type: 'db-request' | 'db-response' | 'preload-performance-success',
|
||||
request: WorkerRequestPerformanceMetrics,
|
||||
operation: InitialImportDatabaseOperation,
|
||||
playlistId: string
|
||||
): MainTimelineRecord {
|
||||
if (!request.requestId || !Number.isSafeInteger(request.ipcCallId)) {
|
||||
throw new Error('database-request-identity-missing');
|
||||
}
|
||||
const matches = timeline.filter((record) => {
|
||||
if (
|
||||
record.type !== type ||
|
||||
record.operation !== operation ||
|
||||
record.playlistId !== playlistId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (type === 'preload-performance-success') {
|
||||
return (
|
||||
record.ipcCallId === request.ipcCallId &&
|
||||
record.sourceEpochMs !== undefined &&
|
||||
record.success === true
|
||||
);
|
||||
}
|
||||
return (
|
||||
record.requestId === request.requestId &&
|
||||
(type !== 'db-response' || record.success === true)
|
||||
);
|
||||
});
|
||||
if (matches.length !== 1 || !matches[0]) {
|
||||
throw new Error(`database-${type}-timeline-correlation`);
|
||||
}
|
||||
const record = matches[0];
|
||||
if (
|
||||
type === 'db-request' &&
|
||||
(record.ipcCallId !== request.ipcCallId ||
|
||||
record.sourceEpochMs !== request.sourceEpochMs)
|
||||
) {
|
||||
throw new Error('database-request-timeline-identity');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function requireInitialImportPreloadIdentity(
|
||||
request: WorkerRequestPerformanceMetrics,
|
||||
operation: InitialImportDatabaseOperation
|
||||
): void {
|
||||
if (
|
||||
request.operation !== operation ||
|
||||
!request.success ||
|
||||
request.performanceCaptureUnavailableReason !== null ||
|
||||
!Number.isSafeInteger(request.ipcCallId) ||
|
||||
Number(request.ipcCallId) < 1 ||
|
||||
typeof request.sourceEpochMs !== 'number' ||
|
||||
!Number.isFinite(request.sourceEpochMs) ||
|
||||
request.sourceEpochMs < 0 ||
|
||||
request.operationId !== null ||
|
||||
request.operationIdUnavailableReason !==
|
||||
'preload-performance-marker-uncorrelated'
|
||||
) {
|
||||
throw new Error('database-request-preload-identity');
|
||||
}
|
||||
}
|
||||
|
||||
function requireSource(request: WorkerRequestPerformanceMetrics): number {
|
||||
return requireTimestamp(request.sourceEpochMs);
|
||||
}
|
||||
|
||||
function requireTimestamp(value: number | null | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error('database-request-timestamp-missing');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireOrdered(startEpochMs: number, endEpochMs: number): void {
|
||||
if (
|
||||
!Number.isFinite(startEpochMs) ||
|
||||
!Number.isFinite(endEpochMs) ||
|
||||
endEpochMs < startEpochMs
|
||||
) {
|
||||
throw new Error('phase-order');
|
||||
}
|
||||
}
|
||||
|
||||
function difference(endEpochMs: number, startEpochMs: number): number {
|
||||
requireOrdered(startEpochMs, endEpochMs);
|
||||
return endEpochMs - startEpochMs;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { once } from 'node:events';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { CDPSession } from '@playwright/test';
|
||||
|
||||
interface TraceData {
|
||||
readonly value: readonly unknown[];
|
||||
}
|
||||
|
||||
export interface RendererArtifactCapture {
|
||||
beginTraceOutput(): void;
|
||||
readonly cpuProfilePath: string | null;
|
||||
finishTraceOutput(): Promise<void>;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly onTraceComplete: () => void;
|
||||
readonly onTraceData: (data: TraceData) => void;
|
||||
readonly traceComplete: Promise<void>;
|
||||
readonly tracePath: string | null;
|
||||
}
|
||||
|
||||
export function createRendererArtifactCapture(options: {
|
||||
readonly diagnostic: boolean;
|
||||
readonly outputDirectory: string;
|
||||
}): RendererArtifactCapture {
|
||||
const tracePath = options.diagnostic
|
||||
? join(options.outputDirectory, 'renderer.trace.json')
|
||||
: null;
|
||||
const traceOutput = tracePath ? createWriteStream(tracePath) : null;
|
||||
let traceOutputOpened = false;
|
||||
let traceOutputFinish: Promise<void> | null = null;
|
||||
let traceFirstEvent = true;
|
||||
let completeTrace: (() => void) | null = null;
|
||||
const traceComplete = new Promise<void>((resolve) => {
|
||||
completeTrace = resolve;
|
||||
});
|
||||
return {
|
||||
beginTraceOutput: () => {
|
||||
if (
|
||||
traceOutput &&
|
||||
!traceOutputOpened &&
|
||||
traceOutputFinish === null
|
||||
) {
|
||||
traceOutput.write('{"traceEvents":[');
|
||||
traceOutputOpened = true;
|
||||
}
|
||||
},
|
||||
cpuProfilePath: options.diagnostic
|
||||
? join(options.outputDirectory, 'renderer.cpuprofile')
|
||||
: null,
|
||||
finishTraceOutput: () => {
|
||||
traceOutputFinish ??= (async () => {
|
||||
if (!traceOutput) {
|
||||
return;
|
||||
}
|
||||
if (!traceOutputOpened) {
|
||||
traceOutput.write('{"traceEvents":[');
|
||||
traceOutputOpened = true;
|
||||
}
|
||||
if (!traceOutput.writableEnded) {
|
||||
traceOutput.write(']}');
|
||||
traceOutput.end();
|
||||
}
|
||||
if (!traceOutput.writableFinished) {
|
||||
await once(traceOutput, 'finish');
|
||||
}
|
||||
})();
|
||||
return traceOutputFinish;
|
||||
},
|
||||
heapSnapshotPath: options.diagnostic
|
||||
? join(options.outputDirectory, 'renderer.heapsnapshot')
|
||||
: null,
|
||||
onTraceComplete: () => {
|
||||
completeTrace?.();
|
||||
completeTrace = null;
|
||||
},
|
||||
onTraceData: (data) => {
|
||||
if (!traceOutputOpened || traceOutput?.writableEnded) {
|
||||
return;
|
||||
}
|
||||
for (const event of data.value) {
|
||||
traceOutput?.write(
|
||||
`${traceFirstEvent ? '' : ','}${JSON.stringify(event)}`
|
||||
);
|
||||
traceFirstEvent = false;
|
||||
}
|
||||
},
|
||||
traceComplete,
|
||||
tracePath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startRendererDiagnosticCapture(
|
||||
session: CDPSession,
|
||||
artifacts: RendererArtifactCapture
|
||||
): Promise<void> {
|
||||
await session.send('Profiler.enable');
|
||||
await session.send('Profiler.start');
|
||||
artifacts.beginTraceOutput();
|
||||
session.on('Tracing.dataCollected', artifacts.onTraceData);
|
||||
session.on('Tracing.tracingComplete', artifacts.onTraceComplete);
|
||||
await session.send('Tracing.start', {
|
||||
categories: [
|
||||
'blink.user_timing',
|
||||
'devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline',
|
||||
'disabled-by-default-v8.cpu_profiler',
|
||||
'v8',
|
||||
].join(','),
|
||||
options: 'sampling-frequency=1000',
|
||||
transferMode: 'ReportEvents',
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopRendererDiagnosticCapture(
|
||||
session: CDPSession,
|
||||
artifacts: RendererArtifactCapture
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cpuProfile = await session.send('Profiler.stop');
|
||||
await writeFile(
|
||||
requirePath(artifacts.cpuProfilePath, 'renderer CPU profile'),
|
||||
JSON.stringify(cpuProfile['profile']),
|
||||
'utf8'
|
||||
);
|
||||
await session.send('Tracing.end');
|
||||
await artifacts.traceComplete;
|
||||
} finally {
|
||||
disposeRendererDiagnosticListeners(session, artifacts);
|
||||
await artifacts.finishTraceOutput();
|
||||
}
|
||||
}
|
||||
|
||||
export async function disposeRendererDiagnosticCapture(
|
||||
session: CDPSession,
|
||||
artifacts: RendererArtifactCapture
|
||||
): Promise<void> {
|
||||
disposeRendererDiagnosticListeners(session, artifacts);
|
||||
await artifacts.finishTraceOutput();
|
||||
}
|
||||
|
||||
export async function takeRendererHeapSnapshot(
|
||||
session: CDPSession,
|
||||
path: string | null
|
||||
): Promise<void> {
|
||||
const output = createWriteStream(
|
||||
requirePath(path, 'renderer heap snapshot')
|
||||
);
|
||||
const onChunk = ({ chunk }: { readonly chunk: string }): void => {
|
||||
output.write(chunk);
|
||||
};
|
||||
session.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
|
||||
try {
|
||||
await session.send('HeapProfiler.takeHeapSnapshot', {
|
||||
reportProgress: false,
|
||||
});
|
||||
} finally {
|
||||
session.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
|
||||
output.end();
|
||||
if (!output.writableFinished) {
|
||||
await once(output, 'finish');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disposeRendererDiagnosticListeners(
|
||||
session: CDPSession,
|
||||
artifacts: RendererArtifactCapture
|
||||
): void {
|
||||
session.off('Tracing.dataCollected', artifacts.onTraceData);
|
||||
session.off('Tracing.tracingComplete', artifacts.onTraceComplete);
|
||||
artifacts.onTraceComplete();
|
||||
}
|
||||
|
||||
function requirePath(value: string | null, label: string): string {
|
||||
if (value === null) {
|
||||
throw new Error(`${label} path is unavailable`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based lifecycle test. */
|
||||
import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { startM3uImportRendererCapture } from './m3u-import-renderer-capture';
|
||||
import { M3U_IMPORT_RENDERER_STATE_KEY } from './m3u-import-renderer-probe';
|
||||
|
||||
test('idempotently disposes failed-iteration timers, probe, trace stream, listeners, and CDP session', async () => {
|
||||
const browser = installFakeProbeEnvironment();
|
||||
const outputDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-renderer-capture-dispose-')
|
||||
);
|
||||
const session = new FakeCdpSession();
|
||||
const control = {
|
||||
click: async () => undefined,
|
||||
fill: async () => undefined,
|
||||
first: () => control,
|
||||
getByRole: () => control,
|
||||
isDisabled: async () => false,
|
||||
last: () => control,
|
||||
waitFor: async () => undefined,
|
||||
};
|
||||
const page = {
|
||||
...browser.page,
|
||||
context: () => ({
|
||||
newCDPSession: async () => session,
|
||||
}),
|
||||
getByRole: () => control,
|
||||
locator: () => control,
|
||||
};
|
||||
|
||||
try {
|
||||
const capture = await startM3uImportRendererCapture(
|
||||
page as never,
|
||||
{
|
||||
diagnostic: true,
|
||||
outputDirectory,
|
||||
playlistTitle: 'Synthetic',
|
||||
playlistUrl:
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
}
|
||||
);
|
||||
assert.equal(session.listenerCount('Tracing.dataCollected'), 1);
|
||||
assert.equal(session.listenerCount('Tracing.tracingComplete'), 1);
|
||||
|
||||
await capture.dispose();
|
||||
await capture.dispose();
|
||||
|
||||
assert.equal(session.detachCount, 1);
|
||||
assert.equal(session.listenerCount('Tracing.dataCollected'), 0);
|
||||
assert.equal(session.listenerCount('Tracing.tracingComplete'), 0);
|
||||
assert.equal(browser.readState(), undefined);
|
||||
const heapSamplesAfterDispose = session.heapUsageCalls;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(session.heapUsageCalls, heapSamplesAfterDispose);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
join(outputDirectory, 'renderer.trace.json'),
|
||||
'utf8'
|
||||
)
|
||||
),
|
||||
{ traceEvents: [] }
|
||||
);
|
||||
} finally {
|
||||
browser.cleanup();
|
||||
await rm(outputDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('failed-iteration disposal does not await a wedged renderer or CDP detach', async () => {
|
||||
const browser = installFakeProbeEnvironment();
|
||||
const outputDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-renderer-capture-wedged-')
|
||||
);
|
||||
const session = new FakeCdpSession({ hangDetach: true });
|
||||
let evaluateCalls = 0;
|
||||
const page = createFakePage(browser, session, async (callback, argument) => {
|
||||
evaluateCalls += 1;
|
||||
if (evaluateCalls > 1) {
|
||||
return new Promise<never>(() => undefined);
|
||||
}
|
||||
return browser.page.evaluate(callback, argument);
|
||||
});
|
||||
|
||||
try {
|
||||
const capture = await startM3uImportRendererCapture(page as never, {
|
||||
diagnostic: false,
|
||||
outputDirectory,
|
||||
playlistTitle: 'Synthetic',
|
||||
playlistUrl:
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
});
|
||||
const outcome = await Promise.race([
|
||||
capture.dispose().then(() => 'disposed'),
|
||||
new Promise<'timeout'>((resolve) =>
|
||||
setTimeout(() => resolve('timeout'), 150)
|
||||
),
|
||||
]);
|
||||
|
||||
assert.equal(outcome, 'disposed');
|
||||
assert.equal(session.detachCount, 1);
|
||||
const heapSamplesAfterDispose = session.heapUsageCalls;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(session.heapUsageCalls, heapSamplesAfterDispose);
|
||||
} finally {
|
||||
browser.cleanup();
|
||||
await rm(outputDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rolls back partially started capture when Chromium tracing rejects', async () => {
|
||||
const browser = installFakeProbeEnvironment();
|
||||
const outputDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-renderer-capture-startup-')
|
||||
);
|
||||
const session = new FakeCdpSession({ failMethod: 'Tracing.start' });
|
||||
const intervalToken = Object.freeze({});
|
||||
const setIntervalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'setInterval'
|
||||
);
|
||||
const clearIntervalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'clearInterval'
|
||||
);
|
||||
let clearIntervalCalls = 0;
|
||||
Object.defineProperty(globalThis, 'setInterval', {
|
||||
configurable: true,
|
||||
value: () => intervalToken,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'clearInterval', {
|
||||
configurable: true,
|
||||
value: (token: unknown) => {
|
||||
if (token === intervalToken) {
|
||||
clearIntervalCalls += 1;
|
||||
}
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
startM3uImportRendererCapture(
|
||||
createFakePage(browser, session) as never,
|
||||
{
|
||||
diagnostic: true,
|
||||
outputDirectory,
|
||||
playlistTitle: 'Synthetic',
|
||||
playlistUrl:
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
}
|
||||
),
|
||||
/synthetic Tracing\.start failure/
|
||||
);
|
||||
|
||||
assert.equal(clearIntervalCalls, 1);
|
||||
assert.equal(session.detachCount, 1);
|
||||
assert.equal(session.listenerCount('Tracing.dataCollected'), 0);
|
||||
assert.equal(session.listenerCount('Tracing.tracingComplete'), 0);
|
||||
assert.equal(browser.readState(), undefined);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
join(outputDirectory, 'renderer.trace.json'),
|
||||
'utf8'
|
||||
)
|
||||
),
|
||||
{ traceEvents: [] }
|
||||
);
|
||||
} finally {
|
||||
restoreGlobal('setInterval', setIntervalDescriptor);
|
||||
restoreGlobal('clearInterval', clearIntervalDescriptor);
|
||||
browser.cleanup();
|
||||
await rm(outputDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
class FakeCdpSession extends EventEmitter {
|
||||
detachCount = 0;
|
||||
heapUsageCalls = 0;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
readonly failMethod?: string;
|
||||
readonly hangDetach?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async detach(): Promise<void> {
|
||||
this.detachCount += 1;
|
||||
if (this.options.hangDetach) {
|
||||
return new Promise<never>(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async send(method: string): Promise<Record<string, unknown>> {
|
||||
if (method === this.options.failMethod) {
|
||||
throw new Error(`synthetic ${method} failure`);
|
||||
}
|
||||
if (method === 'Runtime.getHeapUsage') {
|
||||
this.heapUsageCalls += 1;
|
||||
return { usedSize: 1_024 };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeProbeEnvironment() {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
let nextFrameId = 1;
|
||||
const replaceGlobal = (key: string, value: unknown): void => {
|
||||
descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
class FakePerformanceObserver {
|
||||
static readonly supportedEntryTypes = ['longtask'];
|
||||
|
||||
disconnect(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
observe(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
takeRecords(): PerformanceEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
replaceGlobal('performance', { now: () => 0, timeOrigin: 0 });
|
||||
replaceGlobal('PerformanceObserver', FakePerformanceObserver);
|
||||
replaceGlobal('cancelAnimationFrame', () => undefined);
|
||||
replaceGlobal('requestAnimationFrame', () => nextFrameId++);
|
||||
replaceGlobal('location', { pathname: '/' });
|
||||
replaceGlobal('document', { querySelector: () => null });
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, key, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
Reflect.deleteProperty(
|
||||
globalThis,
|
||||
Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY)
|
||||
);
|
||||
Reflect.deleteProperty(
|
||||
globalThis,
|
||||
M3U_IMPORT_RENDERER_STATE_KEY
|
||||
);
|
||||
},
|
||||
page: {
|
||||
evaluate: async (
|
||||
callback: (argument: never) => unknown,
|
||||
argument: never
|
||||
) => callback(argument),
|
||||
},
|
||||
readState: () =>
|
||||
(globalThis as Record<string, unknown>)[
|
||||
M3U_IMPORT_RENDERER_STATE_KEY
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createFakePage(
|
||||
browser: ReturnType<typeof installFakeProbeEnvironment>,
|
||||
session: FakeCdpSession,
|
||||
evaluate = browser.page.evaluate
|
||||
) {
|
||||
const control = {
|
||||
click: async () => undefined,
|
||||
fill: async () => undefined,
|
||||
first: () => control,
|
||||
getByRole: () => control,
|
||||
isDisabled: async () => false,
|
||||
last: () => control,
|
||||
waitFor: async () => undefined,
|
||||
};
|
||||
return {
|
||||
...browser.page,
|
||||
context: () => ({
|
||||
newCDPSession: async () => session,
|
||||
}),
|
||||
evaluate,
|
||||
getByRole: () => control,
|
||||
locator: () => control,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobal(
|
||||
key: 'clearInterval' | 'setInterval',
|
||||
descriptor: PropertyDescriptor | undefined
|
||||
): void {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, key, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
|
||||
import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
interface ImportProbe {
|
||||
firstChannelVisibleEpochMs: number | null;
|
||||
frameGapsMs: readonly number[];
|
||||
heartbeatDelaysMs: readonly number[];
|
||||
longTasksMs: readonly number[];
|
||||
operationStartEpochMs: number;
|
||||
performancePhaseEvents: readonly unknown[];
|
||||
playlistId: string | null;
|
||||
routeReadyEpochMs: number | null;
|
||||
terminalEpochMs: number | null;
|
||||
uiPaintedEpochMs: number | null;
|
||||
}
|
||||
|
||||
interface RendererCaptureModule {
|
||||
advanceM3uImportHeartbeatDeadline?: (
|
||||
deadlineEpochMs: number,
|
||||
nowEpochMs: number
|
||||
) => number;
|
||||
assertCompleteM3uImportRendererProbe?: (probe: ImportProbe) => void;
|
||||
startM3uImportRendererCapture?: unknown;
|
||||
}
|
||||
|
||||
interface RendererProbeModule {
|
||||
M3U_IMPORT_RENDERER_STATE_KEY?: string;
|
||||
installM3uImportRendererProbe?: (page: unknown) => Promise<void>;
|
||||
recordM3uImportHeartbeat?: (
|
||||
page: unknown,
|
||||
deadlineEpochMs: number
|
||||
) => Promise<void>;
|
||||
stopM3uImportRendererProbe?: (page: unknown) => Promise<ImportProbe>;
|
||||
}
|
||||
|
||||
interface FakeProbeState {
|
||||
frameGapsMs: number[];
|
||||
heartbeatDelaysMs: number[];
|
||||
operationStartEpochMs: number;
|
||||
operationStartMonotonicMs: number;
|
||||
terminalEpochMs: number | null;
|
||||
}
|
||||
|
||||
const captureUrl = new URL('./m3u-import-renderer-capture.ts', import.meta.url);
|
||||
const artifactsUrl = new URL(
|
||||
'./m3u-import-renderer-artifacts.ts',
|
||||
import.meta.url
|
||||
);
|
||||
const probeUrl = new URL('./m3u-import-renderer-probe.ts', import.meta.url);
|
||||
const captureModulePromise = import(captureUrl.href)
|
||||
.then((module) => module as RendererCaptureModule)
|
||||
.catch(() => null);
|
||||
const probeModulePromise = import(probeUrl.href)
|
||||
.then((module) => module as unknown as RendererProbeModule)
|
||||
.catch(() => null);
|
||||
|
||||
function installFakeBrowserEnvironment() {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const frames = new Map<number, (timestamp: number) => void>();
|
||||
let nextFrameId = 1;
|
||||
let nowMs = 0;
|
||||
let observerRecords: PerformanceEntry[] | null = null;
|
||||
|
||||
const replaceGlobal = (key: string, value: unknown): void => {
|
||||
descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
class FakePerformanceObserver {
|
||||
static readonly supportedEntryTypes = ['longtask'];
|
||||
|
||||
constructor() {
|
||||
observerRecords = [];
|
||||
}
|
||||
|
||||
disconnect(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
observe(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
takeRecords(): PerformanceEntry[] {
|
||||
return observerRecords?.splice(0) ?? [];
|
||||
}
|
||||
}
|
||||
replaceGlobal('performance', { now: () => nowMs, timeOrigin: 0 });
|
||||
replaceGlobal('PerformanceObserver', FakePerformanceObserver);
|
||||
replaceGlobal('cancelAnimationFrame', (requestId: number) => {
|
||||
frames.delete(requestId);
|
||||
});
|
||||
replaceGlobal(
|
||||
'requestAnimationFrame',
|
||||
(callback: (timestamp: number) => void) => {
|
||||
const requestId = nextFrameId++;
|
||||
frames.set(requestId, callback);
|
||||
return requestId;
|
||||
}
|
||||
);
|
||||
replaceGlobal('location', { pathname: '/' });
|
||||
replaceGlobal('document', { querySelector: () => null });
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, key, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
Reflect.deleteProperty(
|
||||
globalThis,
|
||||
Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY)
|
||||
);
|
||||
},
|
||||
getObserver: () => observerRecords,
|
||||
page: {
|
||||
evaluate: async (
|
||||
callback: (argument: unknown) => unknown,
|
||||
argument: unknown
|
||||
) => callback(argument),
|
||||
},
|
||||
readState: (stateKey: string) =>
|
||||
(globalThis as Record<string, unknown>)[stateKey] as FakeProbeState,
|
||||
runFrame: (timestamp: number) => {
|
||||
nowMs = timestamp;
|
||||
for (const [requestId, callback] of [...frames]) {
|
||||
if (frames.delete(requestId)) {
|
||||
callback(timestamp);
|
||||
}
|
||||
}
|
||||
},
|
||||
setNow: (value: number) => {
|
||||
nowMs = value;
|
||||
},
|
||||
showTerminal: () => {
|
||||
(
|
||||
globalThis as unknown as {
|
||||
location: { pathname: string };
|
||||
}
|
||||
).location.pathname =
|
||||
'/Users/test/iptvnator/dist/apps/web/workspace/playlists/synthetic-id/all';
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: { querySelector: () => ({}) },
|
||||
writable: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readSource(url: URL): string | null {
|
||||
try {
|
||||
return readFileSync(url, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function completeProbe(): ImportProbe {
|
||||
return {
|
||||
firstChannelVisibleEpochMs: 130,
|
||||
frameGapsMs: [16, 48],
|
||||
heartbeatDelaysMs: [0, 12],
|
||||
longTasksMs: [72],
|
||||
operationStartEpochMs: 100,
|
||||
performancePhaseEvents: [],
|
||||
playlistId: 'synthetic-id',
|
||||
routeReadyEpochMs: 120,
|
||||
terminalEpochMs: 150,
|
||||
uiPaintedEpochMs: 150,
|
||||
};
|
||||
}
|
||||
|
||||
test('exports the formal M3U import renderer collector', async () => {
|
||||
const module = await captureModulePromise;
|
||||
|
||||
assert.ok(module, 'M3U import renderer capture module must exist');
|
||||
assert.equal(typeof module.startM3uImportRendererCapture, 'function');
|
||||
assert.equal(
|
||||
typeof module.assertCompleteM3uImportRendererProbe,
|
||||
'function'
|
||||
);
|
||||
});
|
||||
|
||||
test('prepares the URL dialog before starting any measured capture', () => {
|
||||
const source = readSource(captureUrl);
|
||||
assert.ok(source, 'M3U import renderer capture source must exist');
|
||||
|
||||
const dialogPreparation = source.indexOf(
|
||||
'await prepareM3uImportDialog(page, options)'
|
||||
);
|
||||
const cdpSession = source.indexOf('newCDPSession(page)');
|
||||
const initialHeapSample = source.indexOf('await sampleHeap()');
|
||||
|
||||
assert.ok(dialogPreparation >= 0);
|
||||
assert.ok(cdpSession > dialogPreparation);
|
||||
assert.ok(initialHeapSample > cdpSession);
|
||||
});
|
||||
|
||||
test('captures heap samples in every run and raw CPU, trace, and heap artifacts only in diagnostics', () => {
|
||||
const source = readSource(captureUrl);
|
||||
const artifactSource = readSource(artifactsUrl);
|
||||
assert.ok(source);
|
||||
assert.ok(artifactSource);
|
||||
|
||||
assert.match(source, /setInterval\(\(\) => void sampleHeap\(\), 20\)/);
|
||||
assert.match(source, /Runtime\.getHeapUsage/);
|
||||
assert.match(source, /HeapProfiler\.collectGarbage/);
|
||||
assert.match(
|
||||
artifactSource,
|
||||
/cpuProfilePath: options\.diagnostic[\s\S]*renderer\.cpuprofile/
|
||||
);
|
||||
assert.match(artifactSource, /Profiler\.start/);
|
||||
assert.match(artifactSource, /Tracing\.start/);
|
||||
assert.match(artifactSource, /HeapProfiler\.takeHeapSnapshot/);
|
||||
});
|
||||
|
||||
test('clips drained long tasks and heartbeat samples to the operation window', async () => {
|
||||
const module = await probeModulePromise;
|
||||
assert.ok(module);
|
||||
assert.equal(typeof module.installM3uImportRendererProbe, 'function');
|
||||
assert.equal(typeof module.recordM3uImportHeartbeat, 'function');
|
||||
assert.equal(typeof module.stopM3uImportRendererProbe, 'function');
|
||||
assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string');
|
||||
const browser = installFakeBrowserEnvironment();
|
||||
|
||||
try {
|
||||
await module.installM3uImportRendererProbe?.(browser.page);
|
||||
const state = browser.readState(
|
||||
module.M3U_IMPORT_RENDERER_STATE_KEY ?? ''
|
||||
);
|
||||
state.operationStartEpochMs = 100;
|
||||
state.operationStartMonotonicMs = 100;
|
||||
state.terminalEpochMs = 200;
|
||||
const observerRecords = browser.getObserver();
|
||||
assert.ok(observerRecords);
|
||||
observerRecords.push(
|
||||
{ duration: 40, startTime: 80 } as PerformanceEntry,
|
||||
{ duration: 30, startTime: 190 } as PerformanceEntry,
|
||||
{ duration: 30, startTime: 210 } as PerformanceEntry
|
||||
);
|
||||
|
||||
browser.setNow(250);
|
||||
await module.recordM3uImportHeartbeat?.(browser.page, 90);
|
||||
await module.recordM3uImportHeartbeat?.(browser.page, 150);
|
||||
await module.recordM3uImportHeartbeat?.(browser.page, 210);
|
||||
const metrics = await module.stopM3uImportRendererProbe?.(browser.page);
|
||||
|
||||
assert.deepEqual(metrics?.heartbeatDelaysMs, [50]);
|
||||
assert.deepEqual(metrics?.longTasksMs, [20, 10]);
|
||||
} finally {
|
||||
browser.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('records the terminal animation-frame gap exactly once', async () => {
|
||||
const module = await probeModulePromise;
|
||||
assert.ok(module?.installM3uImportRendererProbe);
|
||||
assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string');
|
||||
const browser = installFakeBrowserEnvironment();
|
||||
|
||||
try {
|
||||
await module.installM3uImportRendererProbe(browser.page);
|
||||
browser.runFrame(80);
|
||||
const state = browser.readState(
|
||||
module.M3U_IMPORT_RENDERER_STATE_KEY ?? ''
|
||||
);
|
||||
state.operationStartEpochMs = 100;
|
||||
state.operationStartMonotonicMs = 100;
|
||||
browser.showTerminal();
|
||||
browser.runFrame(120);
|
||||
browser.runFrame(140);
|
||||
browser.runFrame(160);
|
||||
|
||||
assert.equal(state.terminalEpochMs, 160);
|
||||
assert.deepEqual(state.frameGapsMs, [20, 20, 20]);
|
||||
await module.stopM3uImportRendererProbe?.(browser.page);
|
||||
} finally {
|
||||
browser.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('advances the external heartbeat on its fixed 50ms deadline grid', async () => {
|
||||
const module = await captureModulePromise;
|
||||
assert.ok(module);
|
||||
const advance = module.advanceM3uImportHeartbeatDeadline;
|
||||
assert.equal(typeof advance, 'function');
|
||||
|
||||
assert.equal(advance?.(1_000, 1_005), 1_050);
|
||||
assert.equal(advance?.(1_000, 1_060), 1_100);
|
||||
assert.equal(advance?.(1_000, 1_200), 1_250);
|
||||
});
|
||||
|
||||
test('installs the production renderer phase hook and returns its raw events', () => {
|
||||
const source = readSource(probeUrl);
|
||||
assert.ok(source);
|
||||
|
||||
assert.match(source, /RENDERER_PERFORMANCE_PHASE_HOOK_KEY/);
|
||||
assert.match(source, /Symbol\.for\(hookKey\)/);
|
||||
assert.match(source, /performancePhaseEvents\.push\(event\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/performancePhaseEvents: \[\.\.\.state\.performancePhaseEvents\]/
|
||||
);
|
||||
assert.match(source, /delete target\[hookSymbol\]/);
|
||||
});
|
||||
|
||||
test('timestamps the click immediately before final Add and requires route, first item, and two animation frames', () => {
|
||||
const source = readSource(probeUrl);
|
||||
assert.ok(source);
|
||||
|
||||
const operationStart = source.indexOf(
|
||||
'state.operationStartMonotonicMs = performance.now()'
|
||||
);
|
||||
const click = source.indexOf('button.click()', operationStart);
|
||||
assert.ok(operationStart >= 0);
|
||||
assert.ok(click > operationStart);
|
||||
assert.ok(source.includes('/\\/workspace\\/playlists\\/([^/]+)\\/all$/'));
|
||||
assert.match(source, /\[data-test-id="channel-item"\]/);
|
||||
const firstPaintFrame = source.indexOf(
|
||||
'state.uiPaintFrameRequestId = requestAnimationFrame'
|
||||
);
|
||||
const secondPaintFrame = source.indexOf(
|
||||
'state.uiPaintFrameRequestId = requestAnimationFrame',
|
||||
firstPaintFrame + 1
|
||||
);
|
||||
assert.ok(firstPaintFrame >= 0);
|
||||
assert.ok(secondPaintFrame > firstPaintFrame);
|
||||
for (const phase of [
|
||||
'operationStartEpochMs',
|
||||
'routeReadyEpochMs',
|
||||
'firstChannelVisibleEpochMs',
|
||||
'uiPaintedEpochMs',
|
||||
'terminalEpochMs',
|
||||
]) {
|
||||
assert.match(source, new RegExp(phase));
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when any terminal proof or the probe itself is missing', async () => {
|
||||
const module = await captureModulePromise;
|
||||
assert.ok(module);
|
||||
const assertComplete = module.assertCompleteM3uImportRendererProbe;
|
||||
assert.equal(typeof assertComplete, 'function');
|
||||
|
||||
assert.doesNotThrow(() => assertComplete?.(completeProbe()));
|
||||
for (const field of [
|
||||
'playlistId',
|
||||
'routeReadyEpochMs',
|
||||
'firstChannelVisibleEpochMs',
|
||||
'uiPaintedEpochMs',
|
||||
'terminalEpochMs',
|
||||
] as const) {
|
||||
const incomplete = { ...completeProbe(), [field]: null };
|
||||
assert.throws(
|
||||
() => assertComplete?.(incomplete),
|
||||
/m3u-import-renderer-probe-incomplete/
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
assertComplete?.({
|
||||
...completeProbe(),
|
||||
operationStartEpochMs: 0,
|
||||
}),
|
||||
/m3u-import-renderer-probe-incomplete/
|
||||
);
|
||||
});
|
||||
|
||||
test('clears the harness heap sampler even when the renderer probe is missing', () => {
|
||||
const source = readSource(captureUrl);
|
||||
assert.ok(source);
|
||||
const backgroundStop = source.indexOf(
|
||||
'await stopBackgroundWork()',
|
||||
source.indexOf('const stopCapture')
|
||||
);
|
||||
const probeStop = source.indexOf('probe = await stopProbe()', backgroundStop);
|
||||
|
||||
assert.match(source, /clearInterval\(heapSampleTimer\)/);
|
||||
assert.ok(backgroundStop >= 0);
|
||||
assert.ok(probeStop > backgroundStop);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import type { CDPSession, Page } from '@playwright/test';
|
||||
|
||||
import type { NumericDistribution } from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
createRendererArtifactCapture,
|
||||
disposeRendererDiagnosticCapture,
|
||||
type RendererArtifactCapture,
|
||||
startRendererDiagnosticCapture,
|
||||
stopRendererDiagnosticCapture,
|
||||
takeRendererHeapSnapshot,
|
||||
} from './m3u-import-renderer-artifacts';
|
||||
import {
|
||||
installM3uImportRendererProbe,
|
||||
type M3uImportRendererProbeMetrics,
|
||||
prepareM3uImportDialog,
|
||||
recordM3uImportHeartbeat,
|
||||
stopM3uImportRendererProbe,
|
||||
triggerM3uImport,
|
||||
waitForM3uImportTerminal,
|
||||
} from './m3u-import-renderer-probe';
|
||||
import { summarizeNumbers } from './performance-statistics';
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 50;
|
||||
|
||||
export interface M3uImportRendererCaptureStartOptions {
|
||||
readonly diagnostic: boolean;
|
||||
readonly outputDirectory: string;
|
||||
readonly playlistTitle: string;
|
||||
readonly playlistUrl: string;
|
||||
}
|
||||
|
||||
export interface M3uImportRendererPhaseTimestamps {
|
||||
readonly firstChannelVisibleEpochMs: number;
|
||||
readonly operationStartEpochMs: number;
|
||||
readonly routeReadyEpochMs: number;
|
||||
readonly terminalEpochMs: number;
|
||||
readonly uiPaintedEpochMs: number;
|
||||
}
|
||||
|
||||
export interface M3uImportRendererCaptureMetrics {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly frameGap: NumericDistribution;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly heartbeatDelay: NumericDistribution;
|
||||
readonly longTask: NumericDistribution;
|
||||
readonly peakHeapUsedBytes: number;
|
||||
readonly phaseTimestamps: M3uImportRendererPhaseTimestamps;
|
||||
readonly postGcHeapUsedBytes: number;
|
||||
readonly probe: M3uImportRendererProbeMetrics;
|
||||
readonly tracePath: string | null;
|
||||
}
|
||||
|
||||
export interface RunningM3uImportRendererCapture {
|
||||
readonly session: CDPSession;
|
||||
dispose(): Promise<void>;
|
||||
stop(): Promise<M3uImportRendererCaptureMetrics>;
|
||||
triggerImport(): Promise<number>;
|
||||
waitForTerminal(): Promise<void>;
|
||||
}
|
||||
|
||||
interface HeapUsageResult {
|
||||
readonly usedSize: number;
|
||||
}
|
||||
|
||||
export async function startM3uImportRendererCapture(
|
||||
page: Page,
|
||||
options: M3uImportRendererCaptureStartOptions
|
||||
): Promise<RunningM3uImportRendererCapture> {
|
||||
await prepareM3uImportDialog(page, options);
|
||||
const session = await page.context().newCDPSession(page);
|
||||
let probeInstalled = false;
|
||||
let heapSampleTimer: NodeJS.Timeout | null = null;
|
||||
let artifacts: RendererArtifactCapture | null = null;
|
||||
const heapSamples: number[] = [];
|
||||
let heapSampleError: unknown = null;
|
||||
let heapSamplePromise: Promise<void> | null = null;
|
||||
const sampleHeap = (): Promise<void> => {
|
||||
heapSamplePromise ??= (async () => {
|
||||
try {
|
||||
const sample = (await session.send(
|
||||
'Runtime.getHeapUsage'
|
||||
)) as HeapUsageResult;
|
||||
if (Number.isFinite(sample.usedSize)) {
|
||||
heapSamples.push(sample.usedSize);
|
||||
}
|
||||
} catch (error) {
|
||||
heapSampleError ??= error;
|
||||
} finally {
|
||||
heapSamplePromise = null;
|
||||
}
|
||||
})();
|
||||
return heapSamplePromise;
|
||||
};
|
||||
|
||||
try {
|
||||
await session.send('Runtime.enable');
|
||||
await session.send('Performance.enable');
|
||||
await installM3uImportRendererProbe(page);
|
||||
probeInstalled = true;
|
||||
await sampleHeap();
|
||||
throwCaptureError(heapSampleError, 'Renderer heap sampling failed');
|
||||
heapSampleTimer = setInterval(() => void sampleHeap(), 20);
|
||||
artifacts = createRendererArtifactCapture(options);
|
||||
if (options.diagnostic) {
|
||||
await startRendererDiagnosticCapture(session, artifacts);
|
||||
}
|
||||
} catch (error) {
|
||||
if (heapSampleTimer) {
|
||||
clearInterval(heapSampleTimer);
|
||||
heapSampleTimer = null;
|
||||
}
|
||||
if (artifacts && options.diagnostic) {
|
||||
await disposeRendererDiagnosticCapture(
|
||||
session,
|
||||
artifacts
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (probeInstalled) {
|
||||
continueBestEffort(() => stopM3uImportRendererProbe(page));
|
||||
}
|
||||
continueBestEffort(() => session.detach());
|
||||
throw error;
|
||||
}
|
||||
if (!artifacts || !heapSampleTimer) {
|
||||
throw new Error('M3U import renderer capture initialization failed');
|
||||
}
|
||||
|
||||
let heartbeatDeadlineEpochMs: number | null = null;
|
||||
let heartbeatError: unknown = null;
|
||||
let heartbeatInFlight: Promise<void> | null = null;
|
||||
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
let stopped = false;
|
||||
let backgroundStopPromise: Promise<void> | null = null;
|
||||
let probeStopPromise: Promise<M3uImportRendererProbeMetrics> | null = null;
|
||||
let sessionDetachPromise: Promise<void> | null = null;
|
||||
let abortPromise: Promise<void> | null = null;
|
||||
let stopPromise: Promise<M3uImportRendererCaptureMetrics> | null = null;
|
||||
let stopRequested = false;
|
||||
let disposeRequested = false;
|
||||
const stopSchedulingBackgroundWork = (): void => {
|
||||
stopped = true;
|
||||
if (heartbeatTimer) {
|
||||
clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
if (heapSampleTimer) {
|
||||
clearInterval(heapSampleTimer);
|
||||
heapSampleTimer = null;
|
||||
}
|
||||
};
|
||||
const scheduleHeartbeat = (): void => {
|
||||
if (stopped || heartbeatDeadlineEpochMs === null) {
|
||||
return;
|
||||
}
|
||||
const deadline = heartbeatDeadlineEpochMs;
|
||||
heartbeatTimer = setTimeout(
|
||||
() => {
|
||||
heartbeatTimer = null;
|
||||
heartbeatInFlight = recordM3uImportHeartbeat(page, deadline)
|
||||
.catch((error: unknown) => {
|
||||
heartbeatError ??= error;
|
||||
})
|
||||
.finally(() => {
|
||||
heartbeatInFlight = null;
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
heartbeatDeadlineEpochMs =
|
||||
advanceM3uImportHeartbeatDeadline(
|
||||
deadline,
|
||||
Date.now()
|
||||
);
|
||||
scheduleHeartbeat();
|
||||
});
|
||||
},
|
||||
Math.max(0, deadline - Date.now())
|
||||
);
|
||||
};
|
||||
const stopBackgroundWork = (): Promise<void> => {
|
||||
backgroundStopPromise ??= (async () => {
|
||||
stopSchedulingBackgroundWork();
|
||||
await heartbeatInFlight;
|
||||
await heapSamplePromise;
|
||||
await sampleHeap();
|
||||
})();
|
||||
return backgroundStopPromise;
|
||||
};
|
||||
const stopProbe = (): Promise<M3uImportRendererProbeMetrics> => {
|
||||
probeStopPromise ??= stopM3uImportRendererProbe(page);
|
||||
return probeStopPromise;
|
||||
};
|
||||
const detachSession = (): Promise<void> => {
|
||||
sessionDetachPromise ??= session.detach();
|
||||
return sessionDetachPromise;
|
||||
};
|
||||
const abortResources = (): Promise<void> => {
|
||||
abortPromise ??= (async () => {
|
||||
stopSchedulingBackgroundWork();
|
||||
continueBestEffort(() => heartbeatInFlight ?? Promise.resolve());
|
||||
continueBestEffort(() => heapSamplePromise ?? Promise.resolve());
|
||||
continueBestEffort(stopProbe);
|
||||
if (options.diagnostic) {
|
||||
await disposeRendererDiagnosticCapture(
|
||||
session,
|
||||
artifacts
|
||||
).catch(() => undefined);
|
||||
}
|
||||
continueBestEffort(detachSession);
|
||||
})();
|
||||
return abortPromise;
|
||||
};
|
||||
const stopCapture =
|
||||
async (): Promise<M3uImportRendererCaptureMetrics> => {
|
||||
try {
|
||||
await stopBackgroundWork();
|
||||
let probe: M3uImportRendererProbeMetrics | null = null;
|
||||
let probeError: unknown = null;
|
||||
try {
|
||||
probe = await stopProbe();
|
||||
} catch (error) {
|
||||
probeError = error;
|
||||
}
|
||||
|
||||
if (options.diagnostic) {
|
||||
await stopRendererDiagnosticCapture(session, artifacts);
|
||||
}
|
||||
await session.send('HeapProfiler.enable');
|
||||
await session.send('HeapProfiler.collectGarbage');
|
||||
const postGc = (await session.send(
|
||||
'Runtime.getHeapUsage'
|
||||
)) as HeapUsageResult;
|
||||
if (options.diagnostic) {
|
||||
await takeRendererHeapSnapshot(
|
||||
session,
|
||||
artifacts.heapSnapshotPath
|
||||
);
|
||||
}
|
||||
await detachSession();
|
||||
throwCaptureError(
|
||||
heapSampleError,
|
||||
'Renderer heap sampling failed'
|
||||
);
|
||||
throwCaptureError(heartbeatError, 'Renderer heartbeat failed');
|
||||
throwCaptureError(probeError, 'Renderer probe stop failed');
|
||||
assertCompleteM3uImportRendererProbe(probe);
|
||||
return Object.freeze({
|
||||
cpuProfilePath: artifacts.cpuProfilePath,
|
||||
frameGap: summarizeNumbers(probe.frameGapsMs),
|
||||
heapSnapshotPath: artifacts.heapSnapshotPath,
|
||||
heartbeatDelay: summarizeNumbers(
|
||||
probe.heartbeatDelaysMs
|
||||
),
|
||||
longTask: summarizeNumbers(probe.longTasksMs),
|
||||
peakHeapUsedBytes: Math.max(0, ...heapSamples),
|
||||
phaseTimestamps: Object.freeze({
|
||||
firstChannelVisibleEpochMs:
|
||||
probe.firstChannelVisibleEpochMs,
|
||||
operationStartEpochMs: probe.operationStartEpochMs,
|
||||
routeReadyEpochMs: probe.routeReadyEpochMs,
|
||||
terminalEpochMs: probe.terminalEpochMs,
|
||||
uiPaintedEpochMs: probe.uiPaintedEpochMs,
|
||||
}),
|
||||
postGcHeapUsedBytes: postGc.usedSize,
|
||||
probe,
|
||||
tracePath: artifacts.tracePath,
|
||||
});
|
||||
} catch (error) {
|
||||
await abortResources();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
dispose: async () => {
|
||||
disposeRequested = true;
|
||||
if (stopPromise) {
|
||||
continueBestEffort(() => stopPromise as Promise<unknown>);
|
||||
}
|
||||
await abortResources();
|
||||
},
|
||||
session,
|
||||
stop: () => {
|
||||
if (stopRequested || disposeRequested) {
|
||||
return Promise.reject(
|
||||
new Error('M3U import renderer capture already stopped')
|
||||
);
|
||||
}
|
||||
stopRequested = true;
|
||||
stopPromise = stopCapture();
|
||||
return stopPromise;
|
||||
},
|
||||
triggerImport: async () => {
|
||||
const operationStartEpochMs = await triggerM3uImport(page);
|
||||
heartbeatDeadlineEpochMs =
|
||||
operationStartEpochMs + HEARTBEAT_INTERVAL_MS;
|
||||
scheduleHeartbeat();
|
||||
return operationStartEpochMs;
|
||||
},
|
||||
waitForTerminal: () => waitForM3uImportTerminal(page),
|
||||
});
|
||||
}
|
||||
|
||||
export function advanceM3uImportHeartbeatDeadline(
|
||||
deadlineEpochMs: number,
|
||||
nowEpochMs: number
|
||||
): number {
|
||||
const nextDeadline = deadlineEpochMs + HEARTBEAT_INTERVAL_MS;
|
||||
if (nextDeadline > nowEpochMs) {
|
||||
return nextDeadline;
|
||||
}
|
||||
const missedIntervals =
|
||||
Math.floor((nowEpochMs - nextDeadline) / HEARTBEAT_INTERVAL_MS) + 1;
|
||||
return nextDeadline + missedIntervals * HEARTBEAT_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function assertCompleteM3uImportRendererProbe(
|
||||
probe: M3uImportRendererProbeMetrics | null | undefined
|
||||
): asserts probe is M3uImportRendererProbeMetrics & {
|
||||
readonly firstChannelVisibleEpochMs: number;
|
||||
readonly playlistId: string;
|
||||
readonly routeReadyEpochMs: number;
|
||||
readonly terminalEpochMs: number;
|
||||
readonly uiPaintedEpochMs: number;
|
||||
} {
|
||||
if (
|
||||
!probe ||
|
||||
typeof probe.playlistId !== 'string' ||
|
||||
probe.playlistId.length === 0 ||
|
||||
!isPositiveFinite(probe.operationStartEpochMs) ||
|
||||
!isPositiveFinite(probe.routeReadyEpochMs) ||
|
||||
!isPositiveFinite(probe.firstChannelVisibleEpochMs) ||
|
||||
!isPositiveFinite(probe.uiPaintedEpochMs) ||
|
||||
!isPositiveFinite(probe.terminalEpochMs)
|
||||
) {
|
||||
throw new Error('m3u-import-renderer-probe-incomplete');
|
||||
}
|
||||
if (
|
||||
probe.routeReadyEpochMs < probe.operationStartEpochMs ||
|
||||
probe.firstChannelVisibleEpochMs < probe.routeReadyEpochMs ||
|
||||
probe.uiPaintedEpochMs < probe.firstChannelVisibleEpochMs ||
|
||||
probe.terminalEpochMs < probe.uiPaintedEpochMs
|
||||
) {
|
||||
throw new Error('m3u-import-renderer-probe-incomplete');
|
||||
}
|
||||
}
|
||||
|
||||
function throwCaptureError(error: unknown, label: string): void {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
if (error !== null) {
|
||||
throw new Error(`${label}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function continueBestEffort(work: () => Promise<unknown>): void {
|
||||
try {
|
||||
void work().catch(() => undefined);
|
||||
} catch {
|
||||
// The owning Electron app is closed immediately after abort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveFinite(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
type RendererPerformancePhaseHook,
|
||||
} from '@iptvnator/shared/logging';
|
||||
|
||||
export const M3U_IMPORT_RENDERER_STATE_KEY =
|
||||
'__iptvnatorM3uImportRendererCapture';
|
||||
|
||||
export interface M3uImportDialogOptions {
|
||||
readonly playlistTitle: string;
|
||||
readonly playlistUrl: string;
|
||||
}
|
||||
|
||||
export interface M3uImportRendererProbeMetrics {
|
||||
readonly firstChannelVisibleEpochMs: number | null;
|
||||
readonly frameGapsMs: readonly number[];
|
||||
readonly heartbeatDelaysMs: readonly number[];
|
||||
readonly longTasksMs: readonly number[];
|
||||
readonly operationStartEpochMs: number;
|
||||
readonly performancePhaseEvents: readonly RendererPerformancePhaseEvent[];
|
||||
readonly playlistId: string | null;
|
||||
readonly routeReadyEpochMs: number | null;
|
||||
readonly terminalEpochMs: number | null;
|
||||
readonly uiPaintedEpochMs: number | null;
|
||||
}
|
||||
|
||||
export async function prepareM3uImportDialog(
|
||||
page: Page,
|
||||
options: M3uImportDialogOptions
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).first().click();
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
await dialog
|
||||
.getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' })
|
||||
.fill(options.playlistUrl);
|
||||
await dialog
|
||||
.getByRole('textbox', { name: 'Playlist title' })
|
||||
.fill(options.playlistTitle);
|
||||
const submit = dialog.getByRole('button', { name: 'Add playlist' }).last();
|
||||
await submit.waitFor({ state: 'visible' });
|
||||
if (await submit.isDisabled()) {
|
||||
throw new Error('m3u-import-final-add-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
export async function installM3uImportRendererProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ hookKey, stateKey }) => {
|
||||
interface ProbeState {
|
||||
firstChannelVisibleEpochMs: number | null;
|
||||
frameGapsMs: number[];
|
||||
frameRequestId: number;
|
||||
heartbeatDelaysMs: number[];
|
||||
lastFrameAt: number | null;
|
||||
longTaskObserver: PerformanceObserver | null;
|
||||
longTasksMs: number[];
|
||||
operationStartEpochMs: number;
|
||||
operationStartMonotonicMs: number;
|
||||
performancePhaseEvents: RendererPerformancePhaseEvent[];
|
||||
playlistId: string | null;
|
||||
recordFrameGap(timestamp: number): void;
|
||||
recordLongTask(entry: PerformanceEntry): void;
|
||||
routeReadyEpochMs: number | null;
|
||||
terminalEpochMs: number | null;
|
||||
uiPaintFrameRequestId: number | null;
|
||||
uiPaintedEpochMs: number | null;
|
||||
}
|
||||
const target = globalThis as unknown as Record<
|
||||
PropertyKey,
|
||||
unknown
|
||||
>;
|
||||
const hookSymbol = Symbol.for(hookKey);
|
||||
if (
|
||||
target[stateKey] !== undefined ||
|
||||
target[hookSymbol] !== undefined
|
||||
) {
|
||||
throw new Error('m3u-import-renderer-probe-already-installed');
|
||||
}
|
||||
const epoch = (): number =>
|
||||
performance.timeOrigin + performance.now();
|
||||
const state: ProbeState = {
|
||||
firstChannelVisibleEpochMs: null,
|
||||
frameGapsMs: [],
|
||||
frameRequestId: 0,
|
||||
heartbeatDelaysMs: [],
|
||||
lastFrameAt: null,
|
||||
longTaskObserver: null,
|
||||
longTasksMs: [],
|
||||
operationStartEpochMs: 0,
|
||||
operationStartMonotonicMs: 0,
|
||||
performancePhaseEvents: [],
|
||||
playlistId: null,
|
||||
recordFrameGap: (timestamp) => {
|
||||
const previousFrameAt = state.lastFrameAt;
|
||||
state.lastFrameAt = timestamp;
|
||||
if (
|
||||
previousFrameAt === null ||
|
||||
state.operationStartEpochMs <= 0 ||
|
||||
state.terminalEpochMs !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const measuredFrameStart = Math.max(
|
||||
previousFrameAt,
|
||||
state.operationStartMonotonicMs
|
||||
);
|
||||
if (timestamp > measuredFrameStart) {
|
||||
state.frameGapsMs.push(timestamp - measuredFrameStart);
|
||||
}
|
||||
},
|
||||
recordLongTask: (entry) => {
|
||||
const entryEpochMs =
|
||||
performance.timeOrigin + entry.startTime;
|
||||
const measuredStartEpochMs = Math.max(
|
||||
entryEpochMs,
|
||||
state.operationStartEpochMs
|
||||
);
|
||||
const measuredEndEpochMs = Math.min(
|
||||
entryEpochMs + entry.duration,
|
||||
state.terminalEpochMs ?? Number.POSITIVE_INFINITY
|
||||
);
|
||||
if (
|
||||
state.operationStartEpochMs > 0 &&
|
||||
measuredEndEpochMs > measuredStartEpochMs
|
||||
) {
|
||||
state.longTasksMs.push(
|
||||
measuredEndEpochMs - measuredStartEpochMs
|
||||
);
|
||||
}
|
||||
},
|
||||
routeReadyEpochMs: null,
|
||||
terminalEpochMs: null,
|
||||
uiPaintFrameRequestId: null,
|
||||
uiPaintedEpochMs: null,
|
||||
};
|
||||
const performancePhaseHook: RendererPerformancePhaseHook = (
|
||||
event
|
||||
) => {
|
||||
state.performancePhaseEvents.push(event);
|
||||
};
|
||||
target[hookSymbol] = performancePhaseHook;
|
||||
const terminalTarget = (): string | null => {
|
||||
const route = location.pathname.match(
|
||||
/\/workspace\/playlists\/([^/]+)\/all$/
|
||||
);
|
||||
if (
|
||||
!route ||
|
||||
!document.querySelector('[data-test-id="channel-item"]')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return route[1] ?? null;
|
||||
};
|
||||
const inspectTerminal = (): void => {
|
||||
if (
|
||||
state.operationStartEpochMs <= 0 ||
|
||||
state.terminalEpochMs !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route = location.pathname.match(
|
||||
/\/workspace\/playlists\/([^/]+)\/all$/
|
||||
);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
state.routeReadyEpochMs ??= epoch();
|
||||
if (!document.querySelector('[data-test-id="channel-item"]')) {
|
||||
return;
|
||||
}
|
||||
state.firstChannelVisibleEpochMs ??= epoch();
|
||||
if (state.uiPaintFrameRequestId !== null) {
|
||||
return;
|
||||
}
|
||||
state.uiPaintFrameRequestId = requestAnimationFrame(() => {
|
||||
state.uiPaintFrameRequestId = requestAnimationFrame(
|
||||
(terminalFrameAt) => {
|
||||
state.uiPaintFrameRequestId = null;
|
||||
const playlistId = terminalTarget();
|
||||
if (playlistId === null) {
|
||||
return;
|
||||
}
|
||||
state.recordFrameGap(terminalFrameAt);
|
||||
state.playlistId = playlistId;
|
||||
state.uiPaintedEpochMs = epoch();
|
||||
state.terminalEpochMs = state.uiPaintedEpochMs;
|
||||
cancelAnimationFrame(state.frameRequestId);
|
||||
state.frameRequestId = 0;
|
||||
for (const entry of state.longTaskObserver?.takeRecords() ??
|
||||
[]) {
|
||||
state.recordLongTask(entry);
|
||||
}
|
||||
state.longTaskObserver?.disconnect();
|
||||
state.longTaskObserver = null;
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
const frame = (timestamp: number): void => {
|
||||
state.recordFrameGap(timestamp);
|
||||
if (state.operationStartEpochMs > 0) {
|
||||
inspectTerminal();
|
||||
}
|
||||
state.frameRequestId =
|
||||
state.terminalEpochMs === null
|
||||
? requestAnimationFrame(frame)
|
||||
: 0;
|
||||
};
|
||||
state.frameRequestId = requestAnimationFrame(frame);
|
||||
if (
|
||||
typeof PerformanceObserver === 'function' &&
|
||||
PerformanceObserver.supportedEntryTypes.includes('longtask')
|
||||
) {
|
||||
state.longTaskObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
state.recordLongTask(entry);
|
||||
}
|
||||
});
|
||||
state.longTaskObserver.observe({ entryTypes: ['longtask'] });
|
||||
}
|
||||
target[stateKey] = state;
|
||||
},
|
||||
{
|
||||
hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
stateKey: M3U_IMPORT_RENDERER_STATE_KEY,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function triggerM3uImport(page: Page): Promise<number> {
|
||||
return page.evaluate((stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const state = target[stateKey] as
|
||||
| {
|
||||
operationStartEpochMs: number;
|
||||
operationStartMonotonicMs: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!state || state.operationStartEpochMs > 0) {
|
||||
throw new Error('m3u-import-renderer-probe-unavailable');
|
||||
}
|
||||
const dialogs = Array.from(
|
||||
document.querySelectorAll('mat-dialog-container')
|
||||
);
|
||||
const dialog = dialogs.at(-1);
|
||||
const button = Array.from(dialog?.querySelectorAll('button') ?? [])
|
||||
.reverse()
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.textContent?.trim() === 'Add playlist' &&
|
||||
!candidate.disabled
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error('m3u-import-final-add-unavailable');
|
||||
}
|
||||
state.operationStartMonotonicMs = performance.now();
|
||||
state.operationStartEpochMs =
|
||||
performance.timeOrigin + state.operationStartMonotonicMs;
|
||||
button.click();
|
||||
return state.operationStartEpochMs;
|
||||
}, M3U_IMPORT_RENDERER_STATE_KEY);
|
||||
}
|
||||
|
||||
export async function recordM3uImportHeartbeat(
|
||||
page: Page,
|
||||
deadlineEpochMs: number
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ deadlineEpochMs, stateKey }) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const state = target[stateKey] as
|
||||
| {
|
||||
heartbeatDelaysMs: number[];
|
||||
operationStartEpochMs: number;
|
||||
terminalEpochMs: number | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!state || state.operationStartEpochMs <= 0) {
|
||||
throw new Error('m3u-import-renderer-probe-unavailable');
|
||||
}
|
||||
if (
|
||||
deadlineEpochMs >= state.operationStartEpochMs &&
|
||||
(state.terminalEpochMs === null ||
|
||||
deadlineEpochMs <= state.terminalEpochMs)
|
||||
) {
|
||||
const receivedEpochMs =
|
||||
performance.timeOrigin + performance.now();
|
||||
const measuredEpochMs =
|
||||
state.terminalEpochMs === null
|
||||
? receivedEpochMs
|
||||
: Math.min(receivedEpochMs, state.terminalEpochMs);
|
||||
state.heartbeatDelaysMs.push(
|
||||
Math.max(0, measuredEpochMs - deadlineEpochMs)
|
||||
);
|
||||
}
|
||||
},
|
||||
{ deadlineEpochMs, stateKey: M3U_IMPORT_RENDERER_STATE_KEY }
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForM3uImportTerminal(page: Page): Promise<void> {
|
||||
await page
|
||||
.waitForFunction(
|
||||
(stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const state = target[stateKey] as
|
||||
{ terminalEpochMs: number | null } | undefined;
|
||||
return typeof state?.terminalEpochMs === 'number';
|
||||
},
|
||||
M3U_IMPORT_RENDERER_STATE_KEY,
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.then(() => undefined);
|
||||
}
|
||||
|
||||
export async function stopM3uImportRendererProbe(
|
||||
page: Page
|
||||
): Promise<M3uImportRendererProbeMetrics> {
|
||||
return page.evaluate(
|
||||
({ hookKey, stateKey }) => {
|
||||
const target = globalThis as unknown as Record<
|
||||
PropertyKey,
|
||||
unknown
|
||||
>;
|
||||
const hookSymbol = Symbol.for(hookKey);
|
||||
const state = target[stateKey] as
|
||||
| {
|
||||
firstChannelVisibleEpochMs: number | null;
|
||||
frameGapsMs: number[];
|
||||
frameRequestId: number;
|
||||
heartbeatDelaysMs: number[];
|
||||
longTaskObserver: PerformanceObserver | null;
|
||||
longTasksMs: number[];
|
||||
operationStartEpochMs: number;
|
||||
performancePhaseEvents: RendererPerformancePhaseEvent[];
|
||||
playlistId: string | null;
|
||||
recordLongTask(entry: PerformanceEntry): void;
|
||||
routeReadyEpochMs: number | null;
|
||||
terminalEpochMs: number | null;
|
||||
uiPaintFrameRequestId: number | null;
|
||||
uiPaintedEpochMs: number | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!state) {
|
||||
throw new Error('m3u-import-renderer-probe-unavailable');
|
||||
}
|
||||
cancelAnimationFrame(state.frameRequestId);
|
||||
if (state.uiPaintFrameRequestId !== null) {
|
||||
cancelAnimationFrame(state.uiPaintFrameRequestId);
|
||||
}
|
||||
for (const entry of state.longTaskObserver?.takeRecords() ?? []) {
|
||||
state.recordLongTask(entry);
|
||||
}
|
||||
state.longTaskObserver?.disconnect();
|
||||
delete target[stateKey];
|
||||
delete target[hookSymbol];
|
||||
return {
|
||||
firstChannelVisibleEpochMs: state.firstChannelVisibleEpochMs,
|
||||
frameGapsMs: [...state.frameGapsMs],
|
||||
heartbeatDelaysMs: [...state.heartbeatDelaysMs],
|
||||
longTasksMs: [...state.longTasksMs],
|
||||
operationStartEpochMs: state.operationStartEpochMs,
|
||||
performancePhaseEvents: [...state.performancePhaseEvents],
|
||||
playlistId: state.playlistId,
|
||||
routeReadyEpochMs: state.routeReadyEpochMs,
|
||||
terminalEpochMs: state.terminalEpochMs,
|
||||
uiPaintedEpochMs: state.uiPaintedEpochMs,
|
||||
};
|
||||
},
|
||||
{
|
||||
hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
stateKey: M3U_IMPORT_RENDERER_STATE_KEY,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract';
|
||||
import { deriveM3uImportPhaseMetrics } from './m3u-import-report';
|
||||
import {
|
||||
createTestMainCapture,
|
||||
createTestRendererCapture,
|
||||
TEST_M3U_CHANNEL_COUNT,
|
||||
TEST_M3U_FIXTURE_BYTES,
|
||||
} from './m3u-import-report.test-helpers';
|
||||
|
||||
describe('formal M3U import phase report', () => {
|
||||
it('attributes every required phase without presenting indexes as zero work', () => {
|
||||
const phases = deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: createTestMainCapture(),
|
||||
renderer: createTestRendererCapture(),
|
||||
});
|
||||
|
||||
assert.deepEqual(phases, {
|
||||
angularRenderingMs: 10,
|
||||
dataAcquireMs: 20,
|
||||
databaseWorkerToMainCloneProxyMs: 12,
|
||||
indexAndCommitMs: null,
|
||||
indexAndCommitUnavailableReason:
|
||||
'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write',
|
||||
ipcStructuredCloneProxyMs: 44,
|
||||
mainToDatabaseWorkerCloneProxyMs: 2,
|
||||
mainToRendererCloneProxyMs: 28,
|
||||
m3uParsingMs: 40,
|
||||
normalizationMs: 10,
|
||||
playlistDeserializationMs: 12,
|
||||
playlistSerializationMs: 20,
|
||||
rendererToMainCloneProxyMs: 2,
|
||||
sqliteReadMs: 5,
|
||||
sqliteWriteMs: 30,
|
||||
storeImportDispatchMs: 2,
|
||||
storePublishChannelsMs: 10,
|
||||
storeUpdateMs: 12,
|
||||
totalMs: 215,
|
||||
});
|
||||
assert.equal(
|
||||
phases.ipcStructuredCloneProxyMs,
|
||||
phases.mainToRendererCloneProxyMs +
|
||||
phases.rendererToMainCloneProxyMs +
|
||||
phases.mainToDatabaseWorkerCloneProxyMs +
|
||||
phases.databaseWorkerToMainCloneProxyMs
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when any structured-clone boundary is missing, ambiguous, or out of order', () => {
|
||||
const main = createTestMainCapture();
|
||||
const databaseWorker = main.workers[0];
|
||||
const upsert = databaseWorker?.requests[0];
|
||||
assert.ok(databaseWorker);
|
||||
assert.ok(upsert);
|
||||
|
||||
for (const invalidMain of [
|
||||
{
|
||||
...main,
|
||||
timeline: main.timeline.filter(
|
||||
(event) => event.type !== 'db-request'
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
timeline: [
|
||||
...main.timeline,
|
||||
main.timeline.find((event) => event.type === 'db-request'),
|
||||
].filter(
|
||||
(event): event is (typeof main.timeline)[number] =>
|
||||
event !== undefined
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
sourceEpochMs: 1080,
|
||||
},
|
||||
...databaseWorker.requests.slice(1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
requestReceivedEpochMs: 1078,
|
||||
},
|
||||
...databaseWorker.requests.slice(1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
ipcCallId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-missing',
|
||||
sourceEpochMs: null,
|
||||
},
|
||||
...databaseWorker.requests.slice(1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
workStartedEpochMs: 1079,
|
||||
},
|
||||
...databaseWorker.requests.slice(1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: invalidMain,
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when a required phase, worker identity, or fixture count is ambiguous', () => {
|
||||
const missingNormalize = createTestMainCapture();
|
||||
const missingTimeline = missingNormalize.timeline.filter(
|
||||
(event) => event.phase !== 'normalize'
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: {
|
||||
...missingNormalize,
|
||||
timeline: missingTimeline,
|
||||
},
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: {
|
||||
...createTestMainCapture(),
|
||||
workers: [
|
||||
...createTestMainCapture().workers,
|
||||
{
|
||||
...createTestMainCapture().workers[0],
|
||||
ordinal: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
|
||||
const wrongBytes = createTestMainCapture();
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES + 1,
|
||||
main: wrongBytes,
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
});
|
||||
|
||||
it('requires the route-reload GET and both worker-to-renderer return paths', () => {
|
||||
const main = createTestMainCapture();
|
||||
const databaseWorker = main.workers[0];
|
||||
const upsert = databaseWorker?.requests[0];
|
||||
const get = databaseWorker?.requests[1];
|
||||
assert.ok(databaseWorker);
|
||||
assert.ok(upsert);
|
||||
assert.ok(get);
|
||||
|
||||
const invalidCaptures: MainCaptureMetrics[] = [
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: databaseWorker.requests.slice(0, 1),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...main,
|
||||
timeline: main.timeline.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === 'preload-performance-success' &&
|
||||
event.operation === 'DB_GET_APP_PLAYLIST'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
timeline: main.timeline.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === 'preload-performance-success' &&
|
||||
event.operation === 'DB_UPSERT_APP_PLAYLIST'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
timeline: main.timeline.map((event) =>
|
||||
event.type === 'preload-performance-success' &&
|
||||
event.operation === 'DB_GET_APP_PLAYLIST'
|
||||
? {
|
||||
...event,
|
||||
sourceEpochMs: 1196,
|
||||
}
|
||||
: event
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
upsert,
|
||||
{
|
||||
...get,
|
||||
responsePostedEpochMs: 1175,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const invalidMain of invalidCaptures) {
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: invalidMain,
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects playlist workers and cross-playlist database writes', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: {
|
||||
...createTestMainCapture(),
|
||||
workers: [
|
||||
...createTestMainCapture().workers,
|
||||
{
|
||||
kind: 'playlist-refresh.worker',
|
||||
requests: [],
|
||||
},
|
||||
],
|
||||
} as MainCaptureMetrics,
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
|
||||
const main = createTestMainCapture();
|
||||
const databaseWorker = main.workers[0];
|
||||
assert.ok(databaseWorker);
|
||||
const upsert = databaseWorker.requests[0];
|
||||
assert.ok(upsert);
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: {
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
playlistId: 'other-playlist',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
/* eslint-disable max-lines -- The deterministic fixture keeps the full production-shaped import sequence visible. */
|
||||
import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture';
|
||||
import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract';
|
||||
import type { M3uImportBenchmarkManifest } from './m3u-import-summary';
|
||||
|
||||
export const TEST_M3U_CHANNEL_COUNT = 10_000;
|
||||
export const TEST_M3U_FIXTURE_BYTES = 1_024_000;
|
||||
|
||||
export function createTestMainCapture(): MainCaptureMetrics {
|
||||
return {
|
||||
timeline: [
|
||||
mainPhase('start', 'data.acquire', 1001, null),
|
||||
mainPhase('end', 'data.acquire', 1021, 20, {
|
||||
byteCount: TEST_M3U_FIXTURE_BYTES,
|
||||
}),
|
||||
mainPhase('start', 'parse.m3u', 1021, null),
|
||||
mainPhase('end', 'parse.m3u', 1061, 40, {
|
||||
itemCount: TEST_M3U_CHANNEL_COUNT,
|
||||
}),
|
||||
mainPhase('start', 'normalize', 1061, null),
|
||||
mainPhase('end', 'normalize', 1071, 10, {
|
||||
itemCount: TEST_M3U_CHANNEL_COUNT,
|
||||
}),
|
||||
{
|
||||
epochMs: 1080,
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-1',
|
||||
sourceEpochMs: 1079,
|
||||
type: 'db-request',
|
||||
},
|
||||
{
|
||||
epochMs: 1137,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-1',
|
||||
success: true,
|
||||
type: 'db-response',
|
||||
},
|
||||
{
|
||||
epochMs: 1140,
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
sourceEpochMs: 1140,
|
||||
success: true,
|
||||
type: 'preload-performance-success',
|
||||
},
|
||||
{
|
||||
epochMs: 1142,
|
||||
ipcCallId: 8,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-2',
|
||||
sourceEpochMs: 1141,
|
||||
type: 'db-request',
|
||||
},
|
||||
{
|
||||
epochMs: 1174,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-2',
|
||||
success: true,
|
||||
type: 'db-response',
|
||||
},
|
||||
{
|
||||
epochMs: 1194,
|
||||
ipcCallId: 8,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
sourceEpochMs: 1194,
|
||||
success: true,
|
||||
type: 'preload-performance-success',
|
||||
},
|
||||
],
|
||||
workers: [
|
||||
{
|
||||
kind: 'database.worker',
|
||||
requests: [
|
||||
{
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-uncorrelated',
|
||||
performanceCaptureUnavailableReason: null,
|
||||
phaseEvents: [
|
||||
workerPhase(
|
||||
'start',
|
||||
'serialize.playlist',
|
||||
1082,
|
||||
null,
|
||||
'db-request-1'
|
||||
),
|
||||
workerPhase(
|
||||
'end',
|
||||
'serialize.playlist',
|
||||
1102,
|
||||
20,
|
||||
'db-request-1',
|
||||
{
|
||||
itemCount: TEST_M3U_CHANNEL_COUNT,
|
||||
}
|
||||
),
|
||||
workerPhase(
|
||||
'start',
|
||||
'sqlite.write',
|
||||
1102,
|
||||
null,
|
||||
'db-request-1'
|
||||
),
|
||||
workerPhase(
|
||||
'end',
|
||||
'sqlite.write',
|
||||
1132,
|
||||
30,
|
||||
'db-request-1',
|
||||
{
|
||||
itemCount: 1,
|
||||
}
|
||||
),
|
||||
],
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-1',
|
||||
requestReceivedEpochMs: 1081,
|
||||
responseEpochMs: 1137,
|
||||
responsePostedEpochMs: 1135,
|
||||
sourceEpochMs: 1079,
|
||||
success: true,
|
||||
workEndedEpochMs: 1133,
|
||||
workStartedEpochMs: 1082,
|
||||
},
|
||||
{
|
||||
ipcCallId: 8,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-uncorrelated',
|
||||
performanceCaptureUnavailableReason: null,
|
||||
phaseEvents: [
|
||||
workerPhase(
|
||||
'start',
|
||||
'sqlite.read',
|
||||
1144,
|
||||
null,
|
||||
'db-request-2'
|
||||
),
|
||||
workerPhase(
|
||||
'end',
|
||||
'sqlite.read',
|
||||
1149,
|
||||
5,
|
||||
'db-request-2',
|
||||
{
|
||||
itemCount: 1,
|
||||
}
|
||||
),
|
||||
workerPhase(
|
||||
'start',
|
||||
'deserialize.playlist',
|
||||
1149,
|
||||
null,
|
||||
'db-request-2'
|
||||
),
|
||||
workerPhase(
|
||||
'end',
|
||||
'deserialize.playlist',
|
||||
1161,
|
||||
12,
|
||||
'db-request-2',
|
||||
{
|
||||
itemCount: TEST_M3U_CHANNEL_COUNT,
|
||||
}
|
||||
),
|
||||
],
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-2',
|
||||
requestReceivedEpochMs: 1143,
|
||||
responseEpochMs: 1174,
|
||||
responsePostedEpochMs: 1164,
|
||||
sourceEpochMs: 1141,
|
||||
success: true,
|
||||
workEndedEpochMs: 1162,
|
||||
workStartedEpochMs: 1144,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as MainCaptureMetrics;
|
||||
}
|
||||
|
||||
export function createTestRendererCapture(): M3uImportRendererCaptureMetrics {
|
||||
return {
|
||||
phaseTimestamps: {
|
||||
firstChannelVisibleEpochMs: 1210,
|
||||
operationStartEpochMs: 1000,
|
||||
routeReadyEpochMs: 1140,
|
||||
terminalEpochMs: 1215,
|
||||
uiPaintedEpochMs: 1215,
|
||||
},
|
||||
probe: {
|
||||
performancePhaseEvents: [
|
||||
rendererPhase(
|
||||
'start',
|
||||
'store.m3u-import-dispatch',
|
||||
'renderer-1',
|
||||
1076,
|
||||
76
|
||||
),
|
||||
rendererPhase(
|
||||
'end',
|
||||
'store.m3u-import-dispatch',
|
||||
'renderer-1',
|
||||
1078,
|
||||
78
|
||||
),
|
||||
rendererPhase(
|
||||
'start',
|
||||
'store.m3u-publish-channels',
|
||||
'renderer-2',
|
||||
1195,
|
||||
195,
|
||||
TEST_M3U_CHANNEL_COUNT
|
||||
),
|
||||
rendererPhase(
|
||||
'end',
|
||||
'store.m3u-publish-channels',
|
||||
'renderer-2',
|
||||
1205,
|
||||
205,
|
||||
TEST_M3U_CHANNEL_COUNT
|
||||
),
|
||||
],
|
||||
playlistId: 'playlist-1',
|
||||
},
|
||||
} as unknown as M3uImportRendererCaptureMetrics;
|
||||
}
|
||||
|
||||
export function createCompleteTestMainCapture(
|
||||
overrides: { mainRss?: number; rendererRss?: number } = {}
|
||||
): MainCaptureMetrics {
|
||||
const base = createTestMainCapture();
|
||||
const databaseWorker = base.workers[0];
|
||||
const upsert = databaseWorker?.requests[0];
|
||||
const get = databaseWorker?.requests[1];
|
||||
if (!databaseWorker || !upsert || !get) {
|
||||
throw new Error('invalid-test-main-capture');
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
cpuProfilePath: null,
|
||||
cpuSystemMicros: 20,
|
||||
cpuUserMicros: 30,
|
||||
eventLoopDelay: { maxMs: 3, p95Ms: 1, p99Ms: 2 },
|
||||
eventLoopUtilization: null,
|
||||
eventLoopUtilizationUnavailableReason:
|
||||
'electron-main-embedded-event-loop',
|
||||
heapSnapshotPath: null,
|
||||
memory: {
|
||||
peakHeapUsedBytes: 2_000,
|
||||
peakRssBytes: overrides.mainRss ?? 5_000,
|
||||
postGcHeapUsedBytes: 1_000,
|
||||
postGcRssBytes: 4_500,
|
||||
},
|
||||
rendererWindow: {
|
||||
responsiveEvents: 0,
|
||||
rss: {
|
||||
identity: { creationTime: 100, pid: 42 },
|
||||
missingSampleCount: 0,
|
||||
peakRssBytes: overrides.rendererRss ?? 4_000,
|
||||
unavailableReason: null,
|
||||
validSampleCount: 5,
|
||||
},
|
||||
unresponsiveEvents: 0,
|
||||
windowIdentity: {
|
||||
browserWindowId: 1,
|
||||
webContentsId: 2,
|
||||
},
|
||||
},
|
||||
rssScope:
|
||||
'electron-main-process-including-worker-threads-and-native-memory',
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
externalMemorySampleCount: 4,
|
||||
heapUsedSampleCount: 4,
|
||||
peakExternalBytes: 600,
|
||||
peakExternalUnavailableReason: null,
|
||||
peakHeapUsedBytes: 1_500,
|
||||
peakHeapUsedUnavailableReason: null,
|
||||
postGcHeapUnavailableReason: null,
|
||||
postGcHeapUsedBytes: 900,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
eventLoopDelay: { maxMs: 6, p95Ms: 4, p99Ms: 5 },
|
||||
eventLoopDelayUnavailableReason: null,
|
||||
eventLoopUtilization: 0.8,
|
||||
eventLoopUtilizationUnavailableReason: null,
|
||||
histogramFlushedEpochMs: 1134,
|
||||
invalidReason: null,
|
||||
threadCpuSystemMicros: 40,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: 50,
|
||||
},
|
||||
{
|
||||
...get,
|
||||
eventLoopDelay: { maxMs: 9, p95Ms: 7, p99Ms: 8 },
|
||||
eventLoopDelayUnavailableReason: null,
|
||||
eventLoopUtilization: 0.9,
|
||||
eventLoopUtilizationUnavailableReason: null,
|
||||
histogramFlushedEpochMs: 1163,
|
||||
invalidReason: null,
|
||||
threadCpuSystemMicros: 60,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompleteTestRendererCapture(
|
||||
overrides: { rendererHeap?: number } = {}
|
||||
): M3uImportRendererCaptureMetrics {
|
||||
const base = createTestRendererCapture();
|
||||
return {
|
||||
...base,
|
||||
cpuProfilePath: null,
|
||||
frameGap: distribution([16, 40]),
|
||||
heapSnapshotPath: null,
|
||||
heartbeatDelay: distribution([0, 5]),
|
||||
longTask: distribution([60]),
|
||||
peakHeapUsedBytes: overrides.rendererHeap ?? 3_000,
|
||||
postGcHeapUsedBytes: 1_100,
|
||||
probe: {
|
||||
...base.probe,
|
||||
firstChannelVisibleEpochMs: 1210,
|
||||
frameGapsMs: [16, 40],
|
||||
heartbeatDelaysMs: [0, 5],
|
||||
longTasksMs: [60],
|
||||
operationStartEpochMs: 1000,
|
||||
routeReadyEpochMs: 1140,
|
||||
terminalEpochMs: 1215,
|
||||
uiPaintedEpochMs: 1215,
|
||||
},
|
||||
tracePath: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestM3uImportManifest(): M3uImportBenchmarkManifest {
|
||||
return {
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
fixtureSha256: 'fixture-sha',
|
||||
gitCommit: 'commit',
|
||||
gitDiffSha256: 'diff',
|
||||
gitDirty: false,
|
||||
measuredRuns: 1,
|
||||
memoryAccounting: {
|
||||
databaseWorkerRss: 'unavailable-per-worker-thread',
|
||||
mainRss:
|
||||
'electron-main-process-including-worker-threads-and-native-memory',
|
||||
nativeAndSqliteMemory:
|
||||
'included-in-main-rss-not-separately-attributable',
|
||||
rendererRss: 'separate-renderer-process',
|
||||
workerJsHeap: 'separate-v8-isolate',
|
||||
},
|
||||
runtime: {
|
||||
arch: 'arm64',
|
||||
cdpPort: 9222,
|
||||
electronVersion: '1',
|
||||
harnessNodeVersion: '1',
|
||||
platform: 'darwin',
|
||||
},
|
||||
scenario: 'm3u-import-10k',
|
||||
syntheticSeed: 240_724,
|
||||
variant: 'baseline',
|
||||
warmupRuns: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function mainPhase(
|
||||
boundary: 'end' | 'start',
|
||||
phase: string,
|
||||
epochMs: number,
|
||||
durationMs: number | null,
|
||||
metadata: { byteCount?: number; itemCount?: number } = {}
|
||||
) {
|
||||
return {
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
...metadata,
|
||||
phase,
|
||||
requestId: 'main-request-1',
|
||||
type: 'm3u-import-phase',
|
||||
};
|
||||
}
|
||||
|
||||
function workerPhase(
|
||||
boundary: 'end' | 'start',
|
||||
phase:
|
||||
| 'deserialize.playlist'
|
||||
| 'serialize.playlist'
|
||||
| 'sqlite.read'
|
||||
| 'sqlite.write',
|
||||
epochMs: number,
|
||||
durationMs: number | null,
|
||||
requestId: string,
|
||||
metadata?: { byteCount?: number; itemCount?: number }
|
||||
) {
|
||||
return {
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
...(metadata ? { metadata } : {}),
|
||||
phase,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function rendererPhase(
|
||||
boundary: 'end' | 'start',
|
||||
phase: 'store.m3u-import-dispatch' | 'store.m3u-publish-channels',
|
||||
phaseId: string,
|
||||
epochMs: number,
|
||||
monotonicMs: number,
|
||||
items?: number
|
||||
) {
|
||||
return {
|
||||
boundary,
|
||||
epochMs,
|
||||
...(items === undefined ? {} : { metadata: { items } }),
|
||||
monotonicMs,
|
||||
...(boundary === 'end' ? { outcome: 'success' as const } : {}),
|
||||
phase,
|
||||
phaseId,
|
||||
schemaVersion: 1 as const,
|
||||
};
|
||||
}
|
||||
|
||||
function distribution(values: readonly number[]) {
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
return {
|
||||
count: values.length,
|
||||
max,
|
||||
mean,
|
||||
median: mean,
|
||||
min,
|
||||
p95: values.length === 1 ? max : min + (max - min) * 0.95,
|
||||
p99: values.length === 1 ? max : min + (max - min) * 0.99,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture';
|
||||
import type {
|
||||
MainCaptureMetrics,
|
||||
MainTimelineRecord,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import { deriveM3uImportIpcAttribution } from './m3u-import-ipc-attribution';
|
||||
import {
|
||||
pairPerformancePhaseEvents,
|
||||
requirePerformancePhase,
|
||||
type PairedPerformancePhase,
|
||||
} from './performance-phase-events';
|
||||
import {
|
||||
pairRendererPerformancePhaseEvents,
|
||||
requireRendererPerformancePhase,
|
||||
} from './renderer-performance-phase-events';
|
||||
|
||||
export const M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON =
|
||||
'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write';
|
||||
|
||||
export interface M3uImportPhaseMetrics {
|
||||
readonly angularRenderingMs: number;
|
||||
readonly dataAcquireMs: number;
|
||||
readonly databaseWorkerToMainCloneProxyMs: number;
|
||||
readonly indexAndCommitMs: null;
|
||||
readonly indexAndCommitUnavailableReason: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON;
|
||||
readonly ipcStructuredCloneProxyMs: number;
|
||||
readonly mainToDatabaseWorkerCloneProxyMs: number;
|
||||
readonly mainToRendererCloneProxyMs: number;
|
||||
readonly m3uParsingMs: number;
|
||||
readonly normalizationMs: number;
|
||||
readonly playlistDeserializationMs: number;
|
||||
readonly playlistSerializationMs: number;
|
||||
readonly rendererToMainCloneProxyMs: number;
|
||||
readonly sqliteReadMs: number;
|
||||
readonly sqliteWriteMs: number;
|
||||
readonly storeImportDispatchMs: number;
|
||||
readonly storePublishChannelsMs: number;
|
||||
readonly storeUpdateMs: number;
|
||||
readonly totalMs: number;
|
||||
}
|
||||
|
||||
export interface M3uImportPhaseDerivationInput {
|
||||
readonly channelCount: number;
|
||||
readonly fixtureBytes: number;
|
||||
readonly main: MainCaptureMetrics;
|
||||
readonly renderer: M3uImportRendererCaptureMetrics;
|
||||
}
|
||||
|
||||
export function deriveM3uImportPhaseMetrics(
|
||||
input: M3uImportPhaseDerivationInput
|
||||
): M3uImportPhaseMetrics {
|
||||
try {
|
||||
return deriveM3uImportPhaseMetricsStrict(input);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`m3u-import-phase-attribution-invalid:${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function deriveM3uImportPhaseMetricsStrict(
|
||||
input: M3uImportPhaseDerivationInput
|
||||
): M3uImportPhaseMetrics {
|
||||
requirePositiveSafeCount(input.channelCount, 'channel-count');
|
||||
requirePositiveSafeCount(input.fixtureBytes, 'fixture-bytes');
|
||||
|
||||
const mainPhases = pairPerformancePhaseEvents(
|
||||
input.main.timeline
|
||||
.filter((event) => event.type === 'm3u-import-phase')
|
||||
.map(toPerformancePhaseEvent)
|
||||
);
|
||||
if (mainPhases.length !== 3) {
|
||||
throw new Error('main-phase-cardinality');
|
||||
}
|
||||
const mainRequestIds = new Set(mainPhases.map((phase) => phase.requestId));
|
||||
if (mainRequestIds.size !== 1) {
|
||||
throw new Error('main-request-correlation');
|
||||
}
|
||||
const mainRequestId = mainPhases[0]?.requestId;
|
||||
if (!mainRequestId) {
|
||||
throw new Error('main-request-missing');
|
||||
}
|
||||
const dataAcquire = requirePerformancePhase(
|
||||
mainPhases,
|
||||
mainRequestId,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE
|
||||
);
|
||||
const parseM3u = requirePerformancePhase(
|
||||
mainPhases,
|
||||
mainRequestId,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U
|
||||
);
|
||||
const normalize = requirePerformancePhase(
|
||||
mainPhases,
|
||||
mainRequestId,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE
|
||||
);
|
||||
requireMetadataCount(
|
||||
dataAcquire,
|
||||
'byteCount',
|
||||
input.fixtureBytes,
|
||||
'data-acquire-bytes'
|
||||
);
|
||||
requireMetadataCount(
|
||||
parseM3u,
|
||||
'itemCount',
|
||||
input.channelCount,
|
||||
'parse-items'
|
||||
);
|
||||
requireMetadataCount(
|
||||
normalize,
|
||||
'itemCount',
|
||||
input.channelCount,
|
||||
'normalize-items'
|
||||
);
|
||||
requireOrdered(dataAcquire.endedEpochMs, parseM3u.startedEpochMs);
|
||||
requireOrdered(parseM3u.endedEpochMs, normalize.startedEpochMs);
|
||||
|
||||
const rendererEvents = (
|
||||
input.renderer.probe as typeof input.renderer.probe & {
|
||||
readonly performancePhaseEvents?: readonly unknown[];
|
||||
}
|
||||
).performancePhaseEvents;
|
||||
if (!rendererEvents) {
|
||||
throw new Error('renderer-phase-events-missing');
|
||||
}
|
||||
const rendererPhases = pairRendererPerformancePhaseEvents(rendererEvents);
|
||||
if (rendererPhases.length !== 2) {
|
||||
throw new Error('renderer-phase-cardinality');
|
||||
}
|
||||
const importDispatch = requireRendererPerformancePhase(
|
||||
rendererPhases,
|
||||
'store.m3u-import-dispatch'
|
||||
);
|
||||
const publishChannels = requireRendererPerformancePhase(
|
||||
rendererPhases,
|
||||
'store.m3u-publish-channels'
|
||||
);
|
||||
requireRendererItems(
|
||||
publishChannels.metadata?.items,
|
||||
input.channelCount,
|
||||
'publish-channels-items'
|
||||
);
|
||||
|
||||
const playlistId = input.renderer.probe.playlistId;
|
||||
if (typeof playlistId !== 'string' || playlistId.length === 0) {
|
||||
throw new Error('renderer-playlist-id-missing');
|
||||
}
|
||||
const databaseWorkers = input.main.workers.filter(
|
||||
(worker) => worker.kind === 'database.worker'
|
||||
);
|
||||
if (
|
||||
databaseWorkers.length !== 1 ||
|
||||
input.main.workers.some(
|
||||
(worker) => worker.kind === 'playlist-refresh.worker'
|
||||
)
|
||||
) {
|
||||
throw new Error('worker-cardinality');
|
||||
}
|
||||
const databaseWorker = databaseWorkers[0];
|
||||
if (!databaseWorker) {
|
||||
throw new Error('database-worker-missing');
|
||||
}
|
||||
const upserts = databaseWorker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_UPSERT_APP_PLAYLIST' &&
|
||||
request.playlistId === playlistId &&
|
||||
request.success
|
||||
);
|
||||
if (upserts.length !== 1 || !upserts[0]) {
|
||||
throw new Error('database-upsert-correlation');
|
||||
}
|
||||
const upsert = upserts[0];
|
||||
const gets = databaseWorker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_GET_APP_PLAYLIST' &&
|
||||
request.playlistId === playlistId &&
|
||||
request.success
|
||||
);
|
||||
if (
|
||||
gets.length !== 1 ||
|
||||
!gets[0] ||
|
||||
databaseWorker.requests.length !== 2
|
||||
) {
|
||||
throw new Error('database-get-correlation');
|
||||
}
|
||||
const get = gets[0];
|
||||
const databasePhases = pairPerformancePhaseEvents(upsert.phaseEvents);
|
||||
if (
|
||||
databasePhases.length !== 2 ||
|
||||
!upsert.requestId ||
|
||||
databasePhases.some((phase) => phase.requestId !== upsert.requestId)
|
||||
) {
|
||||
throw new Error('database-phase-cardinality');
|
||||
}
|
||||
const serializePlaylist = requireDatabasePhase(
|
||||
databasePhases,
|
||||
upsert,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST
|
||||
);
|
||||
const sqliteWrite = requireDatabasePhase(
|
||||
databasePhases,
|
||||
upsert,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE
|
||||
);
|
||||
requireMetadataCount(
|
||||
serializePlaylist,
|
||||
'itemCount',
|
||||
input.channelCount,
|
||||
'serialization-items'
|
||||
);
|
||||
requireMetadataCount(sqliteWrite, 'itemCount', 1, 'sqlite-row-count');
|
||||
const getPhases = pairPerformancePhaseEvents(get.phaseEvents);
|
||||
if (
|
||||
getPhases.length !== 2 ||
|
||||
!get.requestId ||
|
||||
getPhases.some((phase) => phase.requestId !== get.requestId)
|
||||
) {
|
||||
throw new Error('database-get-phase-cardinality');
|
||||
}
|
||||
const sqliteRead = requireDatabasePhase(
|
||||
getPhases,
|
||||
get,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ
|
||||
);
|
||||
const deserializePlaylist = requireDatabasePhase(
|
||||
getPhases,
|
||||
get,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST
|
||||
);
|
||||
requireMetadataCount(sqliteRead, 'itemCount', 1, 'sqlite-read-row-count');
|
||||
requireMetadataCount(
|
||||
deserializePlaylist,
|
||||
'itemCount',
|
||||
input.channelCount,
|
||||
'deserialization-items'
|
||||
);
|
||||
|
||||
const operationStart = input.renderer.phaseTimestamps.operationStartEpochMs;
|
||||
const uiPainted = input.renderer.phaseTimestamps.uiPaintedEpochMs;
|
||||
const terminal = input.renderer.phaseTimestamps.terminalEpochMs;
|
||||
requireOrdered(operationStart, dataAcquire.startedEpochMs);
|
||||
requireOrdered(serializePlaylist.endedEpochMs, sqliteWrite.startedEpochMs);
|
||||
requireOrdered(sqliteWrite.endedEpochMs, sqliteRead.startedEpochMs);
|
||||
requireOrdered(sqliteRead.endedEpochMs, deserializePlaylist.startedEpochMs);
|
||||
requireOrdered(
|
||||
deserializePlaylist.endedEpochMs,
|
||||
publishChannels.startedEpochMs
|
||||
);
|
||||
requireOrdered(publishChannels.endedEpochMs, uiPainted);
|
||||
requireOrdered(uiPainted, terminal);
|
||||
|
||||
const ipc = deriveM3uImportIpcAttribution({
|
||||
get,
|
||||
getReadStartedEpochMs: sqliteRead.startedEpochMs,
|
||||
importDispatchEndedEpochMs: importDispatch.endedEpochMs,
|
||||
importDispatchStartedEpochMs: importDispatch.startedEpochMs,
|
||||
normalizeEndedEpochMs: normalize.endedEpochMs,
|
||||
playlistId,
|
||||
publishChannelsStartedEpochMs: publishChannels.startedEpochMs,
|
||||
serializeStartedEpochMs: serializePlaylist.startedEpochMs,
|
||||
timeline: input.main.timeline,
|
||||
upsert,
|
||||
});
|
||||
const angularRenderingMs = difference(
|
||||
uiPainted,
|
||||
publishChannels.endedEpochMs
|
||||
);
|
||||
const totalMs = difference(terminal, operationStart);
|
||||
|
||||
return Object.freeze({
|
||||
angularRenderingMs,
|
||||
dataAcquireMs: dataAcquire.durationMs,
|
||||
indexAndCommitMs: null,
|
||||
indexAndCommitUnavailableReason:
|
||||
M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON,
|
||||
...ipc,
|
||||
m3uParsingMs: parseM3u.durationMs,
|
||||
normalizationMs: normalize.durationMs,
|
||||
playlistDeserializationMs: deserializePlaylist.durationMs,
|
||||
playlistSerializationMs: serializePlaylist.durationMs,
|
||||
sqliteReadMs: sqliteRead.durationMs,
|
||||
sqliteWriteMs: sqliteWrite.durationMs,
|
||||
storeImportDispatchMs: importDispatch.durationMs,
|
||||
storePublishChannelsMs: publishChannels.durationMs,
|
||||
storeUpdateMs: importDispatch.durationMs + publishChannels.durationMs,
|
||||
totalMs,
|
||||
});
|
||||
}
|
||||
|
||||
function toPerformancePhaseEvent(event: MainTimelineRecord): unknown {
|
||||
const metadata = {
|
||||
...(event.byteCount === undefined
|
||||
? {}
|
||||
: { byteCount: event.byteCount }),
|
||||
...(event.itemCount === undefined
|
||||
? {}
|
||||
: { itemCount: event.itemCount }),
|
||||
};
|
||||
return {
|
||||
boundary: event.boundary,
|
||||
durationMs: event.durationMs,
|
||||
epochMs: event.epochMs,
|
||||
...(Object.keys(metadata).length === 0 ? {} : { metadata }),
|
||||
phase: event.phase,
|
||||
requestId: event.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function requireDatabasePhase(
|
||||
phases: readonly PairedPerformancePhase[],
|
||||
request: WorkerRequestPerformanceMetrics,
|
||||
phase: string
|
||||
): PairedPerformancePhase {
|
||||
if (!request.requestId) {
|
||||
throw new Error('database-request-id-missing');
|
||||
}
|
||||
return requirePerformancePhase(phases, request.requestId, phase);
|
||||
}
|
||||
|
||||
function requireMetadataCount(
|
||||
phase: PairedPerformancePhase,
|
||||
key: 'byteCount' | 'itemCount',
|
||||
expected: number,
|
||||
label: string
|
||||
): void {
|
||||
if (phase.metadata?.[key] !== expected) {
|
||||
throw new Error(label);
|
||||
}
|
||||
}
|
||||
|
||||
function requireRendererItems(
|
||||
value: number | undefined,
|
||||
expected: number,
|
||||
label: string
|
||||
): void {
|
||||
if (value !== expected) {
|
||||
throw new Error(label);
|
||||
}
|
||||
}
|
||||
|
||||
function requirePositiveSafeCount(value: number, label: string): void {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(label);
|
||||
}
|
||||
}
|
||||
|
||||
function requireOrdered(startEpochMs: number, endEpochMs: number): void {
|
||||
if (
|
||||
!Number.isFinite(startEpochMs) ||
|
||||
!Number.isFinite(endEpochMs) ||
|
||||
endEpochMs < startEpochMs
|
||||
) {
|
||||
throw new Error('phase-order');
|
||||
}
|
||||
}
|
||||
|
||||
function difference(endEpochMs: number, startEpochMs: number): number {
|
||||
requireOrdered(startEpochMs, endEpochMs);
|
||||
return endEpochMs - startEpochMs;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { DatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity';
|
||||
import type { DatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity';
|
||||
import type { M3uImportScenarioId } from './m3u-import-benchmark-contract';
|
||||
import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture';
|
||||
import {
|
||||
M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON,
|
||||
type M3uImportPhaseMetrics,
|
||||
} from './m3u-import-report';
|
||||
import type {
|
||||
DatabaseWorkerPostGcValidity,
|
||||
MainCaptureMetrics,
|
||||
NumericDistribution,
|
||||
PerformanceIterationKind,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import type { RendererRssValidity } from './renderer-rss-validity';
|
||||
|
||||
export interface M3uImportIterationResult {
|
||||
readonly channelCount: number;
|
||||
readonly fixtureBytes: number;
|
||||
readonly kind: PerformanceIterationKind;
|
||||
readonly main: MainCaptureMetrics;
|
||||
readonly phases: M3uImportPhaseMetrics;
|
||||
readonly renderer: M3uImportRendererCaptureMetrics;
|
||||
readonly runId: string;
|
||||
readonly scenarioId: M3uImportScenarioId;
|
||||
}
|
||||
|
||||
export interface M3uImportBenchmarkManifest {
|
||||
readonly channelCount: number;
|
||||
readonly fixtureBytes: number;
|
||||
readonly fixtureSha256: string;
|
||||
readonly gitCommit: string;
|
||||
readonly gitDiffSha256: string;
|
||||
readonly gitDirty: boolean;
|
||||
readonly measuredRuns: number;
|
||||
readonly memoryAccounting: {
|
||||
readonly databaseWorkerRss: 'unavailable-per-worker-thread';
|
||||
readonly mainRss: 'electron-main-process-including-worker-threads-and-native-memory';
|
||||
readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable';
|
||||
readonly rendererRss: 'separate-renderer-process';
|
||||
readonly workerJsHeap: 'separate-v8-isolate';
|
||||
};
|
||||
readonly runtime: {
|
||||
readonly arch: string;
|
||||
readonly cdpPort: number;
|
||||
readonly electronVersion: string;
|
||||
readonly harnessNodeVersion: string;
|
||||
readonly platform: string;
|
||||
};
|
||||
readonly scenario: M3uImportScenarioId;
|
||||
readonly syntheticSeed: number;
|
||||
readonly variant: string;
|
||||
readonly warmupRuns: number;
|
||||
}
|
||||
|
||||
export interface M3uImportMeasuredSummary {
|
||||
readonly angularRenderingMs: NumericDistribution;
|
||||
readonly dataAcquireMs: NumericDistribution;
|
||||
readonly databaseWorkerGetEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly databaseWorkerGetEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly databaseWorkerGetEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly databaseWorkerGetEventLoopUtilization: NumericDistribution;
|
||||
readonly databaseWorkerGetThreadCpuSystemMicros: NumericDistribution;
|
||||
readonly databaseWorkerGetThreadCpuUserMicros: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopUtilization: NumericDistribution;
|
||||
readonly databaseWorkerExternalPeakBytes: NumericDistribution;
|
||||
readonly databaseWorkerHeapPeakBytes: NumericDistribution;
|
||||
readonly databaseWorkerPostGcHeapBytes: NumericDistribution;
|
||||
readonly databaseWorkerThreadCpuSystemMicros: NumericDistribution;
|
||||
readonly databaseWorkerThreadCpuUserMicros: NumericDistribution;
|
||||
readonly databaseWorkerToMainCloneProxyMs: NumericDistribution;
|
||||
readonly databaseWorkerUpsertEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly databaseWorkerUpsertEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly databaseWorkerUpsertEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly databaseWorkerUpsertEventLoopUtilization: NumericDistribution;
|
||||
readonly databaseWorkerUpsertThreadCpuSystemMicros: NumericDistribution;
|
||||
readonly databaseWorkerUpsertThreadCpuUserMicros: NumericDistribution;
|
||||
readonly ipcStructuredCloneProxyMs: NumericDistribution;
|
||||
readonly mainToDatabaseWorkerCloneProxyMs: NumericDistribution;
|
||||
readonly mainToRendererCloneProxyMs: NumericDistribution;
|
||||
readonly m3uParsingMs: NumericDistribution;
|
||||
readonly mainEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly mainEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly mainEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly mainEventLoopUtilization: NumericDistribution;
|
||||
readonly mainHeapPeakBytes: NumericDistribution;
|
||||
readonly mainHeapPostGcBytes: NumericDistribution;
|
||||
readonly mainRssPeakBytes: NumericDistribution;
|
||||
readonly mainRssPostGcBytes: NumericDistribution;
|
||||
readonly normalizationMs: NumericDistribution;
|
||||
readonly playlistDeserializationMs: NumericDistribution;
|
||||
readonly playlistSerializationMs: NumericDistribution;
|
||||
readonly rendererFrameGapMs: NumericDistribution;
|
||||
readonly rendererHeartbeatDelayMs: NumericDistribution;
|
||||
readonly rendererHeapPeakBytes: NumericDistribution;
|
||||
readonly rendererHeapPostGcBytes: NumericDistribution;
|
||||
readonly rendererLongTaskCount: number;
|
||||
readonly rendererLongTaskMs: NumericDistribution;
|
||||
readonly rendererRssPeakBytes: NumericDistribution;
|
||||
readonly rendererToMainCloneProxyMs: NumericDistribution;
|
||||
readonly responsiveEvents: number;
|
||||
readonly sqliteReadMs: NumericDistribution;
|
||||
readonly sqliteWriteMs: NumericDistribution;
|
||||
readonly storeImportDispatchMs: NumericDistribution;
|
||||
readonly storePublishChannelsMs: NumericDistribution;
|
||||
readonly storeUpdateMs: NumericDistribution;
|
||||
readonly totalMs: NumericDistribution;
|
||||
readonly unresponsiveEvents: number;
|
||||
}
|
||||
|
||||
export interface M3uImportBenchmarkSummary {
|
||||
readonly iterations: readonly M3uImportIterationResult[];
|
||||
readonly manifest: M3uImportBenchmarkManifest;
|
||||
readonly measured: M3uImportMeasuredSummary;
|
||||
readonly notApplicable: {
|
||||
readonly cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation';
|
||||
readonly databaseWorkerRss: 'unavailable-per-worker-thread';
|
||||
readonly indexAndCommitMs: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON;
|
||||
readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable';
|
||||
readonly playlistRefreshWorker: 'not-used-by-initial-import';
|
||||
};
|
||||
readonly validity: {
|
||||
readonly databaseWorkerPeakMemory: DatabaseWorkerPeakMemoryValidity;
|
||||
readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity;
|
||||
readonly databaseWorkerRequestMetrics: DatabaseWorkerRequestMetricsValidity;
|
||||
readonly rendererRss: RendererRssValidity;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { it } from 'node:test';
|
||||
|
||||
import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
createM3uImportBenchmarkSummary,
|
||||
createM3uImportIterationResult,
|
||||
type M3uImportBenchmarkSummary,
|
||||
} from './m3u-import-summary';
|
||||
import { assertM3uImportComparisonValidity } from './m3u-import.benchmark';
|
||||
import {
|
||||
createCompleteTestMainCapture,
|
||||
createCompleteTestRendererCapture,
|
||||
createTestM3uImportManifest,
|
||||
TEST_M3U_CHANNEL_COUNT,
|
||||
TEST_M3U_FIXTURE_BYTES,
|
||||
} from './m3u-import-report.test-helpers';
|
||||
|
||||
it('keeps summary aggregation below the hard line limit without a suppression', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'src/performance/m3u-import-summary.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.doesNotMatch(source, /eslint-disable max-lines/);
|
||||
assert.ok(source.split(/\r?\n/).length <= 400);
|
||||
});
|
||||
|
||||
it('summarizes measured runs only while preserving per-process memory boundaries', () => {
|
||||
const warmup = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
||||
main: createCompleteTestMainCapture(),
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'warmup-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const measured = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
main: createCompleteTestMainCapture(),
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'run-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const diagnostic = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
main: createCompleteTestMainCapture({
|
||||
mainRss: 9_999,
|
||||
rendererRss: 8_888,
|
||||
}),
|
||||
renderer: createCompleteTestRendererCapture({
|
||||
rendererHeap: 7_777,
|
||||
}),
|
||||
runId: 'diagnostic',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const summary = createM3uImportBenchmarkSummary(
|
||||
createTestM3uImportManifest(),
|
||||
[warmup, measured, diagnostic]
|
||||
);
|
||||
|
||||
assert.equal(summary.measured.totalMs.count, 1);
|
||||
assert.equal(summary.measured.totalMs.mean, 215);
|
||||
assert.equal(summary.measured.ipcStructuredCloneProxyMs.mean, 44);
|
||||
assert.equal(summary.measured.mainToDatabaseWorkerCloneProxyMs.mean, 2);
|
||||
assert.equal(summary.measured.databaseWorkerToMainCloneProxyMs.mean, 12);
|
||||
assert.equal(summary.measured.mainToRendererCloneProxyMs.mean, 28);
|
||||
assert.equal(summary.measured.rendererToMainCloneProxyMs.mean, 2);
|
||||
assert.equal(summary.measured.sqliteReadMs.mean, 5);
|
||||
assert.equal(summary.measured.playlistDeserializationMs.mean, 12);
|
||||
assert.equal(summary.measured.databaseWorkerThreadCpuSystemMicros.mean, 100);
|
||||
assert.equal(summary.measured.databaseWorkerThreadCpuUserMicros.mean, 120);
|
||||
assert.equal(
|
||||
summary.measured.databaseWorkerUpsertEventLoopDelayP95Ms.mean,
|
||||
4
|
||||
);
|
||||
assert.equal(
|
||||
summary.measured.databaseWorkerGetEventLoopDelayP95Ms.mean,
|
||||
7
|
||||
);
|
||||
assert.equal(summary.measured.mainRssPeakBytes.mean, 5_000);
|
||||
assert.equal(summary.measured.mainHeapPeakBytes.mean, 2_000);
|
||||
assert.equal(summary.measured.rendererRssPeakBytes.mean, 4_000);
|
||||
assert.equal(summary.measured.rendererHeapPeakBytes.mean, 3_000);
|
||||
assert.equal(summary.measured.databaseWorkerHeapPeakBytes.mean, 1_500);
|
||||
assert.equal(summary.measured.databaseWorkerPostGcHeapBytes.mean, 900);
|
||||
assert.equal(summary.measured.rendererLongTaskCount, 1);
|
||||
assert.equal(summary.measured.unresponsiveEvents, 0);
|
||||
assert.deepEqual(summary.notApplicable, {
|
||||
cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation',
|
||||
databaseWorkerRss: 'unavailable-per-worker-thread',
|
||||
indexAndCommitMs:
|
||||
'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write',
|
||||
nativeAndSqliteMemory:
|
||||
'included-in-main-rss-not-separately-attributable',
|
||||
playlistRefreshWorker: 'not-used-by-initial-import',
|
||||
});
|
||||
assert.equal(summary.validity.rendererRss.validForComparison, true);
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerPeakMemory.validForComparison,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerPostGc.validForComparison,
|
||||
true
|
||||
);
|
||||
assert.doesNotThrow(() => assertM3uImportComparisonValidity(summary));
|
||||
|
||||
const databaseWorker = measured.main.workers[0];
|
||||
assert.ok(databaseWorker);
|
||||
const invalidMeasured = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
main: {
|
||||
...measured.main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
externalMemorySampleCount: 0,
|
||||
heapUsedSampleCount: 0,
|
||||
peakExternalBytes: null,
|
||||
peakExternalUnavailableReason:
|
||||
'worker-external-memory-samples-missing',
|
||||
peakHeapUsedBytes: null,
|
||||
peakHeapUsedUnavailableReason:
|
||||
'worker-heap-used-samples-missing',
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: measured.renderer,
|
||||
runId: 'run-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const invalidPeakSummary = createM3uImportBenchmarkSummary(
|
||||
createTestM3uImportManifest(),
|
||||
[warmup, invalidMeasured, diagnostic]
|
||||
);
|
||||
assert.equal(
|
||||
invalidPeakSummary.validity.databaseWorkerPeakMemory.validForComparison,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
invalidPeakSummary.measured.databaseWorkerHeapPeakBytes.count,
|
||||
0
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertM3uImportComparisonValidity(
|
||||
invalidPeakSummary as M3uImportBenchmarkSummary
|
||||
),
|
||||
/Database worker peak-memory capture is invalid/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createM3uImportBenchmarkSummary(createTestM3uImportManifest(), [
|
||||
measured,
|
||||
diagnostic,
|
||||
]),
|
||||
/m3u-import-summary-schedule-invalid/
|
||||
);
|
||||
});
|
||||
|
||||
it('fails comparison when any measured worker request omits mandatory event-loop or CPU metrics', () => {
|
||||
const completeMain = createCompleteTestMainCapture();
|
||||
const databaseWorker = completeMain.workers[0];
|
||||
const get = databaseWorker?.requests[1];
|
||||
assert.ok(databaseWorker);
|
||||
assert.ok(get);
|
||||
const invalidMain = {
|
||||
...completeMain,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
databaseWorker.requests[0],
|
||||
{
|
||||
...get,
|
||||
eventLoopDelay: null,
|
||||
eventLoopDelayUnavailableReason:
|
||||
'event-loop-delay-capture-unavailable',
|
||||
histogramFlushedEpochMs: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const warmup = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
||||
main: createCompleteTestMainCapture(),
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'warmup-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const measured = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
main: invalidMain,
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'run-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const diagnostic = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
main: createCompleteTestMainCapture(),
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'diagnostic',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const summary = createM3uImportBenchmarkSummary(
|
||||
createTestM3uImportManifest(),
|
||||
[warmup, measured, diagnostic]
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerRequestMetrics.validForComparison,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerRequestMetrics.expectedRequestCount,
|
||||
2
|
||||
);
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerRequestMetrics.validRequestCount,
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
summary.measured.databaseWorkerGetEventLoopDelayP95Ms.count,
|
||||
0
|
||||
);
|
||||
assert.throws(
|
||||
() => assertM3uImportComparisonValidity(summary),
|
||||
/Database worker request metrics are invalid/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture';
|
||||
import type { M3uImportScenarioId } from './m3u-import-benchmark-contract';
|
||||
import type {
|
||||
MainCaptureMetrics,
|
||||
NumericDistribution,
|
||||
PerformanceIterationKind,
|
||||
WorkerCaptureMetrics,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
PERFORMANCE_ITERATION_KIND,
|
||||
PERFORMANCE_WORKER_KIND,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
deriveM3uImportPhaseMetrics,
|
||||
M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON,
|
||||
} from './m3u-import-report';
|
||||
import { summarizeNumbers } from './performance-statistics';
|
||||
import { assessDatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity';
|
||||
import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity';
|
||||
import { assessDatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity';
|
||||
import { assessRendererRssValidity } from './renderer-rss-validity';
|
||||
import type {
|
||||
M3uImportBenchmarkManifest,
|
||||
M3uImportBenchmarkSummary,
|
||||
M3uImportIterationResult,
|
||||
} from './m3u-import-summary-contract';
|
||||
|
||||
export type {
|
||||
M3uImportBenchmarkManifest,
|
||||
M3uImportBenchmarkSummary,
|
||||
M3uImportIterationResult,
|
||||
M3uImportMeasuredSummary,
|
||||
} from './m3u-import-summary-contract';
|
||||
|
||||
export function createM3uImportIterationResult(input: {
|
||||
readonly channelCount: number;
|
||||
readonly fixtureBytes: number;
|
||||
readonly kind: PerformanceIterationKind;
|
||||
readonly main: MainCaptureMetrics;
|
||||
readonly renderer: M3uImportRendererCaptureMetrics;
|
||||
readonly runId: string;
|
||||
readonly scenarioId: M3uImportScenarioId;
|
||||
}): M3uImportIterationResult {
|
||||
return Object.freeze({
|
||||
channelCount: input.channelCount,
|
||||
fixtureBytes: input.fixtureBytes,
|
||||
kind: input.kind,
|
||||
main: input.main,
|
||||
phases: deriveM3uImportPhaseMetrics(input),
|
||||
renderer: input.renderer,
|
||||
runId: input.runId,
|
||||
scenarioId: input.scenarioId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createM3uImportBenchmarkSummary(
|
||||
manifest: M3uImportBenchmarkManifest,
|
||||
iterations: readonly M3uImportIterationResult[]
|
||||
): M3uImportBenchmarkSummary {
|
||||
validateIterationSchedule(manifest, iterations);
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
|
||||
);
|
||||
validateMeasuredIterations(manifest, measured);
|
||||
const values = (
|
||||
select: (iteration: M3uImportIterationResult) => number | null
|
||||
): NumericDistribution => summarizeNumbers(measured.map(select));
|
||||
const rendererSamples = (
|
||||
select: (
|
||||
probe: M3uImportRendererCaptureMetrics['probe']
|
||||
) => readonly number[]
|
||||
): NumericDistribution =>
|
||||
summarizeNumbers(
|
||||
measured.flatMap((iteration) => [
|
||||
...select(iteration.renderer.probe),
|
||||
])
|
||||
);
|
||||
const databaseWorkers = measured.map(requireDatabaseWorker);
|
||||
const databaseRequestPairs = databaseWorkers.map(
|
||||
requireInitialImportRequests
|
||||
);
|
||||
const databaseUpserts = databaseRequestPairs.map((pair) => pair.upsert);
|
||||
const databaseGets = databaseRequestPairs.map((pair) => pair.get);
|
||||
const databaseRequests = databaseRequestPairs.flatMap((pair) => [
|
||||
pair.upsert,
|
||||
pair.get,
|
||||
]);
|
||||
|
||||
return Object.freeze({
|
||||
iterations: Object.freeze([...iterations]),
|
||||
manifest,
|
||||
measured: Object.freeze({
|
||||
angularRenderingMs: values(
|
||||
(iteration) => iteration.phases.angularRenderingMs
|
||||
),
|
||||
dataAcquireMs: values(
|
||||
(iteration) => iteration.phases.dataAcquireMs
|
||||
),
|
||||
databaseWorkerGetEventLoopDelayMaxMs: summarizeNumbers(
|
||||
databaseGets.map((request) => request.eventLoopDelay?.maxMs)
|
||||
),
|
||||
databaseWorkerGetEventLoopDelayP95Ms: summarizeNumbers(
|
||||
databaseGets.map((request) => request.eventLoopDelay?.p95Ms)
|
||||
),
|
||||
databaseWorkerGetEventLoopDelayP99Ms: summarizeNumbers(
|
||||
databaseGets.map((request) => request.eventLoopDelay?.p99Ms)
|
||||
),
|
||||
databaseWorkerGetEventLoopUtilization: summarizeNumbers(
|
||||
databaseGets.map((request) => request.eventLoopUtilization)
|
||||
),
|
||||
databaseWorkerGetThreadCpuSystemMicros: summarizeNumbers(
|
||||
databaseGets.map((request) => request.threadCpuSystemMicros)
|
||||
),
|
||||
databaseWorkerGetThreadCpuUserMicros: summarizeNumbers(
|
||||
databaseGets.map((request) => request.threadCpuUserMicros)
|
||||
),
|
||||
databaseWorkerEventLoopDelayMaxMs: summarizeNumbers(
|
||||
databaseRequests.map((request) => request.eventLoopDelay?.maxMs)
|
||||
),
|
||||
databaseWorkerEventLoopDelayP95Ms: summarizeNumbers(
|
||||
databaseRequests.map((request) => request.eventLoopDelay?.p95Ms)
|
||||
),
|
||||
databaseWorkerEventLoopDelayP99Ms: summarizeNumbers(
|
||||
databaseRequests.map((request) => request.eventLoopDelay?.p99Ms)
|
||||
),
|
||||
databaseWorkerEventLoopUtilization: summarizeNumbers(
|
||||
databaseRequests.map((request) => request.eventLoopUtilization)
|
||||
),
|
||||
databaseWorkerExternalPeakBytes: summarizeNumbers(
|
||||
databaseWorkers.map((worker) => worker.peakExternalBytes)
|
||||
),
|
||||
databaseWorkerHeapPeakBytes: summarizeNumbers(
|
||||
databaseWorkers.map((worker) => worker.peakHeapUsedBytes)
|
||||
),
|
||||
databaseWorkerPostGcHeapBytes: summarizeNumbers(
|
||||
databaseWorkers.map((worker) => worker.postGcHeapUsedBytes)
|
||||
),
|
||||
databaseWorkerThreadCpuSystemMicros: summarizeNumbers(
|
||||
databaseRequestPairs.map((pair) =>
|
||||
sumRequestMetric(
|
||||
pair.upsert.threadCpuSystemMicros,
|
||||
pair.get.threadCpuSystemMicros
|
||||
)
|
||||
)
|
||||
),
|
||||
databaseWorkerThreadCpuUserMicros: summarizeNumbers(
|
||||
databaseRequestPairs.map((pair) =>
|
||||
sumRequestMetric(
|
||||
pair.upsert.threadCpuUserMicros,
|
||||
pair.get.threadCpuUserMicros
|
||||
)
|
||||
)
|
||||
),
|
||||
databaseWorkerToMainCloneProxyMs: values(
|
||||
(iteration) =>
|
||||
iteration.phases.databaseWorkerToMainCloneProxyMs
|
||||
),
|
||||
databaseWorkerUpsertEventLoopDelayMaxMs: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.eventLoopDelay?.maxMs)
|
||||
),
|
||||
databaseWorkerUpsertEventLoopDelayP95Ms: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.eventLoopDelay?.p95Ms)
|
||||
),
|
||||
databaseWorkerUpsertEventLoopDelayP99Ms: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.eventLoopDelay?.p99Ms)
|
||||
),
|
||||
databaseWorkerUpsertEventLoopUtilization: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.eventLoopUtilization)
|
||||
),
|
||||
databaseWorkerUpsertThreadCpuSystemMicros: summarizeNumbers(
|
||||
databaseUpserts.map(
|
||||
(request) => request.threadCpuSystemMicros
|
||||
)
|
||||
),
|
||||
databaseWorkerUpsertThreadCpuUserMicros: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.threadCpuUserMicros)
|
||||
),
|
||||
ipcStructuredCloneProxyMs: values(
|
||||
(iteration) => iteration.phases.ipcStructuredCloneProxyMs
|
||||
),
|
||||
mainToDatabaseWorkerCloneProxyMs: values(
|
||||
(iteration) => iteration.phases.mainToDatabaseWorkerCloneProxyMs
|
||||
),
|
||||
mainToRendererCloneProxyMs: values(
|
||||
(iteration) => iteration.phases.mainToRendererCloneProxyMs
|
||||
),
|
||||
m3uParsingMs: values((iteration) => iteration.phases.m3uParsingMs),
|
||||
mainEventLoopDelayMaxMs: values(
|
||||
(iteration) => iteration.main.eventLoopDelay.maxMs
|
||||
),
|
||||
mainEventLoopDelayP95Ms: values(
|
||||
(iteration) => iteration.main.eventLoopDelay.p95Ms
|
||||
),
|
||||
mainEventLoopDelayP99Ms: values(
|
||||
(iteration) => iteration.main.eventLoopDelay.p99Ms
|
||||
),
|
||||
mainEventLoopUtilization: values(
|
||||
(iteration) => iteration.main.eventLoopUtilization
|
||||
),
|
||||
mainHeapPeakBytes: values(
|
||||
(iteration) => iteration.main.memory.peakHeapUsedBytes
|
||||
),
|
||||
mainHeapPostGcBytes: values(
|
||||
(iteration) => iteration.main.memory.postGcHeapUsedBytes
|
||||
),
|
||||
mainRssPeakBytes: values(
|
||||
(iteration) => iteration.main.memory.peakRssBytes
|
||||
),
|
||||
mainRssPostGcBytes: values(
|
||||
(iteration) => iteration.main.memory.postGcRssBytes
|
||||
),
|
||||
normalizationMs: values(
|
||||
(iteration) => iteration.phases.normalizationMs
|
||||
),
|
||||
playlistDeserializationMs: values(
|
||||
(iteration) => iteration.phases.playlistDeserializationMs
|
||||
),
|
||||
playlistSerializationMs: values(
|
||||
(iteration) => iteration.phases.playlistSerializationMs
|
||||
),
|
||||
rendererFrameGapMs: rendererSamples((probe) => probe.frameGapsMs),
|
||||
rendererHeartbeatDelayMs: rendererSamples(
|
||||
(probe) => probe.heartbeatDelaysMs
|
||||
),
|
||||
rendererHeapPeakBytes: values(
|
||||
(iteration) => iteration.renderer.peakHeapUsedBytes
|
||||
),
|
||||
rendererHeapPostGcBytes: values(
|
||||
(iteration) => iteration.renderer.postGcHeapUsedBytes
|
||||
),
|
||||
rendererLongTaskCount: measured.reduce(
|
||||
(total, iteration) =>
|
||||
total + iteration.renderer.probe.longTasksMs.length,
|
||||
0
|
||||
),
|
||||
rendererLongTaskMs: rendererSamples((probe) => probe.longTasksMs),
|
||||
rendererRssPeakBytes: values(
|
||||
(iteration) => iteration.main.rendererWindow.rss.peakRssBytes
|
||||
),
|
||||
rendererToMainCloneProxyMs: values(
|
||||
(iteration) => iteration.phases.rendererToMainCloneProxyMs
|
||||
),
|
||||
responsiveEvents: measured.reduce(
|
||||
(total, iteration) =>
|
||||
total + iteration.main.rendererWindow.responsiveEvents,
|
||||
0
|
||||
),
|
||||
sqliteReadMs: values((iteration) => iteration.phases.sqliteReadMs),
|
||||
sqliteWriteMs: values(
|
||||
(iteration) => iteration.phases.sqliteWriteMs
|
||||
),
|
||||
storeImportDispatchMs: values(
|
||||
(iteration) => iteration.phases.storeImportDispatchMs
|
||||
),
|
||||
storePublishChannelsMs: values(
|
||||
(iteration) => iteration.phases.storePublishChannelsMs
|
||||
),
|
||||
storeUpdateMs: values(
|
||||
(iteration) => iteration.phases.storeUpdateMs
|
||||
),
|
||||
totalMs: values((iteration) => iteration.phases.totalMs),
|
||||
unresponsiveEvents: measured.reduce(
|
||||
(total, iteration) =>
|
||||
total + iteration.main.rendererWindow.unresponsiveEvents,
|
||||
0
|
||||
),
|
||||
}),
|
||||
notApplicable: Object.freeze({
|
||||
cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation',
|
||||
databaseWorkerRss: 'unavailable-per-worker-thread',
|
||||
indexAndCommitMs: M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON,
|
||||
nativeAndSqliteMemory:
|
||||
'included-in-main-rss-not-separately-attributable',
|
||||
playlistRefreshWorker: 'not-used-by-initial-import',
|
||||
}),
|
||||
validity: Object.freeze({
|
||||
databaseWorkerPeakMemory:
|
||||
assessDatabaseWorkerPeakMemoryValidity(iterations),
|
||||
databaseWorkerPostGc:
|
||||
assessDatabaseWorkerPostGcValidity(iterations),
|
||||
databaseWorkerRequestMetrics:
|
||||
assessDatabaseWorkerRequestMetricsValidity(iterations),
|
||||
rendererRss: assessRendererRssValidity(iterations),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function validateIterationSchedule(
|
||||
manifest: M3uImportBenchmarkManifest,
|
||||
iterations: readonly M3uImportIterationResult[]
|
||||
): void {
|
||||
const warmupCount = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.WARMUP
|
||||
).length;
|
||||
const measuredCount = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
|
||||
).length;
|
||||
const diagnosticCount = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC
|
||||
).length;
|
||||
if (
|
||||
warmupCount !== manifest.warmupRuns ||
|
||||
measuredCount !== manifest.measuredRuns ||
|
||||
diagnosticCount !== 1 ||
|
||||
iterations.length !== warmupCount + measuredCount + diagnosticCount ||
|
||||
new Set(iterations.map((iteration) => iteration.runId)).size !==
|
||||
iterations.length ||
|
||||
iterations.some(
|
||||
(iteration) =>
|
||||
iteration.channelCount !== manifest.channelCount ||
|
||||
iteration.fixtureBytes !== manifest.fixtureBytes ||
|
||||
iteration.scenarioId !== manifest.scenario
|
||||
)
|
||||
) {
|
||||
throw new Error('m3u-import-summary-schedule-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function validateMeasuredIterations(
|
||||
manifest: M3uImportBenchmarkManifest,
|
||||
measured: readonly M3uImportIterationResult[]
|
||||
): void {
|
||||
if (
|
||||
measured.length !== manifest.measuredRuns ||
|
||||
new Set(measured.map((iteration) => iteration.runId)).size !==
|
||||
measured.length ||
|
||||
measured.some(
|
||||
(iteration) =>
|
||||
iteration.channelCount !== manifest.channelCount ||
|
||||
iteration.fixtureBytes !== manifest.fixtureBytes ||
|
||||
iteration.scenarioId !== manifest.scenario
|
||||
)
|
||||
) {
|
||||
throw new Error('m3u-import-summary-measured-runs-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function requireDatabaseWorker(
|
||||
iteration: M3uImportIterationResult
|
||||
): WorkerCaptureMetrics {
|
||||
const databaseWorkers = iteration.main.workers.filter(
|
||||
(worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE
|
||||
);
|
||||
if (databaseWorkers.length !== 1 || !databaseWorkers[0]) {
|
||||
throw new Error('m3u-import-summary-database-worker-invalid');
|
||||
}
|
||||
return databaseWorkers[0];
|
||||
}
|
||||
|
||||
function requireInitialImportRequests(
|
||||
worker: WorkerCaptureMetrics
|
||||
): {
|
||||
readonly get: WorkerRequestPerformanceMetrics;
|
||||
readonly upsert: WorkerRequestPerformanceMetrics;
|
||||
} {
|
||||
const upserts = worker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success
|
||||
);
|
||||
const gets = worker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_GET_APP_PLAYLIST' && request.success
|
||||
);
|
||||
if (
|
||||
worker.requests.length !== 2 ||
|
||||
upserts.length !== 1 ||
|
||||
!upserts[0] ||
|
||||
gets.length !== 1 ||
|
||||
!gets[0]
|
||||
) {
|
||||
throw new Error('m3u-import-summary-database-request-invalid');
|
||||
}
|
||||
return { get: gets[0], upsert: upserts[0] };
|
||||
}
|
||||
|
||||
function sumRequestMetric(
|
||||
first: number | null,
|
||||
second: number | null
|
||||
): number | null {
|
||||
return first === null || second === null ? null : first + second;
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
type LaunchedElectronApp,
|
||||
} from '../electron-test-fixtures';
|
||||
import { validateInitialImportDiagnosticArtifacts } from './initial-import-diagnostic-artifacts';
|
||||
import {
|
||||
assertRendererCdpTarget,
|
||||
assertTcpPortAvailable,
|
||||
type M3uImportBenchmarkConfiguration,
|
||||
resolveM3uImportBenchmarkConfiguration,
|
||||
resolveRendererWindowIdentity,
|
||||
writeM3uImportJson as writeJson,
|
||||
} from './m3u-import-benchmark-support';
|
||||
import {
|
||||
assertSyntheticM3uImportUrl,
|
||||
createM3uImportIterationDefinitions,
|
||||
listM3uImportScenarios,
|
||||
type M3uImportIterationDefinition,
|
||||
type M3uImportScenario,
|
||||
} from './m3u-import-benchmark-contract';
|
||||
import {
|
||||
createM3uImportBenchmarkSummary,
|
||||
createM3uImportIterationResult,
|
||||
type M3uImportBenchmarkManifest,
|
||||
type M3uImportBenchmarkSummary,
|
||||
type M3uImportIterationResult,
|
||||
} from './m3u-import-summary';
|
||||
import {
|
||||
startM3uImportRendererCapture,
|
||||
type RunningM3uImportRendererCapture,
|
||||
} from './m3u-import-renderer-capture';
|
||||
import {
|
||||
installMainCapture,
|
||||
readMainCaptureStatus,
|
||||
startMainCapture,
|
||||
stopMainCapture,
|
||||
} from './m3u-refresh-main-capture';
|
||||
import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract';
|
||||
import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight';
|
||||
import {
|
||||
startSyntheticM3uServer,
|
||||
type SyntheticM3uServer,
|
||||
} from './synthetic-m3u-server';
|
||||
import {
|
||||
createSyntheticM3uFixture,
|
||||
SYNTHETIC_M3U_SEED,
|
||||
type SyntheticM3uFixture,
|
||||
} from './synthetic-m3u';
|
||||
|
||||
export async function runM3uImportBenchmark(): Promise<void> {
|
||||
const config = await resolveM3uImportBenchmarkConfiguration();
|
||||
const definitions = createM3uImportIterationDefinitions(config.smoke);
|
||||
|
||||
for (const scenario of listM3uImportScenarios()) {
|
||||
await runScenario(
|
||||
config,
|
||||
scenario,
|
||||
definitions.filter(
|
||||
(definition) => definition.scenarioId === scenario.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
config: M3uImportBenchmarkConfiguration,
|
||||
scenario: M3uImportScenario,
|
||||
definitions: readonly M3uImportIterationDefinition[]
|
||||
): Promise<void> {
|
||||
const fixture = createSyntheticM3uFixture(scenario.channelCount);
|
||||
const server = await startSyntheticM3uServer(fixture);
|
||||
const scenarioDirectory = join(config.variantDirectory, scenario.id);
|
||||
const iterations: M3uImportIterationResult[] = [];
|
||||
|
||||
try {
|
||||
assertSyntheticM3uImportUrl(server.resourceUrl);
|
||||
await server.prime();
|
||||
await assertPerformanceArtifactCapacity(scenarioDirectory);
|
||||
await mkdir(scenarioDirectory, { recursive: false });
|
||||
for (const definition of definitions) {
|
||||
console.log(
|
||||
`[performance] ${config.variant}/${scenario.id}/${definition.runId} starting`
|
||||
);
|
||||
const result = await runIteration(
|
||||
config,
|
||||
definition,
|
||||
fixture,
|
||||
server,
|
||||
scenarioDirectory
|
||||
);
|
||||
iterations.push(result);
|
||||
console.log(
|
||||
`[performance] ${definition.runId} total=${result.phases.totalMs.toFixed(3)}ms`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
if (server.requestCount() !== definitions.length + 1) {
|
||||
throw new Error('Synthetic M3U server request total is invalid');
|
||||
}
|
||||
const manifest = createManifest(config, scenario, fixture);
|
||||
const summary = createM3uImportBenchmarkSummary(manifest, iterations);
|
||||
await writeJson(join(scenarioDirectory, 'manifest.json'), manifest);
|
||||
await writeJson(join(scenarioDirectory, 'summary.json'), summary);
|
||||
const diagnostic = requireDiagnosticIteration(iterations);
|
||||
await validateInitialImportDiagnosticArtifacts(
|
||||
{ main: diagnostic.main, renderer: diagnostic.renderer },
|
||||
join(scenarioDirectory, diagnostic.runId)
|
||||
);
|
||||
assertM3uImportComparisonValidity(summary);
|
||||
console.log(
|
||||
`[performance] completed: ${join(scenarioDirectory, 'summary.json')}`
|
||||
);
|
||||
}
|
||||
|
||||
export function assertM3uImportComparisonValidity(
|
||||
summary: M3uImportBenchmarkSummary
|
||||
): void {
|
||||
if (!summary.validity.rendererRss.validForComparison) {
|
||||
throw new Error(
|
||||
`Renderer RSS capture is invalid for comparison: ${JSON.stringify(
|
||||
summary.validity.rendererRss.invalidMeasuredRuns
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!summary.validity.databaseWorkerPostGc.validForComparison) {
|
||||
throw new Error(
|
||||
`Database worker post-GC capture is invalid for comparison: ${JSON.stringify(
|
||||
summary.validity.databaseWorkerPostGc.invalidMeasuredRuns
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!summary.validity.databaseWorkerPeakMemory.validForComparison) {
|
||||
throw new Error(
|
||||
`Database worker peak-memory capture is invalid for comparison: ${JSON.stringify(
|
||||
summary.validity.databaseWorkerPeakMemory.invalidMeasuredRuns
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!summary.validity.databaseWorkerRequestMetrics.validForComparison) {
|
||||
throw new Error(
|
||||
`Database worker request metrics are invalid for comparison: ${JSON.stringify(
|
||||
summary.validity.databaseWorkerRequestMetrics.invalidRequests
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runIteration(
|
||||
config: M3uImportBenchmarkConfiguration,
|
||||
definition: M3uImportIterationDefinition,
|
||||
fixture: SyntheticM3uFixture,
|
||||
server: SyntheticM3uServer,
|
||||
scenarioDirectory: string
|
||||
): Promise<M3uImportIterationResult> {
|
||||
const iterationDirectory = join(scenarioDirectory, definition.runId);
|
||||
await assertPerformanceArtifactCapacity(iterationDirectory);
|
||||
await mkdir(iterationDirectory, { recursive: false });
|
||||
const dataDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-m3u-import-performance-')
|
||||
);
|
||||
let app: LaunchedElectronApp | null = null;
|
||||
let renderer: RunningM3uImportRendererCapture | null = null;
|
||||
|
||||
try {
|
||||
await assertTcpPortAvailable(config.rendererCdpPort);
|
||||
app = await launchElectronApp(dataDirectory, {
|
||||
args: [
|
||||
'--js-flags=--expose-gc',
|
||||
'--remote-debugging-address=127.0.0.1',
|
||||
`--remote-debugging-port=${config.rendererCdpPort}`,
|
||||
`--user-data-dir=${join(dataDirectory, 'user-data')}`,
|
||||
],
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0',
|
||||
IPTVNATOR_PERF_CAPTURE: '1',
|
||||
IPTVNATOR_PERF_WORKER_PROFILING: '1',
|
||||
IPTVNATOR_TRACE_RENDERER_CONSOLE:
|
||||
process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0',
|
||||
},
|
||||
});
|
||||
app.mainWindow.setDefaultTimeout(120_000);
|
||||
await assertRendererCdpTarget(
|
||||
app.mainWindow,
|
||||
config.rendererCdpPort
|
||||
);
|
||||
await installMainCapture(app.electronApp);
|
||||
const rendererWindowIdentity = await resolveRendererWindowIdentity(app);
|
||||
const requestsBefore = server.requestCount();
|
||||
const diagnostic =
|
||||
definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC;
|
||||
renderer = await startM3uImportRendererCapture(app.mainWindow, {
|
||||
diagnostic,
|
||||
outputDirectory: iterationDirectory,
|
||||
playlistTitle: `Synthetic M3U import ${definition.channelCount}`,
|
||||
playlistUrl: server.resourceUrl,
|
||||
});
|
||||
|
||||
await startMainCapture(app.electronApp, {
|
||||
diagnostic,
|
||||
outputDirectory: iterationDirectory,
|
||||
rendererWindowIdentity,
|
||||
});
|
||||
await renderer.triggerImport();
|
||||
await renderer.waitForTerminal();
|
||||
await waitForDatabaseSettlement(app);
|
||||
assertM3uImportRequestDelta(requestsBefore, server.requestCount());
|
||||
|
||||
const [rendererMetrics, mainMetrics] = await Promise.all([
|
||||
renderer.stop(),
|
||||
stopMainCapture(app.electronApp),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeJson(
|
||||
join(iterationDirectory, 'main-capture.json'),
|
||||
mainMetrics
|
||||
),
|
||||
writeJson(
|
||||
join(iterationDirectory, 'renderer-capture.json'),
|
||||
rendererMetrics
|
||||
),
|
||||
]);
|
||||
const result = createM3uImportIterationResult({
|
||||
channelCount: definition.channelCount,
|
||||
fixtureBytes: fixture.bytes,
|
||||
kind: definition.kind,
|
||||
main: mainMetrics,
|
||||
renderer: rendererMetrics,
|
||||
runId: definition.runId,
|
||||
scenarioId: definition.scenarioId,
|
||||
});
|
||||
await writeJson(join(iterationDirectory, 'result.json'), result);
|
||||
return result;
|
||||
} finally {
|
||||
if (renderer) {
|
||||
await renderer.dispose().catch((error: unknown) => {
|
||||
console.error(
|
||||
'[performance] Renderer capture cleanup failed',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
if (app) {
|
||||
await closeElectronApp(app).catch((error: unknown) => {
|
||||
console.error('[performance] Electron cleanup failed', error);
|
||||
});
|
||||
}
|
||||
await rm(dataDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDatabaseSettlement(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const status = await readMainCaptureStatus(app.electronApp);
|
||||
return (
|
||||
status.databaseRequests === 2 &&
|
||||
status.databaseUpsertsCompleted === 1 &&
|
||||
status.databaseGetsCompleted === 1 &&
|
||||
status.databasePending === 0 &&
|
||||
status.preloadSuccessMarkers === 2
|
||||
);
|
||||
},
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
function createManifest(
|
||||
config: M3uImportBenchmarkConfiguration,
|
||||
scenario: M3uImportScenario,
|
||||
fixture: SyntheticM3uFixture
|
||||
): M3uImportBenchmarkManifest {
|
||||
return Object.freeze({
|
||||
channelCount: fixture.channelCount,
|
||||
fixtureBytes: fixture.bytes,
|
||||
fixtureSha256: fixture.sha256,
|
||||
gitCommit: config.sourceState.commit,
|
||||
gitDiffSha256: config.sourceState.diffSha256,
|
||||
gitDirty: config.sourceState.dirty,
|
||||
measuredRuns: config.measuredRuns,
|
||||
memoryAccounting: Object.freeze({
|
||||
databaseWorkerRss: 'unavailable-per-worker-thread',
|
||||
mainRss:
|
||||
'electron-main-process-including-worker-threads-and-native-memory',
|
||||
nativeAndSqliteMemory:
|
||||
'included-in-main-rss-not-separately-attributable',
|
||||
rendererRss: 'separate-renderer-process',
|
||||
workerJsHeap: 'separate-v8-isolate',
|
||||
}),
|
||||
runtime: Object.freeze({
|
||||
arch: process.arch,
|
||||
cdpPort: config.rendererCdpPort,
|
||||
electronVersion: config.electronVersion,
|
||||
harnessNodeVersion: process.versions.node,
|
||||
platform: process.platform,
|
||||
}),
|
||||
scenario: scenario.id,
|
||||
syntheticSeed: SYNTHETIC_M3U_SEED,
|
||||
variant: config.variant,
|
||||
warmupRuns: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function requireDiagnosticIteration(
|
||||
iterations: readonly M3uImportIterationResult[]
|
||||
): M3uImportIterationResult {
|
||||
const diagnostic = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC
|
||||
);
|
||||
if (diagnostic.length !== 1 || !diagnostic[0]) {
|
||||
throw new Error('M3U import diagnostic iteration is missing');
|
||||
}
|
||||
return diagnostic[0];
|
||||
}
|
||||
|
||||
export function assertM3uImportRequestDelta(
|
||||
before: number,
|
||||
after: number
|
||||
): void {
|
||||
const oneRequest =
|
||||
Number.isSafeInteger(before) &&
|
||||
Number.isSafeInteger(after) &&
|
||||
after === before + 1;
|
||||
if (!oneRequest) {
|
||||
throw new Error(
|
||||
'Synthetic M3U import must issue exactly one application GET'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { RendererProcessRssCapture } from './renderer-process-rss-capture';
|
||||
import type { RendererRssValidity } from './renderer-rss-validity';
|
||||
import type {
|
||||
RendererProcessRssCapture,
|
||||
RendererProcessRssUnavailableReason,
|
||||
} from './renderer-process-rss-capture';
|
||||
AppPlaylistPerformancePhase,
|
||||
PerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const PERFORMANCE_ITERATION_KIND = {
|
||||
DIAGNOSTIC: 'diagnostic',
|
||||
@@ -54,6 +56,12 @@ export type WorkerPostGcHeapCapture =
|
||||
readonly postGcHeapUsedBytes: null;
|
||||
};
|
||||
|
||||
export const WORKER_PEAK_MEMORY_UNAVAILABLE_REASON = {
|
||||
EXTERNAL_MEMORY_SAMPLES_MISSING:
|
||||
'worker-external-memory-samples-missing',
|
||||
HEAP_USED_SAMPLES_MISSING: 'worker-heap-used-samples-missing',
|
||||
} as const;
|
||||
|
||||
export interface NumericDistribution {
|
||||
readonly count: number;
|
||||
readonly max: number | null;
|
||||
@@ -78,12 +86,19 @@ export interface ProcessMemoryMetrics {
|
||||
}
|
||||
|
||||
export interface MainTimelineRecord {
|
||||
readonly boundary?: 'end' | 'start';
|
||||
readonly byteCount?: number;
|
||||
readonly durationMs?: number | null;
|
||||
readonly epochMs: number;
|
||||
readonly ipcCallId?: number;
|
||||
readonly itemCount?: number;
|
||||
readonly operation?: string;
|
||||
readonly operationId?: string;
|
||||
readonly phase?: string;
|
||||
readonly playlistId?: string;
|
||||
readonly requestId?: string;
|
||||
readonly success?: boolean;
|
||||
readonly sourceEpochMs?: number;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
@@ -93,15 +108,19 @@ export interface WorkerRequestPerformanceMetrics {
|
||||
readonly eventLoopUtilization: number | null;
|
||||
readonly eventLoopUtilizationUnavailableReason: string | null;
|
||||
readonly histogramFlushedEpochMs: number | null;
|
||||
readonly ipcCallId: number | null;
|
||||
readonly invalidReason: string | null;
|
||||
readonly operation: string | null;
|
||||
readonly operationId: string | null;
|
||||
readonly operationIdUnavailableReason: string | null;
|
||||
readonly performanceCaptureUnavailableReason: string | null;
|
||||
readonly phaseEvents: readonly PerformancePhaseEvent<AppPlaylistPerformancePhase>[];
|
||||
readonly playlistId: string | null;
|
||||
readonly requestId: string | null;
|
||||
readonly requestReceivedEpochMs: number | null;
|
||||
readonly responseEpochMs: number;
|
||||
readonly responsePostedEpochMs: number | null;
|
||||
readonly sourceEpochMs: number | null;
|
||||
readonly success: boolean;
|
||||
readonly threadCpuSystemMicros: number | null;
|
||||
readonly threadCpuUnavailableReason: string | null;
|
||||
@@ -117,11 +136,19 @@ export type WorkerCaptureMetrics = {
|
||||
readonly eventLoopDelay: EventLoopDelayMetrics | null;
|
||||
readonly eventLoopDelayUnavailableReason: string | null;
|
||||
readonly eventLoopUtilization: number | null;
|
||||
readonly externalMemorySampleCount: number;
|
||||
readonly heapUsedSampleCount: number;
|
||||
readonly kind: PerformanceWorkerKind;
|
||||
readonly operationId: string | null;
|
||||
readonly ordinal: number;
|
||||
readonly peakExternalBytes: number;
|
||||
readonly peakHeapUsedBytes: number;
|
||||
readonly peakExternalBytes: number | null;
|
||||
readonly peakExternalUnavailableReason:
|
||||
| typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING
|
||||
| null;
|
||||
readonly peakHeapUsedBytes: number | null;
|
||||
readonly peakHeapUsedUnavailableReason:
|
||||
| typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.HEAP_USED_SAMPLES_MISSING
|
||||
| null;
|
||||
readonly playlistId: string | null;
|
||||
readonly profilePath: string | null;
|
||||
readonly requests: readonly WorkerRequestPerformanceMetrics[];
|
||||
@@ -257,22 +284,6 @@ export interface DatabaseWorkerPostGcValidity {
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
export interface InvalidRendererRssMeasuredRun {
|
||||
readonly missingSampleCount: number;
|
||||
readonly reason:
|
||||
RendererProcessRssUnavailableReason | 'renderer-rss-capture-invalid';
|
||||
readonly runId: string;
|
||||
readonly validSampleCount: number;
|
||||
}
|
||||
|
||||
export interface RendererRssValidity {
|
||||
readonly invalidMeasuredRuns: readonly InvalidRendererRssMeasuredRun[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForBenchmark: boolean;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
export interface CancellationBenchmarkSummary {
|
||||
readonly cancellationEffectRate: number;
|
||||
readonly iterations: readonly CancellationIterationResult[];
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
NumericDistribution,
|
||||
PerformanceWorkerKind,
|
||||
RendererCaptureMetrics,
|
||||
RendererRssValidity,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
summarizeNumbers,
|
||||
} from './performance-statistics';
|
||||
import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity';
|
||||
import { assessRendererRssValidity } from './renderer-rss-validity';
|
||||
|
||||
export function createCancellationIterationResult(input: {
|
||||
readonly kind: CancellationIterationResult['kind'];
|
||||
@@ -316,50 +316,6 @@ export function createCancellationBenchmarkSummary(
|
||||
});
|
||||
}
|
||||
|
||||
function assessRendererRssValidity(
|
||||
iterations: readonly CancellationIterationResult[]
|
||||
): RendererRssValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
|
||||
);
|
||||
const invalidMeasuredRuns: RendererRssValidity['invalidMeasuredRuns'][number][] =
|
||||
[];
|
||||
let validMeasuredRunCount = 0;
|
||||
|
||||
for (const iteration of measured) {
|
||||
const rss = iteration.main.rendererWindow.rss;
|
||||
if (
|
||||
rss.identity !== null &&
|
||||
Number.isSafeInteger(rss.peakRssBytes) &&
|
||||
Number(rss.peakRssBytes) > 0 &&
|
||||
rss.unavailableReason === null &&
|
||||
rss.missingSampleCount === 0 &&
|
||||
Number.isSafeInteger(rss.validSampleCount) &&
|
||||
rss.validSampleCount > 0
|
||||
) {
|
||||
validMeasuredRunCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
invalidMeasuredRuns.push({
|
||||
missingSampleCount: rss.missingSampleCount,
|
||||
reason: rss.unavailableReason ?? 'renderer-rss-capture-invalid',
|
||||
runId: iteration.runId,
|
||||
validSampleCount: rss.validSampleCount,
|
||||
});
|
||||
}
|
||||
|
||||
const valid =
|
||||
measured.length > 0 && validMeasuredRunCount === measured.length;
|
||||
return Object.freeze({
|
||||
invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns),
|
||||
measuredRunCount: measured.length,
|
||||
validForBenchmark: valid,
|
||||
validForComparison: valid,
|
||||
validMeasuredRunCount,
|
||||
});
|
||||
}
|
||||
|
||||
function derivePhases(
|
||||
main: MainCaptureMetrics,
|
||||
renderer: RendererCaptureMetrics
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */
|
||||
import type { ElectronApplication } from '@playwright/test';
|
||||
import {
|
||||
M3U_IMPORT_PERFORMANCE_PHASE,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import {
|
||||
DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY,
|
||||
@@ -58,6 +62,7 @@ export interface MainCaptureStartOptions {
|
||||
}
|
||||
|
||||
export interface MainCaptureStatus {
|
||||
readonly databaseGetsCompleted: number;
|
||||
readonly databasePending: number;
|
||||
readonly databaseRequests: number;
|
||||
readonly databaseUpsertsCompleted: number;
|
||||
@@ -65,6 +70,7 @@ export interface MainCaptureStatus {
|
||||
readonly playlistResponsesFailed: number;
|
||||
readonly playlistResponses: number;
|
||||
readonly playlistResponsesSucceeded: number;
|
||||
readonly preloadSuccessMarkers: number;
|
||||
}
|
||||
|
||||
export interface MainCaptureRolloverResult {
|
||||
@@ -101,6 +107,9 @@ export async function installMainCapture(
|
||||
createRendererProcessRssCaptureApi.toString(),
|
||||
rendererWindowRssSessionApiFactorySource:
|
||||
createRendererWindowRssSessionApi.toString(),
|
||||
m3uImportPerformancePhaseEventChannel:
|
||||
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL,
|
||||
m3uImportPerformancePhases: Object.values(M3U_IMPORT_PERFORMANCE_PHASE),
|
||||
stateKey: MAIN_CAPTURE_STATE_KEY,
|
||||
workerTerminationGenerationApiFactorySource:
|
||||
createWorkerTerminationGenerationApi.toString(),
|
||||
@@ -158,12 +167,14 @@ export async function installMainCapture(
|
||||
cpuLast: WorkerCpuUsage | null;
|
||||
elu: number | null;
|
||||
eluStart: WorkerElu | null;
|
||||
externalMemorySampleCount: number;
|
||||
externalPeak: number;
|
||||
finalized: boolean;
|
||||
finalizationKey: object;
|
||||
finalizationTimedOut: boolean;
|
||||
finalizing: Promise<void> | null;
|
||||
heapPeak: number;
|
||||
heapUsedSampleCount: number;
|
||||
kind: 'database.worker' | 'playlist-refresh.worker';
|
||||
operationId: string | null;
|
||||
ordinal: number;
|
||||
@@ -185,12 +196,19 @@ export async function installMainCapture(
|
||||
worker: InstrumentedWorker;
|
||||
}
|
||||
interface TimelineRecord {
|
||||
readonly boundary?: 'end' | 'start';
|
||||
readonly byteCount?: number;
|
||||
readonly durationMs?: number | null;
|
||||
readonly epochMs: number;
|
||||
readonly ipcCallId?: number;
|
||||
readonly itemCount?: number;
|
||||
readonly operation?: string;
|
||||
readonly operationId?: string;
|
||||
readonly phase?: string;
|
||||
readonly playlistId?: string;
|
||||
readonly requestId?: string;
|
||||
readonly success?: boolean;
|
||||
readonly sourceEpochMs?: number;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
@@ -245,6 +263,9 @@ export async function installMainCapture(
|
||||
const inspector = runtimeProcess.getBuiltinModule(
|
||||
'node:inspector'
|
||||
) as typeof import('node:inspector');
|
||||
const diagnosticsChannel = runtimeProcess.getBuiltinModule(
|
||||
'node:diagnostics_channel'
|
||||
) as typeof import('node:diagnostics_channel');
|
||||
const path = runtimeProcess.getBuiltinModule(
|
||||
'node:path'
|
||||
) as typeof import('node:path');
|
||||
@@ -307,6 +328,60 @@ export async function installMainCapture(
|
||||
state.timeline.push({ epochMs: nowEpochMs(), ...record });
|
||||
}
|
||||
};
|
||||
const m3uImportPhases = new Set<string>(
|
||||
input.m3uImportPerformancePhases
|
||||
);
|
||||
diagnosticsChannel
|
||||
.channel(input.m3uImportPerformancePhaseEventChannel)
|
||||
.subscribe((incoming: unknown) => {
|
||||
if (!state.active && !state.stopping) {
|
||||
return;
|
||||
}
|
||||
if (typeof incoming !== 'object' || incoming === null) {
|
||||
return;
|
||||
}
|
||||
const event = incoming as JsonRecord;
|
||||
const boundary = event['boundary'];
|
||||
const durationMs = event['durationMs'];
|
||||
const epochMs = event['epochMs'];
|
||||
const metadata =
|
||||
typeof event['metadata'] === 'object' &&
|
||||
event['metadata'] !== null
|
||||
? (event['metadata'] as JsonRecord)
|
||||
: null;
|
||||
const phase = event['phase'];
|
||||
const requestId = event['requestId'];
|
||||
if (
|
||||
(boundary !== 'start' && boundary !== 'end') ||
|
||||
typeof epochMs !== 'number' ||
|
||||
!Number.isFinite(epochMs) ||
|
||||
typeof phase !== 'string' ||
|
||||
!m3uImportPhases.has(phase) ||
|
||||
typeof requestId !== 'string' ||
|
||||
requestId.length === 0 ||
|
||||
(boundary === 'start' && durationMs !== null) ||
|
||||
(boundary === 'end' &&
|
||||
(typeof durationMs !== 'number' ||
|
||||
!Number.isFinite(durationMs) ||
|
||||
durationMs < 0))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const byteCount = metadata?.['byteCount'];
|
||||
const itemCount = metadata?.['itemCount'];
|
||||
state.timeline.push({
|
||||
boundary,
|
||||
...(isCount(byteCount) ? { byteCount } : {}),
|
||||
durationMs: durationMs as number | null,
|
||||
epochMs,
|
||||
...(isCount(itemCount) ? { itemCount } : {}),
|
||||
phase,
|
||||
requestId,
|
||||
type: 'm3u-import-phase',
|
||||
});
|
||||
});
|
||||
const isCount = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
const classifyRequest = (
|
||||
message: JsonRecord
|
||||
): 'database.worker' | 'playlist-refresh.worker' | null => {
|
||||
@@ -348,12 +423,14 @@ export async function installMainCapture(
|
||||
cpuLast: null,
|
||||
elu: null,
|
||||
eluStart: null,
|
||||
externalMemorySampleCount: 0,
|
||||
externalPeak: 0,
|
||||
finalized: false,
|
||||
finalizationKey: {},
|
||||
finalizationTimedOut: false,
|
||||
finalizing: null,
|
||||
heapPeak: 0,
|
||||
heapUsedSampleCount: 0,
|
||||
kind,
|
||||
operationId: null,
|
||||
ordinal: nextWorkerOrdinal,
|
||||
@@ -400,8 +477,10 @@ export async function installMainCapture(
|
||||
record.responseEpochMs = responseEpochMs;
|
||||
record.requestPerformance.push({
|
||||
identity: {
|
||||
ipcCallId: null,
|
||||
operationId: record.operationId,
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: null,
|
||||
},
|
||||
operation: null,
|
||||
performanceCapture: message['performance'] ?? null,
|
||||
@@ -466,14 +545,30 @@ export async function installMainCapture(
|
||||
try {
|
||||
const stats = await record.worker.getHeapStatistics?.();
|
||||
if (stats) {
|
||||
record.heapPeak = Math.max(
|
||||
record.heapPeak,
|
||||
Number(stats.used_heap_size ?? 0)
|
||||
);
|
||||
record.externalPeak = Math.max(
|
||||
record.externalPeak,
|
||||
Number(stats.external_memory ?? 0)
|
||||
);
|
||||
const heapUsedSample = stats.used_heap_size;
|
||||
if (
|
||||
typeof heapUsedSample === 'number' &&
|
||||
Number.isFinite(heapUsedSample) &&
|
||||
heapUsedSample >= 0
|
||||
) {
|
||||
record.heapUsedSampleCount += 1;
|
||||
record.heapPeak = Math.max(
|
||||
record.heapPeak,
|
||||
heapUsedSample
|
||||
);
|
||||
}
|
||||
const externalMemorySample = stats.external_memory;
|
||||
if (
|
||||
typeof externalMemorySample === 'number' &&
|
||||
Number.isFinite(externalMemorySample) &&
|
||||
externalMemorySample >= 0
|
||||
) {
|
||||
record.externalMemorySampleCount += 1;
|
||||
record.externalPeak = Math.max(
|
||||
record.externalPeak,
|
||||
externalMemorySample
|
||||
);
|
||||
}
|
||||
}
|
||||
const cpu = await record.worker.cpuUsage?.();
|
||||
if (cpu) {
|
||||
@@ -504,12 +599,14 @@ export async function installMainCapture(
|
||||
record.cpuLast = null;
|
||||
record.elu = null;
|
||||
record.eluStart = null;
|
||||
record.externalMemorySampleCount = 0;
|
||||
record.externalPeak = 0;
|
||||
record.finalized = false;
|
||||
record.finalizationKey = {};
|
||||
record.finalizationTimedOut = false;
|
||||
record.finalizing = null;
|
||||
record.heapPeak = 0;
|
||||
record.heapUsedSampleCount = 0;
|
||||
record.operationId = null;
|
||||
record.pendingCount = 0;
|
||||
record.playlistId = null;
|
||||
@@ -863,8 +960,10 @@ export async function installMainCapture(
|
||||
value
|
||||
)
|
||||
: {
|
||||
ipcCallId: null,
|
||||
operationId: payloadOperationId,
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: null,
|
||||
};
|
||||
record.pendingCount += 1;
|
||||
dbRequests.set(value['requestId'], {
|
||||
@@ -874,11 +973,14 @@ export async function installMainCapture(
|
||||
record,
|
||||
});
|
||||
recordTimeline({
|
||||
ipcCallId: identity.ipcCallId ?? undefined,
|
||||
operation: value['operation'],
|
||||
operationId: identity.operationId ?? undefined,
|
||||
playlistId:
|
||||
readDatabasePlaylistId(value) ?? undefined,
|
||||
requestId: value['requestId'],
|
||||
sourceEpochMs:
|
||||
identity.sourceEpochMs ?? undefined,
|
||||
type: 'db-request',
|
||||
});
|
||||
}
|
||||
@@ -1052,6 +1154,12 @@ export async function installMainCapture(
|
||||
|
||||
const api = {
|
||||
status: (): MainCaptureStatus => ({
|
||||
databaseGetsCompleted: state.timeline.filter(
|
||||
(entry) =>
|
||||
entry['type'] === 'db-response' &&
|
||||
entry['operation'] === 'DB_GET_APP_PLAYLIST' &&
|
||||
entry['success'] === true
|
||||
).length,
|
||||
databasePending: dbRequests.size,
|
||||
databaseRequests: state.timeline.filter(
|
||||
(entry) => entry['type'] === 'db-request'
|
||||
@@ -1081,6 +1189,8 @@ export async function installMainCapture(
|
||||
entry['type'] === 'playlist-response' &&
|
||||
entry['success'] === true
|
||||
).length,
|
||||
preloadSuccessMarkers:
|
||||
databaseRequestIdentityCapture.successMarkerCount(),
|
||||
}),
|
||||
start: async (options: MainCaptureStartOptions): Promise<void> => {
|
||||
databaseWorkerPostGcCutoffApi.beginCapture();
|
||||
@@ -1092,6 +1202,18 @@ export async function installMainCapture(
|
||||
databaseWorkerPostGcCutoffApi.beginStop();
|
||||
state.stopping = true;
|
||||
state.active = false;
|
||||
for (const marker of databaseRequestIdentityCapture.takeSuccessMarkers()) {
|
||||
state.timeline.push({
|
||||
epochMs: marker.sourceEpochMs,
|
||||
ipcCallId: marker.ipcCallId,
|
||||
operation: marker.operation,
|
||||
operationId: marker.operationId ?? undefined,
|
||||
playlistId: marker.playlistId,
|
||||
sourceEpochMs: marker.sourceEpochMs,
|
||||
success: true,
|
||||
type: 'preload-performance-success',
|
||||
});
|
||||
}
|
||||
databaseRequestIdentityCapture.stop();
|
||||
if (state.sampleTimer) {
|
||||
clearInterval(state.sampleTimer);
|
||||
@@ -1230,11 +1352,28 @@ export async function installMainCapture(
|
||||
? record.cpuLast.user - record.cpuFirst.user
|
||||
: null,
|
||||
eventLoopUtilization: record.elu,
|
||||
externalMemorySampleCount:
|
||||
record.externalMemorySampleCount,
|
||||
heapUsedSampleCount: record.heapUsedSampleCount,
|
||||
kind: record.kind,
|
||||
operationId: record.operationId,
|
||||
ordinal: record.ordinal,
|
||||
peakExternalBytes: record.externalPeak,
|
||||
peakHeapUsedBytes: record.heapPeak,
|
||||
peakExternalBytes:
|
||||
record.externalMemorySampleCount > 0
|
||||
? record.externalPeak
|
||||
: null,
|
||||
peakExternalUnavailableReason:
|
||||
record.externalMemorySampleCount > 0
|
||||
? null
|
||||
: 'worker-external-memory-samples-missing',
|
||||
peakHeapUsedBytes:
|
||||
record.heapUsedSampleCount > 0
|
||||
? record.heapPeak
|
||||
: null,
|
||||
peakHeapUsedUnavailableReason:
|
||||
record.heapUsedSampleCount > 0
|
||||
? null
|
||||
: 'worker-heap-used-samples-missing',
|
||||
playlistId: record.playlistId,
|
||||
postGcHeapUnavailableReason:
|
||||
record.postGcHeapUnavailableReason,
|
||||
@@ -1245,6 +1384,7 @@ export async function installMainCapture(
|
||||
terminatedEpochMs: record.terminatedEpochMs,
|
||||
},
|
||||
requests: record.requestPerformance.map((request) => ({
|
||||
ipcCallId: request.identity.ipcCallId,
|
||||
operation: request.operation,
|
||||
operationId: request.identity.operationId,
|
||||
operationIdUnavailableReason:
|
||||
@@ -1253,6 +1393,7 @@ export async function installMainCapture(
|
||||
playlistId: request.playlistId,
|
||||
requestId: request.requestId,
|
||||
responseEpochMs: request.responseEpochMs,
|
||||
sourceEpochMs: request.identity.sourceEpochMs,
|
||||
success: request.success,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -270,3 +270,34 @@ test('the cancellation benchmark exposes GC to Electron worker isolates', () =>
|
||||
assert.match(source, /'--js-flags=--expose-gc'/);
|
||||
assert.doesNotMatch(source, /execArgv/);
|
||||
});
|
||||
|
||||
test('the initial M3U import benchmark uses the production-equivalent performance build', () => {
|
||||
const target = e2eProject.targets['benchmark-m3u-import'];
|
||||
|
||||
assert.ok(target, 'electron-backend-e2e must define benchmark-m3u-import');
|
||||
assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']);
|
||||
});
|
||||
|
||||
test('the initial M3U import benchmark command is pinned to its Playwright test file', () => {
|
||||
const target = e2eProject.targets['benchmark-m3u-import'];
|
||||
|
||||
assert.equal(
|
||||
target.options?.['command'],
|
||||
'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts'
|
||||
);
|
||||
});
|
||||
|
||||
test('the initial M3U import benchmark enables opt-in capture and worker GC', () => {
|
||||
const source = readFileSync(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/);
|
||||
assert.match(source, /IPTVNATOR_PERF_WORKER_PROFILING:\s*'1'/);
|
||||
assert.match(source, /'--js-flags=--expose-gc'/);
|
||||
assert.doesNotMatch(source, /execArgv/);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
pairPerformancePhaseEvents,
|
||||
requirePerformancePhase,
|
||||
} from './performance-phase-events';
|
||||
|
||||
describe('performance phase event pairing', () => {
|
||||
it('pairs request-scoped boundaries and preserves end metadata', () => {
|
||||
const phases = pairPerformancePhaseEvents([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'data.acquire',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 12.5,
|
||||
epochMs: 113,
|
||||
metadata: { byteCount: 2048, itemCount: 10 },
|
||||
phase: 'data.acquire',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(phases, [
|
||||
{
|
||||
durationMs: 12.5,
|
||||
endedEpochMs: 113,
|
||||
metadata: { byteCount: 2048, itemCount: 10 },
|
||||
phase: 'data.acquire',
|
||||
requestId: 'request-1',
|
||||
startedEpochMs: 100,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
requirePerformancePhase(phases, 'request-1', 'data.acquire'),
|
||||
phases[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('pairs interleaved requests without cross-correlating them', () => {
|
||||
const phases = pairPerformancePhaseEvents([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-a',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 101,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-b',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 3,
|
||||
epochMs: 104,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-b',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 8,
|
||||
epochMs: 108,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-a',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
phases.map(({ durationMs, requestId }) => ({
|
||||
durationMs,
|
||||
requestId,
|
||||
})),
|
||||
[
|
||||
{ durationMs: 8, requestId: 'request-a' },
|
||||
{ durationMs: 3, requestId: 'request-b' },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for orphaned, duplicate, malformed, or reversed boundaries', () => {
|
||||
const validStart = {
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'normalize',
|
||||
requestId: 'request-1',
|
||||
} as const;
|
||||
const validEnd = {
|
||||
boundary: 'end',
|
||||
durationMs: 2,
|
||||
epochMs: 102,
|
||||
phase: 'normalize',
|
||||
requestId: 'request-1',
|
||||
} as const;
|
||||
|
||||
for (const invalid of [
|
||||
[validStart],
|
||||
[validStart, validStart, validEnd],
|
||||
[validStart, { ...validEnd, durationMs: null }],
|
||||
[validStart, { ...validEnd, epochMs: 99 }],
|
||||
[{ ...validStart, durationMs: 0 }, validEnd],
|
||||
[
|
||||
validStart,
|
||||
{
|
||||
...validEnd,
|
||||
metadata: { byteCount: -1 },
|
||||
},
|
||||
],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => pairPerformancePhaseEvents(invalid),
|
||||
/performance-phase-events-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires one exact request and phase match', () => {
|
||||
const phases = pairPerformancePhaseEvents([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'normalize',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 2,
|
||||
epochMs: 102,
|
||||
phase: 'normalize',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.throws(
|
||||
() => requirePerformancePhase(phases, 'request-2', 'normalize'),
|
||||
/performance-phase-missing:request-2:normalize/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
requirePerformancePhase(
|
||||
[...phases, ...phases],
|
||||
'request-1',
|
||||
'normalize'
|
||||
),
|
||||
/performance-phase-ambiguous:request-1:normalize/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { PerformancePhaseMetadata } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface PairedPerformancePhase {
|
||||
readonly durationMs: number;
|
||||
readonly endedEpochMs: number;
|
||||
readonly metadata: PerformancePhaseMetadata | null;
|
||||
readonly phase: string;
|
||||
readonly requestId: string;
|
||||
readonly startedEpochMs: number;
|
||||
}
|
||||
|
||||
interface ParsedPerformancePhaseEvent {
|
||||
readonly boundary: 'end' | 'start';
|
||||
readonly durationMs: number | null;
|
||||
readonly epochMs: number;
|
||||
readonly metadata: PerformancePhaseMetadata | null;
|
||||
readonly phase: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export function pairPerformancePhaseEvents(
|
||||
input: readonly unknown[]
|
||||
): readonly PairedPerformancePhase[] {
|
||||
const grouped = new Map<string, ParsedPerformancePhaseEvent[]>();
|
||||
|
||||
for (const value of input) {
|
||||
const event = parsePerformancePhaseEvent(value);
|
||||
const key = `${event.requestId}\u0000${event.phase}`;
|
||||
const events = grouped.get(key) ?? [];
|
||||
events.push(event);
|
||||
if (events.length > 2) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
grouped.set(key, events);
|
||||
}
|
||||
|
||||
const paired: PairedPerformancePhase[] = [];
|
||||
for (const events of grouped.values()) {
|
||||
const start = events.find((event) => event.boundary === 'start');
|
||||
const end = events.find((event) => event.boundary === 'end');
|
||||
if (
|
||||
events.length !== 2 ||
|
||||
!start ||
|
||||
!end ||
|
||||
start.durationMs !== null ||
|
||||
start.metadata !== null ||
|
||||
end.durationMs === null ||
|
||||
end.epochMs < start.epochMs
|
||||
) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
paired.push(
|
||||
Object.freeze({
|
||||
durationMs: end.durationMs,
|
||||
endedEpochMs: end.epochMs,
|
||||
metadata: end.metadata,
|
||||
phase: start.phase,
|
||||
requestId: start.requestId,
|
||||
startedEpochMs: start.epochMs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
paired.sort(
|
||||
(left, right) =>
|
||||
left.startedEpochMs - right.startedEpochMs ||
|
||||
left.endedEpochMs - right.endedEpochMs ||
|
||||
left.requestId.localeCompare(right.requestId) ||
|
||||
left.phase.localeCompare(right.phase)
|
||||
);
|
||||
return Object.freeze(paired);
|
||||
}
|
||||
|
||||
export function requirePerformancePhase(
|
||||
phases: readonly PairedPerformancePhase[],
|
||||
requestId: string,
|
||||
phase: string
|
||||
): PairedPerformancePhase {
|
||||
const matches = phases.filter(
|
||||
(candidate) =>
|
||||
candidate.requestId === requestId && candidate.phase === phase
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`performance-phase-missing:${requestId}:${phase}`);
|
||||
}
|
||||
if (matches.length !== 1 || !matches[0]) {
|
||||
throw new Error(`performance-phase-ambiguous:${requestId}:${phase}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function parsePerformancePhaseEvent(
|
||||
value: unknown
|
||||
): ParsedPerformancePhaseEvent {
|
||||
if (!isRecord(value)) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
const boundary = value['boundary'];
|
||||
const durationMs = value['durationMs'];
|
||||
const epochMs = value['epochMs'];
|
||||
const phase = value['phase'];
|
||||
const requestId = value['requestId'];
|
||||
if (
|
||||
(boundary !== 'start' && boundary !== 'end') ||
|
||||
(durationMs !== null && !isFiniteNonNegativeNumber(durationMs)) ||
|
||||
!isFiniteNonNegativeNumber(epochMs) ||
|
||||
typeof phase !== 'string' ||
|
||||
phase.length === 0 ||
|
||||
typeof requestId !== 'string' ||
|
||||
requestId.length === 0
|
||||
) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
|
||||
return {
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
metadata: parseMetadata(value['metadata']),
|
||||
phase,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMetadata(value: unknown): PerformancePhaseMetadata | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
keys.some((key) => key !== 'byteCount' && key !== 'itemCount') ||
|
||||
keys.some((key) => !isSafeCount(value[key]))
|
||||
) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(value['byteCount'] === undefined
|
||||
? {}
|
||||
: { byteCount: Number(value['byteCount']) }),
|
||||
...(value['itemCount'] === undefined
|
||||
? {}
|
||||
: { itemCount: Number(value['itemCount']) }),
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isSafeCount(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(Number.isSafeInteger(value) && Number(value) >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
function invalidPhaseEvents(): Error {
|
||||
return new Error('performance-phase-events-invalid');
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
pairRendererPerformancePhaseEvents,
|
||||
requireRendererPerformancePhase,
|
||||
} from './renderer-performance-phase-events';
|
||||
|
||||
describe('renderer performance phase event pairing', () => {
|
||||
it('pairs a successful renderer phase using monotonic duration', () => {
|
||||
const phases = pairRendererPerformancePhaseEvents([
|
||||
{
|
||||
boundary: 'start',
|
||||
epochMs: 100,
|
||||
metadata: { items: 10_000 },
|
||||
monotonicMs: 20,
|
||||
phase: 'store.m3u-publish-channels',
|
||||
phaseId: 'phase-1',
|
||||
schemaVersion: 1,
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
epochMs: 106,
|
||||
metadata: { items: 10_000 },
|
||||
monotonicMs: 25.5,
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-publish-channels',
|
||||
phaseId: 'phase-1',
|
||||
schemaVersion: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(phases, [
|
||||
{
|
||||
durationMs: 5.5,
|
||||
endedEpochMs: 106,
|
||||
metadata: { items: 10_000 },
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-publish-channels',
|
||||
phaseId: 'phase-1',
|
||||
startedEpochMs: 100,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
requireRendererPerformancePhase(
|
||||
phases,
|
||||
'store.m3u-publish-channels'
|
||||
),
|
||||
phases[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for inconsistent identity, metadata, outcome, and clocks', () => {
|
||||
const start = {
|
||||
boundary: 'start',
|
||||
epochMs: 100,
|
||||
metadata: { items: 50_000 },
|
||||
monotonicMs: 20,
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
phaseId: 'phase-1',
|
||||
schemaVersion: 1,
|
||||
} as const;
|
||||
const end = {
|
||||
boundary: 'end',
|
||||
epochMs: 102,
|
||||
metadata: { items: 50_000 },
|
||||
monotonicMs: 22,
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
phaseId: 'phase-1',
|
||||
schemaVersion: 1,
|
||||
} as const;
|
||||
|
||||
for (const invalid of [
|
||||
[start],
|
||||
[start, start, end],
|
||||
[start, { ...end, phase: 'store.m3u-publish-channels' }],
|
||||
[start, { ...end, metadata: { items: 10_000 } }],
|
||||
[{ ...start, outcome: 'success' }, end],
|
||||
[start, { ...end, outcome: undefined }],
|
||||
[start, { ...end, epochMs: 99 }],
|
||||
[start, { ...end, monotonicMs: 19 }],
|
||||
[start, { ...end, metadata: { items: -1 } }],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => pairRendererPerformancePhaseEvents(invalid),
|
||||
/renderer-performance-phase-events-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires exactly one successful event for a named phase', () => {
|
||||
const successful = {
|
||||
durationMs: 2,
|
||||
endedEpochMs: 102,
|
||||
metadata: { items: 10_000 },
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
phaseId: 'phase-1',
|
||||
startedEpochMs: 100,
|
||||
} as const;
|
||||
const failed = {
|
||||
...successful,
|
||||
outcome: 'error',
|
||||
phaseId: 'phase-2',
|
||||
} as const;
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
requireRendererPerformancePhase(
|
||||
[successful],
|
||||
'store.m3u-publish-channels'
|
||||
),
|
||||
/renderer-performance-phase-missing:store.m3u-publish-channels/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
requireRendererPerformancePhase(
|
||||
[successful, successful],
|
||||
'store.m3u-import-dispatch'
|
||||
),
|
||||
/renderer-performance-phase-ambiguous:store.m3u-import-dispatch/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
requireRendererPerformancePhase(
|
||||
[failed],
|
||||
'store.m3u-import-dispatch'
|
||||
),
|
||||
/renderer-performance-phase-failed:store.m3u-import-dispatch/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
export interface PairedRendererPerformancePhase {
|
||||
readonly durationMs: number;
|
||||
readonly endedEpochMs: number;
|
||||
readonly metadata: Readonly<{ items: number }> | null;
|
||||
readonly outcome: 'error' | 'success';
|
||||
readonly phase: string;
|
||||
readonly phaseId: string;
|
||||
readonly startedEpochMs: number;
|
||||
}
|
||||
|
||||
interface ParsedRendererPerformancePhaseEvent {
|
||||
readonly boundary: 'end' | 'start';
|
||||
readonly epochMs: number;
|
||||
readonly metadata: Readonly<{ items: number }> | null;
|
||||
readonly monotonicMs: number;
|
||||
readonly outcome: 'error' | 'success' | null;
|
||||
readonly phase: string;
|
||||
readonly phaseId: string;
|
||||
}
|
||||
|
||||
export function pairRendererPerformancePhaseEvents(
|
||||
input: readonly unknown[]
|
||||
): readonly PairedRendererPerformancePhase[] {
|
||||
const grouped = new Map<string, ParsedRendererPerformancePhaseEvent[]>();
|
||||
|
||||
for (const value of input) {
|
||||
const event = parseRendererPerformancePhaseEvent(value);
|
||||
const events = grouped.get(event.phaseId) ?? [];
|
||||
events.push(event);
|
||||
if (events.length > 2) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
grouped.set(event.phaseId, events);
|
||||
}
|
||||
|
||||
const paired: PairedRendererPerformancePhase[] = [];
|
||||
for (const events of grouped.values()) {
|
||||
const start = events.find((event) => event.boundary === 'start');
|
||||
const end = events.find((event) => event.boundary === 'end');
|
||||
if (
|
||||
events.length !== 2 ||
|
||||
!start ||
|
||||
!end ||
|
||||
start.outcome !== null ||
|
||||
end.outcome === null ||
|
||||
start.phase !== end.phase ||
|
||||
!sameMetadata(start.metadata, end.metadata) ||
|
||||
end.epochMs < start.epochMs ||
|
||||
end.monotonicMs < start.monotonicMs
|
||||
) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
paired.push(
|
||||
Object.freeze({
|
||||
durationMs: end.monotonicMs - start.monotonicMs,
|
||||
endedEpochMs: end.epochMs,
|
||||
metadata: end.metadata,
|
||||
outcome: end.outcome,
|
||||
phase: end.phase,
|
||||
phaseId: end.phaseId,
|
||||
startedEpochMs: start.epochMs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
paired.sort(
|
||||
(left, right) =>
|
||||
left.startedEpochMs - right.startedEpochMs ||
|
||||
left.endedEpochMs - right.endedEpochMs ||
|
||||
left.phaseId.localeCompare(right.phaseId)
|
||||
);
|
||||
return Object.freeze(paired);
|
||||
}
|
||||
|
||||
export function requireRendererPerformancePhase(
|
||||
phases: readonly PairedRendererPerformancePhase[],
|
||||
phase: string
|
||||
): PairedRendererPerformancePhase {
|
||||
const matches = phases.filter((candidate) => candidate.phase === phase);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`renderer-performance-phase-missing:${phase}`);
|
||||
}
|
||||
if (matches.length !== 1 || !matches[0]) {
|
||||
throw new Error(`renderer-performance-phase-ambiguous:${phase}`);
|
||||
}
|
||||
if (matches[0].outcome !== 'success') {
|
||||
throw new Error(`renderer-performance-phase-failed:${phase}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function parseRendererPerformancePhaseEvent(
|
||||
value: unknown
|
||||
): ParsedRendererPerformancePhaseEvent {
|
||||
if (!isRecord(value) || value['schemaVersion'] !== 1) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
const boundary = value['boundary'];
|
||||
const epochMs = value['epochMs'];
|
||||
const monotonicMs = value['monotonicMs'];
|
||||
const outcome = value['outcome'];
|
||||
const phase = value['phase'];
|
||||
const phaseId = value['phaseId'];
|
||||
if (
|
||||
(boundary !== 'start' && boundary !== 'end') ||
|
||||
!isFiniteNonNegativeNumber(epochMs) ||
|
||||
!isFiniteNonNegativeNumber(monotonicMs) ||
|
||||
typeof phase !== 'string' ||
|
||||
phase.length === 0 ||
|
||||
typeof phaseId !== 'string' ||
|
||||
phaseId.length === 0 ||
|
||||
(boundary === 'start' && outcome !== undefined) ||
|
||||
(boundary === 'end' && outcome !== 'success' && outcome !== 'error')
|
||||
) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
|
||||
return {
|
||||
boundary,
|
||||
epochMs,
|
||||
metadata: parseMetadata(value['metadata']),
|
||||
monotonicMs,
|
||||
outcome: outcome === 'success' || outcome === 'error' ? outcome : null,
|
||||
phase,
|
||||
phaseId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMetadata(value: unknown): Readonly<{ items: number }> | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
Object.keys(value).length !== 1 ||
|
||||
!Number.isSafeInteger(value['items']) ||
|
||||
Number(value['items']) < 0
|
||||
) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
return Object.freeze({ items: Number(value['items']) });
|
||||
}
|
||||
|
||||
function sameMetadata(
|
||||
left: Readonly<{ items: number }> | null,
|
||||
right: Readonly<{ items: number }> | null
|
||||
): boolean {
|
||||
return left === null || right === null
|
||||
? left === right
|
||||
: left.items === right.items;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function invalidRendererEvents(): Error {
|
||||
return new Error('renderer-performance-phase-events-invalid');
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
interface TestIteration {
|
||||
readonly kind: 'diagnostic' | 'measured' | 'warmup';
|
||||
readonly main: {
|
||||
readonly rendererWindow: {
|
||||
readonly rss: {
|
||||
readonly identity: {
|
||||
readonly creationTime: number;
|
||||
readonly pid: number;
|
||||
} | null;
|
||||
readonly missingSampleCount: number;
|
||||
readonly peakRssBytes: number | null;
|
||||
readonly unavailableReason: string | null;
|
||||
readonly validSampleCount: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
interface RendererRssValidity {
|
||||
readonly invalidMeasuredRuns: readonly {
|
||||
readonly missingSampleCount: number;
|
||||
readonly reason: string;
|
||||
readonly runId: string;
|
||||
readonly validSampleCount: number;
|
||||
}[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForBenchmark: boolean;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
interface RendererRssValidityModule {
|
||||
assessRendererRssValidity?: (
|
||||
iterations: readonly TestIteration[]
|
||||
) => RendererRssValidity;
|
||||
}
|
||||
|
||||
const validityModulePromise = import(
|
||||
new URL('./renderer-rss-validity.ts', import.meta.url).href
|
||||
)
|
||||
.then((module) => module as RendererRssValidityModule)
|
||||
.catch(() => null);
|
||||
|
||||
function iteration(
|
||||
runId: string,
|
||||
kind: TestIteration['kind'],
|
||||
input: Partial<TestIteration['main']['rendererWindow']['rss']> = {}
|
||||
): TestIteration {
|
||||
return {
|
||||
kind,
|
||||
main: {
|
||||
rendererWindow: {
|
||||
rss: {
|
||||
identity: {
|
||||
creationTime: 1_721_234_567_890,
|
||||
pid: 42,
|
||||
},
|
||||
missingSampleCount: 0,
|
||||
peakRssBytes: 4_096,
|
||||
unavailableReason: null,
|
||||
validSampleCount: 3,
|
||||
...input,
|
||||
},
|
||||
},
|
||||
},
|
||||
runId,
|
||||
};
|
||||
}
|
||||
|
||||
test('validates only measured renderer RSS runs and preserves the exact summary shape', async () => {
|
||||
const module = await validityModulePromise;
|
||||
assert.ok(module, 'generic renderer RSS validity helper must exist');
|
||||
const assess = module.assessRendererRssValidity;
|
||||
assert.equal(typeof assess, 'function');
|
||||
|
||||
const result = assess([
|
||||
iteration('warmup-invalid', 'warmup', { identity: null }),
|
||||
iteration('run-01', 'measured'),
|
||||
iteration('diagnostic-invalid', 'diagnostic', {
|
||||
unavailableReason: 'renderer-process-gone',
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
invalidMeasuredRuns: [],
|
||||
measuredRunCount: 1,
|
||||
validForBenchmark: true,
|
||||
validForComparison: true,
|
||||
validMeasuredRunCount: 1,
|
||||
});
|
||||
assert.ok(Object.isFrozen(result));
|
||||
assert.ok(Object.isFrozen(result.invalidMeasuredRuns));
|
||||
});
|
||||
|
||||
test('fails every incomplete measured capture without losing its raw reason and counts', async () => {
|
||||
const module = await validityModulePromise;
|
||||
assert.ok(module);
|
||||
const assess = module.assessRendererRssValidity;
|
||||
assert.equal(typeof assess, 'function');
|
||||
|
||||
const result = assess([
|
||||
iteration('run-no-identity', 'measured', { identity: null }),
|
||||
iteration('run-missing-sample', 'measured', {
|
||||
missingSampleCount: 1,
|
||||
peakRssBytes: null,
|
||||
unavailableReason: 'renderer-process-metric-missing-during-capture',
|
||||
validSampleCount: 2,
|
||||
}),
|
||||
iteration('run-zero-peak', 'measured', { peakRssBytes: 0 }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
invalidMeasuredRuns: [
|
||||
{
|
||||
missingSampleCount: 0,
|
||||
reason: 'renderer-rss-capture-invalid',
|
||||
runId: 'run-no-identity',
|
||||
validSampleCount: 3,
|
||||
},
|
||||
{
|
||||
missingSampleCount: 1,
|
||||
reason: 'renderer-process-metric-missing-during-capture',
|
||||
runId: 'run-missing-sample',
|
||||
validSampleCount: 2,
|
||||
},
|
||||
{
|
||||
missingSampleCount: 0,
|
||||
reason: 'renderer-rss-capture-invalid',
|
||||
runId: 'run-zero-peak',
|
||||
validSampleCount: 3,
|
||||
},
|
||||
],
|
||||
measuredRunCount: 3,
|
||||
validForBenchmark: false,
|
||||
validForComparison: false,
|
||||
validMeasuredRunCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('requires at least one measured run', async () => {
|
||||
const module = await validityModulePromise;
|
||||
assert.ok(module);
|
||||
const assess = module.assessRendererRssValidity;
|
||||
assert.equal(typeof assess, 'function');
|
||||
|
||||
assert.deepEqual(assess([iteration('warmup-01', 'warmup')]), {
|
||||
invalidMeasuredRuns: [],
|
||||
measuredRunCount: 0,
|
||||
validForBenchmark: false,
|
||||
validForComparison: false,
|
||||
validMeasuredRunCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('the cancellation report delegates renderer RSS validity to the generic helper', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-cancellation-report.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/import \{ assessRendererRssValidity \} from '\.\/renderer-rss-validity';/
|
||||
);
|
||||
assert.doesNotMatch(source, /function assessRendererRssValidity\(/);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { RendererProcessRssCapture } from './renderer-process-rss-capture';
|
||||
|
||||
export interface RendererRssValidityIteration {
|
||||
readonly kind: string;
|
||||
readonly main: {
|
||||
readonly rendererWindow: {
|
||||
readonly rss: RendererProcessRssCapture;
|
||||
};
|
||||
};
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export interface InvalidRendererRssMeasuredRun {
|
||||
readonly missingSampleCount: number;
|
||||
readonly reason:
|
||||
| Exclude<RendererProcessRssCapture['unavailableReason'], null>
|
||||
| 'renderer-rss-capture-invalid';
|
||||
readonly runId: string;
|
||||
readonly validSampleCount: number;
|
||||
}
|
||||
|
||||
export interface RendererRssValidity {
|
||||
readonly invalidMeasuredRuns: readonly InvalidRendererRssMeasuredRun[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForBenchmark: boolean;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
export function assessRendererRssValidity(
|
||||
iterations: readonly RendererRssValidityIteration[]
|
||||
): RendererRssValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === 'measured'
|
||||
);
|
||||
const invalidMeasuredRuns: RendererRssValidity['invalidMeasuredRuns'][number][] =
|
||||
[];
|
||||
let validMeasuredRunCount = 0;
|
||||
|
||||
for (const iteration of measured) {
|
||||
const rss = iteration.main.rendererWindow.rss;
|
||||
if (
|
||||
rss.identity !== null &&
|
||||
Number.isSafeInteger(rss.peakRssBytes) &&
|
||||
Number(rss.peakRssBytes) > 0 &&
|
||||
rss.unavailableReason === null &&
|
||||
rss.missingSampleCount === 0 &&
|
||||
Number.isSafeInteger(rss.validSampleCount) &&
|
||||
rss.validSampleCount > 0
|
||||
) {
|
||||
validMeasuredRunCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
invalidMeasuredRuns.push({
|
||||
missingSampleCount: rss.missingSampleCount,
|
||||
reason: rss.unavailableReason ?? 'renderer-rss-capture-invalid',
|
||||
runId: iteration.runId,
|
||||
validSampleCount: rss.validSampleCount,
|
||||
});
|
||||
}
|
||||
|
||||
const valid =
|
||||
measured.length > 0 && validMeasuredRunCount === measured.length;
|
||||
return Object.freeze({
|
||||
invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns),
|
||||
measuredRunCount: measured.length,
|
||||
validForBenchmark: valid,
|
||||
validForComparison: valid,
|
||||
validMeasuredRunCount,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable playwright/expect-expect -- Node assertions define the loopback-only fixture server contract. */
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createSyntheticM3uFixture } from './synthetic-m3u';
|
||||
import { startSyntheticM3uServer } from './synthetic-m3u-server';
|
||||
|
||||
test('serves and primes only the deterministic fixture on ephemeral IPv4 loopback', async () => {
|
||||
const fixture = createSyntheticM3uFixture(10_000);
|
||||
const server = await startSyntheticM3uServer(fixture);
|
||||
|
||||
try {
|
||||
const url = new URL(server.resourceUrl);
|
||||
assert.equal(url.protocol, 'http:');
|
||||
assert.equal(url.hostname, '127.0.0.1');
|
||||
assert.notEqual(url.port, '');
|
||||
assert.equal(url.username, '');
|
||||
assert.equal(url.password, '');
|
||||
assert.equal(server.requestCount(), 0);
|
||||
|
||||
await server.prime();
|
||||
assert.equal(server.requestCount(), 1);
|
||||
|
||||
const response = await fetch(server.resourceUrl);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
response.headers.get('content-length'),
|
||||
String(fixture.bytes)
|
||||
);
|
||||
assert.equal(await response.text(), fixture.body);
|
||||
assert.equal(server.requestCount(), 2);
|
||||
|
||||
const missing = await fetch(`${server.origin}/not-the-fixture.m3u`);
|
||||
assert.equal(missing.status, 404);
|
||||
assert.equal(server.requestCount(), 2);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('closes idempotently and rejects priming after close', async () => {
|
||||
const server = await startSyntheticM3uServer(
|
||||
createSyntheticM3uFixture(10_000)
|
||||
);
|
||||
|
||||
await server.close();
|
||||
await server.close();
|
||||
await assert.rejects(server.prime(), /closed/i);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { once } from 'node:events';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
|
||||
import type { SyntheticM3uFixture } from './synthetic-m3u';
|
||||
|
||||
export const SYNTHETIC_M3U_RESOURCE_PATH = '/synthetic-performance.m3u';
|
||||
|
||||
export interface SyntheticM3uServer {
|
||||
readonly origin: string;
|
||||
readonly resourceUrl: string;
|
||||
close(): Promise<void>;
|
||||
prime(): Promise<void>;
|
||||
requestCount(): number;
|
||||
}
|
||||
|
||||
export async function startSyntheticM3uServer(
|
||||
fixture: SyntheticM3uFixture
|
||||
): Promise<SyntheticM3uServer> {
|
||||
let successfulRequestCount = 0;
|
||||
const server = createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
|
||||
if (
|
||||
request.method !== 'GET' ||
|
||||
requestUrl.pathname !== SYNTHETIC_M3U_RESOURCE_PATH ||
|
||||
requestUrl.search.length > 0
|
||||
) {
|
||||
response.writeHead(404, {
|
||||
'Content-Length': '0',
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
successfulRequestCount += 1;
|
||||
response.writeHead(200, {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': String(fixture.bytes),
|
||||
'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8',
|
||||
});
|
||||
response.end(fixture.body);
|
||||
});
|
||||
server.listen({
|
||||
exclusive: true,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
});
|
||||
try {
|
||||
await once(server, 'listening');
|
||||
} catch (error: unknown) {
|
||||
await closeServer(server).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const address = server.address();
|
||||
if (
|
||||
address === null ||
|
||||
typeof address === 'string' ||
|
||||
address.address !== '127.0.0.1' ||
|
||||
!Number.isSafeInteger(address.port) ||
|
||||
address.port <= 0
|
||||
) {
|
||||
await closeServer(server);
|
||||
throw new Error(
|
||||
'Synthetic M3U server did not bind exact IPv4 loopback'
|
||||
);
|
||||
}
|
||||
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
const resourceUrl = `${origin}${SYNTHETIC_M3U_RESOURCE_PATH}`;
|
||||
let closePromise: Promise<void> | null = null;
|
||||
|
||||
return Object.freeze({
|
||||
origin,
|
||||
resourceUrl,
|
||||
close: () => {
|
||||
closePromise ??= closeServer(server);
|
||||
return closePromise;
|
||||
},
|
||||
prime: async () => {
|
||||
if (closePromise !== null) {
|
||||
throw new Error('Synthetic M3U server is closed');
|
||||
}
|
||||
const response = await fetch(resourceUrl);
|
||||
const body = await response.arrayBuffer();
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
Number(response.headers.get('content-length')) !==
|
||||
fixture.bytes ||
|
||||
body.byteLength !== fixture.bytes
|
||||
) {
|
||||
throw new Error(
|
||||
'Synthetic M3U server priming response was invalid'
|
||||
);
|
||||
}
|
||||
},
|
||||
requestCount: () => successfulRequestCount,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
rejectPromise(error);
|
||||
return;
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
createSyntheticM3uFixture,
|
||||
SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS,
|
||||
SYNTHETIC_M3U_SEED,
|
||||
} from './synthetic-m3u';
|
||||
|
||||
const GOLDEN_SHA256 = {
|
||||
10_000: '1d151ab2a878db8818f2bbfbdd689007cbe390182cdd1520c316bd163b7e7db3',
|
||||
50_000: '4a5336a5d1c66b8b706752784815617a012499cb7a231ab35c88df9bd55ee78a',
|
||||
100_000: '2ffe912f61ea106ba8de3414b0a927cdaa3264ac637d44c4b219f199eb94c353',
|
||||
} as const;
|
||||
|
||||
describe('synthetic M3U performance fixture', () => {
|
||||
it('generates each supported catalog deterministically with exact metadata', () => {
|
||||
assert.deepEqual(
|
||||
SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS,
|
||||
[10_000, 50_000, 100_000]
|
||||
);
|
||||
assert.equal(SYNTHETIC_M3U_SEED, 240_724);
|
||||
|
||||
for (const channelCount of SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS) {
|
||||
const fixture = createSyntheticM3uFixture(channelCount);
|
||||
const lines = fixture.body.trimEnd().split('\n');
|
||||
|
||||
assert.equal(fixture.channelCount, channelCount);
|
||||
assert.equal(
|
||||
fixture.bytes,
|
||||
Buffer.byteLength(fixture.body, 'utf8')
|
||||
);
|
||||
assert.equal(
|
||||
fixture.sha256,
|
||||
createHash('sha256').update(fixture.body, 'utf8').digest('hex')
|
||||
);
|
||||
assert.equal(fixture.sha256, GOLDEN_SHA256[channelCount]);
|
||||
assert.equal(lines[0], '#EXTM3U');
|
||||
assert.equal(lines.length, 1 + channelCount * 2);
|
||||
assert.equal(
|
||||
lines.filter((line) => line.startsWith('#EXTINF:')).length,
|
||||
channelCount
|
||||
);
|
||||
assert.doesNotMatch(fixture.body, /\b(?:tvg-|logo|provider)\b/i);
|
||||
assertOnlyLoopbackUrls(fixture.body);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsupported channel counts', () => {
|
||||
assert.throws(
|
||||
() => createSyntheticM3uFixture(9_999),
|
||||
/Unsupported synthetic M3U channel count/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function assertOnlyLoopbackUrls(body: string): void {
|
||||
const urls = body.match(/https?:\/\/[^\s"]+/g) ?? [];
|
||||
|
||||
for (const rawUrl of urls) {
|
||||
const url = new URL(rawUrl);
|
||||
assert.equal(url.protocol, 'http:');
|
||||
assert.equal(url.hostname, '127.0.0.1');
|
||||
assert.equal(url.username, '');
|
||||
assert.equal(url.password, '');
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,30 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
export const SYNTHETIC_M3U_SEED = 240_724;
|
||||
export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000;
|
||||
export const SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS = [
|
||||
10_000,
|
||||
50_000,
|
||||
SYNTHETIC_M3U_CHANNEL_COUNT,
|
||||
] as const;
|
||||
|
||||
export type SyntheticM3uChannelCount =
|
||||
(typeof SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS)[number];
|
||||
|
||||
export interface SyntheticM3uFixture {
|
||||
readonly body: string;
|
||||
readonly bytes: number;
|
||||
readonly channelCount: number;
|
||||
readonly channelCount: SyntheticM3uChannelCount;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export function createSyntheticM3uFixture(): SyntheticM3uFixture {
|
||||
const lines = new Array<string>(1 + SYNTHETIC_M3U_CHANNEL_COUNT * 2);
|
||||
export function createSyntheticM3uFixture(
|
||||
channelCount: number = SYNTHETIC_M3U_CHANNEL_COUNT
|
||||
): SyntheticM3uFixture {
|
||||
assertSupportedChannelCount(channelCount);
|
||||
const lines = new Array<string>(1 + channelCount * 2);
|
||||
lines[0] = '#EXTM3U';
|
||||
|
||||
for (let offset = 0; offset < SYNTHETIC_M3U_CHANNEL_COUNT; offset += 1) {
|
||||
for (let offset = 0; offset < channelCount; offset += 1) {
|
||||
const index = offset + 1;
|
||||
const group = (offset % 100) + 1;
|
||||
const lineOffset = 1 + offset * 2;
|
||||
@@ -34,7 +45,21 @@ export function createSyntheticM3uFixture(): SyntheticM3uFixture {
|
||||
return Object.freeze({
|
||||
body,
|
||||
bytes: Buffer.byteLength(body, 'utf8'),
|
||||
channelCount: SYNTHETIC_M3U_CHANNEL_COUNT,
|
||||
channelCount,
|
||||
sha256: createHash('sha256').update(body, 'utf8').digest('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
function assertSupportedChannelCount(
|
||||
channelCount: number
|
||||
): asserts channelCount is SyntheticM3uChannelCount {
|
||||
if (
|
||||
!SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS.some(
|
||||
(supported) => supported === channelCount
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Unsupported synthetic M3U channel count: ${channelCount}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
|
||||
type AppPlaylistPerformancePhase,
|
||||
type PerformancePhaseEvent,
|
||||
type PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const PHASE_SEQUENCES = {
|
||||
DB_GET_APP_PLAYLIST: [
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST,
|
||||
],
|
||||
DB_UPSERT_APP_PLAYLIST: [
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
|
||||
],
|
||||
} as const satisfies Readonly<
|
||||
Record<string, readonly AppPlaylistPerformancePhase[]>
|
||||
>;
|
||||
const EVENT_KEYS = new Set([
|
||||
'boundary',
|
||||
'durationMs',
|
||||
'epochMs',
|
||||
'metadata',
|
||||
'phase',
|
||||
'requestId',
|
||||
]);
|
||||
|
||||
export function parseWorkerPerformancePhaseEvents(
|
||||
operation: string | null,
|
||||
input: unknown,
|
||||
workStartedEpochMs: number,
|
||||
workEndedEpochMs: number
|
||||
): readonly PerformancePhaseEvent<AppPlaylistPerformancePhase>[] | null {
|
||||
const phaseSequence = getPhaseSequence(operation);
|
||||
if (phaseSequence.length === 0) {
|
||||
return input === undefined ||
|
||||
(Array.isArray(input) && input.length === 0)
|
||||
? []
|
||||
: null;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(input) ||
|
||||
input.length !== phaseSequence.length * 2
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed: PerformancePhaseEvent<AppPlaylistPerformancePhase>[] =
|
||||
[];
|
||||
let lastEpochMs = workStartedEpochMs;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const raw = input[index];
|
||||
if (
|
||||
!isRecord(raw) ||
|
||||
Object.keys(raw).some((key) => !EVENT_KEYS.has(key))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const boundary = raw['boundary'];
|
||||
const durationMs = raw['durationMs'];
|
||||
const epochMs = raw['epochMs'];
|
||||
const metadata = parseMetadata(raw['metadata']);
|
||||
const phase = raw['phase'];
|
||||
const requestId = raw['requestId'];
|
||||
if (
|
||||
boundary !== (index % 2 === 0 ? 'start' : 'end') ||
|
||||
phase !== phaseSequence[Math.floor(index / 2)] ||
|
||||
typeof requestId !== 'string' ||
|
||||
requestId.length === 0 ||
|
||||
!isFiniteNonNegativeNumber(epochMs) ||
|
||||
epochMs < lastEpochMs ||
|
||||
epochMs > workEndedEpochMs ||
|
||||
(boundary === 'start' &&
|
||||
(durationMs !== null || metadata !== undefined)) ||
|
||||
(boundary === 'end' && !isFiniteNonNegativeNumber(durationMs)) ||
|
||||
metadata === null ||
|
||||
(parsed.length > 0 && parsed[0]?.requestId !== requestId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
boundary: boundary as 'end' | 'start',
|
||||
durationMs: durationMs as number | null,
|
||||
epochMs,
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
phase: phase as AppPlaylistPerformancePhase,
|
||||
requestId,
|
||||
});
|
||||
lastEpochMs = epochMs;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getPhaseSequence(
|
||||
operation: string | null
|
||||
): readonly AppPlaylistPerformancePhase[] {
|
||||
if (operation === 'DB_GET_APP_PLAYLIST') {
|
||||
return PHASE_SEQUENCES.DB_GET_APP_PLAYLIST;
|
||||
}
|
||||
if (operation === 'DB_UPSERT_APP_PLAYLIST') {
|
||||
return PHASE_SEQUENCES.DB_UPSERT_APP_PLAYLIST;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseMetadata(
|
||||
input: unknown
|
||||
): PerformancePhaseMetadata | undefined | null {
|
||||
if (input === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!isRecord(input) ||
|
||||
Object.keys(input).some(
|
||||
(key) => key !== 'byteCount' && key !== 'itemCount'
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const byteCount = input['byteCount'];
|
||||
const itemCount = input['itemCount'];
|
||||
if (
|
||||
(byteCount !== undefined && !isSafeCount(byteCount)) ||
|
||||
(itemCount !== undefined && !isSafeCount(itemCount))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(byteCount === undefined ? {} : { byteCount: Number(byteCount) }),
|
||||
...(itemCount === undefined ? {} : { itemCount: Number(itemCount) }),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === 'object' && input !== null && !Array.isArray(input);
|
||||
}
|
||||
|
||||
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isSafeCount(value: unknown): boolean {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
+157
-2
@@ -13,6 +13,7 @@ const validCapture = {
|
||||
histogramFlushedEpochMs: 1_050,
|
||||
invalidReason: null,
|
||||
requestReceivedEpochMs: 1_000,
|
||||
responsePostedEpochMs: 1_055,
|
||||
threadCpuSystemMicros: 50,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: 100,
|
||||
@@ -20,19 +21,28 @@ const validCapture = {
|
||||
workStartedEpochMs: 1_010,
|
||||
} as const;
|
||||
|
||||
function normalize(performanceCapture: unknown) {
|
||||
function normalizeForOperation(
|
||||
operation: string,
|
||||
performanceCapture: unknown
|
||||
) {
|
||||
return normalizeWorkerRequestPerformanceOutcome({
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
ipcCallId: 2,
|
||||
operation,
|
||||
operationId: 'operation-42',
|
||||
operationIdUnavailableReason: null,
|
||||
performanceCapture,
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'request-1',
|
||||
responseEpochMs: 1_060,
|
||||
sourceEpochMs: 990,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
function normalize(performanceCapture: unknown) {
|
||||
return normalizeForOperation('DB_GET_CONTENT', performanceCapture);
|
||||
}
|
||||
|
||||
function assertClosedCapture(
|
||||
capture: WorkerRequestPerformanceMetrics,
|
||||
reason: string
|
||||
@@ -47,6 +57,7 @@ function assertClosedCapture(
|
||||
capture.histogramFlushedEpochMs,
|
||||
capture.invalidReason,
|
||||
capture.requestReceivedEpochMs,
|
||||
capture.responsePostedEpochMs,
|
||||
capture.threadCpuSystemMicros,
|
||||
capture.threadCpuUnavailableReason,
|
||||
capture.threadCpuUserMicros,
|
||||
@@ -55,6 +66,7 @@ function assertClosedCapture(
|
||||
]) {
|
||||
assert.equal(value, null);
|
||||
}
|
||||
assert.deepEqual(capture.phaseEvents, []);
|
||||
}
|
||||
|
||||
test('absent captures are missing while present non-record captures are invalid', () => {
|
||||
@@ -118,6 +130,9 @@ test('malformed nested fields and cross-field invariants fail the whole capture
|
||||
histogramFlushedEpochMs: null,
|
||||
},
|
||||
{ ...validCapture, histogramFlushedEpochMs: 1_039 },
|
||||
{ ...validCapture, responsePostedEpochMs: undefined },
|
||||
{ ...validCapture, responsePostedEpochMs: 1_039 },
|
||||
{ ...validCapture, responsePostedEpochMs: 1_061 },
|
||||
{ ...validCapture, workStartedEpochMs: 999 },
|
||||
{
|
||||
...validCapture,
|
||||
@@ -170,3 +185,143 @@ test('coherent metric-unavailable and invalid worker results remain valid raw ca
|
||||
assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null);
|
||||
assert.equal(legitimateInvalid.invalidReason, invalidReason);
|
||||
});
|
||||
|
||||
test('requires the exact operation-specific worker phase sequence', () => {
|
||||
const upsertPhaseEvents = [
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_015,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 5,
|
||||
epochMs: 1_020,
|
||||
metadata: { byteCount: 2048, itemCount: 10_000 },
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_021,
|
||||
phase: 'sqlite.write',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 12,
|
||||
epochMs: 1_033,
|
||||
metadata: { byteCount: 2048, itemCount: 1 },
|
||||
phase: 'sqlite.write',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
] as const;
|
||||
const getPhaseEvents = [
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_015,
|
||||
phase: 'sqlite.read',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 5,
|
||||
epochMs: 1_020,
|
||||
metadata: { itemCount: 1 },
|
||||
phase: 'sqlite.read',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_021,
|
||||
phase: 'deserialize.playlist',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 12,
|
||||
epochMs: 1_033,
|
||||
metadata: { itemCount: 10_000 },
|
||||
phase: 'deserialize.playlist',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
] as const;
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeForOperation('DB_UPSERT_APP_PLAYLIST', {
|
||||
...validCapture,
|
||||
phaseEvents: upsertPhaseEvents,
|
||||
}).phaseEvents,
|
||||
upsertPhaseEvents
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeForOperation('DB_GET_APP_PLAYLIST', {
|
||||
...validCapture,
|
||||
phaseEvents: getPhaseEvents,
|
||||
}).phaseEvents,
|
||||
getPhaseEvents
|
||||
);
|
||||
|
||||
for (const invalidPhaseEvents of [
|
||||
getPhaseEvents.slice(0, 2),
|
||||
getPhaseEvents.slice(1),
|
||||
[...getPhaseEvents].reverse(),
|
||||
getPhaseEvents.map((event) => ({
|
||||
...event,
|
||||
requestId: 'other-request',
|
||||
})),
|
||||
[
|
||||
getPhaseEvents[0],
|
||||
{
|
||||
...getPhaseEvents[1],
|
||||
metadata: { itemCount: 1, secret: 'not allowed' },
|
||||
},
|
||||
],
|
||||
[
|
||||
getPhaseEvents[0],
|
||||
{
|
||||
...getPhaseEvents[1],
|
||||
phase: 'arbitrary.phase',
|
||||
},
|
||||
],
|
||||
]) {
|
||||
assertClosedCapture(
|
||||
normalizeForOperation('DB_GET_APP_PLAYLIST', {
|
||||
...validCapture,
|
||||
phaseEvents: invalidPhaseEvents,
|
||||
}),
|
||||
'worker-performance-capture-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
for (const [operation, phaseEvents] of [
|
||||
['DB_GET_APP_PLAYLIST', upsertPhaseEvents],
|
||||
['DB_UPSERT_APP_PLAYLIST', getPhaseEvents],
|
||||
['DB_GET_CONTENT', getPhaseEvents],
|
||||
] as const) {
|
||||
assertClosedCapture(
|
||||
normalizeForOperation(operation, {
|
||||
...validCapture,
|
||||
phaseEvents,
|
||||
}),
|
||||
'worker-performance-capture-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
for (const operation of [
|
||||
'DB_GET_APP_PLAYLIST',
|
||||
'DB_UPSERT_APP_PLAYLIST',
|
||||
]) {
|
||||
assertClosedCapture(
|
||||
normalizeForOperation(operation, validCapture),
|
||||
'worker-performance-capture-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
assert.deepEqual(normalize(validCapture).phaseEvents, []);
|
||||
});
|
||||
@@ -35,9 +35,10 @@ function requestPerformance(
|
||||
eventLoopDelayUnavailableReason: null,
|
||||
eventLoopUtilization: p95Ms / 100,
|
||||
eventLoopUtilizationUnavailableReason: null,
|
||||
histogramFlushedEpochMs: 1_050 + p95Ms,
|
||||
histogramFlushedEpochMs: 1_050,
|
||||
invalidReason: null,
|
||||
requestReceivedEpochMs: 1_000,
|
||||
responsePostedEpochMs: 1_055,
|
||||
threadCpuSystemMicros: threadCpuUserMicros / 2,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros,
|
||||
@@ -53,13 +54,15 @@ function requestTransport(
|
||||
requestId = 'request-1'
|
||||
) {
|
||||
return {
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_GET_CONTENT',
|
||||
operationId: 'operation-42',
|
||||
operationIdUnavailableReason: null,
|
||||
performanceCapture,
|
||||
playlistId: 'playlist-1',
|
||||
requestId,
|
||||
responseEpochMs: 1_060,
|
||||
sourceEpochMs: 990,
|
||||
success: true,
|
||||
} as const;
|
||||
}
|
||||
@@ -77,6 +80,7 @@ function assertClosedCapture(
|
||||
capture.histogramFlushedEpochMs,
|
||||
capture.invalidReason,
|
||||
capture.requestReceivedEpochMs,
|
||||
capture.responsePostedEpochMs,
|
||||
capture.threadCpuSystemMicros,
|
||||
capture.threadCpuUnavailableReason,
|
||||
capture.threadCpuUserMicros,
|
||||
@@ -220,8 +224,10 @@ test('main capture retains raw request identity, timestamps, metrics, and reason
|
||||
assert.match(source, /requestPerformance: \[\]/);
|
||||
assert.match(source, /record\.requestPerformance\.map/);
|
||||
assert.match(source, /operationId: request\.identity\.operationId/);
|
||||
assert.match(source, /ipcCallId: request\.identity\.ipcCallId/);
|
||||
assert.match(source, /identity: request\.identity/);
|
||||
assert.match(source, /performanceCapture:\s+message\['performance'\]/);
|
||||
assert.match(source, /sourceEpochMs: request\.identity\.sourceEpochMs/);
|
||||
assert.match(source, /captureGeneration: state\.captureGeneration/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
@@ -229,6 +235,13 @@ test('main capture retains raw request identity, timestamps, metrics, and reason
|
||||
);
|
||||
});
|
||||
|
||||
test('normalized raw request metrics retain the exact preload IPC call identity', () => {
|
||||
const capture = requestPerformance('request-1', 5, 100);
|
||||
|
||||
assert.equal(capture.ipcCallId, 7);
|
||||
assert.equal(capture.sourceEpochMs, 990);
|
||||
});
|
||||
|
||||
test('benchmark resolves one exact BrowserWindow and main capture never scans all windows', () => {
|
||||
const benchmarkSource = readFileSync(
|
||||
new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url),
|
||||
|
||||
@@ -6,8 +6,10 @@ import type {
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import { WORKER_POST_GC_HEAP_UNAVAILABLE_REASON } from './m3u-refresh-cancellation-contract';
|
||||
import { parseWorkerPerformancePhaseEvents } from './worker-performance-phase-events';
|
||||
|
||||
export interface WorkerRequestPerformanceOutcomeTransport {
|
||||
readonly ipcCallId: number | null;
|
||||
readonly operation: string | null;
|
||||
readonly operationId: string | null;
|
||||
readonly operationIdUnavailableReason: string | null;
|
||||
@@ -15,6 +17,7 @@ export interface WorkerRequestPerformanceOutcomeTransport {
|
||||
readonly playlistId: string | null;
|
||||
readonly requestId: string | null;
|
||||
readonly responseEpochMs: number;
|
||||
readonly sourceEpochMs: number | null;
|
||||
readonly success: boolean;
|
||||
}
|
||||
|
||||
@@ -39,7 +42,9 @@ type ParsedWorkerPerformanceCapture = Pick<
|
||||
| 'eventLoopUtilizationUnavailableReason'
|
||||
| 'histogramFlushedEpochMs'
|
||||
| 'invalidReason'
|
||||
| 'phaseEvents'
|
||||
| 'requestReceivedEpochMs'
|
||||
| 'responsePostedEpochMs'
|
||||
| 'threadCpuSystemMicros'
|
||||
| 'threadCpuUnavailableReason'
|
||||
| 'threadCpuUserMicros'
|
||||
@@ -146,7 +151,8 @@ function parseEventLoopDelay(
|
||||
}
|
||||
|
||||
function parseWorkerPerformanceCapture(
|
||||
input: unknown
|
||||
input: unknown,
|
||||
operation: string | null
|
||||
): ParsedWorkerPerformanceCapture | null {
|
||||
if (!isRecord(input)) {
|
||||
return null;
|
||||
@@ -161,6 +167,7 @@ function parseWorkerPerformanceCapture(
|
||||
const histogramFlushedEpochMs = input['histogramFlushedEpochMs'];
|
||||
const invalidReason = input['invalidReason'];
|
||||
const requestReceivedEpochMs = input['requestReceivedEpochMs'];
|
||||
const responsePostedEpochMs = input['responsePostedEpochMs'];
|
||||
const threadCpuSystemMicros = input['threadCpuSystemMicros'];
|
||||
const threadCpuUnavailableReason = input['threadCpuUnavailableReason'];
|
||||
const threadCpuUserMicros = input['threadCpuUserMicros'];
|
||||
@@ -182,6 +189,7 @@ function parseWorkerPerformanceCapture(
|
||||
!isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) ||
|
||||
!isNullableReason(invalidReason, INVALID_REASONS) ||
|
||||
!isFiniteNonNegativeNumber(requestReceivedEpochMs) ||
|
||||
!isFiniteNonNegativeNumber(responsePostedEpochMs) ||
|
||||
!isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) ||
|
||||
!isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) ||
|
||||
!isNullableFiniteNonNegativeNumber(threadCpuUserMicros) ||
|
||||
@@ -191,11 +199,23 @@ function parseWorkerPerformanceCapture(
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseEvents = parseWorkerPerformancePhaseEvents(
|
||||
operation,
|
||||
input['phaseEvents'],
|
||||
workStartedEpochMs,
|
||||
workEndedEpochMs
|
||||
);
|
||||
if (phaseEvents === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestampsAreOrdered =
|
||||
requestReceivedEpochMs <= workStartedEpochMs &&
|
||||
workStartedEpochMs <= workEndedEpochMs &&
|
||||
workEndedEpochMs <= responsePostedEpochMs &&
|
||||
(histogramFlushedEpochMs === null ||
|
||||
workEndedEpochMs <= histogramFlushedEpochMs);
|
||||
(workEndedEpochMs <= histogramFlushedEpochMs &&
|
||||
histogramFlushedEpochMs <= responsePostedEpochMs));
|
||||
const eventLoopDelayIsCoherent =
|
||||
eventLoopDelay === null
|
||||
? eventLoopDelayUnavailableReason !== null &&
|
||||
@@ -241,7 +261,9 @@ function parseWorkerPerformanceCapture(
|
||||
eventLoopUtilizationUnavailableReason,
|
||||
histogramFlushedEpochMs,
|
||||
invalidReason,
|
||||
phaseEvents,
|
||||
requestReceivedEpochMs,
|
||||
responsePostedEpochMs,
|
||||
threadCpuSystemMicros,
|
||||
threadCpuUnavailableReason,
|
||||
threadCpuUserMicros,
|
||||
@@ -254,12 +276,14 @@ export function normalizeWorkerRequestPerformanceOutcome(
|
||||
input: WorkerRequestPerformanceOutcomeTransport
|
||||
): WorkerRequestPerformanceMetrics {
|
||||
const identity = {
|
||||
ipcCallId: input.ipcCallId,
|
||||
operation: input.operation,
|
||||
operationId: input.operationId,
|
||||
operationIdUnavailableReason: input.operationIdUnavailableReason,
|
||||
playlistId: input.playlistId,
|
||||
requestId: input.requestId,
|
||||
responseEpochMs: input.responseEpochMs,
|
||||
sourceEpochMs: input.sourceEpochMs,
|
||||
success: input.success,
|
||||
};
|
||||
const unavailable = (
|
||||
@@ -275,7 +299,9 @@ export function normalizeWorkerRequestPerformanceOutcome(
|
||||
invalidReason: null,
|
||||
...identity,
|
||||
performanceCaptureUnavailableReason: reason,
|
||||
phaseEvents: [],
|
||||
requestReceivedEpochMs: null,
|
||||
responsePostedEpochMs: null,
|
||||
threadCpuSystemMicros: null,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: null,
|
||||
@@ -291,11 +317,27 @@ export function normalizeWorkerRequestPerformanceOutcome(
|
||||
}
|
||||
|
||||
const performanceCapture = parseWorkerPerformanceCapture(
|
||||
input.performanceCapture
|
||||
input.performanceCapture,
|
||||
input.operation
|
||||
);
|
||||
if (!performanceCapture) {
|
||||
return unavailable('worker-performance-capture-invalid');
|
||||
}
|
||||
if (
|
||||
!isFiniteNonNegativeNumber(input.responseEpochMs) ||
|
||||
performanceCapture.responsePostedEpochMs > input.responseEpochMs
|
||||
) {
|
||||
return unavailable('worker-performance-capture-invalid');
|
||||
}
|
||||
if (
|
||||
performanceCapture.phaseEvents.length > 0 &&
|
||||
(input.requestId === null ||
|
||||
performanceCapture.phaseEvents.some(
|
||||
(event) => event.requestId !== input.requestId
|
||||
))
|
||||
) {
|
||||
return unavailable('worker-performance-capture-invalid');
|
||||
}
|
||||
|
||||
return {
|
||||
...performanceCapture,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
AppPlaylistGetPerformancePhase,
|
||||
PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { getAppPlaylist } from './playlist.operations';
|
||||
|
||||
type TestGetPhaseCapture = {
|
||||
captureAsync: <TResult>(
|
||||
phase: AppPlaylistGetPerformancePhase,
|
||||
execute: () => Promise<TResult>,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
) => Promise<TResult>;
|
||||
captureSync: <TResult>(
|
||||
phase: AppPlaylistGetPerformancePhase,
|
||||
execute: () => TResult,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
) => TResult;
|
||||
};
|
||||
|
||||
function createDatabaseMock(rows: unknown[]) {
|
||||
const limit = jest.fn().mockResolvedValue(rows);
|
||||
const where = jest.fn(() => ({ limit }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
|
||||
return {
|
||||
db: { select } as unknown as AppDatabase,
|
||||
from,
|
||||
limit,
|
||||
select,
|
||||
where,
|
||||
};
|
||||
}
|
||||
|
||||
const playlistRow = {
|
||||
id: 'synthetic-playlist',
|
||||
name: 'Synthetic playlist',
|
||||
type: 'm3u-url',
|
||||
payload: JSON.stringify({
|
||||
_id: 'synthetic-playlist',
|
||||
playlist: {
|
||||
items: [{ id: 'channel-1' }, { id: 'channel-2' }],
|
||||
},
|
||||
title: 'Synthetic playlist',
|
||||
}),
|
||||
favorites: null,
|
||||
recentlyViewed: null,
|
||||
} as never;
|
||||
|
||||
describe('app playlist GET performance boundaries', () => {
|
||||
it('keeps the default read and parse path unchanged', async () => {
|
||||
const { db, limit, select } = createDatabaseMock([playlistRow]);
|
||||
|
||||
await expect(
|
||||
getAppPlaylist(db, 'synthetic-playlist')
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
_id: 'synthetic-playlist',
|
||||
playlist: {
|
||||
items: [{ id: 'channel-1' }, { id: 'channel-2' }],
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(select).toHaveBeenCalledTimes(1);
|
||||
expect(limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('separates the awaited SQLite read from playlist deserialization', async () => {
|
||||
const { db, limit } = createDatabaseMock([playlistRow]);
|
||||
const phases: Array<{
|
||||
metadata: PerformancePhaseMetadata | undefined;
|
||||
phase: AppPlaylistGetPerformancePhase;
|
||||
}> = [];
|
||||
const capture: TestGetPhaseCapture = {
|
||||
async captureAsync(phase, execute, metadata) {
|
||||
const result = await execute();
|
||||
phases.push({
|
||||
metadata: metadata?.(result),
|
||||
phase,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
captureSync(phase, execute, metadata) {
|
||||
const result = execute();
|
||||
phases.push({
|
||||
metadata: metadata?.(result),
|
||||
phase,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
};
|
||||
const getWithCapture = getAppPlaylist as unknown as (
|
||||
database: AppDatabase,
|
||||
playlistId: string,
|
||||
phaseCapture: TestGetPhaseCapture
|
||||
) => Promise<Record<string, unknown> | null>;
|
||||
|
||||
await expect(
|
||||
getWithCapture(db, 'synthetic-playlist', capture)
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
_id: 'synthetic-playlist',
|
||||
})
|
||||
);
|
||||
|
||||
expect(phases).toEqual([
|
||||
{
|
||||
metadata: { itemCount: 1 },
|
||||
phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
},
|
||||
{
|
||||
metadata: { itemCount: 2 },
|
||||
phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST,
|
||||
},
|
||||
]);
|
||||
expect(limit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
|
||||
type AppPlaylistUpsertPerformancePhase,
|
||||
type PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
type AppPlaylistUpsertPhaseCapture,
|
||||
upsertAppPlaylist,
|
||||
} from './playlist.operations';
|
||||
|
||||
function createDatabaseMock(runImplementation: () => unknown = () => ({})) {
|
||||
const run = jest.fn(runImplementation);
|
||||
const then = jest.fn(
|
||||
(
|
||||
onFulfilled: (value: unknown) => unknown,
|
||||
onRejected: (error: unknown) => unknown
|
||||
) => Promise.resolve().then(run).then(onFulfilled, onRejected)
|
||||
);
|
||||
const onConflictDoUpdate = jest.fn(() => ({ run, then }));
|
||||
const values = jest.fn(() => ({ onConflictDoUpdate }));
|
||||
const insert = jest.fn(() => ({ values }));
|
||||
|
||||
return {
|
||||
db: { insert } as unknown as AppDatabase,
|
||||
insert,
|
||||
onConflictDoUpdate,
|
||||
run,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
describe('app playlist upsert performance boundaries', () => {
|
||||
const playlist = {
|
||||
_id: 'synthetic-playlist',
|
||||
playlist: {
|
||||
items: [{ id: 'channel-1' }, { id: 'channel-2' }],
|
||||
},
|
||||
title: 'Synthetic playlist',
|
||||
type: 'm3u-url',
|
||||
url: 'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the default path free of phase hooks and executes one autocommit run', async () => {
|
||||
const { db, run } = createDatabaseMock();
|
||||
|
||||
await expect(upsertAppPlaylist(db, playlist)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('separates row/JSON serialization from SQLite without rescanning the payload for metadata', async () => {
|
||||
const { db, run } = createDatabaseMock();
|
||||
const byteLength = jest.spyOn(Buffer, 'byteLength');
|
||||
const phases: Array<{
|
||||
metadata: PerformancePhaseMetadata | undefined;
|
||||
phase: AppPlaylistUpsertPerformancePhase;
|
||||
}> = [];
|
||||
const capture: AppPlaylistUpsertPhaseCapture = {
|
||||
async captureAsync(phase, execute, metadata) {
|
||||
const result = await execute();
|
||||
phases.push({
|
||||
metadata: metadata?.(result),
|
||||
phase,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
captureSync(phase, execute, metadata) {
|
||||
const result = execute();
|
||||
phases.push({
|
||||
metadata: metadata?.(result),
|
||||
phase,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(upsertAppPlaylist(db, playlist, capture)).resolves.toEqual(
|
||||
{ success: true }
|
||||
);
|
||||
|
||||
expect(phases).toEqual([
|
||||
{
|
||||
metadata: { itemCount: 2 },
|
||||
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
|
||||
},
|
||||
{
|
||||
metadata: { itemCount: 1 },
|
||||
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
|
||||
},
|
||||
]);
|
||||
expect(byteLength).not.toHaveBeenCalled();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
|
||||
type AppPlaylistGetPerformancePhase,
|
||||
type AppPlaylistUpsertPerformancePhase,
|
||||
type PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
checkpointOperation,
|
||||
@@ -19,6 +26,32 @@ const PLAYLIST_TYPES = {
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 100;
|
||||
|
||||
export interface AppPlaylistUpsertPhaseCapture {
|
||||
captureAsync: <TResult>(
|
||||
phase: AppPlaylistUpsertPerformancePhase,
|
||||
execute: () => Promise<TResult>,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
) => Promise<TResult>;
|
||||
captureSync: <TResult>(
|
||||
phase: AppPlaylistUpsertPerformancePhase,
|
||||
execute: () => TResult,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
) => TResult;
|
||||
}
|
||||
|
||||
export interface AppPlaylistGetPhaseCapture {
|
||||
captureAsync: <TResult>(
|
||||
phase: AppPlaylistGetPerformancePhase,
|
||||
execute: () => Promise<TResult>,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
) => Promise<TResult>;
|
||||
captureSync: <TResult>(
|
||||
phase: AppPlaylistGetPerformancePhase,
|
||||
execute: () => TResult,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
) => TResult;
|
||||
}
|
||||
|
||||
type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES];
|
||||
const PLAYLIST_TYPE_VALUES = new Set<PlaylistType>(
|
||||
Object.values(PLAYLIST_TYPES)
|
||||
@@ -196,6 +229,21 @@ function buildPlaylistRow(
|
||||
};
|
||||
}
|
||||
|
||||
function getPlaylistItemCount(
|
||||
playlist: Record<string, unknown>
|
||||
): number | undefined {
|
||||
try {
|
||||
const value = playlist.playlist;
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const items = Reflect.get(value, 'items');
|
||||
return Array.isArray(items) ? items.length : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAppPlaylist(
|
||||
row: schema.Playlist
|
||||
): Record<string, unknown> {
|
||||
@@ -300,17 +348,42 @@ export async function createPlaylist(
|
||||
|
||||
export async function upsertAppPlaylist(
|
||||
db: AppDatabase,
|
||||
playlist: Record<string, unknown>
|
||||
playlist: Record<string, unknown>,
|
||||
capturePhase?: AppPlaylistUpsertPhaseCapture
|
||||
): Promise<{ success: boolean }> {
|
||||
const row = buildPlaylistRow(playlist);
|
||||
const row = capturePhase
|
||||
? capturePhase.captureSync(
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
|
||||
() => buildPlaylistRow(playlist),
|
||||
() => ({
|
||||
itemCount: getPlaylistItemCount(playlist),
|
||||
})
|
||||
)
|
||||
: buildPlaylistRow(playlist);
|
||||
if (!row) {
|
||||
throw new Error('Playlist ID is required for upsert');
|
||||
}
|
||||
|
||||
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: row,
|
||||
});
|
||||
const write = async () => {
|
||||
await db
|
||||
.insert(schema.playlists)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: row,
|
||||
});
|
||||
};
|
||||
if (capturePhase) {
|
||||
await capturePhase.captureAsync(
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
|
||||
write,
|
||||
() => ({
|
||||
itemCount: 1,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
await write();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -392,14 +465,38 @@ export async function getAppPlaylistMetas(db: AppDatabase) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAppPlaylist(db: AppDatabase, playlistId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, playlistId))
|
||||
.limit(1);
|
||||
export async function getAppPlaylist(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
capturePhase?: AppPlaylistGetPhaseCapture
|
||||
) {
|
||||
const read = () =>
|
||||
db
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, playlistId))
|
||||
.limit(1);
|
||||
const rows = capturePhase
|
||||
? await capturePhase.captureAsync(
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
read,
|
||||
(value) => ({ itemCount: value.length })
|
||||
)
|
||||
: await read();
|
||||
const deserialize = () =>
|
||||
rows[0] ? parseAppPlaylist(rows[0]) : null;
|
||||
|
||||
return rows[0] ? parseAppPlaylist(rows[0]) : null;
|
||||
return capturePhase
|
||||
? capturePhase.captureSync(
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST,
|
||||
deserialize,
|
||||
(value) => ({
|
||||
itemCount: value
|
||||
? (getPlaylistItemCount(value) ?? 0)
|
||||
: 0,
|
||||
})
|
||||
)
|
||||
: deserialize();
|
||||
}
|
||||
|
||||
export async function getAppPlaylistFavoriteChannels(
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL,
|
||||
PERFORMANCE_PHASE_BOUNDARY,
|
||||
type M3uImportPerformancePhase,
|
||||
type PerformancePhaseEvent,
|
||||
type PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { channel, type Channel } from 'node:diagnostics_channel';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
export interface M3uImportPerformanceCaptureRuntime {
|
||||
readonly createRequestId: () => string;
|
||||
readonly readEpochMs: () => number;
|
||||
readonly readMonotonicMs: () => number;
|
||||
}
|
||||
|
||||
export interface M3uImportPerformanceCapture {
|
||||
measure<TResult>(
|
||||
phase: M3uImportPerformancePhase,
|
||||
execute: () => TResult,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
): TResult;
|
||||
measureAsync<TResult>(
|
||||
phase: M3uImportPerformancePhase,
|
||||
execute: () => Promise<TResult>,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
): Promise<TResult>;
|
||||
}
|
||||
|
||||
interface CaptureOptions {
|
||||
readonly emit?: (
|
||||
event: PerformancePhaseEvent<M3uImportPerformancePhase>
|
||||
) => void;
|
||||
readonly enabled: boolean;
|
||||
readonly requestId?: string;
|
||||
readonly runtime?: M3uImportPerformanceCaptureRuntime;
|
||||
}
|
||||
|
||||
interface PhaseStart {
|
||||
readonly monotonicMs: number;
|
||||
readonly phase: M3uImportPerformancePhase;
|
||||
}
|
||||
|
||||
const DEFAULT_RUNTIME: M3uImportPerformanceCaptureRuntime = {
|
||||
createRequestId: () => randomUUID(),
|
||||
readEpochMs: () => performance.timeOrigin + performance.now(),
|
||||
readMonotonicMs: () => performance.now(),
|
||||
};
|
||||
|
||||
let performanceChannel: Channel | null = null;
|
||||
let fallbackRequestSequence = 0;
|
||||
|
||||
export function publishM3uImportPerformancePhaseEvent(
|
||||
event: PerformancePhaseEvent<M3uImportPerformancePhase>
|
||||
): void {
|
||||
try {
|
||||
performanceChannel ??= channel(
|
||||
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL
|
||||
);
|
||||
performanceChannel.publish(event);
|
||||
} catch {
|
||||
// Development-only instrumentation must never affect playlist import.
|
||||
}
|
||||
}
|
||||
|
||||
export function createM3uImportPerformanceCapture(
|
||||
options: CaptureOptions
|
||||
): M3uImportPerformanceCapture | null {
|
||||
if (!options.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtime = options.runtime ?? DEFAULT_RUNTIME;
|
||||
const requestId =
|
||||
normalizeRequestId(options.requestId) ?? createRequestId(runtime);
|
||||
const emit = options.emit ?? publishM3uImportPerformancePhaseEvent;
|
||||
|
||||
const begin = (phase: M3uImportPerformancePhase): PhaseStart => {
|
||||
const monotonicMs = safeReadMonotonicMs(runtime);
|
||||
safeEmit(emit, {
|
||||
boundary: PERFORMANCE_PHASE_BOUNDARY.START,
|
||||
durationMs: null,
|
||||
epochMs: safeReadEpochMs(runtime),
|
||||
metadata: undefined,
|
||||
phase,
|
||||
requestId,
|
||||
});
|
||||
return { monotonicMs, phase };
|
||||
};
|
||||
|
||||
const end = (
|
||||
start: PhaseStart,
|
||||
createMetadata?: () => PerformancePhaseMetadata
|
||||
): void => {
|
||||
const endedMonotonicMs = safeReadMonotonicMs(runtime);
|
||||
const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs);
|
||||
const epochMs = safeReadEpochMs(runtime);
|
||||
safeEmit(emit, {
|
||||
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
|
||||
durationMs: Number.isFinite(durationMs) ? durationMs : 0,
|
||||
epochMs,
|
||||
metadata: sanitizeMetadata(safelyCreateMetadata(createMetadata)),
|
||||
phase: start.phase,
|
||||
requestId,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
measure: (phase, execute, metadata) => {
|
||||
const start = begin(phase);
|
||||
try {
|
||||
const result = execute();
|
||||
end(start, metadata ? () => metadata(result) : undefined);
|
||||
return result;
|
||||
} catch (error) {
|
||||
end(start);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
measureAsync: async (phase, execute, metadata) => {
|
||||
const start = begin(phase);
|
||||
try {
|
||||
const result = await execute();
|
||||
end(start, metadata ? () => metadata(result) : undefined);
|
||||
return result;
|
||||
} catch (error) {
|
||||
end(start);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRequestId(runtime: M3uImportPerformanceCaptureRuntime): string {
|
||||
try {
|
||||
const requestId = normalizeRequestId(runtime.createRequestId());
|
||||
if (requestId) {
|
||||
return requestId;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to a process-local, credential-free identifier.
|
||||
}
|
||||
fallbackRequestSequence += 1;
|
||||
return `m3u-import-${Date.now()}-${fallbackRequestSequence}`;
|
||||
}
|
||||
|
||||
function normalizeRequestId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function safeReadEpochMs(runtime: M3uImportPerformanceCaptureRuntime): number {
|
||||
try {
|
||||
const value = runtime.readEpochMs();
|
||||
return Number.isFinite(value) ? value : Date.now();
|
||||
} catch {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadMonotonicMs(
|
||||
runtime: M3uImportPerformanceCaptureRuntime
|
||||
): number {
|
||||
try {
|
||||
const value = runtime.readMonotonicMs();
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function safelyCreateMetadata(
|
||||
createMetadata: (() => PerformancePhaseMetadata) | undefined
|
||||
): PerformancePhaseMetadata | undefined {
|
||||
if (!createMetadata) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return createMetadata();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMetadata(
|
||||
metadata: PerformancePhaseMetadata | undefined
|
||||
): PerformancePhaseMetadata | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sanitized: PerformancePhaseMetadata = {};
|
||||
if (isCount(metadata.byteCount)) {
|
||||
(sanitized as { byteCount?: number }).byteCount = metadata.byteCount;
|
||||
}
|
||||
if (isCount(metadata.itemCount)) {
|
||||
(sanitized as { itemCount?: number }).itemCount = metadata.itemCount;
|
||||
}
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function isCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function safeEmit(
|
||||
emit: (event: PerformancePhaseEvent<M3uImportPerformancePhase>) => void,
|
||||
event: PerformancePhaseEvent<M3uImportPerformancePhase>
|
||||
): void {
|
||||
try {
|
||||
emit(event);
|
||||
} catch {
|
||||
// Instrumentation delivery is deliberately fail-neutral.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE,
|
||||
type PerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import {
|
||||
createM3uImportPerformanceCapture,
|
||||
type M3uImportPerformanceCaptureRuntime,
|
||||
} from './playlist-import-performance';
|
||||
import { fetchPlaylistFromUrl } from './playlist-source';
|
||||
|
||||
const mockCreatePlaylistObject = jest.fn();
|
||||
const mockGetFilenameFromUrl = jest.fn();
|
||||
const mockParse = jest.fn();
|
||||
const mockRequestWithValidatedRedirects = jest.fn();
|
||||
|
||||
jest.mock('@iptvnator/shared/m3u-utils', () => ({
|
||||
createPlaylistObject: (...args: unknown[]) =>
|
||||
mockCreatePlaylistObject(...args),
|
||||
getFilenameFromUrl: (...args: unknown[]) => mockGetFilenameFromUrl(...args),
|
||||
}));
|
||||
|
||||
jest.mock('iptv-playlist-parser', () => ({
|
||||
parse: (...args: unknown[]) => mockParse(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../util/secure-https', () => ({
|
||||
createPlaylistAgentFactory: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
|
||||
jest.mock('../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects: (...args: unknown[]) =>
|
||||
mockRequestWithValidatedRedirects(...args),
|
||||
}));
|
||||
|
||||
type ImportPhaseEvent = PerformancePhaseEvent<
|
||||
(typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE]
|
||||
>;
|
||||
|
||||
function createRuntime(): M3uImportPerformanceCaptureRuntime {
|
||||
const epochValues = [100, 110, 120, 130, 140, 150];
|
||||
const monotonicValues = [10, 14, 20, 23, 30, 36];
|
||||
|
||||
return {
|
||||
createRequestId: jest.fn(() => 'm3u-request-1'),
|
||||
readEpochMs: jest.fn(() => epochValues.shift() ?? 150),
|
||||
readMonotonicMs: jest.fn(() => monotonicValues.shift() ?? 36),
|
||||
};
|
||||
}
|
||||
|
||||
describe('URL playlist import performance phases', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreatePlaylistObject.mockReset();
|
||||
mockGetFilenameFromUrl.mockReset();
|
||||
mockParse.mockReset();
|
||||
mockRequestWithValidatedRedirects.mockReset();
|
||||
});
|
||||
|
||||
it('does not allocate phase state or read clocks while capture is disabled', async () => {
|
||||
const runtime = {
|
||||
createRequestId: jest.fn(() => {
|
||||
throw new Error('request id must stay untouched');
|
||||
}),
|
||||
readEpochMs: jest.fn(() => {
|
||||
throw new Error('epoch clock must stay untouched');
|
||||
}),
|
||||
readMonotonicMs: jest.fn(() => {
|
||||
throw new Error('monotonic clock must stay untouched');
|
||||
}),
|
||||
};
|
||||
const emit = jest.fn();
|
||||
const playlist = { playlist: { items: [] }, title: 'Synthetic' };
|
||||
|
||||
mockRequestWithValidatedRedirects.mockResolvedValue({
|
||||
data: '#EXTM3U',
|
||||
});
|
||||
mockParse.mockReturnValue({ items: [] });
|
||||
mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u');
|
||||
mockCreatePlaylistObject.mockReturnValue(playlist);
|
||||
|
||||
const capture = createM3uImportPerformanceCapture({
|
||||
emit,
|
||||
enabled: false,
|
||||
runtime,
|
||||
});
|
||||
const result = await fetchPlaylistFromUrl(
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
undefined,
|
||||
{ performanceCapture: capture }
|
||||
);
|
||||
|
||||
expect(capture).toBeNull();
|
||||
expect(result).toBe(playlist);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
expect(runtime.createRequestId).not.toHaveBeenCalled();
|
||||
expect(runtime.readEpochMs).not.toHaveBeenCalled();
|
||||
expect(runtime.readMonotonicMs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits ordered epoch-aligned pairs with durations and only byte/item metadata', async () => {
|
||||
const events: ImportPhaseEvent[] = [];
|
||||
const body = '#EXTM3U\n#EXTINF:-1,One\nhttps://stream.test/1';
|
||||
const bodyBytes = Buffer.byteLength(body, 'utf8');
|
||||
const parsed = { items: [{ name: 'One' }, { name: 'Two' }] };
|
||||
const playlist = {
|
||||
playlist: { items: parsed.items },
|
||||
title: 'Synthetic',
|
||||
};
|
||||
|
||||
mockRequestWithValidatedRedirects.mockResolvedValue({
|
||||
data: body,
|
||||
headers: {
|
||||
'content-length': String(bodyBytes),
|
||||
},
|
||||
});
|
||||
mockParse.mockReturnValue(parsed);
|
||||
mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u');
|
||||
mockCreatePlaylistObject.mockReturnValue(playlist);
|
||||
|
||||
const capture = createM3uImportPerformanceCapture({
|
||||
emit: (event) => events.push(event),
|
||||
enabled: true,
|
||||
requestId: 'm3u-request-1',
|
||||
runtime: createRuntime(),
|
||||
});
|
||||
const byteLength = jest.spyOn(Buffer, 'byteLength');
|
||||
const result = await fetchPlaylistFromUrl(
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
undefined,
|
||||
{ performanceCapture: capture }
|
||||
);
|
||||
|
||||
expect(result).toBe(playlist);
|
||||
expect(byteLength).not.toHaveBeenCalled();
|
||||
expect(
|
||||
events.map(
|
||||
({
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
metadata,
|
||||
phase,
|
||||
requestId,
|
||||
}) => ({
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
metadata,
|
||||
phase,
|
||||
requestId,
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
metadata: undefined,
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
|
||||
requestId: 'm3u-request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 4,
|
||||
epochMs: 110,
|
||||
metadata: { byteCount: bodyBytes },
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
|
||||
requestId: 'm3u-request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 120,
|
||||
metadata: undefined,
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
|
||||
requestId: 'm3u-request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 3,
|
||||
epochMs: 130,
|
||||
metadata: { itemCount: 2 },
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
|
||||
requestId: 'm3u-request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 140,
|
||||
metadata: undefined,
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
|
||||
requestId: 'm3u-request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 6,
|
||||
epochMs: 150,
|
||||
metadata: { itemCount: 2 },
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
|
||||
requestId: 'm3u-request-1',
|
||||
},
|
||||
]);
|
||||
const serialized = JSON.stringify(events);
|
||||
expect(serialized).not.toContain('127.0.0.1');
|
||||
expect(serialized).not.toContain('stream.test');
|
||||
expect(serialized).not.toContain('Synthetic');
|
||||
expect(serialized).not.toContain('username');
|
||||
expect(serialized).not.toContain('password');
|
||||
expect(serialized).not.toContain(
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves exact results and errors when event or metadata hooks fail', async () => {
|
||||
const resultIdentity = { result: 'same-object' };
|
||||
const capture = createM3uImportPerformanceCapture({
|
||||
emit: () => {
|
||||
throw new Error('event sink failed');
|
||||
},
|
||||
enabled: true,
|
||||
requestId: 'm3u-request-2',
|
||||
runtime: createRuntime(),
|
||||
});
|
||||
|
||||
expect(
|
||||
capture?.measure(
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
|
||||
() => resultIdentity,
|
||||
() => {
|
||||
throw new Error('metadata failed');
|
||||
}
|
||||
)
|
||||
).toBe(resultIdentity);
|
||||
|
||||
const businessError = new Error('download failed');
|
||||
const events: ImportPhaseEvent[] = [];
|
||||
mockRequestWithValidatedRedirects.mockRejectedValue(businessError);
|
||||
const errorCapture = createM3uImportPerformanceCapture({
|
||||
emit: (event) => events.push(event),
|
||||
enabled: true,
|
||||
requestId: 'm3u-request-3',
|
||||
runtime: createRuntime(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchPlaylistFromUrl(
|
||||
'http://127.0.0.1:43210/failure.m3u',
|
||||
undefined,
|
||||
{
|
||||
performanceCapture: errorCapture,
|
||||
}
|
||||
)
|
||||
).rejects.toBe(businessError);
|
||||
expect(
|
||||
events.map(({ boundary, phase }) => ({ boundary, phase }))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('samples the end boundary before creating phase metadata', () => {
|
||||
const events: ImportPhaseEvent[] = [];
|
||||
let epochMs = 100;
|
||||
let monotonicMs = 10;
|
||||
const resultIdentity = { itemCount: 2 };
|
||||
const capture = createM3uImportPerformanceCapture({
|
||||
emit: (event) => events.push(event),
|
||||
enabled: true,
|
||||
requestId: 'm3u-request-boundary',
|
||||
runtime: {
|
||||
createRequestId: jest.fn(() => 'unused-request-id'),
|
||||
readEpochMs: jest.fn(() => epochMs),
|
||||
readMonotonicMs: jest.fn(() => monotonicMs),
|
||||
},
|
||||
});
|
||||
|
||||
const result = capture?.measure(
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
|
||||
() => {
|
||||
epochMs = 110;
|
||||
monotonicMs = 20;
|
||||
return resultIdentity;
|
||||
},
|
||||
({ itemCount }) => {
|
||||
epochMs = 1_110;
|
||||
monotonicMs = 1_020;
|
||||
return { itemCount };
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(resultIdentity);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[1]).toMatchObject({
|
||||
boundary: 'end',
|
||||
durationMs: 10,
|
||||
epochMs: 110,
|
||||
metadata: { itemCount: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
isInvalidTlsCertificateError,
|
||||
} from '../util/security-errors';
|
||||
import { requestWithValidatedRedirects } from '../util/validated-axios';
|
||||
import type { M3uImportPerformanceCapture } from './playlist-import-performance';
|
||||
import { M3U_IMPORT_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface PlaylistFetchOptions {
|
||||
performanceCapture?: M3uImportPerformanceCapture | null;
|
||||
trustedInsecureTlsHosts?: readonly string[];
|
||||
}
|
||||
|
||||
@@ -26,14 +29,67 @@ export interface PlaylistFetchOptions {
|
||||
*/
|
||||
export const PLAYLIST_FETCH_TIMEOUT_MS = 30000;
|
||||
|
||||
function countPlaylistItems(value: unknown): number | undefined {
|
||||
try {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const items = Reflect.get(value, 'items');
|
||||
return Array.isArray(items) ? items.length : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function countNormalizedPlaylistItems(value: unknown): number | undefined {
|
||||
try {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return countPlaylistItems(Reflect.get(value, 'playlist'));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readResponseContentLength(response: unknown): number | undefined {
|
||||
try {
|
||||
if (typeof response !== 'object' || response === null) {
|
||||
return undefined;
|
||||
}
|
||||
const headers = Reflect.get(response, 'headers');
|
||||
if (typeof headers !== 'object' || headers === null) {
|
||||
return undefined;
|
||||
}
|
||||
const directValue = Reflect.get(headers, 'content-length');
|
||||
const getHeader = Reflect.get(headers, 'get');
|
||||
const value =
|
||||
directValue ??
|
||||
(typeof getHeader === 'function'
|
||||
? Reflect.apply(getHeader, headers, ['content-length'])
|
||||
: undefined);
|
||||
const numericValue =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && value.trim().length > 0
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
return Number.isSafeInteger(numericValue) && numericValue >= 0
|
||||
? numericValue
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPlaylistFromUrl(
|
||||
url: string,
|
||||
title?: string,
|
||||
options: PlaylistFetchOptions = {}
|
||||
): Promise<Playlist> {
|
||||
let result;
|
||||
try {
|
||||
result = await requestWithValidatedRedirects<string>(
|
||||
const performanceCapture = options.performanceCapture;
|
||||
const request = () =>
|
||||
requestWithValidatedRedirects<string>(
|
||||
url,
|
||||
{
|
||||
agentFactory: createPlaylistAgentFactory({
|
||||
@@ -44,6 +100,17 @@ export async function fetchPlaylistFromUrl(
|
||||
},
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
let result;
|
||||
try {
|
||||
result = performanceCapture
|
||||
? await performanceCapture.measureAsync(
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
|
||||
request,
|
||||
(response) => ({
|
||||
byteCount: readResponseContentLength(response),
|
||||
})
|
||||
)
|
||||
: await request();
|
||||
} catch (error) {
|
||||
if (isInvalidTlsCertificateError(error)) {
|
||||
throw createInvalidTlsCertificateError(
|
||||
@@ -53,19 +120,38 @@ export async function fetchPlaylistFromUrl(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const parsedPlaylist = parse(result.data);
|
||||
const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : '';
|
||||
const playlistName =
|
||||
!extractedName || extractedName === 'Untitled playlist'
|
||||
? 'Imported from URL'
|
||||
: extractedName;
|
||||
const parsedPlaylist = performanceCapture
|
||||
? performanceCapture.measure(
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
|
||||
() => parse(result.data),
|
||||
(parsed) => ({ itemCount: countPlaylistItems(parsed) })
|
||||
)
|
||||
: parse(result.data);
|
||||
const normalize = (): Playlist => {
|
||||
const extractedName =
|
||||
url && url.length > 1 ? getFilenameFromUrl(url) : '';
|
||||
const playlistName =
|
||||
!extractedName || extractedName === 'Untitled playlist'
|
||||
? 'Imported from URL'
|
||||
: extractedName;
|
||||
|
||||
return createPlaylistObject(
|
||||
title ?? playlistName,
|
||||
parsedPlaylist,
|
||||
url,
|
||||
'URL'
|
||||
);
|
||||
return createPlaylistObject(
|
||||
title ?? playlistName,
|
||||
parsedPlaylist,
|
||||
url,
|
||||
'URL'
|
||||
);
|
||||
};
|
||||
|
||||
return performanceCapture
|
||||
? performanceCapture.measure(
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
|
||||
normalize,
|
||||
(playlist) => ({
|
||||
itemCount: countNormalizedPlaylistItems(playlist),
|
||||
})
|
||||
)
|
||||
: normalize();
|
||||
}
|
||||
|
||||
export async function fetchPlaylistFromFile(
|
||||
|
||||
@@ -6,10 +6,13 @@ import type {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL,
|
||||
PLAYLIST_CANCEL_REFRESH,
|
||||
PLAYLIST_REFRESH,
|
||||
PLAYLIST_REFRESH_EVENT,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { channel } from 'node:diagnostics_channel';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise<unknown>;
|
||||
@@ -41,6 +44,8 @@ const mockCreatePlaylistObject = jest.fn();
|
||||
const mockGetFilenameFromUrl = jest.fn();
|
||||
const mockResolveWorkerRuntimeBootstrap = jest.fn();
|
||||
const mockWorkerInstances: MockWorker[] = [];
|
||||
const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE';
|
||||
const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV];
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
@@ -169,6 +174,11 @@ describe('playlist IPC events', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPerformanceCaptureValue === undefined) {
|
||||
delete process.env[PERF_CAPTURE_ENV];
|
||||
} else {
|
||||
process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue;
|
||||
}
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
@@ -210,6 +220,101 @@ describe('playlist IPC events', () => {
|
||||
expect(result).toEqual(playlist);
|
||||
});
|
||||
|
||||
it('publishes request-scoped URL import phases only through the opt-in internal channel', async () => {
|
||||
process.env[PERF_CAPTURE_ENV] = '1';
|
||||
const body = '#EXTM3U\n#EXTINF:-1,News\nhttps://stream.test/news';
|
||||
const parsedPlaylist = { items: [{ name: 'News' }] };
|
||||
const playlist = createPlaylist({
|
||||
title: 'synthetic-performance.m3u',
|
||||
url: 'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const performanceChannel = channel(
|
||||
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL
|
||||
);
|
||||
const subscriber = (event: unknown) => events.push(event);
|
||||
|
||||
mockAxiosGet.mockResolvedValue({
|
||||
data: body,
|
||||
headers: {
|
||||
'content-length': String(Buffer.byteLength(body, 'utf8')),
|
||||
},
|
||||
});
|
||||
mockParse.mockReturnValue(parsedPlaylist);
|
||||
mockGetFilenameFromUrl.mockReturnValue('synthetic-performance.m3u');
|
||||
mockCreatePlaylistObject.mockReturnValue(playlist);
|
||||
performanceChannel.subscribe(subscriber);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('fetch-playlist-by-url')(
|
||||
createIpcEvent(),
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u'
|
||||
)
|
||||
).resolves.toBe(playlist);
|
||||
} finally {
|
||||
performanceChannel.unsubscribe(subscriber);
|
||||
}
|
||||
|
||||
expect(
|
||||
events.map((event) => {
|
||||
const marker = event as {
|
||||
boundary: string;
|
||||
metadata?: { byteCount?: number; itemCount?: number };
|
||||
phase: string;
|
||||
requestId: string;
|
||||
};
|
||||
return {
|
||||
boundary: marker.boundary,
|
||||
metadata: marker.metadata,
|
||||
phase: marker.phase,
|
||||
requestIdType: typeof marker.requestId,
|
||||
};
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
metadata: undefined,
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
|
||||
requestIdType: 'string',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
metadata: { byteCount: Buffer.byteLength(body, 'utf8') },
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
|
||||
requestIdType: 'string',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
metadata: undefined,
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
|
||||
requestIdType: 'string',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
metadata: { itemCount: 1 },
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
|
||||
requestIdType: 'string',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
metadata: undefined,
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
|
||||
requestIdType: 'string',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
metadata: { itemCount: 1 },
|
||||
phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
|
||||
requestIdType: 'string',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
new Set(events.map((event) => (event as any).requestId)).size
|
||||
).toBe(1);
|
||||
expect(JSON.stringify(events)).not.toContain('stream.test');
|
||||
});
|
||||
|
||||
it('returns null when the open playlist dialog is cancelled', async () => {
|
||||
mockShowOpenDialog.mockResolvedValue({
|
||||
canceled: true,
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
fetchPlaylistFromUrl,
|
||||
} from './playlist-source';
|
||||
import { PlaylistWriteAuthorizer } from './playlist-write-authorization';
|
||||
import { createM3uImportPerformanceCapture } from './playlist-import-performance';
|
||||
import { isPerformanceCaptureEnabled } from '../services/debug-trace';
|
||||
|
||||
export default class PlaylistEvents {
|
||||
static bootstrapPlaylistEvents(): Electron.IpcMain {
|
||||
@@ -95,7 +97,11 @@ ipcMain.handle(
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => {
|
||||
try {
|
||||
const performanceCapture = createM3uImportPerformanceCapture({
|
||||
enabled: isPerformanceCaptureEnabled(),
|
||||
});
|
||||
return await fetchPlaylistFromUrl(url, title, {
|
||||
performanceCapture,
|
||||
trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -64,6 +64,8 @@ import {
|
||||
getAppState,
|
||||
getPlaylist,
|
||||
setAppState,
|
||||
type AppPlaylistGetPhaseCapture,
|
||||
type AppPlaylistUpsertPhaseCapture,
|
||||
updatePlaylist,
|
||||
upsertAppPlaylist,
|
||||
upsertAppPlaylists,
|
||||
@@ -93,9 +95,14 @@ import {
|
||||
executeWithWorkerPerformanceCapture,
|
||||
registerDatabaseWorkerPerformanceCapture,
|
||||
releaseDatabaseWorkerPerformanceCapture,
|
||||
stampWorkerPerformanceResponsePostedEpoch,
|
||||
startWorkerPerformanceCapture,
|
||||
type WorkerPerformanceCapture,
|
||||
} from './worker-performance-capture';
|
||||
import {
|
||||
captureWorkerPerformancePhase,
|
||||
captureWorkerPerformancePhaseAsync,
|
||||
} from './worker-performance-phase';
|
||||
import {
|
||||
handleDatabaseWorkerPostGcHeapRequest,
|
||||
isDatabaseWorkerPostGcHeapRequest,
|
||||
@@ -349,7 +356,8 @@ function releasePreRegisteredOperation(
|
||||
|
||||
async function executeRequest(
|
||||
message: DbWorkerRequestMessage,
|
||||
preRegisteredState?: ActiveOperationState
|
||||
preRegisteredState?: ActiveOperationState,
|
||||
performanceCapture: WorkerPerformanceCapture | null = null
|
||||
) {
|
||||
const db = await getWorkerDatabase();
|
||||
|
||||
@@ -430,11 +438,7 @@ async function executeRequest(
|
||||
kind?: 'all' | 'vod' | 'series';
|
||||
limit?: number;
|
||||
playlistType?:
|
||||
| 'xtream'
|
||||
| 'stalker'
|
||||
| 'm3u-file'
|
||||
| 'm3u-text'
|
||||
| 'm3u-url';
|
||||
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
|
||||
};
|
||||
return getGlobalRecentlyAdded(
|
||||
db,
|
||||
@@ -575,9 +579,29 @@ async function executeRequest(
|
||||
}
|
||||
|
||||
case 'DB_UPSERT_APP_PLAYLIST': {
|
||||
const capturePhase: AppPlaylistUpsertPhaseCapture | undefined =
|
||||
performanceCapture
|
||||
? {
|
||||
captureAsync: (phase, execute, metadata) =>
|
||||
captureWorkerPerformancePhaseAsync(
|
||||
performanceCapture,
|
||||
phase,
|
||||
execute,
|
||||
metadata
|
||||
),
|
||||
captureSync: (phase, execute, metadata) =>
|
||||
captureWorkerPerformancePhase(
|
||||
performanceCapture,
|
||||
phase,
|
||||
execute,
|
||||
metadata
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
return upsertAppPlaylist(
|
||||
db,
|
||||
message.payload as Record<string, unknown>
|
||||
message.payload as Record<string, unknown>,
|
||||
capturePhase
|
||||
);
|
||||
}
|
||||
|
||||
@@ -596,7 +620,26 @@ async function executeRequest(
|
||||
|
||||
case 'DB_GET_APP_PLAYLIST': {
|
||||
const payload = message.payload as { playlistId: string };
|
||||
return getAppPlaylist(db, payload.playlistId);
|
||||
const capturePhase: AppPlaylistGetPhaseCapture | undefined =
|
||||
performanceCapture
|
||||
? {
|
||||
captureAsync: (phase, execute, metadata) =>
|
||||
captureWorkerPerformancePhaseAsync(
|
||||
performanceCapture,
|
||||
phase,
|
||||
execute,
|
||||
metadata
|
||||
),
|
||||
captureSync: (phase, execute, metadata) =>
|
||||
captureWorkerPerformancePhase(
|
||||
performanceCapture,
|
||||
phase,
|
||||
execute,
|
||||
metadata
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
return getAppPlaylist(db, payload.playlistId, capturePhase);
|
||||
}
|
||||
|
||||
case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS': {
|
||||
@@ -1013,7 +1056,9 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
|
||||
let performanceCapture: WorkerPerformanceCapture | null = null;
|
||||
const execution = await (async () => {
|
||||
try {
|
||||
performanceCapture = startWorkerPerformanceCapture();
|
||||
performanceCapture = startWorkerPerformanceCapture({
|
||||
requestId: message.requestId,
|
||||
});
|
||||
registerDatabaseWorkerPerformanceCapture(
|
||||
activePerformanceCaptures,
|
||||
performanceCapture
|
||||
@@ -1021,7 +1066,12 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
|
||||
await armWorkerPerformanceCapture(performanceCapture);
|
||||
return await executeWithWorkerPerformanceCapture(
|
||||
performanceCapture,
|
||||
() => executeRequest(message, preRegisteredOperation?.state)
|
||||
() =>
|
||||
executeRequest(
|
||||
message,
|
||||
preRegisteredOperation?.state,
|
||||
performanceCapture
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
releaseDatabaseWorkerPerformanceCapture(
|
||||
@@ -1038,7 +1088,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
|
||||
requestId: message.requestId,
|
||||
success: true,
|
||||
result: execution.result,
|
||||
performance: execution.performance,
|
||||
performance: stampWorkerPerformanceResponsePostedEpoch(
|
||||
performanceCapture,
|
||||
execution.performance
|
||||
),
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
@@ -1051,7 +1104,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
|
||||
requestId: message.requestId,
|
||||
success: false,
|
||||
error: serializeError(execution.error),
|
||||
performance: execution.performance,
|
||||
performance: stampWorkerPerformanceResponsePostedEpoch(
|
||||
performanceCapture,
|
||||
execution.performance
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source';
|
||||
import {
|
||||
armWorkerPerformanceCapture,
|
||||
executeWithWorkerPerformanceCapture,
|
||||
stampWorkerPerformanceResponsePostedEpoch,
|
||||
startWorkerPerformanceCapture,
|
||||
} from './worker-performance-capture';
|
||||
|
||||
@@ -229,7 +230,10 @@ parentPort.on(
|
||||
type: 'response',
|
||||
success: true,
|
||||
result: execution.result,
|
||||
performance: execution.performance,
|
||||
performance: stampWorkerPerformanceResponsePostedEpoch(
|
||||
performanceCapture,
|
||||
execution.performance
|
||||
),
|
||||
});
|
||||
} else {
|
||||
const error = execution.error;
|
||||
@@ -237,7 +241,10 @@ parentPort.on(
|
||||
type: 'response',
|
||||
success: false,
|
||||
error: serializeError(error),
|
||||
performance: execution.performance,
|
||||
performance: stampWorkerPerformanceResponsePostedEpoch(
|
||||
performanceCapture,
|
||||
execution.performance
|
||||
),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -82,6 +82,9 @@ function mockPerformanceCapture(
|
||||
),
|
||||
registerDatabaseWorkerPerformanceCapture: jest.fn(),
|
||||
releaseDatabaseWorkerPerformanceCapture: jest.fn(),
|
||||
stampWorkerPerformanceResponsePostedEpoch: jest.fn(
|
||||
(_capture: unknown, performance: unknown) => performance
|
||||
),
|
||||
startWorkerPerformanceCapture: jest.fn(() => ({})),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ function createFallbackResult(
|
||||
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE,
|
||||
histogramFlushedEpochMs: null,
|
||||
invalidReason,
|
||||
...copyPhaseEvents(capture),
|
||||
requestReceivedEpochMs: capture.requestReceivedEpochMs,
|
||||
threadCpuSystemMicros: null,
|
||||
threadCpuUnavailableReason:
|
||||
@@ -213,6 +214,7 @@ export function createWorkerPerformanceCaptureResult(
|
||||
capture.eventLoopUtilizationUnavailableReason,
|
||||
histogramFlushedEpochMs: capture.histogramFlushedEpochMs,
|
||||
invalidReason: capture.invalidReason,
|
||||
...copyPhaseEvents(capture),
|
||||
requestReceivedEpochMs: capture.requestReceivedEpochMs,
|
||||
...threadCpu,
|
||||
threadCpuUnavailableReason: capture.threadCpuUnavailableReason,
|
||||
@@ -226,6 +228,18 @@ export function createWorkerPerformanceCaptureResult(
|
||||
}
|
||||
}
|
||||
|
||||
function copyPhaseEvents(
|
||||
capture: WorkerPerformanceCapture
|
||||
): Pick<WorkerPerformanceCaptureResult, 'phaseEvents'> {
|
||||
try {
|
||||
return capture.phaseEvents.length > 0
|
||||
? { phaseEvents: Object.freeze([...capture.phaseEvents]) }
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function finishWorkerPerformanceMetricBoundaries(
|
||||
capture: WorkerPerformanceCapture
|
||||
): void {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { EventLoopUtilization } from 'node:perf_hooks';
|
||||
import type {
|
||||
AppPlaylistPerformancePhase,
|
||||
PerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const WORKER_PERFORMANCE_INVALID_REASON = {
|
||||
OVERLAPPING_DATABASE_WORKER_REQUESTS:
|
||||
@@ -46,7 +50,9 @@ export interface WorkerPerformanceCaptureResult {
|
||||
eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
|
||||
histogramFlushedEpochMs: number | null;
|
||||
invalidReason: WorkerPerformanceInvalidReason | null;
|
||||
phaseEvents?: readonly PerformancePhaseEvent<AppPlaylistPerformancePhase>[];
|
||||
requestReceivedEpochMs: number;
|
||||
responsePostedEpochMs?: number;
|
||||
threadCpuSystemMicros: number | null;
|
||||
threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
|
||||
threadCpuUserMicros: number | null;
|
||||
@@ -65,6 +71,7 @@ export interface WorkerPerformanceCaptureRuntime {
|
||||
|
||||
export interface WorkerPerformanceCaptureOptions {
|
||||
enabled?: boolean;
|
||||
requestId?: string;
|
||||
runtime?: WorkerPerformanceCaptureRuntime;
|
||||
}
|
||||
|
||||
@@ -84,7 +91,9 @@ export interface WorkerPerformanceCapture {
|
||||
histogramDisabled: boolean;
|
||||
histogramFlushedEpochMs: number | null;
|
||||
invalidReason: WorkerPerformanceInvalidReason | null;
|
||||
phaseEvents: PerformancePhaseEvent<AppPlaylistPerformancePhase>[];
|
||||
requestReceivedEpochMs: number;
|
||||
requestId: string | null;
|
||||
runtime: WorkerPerformanceCaptureRuntime;
|
||||
threadCpuEnd: NodeJS.CpuUsage | null;
|
||||
threadCpuStart: NodeJS.CpuUsage | null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
|
||||
armWorkerPerformanceCapture,
|
||||
executeWithWorkerPerformanceCapture,
|
||||
stampWorkerPerformanceResponsePostedEpoch,
|
||||
startWorkerPerformanceCapture,
|
||||
} from './worker-performance-capture';
|
||||
import { createFakeRuntime } from './worker-performance-capture.test-harness';
|
||||
@@ -86,6 +87,34 @@ describe('worker performance capture', () => {
|
||||
expect('reset' in harness.histogram).toBe(false);
|
||||
});
|
||||
|
||||
it('stamps the worker-send boundary only after request profiling has finished', async () => {
|
||||
const harness = createFakeRuntime();
|
||||
const capture = startWorkerPerformanceCapture({
|
||||
enabled: true,
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
await armWorkerPerformanceCapture(capture);
|
||||
const execution = await executeWithWorkerPerformanceCapture(
|
||||
capture,
|
||||
async () => 'result'
|
||||
);
|
||||
|
||||
const stamped = stampWorkerPerformanceResponsePostedEpoch(
|
||||
capture,
|
||||
execution.performance
|
||||
);
|
||||
|
||||
expect(stamped).toMatchObject({
|
||||
histogramFlushedEpochMs: 145,
|
||||
responsePostedEpochMs: 149,
|
||||
workEndedEpochMs: 140,
|
||||
});
|
||||
expect(harness.lifecycle.at(-1)).toBe('epoch:149');
|
||||
expect(
|
||||
stampWorkerPerformanceResponsePostedEpoch(null, undefined)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finishes profiling before returning a rejected execution to worker side effects', async () => {
|
||||
const harness = createFakeRuntime();
|
||||
const capture = startWorkerPerformanceCapture({
|
||||
|
||||
@@ -57,7 +57,13 @@ export function startWorkerPerformanceCapture(
|
||||
histogramDisabled: false,
|
||||
histogramFlushedEpochMs: null,
|
||||
invalidReason: null,
|
||||
phaseEvents: [],
|
||||
requestReceivedEpochMs: safeReadEpochMs(runtime),
|
||||
requestId:
|
||||
typeof options.requestId === 'string' &&
|
||||
options.requestId.length > 0
|
||||
? options.requestId
|
||||
: null,
|
||||
runtime,
|
||||
threadCpuEnd: null,
|
||||
threadCpuStart: null,
|
||||
@@ -269,3 +275,21 @@ export function releaseDatabaseWorkerPerformanceCapture(
|
||||
activeCaptures.delete(capture);
|
||||
}
|
||||
}
|
||||
|
||||
export function stampWorkerPerformanceResponsePostedEpoch(
|
||||
capture: WorkerPerformanceCapture | null,
|
||||
performance: WorkerPerformanceCaptureResult | undefined
|
||||
): WorkerPerformanceCaptureResult | undefined {
|
||||
if (!capture || !performance) {
|
||||
return performance;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
...performance,
|
||||
responsePostedEpochMs: safeReadEpochMs(capture.runtime),
|
||||
};
|
||||
} catch {
|
||||
return performance;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ describe('worker performance integration order', () => {
|
||||
expect(
|
||||
handler.indexOf('executeWithWorkerPerformanceCapture')
|
||||
).toBeLessThan(handler.indexOf('postMessage'));
|
||||
expect(
|
||||
handler.match(/stampWorkerPerformanceResponsePostedEpoch/g)
|
||||
).toHaveLength(2);
|
||||
expect(handler).toMatch(
|
||||
/postMessage\(\{[\s\S]*?performance:\s*stampWorkerPerformanceResponsePostedEpoch\([\s\S]*?execution\.performance[\s\S]*?\),[\s\S]*?\}\)/
|
||||
);
|
||||
expect(handler).not.toContain('await previous');
|
||||
expect(handler).not.toContain('requestQueue');
|
||||
});
|
||||
@@ -52,6 +58,34 @@ describe('worker performance integration order', () => {
|
||||
expect(executionIndex).toBeLessThan(
|
||||
handler.indexOf('postMessage', executionIndex)
|
||||
);
|
||||
expect(
|
||||
handler.match(/stampWorkerPerformanceResponsePostedEpoch/g)
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('passes the request capture into the single-playlist upsert boundary', () => {
|
||||
const source = readWorkerSource('database.worker.ts');
|
||||
const upsertCase = source.slice(
|
||||
source.indexOf("case 'DB_UPSERT_APP_PLAYLIST':"),
|
||||
source.indexOf("case 'DB_UPSERT_APP_PLAYLISTS':")
|
||||
);
|
||||
|
||||
expect(upsertCase).toContain('captureWorkerPerformancePhase');
|
||||
expect(upsertCase).toContain('performanceCapture');
|
||||
expect(upsertCase).toContain('upsertAppPlaylist');
|
||||
});
|
||||
|
||||
it('passes async read and sync deserialize capture into the single-playlist GET boundary', () => {
|
||||
const source = readWorkerSource('database.worker.ts');
|
||||
const getCase = source.slice(
|
||||
source.indexOf("case 'DB_GET_APP_PLAYLIST':"),
|
||||
source.indexOf("case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS':")
|
||||
);
|
||||
|
||||
expect(getCase).toContain('captureWorkerPerformancePhaseAsync');
|
||||
expect(getCase).toContain('captureWorkerPerformancePhase');
|
||||
expect(getCase).toContain('performanceCapture');
|
||||
expect(getCase).toContain('getAppPlaylist');
|
||||
});
|
||||
|
||||
it('preserves success and business errors when the finish seam throws once', async () => {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
|
||||
type PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import {
|
||||
finishWorkerPerformanceCapture,
|
||||
startWorkerPerformanceCapture,
|
||||
} from './worker-performance-capture';
|
||||
import {
|
||||
captureWorkerPerformancePhase,
|
||||
captureWorkerPerformancePhaseAsync,
|
||||
} from './worker-performance-phase';
|
||||
import { createFakeRuntime } from './worker-performance-capture.test-harness';
|
||||
|
||||
describe('worker request performance phases', () => {
|
||||
it('executes directly without phase allocation or metadata work when disabled', () => {
|
||||
const resultIdentity = { success: true };
|
||||
const execute = jest.fn(() => resultIdentity);
|
||||
const metadata = jest.fn(() => {
|
||||
throw new Error('metadata must remain untouched');
|
||||
});
|
||||
|
||||
const result = captureWorkerPerformancePhase(
|
||||
null,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
|
||||
execute,
|
||||
metadata
|
||||
);
|
||||
|
||||
expect(result).toBe(resultIdentity);
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(metadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds ordered request-scoped pairs to the existing performance envelope', async () => {
|
||||
const harness = createFakeRuntime();
|
||||
const monotonicValues = [10, 17, 20, 31];
|
||||
const capture = startWorkerPerformanceCapture({
|
||||
enabled: true,
|
||||
requestId: 'db-request-1',
|
||||
runtime: {
|
||||
...harness.runtime,
|
||||
readMonotonicMs: () => monotonicValues.shift() ?? 31,
|
||||
},
|
||||
});
|
||||
const rowIdentity = { payload: '{"playlist":"synthetic"}' };
|
||||
|
||||
expect(
|
||||
captureWorkerPerformancePhase(
|
||||
capture,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
|
||||
() => rowIdentity,
|
||||
() =>
|
||||
({
|
||||
byteCount: 24,
|
||||
itemCount: 100_000,
|
||||
secret: 'must-not-be-copied',
|
||||
}) as PerformancePhaseMetadata
|
||||
)
|
||||
).toBe(rowIdentity);
|
||||
captureWorkerPerformancePhase(
|
||||
capture,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
|
||||
() => undefined,
|
||||
() => ({ byteCount: 24, itemCount: 1 })
|
||||
);
|
||||
|
||||
const result = await finishWorkerPerformanceCapture(capture);
|
||||
|
||||
expect(result?.phaseEvents).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 110,
|
||||
metadata: undefined,
|
||||
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
|
||||
requestId: 'db-request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 7,
|
||||
epochMs: 140,
|
||||
metadata: { byteCount: 24, itemCount: 100_000 },
|
||||
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
|
||||
requestId: 'db-request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 145,
|
||||
metadata: undefined,
|
||||
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
|
||||
requestId: 'db-request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 11,
|
||||
epochMs: 149,
|
||||
metadata: { byteCount: 24, itemCount: 1 },
|
||||
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
|
||||
requestId: 'db-request-1',
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(result?.phaseEvents)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('preserves exact error identity and still closes the phase pair', async () => {
|
||||
const harness = createFakeRuntime();
|
||||
const capture = startWorkerPerformanceCapture({
|
||||
enabled: true,
|
||||
requestId: 'db-request-error',
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const businessError = new Error('sqlite write failed');
|
||||
|
||||
expect(() =>
|
||||
captureWorkerPerformancePhase(
|
||||
capture,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
|
||||
() => {
|
||||
throw businessError;
|
||||
},
|
||||
() => {
|
||||
throw new Error('metadata failed');
|
||||
}
|
||||
)
|
||||
).toThrow(businessError);
|
||||
|
||||
const result = await finishWorkerPerformanceCapture(capture);
|
||||
expect(
|
||||
result?.phaseEvents?.map(({ boundary, metadata }) => ({
|
||||
boundary,
|
||||
metadata,
|
||||
}))
|
||||
).toEqual([
|
||||
{ boundary: 'start', metadata: undefined },
|
||||
{ boundary: 'end', metadata: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the exact business promise without metadata work when async capture is disabled', async () => {
|
||||
const resultIdentity = { rows: 1 };
|
||||
const businessPromise = Promise.resolve(resultIdentity);
|
||||
const execute = jest.fn(() => businessPromise);
|
||||
const metadata = jest.fn(() => {
|
||||
throw new Error('metadata must remain untouched');
|
||||
});
|
||||
|
||||
const result = captureWorkerPerformancePhaseAsync(
|
||||
null,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
execute,
|
||||
metadata
|
||||
);
|
||||
|
||||
expect(result).toBe(businessPromise);
|
||||
await expect(result).resolves.toBe(resultIdentity);
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(metadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes an async phase only after the awaited operation settles', async () => {
|
||||
const harness = createFakeRuntime();
|
||||
const monotonicValues = [10, 27];
|
||||
const capture = startWorkerPerformanceCapture({
|
||||
enabled: true,
|
||||
requestId: 'db-get-request',
|
||||
runtime: {
|
||||
...harness.runtime,
|
||||
readMonotonicMs: () => monotonicValues.shift() ?? 27,
|
||||
},
|
||||
});
|
||||
const rows = [{ id: 'synthetic-playlist' }];
|
||||
let resolveRows!: (value: typeof rows) => void;
|
||||
const pendingRows = new Promise<typeof rows>((resolve) => {
|
||||
resolveRows = resolve;
|
||||
});
|
||||
|
||||
const result = captureWorkerPerformancePhaseAsync(
|
||||
capture,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
() => pendingRows,
|
||||
(value) => ({ itemCount: value.length })
|
||||
);
|
||||
|
||||
expect(capture?.phaseEvents).toHaveLength(1);
|
||||
resolveRows(rows);
|
||||
await expect(result).resolves.toBe(rows);
|
||||
expect(capture?.phaseEvents).toHaveLength(2);
|
||||
|
||||
const performance = await finishWorkerPerformanceCapture(capture);
|
||||
expect(performance?.phaseEvents).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 110,
|
||||
metadata: undefined,
|
||||
phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
requestId: 'db-get-request',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 17,
|
||||
epochMs: 140,
|
||||
metadata: { itemCount: 1 },
|
||||
phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
requestId: 'db-get-request',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves async rejection identity and closes the phase without metadata', async () => {
|
||||
const harness = createFakeRuntime();
|
||||
const capture = startWorkerPerformanceCapture({
|
||||
enabled: true,
|
||||
requestId: 'db-get-error',
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const businessError = new Error('sqlite read failed');
|
||||
|
||||
await expect(
|
||||
captureWorkerPerformancePhaseAsync(
|
||||
capture,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
() => Promise.reject(businessError),
|
||||
() => {
|
||||
throw new Error('metadata must not run for errors');
|
||||
}
|
||||
)
|
||||
).rejects.toBe(businessError);
|
||||
|
||||
const result = await finishWorkerPerformanceCapture(capture);
|
||||
expect(
|
||||
result?.phaseEvents?.map(({ boundary, metadata }) => ({
|
||||
boundary,
|
||||
metadata,
|
||||
}))
|
||||
).toEqual([
|
||||
{ boundary: 'start', metadata: undefined },
|
||||
{ boundary: 'end', metadata: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
PERFORMANCE_PHASE_BOUNDARY,
|
||||
type AppPlaylistPerformancePhase,
|
||||
type PerformancePhaseEvent,
|
||||
type PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { WorkerPerformanceCapture } from './worker-performance-capture.model';
|
||||
import { safeReadEpochMs } from './worker-performance-capture.runtime';
|
||||
|
||||
export function captureWorkerPerformancePhase<TResult>(
|
||||
capture: WorkerPerformanceCapture | null,
|
||||
phase: AppPlaylistPerformancePhase,
|
||||
execute: () => TResult,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
): TResult {
|
||||
if (!capture || !capture.requestId) {
|
||||
return execute();
|
||||
}
|
||||
|
||||
const startMonotonicMs = safeReadMonotonicMs(capture);
|
||||
appendPhaseEvent(capture, {
|
||||
boundary: PERFORMANCE_PHASE_BOUNDARY.START,
|
||||
durationMs: null,
|
||||
epochMs: safeReadEpochMs(capture.runtime),
|
||||
metadata: undefined,
|
||||
phase,
|
||||
requestId: capture.requestId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = execute();
|
||||
const endMonotonicMs = safeReadMonotonicMs(capture);
|
||||
appendPhaseEvent(capture, {
|
||||
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
|
||||
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
|
||||
epochMs: safeReadEpochMs(capture.runtime),
|
||||
metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)),
|
||||
phase,
|
||||
requestId: capture.requestId,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const endMonotonicMs = safeReadMonotonicMs(capture);
|
||||
appendPhaseEvent(capture, {
|
||||
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
|
||||
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
|
||||
epochMs: safeReadEpochMs(capture.runtime),
|
||||
metadata: undefined,
|
||||
phase,
|
||||
requestId: capture.requestId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function captureWorkerPerformancePhaseAsync<TResult>(
|
||||
capture: WorkerPerformanceCapture | null,
|
||||
phase: AppPlaylistPerformancePhase,
|
||||
execute: () => Promise<TResult>,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
): Promise<TResult> {
|
||||
const requestId = capture?.requestId;
|
||||
if (!capture || !requestId) {
|
||||
return execute();
|
||||
}
|
||||
|
||||
return captureAsync(capture, requestId, phase, execute, metadata);
|
||||
}
|
||||
|
||||
async function captureAsync<TResult>(
|
||||
capture: WorkerPerformanceCapture,
|
||||
requestId: string,
|
||||
phase: AppPlaylistPerformancePhase,
|
||||
execute: () => Promise<TResult>,
|
||||
metadata?: (result: TResult) => PerformancePhaseMetadata
|
||||
): Promise<TResult> {
|
||||
const startMonotonicMs = safeReadMonotonicMs(capture);
|
||||
appendPhaseEvent(capture, {
|
||||
boundary: PERFORMANCE_PHASE_BOUNDARY.START,
|
||||
durationMs: null,
|
||||
epochMs: safeReadEpochMs(capture.runtime),
|
||||
metadata: undefined,
|
||||
phase,
|
||||
requestId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute();
|
||||
const endMonotonicMs = safeReadMonotonicMs(capture);
|
||||
appendPhaseEvent(capture, {
|
||||
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
|
||||
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
|
||||
epochMs: safeReadEpochMs(capture.runtime),
|
||||
metadata: sanitizeMetadata(
|
||||
safelyCreateMetadata(metadata, result)
|
||||
),
|
||||
phase,
|
||||
requestId,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const endMonotonicMs = safeReadMonotonicMs(capture);
|
||||
appendPhaseEvent(capture, {
|
||||
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
|
||||
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
|
||||
epochMs: safeReadEpochMs(capture.runtime),
|
||||
metadata: undefined,
|
||||
phase,
|
||||
requestId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadMonotonicMs(capture: WorkerPerformanceCapture): number {
|
||||
try {
|
||||
const value = capture.runtime.readMonotonicMs();
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function safeDuration(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function safelyCreateMetadata<TResult>(
|
||||
createMetadata: ((result: TResult) => PerformancePhaseMetadata) | undefined,
|
||||
result: TResult
|
||||
): PerformancePhaseMetadata | undefined {
|
||||
if (!createMetadata) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return createMetadata(result);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMetadata(
|
||||
metadata: PerformancePhaseMetadata | undefined
|
||||
): PerformancePhaseMetadata | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sanitized: { byteCount?: number; itemCount?: number } = {};
|
||||
if (isCount(metadata.byteCount)) {
|
||||
sanitized.byteCount = metadata.byteCount;
|
||||
}
|
||||
if (isCount(metadata.itemCount)) {
|
||||
sanitized.itemCount = metadata.itemCount;
|
||||
}
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function isCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function appendPhaseEvent(
|
||||
capture: WorkerPerformanceCapture,
|
||||
event: PerformancePhaseEvent<AppPlaylistPerformancePhase>
|
||||
): void {
|
||||
try {
|
||||
capture.phaseEvents.push(event);
|
||||
} catch {
|
||||
// Phase instrumentation is deliberately fail-neutral.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
type Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { ElectronService } from './electron.service';
|
||||
|
||||
describe('ElectronService performance phases', () => {
|
||||
const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
const electronBridge = {
|
||||
fetchPlaylistByUrl: jest.fn(),
|
||||
onPlayerError: jest.fn(),
|
||||
};
|
||||
const store = { dispatch: jest.fn() };
|
||||
let service: ElectronService;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: electronBridge,
|
||||
});
|
||||
electronBridge.fetchPlaylistByUrl.mockReset();
|
||||
electronBridge.onPlayerError.mockReset();
|
||||
store.dispatch.mockReset();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ElectronService,
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: { openConfirmDialog: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: { open: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
getTrustOptions: jest.fn(() => ({
|
||||
trustedInsecureTlsHosts: [],
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: store },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: jest.fn((key: string) => key) },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(ElectronService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as Record<symbol, unknown>)[hookSymbol];
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the initial URL import store dispatch without exposing playlist data', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
(
|
||||
globalThis as unknown as Record<
|
||||
symbol,
|
||||
(event: RendererPerformancePhaseEvent) => void
|
||||
>
|
||||
)[hookSymbol] = (event) => events.push(event);
|
||||
const playlist = {
|
||||
playlist: { items: [{ name: 'Sensitive channel' }] },
|
||||
title: 'Sensitive title',
|
||||
url: 'https://user:secret@example.test/list.m3u',
|
||||
} as Playlist;
|
||||
electronBridge.fetchPlaylistByUrl.mockResolvedValue(playlist);
|
||||
|
||||
await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
|
||||
title: playlist.title,
|
||||
url: playlist.url,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.handleAddingPlaylistByUrl({
|
||||
isTemporary: false,
|
||||
playlist,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
events.map(({ boundary, outcome, phase }) => ({
|
||||
boundary,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
outcome: undefined,
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toContain('Sensitive');
|
||||
expect(JSON.stringify(events)).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
XTREAM_RESPONSE,
|
||||
XtreamCodeActions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { AppConfig } from '../../environments/environment';
|
||||
import { buildAutoUpdatePlaylistsFeedback } from './auto-update-playlists-feedback';
|
||||
import {
|
||||
@@ -316,11 +320,15 @@ export class ElectronService extends DataService {
|
||||
this.settingsStore.getTrustOptions()
|
||||
)
|
||||
.then((result) => {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.handleAddingPlaylistByUrl({
|
||||
isTemporary: !!payload?.isTemporary,
|
||||
playlist: result,
|
||||
})
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.M3U_IMPORT_DISPATCH,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.handleAddingPlaylistByUrl({
|
||||
isTemporary: !!payload?.isTemporary,
|
||||
playlist: result,
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
||||
@@ -56,6 +56,95 @@ There is intentionally **no URL validation** (upstream removed it in 0.15.0): an
|
||||
|
||||
The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.contract.spec.ts` (jest maps the module to the real parser source) and by the fork's own test suite.
|
||||
|
||||
## Initial URL Import Performance Benchmark (Electron)
|
||||
|
||||
The Electron E2E project has a deterministic initial-import benchmark for
|
||||
10,000-, 50,000-, and 100,000-channel M3U playlists. It uses only generated
|
||||
fixtures served by an ephemeral `127.0.0.1` HTTP server; provider URLs,
|
||||
credentials, and playlist data must never be used. Each formal scenario runs
|
||||
one warm-up, five measured iterations, and one diagnostic iteration in a fresh
|
||||
Electron process and data directory:
|
||||
|
||||
```bash
|
||||
perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-import"
|
||||
IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \
|
||||
IPTVNATOR_PERF_VARIANT=baseline \
|
||||
pnpm nx run electron-backend-e2e:benchmark-m3u-import
|
||||
```
|
||||
|
||||
Use a new output directory and `IPTVNATOR_PERF_VARIANT=after` for the identical
|
||||
post-change run. Formal runs require a clean worktree and record the commit,
|
||||
source-state hash, runtime, and exact fixture identity. A development smoke run
|
||||
uses one warm-up, one measured iteration, and one diagnostic iteration per
|
||||
size:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_PERF_OUTPUT_DIR="$PWD/dist/performance/<timestamp>-m3u-import-smoke" \
|
||||
IPTVNATOR_PERF_VARIANT=smoke \
|
||||
IPTVNATOR_PERF_SMOKE=1 \
|
||||
pnpm nx run electron-backend-e2e:benchmark-m3u-import
|
||||
```
|
||||
|
||||
Smoke runs may use a dirty worktree and validate only the harness; they cannot
|
||||
support a performance claim. If another local application owns port 9222, smoke
|
||||
only may set `IPTVNATOR_PERF_CDP_PORT` to an unused loopback port; formal runs
|
||||
fail closed unless CDP uses `127.0.0.1:9222`. Raw captures and JSON results stay
|
||||
under the gitignored `dist/performance/` tree; preflight rejects a symbolic
|
||||
link in any existing output-path component before creating artifacts. Headline
|
||||
distributions contain only the five measured runs: warm-up and diagnostic
|
||||
profiles are never mixed into them.
|
||||
|
||||
The benchmark attributes these non-additive intervals:
|
||||
|
||||
| Field | Boundary |
|
||||
| --- | --- |
|
||||
| `dataAcquireMs` | loopback response acquisition |
|
||||
| `m3uParsingMs` | parser call |
|
||||
| `normalizationMs` | parsed-item normalization |
|
||||
| `mainToRendererCloneProxyMs` | sum of normalized import-result delivery plus the upsert and GET main-response-to-preload-success legs |
|
||||
| `storeImportDispatchMs` | renderer import dispatch |
|
||||
| `rendererToMainCloneProxyMs` | sum of the upsert and GET preload-source-to-main-request legs |
|
||||
| `mainToDatabaseWorkerCloneProxyMs` | sum of the upsert and GET main-request-to-worker-receive legs |
|
||||
| `playlistSerializationMs` | database-worker playlist JSON serialization |
|
||||
| `sqliteWriteMs` | SQLite upsert, including its autocommit |
|
||||
| `sqliteReadMs` | SQLite read of the newly persisted playlist |
|
||||
| `playlistDeserializationMs` | database-worker `parseAppPlaylist`, including playlist JSON parsing |
|
||||
| `databaseWorkerToMainCloneProxyMs` | sum of the upsert and GET worker-response-post-to-main-response legs |
|
||||
| `storePublishChannelsMs` | renderer channel publication |
|
||||
| `angularRenderingMs` | publication end to the terminal two-frame paint proof |
|
||||
|
||||
`ipcStructuredCloneProxyMs` is the explicit sum of the four directional proxy
|
||||
fields. Each directional field can combine the applicable initial-result,
|
||||
upsert, and `DB_GET_APP_PLAYLIST` legs. The worker stamps
|
||||
`responsePostedEpochMs` after request profiling is finalized and immediately
|
||||
before each response is posted; the database-worker-to-main proxy ends when
|
||||
main receives that response. These fields are attribution aids, not an additive
|
||||
waterfall: scheduler work and gaps can remain inside total wall time, and a
|
||||
proxy can include response construction, dispatch overhead, and structured
|
||||
clone work. Initial import does not create indexes; `indexAndCommitMs` is
|
||||
therefore `N/A` with
|
||||
`indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write`.
|
||||
|
||||
Instrumentation is development-only, opt-in, fail-neutral, and count-only. It
|
||||
must not scan or log playlist payloads to generate metadata. Renderer
|
||||
long-task, frame-gap, and heartbeat samples are clipped to the measured
|
||||
operation boundary. Main capture stops only after the upsert and route-reload
|
||||
GET responses plus both asynchronous preload success markers have arrived, so
|
||||
return-clone attribution cannot race capture shutdown. Formal comparison fails
|
||||
closed after writing raw results if exact-window renderer RSS, database-worker
|
||||
peak heap/external samples, or the database worker's explicit post-GC heap is
|
||||
missing or incoherent. Both initial-import database requests in every measured
|
||||
run must also contain coherent event-loop delay, event-loop utilization, and
|
||||
thread-CPU metrics; the validity record exposes the exact expected and valid
|
||||
request counts rather than silently dropping nullable samples. An iteration
|
||||
failure idempotently stops renderer timers, closes trace listeners/output, and
|
||||
starts best-effort probe/session teardown without waiting on a wedged renderer
|
||||
before Electron is closed. A partial capture-start failure performs the same
|
||||
rollback before it escapes to the benchmark lifecycle.
|
||||
Diagnostic artifacts require separate renderer, main, and database-worker CPU
|
||||
profiles plus renderer/main/database-worker heap snapshots and a Chromium
|
||||
trace; raw profiles remain ignored.
|
||||
|
||||
## Playlist Refresh And Startup Auto-Update (Electron)
|
||||
|
||||
Two paths re-download an M3U playlist from its original source:
|
||||
|
||||
@@ -128,6 +128,8 @@ Each enabled request gets a fresh event-loop-delay histogram and records:
|
||||
|
||||
- `requestReceivedEpochMs`, `workStartedEpochMs`, `workEndedEpochMs`, and
|
||||
`histogramFlushedEpochMs`
|
||||
- `responsePostedEpochMs`, sampled after profiling finalization and immediately
|
||||
before the worker posts the response to main
|
||||
- worker-thread CPU user/system microseconds from `process.threadCpuUsage()`
|
||||
- event-loop utilization across the exact work interval
|
||||
- event-loop-delay max/p95/p99 from the request's own histogram
|
||||
@@ -146,6 +148,46 @@ profiling queue. If captures overlap, every overlapping response carries
|
||||
`invalidReason: "overlapping-database-worker-requests"` and all attributable
|
||||
CPU, ELU, and event-loop-delay values are `null`. This avoids assigning shared
|
||||
worker activity to one request while preserving normal worker concurrency.
|
||||
`responsePostedEpochMs` remains a separate response boundary: the initial M3U
|
||||
benchmark subtracts it from main's response receipt to attribute the
|
||||
database-worker-to-main structured-clone proxy without folding that interval
|
||||
into worker execution.
|
||||
|
||||
Initial M3U import profiling requires exact operation-specific phase pairs:
|
||||
|
||||
- `DB_UPSERT_APP_PLAYLIST`: `serialize.playlist`, then `sqlite.write`. The
|
||||
first covers playlist JSON serialization; the second covers the SQLite
|
||||
upsert and its autocommit.
|
||||
- `DB_GET_APP_PLAYLIST`: `sqlite.read`, then `deserialize.playlist`. The first
|
||||
covers the awaited single-row SQLite select; the second covers
|
||||
`parseAppPlaylist`, including JSON parsing and persisted-field hydration.
|
||||
|
||||
Missing, partial, reordered, or cross-operation phase sequences fail closed.
|
||||
Markers carry item counts only and do not scan or copy the payload to compute
|
||||
profiling metadata. The import creates no indexes, so there is no separate
|
||||
index/transaction-commit phase. Across the upsert and GET requests, the formal
|
||||
benchmark reports renderer-to-main, main-to-database-worker,
|
||||
database-worker-to-main, and main-to-renderer structured-clone proxies; their
|
||||
explicit sum is `ipcStructuredCloneProxyMs`. See
|
||||
[M3U Playlist Module Architecture](./m3u-playlist-module.md#initial-url-import-performance-benchmark-electron)
|
||||
for the complete cross-process attribution.
|
||||
|
||||
Formal initial-import comparison also requires both request-scoped captures in
|
||||
every measured run to have coherent event-loop delay, event-loop utilization,
|
||||
and worker-thread CPU values with no unavailable or invalid reason. Summary
|
||||
validity records the exact expected and valid request counts; nullable metrics
|
||||
remain in raw results but cannot be silently omitted from comparison
|
||||
distributions.
|
||||
|
||||
The main-process benchmark samples the database worker's V8
|
||||
`used_heap_size` and `external_memory` independently. Raw output includes a
|
||||
valid-sample count for each metric. A peak is numeric only after at least one
|
||||
finite, non-negative isolate sample; otherwise it is `null` with a fixed
|
||||
unavailability reason. An initialized zero is never used as evidence of a
|
||||
successful sample. Formal initial-import comparisons require valid peak
|
||||
samples from exactly one database worker in every measured run. Worker RSS is
|
||||
not available per thread and remains part of the separately reported Electron
|
||||
main-process RSS together with native and SQLite memory.
|
||||
|
||||
### Opt-in post-GC heap capture
|
||||
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { type Channel, type Playlist } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service';
|
||||
|
||||
const PLAYLIST_ID = 'performance-playlist';
|
||||
const PRIMARY_CHANNEL = {
|
||||
id: 'channel-1',
|
||||
name: 'Sensitive channel',
|
||||
url: 'https://stream-secret.example.test/1',
|
||||
} as Channel;
|
||||
const NEXT_CHANNEL = {
|
||||
id: 'channel-2',
|
||||
name: 'Next sensitive channel',
|
||||
url: 'https://stream-secret.example.test/2',
|
||||
} as Channel;
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('M3uWorkspaceRouteSession performance phases', () => {
|
||||
const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
const store = { dispatch: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: { setUserAgent: jest.fn().mockResolvedValue(true) },
|
||||
});
|
||||
store.dispatch.mockReset();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
M3uWorkspaceRouteSession,
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
syncFromUrl: jest.fn(() => ({
|
||||
inWorkspace: true,
|
||||
playlistId: PLAYLIST_ID,
|
||||
provider: 'playlists',
|
||||
section: 'all',
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getPlaylist: jest.fn(() =>
|
||||
of({
|
||||
playlist: {
|
||||
items: [PRIMARY_CHANNEL, NEXT_CHANNEL],
|
||||
},
|
||||
title: 'Sensitive title',
|
||||
url: 'https://user:secret@example.test/list.m3u',
|
||||
} as Playlist)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: {
|
||||
events: new Subject().asObservable(),
|
||||
url: `/workspace/playlists/${PLAYLIST_ID}/all`,
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: store },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as Record<symbol, unknown>)[hookSymbol];
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('marks channel publication with only the imported item count', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
(
|
||||
globalThis as unknown as Record<
|
||||
symbol,
|
||||
(event: RendererPerformancePhaseEvent) => void
|
||||
>
|
||||
)[hookSymbol] = (event) => events.push(event);
|
||||
|
||||
TestBed.inject(M3uWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
events.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
metadata,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
metadata: { items: 2 },
|
||||
outcome: undefined,
|
||||
phase: 'store.m3u-publish-channels',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
metadata: { items: 2 },
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-publish-channels',
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toContain('Sensitive');
|
||||
expect(JSON.stringify(events)).not.toContain('secret');
|
||||
expect(JSON.stringify(events)).not.toContain(PRIMARY_CHANNEL.url);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
@@ -107,10 +111,16 @@ export class M3uWorkspaceRouteSession {
|
||||
);
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannels({
|
||||
channels: playlist.playlist?.items ?? [],
|
||||
})
|
||||
const channels = playlist.playlist?.items ?? [];
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.M3U_PUBLISH_CHANNELS,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannels({
|
||||
channels,
|
||||
})
|
||||
),
|
||||
() => ({ items: channels.length })
|
||||
);
|
||||
|
||||
const favorites = (playlist.favorites ?? []).filter(
|
||||
|
||||
@@ -19,6 +19,7 @@ export * from './lib/ipc-commands';
|
||||
export * from './lib/language.enum';
|
||||
export * from './lib/m3u-favorite-channel.interface';
|
||||
export * from './lib/parsed-playlist.interface';
|
||||
export * from './lib/performance-phase.interface';
|
||||
export * from './lib/playback-position.interface';
|
||||
export * from './lib/playlist-auto-update.interface';
|
||||
export * from './lib/playlist-backup.interface';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export const PERFORMANCE_PHASE_BOUNDARY = {
|
||||
END: 'end',
|
||||
START: 'start',
|
||||
} as const;
|
||||
|
||||
export type PerformancePhaseBoundary =
|
||||
(typeof PERFORMANCE_PHASE_BOUNDARY)[keyof typeof PERFORMANCE_PHASE_BOUNDARY];
|
||||
|
||||
export const M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL =
|
||||
'IPTVNATOR_M3U_IMPORT_PERFORMANCE_PHASE_EVENT';
|
||||
|
||||
export const M3U_IMPORT_PERFORMANCE_PHASE = {
|
||||
DATA_ACQUIRE: 'data.acquire',
|
||||
NORMALIZE: 'normalize',
|
||||
PARSE_M3U: 'parse.m3u',
|
||||
} as const;
|
||||
|
||||
export type M3uImportPerformancePhase =
|
||||
(typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE];
|
||||
|
||||
export const APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE = {
|
||||
SERIALIZE_PLAYLIST: 'serialize.playlist',
|
||||
SQLITE_WRITE: 'sqlite.write',
|
||||
} as const;
|
||||
|
||||
export type AppPlaylistUpsertPerformancePhase =
|
||||
(typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE];
|
||||
|
||||
export const APP_PLAYLIST_GET_PERFORMANCE_PHASE = {
|
||||
DESERIALIZE_PLAYLIST: 'deserialize.playlist',
|
||||
SQLITE_READ: 'sqlite.read',
|
||||
} as const;
|
||||
|
||||
export type AppPlaylistGetPerformancePhase =
|
||||
(typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE];
|
||||
|
||||
export type AppPlaylistPerformancePhase =
|
||||
| AppPlaylistGetPerformancePhase
|
||||
| AppPlaylistUpsertPerformancePhase;
|
||||
|
||||
export interface PerformancePhaseMetadata {
|
||||
readonly byteCount?: number;
|
||||
readonly itemCount?: number;
|
||||
}
|
||||
|
||||
export interface PerformancePhaseEvent<TPhase extends string = string> {
|
||||
readonly boundary: PerformancePhaseBoundary;
|
||||
readonly durationMs: number | null;
|
||||
readonly epochMs: number;
|
||||
readonly metadata?: PerformancePhaseMetadata;
|
||||
readonly phase: TPhase;
|
||||
readonly requestId: string;
|
||||
}
|
||||
@@ -3,3 +3,16 @@ export {
|
||||
redactSensitiveData,
|
||||
} from './lib/redact-sensitive-data';
|
||||
export type { RedactionOptions } from './lib/redact-sensitive-data';
|
||||
export {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
} from './lib/renderer-performance-phase';
|
||||
export type {
|
||||
RendererPerformancePhase,
|
||||
RendererPerformancePhaseBoundary,
|
||||
RendererPerformancePhaseEvent,
|
||||
RendererPerformancePhaseHook,
|
||||
RendererPerformancePhaseMetadata,
|
||||
RendererPerformancePhaseOutcome,
|
||||
} from './lib/renderer-performance-phase';
|
||||
@@ -0,0 +1,199 @@
|
||||
import * as sharedLogging from '../index';
|
||||
|
||||
type RendererPerformancePhase =
|
||||
'store.m3u-import-dispatch' | 'store.m3u-publish-channels';
|
||||
type RendererPerformancePhaseOutcome = 'error' | 'success';
|
||||
|
||||
interface RendererPerformancePhaseEvent {
|
||||
readonly boundary: 'end' | 'start';
|
||||
readonly epochMs: number;
|
||||
readonly metadata?: Readonly<{ items: number }>;
|
||||
readonly monotonicMs: number;
|
||||
readonly outcome?: RendererPerformancePhaseOutcome;
|
||||
readonly phase: RendererPerformancePhase;
|
||||
readonly phaseId: string;
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
type RendererPerformancePhaseHook = (
|
||||
event: RendererPerformancePhaseEvent
|
||||
) => void;
|
||||
|
||||
interface RendererPerformancePhaseModule {
|
||||
readonly measureRendererPerformancePhase?: <T>(
|
||||
phase: RendererPerformancePhase,
|
||||
execute: () => T,
|
||||
metadata?: () => Readonly<{ items: number }>
|
||||
) => T;
|
||||
readonly RENDERER_PERFORMANCE_PHASE?: Readonly<{
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch';
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels';
|
||||
}>;
|
||||
readonly RENDERER_PERFORMANCE_PHASE_HOOK_KEY?: string;
|
||||
}
|
||||
|
||||
const rendererPerformance = sharedLogging as RendererPerformancePhaseModule;
|
||||
const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1';
|
||||
const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY);
|
||||
|
||||
function measure<T>(
|
||||
phase: RendererPerformancePhase,
|
||||
execute: () => T,
|
||||
metadata?: () => Readonly<{ items: number }>
|
||||
): T {
|
||||
const measurePhase = rendererPerformance.measureRendererPerformancePhase;
|
||||
expect(typeof measurePhase).toBe('function');
|
||||
if (measurePhase === undefined) {
|
||||
throw new Error('renderer performance phase helper is unavailable');
|
||||
}
|
||||
return measurePhase(phase, execute, metadata);
|
||||
}
|
||||
|
||||
function setHook(hook: RendererPerformancePhaseHook | null): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[hookSymbol];
|
||||
return;
|
||||
}
|
||||
target[hookSymbol] = hook;
|
||||
}
|
||||
|
||||
describe('renderer performance phases', () => {
|
||||
afterEach(() => {
|
||||
setHook(null);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not evaluate metadata while the global opt-in hook is absent', () => {
|
||||
const metadata = jest.fn(() => ({ items: 3 }));
|
||||
const resultIdentity = { imported: true };
|
||||
|
||||
const result = measure(
|
||||
'store.m3u-publish-channels',
|
||||
() => resultIdentity,
|
||||
metadata
|
||||
);
|
||||
|
||||
expect(result).toBe(resultIdentity);
|
||||
expect(metadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits an immutable successful pair with epoch and monotonic timestamps', () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setHook((event) => events.push(event));
|
||||
const resultIdentity = { imported: true };
|
||||
|
||||
const result = measure(
|
||||
'store.m3u-publish-channels',
|
||||
() => resultIdentity,
|
||||
() => ({ items: 3 })
|
||||
);
|
||||
|
||||
expect(result).toBe(resultIdentity);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
boundary: 'start',
|
||||
metadata: { items: 3 },
|
||||
phase: 'store.m3u-publish-channels',
|
||||
schemaVersion: 1,
|
||||
});
|
||||
expect(events[1]).toMatchObject({
|
||||
boundary: 'end',
|
||||
metadata: { items: 3 },
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-publish-channels',
|
||||
schemaVersion: 1,
|
||||
});
|
||||
expect(events[1].phaseId).toBe(events[0].phaseId);
|
||||
expect(events[0].epochMs).toEqual(expect.any(Number));
|
||||
expect(events[0].monotonicMs).toEqual(expect.any(Number));
|
||||
expect(events[1].epochMs).toBeGreaterThanOrEqual(events[0].epochMs);
|
||||
expect(events[1].monotonicMs).toBeGreaterThanOrEqual(
|
||||
events[0].monotonicMs
|
||||
);
|
||||
expect(Object.isFrozen(events[0])).toBe(true);
|
||||
expect(Object.isFrozen(events[0].metadata)).toBe(true);
|
||||
expect(events[1].metadata).toBe(events[0].metadata);
|
||||
});
|
||||
|
||||
it('emits an error boundary and rethrows the exact product error', () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setHook((event) => events.push(event));
|
||||
const productError = new Error('dispatch failed');
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
measure('store.m3u-import-dispatch', () => {
|
||||
throw productError;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBe(productError);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[1]).toMatchObject({
|
||||
boundary: 'end',
|
||||
outcome: 'error',
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
});
|
||||
expect(events[1].phaseId).toBe(events[0].phaseId);
|
||||
});
|
||||
|
||||
it('keeps product results and errors unchanged when metadata or hooks fail', () => {
|
||||
setHook(() => {
|
||||
throw new Error('benchmark hook failed');
|
||||
});
|
||||
const resultIdentity = { imported: true };
|
||||
|
||||
expect(
|
||||
measure(
|
||||
'store.m3u-publish-channels',
|
||||
() => resultIdentity,
|
||||
() => {
|
||||
throw new Error('metadata failed');
|
||||
}
|
||||
)
|
||||
).toBe(resultIdentity);
|
||||
|
||||
const productError = new Error('product failed');
|
||||
let thrown: unknown;
|
||||
try {
|
||||
measure('store.m3u-import-dispatch', () => {
|
||||
throw productError;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toBe(productError);
|
||||
});
|
||||
|
||||
it('exposes only the fixed phase vocabulary and item-count metadata', () => {
|
||||
expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE_HOOK_KEY).toBe(
|
||||
EXPECTED_HOOK_KEY
|
||||
);
|
||||
expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE).toEqual({
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch',
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels',
|
||||
});
|
||||
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setHook((event) => events.push(event));
|
||||
measure(
|
||||
'store.m3u-publish-channels',
|
||||
() => undefined,
|
||||
() =>
|
||||
({
|
||||
items: 2,
|
||||
playlistUrl: 'https://user:secret@example.test/list.m3u',
|
||||
}) as { items: number }
|
||||
);
|
||||
|
||||
expect(events.map((event) => event.metadata)).toEqual([
|
||||
{ items: 2 },
|
||||
{ items: 2 },
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toContain('secret');
|
||||
expect(JSON.stringify(events)).not.toContain('playlistUrl');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY =
|
||||
'iptvnator.renderer.performance-phase-hook.v1';
|
||||
|
||||
export const RENDERER_PERFORMANCE_PHASE = {
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch',
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels',
|
||||
} as const;
|
||||
|
||||
export type RendererPerformancePhase =
|
||||
(typeof RENDERER_PERFORMANCE_PHASE)[keyof typeof RENDERER_PERFORMANCE_PHASE];
|
||||
|
||||
export type RendererPerformancePhaseBoundary = 'end' | 'start';
|
||||
export type RendererPerformancePhaseOutcome = 'error' | 'success';
|
||||
|
||||
export interface RendererPerformancePhaseMetadata {
|
||||
readonly items: number;
|
||||
}
|
||||
|
||||
export interface RendererPerformancePhaseEvent {
|
||||
readonly boundary: RendererPerformancePhaseBoundary;
|
||||
readonly epochMs: number;
|
||||
readonly metadata?: Readonly<RendererPerformancePhaseMetadata>;
|
||||
readonly monotonicMs: number;
|
||||
readonly outcome?: RendererPerformancePhaseOutcome;
|
||||
readonly phase: RendererPerformancePhase;
|
||||
readonly phaseId: string;
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
export type RendererPerformancePhaseHook = (
|
||||
event: RendererPerformancePhaseEvent
|
||||
) => void;
|
||||
|
||||
interface PhaseStart {
|
||||
readonly epochMs: number;
|
||||
readonly metadata?: Readonly<RendererPerformancePhaseMetadata>;
|
||||
readonly monotonicMs: number;
|
||||
readonly phase: RendererPerformancePhase;
|
||||
readonly phaseId: string;
|
||||
}
|
||||
|
||||
interface PhaseTimestamp {
|
||||
readonly epochMs: number;
|
||||
readonly monotonicMs: number;
|
||||
}
|
||||
|
||||
const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
let nextPhaseId = 0;
|
||||
|
||||
export function measureRendererPerformancePhase<T>(
|
||||
phase: RendererPerformancePhase,
|
||||
execute: () => T,
|
||||
createMetadata?: () => RendererPerformancePhaseMetadata
|
||||
): T {
|
||||
const hook = readHook();
|
||||
if (hook === null) {
|
||||
return execute();
|
||||
}
|
||||
|
||||
const start = beginPhase(phase, createMetadata);
|
||||
emit(hook, {
|
||||
boundary: 'start',
|
||||
epochMs: start.epochMs,
|
||||
metadata: start.metadata,
|
||||
monotonicMs: start.monotonicMs,
|
||||
phase: start.phase,
|
||||
phaseId: start.phaseId,
|
||||
schemaVersion: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = execute();
|
||||
endPhase(hook, start, 'success');
|
||||
return result;
|
||||
} catch (error) {
|
||||
endPhase(hook, start, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function beginPhase(
|
||||
phase: RendererPerformancePhase,
|
||||
createMetadata: (() => RendererPerformancePhaseMetadata) | undefined
|
||||
): PhaseStart {
|
||||
const timestamp = sampleTimestamp();
|
||||
return {
|
||||
...timestamp,
|
||||
metadata: readMetadata(createMetadata),
|
||||
phase,
|
||||
phaseId: `renderer-performance-${++nextPhaseId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function endPhase(
|
||||
hook: RendererPerformancePhaseHook,
|
||||
start: PhaseStart,
|
||||
outcome: RendererPerformancePhaseOutcome
|
||||
): void {
|
||||
const sampled = sampleTimestamp();
|
||||
emit(hook, {
|
||||
boundary: 'end',
|
||||
epochMs: Math.max(start.epochMs, sampled.epochMs),
|
||||
metadata: start.metadata,
|
||||
monotonicMs: Math.max(start.monotonicMs, sampled.monotonicMs),
|
||||
outcome,
|
||||
phase: start.phase,
|
||||
phaseId: start.phaseId,
|
||||
schemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function readHook(): RendererPerformancePhaseHook | null {
|
||||
try {
|
||||
const hook = (globalThis as unknown as Record<symbol, unknown>)[
|
||||
hookSymbol
|
||||
];
|
||||
return typeof hook === 'function'
|
||||
? (hook as RendererPerformancePhaseHook)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMetadata(
|
||||
createMetadata: (() => RendererPerformancePhaseMetadata) | undefined
|
||||
): Readonly<RendererPerformancePhaseMetadata> | undefined {
|
||||
if (createMetadata === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = createMetadata().items;
|
||||
return Number.isSafeInteger(items) && items >= 0
|
||||
? Object.freeze({ items })
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function emit(
|
||||
hook: RendererPerformancePhaseHook,
|
||||
event: RendererPerformancePhaseEvent
|
||||
): void {
|
||||
try {
|
||||
hook(Object.freeze(event));
|
||||
} catch {
|
||||
// Development-only benchmark instrumentation must stay fail-neutral.
|
||||
}
|
||||
}
|
||||
|
||||
function sampleTimestamp(): PhaseTimestamp {
|
||||
const fallbackMs = readDateNow();
|
||||
try {
|
||||
const monotonicMs = globalThis.performance?.now();
|
||||
const timeOrigin = globalThis.performance?.timeOrigin;
|
||||
if (!Number.isFinite(monotonicMs)) {
|
||||
return { epochMs: fallbackMs, monotonicMs: fallbackMs };
|
||||
}
|
||||
const epochMs =
|
||||
Number.isFinite(timeOrigin) && timeOrigin !== undefined
|
||||
? timeOrigin + monotonicMs
|
||||
: fallbackMs;
|
||||
return {
|
||||
epochMs: Number.isFinite(epochMs) ? epochMs : fallbackMs,
|
||||
monotonicMs,
|
||||
};
|
||||
} catch {
|
||||
return { epochMs: fallbackMs, monotonicMs: fallbackMs };
|
||||
}
|
||||
}
|
||||
|
||||
function readDateNow(): number {
|
||||
try {
|
||||
const value = Date.now();
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user