diff --git a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts index 0d491b233..ec335bc73 100644 --- a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import * as rendererProcessRssCaptureModule from './renderer-process-rss-capture'; import { captureRendererProcessRssSample, createRendererProcessRssCapture, @@ -12,6 +13,34 @@ import { const RENDERER_PID = 42; const RENDERER_CREATION_TIME = 1_721_234_567_890; +test('exposes a self-contained factory for the Electron main-process injection boundary', () => { + const factory = ( + rendererProcessRssCaptureModule as unknown as Record + )['createRendererProcessRssCaptureApi']; + + assert.equal(typeof factory, 'function'); + const source = (factory as () => unknown).toString(); + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => { + create( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ): RendererProcessRssCapture; + }; + + assert.deepEqual(restoredFactory().create(RENDERER_PID, [metric()]), { + identity: { + creationTime: RENDERER_CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: 1_263_616, + unavailableReason: null, + validSampleCount: 1, + }); +}); + interface MetricOverrides { readonly creationTime?: unknown; readonly pid?: unknown; diff --git a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts index bc2a09b8f..3e18a76c8 100644 --- a/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts @@ -21,132 +21,237 @@ export interface RendererProcessRssCapture { readonly validSampleCount: number; } -type JsonRecord = Record; - -const KIBIBYTE_BYTES = 1_024; -const MAX_WORKING_SET_KIB = Math.floor( - Number.MAX_SAFE_INTEGER / KIBIBYTE_BYTES -); - -function readRecord(value: unknown): JsonRecord | null { - return typeof value === 'object' && value !== null - ? (value as JsonRecord) - : null; +export interface RendererProcessRssCaptureApi { + readonly create: ( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ) => RendererProcessRssCapture; + readonly sample: ( + capture: RendererProcessRssCapture, + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ) => RendererProcessRssCapture; } -function isValidOsPid(value: unknown): value is number { - return Number.isSafeInteger(value) && Number(value) > 0; -} +export function createRendererProcessRssCaptureApi(): RendererProcessRssCaptureApi { + type JsonRecord = Record; -function findPidCandidates( - processMetrics: readonly unknown[], - pid: number -): JsonRecord[] { - return processMetrics - .map((metric) => readRecord(metric)) - .filter( - (metric): metric is JsonRecord => - metric !== null && metric['pid'] === pid - ); -} + const KIBIBYTE_BYTES = 1_024; + const MAX_WORKING_SET_KIB = Math.floor( + Number.MAX_SAFE_INTEGER / KIBIBYTE_BYTES + ); -function readMetric(metric: JsonRecord): - | { - readonly creationTime: number; - readonly rssBytes: number; - } - | RendererProcessRssUnavailableReason { - if (metric['type'] !== 'Tab' && metric['type'] !== 'Renderer') { - return 'renderer-process-metric-type-invalid'; - } + const helpers = { + readRecord(value: unknown): JsonRecord | null { + return typeof value === 'object' && value !== null + ? (value as JsonRecord) + : null; + }, - const creationTime = metric['creationTime']; - if ( - typeof creationTime !== 'number' || - !Number.isFinite(creationTime) || - creationTime < 0 - ) { - return 'renderer-process-metric-creation-time-invalid'; - } + isValidOsPid(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; + }, - const memory = readRecord(metric['memory']); - const workingSetSize = memory?.['workingSetSize']; - if ( - !Number.isSafeInteger(workingSetSize) || - Number(workingSetSize) <= 0 || - Number(workingSetSize) > MAX_WORKING_SET_KIB - ) { - return 'renderer-process-metric-working-set-invalid'; - } + findPidCandidates( + processMetrics: readonly unknown[], + pid: number + ): JsonRecord[] { + return processMetrics + .map((metric) => helpers.readRecord(metric)) + .filter( + (metric): metric is JsonRecord => + metric !== null && metric['pid'] === pid + ); + }, - return { - creationTime, - rssBytes: Number(workingSetSize) * KIBIBYTE_BYTES, + readMetric(metric: JsonRecord): + | { + readonly creationTime: number; + readonly rssBytes: number; + } + | RendererProcessRssUnavailableReason { + if (metric['type'] !== 'Tab' && metric['type'] !== 'Renderer') { + return 'renderer-process-metric-type-invalid'; + } + + const creationTime = metric['creationTime']; + if ( + typeof creationTime !== 'number' || + !Number.isFinite(creationTime) || + creationTime < 0 + ) { + return 'renderer-process-metric-creation-time-invalid'; + } + + const memory = helpers.readRecord(metric['memory']); + const workingSetSize = memory?.['workingSetSize']; + if ( + !Number.isSafeInteger(workingSetSize) || + Number(workingSetSize) <= 0 || + Number(workingSetSize) > MAX_WORKING_SET_KIB + ) { + return 'renderer-process-metric-working-set-invalid'; + } + + return { + creationTime, + rssBytes: Number(workingSetSize) * KIBIBYTE_BYTES, + }; + }, + + unavailableCapture( + unavailableReason: RendererProcessRssUnavailableReason, + options: { + readonly identity?: RendererProcessIdentity | null; + readonly missingSampleCount?: number; + readonly validSampleCount?: number; + } = {} + ): RendererProcessRssCapture { + return Object.freeze({ + identity: options.identity ?? null, + missingSampleCount: options.missingSampleCount ?? 0, + peakRssBytes: null, + unavailableReason, + validSampleCount: options.validSampleCount ?? 0, + }); + }, + + invalidateCapture( + capture: RendererProcessRssCapture, + unavailableReason: RendererProcessRssUnavailableReason, + missingSampleCount = capture.missingSampleCount + ): RendererProcessRssCapture { + return helpers.unavailableCapture(unavailableReason, { + identity: capture.identity, + missingSampleCount, + validSampleCount: capture.validSampleCount, + }); + }, + + create( + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ): RendererProcessRssCapture { + if (!helpers.isValidOsPid(webContentsOsPid)) { + return helpers.unavailableCapture('renderer-os-pid-invalid'); + } + + const candidates = helpers.findPidCandidates( + processMetrics, + webContentsOsPid + ); + if (candidates.length === 0) { + return helpers.unavailableCapture( + 'renderer-process-metric-missing-at-start', + { + missingSampleCount: 1, + } + ); + } + if (candidates.length !== 1) { + return helpers.unavailableCapture( + 'renderer-process-metric-ambiguous' + ); + } + + const metric = helpers.readMetric(candidates[0] as JsonRecord); + if (typeof metric === 'string') { + return helpers.unavailableCapture(metric); + } + + return Object.freeze({ + identity: Object.freeze({ + creationTime: metric.creationTime, + pid: webContentsOsPid, + }), + missingSampleCount: 0, + peakRssBytes: metric.rssBytes, + unavailableReason: null, + validSampleCount: 1, + }); + }, + + sample( + capture: RendererProcessRssCapture, + webContentsOsPid: unknown, + processMetrics: readonly unknown[] + ): RendererProcessRssCapture { + if (capture.unavailableReason !== null) { + return capture; + } + if (!helpers.isValidOsPid(webContentsOsPid)) { + return helpers.invalidateCapture( + capture, + 'renderer-os-pid-invalid' + ); + } + + const identity = capture.identity; + if (identity === null || webContentsOsPid !== identity.pid) { + return helpers.invalidateCapture( + capture, + 'renderer-process-identity-changed' + ); + } + + const candidates = helpers.findPidCandidates( + processMetrics, + webContentsOsPid + ); + if (candidates.length === 0) { + return helpers.invalidateCapture( + capture, + 'renderer-process-metric-missing-during-capture', + capture.missingSampleCount + 1 + ); + } + if (candidates.length !== 1) { + return helpers.invalidateCapture( + capture, + 'renderer-process-metric-ambiguous' + ); + } + + const metric = helpers.readMetric(candidates[0] as JsonRecord); + if (typeof metric === 'string') { + return helpers.invalidateCapture(capture, metric); + } + if (metric.creationTime !== identity.creationTime) { + return helpers.invalidateCapture( + capture, + 'renderer-process-identity-changed' + ); + } + + return Object.freeze({ + identity, + missingSampleCount: capture.missingSampleCount, + peakRssBytes: Math.max( + capture.peakRssBytes ?? metric.rssBytes, + metric.rssBytes + ), + unavailableReason: null, + validSampleCount: capture.validSampleCount + 1, + }); + }, }; -} -function unavailableCapture( - unavailableReason: RendererProcessRssUnavailableReason, - options: { - readonly identity?: RendererProcessIdentity | null; - readonly missingSampleCount?: number; - readonly validSampleCount?: number; - } = {} -): RendererProcessRssCapture { return Object.freeze({ - identity: options.identity ?? null, - missingSampleCount: options.missingSampleCount ?? 0, - peakRssBytes: null, - unavailableReason, - validSampleCount: options.validSampleCount ?? 0, + create: helpers.create, + sample: helpers.sample, }); } -function invalidateCapture( - capture: RendererProcessRssCapture, - unavailableReason: RendererProcessRssUnavailableReason, - missingSampleCount = capture.missingSampleCount -): RendererProcessRssCapture { - return unavailableCapture(unavailableReason, { - identity: capture.identity, - missingSampleCount, - validSampleCount: capture.validSampleCount, - }); -} +const rendererProcessRssCaptureApi = createRendererProcessRssCaptureApi(); export function createRendererProcessRssCapture( webContentsOsPid: unknown, processMetrics: readonly unknown[] ): RendererProcessRssCapture { - if (!isValidOsPid(webContentsOsPid)) { - return unavailableCapture('renderer-os-pid-invalid'); - } - - const candidates = findPidCandidates(processMetrics, webContentsOsPid); - if (candidates.length === 0) { - return unavailableCapture('renderer-process-metric-missing-at-start', { - missingSampleCount: 1, - }); - } - if (candidates.length !== 1) { - return unavailableCapture('renderer-process-metric-ambiguous'); - } - - const metric = readMetric(candidates[0] as JsonRecord); - if (typeof metric === 'string') { - return unavailableCapture(metric); - } - - return Object.freeze({ - identity: Object.freeze({ - creationTime: metric.creationTime, - pid: webContentsOsPid, - }), - missingSampleCount: 0, - peakRssBytes: metric.rssBytes, - unavailableReason: null, - validSampleCount: 1, - }); + return rendererProcessRssCaptureApi.create( + webContentsOsPid, + processMetrics + ); } export function captureRendererProcessRssSample( @@ -154,46 +259,9 @@ export function captureRendererProcessRssSample( webContentsOsPid: unknown, processMetrics: readonly unknown[] ): RendererProcessRssCapture { - if (capture.unavailableReason !== null) { - return capture; - } - if (!isValidOsPid(webContentsOsPid)) { - return invalidateCapture(capture, 'renderer-os-pid-invalid'); - } - - const identity = capture.identity; - if (identity === null || webContentsOsPid !== identity.pid) { - return invalidateCapture(capture, 'renderer-process-identity-changed'); - } - - const candidates = findPidCandidates(processMetrics, webContentsOsPid); - if (candidates.length === 0) { - return invalidateCapture( - capture, - 'renderer-process-metric-missing-during-capture', - capture.missingSampleCount + 1 - ); - } - if (candidates.length !== 1) { - return invalidateCapture(capture, 'renderer-process-metric-ambiguous'); - } - - const metric = readMetric(candidates[0] as JsonRecord); - if (typeof metric === 'string') { - return invalidateCapture(capture, metric); - } - if (metric.creationTime !== identity.creationTime) { - return invalidateCapture(capture, 'renderer-process-identity-changed'); - } - - return Object.freeze({ - identity, - missingSampleCount: capture.missingSampleCount, - peakRssBytes: Math.max( - capture.peakRssBytes ?? metric.rssBytes, - metric.rssBytes - ), - unavailableReason: null, - validSampleCount: capture.validSampleCount + 1, - }); + return rendererProcessRssCaptureApi.sample( + capture, + webContentsOsPid, + processMetrics + ); }