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 new file mode 100644 index 000000000..0d491b233 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.spec.ts @@ -0,0 +1,280 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + captureRendererProcessRssSample, + createRendererProcessRssCapture, + type RendererProcessRssCapture, + type RendererProcessRssUnavailableReason, +} from './renderer-process-rss-capture'; + +const RENDERER_PID = 42; +const RENDERER_CREATION_TIME = 1_721_234_567_890; + +interface MetricOverrides { + readonly creationTime?: unknown; + readonly pid?: unknown; + readonly type?: unknown; + readonly workingSetSize?: unknown; +} + +function metric(overrides: MetricOverrides = {}): unknown { + return { + creationTime: + overrides.creationTime === undefined + ? RENDERER_CREATION_TIME + : overrides.creationTime, + memory: { + workingSetSize: + overrides.workingSetSize === undefined + ? 1_234 + : overrides.workingSetSize, + }, + pid: overrides.pid === undefined ? RENDERER_PID : overrides.pid, + type: overrides.type === undefined ? 'Tab' : overrides.type, + }; +} + +function assertUnavailable( + capture: RendererProcessRssCapture, + unavailableReason: RendererProcessRssUnavailableReason, + expected: { + readonly identity: RendererProcessRssCapture['identity']; + readonly missingSampleCount?: number; + readonly validSampleCount?: number; + } +): void { + assert.deepEqual(capture, { + identity: expected.identity, + missingSampleCount: expected.missingSampleCount ?? 0, + peakRssBytes: null, + unavailableReason, + validSampleCount: expected.validSampleCount ?? 0, + }); +} + +test('binds only the exact BrowserWindow renderer identity and converts KiB to bytes once', () => { + const capture = createRendererProcessRssCapture(RENDERER_PID, [ + metric({ + creationTime: RENDERER_CREATION_TIME + 1, + pid: 900, + type: 'Renderer', + workingSetSize: 9_999_999, + }), + metric(), + ]); + + assert.deepEqual(capture, { + identity: { + creationTime: RENDERER_CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: 1_263_616, + unavailableReason: null, + validSampleCount: 1, + }); +}); + +test('accepts only exact Tab or Renderer process types and tracks the peak without mutating prior state', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [ + metric({ type: 'Tab', workingSetSize: 2_000 }), + ]); + const lower = captureRendererProcessRssSample(initial, RENDERER_PID, [ + metric({ type: 'Renderer', workingSetSize: 1_500 }), + metric({ + pid: 777, + type: 'Renderer', + workingSetSize: 8_000_000, + }), + ]); + + assert.equal(initial.validSampleCount, 1); + assert.equal(initial.peakRssBytes, 2_048_000); + assert.deepEqual(lower, { + identity: { + creationTime: RENDERER_CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: 2_048_000, + unavailableReason: null, + validSampleCount: 2, + }); + + const higher = captureRendererProcessRssSample(lower, RENDERER_PID, [ + metric({ workingSetSize: 2_500 }), + ]); + assert.equal(higher.peakRssBytes, 2_560_000); + assert.equal(higher.validSampleCount, 3); +}); + +test('fails closed when the BrowserWindow OS process id is invalid', () => { + for (const invalidPid of [0, -1, 1.5, Number.NaN, '42'] as const) { + assertUnavailable( + createRendererProcessRssCapture(invalidPid, [metric()]), + 'renderer-os-pid-invalid', + { identity: null } + ); + } +}); + +test('fails closed when the initial target metric is missing or ambiguous', () => { + assertUnavailable( + createRendererProcessRssCapture(RENDERER_PID, [ + metric({ pid: 99, workingSetSize: 99_999 }), + ]), + 'renderer-process-metric-missing-at-start', + { + identity: null, + missingSampleCount: 1, + } + ); + + assertUnavailable( + createRendererProcessRssCapture(RENDERER_PID, [ + metric(), + metric({ type: 'Renderer', workingSetSize: 2_000 }), + ]), + 'renderer-process-metric-ambiguous', + { identity: null } + ); +}); + +test('fails closed for invalid initial type, creation time, or working set', () => { + const cases: ReadonlyArray<{ + readonly metric: unknown; + readonly reason: RendererProcessRssUnavailableReason; + }> = [ + { + metric: metric({ type: 'RendererHelper' }), + reason: 'renderer-process-metric-type-invalid', + }, + { + metric: metric({ creationTime: '1721234567890' }), + reason: 'renderer-process-metric-creation-time-invalid', + }, + { + metric: metric({ workingSetSize: Number.POSITIVE_INFINITY }), + reason: 'renderer-process-metric-working-set-invalid', + }, + { + metric: metric({ workingSetSize: 0 }), + reason: 'renderer-process-metric-working-set-invalid', + }, + ]; + + for (const testCase of cases) { + assertUnavailable( + createRendererProcessRssCapture(RENDERER_PID, [testCase.metric]), + testCase.reason, + { identity: null } + ); + } +}); + +test('invalidates the capture when the bound renderer is missing and never recovers', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [metric()]); + const missing = captureRendererProcessRssSample(initial, RENDERER_PID, [ + metric({ pid: 700, workingSetSize: 9_999_999 }), + ]); + + assertUnavailable( + missing, + 'renderer-process-metric-missing-during-capture', + { + identity: initial.identity, + missingSampleCount: 1, + validSampleCount: 1, + } + ); + + const laterValid = captureRendererProcessRssSample(missing, RENDERER_PID, [ + metric({ workingSetSize: 5_000 }), + ]); + assert.deepEqual(laterValid, missing); +}); + +test('invalidates the capture when the BrowserWindow or process metric identity changes', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [metric()]); + + assertUnavailable( + captureRendererProcessRssSample(initial, 43, [ + metric({ + creationTime: RENDERER_CREATION_TIME + 1, + pid: 43, + }), + ]), + 'renderer-process-identity-changed', + { + identity: initial.identity, + validSampleCount: 1, + } + ); + + assertUnavailable( + captureRendererProcessRssSample(initial, RENDERER_PID, [ + metric({ creationTime: RENDERER_CREATION_TIME + 1 }), + ]), + 'renderer-process-identity-changed', + { + identity: initial.identity, + validSampleCount: 1, + } + ); +}); + +test('fails closed for invalid or ambiguous metrics after binding', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [metric()]); + const cases: ReadonlyArray<{ + readonly metrics: readonly unknown[]; + readonly reason: RendererProcessRssUnavailableReason; + }> = [ + { + metrics: [metric(), metric({ type: 'Renderer' })], + reason: 'renderer-process-metric-ambiguous', + }, + { + metrics: [metric({ type: 'RendererHelper' })], + reason: 'renderer-process-metric-type-invalid', + }, + { + metrics: [metric({ creationTime: Number.NaN })], + reason: 'renderer-process-metric-creation-time-invalid', + }, + { + metrics: [metric({ workingSetSize: -1 })], + reason: 'renderer-process-metric-working-set-invalid', + }, + ]; + + for (const testCase of cases) { + assertUnavailable( + captureRendererProcessRssSample( + initial, + RENDERER_PID, + testCase.metrics + ), + testCase.reason, + { + identity: initial.identity, + validSampleCount: 1, + } + ); + } +}); + +test('keeps invalid renderer PIDs sticky after binding', () => { + const initial = createRendererProcessRssCapture(RENDERER_PID, [metric()]); + const invalid = captureRendererProcessRssSample(initial, 0, [metric()]); + + assertUnavailable(invalid, 'renderer-os-pid-invalid', { + identity: initial.identity, + validSampleCount: 1, + }); + assert.deepEqual( + captureRendererProcessRssSample(invalid, RENDERER_PID, [metric()]), + invalid + ); +}); 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 new file mode 100644 index 000000000..bc2a09b8f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-process-rss-capture.ts @@ -0,0 +1,199 @@ +export type RendererProcessRssUnavailableReason = + | 'renderer-os-pid-invalid' + | 'renderer-process-identity-changed' + | 'renderer-process-metric-ambiguous' + | 'renderer-process-metric-creation-time-invalid' + | 'renderer-process-metric-missing-at-start' + | 'renderer-process-metric-missing-during-capture' + | 'renderer-process-metric-type-invalid' + | 'renderer-process-metric-working-set-invalid'; + +export interface RendererProcessIdentity { + readonly creationTime: number; + readonly pid: number; +} + +export interface RendererProcessRssCapture { + readonly identity: RendererProcessIdentity | null; + readonly missingSampleCount: number; + readonly peakRssBytes: number | null; + readonly unavailableReason: RendererProcessRssUnavailableReason | null; + 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; +} + +function isValidOsPid(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function findPidCandidates( + processMetrics: readonly unknown[], + pid: number +): JsonRecord[] { + return processMetrics + .map((metric) => readRecord(metric)) + .filter( + (metric): metric is JsonRecord => + metric !== null && metric['pid'] === pid + ); +} + +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 creationTime = metric['creationTime']; + if ( + typeof creationTime !== 'number' || + !Number.isFinite(creationTime) || + creationTime < 0 + ) { + return 'renderer-process-metric-creation-time-invalid'; + } + + 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'; + } + + return { + creationTime, + rssBytes: Number(workingSetSize) * KIBIBYTE_BYTES, + }; +} + +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, + }); +} + +function invalidateCapture( + capture: RendererProcessRssCapture, + unavailableReason: RendererProcessRssUnavailableReason, + missingSampleCount = capture.missingSampleCount +): RendererProcessRssCapture { + return unavailableCapture(unavailableReason, { + identity: capture.identity, + missingSampleCount, + validSampleCount: capture.validSampleCount, + }); +} + +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, + }); +} + +export function captureRendererProcessRssSample( + capture: RendererProcessRssCapture, + 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, + }); +}