mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(perf): scope renderer RSS to exact window
This commit is contained in:
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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user