fix(desktop): fence diagnostic cancellation by request identity

Problem: A delayed cancellation after a long suspension could terminate a newer capture, and a stale dismiss action could remove a replacement report.

Root cause: Native cancellation lacked request identity and old UI actions were not guarded by the report generation.

Fix: Carry unique capture identities through preload/IPC, ignore unscoped or mismatched cancellation, and fence report actions. Separate controlled native smoke preconditions from production activity-policy event tests, and trigger synthetic long tasks through the renderer event loop.

Verification: 167 Electron tests, frontend typecheck and crash/lifecycle/enrichment/cancellation suites, repository lint, and actual CDP/ASAR-worker/report/heap smoke passed. The prior steady-state experiment used identical renderer bundles across nine trials; CI collects fresh Windows evidence.
This commit is contained in:
SivanCola committed 2026-09-11 15:32:45 +08:00
1 parent 18e55a8dc1
commit 0b82ba17e9
17 files changed
+200 -49

No files matched your search

@@ -63,8 +63,6 @@ export async function performanceFixture(monitor = true, { archiveWorker = false
import { registerRendererIpc } from './src/main/ipc.ts';
import { ProcessDiagnostics } from './src/main/processDiagnostics.ts';
import { createPerformanceHost } from './src/main/performanceHost.ts';
import { RendererDiagnostics } from './src/main/rendererDiagnostics.ts';
import { analyseInWorker } from './src/main/profileAnalysisHost.ts';
app.setPath('userData', ${JSON.stringify(join(temp, "profile"))});
protocol.registerSchemesAsPrivileged([{scheme:'reasonix', privileges:{standard:true,secure:true,supportFetchAPI:true,corsEnabled:false,stream:true}}]);
app.whenReady().then(async () => {
@@ -74,21 +72,13 @@ export async function performanceFixture(monitor = true, { archiveWorker = false
const win = new BrowserWindow({show:true,focusable:${!benchmark},width:1000,height:750,webPreferences:{backgroundThrottling:${!benchmark},preload:${JSON.stringify(join(temp, "preload.cjs"))},sandbox:true,contextIsolation:true,nodeIntegration:false}});
const diagnostics = new ProcessDiagnostics(()=>app.getAppMetrics(), undefined, ()=>${benchmark}||(win.isVisible()&&win.isFocused()));
if (${monitor}) { diagnostics.sample(); setInterval(()=>diagnostics.sample(),30000).unref(); }
const performanceHost = createPerformanceHost({window:()=>win.isDestroyed()?null:win,workerPath:${JSON.stringify(workerPath)},locale:()=>'en',dialog:{
const performanceHost = createPerformanceHost({window:()=>win.isDestroyed()?null:win,workerPath:${JSON.stringify(workerPath)},locale:()=>'en',isForeground:${benchmark} ? ()=>true : undefined,dialog:{
showMessageBox:async()=>({response:1}),showSaveDialog:async()=>({canceled:false,filePath:${JSON.stringify(join(temp, "fixture.heapsnapshot"))}})
}});
// Pin activity only for cost measurement. The separate smoke exercises
// the production focus/navigation adapter without these overrides.
const benchmarkCpu = ${benchmark} ? new RendererDiagnostics({
target:()=>win.webContents,isForeground:()=>true,
onInvalidated:(cancel)=>{win.webContents.on('destroyed',cancel);return ()=>win.webContents.removeListener('destroyed',cancel);},
analyse:(profile)=>analyseInWorker(profile,${JSON.stringify(workerPath)})
}) : null;
const actions = benchmarkCpu ? {...performanceHost,captureRendererProfile:()=>benchmarkCpu.capture(),cancelRendererProfile:()=>benchmarkCpu.cancel()} : performanceHost;
registerRendererIpc({ipcMain,contract:{protocolVersion:1,digest:'test',commands:[],commandSet:new Set()},
window:{isTrustedSender:(sender,frame)=>sender===win.webContents && frame===sender.mainFrame},clipboard,
serviceState:()=>({phase:'ready',generation:'test'}),processDiagnostics:()=>diagnostics.snapshot(),performance:actions,log});
app.once('will-quit',()=>{performanceHost.dispose();benchmarkCpu?.dispose();});
serviceState:()=>({phase:'ready',generation:'test'}),processDiagnostics:()=>diagnostics.snapshot(),performance:performanceHost,log});
app.once('will-quit',()=>performanceHost.dispose());
await win.loadURL('reasonix://app/index.html?monitor=${monitor ? "1" : "0"}'); if (!${benchmark}) win.focus();
});
` }, outfile: join(temp, "main.cjs") });
+13 -5
View File
@@ -4,7 +4,7 @@ import { readFileSync, statSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { performanceFixture } from "./performance-fixture.mjs";
const fixture = await performanceFixture(true, { archiveWorker: true });
const fixture = await performanceFixture(true, { archiveWorker: true, benchmark: true });
try {
const { page, app, temp } = fixture;
assert.equal(await page.evaluate(async () => (await fetch(location.href)).headers.get("Document-Policy")), null);
@@ -13,10 +13,18 @@ try {
// Exercise the production startup/visibility grace period before a deliberate
// long task in the disposable document.
await page.waitForTimeout(16000);
await app.evaluate(({ app, BrowserWindow }) => { app.focus({ steal: true }); BrowserWindow.getAllWindows()[0].focus(); });
await page.waitForFunction(() => document.hasFocus());
await page.locator("#work").click();
await page.locator("#performance-report-prompt").waitFor();
await page.evaluate(() => {
window.fixtureLongTasks = [];
new PerformanceObserver((entries) => { window.fixtureLongTasks.push(...entries.getEntries().map((entry) => entry.duration)); }).observe({ entryTypes: ["longtask"] });
// A DevTools Runtime.evaluate call is not a normal renderer task. Schedule
// work through the event loop so the browser's long-task observer sees it.
setTimeout(() => window.diagnosticFixture.burn(950), 0);
});
try { await page.locator("#performance-report-prompt").waitFor(); }
catch (error) {
console.error(await page.evaluate(() => ({ focused: document.hasFocus(), visibility: document.visibilityState, longTasks: window.fixtureLongTasks })));
throw error;
}
const copy = page.locator(".performance-report__copy").first();
await page.evaluate(() => window.diagnosticFixture.run(5500));
try {
+3 -1
View File
@@ -80,7 +80,9 @@ test("renderer invokes are gated by sender identity and the contract allowlist",
assert.deepEqual(await handlers.get(channel)!({ sender: trustedSender, senderFrame: {} }), { ok: false, message: "untrusted sender" });
}
assert.deepEqual(performanceCalls, []);
for (const channel of [IPC.captureRendererProfile, IPC.cancelRendererProfile, IPC.exportHeapSnapshot]) await handlers.get(channel)!(trusted);
await handlers.get(IPC.cancelRendererProfile)!(trusted);
assert.deepEqual(performanceCalls, [], "unscoped IPC cancellation is ignored");
for (const channel of [IPC.captureRendererProfile, IPC.cancelRendererProfile, IPC.exportHeapSnapshot]) await handlers.get(channel)!(trusted, "test-request");
assert.deepEqual(performanceCalls, ["capture", "cancel", "heap"]);
assert.deepEqual(await invoke({ sender: { id: 9 }, senderFrame: trustedFrame }, "OpenProjectTab", ["/p"]), { ok: false, message: "untrusted sender" });
assert.deepEqual(await invoke({ sender: trustedSender, senderFrame: {} }, "OpenProjectTab", ["/p"]), { ok: false, message: "untrusted sender" });
+11 -2
View File
@@ -66,6 +66,12 @@ export interface RendererIpcDeps {
const NAVIGATE_ACTIONS = new Set(["back", "forward", "reload", "stop"]);
function diagnosticRequestId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || !/^[a-zA-Z0-9-]{1,96}$/.test(value)) throw new Error("invalid diagnostic request identity");
return value;
}
export function parseNavigateTarget(value: unknown): BrowserNavigateTarget {
const target = record(value);
const action = str(target, "action");
@@ -121,8 +127,11 @@ export function registerRendererIpc(deps: RendererIpcDeps): void {
});
handle(IPC.serviceStateGet, () => deps.serviceState());
handle(IPC.processDiagnostics, () => deps.processDiagnostics?.() ?? null);
handle(IPC.captureRendererProfile, () => deps.performance?.captureRendererProfile() ?? { status: "unavailable" });
handle(IPC.cancelRendererProfile, () => deps.performance?.cancelRendererProfile());
handle(IPC.captureRendererProfile, (id) => deps.performance?.captureRendererProfile(diagnosticRequestId(id)) ?? { status: "unavailable" });
handle(IPC.cancelRendererProfile, (id) => {
const requestId = diagnosticRequestId(id);
if (requestId) deps.performance?.cancelRendererProfile(requestId);
});
handle(IPC.exportHeapSnapshot, () => deps.performance?.exportHeapSnapshot() ?? { status: "unavailable" });
handle(IPC.openExternal, (url) => {
if (!isOpenableExternalURL(url)) throw new Error(`refusing to open ${typeof url === "string" ? url : typeof url}`);
@@ -39,3 +39,31 @@ test("navigation while the consent dialog is open cancels export", async () => {
assert.deepEqual(await host.exportHeapSnapshot(), { status: "cancelled" });
assert.equal(contents.listenerCount("did-start-navigation"), 0);
});
test("production activity policy requires native focus and cancels on blur", async () => {
let focused = false;
let attached = false;
const debuggerApi = Object.assign(new EventEmitter(), {
isAttached: () => attached,
attach: () => { attached = true; },
detach: () => { attached = false; },
sendCommand: async () => ({ profile: { nodes: [], startTime: 0, endTime: 0 } }),
});
const contents = Object.assign(new EventEmitter(), { debugger: debuggerApi, isDestroyed: () => false, isDevToolsOpened: () => false });
const win = Object.assign(new EventEmitter(), { webContents: contents, isVisible: () => true, isFocused: () => focused });
const host = createPerformanceHost({ window: () => win as unknown as BrowserWindow, workerPath: "must-not-run", locale: () => "en", dialog: {
showMessageBox: async () => { throw Error("must not prompt"); }, showSaveDialog: async () => { throw Error("must not save"); },
} });
assert.deepEqual(await host.captureRendererProfile("inactive"), { status: "inactive" });
assert.deepEqual(await host.exportHeapSnapshot(), { status: "unavailable" });
assert.equal(attached, false);
focused = true;
const capture = host.captureRendererProfile("active");
for (let i = 0; i < 20; i++) await Promise.resolve();
assert.equal(attached, true);
focused = false;
win.emit("blur");
assert.deepEqual(await capture, { status: "cancelled" });
assert.equal(attached, false);
assert.equal(win.listenerCount("blur"), 0);
});
+7 -4
View File
@@ -8,10 +8,13 @@ export function createPerformanceHost(deps: {
dialog: Pick<Dialog, "showMessageBox" | "showSaveDialog">;
workerPath: string;
locale(): string;
/** Controlled native fixtures can pin activity without changing production policy. */
isForeground?(): boolean;
}) {
const isForeground = deps.isForeground ?? (() => Boolean(deps.window()?.isVisible() && deps.window()?.isFocused()));
const cpu = new RendererDiagnostics({
target: () => deps.window()?.webContents ?? null,
isForeground: () => Boolean(deps.window()?.isVisible() && deps.window()?.isFocused()),
isForeground,
onInvalidated: (cancel) => {
const win = deps.window();
if (!win) { cancel(); return () => {}; }
@@ -43,14 +46,14 @@ export function createPerformanceHost(deps: {
let heapBusy = false;
let disposed = false;
return {
captureRendererProfile: () => heapBusy ? Promise.resolve({ status: "busy" as const }) : cpu.capture(),
cancelRendererProfile: () => cpu.cancel(),
captureRendererProfile: (requestId?: string) => heapBusy ? Promise.resolve({ status: "busy" as const }) : cpu.capture(requestId),
cancelRendererProfile: (requestId?: string) => { if (requestId) cpu.cancel(requestId); },
dispose: () => { disposed = true; cpu.dispose(); },
async exportHeapSnapshot(): Promise<HeapResult> {
if (disposed) return { status: "unavailable" };
if (heapBusy || cpu.busy) return { status: "busy" };
const win = deps.window();
if (!win || !win.isVisible() || !win.isFocused()) return { status: "unavailable" };
if (!win || !isForeground()) return { status: "unavailable" };
heapBusy = true;
let invalidated = false;
const contents = win.webContents;
@@ -292,3 +292,36 @@ test('listener cleanup failure cannot skip debugger cleanup or replace the captu
assert.equal(fixture.debug.detaches, 1);
assert.equal((await fixture.owner.capture()).status, 'cooldown');
});
test('a late cancellation for an old request cannot interrupt a newer capture', async t => {
const fixture = setup(t, { durationMs: 100, cooldownMs: 1000 });
const old = fixture.owner.capture('old');
await flush();
await fixture.advance(100);
assert.equal((await old).status, 'captured');
await fixture.advance(1001);
const current = fixture.owner.capture('new');
await flush();
fixture.owner.cancel('old');
await flush();
assert.equal(fixture.debug.attached, true);
assert.equal((await fixture.owner.capture('another')).status, 'busy');
await fixture.advance(99);
assert.equal(fixture.debug.attached, true);
await fixture.advance(1);
assert.equal((await current).status, 'captured');
assert.equal(fixture.analysed(), 2);
assert.equal(fixture.debug.attached, false);
});
test('a cancellation with the active request identity ends its own capture', async t => {
const fixture = setup(t);
const result = fixture.owner.capture('new');
await flush();
fixture.owner.cancel('new');
await flush();
assert.equal((await result).status, 'cancelled');
assert.equal(fixture.analysed(), 0);
assert.equal(fixture.debug.attached, false);
});
@@ -39,17 +39,20 @@ interface Dependencies {
// connection from DevTools or another caller; cancellation retains ownership
// until stop/detach has completed, so a late reply cannot affect a new capture.
export class RendererDiagnostics {
private active: { cancel(): void } | undefined;
private active: { requestId?: string; cancel(): void } | undefined;
private lastAttempt = -Infinity;
private captures = 0;
private disposed = false;
constructor(private readonly deps: Dependencies) {}
get busy(): boolean { return this.active !== undefined; }
cancel(): void { this.active?.cancel(); }
cancel(requestId?: string): void {
if (requestId !== undefined && this.active?.requestId !== requestId) return;
this.active?.cancel();
}
dispose(): void { this.disposed = true; this.cancel(); }
async capture(): Promise<RendererProfileResult> {
async capture(requestId?: string): Promise<RendererProfileResult> {
if (this.disposed) return { status: "unavailable" };
if (this.active) return { status: "busy" };
if (!this.deps.isForeground()) return { status: "inactive" };
@@ -64,7 +67,7 @@ export class RendererDiagnostics {
let ownsConnection = false;
let wake!: () => void;
const interrupted = new Promise<void>((resolve) => { wake = resolve; });
const owner = { cancel: () => { cancelled = true; wake(); } };
const owner = { requestId, cancel: () => { cancelled = true; wake(); } };
this.active = owner;
const onDetach = () => { ownsConnection = false; owner.cancel(); };
let removeInvalidated = () => {};
+2 -2
View File
@@ -191,8 +191,8 @@ contextBridge.exposeInMainWorld("reasonixDesktop", {
on,
native: {
processDiagnostics: () => call(IPC.processDiagnostics),
captureRendererProfile: () => call(IPC.captureRendererProfile),
cancelRendererProfile: () => call(IPC.cancelRendererProfile),
captureRendererProfile: (requestId?: string) => call(IPC.captureRendererProfile, requestId),
cancelRendererProfile: (requestId?: string) => call(IPC.cancelRendererProfile, requestId),
exportHeapSnapshot: () => call(IPC.exportHeapSnapshot),
openExternal: (url: string) => call(IPC.openExternal, url).then(() => undefined),
clipboard: {
@@ -18,12 +18,12 @@ installPerformancePressureMonitor();
assert.equal(starts, 0, "lightweight monitoring never starts a JS profiler");
focused = false;
events.get("blur")!();
assert.equal(cancellations, 1);
assert.equal(cancellations, 0, "no cancellation is sent without an owned capture");
focused = true;
events.get("focus")!();
events.get("focus")!();
assert.equal(starts, 0, "refocusing does not start a profiler");
Object.assign(document, { visibilityState: "hidden" });
events.get("visibilitychange")!();
assert.equal(cancellations, 2);
assert.equal(cancellations, 0);
console.log("profiler lifecycle tests passed");
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { installDesktopHostStub } from "./desktopHostStub";
import { installPerformancePressureMonitor } from "../lib/crash";
import type { RendererProfileResult } from "../lib/processDiagnostics";
const dom = new JSDOM("<!doctype html><body></body>", { url: "http://localhost" });
let now = 0;
let wall = 1_000_000;
const oldDateNow = Date.now;
Date.now = () => wall;
let observe!: (list: { getEntries(): unknown[] }) => void;
const captures: { id?: string; finish(value: RendererProfileResult): void }[] = [];
const cancelled: (string | undefined)[] = [];
Object.defineProperty(dom.window.document, "visibilityState", { value: "visible" });
dom.window.document.hasFocus = () => true;
dom.window.setInterval = () => 1;
Object.assign(globalThis, {
window: dom.window, document: dom.window.document, performance: { now: () => now },
PerformanceObserver: class { constructor(callback: typeof observe) { observe = callback; } observe() {} },
});
installDesktopHostStub({}, { performance: {
captureRendererProfile: (id) => new Promise((finish) => { captures.push({ id, finish }); }),
cancelRendererProfile: async (id) => { cancelled.push(id); },
} });
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
try {
installPerformancePressureMonitor();
now = 20_000;
observe({ getEntries: () => [{ startTime: now, duration: 900, name: "self" }] });
await flush();
const staleDismiss = document.querySelector<HTMLButtonElement>(".performance-report__dismiss")!;
now += 620_000;
wall += 620_000;
observe({ getEntries: () => [{ startTime: now, duration: 900, name: "self" }] });
await flush();
assert.equal(captures.length, 2);
assert.ok(captures[0].id && captures[1].id && captures[0].id !== captures[1].id);
staleDismiss.click();
assert.ok(document.getElementById("performance-report-prompt"), "stale actions cannot close a newer report");
assert.deepEqual(cancelled, []);
document.querySelector<HTMLButtonElement>(".performance-report__dismiss")!.click();
assert.deepEqual(cancelled, [captures[1].id], "dismissal cancels only its own capture");
captures[0].finish({ status: "captured", frames: [] });
captures[1].finish({ status: "cancelled" });
await flush();
assert.equal(document.getElementById("performance-report-prompt"), null);
console.log("performance report cancellation identity tests passed");
} finally {
for (const capture of captures) capture.finish({ status: "cancelled" });
Date.now = oldDateNow;
dom.window.close();
}
@@ -47,7 +47,7 @@ assert.match(copies[0], /render \(app.js:10\)/);
assert.match(reports[0], /CPU profile after trigger: captured/);
assert.equal(heapRequests, 0, "heap capture never happens automatically");
host.querySelector<HTMLButtonElement>(".performance-report__dismiss")!.click();
assert.equal(cancelled, 1);
assert.equal(cancelled, 0, "closing a completed report cannot cancel a later capture");
completeProcesses({ scope: "electron", samples: [] });
await flush();
assert.equal(document.getElementById("performance-report-prompt"), null, "late diagnostics never recreate a dismissed prompt");
+19 -7
View File
@@ -126,6 +126,12 @@ let performanceMonitorInstalled = false;
let lastPerformancePromptAt = 0;
let heapSnapshotInProgress = false;
let diagnosticQuietUntil = 0;
let activeCaptureId: string | undefined;
function cancelCapture(requestId = activeCaptureId): void {
if (!requestId || activeCaptureId !== requestId) return;
void desktopHost().native.cancelRendererProfile?.(requestId).catch(() => {});
}
const PERF_REPORTED_STORAGE_KEY = "reasonix:perf-reported";
@@ -547,7 +553,7 @@ function copyButton(text: string | (() => string), className: string): HTMLButto
}
let performancePromptGeneration = 0;
function paintPerformancePrompt(payload: CrashPayload, snapshot: PerformanceSnapshot) {
function paintPerformancePrompt(payload: CrashPayload, snapshot: PerformanceSnapshot, captureId?: string) {
if (typeof document === "undefined") return;
const generation = ++performancePromptGeneration;
let currentPayload = payload;
@@ -571,9 +577,10 @@ function paintPerformancePrompt(payload: CrashPayload, snapshot: PerformanceSnap
dismiss.className = "performance-report__dismiss";
dismiss.textContent = t("performanceReport.dismiss");
dismiss.onclick = () => {
if (generation !== performancePromptGeneration) return;
dismissedPerfLabels.add(payload.label);
performancePromptGeneration++;
void desktopHost().native.cancelRendererProfile?.().catch(() => {});
if (captureId) cancelCapture(captureId);
host?.remove();
};
if (send) actions.append(send);
@@ -584,6 +591,7 @@ function paintPerformancePrompt(payload: CrashPayload, snapshot: PerformanceSnap
heap.className = "performance-report__copy";
heap.textContent = t("performanceReport.saveHeap");
heap.onclick = async () => {
if (generation !== performancePromptGeneration || !host?.isConnected) return;
heap.disabled = true;
heapSnapshotInProgress = true;
try {
@@ -733,19 +741,23 @@ function promptPerformanceReport(reason: string, currentLagMs = 0, processes?: P
snapshot.processes = processes;
const native = desktopHost().native;
const capture = label === "performance.longtask" || label === "performance.lag";
const requestId = capture && native.captureRendererProfile
? globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` : undefined;
if (requestId) activeCaptureId = requestId;
if (capture) snapshot.cpuProfile = { status: native.captureRendererProfile ? "recording" : "unavailable" };
// Show the original evidence immediately. Slow diagnostics only enrich this
// same prompt; they cannot recreate a dismissed/replaced report.
const update = paintPerformancePrompt(buildPerformancePayload(snapshot), snapshot);
const update = paintPerformancePrompt(buildPerformancePayload(snapshot), snapshot, requestId);
if (!processes && native.processDiagnostics) {
void boundedDiagnostics(() => native.processDiagnostics!()).then((sample) => {
if (sample) { snapshot.processes = sample; update?.(); }
});
}
if (capture && native.captureRendererProfile) {
void boundedDiagnostics(() => native.captureRendererProfile!(), 12_000).then((result) => {
if (requestId && native.captureRendererProfile) {
void boundedDiagnostics(() => native.captureRendererProfile!(requestId), 12_000).then((result) => {
snapshot.cpuProfile = result ?? { status: "failed" };
if (!result) void native.cancelRendererProfile?.().catch(() => {});
if (!result) cancelCapture(requestId);
if (activeCaptureId === requestId) activeCaptureId = undefined;
update?.();
});
}
@@ -799,7 +811,7 @@ export function installPerformancePressureMonitor() {
visibleSince = isHidden() ? Number.POSITIVE_INFINITY : now;
focusedSince = isFocused() ? now : Number.POSITIVE_INFINITY;
pendingResume = isHidden() || !isFocused();
if (pendingResume) void desktopHost().native.cancelRendererProfile?.().catch(() => {});
if (pendingResume) cancelCapture();
};
if (typeof document !== "undefined") {
+2 -2
View File
@@ -217,8 +217,8 @@ const electronHostFrom = (host: ReasonixDesktopHost): DesktopHost => {
resetAppZoom: () => host.native.window.resetAppZoom(),
graphics: host.native.graphics,
...(host.native.processDiagnostics ? { processDiagnostics: () => host.native.processDiagnostics!() } : {}),
...(host.native.captureRendererProfile ? { captureRendererProfile: () => host.native.captureRendererProfile!() } : {}),
...(host.native.cancelRendererProfile ? { cancelRendererProfile: () => host.native.cancelRendererProfile!() } : {}),
...(host.native.captureRendererProfile ? { captureRendererProfile: (id?: string) => host.native.captureRendererProfile!(id) } : {}),
...(host.native.cancelRendererProfile ? { cancelRendererProfile: (id?: string) => host.native.cancelRendererProfile!(id) } : {}),
...(host.native.exportHeapSnapshot ? { exportHeapSnapshot: () => host.native.exportHeapSnapshot!() } : {}),
browserControl: host.native.browserControl,
onFilesDropped: (cb) => {
@@ -15,8 +15,8 @@ export interface RendererProfileResult {
frames?: { label: string; samples: number; selfMs: number }[];
}
export interface NativePerformanceActions {
captureRendererProfile?(): Promise<RendererProfileResult>;
cancelRendererProfile?(): Promise<unknown>;
captureRendererProfile?(requestId?: string): Promise<RendererProfileResult>;
cancelRendererProfile?(requestId?: string): Promise<unknown>;
exportHeapSnapshot?(): Promise<{ status: "saved" | "cancelled" | "busy" | "unavailable" | "failed" }>;
}
+8 -2
View File
@@ -308,14 +308,16 @@ are required.
and once per 60 seconds otherwise. Retention is at most 12 snapshots and five
minutes, with at most 128 processes per snapshot. No titles, URLs or process
names are collected. Electron-managed processes only; Go is excluded.
- `captureRendererProfile()` records the current renderer through CDP for
- `captureRendererProfile(requestId?)` records the current renderer through CDP for
five seconds at a requested 10 ms sample interval. It returns a status,
duration and at most eight app-script self-time summaries. Normal documents
do not enable JS self-profiling. Capture is single-flight, requires the
foreground window, observes a ten-minute cooldown, and allows at most three
attempts per shell lifetime. Existing debugger/DevTools sessions are not
taken over. Blur, hide, navigation, renderer loss or cancellation stops it.
- `cancelRendererProfile()` cancels the owned capture. Each CDP command has a
- `cancelRendererProfile(requestId)` cancels only the matching capture; unscoped
renderer cancellation is ignored. This also fences delayed requests across
long suspension/resume gaps. Each CDP command has a
1.5 second deadline and the owned debugger is released on every terminal path.
Analysis runs in a disposable Worker with a 32 MiB old-generation limit,
1.5 second deadline, and input limits of 20,000 nodes / 100,000 samples.
@@ -344,6 +346,10 @@ From `desktop/electron`, run `node scripts/performance-smoke.mjs` to verify the
production owner, Worker, report enrichment and local heap snapshot with an
isolated native fixture. `node scripts/performance-benchmark.mjs` compares off,
lightweight monitoring and short capture in three fresh-process trials each.
All modes use the same renderer bundle and runtime mode selection. Activity
signals are pinned and background throttling disabled for unattended native
measurement. Host event tests separately cover the production focus and
navigation cancellation policy; the smoke verifies actual CDP and ASAR paths.
It records CPU time where available, frame timings, working sets and metric
collection cost in `artifacts/performance/overhead.json`. This synthetic
benchmark is not a reproduction of the Windows user workload. Field comparison
+6 -2
View File
@@ -276,12 +276,13 @@ warning: "invalid-config" | "unreadable-config" | "unsupported-version" | null }
工作集及私有内存(MiB)、截断标记。前台最多每 30 秒采集一次,后台每 60 秒一次;
最多保留五分钟内的 12 条记录,每条最多 128 个进程。不采集标题、URL 或进程名称。
范围仅含 Electron 管理的进程,不含 Go 服务。
- `captureRendererProfile()` 通过 CDP 录制当前 renderer 五秒,
- `captureRendererProfile(requestId?)` 通过 CDP 录制当前 renderer 五秒,
请求的采样间隔为 10ms,返回状态、时长和最多八个应用脚本的自身耗时摘要。
普通页面不启用 JS Self-Profiling。最多一个进行中的采样,要求窗口在前台,
冷却十分钟,每次启动 shell 最多尝试三次。不接管已有 debugger/DevTools。
失焦、隐藏、导航、renderer 退出或取消会停止采样。
- `cancelRendererProfile()` 取消当前拥有的采样。每条 CDP 命令最多等待 1.5 秒,
- `cancelRendererProfile(requestId)` 仅取消身份匹配的采样;忽略 renderer 不带身份的取消请求,
防止长时间挂起后迟到的旧请求干扰新采样。每条 CDP 命令最多等待 1.5 秒,
所有终态均释放自己的 debugger。分析在临时 Worker 中运行,老生代限制 32 MiB,
超时 1.5 秒,输入最多 20,000 个节点及 100,000 个样本。
原始 profile 不进入 UI 报告。
@@ -304,5 +305,8 @@ warning: "invalid-config" | "unreadable-config" | "unsupported-version" | null }
可用隔离原生测试验证采样 owner、Worker、报告更新及本地堆快照。
`node scripts/performance-benchmark.mjs` 分别以关闭监测、基础监测、短时采样运行
三次独立进程对照,记录可用的 CPU 时间、帧时序、工作集及指标采集耗时,
各模式使用同一 renderer bundle,通过运行时开关选择;固定活动信号并关闭后台节流,
用于无人值守比较成本。宿主事件测试独立覆盖生产焦点和导航取消策略,
原生 smoke 验证实际 CDP 与 ASAR 路径。
输出至 `artifacts/performance/overhead.json`。该合成测试不等同于 Windows 用户场景复现;
仍需对比刚启动、长时间使用、切回窗口和关闭标签等阶段。