fix(perf): scope renderer RSS to exact window

This commit is contained in:
4gray committed 2026-07-27 02:15:59 +02:00
1 parent 5aab81e2cb
commit 918c8f2ded
9 files changed
+744 -58

No files matched your search

@@ -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[];
}
@@ -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(
@@ -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<RendererWindowIdentity> {
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<void> {
await page.getByRole('button', { name: 'Add playlist' }).first().click();
const dialog = page.locator('mat-dialog-container').last();
@@ -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 = <T>(source: string): T => {
const factory = new Function(
`"use strict"; return (${source});`
)() as () => T;
return factory();
};
const rendererProcessRssApi =
restoreFactory<RendererProcessRssCaptureApi>(
input.rendererProcessRssApiFactorySource
);
const rendererWindowRssSessionApi =
restoreFactory<RendererWindowRssSessionApi>(
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<void> => {
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,
};
@@ -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);
});
@@ -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<RendererWindowRssSessionFactory> {
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();
});
@@ -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 });
}
@@ -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),
+9 -3
View File
@@ -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.