diff --git a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts index 96cdf66125c..97a0519a493 100644 --- a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts +++ b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts @@ -120,6 +120,8 @@ export class ExtensionHostStarter extends Disposable implements IDisposable, IEx execArgv: opts.execArgv, allowLoadingUnsignedLibraries: true, respondToAuthRequestsFromMainProcess: true, + windowLifecycleBound: true, + windowLifecycleGraceTime: 6000, correlationId: id }); const pid = await Event.toPromise(extHost.onSpawn); diff --git a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts index 05d83649c01..fccddba0156 100644 --- a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts +++ b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts @@ -99,6 +99,14 @@ export interface IWindowUtilityProcessConfiguration extends IUtilityProcessConfi * when the associated browser window closes or reloads. */ readonly windowLifecycleBound?: boolean; + + /** + * Optional period in milliseconds to allow for graceful shutdown + * before forcefully killing the process when the window lifecycle ends. + * If not set or 0, the process will be killed immediately. + * This is useful for extension hosts that need time to deactivate extensions. + */ + readonly windowLifecycleGraceTime?: number; } function isWindowUtilityProcessConfiguration(config: IUtilityProcessConfiguration): config is IWindowUtilityProcessConfiguration { @@ -488,11 +496,17 @@ export class WindowUtilityProcess extends UtilityProcess { private registerWindowListeners(window: BrowserWindow, configuration: IWindowUtilityProcessConfiguration): void { // If the lifecycle of the utility process is bound to the window, - // we kill the process if the window closes or changes + // we terminate the process if the window closes or changes. + // If a grace period is configured, we wait for the process to exit + // before terminating (e.g. extensions need time to deactivate). if (configuration.windowLifecycleBound) { - this._register(Event.filter(this.lifecycleMainService.onWillLoadWindow, e => e.window.win === window)(() => this.kill())); - this._register(Event.fromNodeEventEmitter(window, 'closed')(() => this.kill())); + const graceTime = configuration.windowLifecycleGraceTime; + const terminate = graceTime && graceTime > 0 + ? () => this.waitForExit(graceTime) + : () => this.kill(); + this._register(Event.filter(this.lifecycleMainService.onWillLoadWindow, e => e.window.win === window)(terminate)); + this._register(Event.fromNodeEventEmitter(window, 'closed')(terminate)); } } } diff --git a/test/smoke/extensions/vscode-smoketest-ext-host/extension.js b/test/smoke/extensions/vscode-smoketest-ext-host/extension.js new file mode 100644 index 00000000000..e69899a6b29 --- /dev/null +++ b/test/smoke/extensions/vscode-smoketest-ext-host/extension.js @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check +const vscode = require('vscode'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +/** @type {string | undefined} */ +let deactivateMarkerFile; + +/** + * @param {vscode.ExtensionContext} context + */ +function activate(context) { + // This is used to verify that the extension host process is properly killed + // when window reloads even if the extension host is blocked + // Refs: https://github.com/microsoft/vscode/issues/291346 + context.subscriptions.push( + vscode.commands.registerCommand('smoketest.getExtensionHostPidAndBlock', (delayMs = 100, durationMs = 60000) => { + const pid = process.pid; + + // Write PID file to workspace folder if available, otherwise temp dir + // Note: filename must match name in extension-host-restart.test.ts + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const pidFile = workspaceFolder + ? path.join(workspaceFolder, 'vscode-ext-host-pid.txt') + : path.join(os.tmpdir(), 'vscode-ext-host-pid.txt'); + setTimeout(() => { + fs.writeFileSync(pidFile, String(pid), 'utf-8'); + + // Block the extension host without busy-spinning to avoid pegging a CPU core. + // Prefer Atomics.wait on a SharedArrayBuffer when available; otherwise, fall back + // to the original busy loop to preserve behavior in older environments. + if (typeof SharedArrayBuffer === 'function' && typeof Atomics !== 'undefined' && typeof Atomics.wait === 'function') { + const sab = new SharedArrayBuffer(4); + const blocker = new Int32Array(sab); + // Wait up to durationMs milliseconds. This blocks the thread without consuming CPU. + Atomics.wait(blocker, 0, 0, durationMs); + } else { + const start = Date.now(); + while (Date.now() - start < durationMs) { + // Busy loop (fallback) + } + } + }, delayMs); + + return pid; + }) + ); + + // This command sets up a marker file path that will be written during deactivation. + // It allows the smoke test to verify that extensions get a chance to deactivate. + context.subscriptions.push( + vscode.commands.registerCommand('smoketest.setupGracefulDeactivation', () => { + const pid = process.pid; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const pidFile = workspaceFolder + ? path.join(workspaceFolder, 'vscode-ext-host-pid-graceful.txt') + : path.join(os.tmpdir(), 'vscode-ext-host-pid-graceful.txt'); + deactivateMarkerFile = workspaceFolder + ? path.join(workspaceFolder, 'vscode-ext-host-deactivated.txt') + : path.join(os.tmpdir(), 'vscode-ext-host-deactivated.txt'); + + // Write PID file immediately so test knows the extension is ready + fs.writeFileSync(pidFile, String(pid), 'utf-8'); + + return { pid, markerFile: deactivateMarkerFile }; + }) + ); +} + +function deactivate() { + // Write marker file to indicate deactivation was called + if (deactivateMarkerFile) { + try { + fs.writeFileSync(deactivateMarkerFile, `deactivated at ${Date.now()}`, 'utf-8'); + } catch { + // Ignore errors (e.g., folder not accessible) + } + } +} + +module.exports = { + activate, + deactivate +}; diff --git a/test/smoke/extensions/vscode-smoketest-ext-host/package.json b/test/smoke/extensions/vscode-smoketest-ext-host/package.json new file mode 100644 index 00000000000..4b517b57d52 --- /dev/null +++ b/test/smoke/extensions/vscode-smoketest-ext-host/package.json @@ -0,0 +1,29 @@ +{ + "name": "vscode-smoketest-ext-host", + "displayName": "Smoke Test Extension Host", + "description": "Extension for smoke testing extension host lifecycle", + "version": "0.0.1", + "publisher": "vscode", + "license": "MIT", + "private": true, + "engines": { + "vscode": "^1.55.0" + }, + "activationEvents": [ + "onStartupFinished" + ], + "main": "./extension.js", + "extensionKind": ["ui"], + "contributes": { + "commands": [ + { + "command": "smoketest.getExtensionHostPidAndBlock", + "title": "Smoke Test: Get Extension Host PID and Block" + }, + { + "command": "smoketest.setupGracefulDeactivation", + "title": "Smoke Test: Setup Graceful Deactivation" + } + ] + } +} diff --git a/test/smoke/src/areas/extensions/extension-host-restart.test.ts b/test/smoke/src/areas/extensions/extension-host-restart.test.ts new file mode 100644 index 00000000000..2fe6750aff5 --- /dev/null +++ b/test/smoke/src/areas/extensions/extension-host-restart.test.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Application, Logger } from '../../../../automation'; +import { installAllHandlers, timeout } from '../../utils'; + +/** + * Verifies that window reload kills the extension host even when blocked. + * + */ +export function setup(logger: Logger) { + describe('Extension Host Restart', () => { + + installAllHandlers(logger, opts => opts); + + function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + it('kills blocked extension host on window reload (windowLifecycleBound)', async function () { + this.timeout(60_000); + + const app = this.app as Application; + const pidFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-pid.txt'); + + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + + await app.workbench.quickaccess.runCommand('smoketest.getExtensionHostPidAndBlock'); + + // Wait for PID file to be created + let retries = 0; + while (!fs.existsSync(pidFile) && retries < 20) { + await timeout(500); + retries++; + } + + if (!fs.existsSync(pidFile)) { + throw new Error('PID file was not created - extension may not have activated'); + } + + const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10); + logger.log(`Old extension host PID: ${pid}`); + + // Reload window while extension host is blocked + await app.workbench.quickaccess.runCommand('Developer: Reload Window'); + await app.code.whenWorkbenchRestored(); + logger.log('Window reloaded'); + + // Verify old process is gone, allowing for slower teardown on busy machines + const maxWaitMs = 10_000; + const pollIntervalMs = 500; + let waitedMs = 0; + while (processExists(pid) && waitedMs < maxWaitMs) { + await timeout(pollIntervalMs); + waitedMs += pollIntervalMs; + } + + const stillExists = processExists(pid); + if (stillExists) { + throw new Error(`Extension host ${pid} still running after reload (waited ${maxWaitMs}ms)`); + } + + logger.log('Extension host was properly killed on reload'); + }); + + it('allows extensions to gracefully deactivate on window reload (windowLifecycleGraceTime)', async function () { + this.timeout(60_000); + + const app = this.app as Application; + const pidFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-pid-graceful.txt'); + const markerFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-deactivated.txt'); + + // Clean up any existing files + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + if (fs.existsSync(markerFile)) { + fs.unlinkSync(markerFile); + } + + // Setup the extension to write a marker file on deactivation + await app.workbench.quickaccess.runCommand('smoketest.setupGracefulDeactivation'); + + // Wait for PID file to be created (confirms extension is ready) + let retries = 0; + while (!fs.existsSync(pidFile) && retries < 20) { + await timeout(500); + retries++; + } + + if (!fs.existsSync(pidFile)) { + throw new Error('PID file was not created - extension may not have activated'); + } + + const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10); + logger.log(`Extension host PID for graceful deactivation test: ${pid}`); + + // Reload window - this should trigger graceful deactivation + await app.workbench.quickaccess.runCommand('Developer: Reload Window'); + await app.code.whenWorkbenchRestored(); + logger.log('Window reloaded'); + + // Wait for the process to exit and marker file to be written + const maxWaitMs = 10_000; + const pollIntervalMs = 500; + let waitedMs = 0; + while (!fs.existsSync(markerFile) && waitedMs < maxWaitMs) { + await timeout(pollIntervalMs); + waitedMs += pollIntervalMs; + } + + if (!fs.existsSync(markerFile)) { + throw new Error(`Deactivation marker file was not created within ${maxWaitMs}ms - extension may not have been given time to deactivate gracefully`); + } + + logger.log('Extension was given time to gracefully deactivate on reload'); + + // Also verify the old process is gone + waitedMs = 0; + while (processExists(pid) && waitedMs < maxWaitMs) { + await timeout(pollIntervalMs); + waitedMs += pollIntervalMs; + } + + if (processExists(pid)) { + throw new Error(`Extension host ${pid} still running after reload (waited ${maxWaitMs}ms)`); + } + + logger.log('Extension host was properly terminated after graceful deactivation'); + }); + }); +} diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index fc8b4f8800f..c3e39215de6 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -21,6 +21,7 @@ import { setup as setupNotebookTests } from './areas/notebook/notebook.test'; import { setup as setupLanguagesTests } from './areas/languages/languages.test'; import { setup as setupStatusbarTests } from './areas/statusbar/statusbar.test'; import { setup as setupExtensionTests } from './areas/extensions/extensions.test'; +import { setup as setupExtensionHostRestartTests } from './areas/extensions/extension-host-restart.test'; import { setup as setupMultirootTests } from './areas/multiroot/multiroot.test'; import { setup as setupLocalizationTests } from './areas/workbench/localization.test'; import { setup as setupLaunchTests } from './areas/workbench/launch.test'; @@ -351,6 +352,16 @@ async function setup(): Promise { } await measureAndLog(() => setupRepository(), 'setupRepository', logger); + // Copy smoke test extension for extension host restart test + if (!opts.web) { + const smokeExtPath = path.join(rootPath, 'test', 'smoke', 'extensions', 'vscode-smoketest-ext-host'); + const dest = path.join(extensionsPath, 'vscode-smoketest-ext-host'); + if (fs.existsSync(dest)) { + fs.rmSync(dest, { recursive: true, force: true }); + } + fs.cpSync(smokeExtPath, dest, { recursive: true }); + } + logger.log('Smoketest setup done!\n'); } @@ -403,6 +414,7 @@ describe(`VSCode Smoke Tests (${opts.web ? 'Web' : 'Electron'})`, () => { setupTaskTests(logger); setupStatusbarTests(logger); if (quality !== Quality.Dev && quality !== Quality.OSS) { setupExtensionTests(logger); } + if (!opts.web) { setupExtensionHostRestartTests(logger); } setupMultirootTests(logger); if (!opts.web && !opts.remote && quality !== Quality.Dev && quality !== Quality.OSS) { setupLocalizationTests(logger); } if (!opts.web && !opts.remote) { setupLaunchTests(logger); }