Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/vs/base/common/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/vs/platform/environment/common/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/vs/platform/environment/node/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
14 changes: 12 additions & 2 deletions src/vs/platform/protocol/electron-main/protocolMainService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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 });
Expand Down
7 changes: 6 additions & 1 deletion src/vs/platform/window/electron-main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
97 changes: 96 additions & 1 deletion src/vs/platform/windows/electron-main/windowImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this be zero, and then cause div-by-zero below?

> parseInt('1000') / parseInt('15000')
0.06666666666666667
> Math.round(parseInt('1000') / parseInt('15000'))
0

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@deepak1556 can you check?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, good catch. It should have been the other way.

this.jsCallStackEffectiveSampleCount = Math.round(samplePeriod / sampleInterval);

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();

Expand Down Expand Up @@ -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 })));

Expand Down Expand Up @@ -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> {
Expand All @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -847,6 +886,9 @@ export class CodeWindow extends BaseWindow implements ICodeWindow {
await this.destroyWindow(reopen, checkboxChecked);
}
break;
case WindowError.RESPONSIVE:
this.stopCollectingJScallStacks();
break;
}
}

Expand Down Expand Up @@ -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) {
Copy link
Collaborator Author

@deepak1556 deepak1556 Mar 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken inspiration from renderer auto profiler but unlike the auto profiler we don't have individual frame timings, given the samples are sorted by their effective appearance over the sample period, I feel this should cover the interesting bits to error telemetry as UnresponsiveSampleError

Adding the window id and process id to the error message so that we can aggregate them. Let me know if there are better markers to add.

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;
}
Expand All @@ -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;
}
}
Loading