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 344767515..daf609acb 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,3 +1,5 @@ +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; + export const PERFORMANCE_ITERATION_KIND = { DIAGNOSTIC: 'diagnostic', MEASURED: 'measured', @@ -100,11 +102,17 @@ export interface MainCaptureMetrics { readonly eventLoopUtilizationUnavailableReason: string | null; readonly heapSnapshotPath: string | null; readonly memory: ProcessMemoryMetrics; - readonly rendererPeakRssBytes: number; + readonly rendererWindow: { + readonly responsiveEvents: number; + readonly rss: RendererProcessRssCapture; + readonly unresponsiveEvents: number; + readonly windowIdentity: { + readonly browserWindowId: number; + readonly webContentsId: number; + }; + }; readonly rssScope: 'electron-main-process-including-worker-threads-and-native-memory'; readonly timeline: readonly MainTimelineRecord[]; - readonly unresponsiveEvents: number; - readonly responsiveEvents: number; readonly workers: readonly WorkerCaptureMetrics[]; } 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 7a8e4c220..9eece7fc6 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 @@ -280,10 +280,14 @@ export function createCancellationBenchmarkSummary( ) ), rendererRssPeakBytes: summarizeNumbers( - measured.map((iteration) => iteration.main.rendererPeakRssBytes) + measured.map( + (iteration) => + iteration.main.rendererWindow.rss.peakRssBytes + ) ), responsiveEvents: measured.reduce( - (sum, iteration) => sum + iteration.main.responsiveEvents, + (sum, iteration) => + sum + iteration.main.rendererWindow.responsiveEvents, 0 ), totalMs: summarizeNumbers( @@ -295,7 +299,8 @@ export function createCancellationBenchmarkSummary( ) ), unresponsiveEvents: measured.reduce( - (sum, iteration) => sum + iteration.main.unresponsiveEvents, + (sum, iteration) => + sum + iteration.main.rendererWindow.unresponsiveEvents, 0 ), visibleTotalMs: summarizeNumbers( 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 311cc4ba8..d78ad9f73 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 @@ -43,6 +43,7 @@ import { stopMainCapture, } from './m3u-refresh-main-capture'; import { startRendererCapture } from './m3u-refresh-renderer-capture'; +import type { RendererWindowIdentity } from './renderer-window-rss-session'; import { createSyntheticM3uFixture, SYNTHETIC_M3U_CHANNEL_COUNT, @@ -193,9 +194,11 @@ async function runIteration( const diagnostic = definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; + const rendererWindowIdentity = await resolveRendererWindowIdentity(app); await startMainCapture(app.electronApp, { diagnostic, outputDirectory: iterationDirectory, + rendererWindowIdentity, }); const renderer = await startRendererCapture(app.mainWindow, { diagnostic, @@ -238,6 +241,28 @@ async function runIteration( } } +async function resolveRendererWindowIdentity( + app: LaunchedElectronApp +): Promise { + const browserWindowHandle = await app.electronApp.browserWindow( + app.mainWindow + ); + try { + return await browserWindowHandle.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 browserWindowHandle.dispose(); + } +} + async function seedPlaylist(page: Page, playlistUrl: string): Promise { await page.getByRole('button', { name: 'Add playlist' }).first().click(); const dialog = page.locator('mat-dialog-container').last(); diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index f61fa12b4..9d8a6323f 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -12,12 +12,24 @@ import { selectMainCaptureGeneration, type MainCaptureGenerationTransport, } from './worker-request-performance'; +import { + createRendererProcessRssCaptureApi, + type RendererProcessRssCaptureApi, +} from './renderer-process-rss-capture'; +import { + createRendererWindowRssSessionApi, + type RendererWindowIdentity, + type RendererWindowRssSession, + type RendererWindowRssSessionApi, + type RendererWindowRssSessionMetrics, +} from './renderer-window-rss-session'; const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; export interface MainCaptureStartOptions { readonly diagnostic: boolean; readonly outputDirectory: string; + readonly rendererWindowIdentity: RendererWindowIdentity; } export interface MainCaptureStatus { @@ -37,6 +49,10 @@ export async function installMainCapture( const captureStateKeys = { databaseRequestIdentityStateKey: DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + rendererProcessRssApiFactorySource: + createRendererProcessRssCaptureApi.toString(), + rendererWindowRssSessionApiFactorySource: + createRendererWindowRssSessionApi.toString(), stateKey: MAIN_CAPTURE_STATE_KEY, }; await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { @@ -127,6 +143,20 @@ export async function installMainCapture( const databaseRequestIdentityCapture = target[ input.databaseRequestIdentityStateKey ] as DatabaseRequestIdentityCaptureApi; + const restoreFactory = (source: string): T => { + const factory = new Function( + `"use strict"; return (${source});` + )() as () => T; + return factory(); + }; + const rendererProcessRssApi = + restoreFactory( + input.rendererProcessRssApiFactorySource + ); + const rendererWindowRssSessionApi = + restoreFactory( + input.rendererWindowRssSessionApiFactorySource + ); const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; @@ -183,16 +213,9 @@ export async function installMainCapture( outputDirectory: '', postGcHeap: null as number | null, postGcRss: null as number | null, - rendererPeakRss: 0, - responsiveEvents: 0, + rendererWindowSession: null as RendererWindowRssSession | null, sampleTimer: null as NodeJS.Timeout | null, timeline: [] as TimelineRecord[], - unresponsiveEvents: 0, - windowListeners: [] as { - responsive: () => void; - unresponsive: () => void; - window: Electron.BrowserWindow; - }[], }; const nowEpochMs = (): number => @@ -644,32 +667,7 @@ export async function installMainCapture( 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, - }); - } + state.rendererWindowSession?.sample(); }; const api = { @@ -705,6 +703,19 @@ export async function installMainCapture( ).length, }), start: async (options: MainCaptureStartOptions): Promise => { + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + const rendererWindowSession = + rendererWindowRssSessionApi.create({ + browserWindowFromId: (browserWindowId) => + BrowserWindow.fromId(browserWindowId), + browserWindowId: + options.rendererWindowIdentity.browserWindowId, + getAppMetrics: () => app.getAppMetrics(), + rendererRssApi: rendererProcessRssApi, + webContentsId: + options.rendererWindowIdentity.webContentsId, + }); state.captureGeneration += 1; state.active = true; databaseRequestIdentityCapture.start(); @@ -715,11 +726,9 @@ export async function installMainCapture( state.timeline = []; state.mainPeakHeap = 0; state.mainPeakRss = 0; - state.rendererPeakRss = 0; state.postGcHeap = null; state.postGcRss = null; - state.unresponsiveEvents = 0; - state.responsiveEvents = 0; + state.rendererWindowSession = rendererWindowSession; state.cpuStart = process.cpuUsage(); state.eventLoopStart = perfHooks.performance.eventLoopUtilization(); @@ -727,7 +736,6 @@ export async function installMainCapture( resolution: 1, }); state.eventLoopDelay.enable(); - attachWindowListeners(); sampleMain(); state.sampleTimer = setInterval(sampleMain, 20); if (state.diagnostic) { @@ -753,6 +761,13 @@ export async function installMainCapture( } state.eventLoopDelay?.disable(); sampleMain(); + const rendererWindow: RendererWindowRssSessionMetrics | null = + state.rendererWindowSession?.snapshot() ?? null; + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + if (rendererWindow === null) { + throw new Error('renderer-window-session-missing'); + } const cpu = process.cpuUsage(state.cpuStart ?? undefined); const eluEnd = perfHooks.performance.eventLoopUtilization(); const elu = perfHooks.performance.eventLoopUtilization( @@ -815,11 +830,6 @@ export async function installMainCapture( } 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; databaseRequestIdentityCapture.stop(); @@ -893,12 +903,10 @@ export async function installMainCapture( postGcHeapUsedBytes: state.postGcHeap, postGcRssBytes: state.postGcRss, }, - rendererPeakRssBytes: state.rendererPeakRss, - responsiveEvents: state.responsiveEvents, + rendererWindow, rssScope: 'electron-main-process-including-worker-threads-and-native-memory', timeline: state.timeline, - unresponsiveEvents: state.unresponsiveEvents, }, workers, }; 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 new file mode 100644 index 000000000..e25251704 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-report.spec.ts @@ -0,0 +1,102 @@ +/* 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 { + type CancellationBenchmarkManifest, + type CancellationIterationResult, + PERFORMANCE_ITERATION_KIND, +} from './m3u-refresh-cancellation-contract'; +import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report'; +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; + +function measuredIteration( + rss: RendererProcessRssCapture, + responsiveEvents: number, + unresponsiveEvents: number +): CancellationIterationResult { + return { + cancellationEffectObserved: true, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 }, + eventLoopUtilization: null, + memory: { + peakHeapUsedBytes: 0, + peakRssBytes: 0, + postGcHeapUsedBytes: null, + postGcRssBytes: null, + }, + rendererPeakRssBytes: 999_999_999, + rendererWindow: { + responsiveEvents, + rss, + unresponsiveEvents, + windowIdentity: { + browserWindowId: 7, + webContentsId: 11, + }, + }, + responsiveEvents: 100, + unresponsiveEvents: 100, + workers: [], + }, + phases: {}, + renderer: { + peakHeapUsedBytes: 0, + postGcHeapUsedBytes: null, + probe: { + frameGapsMs: [], + heartbeatDelaysMs: [], + longTasksMs: [], + }, + }, + runId: 'measured', + } as unknown as CancellationIterationResult; +} + +test('summary uses only exact target-window RSS and scoped responsiveness events', () => { + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [ + measuredIteration( + { + identity: { + creationTime: 1_721_234_567_890, + pid: 42, + }, + missingSampleCount: 0, + peakRssBytes: 2_048, + unavailableReason: null, + validSampleCount: 3, + }, + 1, + 2 + ), + measuredIteration( + { + identity: null, + missingSampleCount: 1, + peakRssBytes: null, + unavailableReason: + 'renderer-process-metric-missing-at-start', + validSampleCount: 0, + }, + 3, + 4 + ), + ] + ); + + assert.deepEqual(summary.measured.rendererRssPeakBytes, { + count: 1, + max: 2_048, + mean: 2_048, + median: 2_048, + min: 2_048, + p95: 2_048, + p99: 2_048, + }); + assert.equal(summary.measured.responsiveEvents, 4); + assert.equal(summary.measured.unresponsiveEvents, 6); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts new file mode 100644 index 000000000..3aa641215 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.spec.ts @@ -0,0 +1,323 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + createRendererProcessRssCaptureApi, + type RendererProcessRssCapture, + type RendererProcessRssCaptureApi, +} from './renderer-process-rss-capture'; + +interface RendererWindowIdentity { + readonly browserWindowId: number; + readonly webContentsId: number; +} + +interface RendererWindowRssSessionMetrics { + readonly responsiveEvents: number; + readonly rss: RendererProcessRssCapture; + readonly unresponsiveEvents: number; + readonly windowIdentity: RendererWindowIdentity; +} + +interface RendererWindowRssSession { + detach(): void; + sample(): RendererWindowRssSessionMetrics; + snapshot(): RendererWindowRssSessionMetrics; +} + +interface RendererWindowRssSessionFactory { + create(input: { + readonly browserWindowFromId: ( + browserWindowId: number + ) => FakeBrowserWindow | null; + readonly browserWindowId: number; + readonly getAppMetrics: () => readonly unknown[]; + readonly rendererRssApi: RendererProcessRssCaptureApi; + readonly webContentsId: number; + }): RendererWindowRssSession; +} + +interface RendererWindowRssSessionModule { + createRendererWindowRssSessionApi?: () => RendererWindowRssSessionFactory; +} + +class FakeWebContents { + pidReads = 0; + throwOnPidRead = false; + + constructor( + readonly id: number, + public pid: number + ) {} + + getOSProcessId(): number { + this.pidReads += 1; + if (this.throwOnPidRead) { + throw new Error('renderer unavailable'); + } + return this.pid; + } +} + +class FakeBrowserWindow extends EventEmitter { + constructor( + readonly id: number, + readonly webContents: FakeWebContents + ) { + super(); + } +} + +const BROWSER_WINDOW_ID = 7; +const WEB_CONTENTS_ID = 70; +const RENDERER_PID = 700; +const CREATION_TIME = 1_721_234_567_890; + +const sessionModulePromise = import( + new URL('./renderer-window-rss-session.ts', import.meta.url).href +) + .then((module) => module as RendererWindowRssSessionModule) + .catch(() => null); + +function processMetric( + pid: number, + workingSetSize: number, + creationTime = CREATION_TIME +): unknown { + return { + creationTime, + memory: { workingSetSize }, + pid, + type: 'Tab', + }; +} + +async function restoreSerializableFactory(): Promise { + const module = await sessionModulePromise; + assert.ok(module, 'renderer window RSS session module must exist'); + const factory = module.createRendererWindowRssSessionApi; + assert.equal(typeof factory, 'function'); + + const source = factory.toString(); + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => RendererWindowRssSessionFactory; + return restoredFactory(); +} + +test('scopes renderer RSS and window events to the exact BrowserWindow', async () => { + const api = await restoreSerializableFactory(); + const targetWebContents = new FakeWebContents( + WEB_CONTENTS_ID, + RENDERER_PID + ); + const targetWindow = new FakeBrowserWindow( + BROWSER_WINDOW_ID, + targetWebContents + ); + const decoyWebContents = new FakeWebContents(71, 701); + const decoyWindow = new FakeBrowserWindow(8, decoyWebContents); + let targetExternalEvents = 0; + let decoyExternalEvents = 0; + targetWindow.on('unresponsive', () => { + targetExternalEvents += 1; + }); + decoyWindow.on('unresponsive', () => { + decoyExternalEvents += 1; + }); + + let appMetricReads = 0; + const targetWorkingSets = [1_000, 1_500]; + const session = api.create({ + browserWindowFromId: (id) => + id === BROWSER_WINDOW_ID ? targetWindow : decoyWindow, + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => { + const targetWorkingSet = targetWorkingSets[ + Math.min(appMetricReads, 1) + ] as number; + appMetricReads += 1; + return [ + processMetric(701, 9_999_999, CREATION_TIME + 1), + processMetric(RENDERER_PID, targetWorkingSet), + ]; + }, + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }); + + decoyWindow.emit('unresponsive'); + decoyWindow.emit('responsive'); + targetWindow.emit('unresponsive'); + targetWindow.emit('responsive'); + const sampled = session.sample(); + + assert.deepEqual(sampled, { + rss: { + identity: { + creationTime: CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: 1_536_000, + unavailableReason: null, + validSampleCount: 2, + }, + responsiveEvents: 1, + unresponsiveEvents: 1, + windowIdentity: { + browserWindowId: BROWSER_WINDOW_ID, + webContentsId: WEB_CONTENTS_ID, + }, + }); + assert.equal(targetWebContents.pidReads, 2); + assert.equal(decoyWebContents.pidReads, 0); + assert.equal(appMetricReads, 2); + assert.equal(targetExternalEvents, 1); + assert.equal(decoyExternalEvents, 1); + assert.equal(targetWindow.listenerCount('unresponsive'), 2); + assert.equal(decoyWindow.listenerCount('unresponsive'), 1); + + session.detach(); + assert.equal(targetWindow.listenerCount('unresponsive'), 1); + assert.equal(targetWindow.listenerCount('responsive'), 0); + assert.equal(decoyWindow.listenerCount('unresponsive'), 1); + targetWindow.emit('unresponsive'); + decoyWindow.emit('unresponsive'); + assert.equal(session.snapshot().unresponsiveEvents, 1); + assert.equal(targetExternalEvents, 2); + assert.equal(decoyExternalEvents, 2); +}); + +test('rejects a missing or mismatched BrowserWindow before attaching listeners', async () => { + const api = await restoreSerializableFactory(); + const targetWindow = new FakeBrowserWindow( + BROWSER_WINDOW_ID, + new FakeWebContents(WEB_CONTENTS_ID, RENDERER_PID) + ); + let appMetricReads = 0; + const baseInput = { + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => { + appMetricReads += 1; + return [processMetric(RENDERER_PID, 1_000)]; + }, + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }; + + assert.throws( + () => + api.create({ + ...baseInput, + browserWindowFromId: () => null, + }), + /renderer-browser-window-missing/ + ); + assert.throws( + () => + api.create({ + ...baseInput, + browserWindowFromId: () => + new FakeBrowserWindow( + BROWSER_WINDOW_ID + 1, + targetWindow.webContents + ), + }), + /renderer-browser-window-identity-mismatch/ + ); + assert.throws( + () => + api.create({ + ...baseInput, + browserWindowFromId: () => + new FakeBrowserWindow( + BROWSER_WINDOW_ID, + new FakeWebContents(WEB_CONTENTS_ID + 1, RENDERER_PID) + ), + }), + /renderer-web-contents-identity-mismatch/ + ); + assert.equal(targetWindow.listenerCount('unresponsive'), 0); + assert.equal(targetWindow.listenerCount('responsive'), 0); + assert.equal(targetWindow.webContents.pidReads, 0); + assert.equal(appMetricReads, 0); +}); + +test('keeps PID and metric failures sticky while sampling the exact target every time', async () => { + const api = await restoreSerializableFactory(); + const targetWebContents = new FakeWebContents( + WEB_CONTENTS_ID, + RENDERER_PID + ); + const targetWindow = new FakeBrowserWindow( + BROWSER_WINDOW_ID, + targetWebContents + ); + let appMetricReads = 0; + let metrics: readonly unknown[] = [processMetric(RENDERER_PID, 1_000)]; + const session = api.create({ + browserWindowFromId: () => targetWindow, + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => { + appMetricReads += 1; + return metrics; + }, + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }); + + targetWebContents.pid = RENDERER_PID + 1; + metrics = [processMetric(RENDERER_PID + 1, 5_000, CREATION_TIME + 1)]; + const invalid = session.sample(); + assert.deepEqual(invalid.rss, { + identity: { + creationTime: CREATION_TIME, + pid: RENDERER_PID, + }, + missingSampleCount: 0, + peakRssBytes: null, + unavailableReason: 'renderer-process-identity-changed', + validSampleCount: 1, + }); + + targetWebContents.pid = RENDERER_PID; + metrics = [processMetric(RENDERER_PID, 10_000)]; + assert.deepEqual(session.sample().rss, invalid.rss); + assert.equal(targetWebContents.pidReads, 3); + assert.equal(appMetricReads, 3); + + const missingMetricSession = api.create({ + browserWindowFromId: () => targetWindow, + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => [], + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }); + assert.deepEqual(missingMetricSession.snapshot().rss, { + identity: null, + missingSampleCount: 1, + peakRssBytes: null, + unavailableReason: 'renderer-process-metric-missing-at-start', + validSampleCount: 0, + }); + + targetWebContents.throwOnPidRead = true; + const invalidPidSession = api.create({ + browserWindowFromId: () => targetWindow, + browserWindowId: BROWSER_WINDOW_ID, + getAppMetrics: () => [processMetric(RENDERER_PID, 1_000)], + rendererRssApi: createRendererProcessRssCaptureApi(), + webContentsId: WEB_CONTENTS_ID, + }); + assert.equal( + invalidPidSession.snapshot().rss.unavailableReason, + 'renderer-os-pid-invalid' + ); + + session.detach(); + missingMetricSession.detach(); + invalidPidSession.detach(); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts new file mode 100644 index 000000000..bef34fc32 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-window-rss-session.ts @@ -0,0 +1,174 @@ +import type { + RendererProcessRssCapture, + RendererProcessRssCaptureApi, +} from './renderer-process-rss-capture'; + +export interface RendererWindowIdentity { + readonly browserWindowId: number; + readonly webContentsId: number; +} + +export interface RendererWindowRssSessionMetrics { + readonly responsiveEvents: number; + readonly rss: RendererProcessRssCapture; + readonly unresponsiveEvents: number; + readonly windowIdentity: RendererWindowIdentity; +} + +export interface RendererWindowWebContents { + readonly id: number; + getOSProcessId(): number; +} + +export interface RendererWindow { + readonly id: number; + readonly webContents: RendererWindowWebContents; + off(event: 'responsive' | 'unresponsive', listener: () => void): unknown; + on(event: 'responsive' | 'unresponsive', listener: () => void): unknown; +} + +export interface RendererWindowRssSession { + detach(): void; + sample(): RendererWindowRssSessionMetrics; + snapshot(): RendererWindowRssSessionMetrics; +} + +export interface RendererWindowRssSessionCreateInput { + readonly browserWindowFromId: ( + browserWindowId: number + ) => RendererWindow | null; + readonly browserWindowId: number; + readonly getAppMetrics: () => readonly unknown[]; + readonly rendererRssApi: RendererProcessRssCaptureApi; + readonly webContentsId: number; +} + +export interface RendererWindowRssSessionApi { + create( + input: RendererWindowRssSessionCreateInput + ): RendererWindowRssSession; +} + +export function createRendererWindowRssSessionApi(): RendererWindowRssSessionApi { + const helpers = { + isValidIdentityId(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; + }, + + create( + input: RendererWindowRssSessionCreateInput + ): RendererWindowRssSession { + if (!helpers.isValidIdentityId(input.browserWindowId)) { + throw new Error('renderer-browser-window-id-invalid'); + } + if (!helpers.isValidIdentityId(input.webContentsId)) { + throw new Error('renderer-web-contents-id-invalid'); + } + + let browserWindow: RendererWindow | null = null; + try { + browserWindow = input.browserWindowFromId( + input.browserWindowId + ); + } catch { + throw new Error('renderer-browser-window-missing'); + } + if (browserWindow === null) { + throw new Error('renderer-browser-window-missing'); + } + if (browserWindow.id !== input.browserWindowId) { + throw new Error('renderer-browser-window-identity-mismatch'); + } + const webContents = browserWindow.webContents; + if ( + webContents === null || + typeof webContents !== 'object' || + webContents.id !== input.webContentsId + ) { + throw new Error('renderer-web-contents-identity-mismatch'); + } + + const windowIdentity = Object.freeze({ + browserWindowId: input.browserWindowId, + webContentsId: input.webContentsId, + }); + let rendererRss = input.rendererRssApi.create( + helpers.readOsProcessId(webContents), + helpers.readAppMetrics(input.getAppMetrics) + ); + let responsiveEvents = 0; + let unresponsiveEvents = 0; + let detached = false; + const callbacks = { + onResponsive(): void { + if (!detached) { + responsiveEvents += 1; + } + }, + onUnresponsive(): void { + if (!detached) { + unresponsiveEvents += 1; + } + }, + snapshot(): RendererWindowRssSessionMetrics { + return Object.freeze({ + responsiveEvents, + rss: rendererRss, + unresponsiveEvents, + windowIdentity, + }); + }, + }; + browserWindow.on('unresponsive', callbacks.onUnresponsive); + try { + browserWindow.on('responsive', callbacks.onResponsive); + } catch (error) { + browserWindow.off('unresponsive', callbacks.onUnresponsive); + throw error; + } + + return Object.freeze({ + detach(): void { + if (detached) { + return; + } + detached = true; + browserWindow.off('unresponsive', callbacks.onUnresponsive); + browserWindow.off('responsive', callbacks.onResponsive); + }, + sample(): RendererWindowRssSessionMetrics { + if (!detached) { + rendererRss = input.rendererRssApi.sample( + rendererRss, + helpers.readOsProcessId(webContents), + helpers.readAppMetrics(input.getAppMetrics) + ); + } + return callbacks.snapshot(); + }, + snapshot: callbacks.snapshot, + }); + }, + + readAppMetrics( + getAppMetrics: () => readonly unknown[] + ): readonly unknown[] { + try { + const metrics = getAppMetrics(); + return Array.isArray(metrics) ? metrics : []; + } catch { + return []; + } + }, + + readOsProcessId(webContents: RendererWindowWebContents): unknown { + try { + return webContents.getOSProcessId(); + } catch { + return null; + } + }, + }; + + return Object.freeze({ create: helpers.create }); +} diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts index 72a3ae522..a1cfb2a64 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -130,9 +130,24 @@ function measuredIteration( postGcHeapUsedBytes: null, postGcRssBytes: null, }, - rendererPeakRssBytes: 0, - responsiveEvents: 0, - unresponsiveEvents: 0, + rendererWindow: { + responsiveEvents: 0, + rss: { + identity: { + creationTime: 1_721_234_567_890, + pid: 42, + }, + missingSampleCount: 0, + peakRssBytes: 0, + unavailableReason: null, + validSampleCount: 1, + }, + unresponsiveEvents: 0, + windowIdentity: { + browserWindowId: 7, + webContentsId: 11, + }, + }, workers, } as CancellationIterationResult['main'], phases: {} as CancellationIterationResult['phases'], @@ -214,6 +229,26 @@ test('main capture retains raw request identity, timestamps, metrics, and reason ); }); +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), + 'utf8' + ); + const mainCaptureSource = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match( + benchmarkSource, + /electronApp\.browserWindow\(\s*app\.mainWindow\s*\)/ + ); + assert.match(benchmarkSource, /rendererWindowIdentity/); + assert.doesNotMatch(mainCaptureSource, /BrowserWindow\.getAllWindows/); + assert.doesNotMatch(mainCaptureSource, /type\.includes\('renderer'\)/); + assert.doesNotMatch(mainCaptureSource, /type\.includes\('tab'\)/); +}); + test('capture-generation selection excludes a pre-start seed worker', () => { const seed = playlistWorker( requestPerformance('seed-request', 999, 999), diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 09aef8f12..a6bb2cd23 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -50,7 +50,7 @@ All four parse call sites (Electron `playlist-source.ts` import, `playlist-refre - **`radio` attribute** — `item.radio` (string, `'true'` triggers the radio player, EPG suppression, and external-player gating app-wide). Upstream does not have this field; it must survive every upstream sync. - **Pipe stripping** — `item.url` is cut at the first `|`; `|User-Agent=` / `|Referer=` params still land in `item.http`. Upstream 0.15.0 stopped stripping, but iptvnator consumes `item.url` verbatim in hls.js/mpv/vlc, catch-up URL building, and url-keyed favorites. -- **`#KODIPROP` lines before `#EXTINF` are preserved** (since `v0.15.2-iptvnator.2`) — Kodi property lines apply to the *next* list entry, so ones placed above the `#EXTINF` are buffered and attached to that item's `raw` in file order (case-insensitive prefix); other stray `#` lines outside an open item are still dropped. The DASH + ClearKey feature extracts `inputstream.adaptive.license_*` config from `item.raw`, so this delta must survive every upstream sync. +- **`#KODIPROP` lines before `#EXTINF` are preserved** (since `v0.15.2-iptvnator.2`) — Kodi property lines apply to the _next_ list entry, so ones placed above the `#EXTINF` are buffered and attached to that item's `raw` in file order (case-insensitive prefix); other stray `#` lines outside an open item are still dropped. The DASH + ClearKey feature extracts `inputstream.adaptive.license_*` config from `item.raw`, so this delta must survive every upstream sync. There is intentionally **no URL validation** (upstream removed it in 0.15.0): any non-empty non-`#` line after `#EXTINF` becomes the item URL. This is what fixes issue #1189 (Pluto TV JWT URLs longer than validator's 2084-char IE-era limit used to be rejected, and the stalled item index collapsed the whole playlist into one channel). `#` comment lines and unknown directives are appended to `item.raw` and never treated as URLs. @@ -123,7 +123,13 @@ The target reserves and verifies CDP port 9222, freezes renderer long-task, frame-gap, and heartbeat probes before forced post-GC heap collection, and builds the Electron main process, renderer, and workers with optimized, source-mapped performance configurations before enabling opt-in worker -profiling. Each worker response retains a raw +profiling. Renderer RSS is scoped to the Playwright page's exact +`BrowserWindow` and `webContents`: every sample matches `getOSProcessId()` to +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 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 @@ -811,7 +817,7 @@ player in settings. 1. The playlist parser fork does not interpret `#KODIPROP:` lines, but preserves them in `item.raw` for **both** layouts: unknown lines between `#EXTINF` and the stream URL are kept as before, and since parser pin - `v0.15.2-iptvnator.2` `#KODIPROP` lines placed *before* the `#EXTINF` are + `v0.15.2-iptvnator.2` `#KODIPROP` lines placed _before_ the `#EXTINF` are buffered and attached to the **next** entry's `raw` in file order (Kodi semantics, case-insensitive prefix). Other stray `#` lines outside an open item are still dropped, matching upstream.