diff --git a/src/main.ts b/src/main.ts index fdc424e10875e..28fc2af863375 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index f476d8b140952..193f687da2443 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -358,7 +358,11 @@ class FileAccessImpl { export const FileAccess = new FileAccessImpl(); export const CacheControlheaders: Record = Object.freeze({ - 'Cache-Control': 'no-cache, no-store', + 'Cache-Control': 'no-cache, no-store' +}); + +export const DocumentPolicyheaders: Record = Object.freeze({ + 'Document-Policy': 'include-js-call-stacks-in-crash-reports' }); export namespace COI { diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 1efc1b5de457b..e4522f6492eb7 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -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; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 0025e4b597394..a205483c48cf3 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -182,8 +182,9 @@ export const OPTIONS: OptionDescriptions> = { '__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' }, diff --git a/src/vs/platform/protocol/electron-main/protocolMainService.ts b/src/vs/platform/protocol/electron-main/protocolMainService.ts index 2f0c61b8a81db..f5c2e3a9dd709 100644 --- a/src/vs/platform/protocol/electron-main/protocolMainService.ts +++ b/src/vs/platform/protocol/electron-main/protocolMainService.ts @@ -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 | 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 }); diff --git a/src/vs/platform/window/electron-main/window.ts b/src/vs/platform/window/electron-main/window.ts index af926cf5b513d..11c6040803637 100644 --- a/src/vs/platform/window/electron-main/window.ts +++ b/src/vs/platform/window/electron-main/window.ts @@ -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, } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 8e80df8051970..1aa19e7d76a22 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -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; + private readonly jsCallStackEffectiveSampleCount: number; + private readonly jsCallStackCollector: Delayer; + 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(); + this.jsCallStackEffectiveSampleCount = Math.round(sampleInterval / samplePeriod); + this.jsCallStackCollector = this._register(new Delayer(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; + private async onWindowError(error: WindowError.RESPONSIVE): Promise; private async onWindowError(error: WindowError.PROCESS_GONE, details: { reason: string; exitCode: number }): Promise; private async onWindowError(error: WindowError.LOAD, details: { reason: string; exitCode: number }): Promise; private async onWindowError(type: WindowError, details?: { reason?: string; exitCode?: number }): Promise { @@ -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 || ''}, code: ${details?.exitCode || ''})`); 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 { + 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; + } +}