mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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") });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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" }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 用户场景复现;
|
||||
仍需对比刚启动、长时间使用、切回窗口和关闭标签等阶段。
|
||||
Reference in new issue
Block a user