mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
872 lines
36 KiB
TypeScript
872 lines
36 KiB
TypeScript
/* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */
|
|
import type { ElectronApplication } from '@playwright/test';
|
|
|
|
import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract';
|
|
|
|
const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture';
|
|
|
|
export interface MainCaptureStartOptions {
|
|
readonly diagnostic: boolean;
|
|
readonly outputDirectory: string;
|
|
}
|
|
|
|
export interface MainCaptureStatus {
|
|
readonly databasePending: number;
|
|
readonly databaseRequests: number;
|
|
readonly databaseUpsertsCompleted: number;
|
|
readonly playlistTerminated: number;
|
|
readonly playlistResponsesFailed: number;
|
|
readonly playlistResponses: number;
|
|
readonly playlistResponsesSucceeded: number;
|
|
}
|
|
|
|
export async function installMainCapture(
|
|
electronApp: ElectronApplication
|
|
): Promise<void> {
|
|
await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => {
|
|
type JsonRecord = Record<string, unknown>;
|
|
type WorkerTransferable = import('node:worker_threads').Transferable;
|
|
interface CpuProfileHandle {
|
|
stop(): Promise<unknown>;
|
|
}
|
|
interface WorkerHeapStatistics {
|
|
external_memory?: number;
|
|
used_heap_size?: number;
|
|
}
|
|
interface WorkerCpuUsage {
|
|
system: number;
|
|
user: number;
|
|
}
|
|
interface WorkerElu {
|
|
active: number;
|
|
idle: number;
|
|
utilization: number;
|
|
}
|
|
interface InstrumentedWorker {
|
|
cpuUsage?(): Promise<WorkerCpuUsage>;
|
|
getHeapSnapshot?(): Promise<NodeJS.ReadableStream>;
|
|
getHeapStatistics?(): Promise<WorkerHeapStatistics>;
|
|
on(event: 'message', listener: (message: unknown) => void): this;
|
|
performance?: {
|
|
eventLoopUtilization(
|
|
utilization1?: WorkerElu,
|
|
utilization2?: WorkerElu
|
|
): WorkerElu;
|
|
};
|
|
postMessage(
|
|
message: unknown,
|
|
transferList?: readonly WorkerTransferable[]
|
|
): void;
|
|
startCpuProfile?(): Promise<CpuProfileHandle>;
|
|
terminate(): Promise<number>;
|
|
}
|
|
interface WorkerRecord {
|
|
cancelPostedEpochMs: number | null;
|
|
cpuFirst: WorkerCpuUsage | null;
|
|
cpuLast: WorkerCpuUsage | null;
|
|
elu: number | null;
|
|
eluStart: WorkerElu | null;
|
|
eventLoopDelay: {
|
|
maxMs: number;
|
|
p95Ms: number;
|
|
p99Ms: number;
|
|
} | null;
|
|
externalPeak: number;
|
|
finalized: boolean;
|
|
finalizing: Promise<void> | null;
|
|
heapPeak: number;
|
|
kind: 'database.worker' | 'playlist-refresh.worker';
|
|
operationId: string | null;
|
|
playlistId: string | null;
|
|
postGcHeapUsed: number | null;
|
|
profileHandle: Promise<CpuProfileHandle> | null;
|
|
profilePath: string | null;
|
|
responseEpochMs: number | null;
|
|
sampleBusy: boolean;
|
|
sampleTimer: NodeJS.Timeout | null;
|
|
snapshotPath: string | null;
|
|
terminatedEpochMs: number | null;
|
|
worker: InstrumentedWorker;
|
|
}
|
|
interface TimelineRecord {
|
|
readonly epochMs: number;
|
|
readonly operation?: string;
|
|
readonly operationId?: string;
|
|
readonly playlistId?: string;
|
|
readonly requestId?: string;
|
|
readonly success?: boolean;
|
|
readonly type: string;
|
|
}
|
|
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
if (target[stateKey] !== undefined) {
|
|
return;
|
|
}
|
|
|
|
const runtimeProcess = process as typeof process & {
|
|
getBuiltinModule(id: string): unknown;
|
|
};
|
|
const fs = runtimeProcess.getBuiltinModule(
|
|
'node:fs'
|
|
) as typeof import('node:fs');
|
|
const inspector = runtimeProcess.getBuiltinModule(
|
|
'node:inspector'
|
|
) as typeof import('node:inspector');
|
|
const path = runtimeProcess.getBuiltinModule(
|
|
'node:path'
|
|
) as typeof import('node:path');
|
|
const perfHooks = runtimeProcess.getBuiltinModule(
|
|
'node:perf_hooks'
|
|
) as typeof import('node:perf_hooks');
|
|
const streamPromises = runtimeProcess.getBuiltinModule(
|
|
'node:stream/promises'
|
|
) as typeof import('node:stream/promises');
|
|
const workerThreads = runtimeProcess.getBuiltinModule(
|
|
'node:worker_threads'
|
|
) as typeof import('node:worker_threads');
|
|
const WorkerClass = workerThreads.Worker;
|
|
const originalPostMessage = WorkerClass.prototype.postMessage;
|
|
const originalTerminate = WorkerClass.prototype.terminate;
|
|
const records = new Map<InstrumentedWorker, WorkerRecord>();
|
|
const operationWorkers = new Map<string, WorkerRecord>();
|
|
const dbRequests = new Map<
|
|
string,
|
|
{
|
|
operation: string;
|
|
playlistId: string | null;
|
|
record: WorkerRecord;
|
|
}
|
|
>();
|
|
|
|
const state = {
|
|
active: false,
|
|
cpuStart: null as NodeJS.CpuUsage | null,
|
|
diagnostic: false,
|
|
eventLoopDelay: null as ReturnType<
|
|
typeof perfHooks.monitorEventLoopDelay
|
|
> | null,
|
|
eventLoopStart: null as ReturnType<
|
|
typeof perfHooks.performance.eventLoopUtilization
|
|
> | null,
|
|
inspectorSession: null as import('node:inspector').Session | null,
|
|
mainPeakHeap: 0,
|
|
mainPeakRss: 0,
|
|
mainProfilePath: null as string | null,
|
|
mainSnapshotPath: null as string | null,
|
|
outputDirectory: '',
|
|
postGcHeap: null as number | null,
|
|
postGcRss: null as number | null,
|
|
rendererPeakRss: 0,
|
|
responsiveEvents: 0,
|
|
sampleTimer: null as NodeJS.Timeout | null,
|
|
timeline: [] as TimelineRecord[],
|
|
unresponsiveEvents: 0,
|
|
windowListeners: [] as {
|
|
responsive: () => void;
|
|
unresponsive: () => void;
|
|
window: Electron.BrowserWindow;
|
|
}[],
|
|
};
|
|
|
|
const nowEpochMs = (): number =>
|
|
perfHooks.performance.timeOrigin + perfHooks.performance.now();
|
|
const recordTimeline = (
|
|
record: Omit<TimelineRecord, 'epochMs'>
|
|
): void => {
|
|
if (state.active) {
|
|
state.timeline.push({ epochMs: nowEpochMs(), ...record });
|
|
}
|
|
};
|
|
const classifyRequest = (
|
|
message: JsonRecord
|
|
): 'database.worker' | 'playlist-refresh.worker' | null => {
|
|
if (typeof message['operation'] === 'string') {
|
|
return 'database.worker';
|
|
}
|
|
const payload = message['payload'];
|
|
return typeof payload === 'object' &&
|
|
payload !== null &&
|
|
typeof (payload as JsonRecord)['operationId'] === 'string' &&
|
|
typeof (payload as JsonRecord)['playlistId'] === 'string'
|
|
? 'playlist-refresh.worker'
|
|
: null;
|
|
};
|
|
const readDatabasePlaylistId = (message: JsonRecord): string | null => {
|
|
const payload = message['payload'];
|
|
if (typeof payload !== 'object' || payload === null) {
|
|
return null;
|
|
}
|
|
const value = payload as JsonRecord;
|
|
const playlistId = value['playlistId'] ?? value['_id'];
|
|
return typeof playlistId === 'string' ? playlistId : null;
|
|
};
|
|
const createWorkerRecord = (
|
|
worker: InstrumentedWorker,
|
|
kind: WorkerRecord['kind']
|
|
): WorkerRecord => {
|
|
const existing = records.get(worker);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const record: WorkerRecord = {
|
|
cancelPostedEpochMs: null,
|
|
cpuFirst: null,
|
|
cpuLast: null,
|
|
elu: null,
|
|
eluStart: null,
|
|
eventLoopDelay: null,
|
|
externalPeak: 0,
|
|
finalized: false,
|
|
finalizing: null,
|
|
heapPeak: 0,
|
|
kind,
|
|
operationId: null,
|
|
playlistId: null,
|
|
postGcHeapUsed: null,
|
|
profileHandle: null,
|
|
profilePath: null,
|
|
responseEpochMs: null,
|
|
sampleBusy: false,
|
|
sampleTimer: null,
|
|
snapshotPath: null,
|
|
terminatedEpochMs: null,
|
|
worker,
|
|
};
|
|
records.set(worker, record);
|
|
worker.on('message', (incoming) => {
|
|
if (typeof incoming !== 'object' || incoming === null) {
|
|
return;
|
|
}
|
|
const message = incoming as JsonRecord;
|
|
const performanceCapture = readWorkerPerformance(message);
|
|
if (performanceCapture) {
|
|
record.eventLoopDelay = record.eventLoopDelay
|
|
? {
|
|
maxMs: Math.max(
|
|
record.eventLoopDelay.maxMs,
|
|
performanceCapture.eventLoopDelay.maxMs
|
|
),
|
|
p95Ms: Math.max(
|
|
record.eventLoopDelay.p95Ms,
|
|
performanceCapture.eventLoopDelay.p95Ms
|
|
),
|
|
p99Ms: Math.max(
|
|
record.eventLoopDelay.p99Ms,
|
|
performanceCapture.eventLoopDelay.p99Ms
|
|
),
|
|
}
|
|
: performanceCapture.eventLoopDelay;
|
|
}
|
|
if (record.kind === 'playlist-refresh.worker') {
|
|
if (message['type'] === 'event') {
|
|
const event = message['event'] as
|
|
| JsonRecord
|
|
| undefined;
|
|
recordTimeline({
|
|
operationId: record.operationId ?? undefined,
|
|
playlistId: record.playlistId ?? undefined,
|
|
type: `playlist-event:${String(
|
|
event?.['status'] ?? 'unknown'
|
|
)}:${String(event?.['phase'] ?? 'none')}`,
|
|
});
|
|
} else if (message['type'] === 'response') {
|
|
record.responseEpochMs = nowEpochMs();
|
|
recordTimeline({
|
|
operationId: record.operationId ?? undefined,
|
|
playlistId: record.playlistId ?? undefined,
|
|
success: message['success'] === true,
|
|
type: 'playlist-response',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
message['type'] === 'response' &&
|
|
typeof message['requestId'] === 'string'
|
|
) {
|
|
const request = dbRequests.get(message['requestId']);
|
|
if (request) {
|
|
dbRequests.delete(message['requestId']);
|
|
recordTimeline({
|
|
operation: request.operation,
|
|
playlistId: request.playlistId ?? undefined,
|
|
requestId: message['requestId'],
|
|
success: message['success'] === true,
|
|
type: 'db-response',
|
|
});
|
|
}
|
|
}
|
|
});
|
|
return record;
|
|
};
|
|
const readWorkerPerformance = (
|
|
message: JsonRecord
|
|
): {
|
|
eventLoopDelay: {
|
|
maxMs: number;
|
|
p95Ms: number;
|
|
p99Ms: number;
|
|
};
|
|
} | null => {
|
|
const performanceCapture = message['performance'];
|
|
if (
|
|
typeof performanceCapture !== 'object' ||
|
|
performanceCapture === null
|
|
) {
|
|
return null;
|
|
}
|
|
const eventLoopDelay = (performanceCapture as JsonRecord)[
|
|
'eventLoopDelay'
|
|
];
|
|
if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) {
|
|
return null;
|
|
}
|
|
const value = eventLoopDelay as JsonRecord;
|
|
const maxMs = value['maxMs'];
|
|
const p95Ms = value['p95Ms'];
|
|
const p99Ms = value['p99Ms'];
|
|
if (
|
|
typeof maxMs !== 'number' ||
|
|
!Number.isFinite(maxMs) ||
|
|
typeof p95Ms !== 'number' ||
|
|
!Number.isFinite(p95Ms) ||
|
|
typeof p99Ms !== 'number' ||
|
|
!Number.isFinite(p99Ms)
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
eventLoopDelay: { maxMs, p95Ms, p99Ms },
|
|
};
|
|
};
|
|
const sampleWorker = async (record: WorkerRecord): Promise<void> => {
|
|
if (record.sampleBusy || record.finalized) {
|
|
return;
|
|
}
|
|
record.sampleBusy = true;
|
|
try {
|
|
const stats = await record.worker.getHeapStatistics?.();
|
|
if (stats) {
|
|
record.heapPeak = Math.max(
|
|
record.heapPeak,
|
|
Number(stats.used_heap_size ?? 0)
|
|
);
|
|
record.externalPeak = Math.max(
|
|
record.externalPeak,
|
|
Number(stats.external_memory ?? 0)
|
|
);
|
|
}
|
|
const cpu = await record.worker.cpuUsage?.();
|
|
if (cpu) {
|
|
record.cpuFirst ??= cpu;
|
|
record.cpuLast = cpu;
|
|
}
|
|
const elu = record.worker.performance?.eventLoopUtilization(
|
|
record.eluStart ?? undefined
|
|
);
|
|
if (elu) {
|
|
record.elu = elu.utilization;
|
|
}
|
|
} catch {
|
|
// A one-shot worker may terminate between sampling calls.
|
|
} finally {
|
|
record.sampleBusy = false;
|
|
}
|
|
};
|
|
const startWorker = (record: WorkerRecord): void => {
|
|
if (!state.active || record.sampleTimer !== null) {
|
|
return;
|
|
}
|
|
record.elu = null;
|
|
record.eluStart =
|
|
record.worker.performance?.eventLoopUtilization() ?? null;
|
|
record.eventLoopDelay = null;
|
|
void sampleWorker(record);
|
|
record.sampleTimer = setInterval(
|
|
() => void sampleWorker(record),
|
|
20
|
|
);
|
|
if (
|
|
state.diagnostic &&
|
|
typeof record.worker.startCpuProfile === 'function'
|
|
) {
|
|
record.profilePath = path.join(
|
|
state.outputDirectory,
|
|
`${record.kind}.cpuprofile`
|
|
);
|
|
record.profileHandle = record.worker.startCpuProfile();
|
|
}
|
|
};
|
|
const finalizeWorker = (record: WorkerRecord): Promise<void> => {
|
|
record.finalizing ??= (async () => {
|
|
if (record.sampleTimer) {
|
|
clearInterval(record.sampleTimer);
|
|
record.sampleTimer = null;
|
|
}
|
|
await sampleWorker(record);
|
|
if (record.profileHandle && record.profilePath) {
|
|
const handle = await record.profileHandle;
|
|
const profile = await handle.stop();
|
|
fs.writeFileSync(
|
|
record.profilePath,
|
|
JSON.stringify(normalizeWorkerCpuProfile(profile))
|
|
);
|
|
}
|
|
if (
|
|
state.diagnostic &&
|
|
typeof record.worker.getHeapSnapshot === 'function'
|
|
) {
|
|
record.snapshotPath = path.join(
|
|
state.outputDirectory,
|
|
`${record.kind}.heapsnapshot`
|
|
);
|
|
const snapshot = await record.worker.getHeapSnapshot();
|
|
await streamPromises.pipeline(
|
|
snapshot,
|
|
fs.createWriteStream(record.snapshotPath)
|
|
);
|
|
const postSnapshot =
|
|
await record.worker.getHeapStatistics?.();
|
|
record.postGcHeapUsed = Number(
|
|
postSnapshot?.used_heap_size ?? 0
|
|
);
|
|
}
|
|
record.finalized = true;
|
|
})().catch((error: unknown) => {
|
|
recordTimeline({
|
|
type: `worker-profile-error:${
|
|
error instanceof Error
|
|
? error.message.slice(0, 160)
|
|
: String(error).slice(0, 160)
|
|
}`,
|
|
});
|
|
record.finalized = true;
|
|
});
|
|
return record.finalizing;
|
|
};
|
|
|
|
WorkerClass.prototype.postMessage = function (
|
|
this: InstrumentedWorker,
|
|
message: unknown,
|
|
transferList?: readonly WorkerTransferable[]
|
|
): void {
|
|
if (typeof message === 'object' && message !== null) {
|
|
const value = message as JsonRecord;
|
|
if (value['type'] === 'request') {
|
|
const kind = classifyRequest(value);
|
|
if (kind) {
|
|
const record = createWorkerRecord(this, kind);
|
|
if (kind === 'playlist-refresh.worker') {
|
|
const payload = value['payload'] as JsonRecord;
|
|
record.operationId = String(payload['operationId']);
|
|
record.playlistId = String(payload['playlistId']);
|
|
operationWorkers.set(record.operationId, record);
|
|
recordTimeline({
|
|
operationId: record.operationId,
|
|
playlistId: record.playlistId,
|
|
type: 'playlist-request',
|
|
});
|
|
} else if (
|
|
typeof value['requestId'] === 'string' &&
|
|
typeof value['operation'] === 'string'
|
|
) {
|
|
dbRequests.set(value['requestId'], {
|
|
operation: value['operation'],
|
|
playlistId: readDatabasePlaylistId(value),
|
|
record,
|
|
});
|
|
recordTimeline({
|
|
operation: value['operation'],
|
|
playlistId:
|
|
readDatabasePlaylistId(value) ?? undefined,
|
|
requestId: value['requestId'],
|
|
type: 'db-request',
|
|
});
|
|
}
|
|
startWorker(record);
|
|
}
|
|
} else if (
|
|
value['type'] === 'cancel' &&
|
|
typeof value['operationId'] === 'string'
|
|
) {
|
|
const record = operationWorkers.get(value['operationId']);
|
|
if (record) {
|
|
record.cancelPostedEpochMs = nowEpochMs();
|
|
recordTimeline({
|
|
operationId: value['operationId'],
|
|
type: 'playlist-cancel-posted',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
originalPostMessage.call(this, message, transferList);
|
|
};
|
|
WorkerClass.prototype.terminate = function (
|
|
this: InstrumentedWorker
|
|
): Promise<number> {
|
|
const record = records.get(this);
|
|
if (!record || !state.active) {
|
|
return originalTerminate.call(this);
|
|
}
|
|
const markTerminated = (code: number): number => {
|
|
record.terminatedEpochMs = nowEpochMs();
|
|
record.finalized = true;
|
|
recordTimeline({
|
|
operationId: record.operationId ?? undefined,
|
|
playlistId: record.playlistId ?? undefined,
|
|
type: `${record.kind}-terminated`,
|
|
});
|
|
return code;
|
|
};
|
|
if (!state.diagnostic) {
|
|
if (record.sampleTimer) {
|
|
clearInterval(record.sampleTimer);
|
|
record.sampleTimer = null;
|
|
}
|
|
return originalTerminate.call(this).then(markTerminated);
|
|
}
|
|
return finalizeWorker(record)
|
|
.then(() => originalTerminate.call(this))
|
|
.then(markTerminated);
|
|
};
|
|
|
|
const inspectorPost = (
|
|
session: import('node:inspector').Session,
|
|
method: string,
|
|
params: JsonRecord = {}
|
|
): Promise<JsonRecord> =>
|
|
new Promise((resolve, reject) => {
|
|
session.post(method, params, (error, result) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve((result ?? {}) as JsonRecord);
|
|
}
|
|
});
|
|
});
|
|
const normalizeWorkerCpuProfile = (profile: unknown): unknown => {
|
|
if (typeof profile !== 'string') {
|
|
return profile;
|
|
}
|
|
try {
|
|
return JSON.parse(profile) as unknown;
|
|
} catch {
|
|
// Node 24's Worker CPU profiler can leave quotes inside
|
|
// RegExp pseudo-frame names unescaped. Repair only those
|
|
// generated frame names, then require the full JSON to parse.
|
|
const fieldPrefix = '"functionName":"RegExp: ';
|
|
const fieldSuffix = '","lineNumber":';
|
|
const valuePrefixLength = '"functionName":"'.length;
|
|
let cursor = 0;
|
|
let repaired = '';
|
|
while (true) {
|
|
const fieldStart = profile.indexOf(fieldPrefix, cursor);
|
|
if (fieldStart < 0) {
|
|
repaired += profile.slice(cursor);
|
|
break;
|
|
}
|
|
const valueStart = fieldStart + valuePrefixLength;
|
|
const valueEnd = profile.indexOf(fieldSuffix, valueStart);
|
|
if (valueEnd < 0) {
|
|
throw new Error(
|
|
'Worker CPU profile has an unterminated RegExp frame'
|
|
);
|
|
}
|
|
repaired +=
|
|
profile.slice(cursor, fieldStart) +
|
|
'"functionName":' +
|
|
JSON.stringify(profile.slice(valueStart, valueEnd));
|
|
cursor = valueEnd + 1;
|
|
}
|
|
return JSON.parse(repaired) as unknown;
|
|
}
|
|
};
|
|
const sampleMain = (): void => {
|
|
const memory = process.memoryUsage();
|
|
state.mainPeakHeap = Math.max(state.mainPeakHeap, memory.heapUsed);
|
|
state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss);
|
|
for (const metric of app.getAppMetrics()) {
|
|
const type = String(metric.type).toLowerCase();
|
|
if (type.includes('tab') || type.includes('renderer')) {
|
|
state.rendererPeakRss = Math.max(
|
|
state.rendererPeakRss,
|
|
Number(metric.memory?.workingSetSize ?? 0) * 1024
|
|
);
|
|
}
|
|
}
|
|
};
|
|
const attachWindowListeners = (): void => {
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
const unresponsive = () => {
|
|
state.unresponsiveEvents += 1;
|
|
};
|
|
const responsive = () => {
|
|
state.responsiveEvents += 1;
|
|
};
|
|
window.on('unresponsive', unresponsive);
|
|
window.on('responsive', responsive);
|
|
state.windowListeners.push({
|
|
responsive,
|
|
unresponsive,
|
|
window,
|
|
});
|
|
}
|
|
};
|
|
|
|
const api = {
|
|
status: (): MainCaptureStatus => ({
|
|
databasePending: dbRequests.size,
|
|
databaseRequests: state.timeline.filter(
|
|
(entry) => entry['type'] === 'db-request'
|
|
).length,
|
|
databaseUpsertsCompleted: state.timeline.filter(
|
|
(entry) =>
|
|
entry['type'] === 'db-response' &&
|
|
entry['operation'] === 'DB_UPSERT_APP_PLAYLIST' &&
|
|
entry['success'] === true
|
|
).length,
|
|
playlistTerminated: [...records.values()].filter(
|
|
(record) =>
|
|
record.kind === 'playlist-refresh.worker' &&
|
|
record.terminatedEpochMs !== null
|
|
).length,
|
|
playlistResponsesFailed: state.timeline.filter(
|
|
(entry) =>
|
|
entry['type'] === 'playlist-response' &&
|
|
entry['success'] === false
|
|
).length,
|
|
playlistResponses: state.timeline.filter(
|
|
(entry) => entry['type'] === 'playlist-response'
|
|
).length,
|
|
playlistResponsesSucceeded: state.timeline.filter(
|
|
(entry) =>
|
|
entry['type'] === 'playlist-response' &&
|
|
entry['success'] === true
|
|
).length,
|
|
}),
|
|
start: async (options: MainCaptureStartOptions): Promise<void> => {
|
|
state.active = true;
|
|
state.diagnostic = options.diagnostic;
|
|
state.outputDirectory = options.outputDirectory;
|
|
state.timeline = [];
|
|
state.mainPeakHeap = 0;
|
|
state.mainPeakRss = 0;
|
|
state.rendererPeakRss = 0;
|
|
state.postGcHeap = null;
|
|
state.postGcRss = null;
|
|
state.unresponsiveEvents = 0;
|
|
state.responsiveEvents = 0;
|
|
state.cpuStart = process.cpuUsage();
|
|
state.eventLoopStart =
|
|
perfHooks.performance.eventLoopUtilization();
|
|
state.eventLoopDelay = perfHooks.monitorEventLoopDelay({
|
|
resolution: 1,
|
|
});
|
|
state.eventLoopDelay.enable();
|
|
attachWindowListeners();
|
|
sampleMain();
|
|
state.sampleTimer = setInterval(sampleMain, 20);
|
|
if (state.diagnostic) {
|
|
const session = new inspector.Session();
|
|
session.connect();
|
|
state.inspectorSession = session;
|
|
await inspectorPost(session, 'Profiler.enable');
|
|
await inspectorPost(session, 'Profiler.start');
|
|
state.mainProfilePath = path.join(
|
|
state.outputDirectory,
|
|
'main.cpuprofile'
|
|
);
|
|
state.mainSnapshotPath = path.join(
|
|
state.outputDirectory,
|
|
'main.heapsnapshot'
|
|
);
|
|
}
|
|
},
|
|
stop: async (): Promise<MainCaptureMetrics> => {
|
|
if (state.sampleTimer) {
|
|
clearInterval(state.sampleTimer);
|
|
state.sampleTimer = null;
|
|
}
|
|
state.eventLoopDelay?.disable();
|
|
sampleMain();
|
|
const cpu = process.cpuUsage(state.cpuStart ?? undefined);
|
|
const eluEnd = perfHooks.performance.eventLoopUtilization();
|
|
const elu = perfHooks.performance.eventLoopUtilization(
|
|
state.eventLoopStart ?? undefined
|
|
);
|
|
const eventLoopUtilization =
|
|
perfHooks.performance.nodeTiming.loopStart < 0 ||
|
|
(elu.active === 0 &&
|
|
elu.idle === 0 &&
|
|
eluEnd.active === 0 &&
|
|
eluEnd.idle === 0)
|
|
? null
|
|
: elu.utilization;
|
|
const delay = state.eventLoopDelay;
|
|
const databaseRecords = [...records.values()].filter(
|
|
(record) =>
|
|
record.kind === 'database.worker' &&
|
|
record.sampleTimer !== null
|
|
);
|
|
await Promise.all(
|
|
databaseRecords.map((record) => finalizeWorker(record))
|
|
);
|
|
const session =
|
|
state.inspectorSession ?? new inspector.Session();
|
|
if (!state.inspectorSession) {
|
|
session.connect();
|
|
}
|
|
if (state.diagnostic && state.mainProfilePath) {
|
|
const result = await inspectorPost(
|
|
session,
|
|
'Profiler.stop'
|
|
);
|
|
fs.writeFileSync(
|
|
state.mainProfilePath,
|
|
JSON.stringify(result['profile'])
|
|
);
|
|
}
|
|
await inspectorPost(session, 'HeapProfiler.enable');
|
|
await inspectorPost(session, 'HeapProfiler.collectGarbage');
|
|
const postGc = process.memoryUsage();
|
|
state.postGcHeap = postGc.heapUsed;
|
|
state.postGcRss = postGc.rss;
|
|
if (state.diagnostic && state.mainSnapshotPath) {
|
|
const output = fs.createWriteStream(state.mainSnapshotPath);
|
|
const onChunk = (message: { params: { chunk: string } }) =>
|
|
output.write(message.params.chunk);
|
|
session.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
|
|
await inspectorPost(
|
|
session,
|
|
'HeapProfiler.takeHeapSnapshot',
|
|
{ reportProgress: false }
|
|
);
|
|
session.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
|
|
output.end();
|
|
await new Promise<void>((resolve, reject) => {
|
|
output.once('finish', resolve);
|
|
output.once('error', reject);
|
|
});
|
|
}
|
|
session.disconnect();
|
|
state.inspectorSession = null;
|
|
for (const listener of state.windowListeners) {
|
|
listener.window.off('unresponsive', listener.unresponsive);
|
|
listener.window.off('responsive', listener.responsive);
|
|
}
|
|
state.windowListeners = [];
|
|
state.active = false;
|
|
|
|
const workers = [...records.values()]
|
|
.filter(
|
|
(record) =>
|
|
record.kind === 'playlist-refresh.worker' ||
|
|
record.heapPeak > 0
|
|
)
|
|
.map((record) => ({
|
|
cancelPostedEpochMs: record.cancelPostedEpochMs,
|
|
cpuSystemMicros:
|
|
record.cpuFirst && record.cpuLast
|
|
? record.cpuLast.system - record.cpuFirst.system
|
|
: null,
|
|
cpuUserMicros:
|
|
record.cpuFirst && record.cpuLast
|
|
? record.cpuLast.user - record.cpuFirst.user
|
|
: null,
|
|
eventLoopDelay: record.eventLoopDelay,
|
|
eventLoopDelayUnavailableReason:
|
|
record.eventLoopDelay === null
|
|
? record.terminatedEpochMs !== null
|
|
? 'worker-terminated-before-profile-flush'
|
|
: 'worker-self-profile-result-unavailable'
|
|
: null,
|
|
eventLoopUtilization: record.elu,
|
|
kind: record.kind,
|
|
operationId: record.operationId,
|
|
peakExternalBytes: record.externalPeak,
|
|
peakHeapUsedBytes: record.heapPeak,
|
|
playlistId: record.playlistId,
|
|
postGcHeapUsedBytes: record.postGcHeapUsed,
|
|
profilePath: record.profilePath,
|
|
responseEpochMs: record.responseEpochMs,
|
|
snapshotPath: record.snapshotPath,
|
|
terminatedEpochMs: record.terminatedEpochMs,
|
|
}));
|
|
return {
|
|
cpuProfilePath: state.mainProfilePath,
|
|
cpuSystemMicros: cpu.system,
|
|
cpuUserMicros: cpu.user,
|
|
eventLoopDelay: {
|
|
maxMs: Number(delay?.max ?? 0) / 1e6,
|
|
p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6,
|
|
p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6,
|
|
},
|
|
// Electron's Chromium-owned main message pump currently
|
|
// leaves Node's libuv ELU counters at zero. Preserve that
|
|
// as unavailable instead of reporting a misleading 0%.
|
|
eventLoopUtilization,
|
|
eventLoopUtilizationUnavailableReason:
|
|
eventLoopUtilization === null
|
|
? 'electron-main-embedded-event-loop'
|
|
: null,
|
|
heapSnapshotPath: state.mainSnapshotPath,
|
|
memory: {
|
|
peakHeapUsedBytes: state.mainPeakHeap,
|
|
peakRssBytes: state.mainPeakRss,
|
|
postGcHeapUsedBytes: state.postGcHeap,
|
|
postGcRssBytes: state.postGcRss,
|
|
},
|
|
rendererPeakRssBytes: state.rendererPeakRss,
|
|
responsiveEvents: state.responsiveEvents,
|
|
rssScope:
|
|
'electron-main-process-including-worker-threads-and-native-memory',
|
|
timeline: state.timeline,
|
|
unresponsiveEvents: state.unresponsiveEvents,
|
|
workers,
|
|
};
|
|
},
|
|
};
|
|
target[stateKey] = api;
|
|
}, MAIN_CAPTURE_STATE_KEY);
|
|
}
|
|
|
|
export async function startMainCapture(
|
|
electronApp: ElectronApplication,
|
|
options: MainCaptureStartOptions
|
|
): Promise<void> {
|
|
await electronApp.evaluate(
|
|
async (_electron, input) => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const api = target[input.stateKey] as {
|
|
start(options: MainCaptureStartOptions): Promise<void>;
|
|
};
|
|
await api.start(input.options);
|
|
},
|
|
{ options, stateKey: MAIN_CAPTURE_STATE_KEY }
|
|
);
|
|
}
|
|
|
|
export async function readMainCaptureStatus(
|
|
electronApp: ElectronApplication
|
|
): Promise<MainCaptureStatus> {
|
|
return electronApp.evaluate(async (_electron, stateKey) => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const api = target[stateKey] as {
|
|
status(): MainCaptureStatus;
|
|
};
|
|
return api.status();
|
|
}, MAIN_CAPTURE_STATE_KEY);
|
|
}
|
|
|
|
export async function stopMainCapture(
|
|
electronApp: ElectronApplication
|
|
): Promise<MainCaptureMetrics> {
|
|
return electronApp.evaluate(async (_electron, stateKey) => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const api = target[stateKey] as {
|
|
stop(): Promise<MainCaptureMetrics>;
|
|
};
|
|
return api.stop();
|
|
}, MAIN_CAPTURE_STATE_KEY);
|
|
}
|