diff --git a/src/vs/base/parts/sandbox/electron-main/electronTypes.ts b/src/vs/base/parts/sandbox/electron-main/electronTypes.ts new file mode 100644 index 0000000000000..5514d13345513 --- /dev/null +++ b/src/vs/base/parts/sandbox/electron-main/electronTypes.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// TODO@bpasero remove me once we are on Electron 22 + +import type { EventEmitter } from 'events'; +import * as electron from 'electron'; + +export declare namespace UtilityProcessProposedApi { + interface ForkOptions { + /** + * Environment key-value pairs. Default is `process.env`. + */ + env?: NodeJS.ProcessEnv; + /** + * List of string arguments passed to the executable. + */ + execArgv?: string[]; + /** + * Current working directory of the child process. + */ + cwd?: string; + /** + * Allows configuring the mode for `stdout` and `stderr` of the child process. + * Default is `inherit`. String value can be one of `pipe`, `ignore`, `inherit`, + * for more details on these values you can refer to stdio documentation from + * Node.js. Currently this option only supports configuring `stdout` and `stderr` + * to either `pipe`, `inherit` or `ignore`. Configuring `stdin` is not supported; + * `stdin` will always be ignored. For example, the supported values will be + * processed as following: + */ + stdio?: (Array<'pipe' | 'ignore' | 'inherit'>) | (string); + /** + * Name of the process that will appear in `name` property of `child-process-gone` + * event of `app`. Default is `node.mojom.NodeService`. + */ + serviceName?: string; + /** + * With this flag, the utility process will be launched via the `Electron Helper + * (Plugin).app` helper executable on macOS, which can be codesigned with + * `com.apple.security.cs.disable-library-validation` and + * `com.apple.security.cs.allow-unsigned-executable-memory` entitlements. This will + * allow the utility process to load unsigned libraries. Unless you specifically + * need this capability, it is best to leave this disabled. Default is `false`. + * + * @platform darwin + */ + allowLoadingUnsignedLibraries?: boolean; + } + class UtilityProcess extends EventEmitter { + + // Docs: https://electronjs.org/docs/api/utility-process + + static fork(modulePath: string, args?: string[], options?: ForkOptions): UtilityProcess; + /** + * Emitted after the child process ends. + */ + on(event: 'exit', listener: ( + /** + * Contains the exit code for the process obtained from waitpid on posix, or + * GetExitCodeProcess on windows. + */ + code: number) => void): this; + once(event: 'exit', listener: ( + /** + * Contains the exit code for the process obtained from waitpid on posix, or + * GetExitCodeProcess on windows. + */ + code: number) => void): this; + addListener(event: 'exit', listener: ( + /** + * Contains the exit code for the process obtained from waitpid on posix, or + * GetExitCodeProcess on windows. + */ + code: number) => void): this; + removeListener(event: 'exit', listener: ( + /** + * Contains the exit code for the process obtained from waitpid on posix, or + * GetExitCodeProcess on windows. + */ + code: number) => void): this; + /** + * Emitted when the child process sends a message using + * `process.parentPort.postMessage()`. + */ + on(event: 'message', listener: (message: any) => void): this; + once(event: 'message', listener: (message: any) => void): this; + addListener(event: 'message', listener: (message: any) => void): this; + removeListener(event: 'message', listener: (message: any) => void): this; + /** + * Emitted once the child process has spawned successfully. + */ + on(event: 'spawn', listener: Function): this; + once(event: 'spawn', listener: Function): this; + addListener(event: 'spawn', listener: Function): this; + removeListener(event: 'spawn', listener: Function): this; + /** + * Terminates the process gracefully. On POSIX, it uses SIGTERM but will ensure the + * process is reaped on exit. This function returns true if the kill is successful, + * and false otherwise. + */ + kill(): boolean; + /** + * Send a message to the child process, optionally transferring ownership of zero + * or more [`MessagePortMain`][] objects. + * + * For example: + */ + postMessage(message: any, transfer?: Electron.MessagePortMain[]): void; + /** + * A `Integer | undefined` representing the process identifier (PID) of the child + * process. If the child process fails to spawn due to errors, then the value is + * `undefined`. When the child process exits, then the value is `undefined` after + * the `exit` event is emitted. + */ + pid: (number) | (undefined); + /** + * A `NodeJS.ReadableStream | null` that represents the child process's stderr. If + * the child was spawned with options.stdio[2] set to anything other than 'pipe', + * then this will be `null`. When the child process exits, then the value is `null` + * after the `exit` event is emitted. + */ + stderr: (NodeJS.ReadableStream) | (null); + /** + * A `NodeJS.ReadableStream | null` that represents the child process's stdout. If + * the child was spawned with options.stdio[1] set to anything other than 'pipe', + * then this will be `null`. When the child process exits, then the value is `null` + * after the `exit` event is emitted. + */ + stdout: (NodeJS.ReadableStream) | (null); + } +} + +export const UtilityProcess = ((electron as any).utilityProcess); +export const canUseUtilityProcess = (typeof UtilityProcess !== 'undefined'); diff --git a/src/vs/base/parts/sandbox/node/electronTypes.ts b/src/vs/base/parts/sandbox/node/electronTypes.ts new file mode 100644 index 0000000000000..40a896601523d --- /dev/null +++ b/src/vs/base/parts/sandbox/node/electronTypes.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// TODO@bpasero remove me once we are on Electron 22 + +export interface ParentPort extends NodeJS.EventEmitter { + + // Docs: https://electronjs.org/docs/api/parent-port + + /** + * Emitted when the process receives a message. Messages received on this port will + * be queued up until a handler is registered for this event. + */ + on(event: 'message', listener: (messageEvent: Electron.MessageEvent) => void): this; + once(event: 'message', listener: (messageEvent: Electron.MessageEvent) => void): this; + addListener(event: 'message', listener: (messageEvent: Electron.MessageEvent) => void): this; + removeListener(event: 'message', listener: (messageEvent: Electron.MessageEvent) => void): this; + /** + * Sends a message from the process to its parent. + */ + postMessage(message: any): void; +} + +export interface UtilityNodeJSProcess extends NodeJS.Process { + + /** + * A `Electron.ParentPort` property if this is a `UtilityProcess` (or `null` + * otherwise) allowing communication with the parent process. + */ + parentPort: ParentPort; +} diff --git a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts index da9e7b5a70d82..7ad4db3d5f27a 100644 --- a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts +++ b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts @@ -17,151 +17,26 @@ import { FileAccess } from 'vs/base/common/network'; import { mixin } from 'vs/base/common/objects'; import * as platform from 'vs/base/common/platform'; import { cwd } from 'vs/base/common/process'; -import type { EventEmitter } from 'events'; -import * as electron from 'electron'; +import { canUseUtilityProcess } from 'vs/base/parts/sandbox/electron-main/electronTypes'; +import { UtilityProcess } from 'vs/platform/utilityProcess/electron-main/utilityProcess'; import { IWindowsMainService } from 'vs/platform/windows/electron-main/windows'; - -declare namespace UtilityProcessProposedApi { - interface ForkOptions { - /** - * Environment key-value pairs. Default is `process.env`. - */ - env?: NodeJS.ProcessEnv; - /** - * List of string arguments passed to the executable. - */ - execArgv?: string[]; - /** - * Current working directory of the child process. - */ - cwd?: string; - /** - * Allows configuring the mode for `stdout` and `stderr` of the child process. - * Default is `inherit`. String value can be one of `pipe`, `ignore`, `inherit`, - * for more details on these values you can refer to stdio documentation from - * Node.js. Currently this option only supports configuring `stdout` and `stderr` - * to either `pipe`, `inherit` or `ignore`. Configuring `stdin` is not supported; - * `stdin` will always be ignored. For example, the supported values will be - * processed as following: - */ - stdio?: (Array<'pipe' | 'ignore' | 'inherit'>) | (string); - /** - * Name of the process that will appear in `name` property of `child-process-gone` - * event of `app`. Default is `node.mojom.NodeService`. - */ - serviceName?: string; - /** - * With this flag, the utility process will be launched via the `Electron Helper - * (Plugin).app` helper executable on macOS, which can be codesigned with - * `com.apple.security.cs.disable-library-validation` and - * `com.apple.security.cs.allow-unsigned-executable-memory` entitlements. This will - * allow the utility process to load unsigned libraries. Unless you specifically - * need this capability, it is best to leave this disabled. Default is `false`. - * - * @platform darwin - */ - allowLoadingUnsignedLibraries?: boolean; - } - class UtilityProcess extends EventEmitter { - - // Docs: https://electronjs.org/docs/api/utility-process - - static fork(modulePath: string, args?: string[], options?: ForkOptions): UtilityProcess; - /** - * Emitted after the child process ends. - */ - on(event: 'exit', listener: ( - /** - * Contains the exit code for the process obtained from waitpid on posix, or - * GetExitCodeProcess on windows. - */ - code: number) => void): this; - once(event: 'exit', listener: ( - /** - * Contains the exit code for the process obtained from waitpid on posix, or - * GetExitCodeProcess on windows. - */ - code: number) => void): this; - addListener(event: 'exit', listener: ( - /** - * Contains the exit code for the process obtained from waitpid on posix, or - * GetExitCodeProcess on windows. - */ - code: number) => void): this; - removeListener(event: 'exit', listener: ( - /** - * Contains the exit code for the process obtained from waitpid on posix, or - * GetExitCodeProcess on windows. - */ - code: number) => void): this; - /** - * Emitted when the child process sends a message using - * `process.parentPort.postMessage()`. - */ - on(event: 'message', listener: (message: any) => void): this; - once(event: 'message', listener: (message: any) => void): this; - addListener(event: 'message', listener: (message: any) => void): this; - removeListener(event: 'message', listener: (message: any) => void): this; - /** - * Emitted once the child process has spawned successfully. - */ - on(event: 'spawn', listener: Function): this; - once(event: 'spawn', listener: Function): this; - addListener(event: 'spawn', listener: Function): this; - removeListener(event: 'spawn', listener: Function): this; - /** - * Terminates the process gracefully. On POSIX, it uses SIGTERM but will ensure the - * process is reaped on exit. This function returns true if the kill is successful, - * and false otherwise. - */ - kill(): boolean; - /** - * Send a message to the child process, optionally transferring ownership of zero - * or more [`MessagePortMain`][] objects. - * - * For example: - */ - postMessage(message: any, transfer?: Electron.MessagePortMain[]): void; - /** - * A `Integer | undefined` representing the process identifier (PID) of the child - * process. If the child process fails to spawn due to errors, then the value is - * `undefined`. When the child process exits, then the value is `undefined` after - * the `exit` event is emitted. - */ - pid: (number) | (undefined); - /** - * A `NodeJS.ReadableStream | null` that represents the child process's stderr. If - * the child was spawned with options.stdio[2] set to anything other than 'pipe', - * then this will be `null`. When the child process exits, then the value is `null` - * after the `exit` event is emitted. - */ - stderr: (NodeJS.ReadableStream) | (null); - /** - * A `NodeJS.ReadableStream | null` that represents the child process's stdout. If - * the child was spawned with options.stdio[1] set to anything other than 'pipe', - * then this will be `null`. When the child process exits, then the value is `null` - * after the `exit` event is emitted. - */ - stdout: (NodeJS.ReadableStream) | (null); - } -} -const UtilityProcess = ((electron as any).utilityProcess); -const canUseUtilityProcess = (typeof UtilityProcess !== 'undefined'); +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; export class ExtensionHostStarter implements IDisposable, IExtensionHostStarter { _serviceBrand: undefined; private static _lastId: number = 0; - protected readonly _extHosts: Map; + protected readonly _extHosts: Map; private _shutdown = false; constructor( @ILogService private readonly _logService: ILogService, @ILifecycleMainService lifecycleMainService: ILifecycleMainService, @IWindowsMainService private readonly _windowsMainService: IWindowsMainService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { - this._extHosts = new Map(); + this._extHosts = new Map(); // On shutdown: gracefully await extension host shutdowns lifecycleMainService.onWillShutdown((e) => { @@ -174,7 +49,7 @@ export class ExtensionHostStarter implements IDisposable, IExtensionHostStarter // Intentionally not killing the extension host processes } - private _getExtHost(id: string): ExtensionHostProcess | UtilityExtensionHostProcess { + private _getExtHost(id: string): ExtensionHostProcess | UtilityProcess { const extHostProcess = this._extHosts.get(id); if (!extHostProcess) { throw new Error(`Unknown extension host!`); @@ -195,7 +70,12 @@ export class ExtensionHostStarter implements IDisposable, IExtensionHostStarter } onDynamicError(id: string): Event<{ error: SerializedError }> { - return this._getExtHost(id).onError; + const exthost = this._getExtHost(id); + if (exthost instanceof UtilityProcess) { + return Event.None; + } + + return exthost.onError; } onDynamicExit(id: string): Event<{ code: number; signal: string }> { @@ -211,12 +91,12 @@ export class ExtensionHostStarter implements IDisposable, IExtensionHostStarter throw canceled(); } const id = String(++ExtensionHostStarter._lastId); - let extHost: UtilityExtensionHostProcess | ExtensionHostProcess; + let extHost: UtilityProcess | ExtensionHostProcess; if (useUtilityProcess) { if (!canUseUtilityProcess) { throw new Error(`Cannot use UtilityProcess!`); } - extHost = new UtilityExtensionHostProcess(id, this._logService, this._windowsMainService); + extHost = new UtilityProcess(this._logService, this._windowsMainService, this._telemetryService); } else { extHost = new ExtensionHostProcess(id, this._logService); } @@ -235,7 +115,14 @@ export class ExtensionHostStarter implements IDisposable, IExtensionHostStarter if (this._shutdown) { throw canceled(); } - return this._getExtHost(id).start(opts); + return this._getExtHost(id).start({ + ...opts, + type: 'extensionHost', + args: ['--skipWorkspaceStorageLock'], + execArgv: opts.execArgv ?? [], + allowLoadingUnsignedLibraries: true, + correlationId: id + }); } async enableInspectPort(id: string): Promise { @@ -392,143 +279,3 @@ class ExtensionHostProcess extends Disposable { } } } - -class UtilityExtensionHostProcess extends Disposable { - - readonly onError = Event.None; - - readonly _onStdout = this._register(new Emitter()); - readonly onStdout = this._onStdout.event; - - readonly _onStderr = this._register(new Emitter()); - readonly onStderr = this._onStderr.event; - - readonly _onMessage = this._register(new Emitter()); - readonly onMessage = this._onMessage.event; - - readonly _onExit = this._register(new Emitter<{ pid: number; code: number; signal: string }>()); - readonly onExit = this._onExit.event; - - private _process: UtilityProcessProposedApi.UtilityProcess | null = null; - private _hasExited: boolean = false; - - constructor( - public readonly id: string, - @ILogService private readonly _logService: ILogService, - @IWindowsMainService private readonly _windowsMainService: IWindowsMainService, - ) { - super(); - } - - start(opts: IExtensionHostProcessOptions): void { - const codeWindow = this._windowsMainService.getWindowById(opts.responseWindowId); - if (!codeWindow) { - this._logService.info(`UtilityProcess<${this.id}>: Refusing to create new Extension Host UtilityProcess because requesting window cannot be found...`); - return; - } - - const responseWindow = codeWindow.win; - if (!responseWindow || responseWindow.isDestroyed() || responseWindow.webContents.isDestroyed()) { - this._logService.info(`UtilityProcess<${this.id}>: Refusing to create new Extension Host UtilityProcess because requesting window cannot be found...`); - return; - } - - const serviceName = `extensionHost${this.id}`; - const modulePath = FileAccess.asFileUri('bootstrap-fork.js').fsPath; - const args: string[] = ['--type=extensionHost', '--skipWorkspaceStorageLock']; - const execArgv: string[] = opts.execArgv || []; - const env: { [key: string]: any } = { ...opts.env }; - const allowLoadingUnsignedLibraries: boolean = true; - const stdio: (Array<'pipe' | 'ignore' | 'inherit'>) | (string) = 'pipe'; - - // Make sure all values are strings, otherwise the process will not start - for (const key of Object.keys(env)) { - env[key] = String(env[key]); - } - - this._logService.info(`UtilityProcess<${this.id}>: Creating new...`); - - this._process = UtilityProcess.fork(modulePath, args, { - serviceName, - env, - execArgv, - allowLoadingUnsignedLibraries, - stdio - }); - - const stdoutDecoder = new StringDecoder('utf-8'); - this._process.stdout?.on('data', (chunk) => { - const strChunk = typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk); - this._onStdout.fire(strChunk); - }); - - const stderrDecoder = new StringDecoder('utf-8'); - this._process.stderr?.on('data', (chunk) => { - const strChunk = typeof chunk === 'string' ? chunk : stderrDecoder.write(chunk); - this._onStderr.fire(strChunk); - }); - - this._process.on('message', msg => { - this._onMessage.fire(msg); - }); - - this._register(Event.fromNodeEventEmitter(this._process, 'spawn')(() => { - this._logService.info(`UtilityProcess<${this.id}>: received spawn event.`); - })); - const onExit = Event.fromNodeEventEmitter(this._process, 'exit', (code: number) => code); - this._register(onExit((code: number) => { - this._logService.info(`UtilityProcess<${this.id}>: received exit event with code ${code}.`); - this._hasExited = true; - this._onExit.fire({ pid: this._process!.pid!, code, signal: '' }); - })); - - const { port1, port2 } = new electron.MessageChannelMain(); - - this._process.postMessage('null', [port2]); - responseWindow.webContents.postMessage(opts.responseChannel, opts.responseNonce, [port1]); - } - - enableInspectPort(): boolean { - if (!this._process) { - return false; - } - - this._logService.info(`UtilityProcess<${this.id}>: Enabling inspect port on extension host with pid ${this._process.pid}.`); - - interface ProcessExt { - _debugProcess?(n: number): any; - } - - if (typeof (process)._debugProcess === 'function') { - // use (undocumented) _debugProcess feature of node - (process)._debugProcess!(this._process.pid!); - return true; - } else { - // not supported... - return false; - } - } - - kill(): void { - if (!this._process) { - return; - } - this._logService.info(`UtilityProcess<${this.id}>: Killing extension host with pid ${this._process.pid}.`); - this._process.kill(); - } - - async waitForExit(maxWaitTimeMs: number): Promise { - if (!this._process) { - return; - } - const pid = this._process.pid; - this._logService.info(`UtilityProcess<${this.id}>: Waiting for extension host with pid ${pid} to exit.`); - await Promise.race([Event.toPromise(this.onExit), timeout(maxWaitTimeMs)]); - - if (!this._hasExited) { - // looks like we timed out - this._logService.info(`UtilityProcess<${this.id}>: Extension host with pid ${pid} did not exit within ${maxWaitTimeMs}ms, will kill it now.`); - this._process.kill(); - } - } -} diff --git a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts new file mode 100644 index 0000000000000..5a9f7a06f4dda --- /dev/null +++ b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts @@ -0,0 +1,330 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BrowserWindow, Details, MessageChannelMain, app } from 'electron'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { Emitter, Event } from 'vs/base/common/event'; +import { ILogService } from 'vs/platform/log/common/log'; +import { StringDecoder } from 'string_decoder'; +import { timeout } from 'vs/base/common/async'; +import { FileAccess } from 'vs/base/common/network'; +import { UtilityProcess as ElectronUtilityProcess, UtilityProcessProposedApi, canUseUtilityProcess } from 'vs/base/parts/sandbox/electron-main/electronTypes'; +import { IWindowsMainService } from 'vs/platform/windows/electron-main/windows'; +import Severity from 'vs/base/common/severity'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; + +export interface IUtilityProcessConfiguration { + + // --- message port response related + + readonly responseWindowId: number; + readonly responseChannel: string; + readonly responseNonce: string; + + // --- utility process options + + /** + * A way to group utility processes of same type together. + */ + readonly type: string; + + /** + * Environment key-value pairs. Default is `process.env`. + */ + readonly env?: { [key: string]: string | undefined }; + + /** + * List of string arguments that will be available as `process.argv` + * in the child process. + */ + readonly args?: string[]; + + /** + * List of string arguments passed to the executable. + */ + readonly execArgv?: string[]; + + /** + * Allow the utility process to load unsigned libraries. + */ + readonly allowLoadingUnsignedLibraries?: boolean; + + /** + * Used in log messages to correlate the process + * with other components. + */ + readonly correlationId?: string; +} + +interface IUtilityProcessExitBaseEvent { + + /** + * The process id of the process that exited. + */ + readonly pid: number; + + /** + * The exit code of the process. + */ + readonly code: number; +} + +export interface IUtilityProcessExitEvent extends IUtilityProcessExitBaseEvent { + + /** + * The signal that caused the process to exit is unknown + * for utility processes. + */ + readonly signal: 'unknown'; +} + +export interface IUtilityProcessCrashEvent extends IUtilityProcessExitBaseEvent { + + /** + * The reason of the utility process crash. + */ + readonly reason: 'clean-exit' | 'abnormal-exit' | 'killed' | 'crashed' | 'oom' | 'launch-failed' | 'integrity-failure'; +} + +export class UtilityProcess extends Disposable { + + private static ID_COUNTER = 0; + + private readonly id = String(++UtilityProcess.ID_COUNTER); + + private readonly _onStdout = this._register(new Emitter()); + readonly onStdout = this._onStdout.event; + + private readonly _onStderr = this._register(new Emitter()); + readonly onStderr = this._onStderr.event; + + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onExit = this._register(new Emitter()); + readonly onExit = this._onExit.event; + + private readonly _onCrash = this._register(new Emitter()); + readonly onCrash = this._onCrash.event; + + private process: UtilityProcessProposedApi.UtilityProcess | undefined = undefined; + private processPid: number | undefined = undefined; + private configuration: IUtilityProcessConfiguration | undefined = undefined; + + private didExit: boolean = false; + + constructor( + @ILogService private readonly logService: ILogService, + @IWindowsMainService private readonly windowsMainService: IWindowsMainService, + @ITelemetryService private readonly telemetryService: ITelemetryService + ) { + super(); + } + + private log(msg: string, severity: Severity): void { + let logMsg: string; + if (this.configuration?.correlationId) { + logMsg = `[UtilityProcess id: ${this.configuration?.correlationId}, type: ${this.configuration?.type}, pid: ${this.processPid ?? ''}]: ${msg}`; + } else { + logMsg = `[UtilityProcess type: ${this.configuration?.type}, pid: ${this.processPid ?? ''}]: ${msg}`; + } + + switch (severity) { + case Severity.Error: + this.logService.error(logMsg); + break; + case Severity.Warning: + this.logService.warn(logMsg); + break; + case Severity.Info: + this.logService.info(logMsg); + break; + } + } + + private validateCanStart(configuration: IUtilityProcessConfiguration): BrowserWindow | undefined { + if (!canUseUtilityProcess) { + throw new Error('Cannot use UtilityProcess API from Electron!'); + } + + if (this.process) { + this.log('Cannot start utility process because it is already running...', Severity.Error); + return undefined; + } + + const responseWindow = this.windowsMainService.getWindowById(configuration.responseWindowId)?.win; + if (!responseWindow || responseWindow.isDestroyed() || responseWindow.webContents.isDestroyed()) { + this.log('Refusing to start utility process because requesting window cannot be found or is destroyed...', Severity.Error); + return undefined; + } + + return responseWindow; + } + + start(configuration: IUtilityProcessConfiguration): void { + const responseWindow = this.validateCanStart(configuration); + if (!responseWindow) { + return; + } + + this.configuration = configuration; + + const serviceName = `${this.configuration.type}-${this.id}`; + const modulePath = FileAccess.asFileUri('bootstrap-fork.js').fsPath; + const args = [...this.configuration.args ?? [], `--type=${this.configuration.type}`]; + const execArgv = this.configuration.execArgv; + const allowLoadingUnsignedLibraries = this.configuration.allowLoadingUnsignedLibraries; + const stdio = 'pipe'; + + let env: { [key: string]: any } | undefined = this.configuration.env; + if (env) { + env = { ...env }; // make a copy since we may be going to mutate it + + for (const key of Object.keys(env)) { + env[key] = String(env[key]); // make sure all values are strings, otherwise the process will not start + } + } + + this.log('creating new...', Severity.Info); + + // Fork utility process + this.process = ElectronUtilityProcess.fork(modulePath, args, { + serviceName, + env, + execArgv, + allowLoadingUnsignedLibraries, + stdio + }); + + // Register to events + this.registerListeners(this.process, this.configuration, serviceName); + + // Exchange message ports + this.exchangeMessagePorts(this.process, this.configuration, responseWindow); + } + + private registerListeners(process: UtilityProcessProposedApi.UtilityProcess, configuration: IUtilityProcessConfiguration, serviceName: string): void { + + // Stdout + if (process.stdout) { + const stdoutDecoder = new StringDecoder('utf-8'); + this._register(Event.fromNodeEventEmitter(process.stdout, 'data')(chunk => this._onStdout.fire(typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)))); + } + + // Stderr + if (process.stderr) { + const stderrDecoder = new StringDecoder('utf-8'); + this._register(Event.fromNodeEventEmitter(process.stderr, 'data')(chunk => this._onStderr.fire(typeof chunk === 'string' ? chunk : stderrDecoder.write(chunk)))); + } + + //Messages + this._register(Event.fromNodeEventEmitter(process, 'message')(msg => this._onMessage.fire(msg))); + + // Spawn + this._register(Event.fromNodeEventEmitter(process, 'spawn')(() => { + this.processPid = process.pid; + + this.log('successfully created', Severity.Info); + })); + + // Exit + this._register(Event.fromNodeEventEmitter(process, 'exit')(code => { + this.log(`received exit event with code ${code}`, Severity.Info); + + this.didExit = true; + + this._onExit.fire({ pid: this.processPid!, code, signal: 'unknown' }); + })); + + // Child process gone + this._register(Event.fromNodeEventEmitter<{ details: Details }>(app, 'child-process-gone', (event, details) => ({ event, details }))(({ details }) => { + if (details.type === 'Utility' && details.name === serviceName) { + this.log(`crashed with code ${details.exitCode} and reason '${details.reason}'`, Severity.Error); + + // Telemetry + type UtilityProcessCrashClassification = { + type: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The type of utility process to understand the origin of the crash better.' }; + reason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The reason of the utility process crash to understand the nature of the crash better.' }; + code: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The exit code of the utility process to understand the nature of the crash better' }; + owner: 'bpasero'; + comment: 'Provides insight into reasons the utility process crashed.'; + }; + type UtilityProcessCrashEvent = { + type: string; + reason: string; + code: number; + }; + this.telemetryService.publicLog2('utilityprocesscrash', { type: configuration.type, reason: details.reason, code: details.exitCode }); + + // Event + this._onCrash.fire({ pid: this.processPid!, code: details.exitCode, reason: details.reason }); + } + })); + } + + private exchangeMessagePorts(process: UtilityProcessProposedApi.UtilityProcess, configuration: IUtilityProcessConfiguration, responseWindow: BrowserWindow) { + const { port1: windowPort, port2: utilityProcessPort } = new MessageChannelMain(); + + process.postMessage('null', [utilityProcessPort]); + responseWindow.webContents.postMessage(configuration.responseChannel, configuration.responseNonce, [windowPort]); + } + + enableInspectPort(): boolean { + if (typeof this.processPid !== 'number') { + return false; + } + + this.log('enabling inspect port', Severity.Info); + + interface ProcessExt { + _debugProcess?(pid: number): unknown; + } + + // use (undocumented) _debugProcess feature of node if available + const processExt = process; + if (typeof processExt._debugProcess === 'function') { + processExt._debugProcess(this.processPid); + + return true; + } + + // not supported... + return false; + } + + kill(): void { + if (!this.process) { + this.log('no running process to kill', Severity.Warning); + return; + } + + this.log('attempting to kill the process...', Severity.Info); + const killed = this.process.kill(); + if (killed) { + this.log('successfully killed the process', Severity.Info); + } else { + this.log('unable to kill the process', Severity.Warning); + } + } + + async waitForExit(maxWaitTimeMs: number): Promise { + if (!this.process) { + this.log('no running process to wait for exit', Severity.Warning); + return; + } + + if (this.didExit) { + return; + } + + this.log('waiting to exit...', Severity.Info); + await Promise.race([Event.toPromise(this.onExit), timeout(maxWaitTimeMs)]); + + if (!this.didExit) { + this.log('did not exit within ${maxWaitTimeMs}ms, will kill it now...', Severity.Info); + this.kill(); + } + } +} diff --git a/src/vs/workbench/api/node/extensionHostProcess.ts b/src/vs/workbench/api/node/extensionHostProcess.ts index f69d06dfce90e..e2d99e751b254 100644 --- a/src/vs/workbench/api/node/extensionHostProcess.ts +++ b/src/vs/workbench/api/node/extensionHostProcess.ts @@ -23,37 +23,21 @@ import { IHostUtils } from 'vs/workbench/api/common/extHostExtensionService'; import { ProcessTimeRunOnceScheduler } from 'vs/base/common/async'; import { boolean } from 'vs/editor/common/config/editorOptions'; import { createURITransformer } from 'vs/workbench/api/node/uriTransformer'; -import { MessagePortMain } from 'electron'; import { ExtHostConnectionType, readExtHostConnection } from 'vs/workbench/services/extensions/common/extensionHostEnv'; -import type { EventEmitter } from 'events'; import 'vs/workbench/api/common/extHost.common.services'; import 'vs/workbench/api/node/extHost.node.services'; +// TODO this is a layer breaker for types only provided in Electron +import type { MessagePortMain } from 'electron'; +import type { UtilityNodeJSProcess } from 'vs/base/parts/sandbox/node/electronTypes'; + interface ParsedExtHostArgs { transformURIs?: boolean; skipWorkspaceStorageLock?: boolean; useHostProxy?: 'true' | 'false'; // use a string, as undefined is also a valid value } -interface ParentPort extends EventEmitter { - - // Docs: https://electronjs.org/docs/api/parent-port - - /** - * Emitted when the process receives a message. Messages received on this port will - * be queued up until a handler is registered for this event. - */ - on(event: 'message', listener: (messageEvent: Electron.MessageEvent) => void): this; - once(event: 'message', listener: (messageEvent: Electron.MessageEvent) => void): this; - addListener(event: 'message', listener: (messageEvent: Electron.MessageEvent) => void): this; - removeListener(event: 'message', listener: (messageEvent: Electron.MessageEvent) => void): this; - /** - * Sends a message from the process to its parent. - */ - postMessage(message: any): void; -} - // workaround for https://github.com/microsoft/vscode/issues/85490 // remove --inspect-port=0 after start so that it doesn't trigger LSP debugging (function removeInspectPort() { @@ -151,7 +135,7 @@ function _createExtHostProtocol(): Promise { }); }; - (process as NodeJS.Process & { parentPort: ParentPort })?.parentPort.on('message', (e: Electron.MessageEvent) => withPorts(e.ports)); + (process as UtilityNodeJSProcess).parentPort.on('message', (e: Electron.MessageEvent) => withPorts(e.ports)); }); } else if (extHostConnection.type === ExtHostConnectionType.Socket) {