chore: log js stacks from unresponsive window (#241390)

* chore: log js stacks from unresponsive window

* chore: address review feedback

* chore: remove setting

* chore: remove unused setter

* chore: add link to tracing cpu profile

* chore: add cli support to control sample period

* 💄

* chore: upload interesting samples to error telemetry

---------

Co-authored-by: Benjamin Pasero <benjamin.pasero@microsoft.com>
This commit is contained in:
Robo 2025-03-24 22:25:44 +09:00 committed by GitHub
parent f82be6d0f1
commit 395bddec8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 129 additions and 7 deletions

View File

@ -311,6 +311,12 @@ function configureCommandlineSwitchesSync(cliArgs: NativeParsedArgs) {
}
});
// Following features are enabled from the runtime:
// `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental
const featuresToEnable =
`DocumentPolicyIncludeJSCallStacksInCrashReports, ${app.commandLine.getSwitchValue('enable-features')}`;
app.commandLine.appendSwitch('enable-features', featuresToEnable);
// Following features are disabled from the runtime:
// `CalculateNativeWinOcclusion` - Disable native window occlusion tracker (https://groups.google.com/a/chromium.org/g/embedder-dev/c/ZF3uHHyWLKw/m/VDN2hDXMAAAJ)
// `PlzDedicatedWorker` - Refs https://github.com/microsoft/vscode/issues/233060#issuecomment-2523212427

View File

