diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index ca5b02a0b..217dd1ca3 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -1,4 +1,7 @@ -import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; +import type { + RendererProcessRssCapture, + RendererProcessRssUnavailableReason, +} from './renderer-process-rss-capture'; export const PERFORMANCE_ITERATION_KIND = { DIAGNOSTIC: 'diagnostic', @@ -254,6 +257,22 @@ 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[]; @@ -309,5 +328,6 @@ export interface CancellationBenchmarkSummary { }; readonly validity: { readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity; + readonly rendererRss: RendererRssValidity; }; } diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index a384d27de..6311fbd06 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -9,6 +9,7 @@ import type { NumericDistribution, PerformanceWorkerKind, RendererCaptureMetrics, + RendererRssValidity, WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { @@ -310,10 +311,55 @@ export function createCancellationBenchmarkSummary( validity: Object.freeze({ databaseWorkerPostGc: assessDatabaseWorkerPostGcValidity(iterations), + rendererRss: assessRendererRssValidity(iterations), }), }); } +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 diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index 56b8a1a04..243803989 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -157,6 +157,13 @@ export async function runM3uRefreshCancellationBenchmark(): Promise { `Cancellation effect was not observed in every measured run: ${summary.cancellationEffectRate}` ); } + if (!summary.validity.rendererRss.validForBenchmark) { + throw new Error( + `Renderer RSS capture is invalid: ${JSON.stringify( + summary.validity.rendererRss.invalidMeasuredRuns + )}` + ); + } if (!summary.validity.databaseWorkerPostGc.validForBenchmark) { throw new Error( `Database worker post-GC capture is invalid: ${JSON.stringify( diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts index e25251704..a6f0c2b21 100644 --- a/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import test from 'node:test'; import { @@ -13,7 +14,8 @@ import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; function measuredIteration( rss: RendererProcessRssCapture, responsiveEvents: number, - unresponsiveEvents: number + unresponsiveEvents: number, + runId: string ): CancellationIterationResult { return { cancellationEffectObserved: true, @@ -51,7 +53,7 @@ function measuredIteration( longTasksMs: [], }, }, - runId: 'measured', + runId, } as unknown as CancellationIterationResult; } @@ -71,19 +73,24 @@ test('summary uses only exact target-window RSS and scoped responsiveness events validSampleCount: 3, }, 1, - 2 + 2, + 'run-01' ), measuredIteration( { - identity: null, + identity: { + creationTime: 1_721_234_567_890, + pid: 43, + }, missingSampleCount: 1, peakRssBytes: null, unavailableReason: - 'renderer-process-metric-missing-at-start', - validSampleCount: 0, + 'renderer-process-metric-missing-during-capture', + validSampleCount: 2, }, 3, - 4 + 4, + 'run-02' ), ] ); @@ -99,4 +106,48 @@ test('summary uses only exact target-window RSS and scoped responsiveness events }); assert.equal(summary.measured.responsiveEvents, 4); assert.equal(summary.measured.unresponsiveEvents, 6); + assert.deepEqual( + ( + summary as unknown as { + readonly validity: { + readonly rendererRss: unknown; + }; + } + ).validity.rendererRss, + { + invalidMeasuredRuns: [ + { + missingSampleCount: 1, + reason: 'renderer-process-metric-missing-during-capture', + runId: 'run-02', + validSampleCount: 2, + }, + ], + measuredRunCount: 2, + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 1, + } + ); +}); + +test('benchmark preserves raw artifacts before rejecting incomplete renderer RSS', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), + 'utf8' + ); + const summaryWrite = source.indexOf( + "writeJson(join(config.outputDirectory, 'summary.json'), summary)" + ); + const validityGuard = source.indexOf( + 'summary.validity.rendererRss.validForBenchmark' + ); + + assert.ok(summaryWrite >= 0, 'summary artifact write must exist'); + assert.ok(validityGuard >= 0, 'renderer RSS validity guard must exist'); + assert.ok( + summaryWrite < validityGuard, + 'raw summary must be durable before the renderer RSS guard fails' + ); + assert.match(source, /Renderer RSS capture is invalid/); }); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 6456e626c..df6d257a0 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -129,7 +129,10 @@ one exact `app.getAppMetrics()` PID and creation time, while responsive and unresponsive events come only from that window. Identity changes, missing or ambiguous process metrics, and invalid working-set values fail closed with a raw reason and nullable RSS; summaries exclude unavailable RSS instead of -reporting zero. Each worker response retains a raw +reporting zero. Formal runs also list every invalid measured capture under +`summary.validity.rendererRss` and fail after persisting the raw summary unless +all measured iterations contribute one valid exact-window RSS value. Each +worker response retains a raw request-scoped record containing request/operation identity, received/work/flush timestamps, thread CPU, event-loop utilization, event-loop delay, and fixed unavailability or invalid reasons. Missing or malformed profiling metadata