@ -358,7 +358,11 @@ class FileAccessImpl {
export const FileAccess = new FileAccessImpl();
export const CacheControlheaders: Record<string, string> = Object.freeze({
'Cache-Control': 'no-cache, no-store',
'Cache-Control': 'no-cache, no-store'
});
export const DocumentPolicyheaders: Record<string, string> = Object.freeze({
'Document-Policy': 'include-js-call-stacks-in-crash-reports'
});
export namespace COI {

View File

@ -121,8 +121,9 @@ export interface NativeParsedArgs {
'profile-temp'?: boolean;
'disable-chromium-sandbox'?: boolean;
sandbox?: boolean;
'enable-coi'?: boolean;
'unresponsive-sample-interval'?: string;
'unresponsive-sample-period'?: string;
// chromium command line args: https://electronjs.org/docs/all#supported-chrome-command-line-switches
'no-proxy-server'?: boolean;

View File

@ -182,8 +182,9 @@ export const OPTIONS: OptionDescriptions<Required<NativeParsedArgs>> = {
'__enable-file-policy': { type: 'boolean' },
'editSessionId': { type: 'string' },
'continueOn': { type: 'string' },
'enable-coi': { type: 'boolean' },
'unresponsive-sample-interval': { type: 'string' },
'unresponsive-sample-period': { type: 'string' },
// chromium flags
'no-proxy-server': { type: 'boolean' },

View File

@ -5,7 +5,7 @@
import { session } from 'electron';
import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { COI, FileAccess, Schemas, CacheControlheaders } from '../../../base/common/network.js';
import { COI, FileAccess, Schemas, CacheControlheaders, DocumentPolicyheaders } from '../../../base/common/network.js';
import { basename, extname, normalize } from '../../../base/common/path.js';
import { isLinux } from '../../../base/common/platform.js';
import { TernarySearchTree } from '../../../base/common/ternarySearchTree.js';
@ -93,10 +93,10 @@ export class ProtocolMainService extends Disposable implements IProtocolMainServ
private handleResourceRequest(request: Electron.ProtocolRequest, callback: ProtocolCallback): void {
const path = this.requestToNormalizedFilePath(request);
const pathBasename = basename(path);
let headers: Record<string, string> | undefined;
if (this.environmentService.crossOriginIsolated) {
const pathBasename = basename(path);
if (pathBasename === 'workbench.html' || pathBasename === 'workbench-dev.html') {
headers = COI.CoopAndCoep;
} else {
@ -113,6 +113,16 @@ export class ProtocolMainService extends Disposable implements IProtocolMainServ
};
}
// Document-policy header is needed for collecting
// JavaScript callstacks via https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental
// until https://github.com/electron/electron/issues/45356 is resolved.
if (pathBasename === 'workbench.html' || pathBasename === 'workbench-dev.html') {
headers = {
...headers,
...DocumentPolicyheaders
};
}
// first check by validRoots
if (this.validRoots.findSubstr(path)) {
return callback({ path, headers });

View File

@ -196,5 +196,10 @@ export const enum WindowError {
/**
* Maps to the `did-fail-load` event on a `WebContents`.
*/
LOAD = 3
LOAD = 3,
/**
* Maps to the `responsive` event on a `BrowserWindow`.
*/
RESPONSIVE = 4,
}

View File

@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import electron, { BrowserWindowConstructorOptions } from 'electron';
import { DeferredPromise, RunOnceScheduler, timeout } from '../../../base/common/async.js';
import { DeferredPromise, RunOnceScheduler, timeout, Delayer } from '../../../base/common/async.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { toErrorMessage } from '../../../base/common/errorMessage.js';
import { Emitter, Event } from '../../../base/common/event.js';
@ -44,6 +44,7 @@ import { IUserDataProfilesMainService } from '../../userDataProfile/electron-mai
import { ILoggerMainService } from '../../log/electron-main/loggerService.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { VSBuffer } from '../../../base/common/buffer.js';
import { errorHandler } from '../../../base/common/errors.js';
export interface IWindowCreationOptions {
readonly state: IWindowState;
@ -540,6 +541,11 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
private pendingLoadConfig: INativeWindowConfiguration | undefined;
private wasLoaded = false;
private readonly jsCallStackMap: Map<string, number>;
private readonly jsCallStackEffectiveSampleCount: number;
private readonly jsCallStackCollector: Delayer<void>;
private readonly jsCallStackCollectorStopScheduler: RunOnceScheduler;
constructor(
config: IWindowCreationOptions,
@ILogService logService: ILogService,
@ -595,6 +601,25 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
}
//#endregion
//#region JS Callstack Collector
let sampleInterval = parseInt(this.environmentMainService.args['unresponsive-sample-interval'] || '1000');
let samplePeriod = parseInt(this.environmentMainService.args['unresponsive-sample-period'] || '15000');
if (sampleInterval <= 0 || samplePeriod <= 0 || sampleInterval > samplePeriod) {
this.logService.warn(`Invalid unresponsive sample interval (${sampleInterval}ms) or period (${samplePeriod}ms), using defaults.`);
sampleInterval = 1000;
samplePeriod = 15000;
}
this.jsCallStackMap = new Map<string, number>();
this.jsCallStackEffectiveSampleCount = Math.round(sampleInterval / samplePeriod);
this.jsCallStackCollector = this._register(new Delayer<void>(sampleInterval));
this.jsCallStackCollectorStopScheduler = this._register(new RunOnceScheduler(() => {
this.stopCollectingJScallStacks(); // Stop collecting after 15s max
}, samplePeriod));
//#endregion
// respect configured menu bar visibility
this.onConfigurationUpdated();
@ -655,6 +680,7 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
// Window error conditions to handle
this._register(Event.fromNodeEventEmitter(this._win, 'unresponsive')(() => this.onWindowError(WindowError.UNRESPONSIVE)));
this._register(Event.fromNodeEventEmitter(this._win, 'responsive')(() => this.onWindowError(WindowError.RESPONSIVE)));
this._register(Event.fromNodeEventEmitter(this._win.webContents, 'render-process-gone', (event, details) => details)(details => this.onWindowError(WindowError.PROCESS_GONE, { ...details })));
this._register(Event.fromNodeEventEmitter(this._win.webContents, 'did-fail-load', (event, exitCode, reason) => ({ exitCode, reason }))(({ exitCode, reason }) => this.onWindowError(WindowError.LOAD, { reason, exitCode })));
@ -730,6 +756,7 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
}
private async onWindowError(error: WindowError.UNRESPONSIVE): Promise<void>;
private async onWindowError(error: WindowError.RESPONSIVE): Promise<void>;
private async onWindowError(error: WindowError.PROCESS_GONE, details: { reason: string; exitCode: number }): Promise<void>;
private async onWindowError(error: WindowError.LOAD, details: { reason: string; exitCode: number }): Promise<void>;
private async onWindowError(type: WindowError, details?: { reason?: string; exitCode?: number }): Promise<void> {
@ -741,6 +768,9 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
case WindowError.UNRESPONSIVE:
this.logService.error('CodeWindow: detected unresponsive');
break;
case WindowError.RESPONSIVE:
this.logService.error('CodeWindow: recovered from unresponsive');
break;
case WindowError.LOAD:
this.logService.error(`CodeWindow: failed to load (reason: ${details?.reason || '<unknown>'}, code: ${details?.exitCode || '<unknown>'})`);
break;
@ -799,6 +829,14 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
return;
}
// Interrupt V8 and collect JavaScript stack
this.jsCallStackCollector.trigger(() => this.startCollectingJScallStacks());
// Stack collection will stop under any of the following conditions:
// - The window becomes responsive again
// - The window is destroyed i-e reopen or closed
// - sampling period is complete, default is 15s
this.jsCallStackCollectorStopScheduler.schedule();
// Show Dialog
const { response, checkboxChecked } = await this.dialogMainService.showMessageBox({
type: 'warning',
@ -815,6 +853,7 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
// Handle choice
if (response !== 2 /* keep waiting */) {
const reopen = response === 0;
this.stopCollectingJScallStacks();
await this.destroyWindow(reopen, checkboxChecked);
}
}
@ -847,6 +886,9 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
await this.destroyWindow(reopen, checkboxChecked);
}
break;
case WindowError.RESPONSIVE:
this.stopCollectingJScallStacks();
break;
}
}
@ -1449,6 +1491,50 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
return segments;
}
private async startCollectingJScallStacks(): Promise<void> {
if (!this.jsCallStackCollector.isTriggered()) {
const stack = await this._win.webContents.mainFrame.collectJavaScriptCallStack();
// Increment the count for this stack trace
if (stack) {
const count = this.jsCallStackMap.get(stack) || 0;
this.jsCallStackMap.set(stack, count + 1);
}
this.jsCallStackCollector.trigger(() => this.startCollectingJScallStacks());
}
}
private stopCollectingJScallStacks(): void {
this.jsCallStackCollectorStopScheduler.cancel();
this.jsCallStackCollector.cancel();
if (this.jsCallStackMap.size) {
let logMessage = `CodeWindow unresponsive samples:\n`;
let samples = 0;
const sortedEntries = Array.from(this.jsCallStackMap.entries())
.sort((a, b) => b[1] - a[1]);
for (const [stack, count] of sortedEntries) {
samples += count;
// If the stack appears more than 20 percent of the time, log it
// to the error telemetry as UnresponsiveSampleError.
if (Math.round((count * 100) / this.jsCallStackEffectiveSampleCount) > 20) {
const fakeError = new UnresponsiveError(stack, this.id, this.win?.webContents.getOSProcessId());
errorHandler.onUnexpectedError(fakeError);
}
logMessage += `<${count}> ${stack}\n`;
}
logMessage += `Total Samples: ${samples}\n`;
logMessage += 'For full overview of the unresponsive period, capture cpu profile via https://aka.ms/vscode-tracing-cpu-profile';
this.logService.error(logMessage);
}
this.jsCallStackMap.clear();
}
matches(webContents: electron.WebContents): boolean {
return this._win?.webContents.id === webContents.id;
}
@ -1460,3 +1546,12 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
this.loggerMainService.deregisterLoggers(this.id);
}
}
class UnresponsiveError extends Error {
constructor(sample: string, windowId: number, pid: number = 0) {
super(`UnresponsiveSampleError: by ${windowId} from ${pid}`);
this.name = 'UnresponsiveSampleError';
this.stack = sample;
}
}