UNPKG

@theia/process

Version:
295 lines (254 loc) • 11.5 kB
// ***************************************************************************** // Copyright (C) 2017 Ericsson and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, inject, named } from '@theia/core/shared/inversify'; import { Disposable, DisposableCollection, Emitter, Event, isWindows } from '@theia/core'; import { ILogger } from '@theia/core/lib/common'; import { Process, ProcessType, ProcessOptions, /* ProcessErrorEvent */ } from './process'; import { ProcessManager } from './process-manager'; import { IPty, spawn } from 'node-pty'; import { MultiRingBuffer, MultiRingBufferReadableStream } from './multi-ring-buffer'; import { DevNullStream } from './dev-null-stream'; import { signame } from './utils'; import { PseudoPty } from './pseudo-pty'; import { Writable } from 'stream'; export const TerminalProcessOptions = Symbol('TerminalProcessOptions'); export interface TerminalProcessOptions extends ProcessOptions { /** * Windows only. Allow passing complex command lines already escaped for CommandLineToArgvW. */ commandLine?: string; isPseudo?: boolean; } export const TerminalProcessFactory = Symbol('TerminalProcessFactory'); export interface TerminalProcessFactory { (options: TerminalProcessOptions): TerminalProcess; } export enum NodePtyErrors { EACCES = 'Permission denied', ENOENT = 'No such file or directory' } /** * Run arbitrary processes inside pseudo-terminals (PTY). * * Note: a PTY is not a shell process (bash/pwsh/cmd...) */ @injectable() export class TerminalProcess extends Process { protected readonly terminal: IPty | undefined; private _delayedResizer: DelayedResizer | undefined; private _exitCode: number | undefined; readonly outputStream = this.createOutputStream(); readonly errorStream = new DevNullStream({ autoDestroy: true }); readonly inputStream: Writable; constructor( // eslint-disable-next-line @typescript-eslint/indent @inject(TerminalProcessOptions) protected override readonly options: TerminalProcessOptions, @inject(ProcessManager) processManager: ProcessManager, @inject(MultiRingBuffer) protected readonly ringBuffer: MultiRingBuffer, @inject(ILogger) @named('process') logger: ILogger ) { super(processManager, logger, ProcessType.Terminal, options); if (options.isPseudo) { // do not need to spawn a process, new a pseudo pty instead this.terminal = new PseudoPty(); this.inputStream = new DevNullStream({ autoDestroy: true }); return; } if (this.isForkOptions(this.options)) { throw new Error('terminal processes cannot be forked as of today'); } this.logger.debug('Starting terminal process', JSON.stringify(options, undefined, 2)); // Delay resizes to avoid conpty not respecting very early resize calls // see https://github.com/microsoft/vscode/blob/a1c783c/src/vs/platform/terminal/node/terminalProcess.ts#L177 if (isWindows) { this._delayedResizer = new DelayedResizer(); this._delayedResizer.onTrigger(dimensions => { this._delayedResizer?.dispose(); this._delayedResizer = undefined; if (dimensions.cols && dimensions.rows) { this.resize(dimensions.cols, dimensions.rows); } }); } const startTerminal = (command: string): { terminal: IPty | undefined, inputStream: Writable } => { try { return this.createPseudoTerminal(command, options, ringBuffer); } catch (error) { // Normalize the error to make it as close as possible as what // node's child_process.spawn would generate in the same // situation. const message: string = error.message; if (message.startsWith('File not found: ') || message.endsWith(NodePtyErrors.ENOENT)) { if (isWindows && command && !command.toLowerCase().endsWith('.exe')) { const commandExe = command + '.exe'; this.logger.debug(`Trying terminal command '${commandExe}' because '${command}' was not found.`); return startTerminal(commandExe); } // Proceed with failure, reporting the original command because it was // the intended command and it was not found error.errno = 'ENOENT'; error.code = 'ENOENT'; error.path = options.command; } else if (message.endsWith(NodePtyErrors.EACCES)) { // The shell program exists but was not accessible, so just fail error.errno = 'EACCES'; error.code = 'EACCES'; error.path = options.command; } // node-pty throws exceptions on Windows. // Call the client error handler, but first give them a chance to register it. this.emitOnErrorAsync(error); return { terminal: undefined, inputStream: new DevNullStream({ autoDestroy: true }) }; } }; const { terminal, inputStream } = startTerminal(options.command); this.terminal = terminal; this.inputStream = inputStream; } /** * Helper for the constructor to attempt to create the pseudo-terminal encapsulating the shell process. * * @param command the shell command to launch * @param options options for the shell process * @param ringBuffer a ring buffer in which to collect terminal output * @returns the terminal PTY and a stream by which it may be sent input */ private createPseudoTerminal(command: string, options: TerminalProcessOptions, ringBuffer: MultiRingBuffer): { terminal: IPty | undefined, inputStream: Writable } { const terminal = spawn( command, (isWindows && options.commandLine) || options.args || [], options.options || {} ); process.nextTick(() => this.emitOnStarted()); // node-pty actually wait for the underlying streams to be closed before emitting exit. // We should emulate the `exit` and `close` sequence. terminal.onExit(({ exitCode, signal }) => { // see https://github.com/microsoft/node-pty/issues/751 if (exitCode === undefined) { exitCode = 0; } // Make sure to only pass either code or signal as !undefined, not // both. // // node-pty quirk: On Linux/macOS, if the process exited through the // exit syscall (with an exit code), signal will be 0 (an invalid // signal value). If it was terminated because of a signal, the // signal parameter will hold the signal number and code should // be ignored. this._exitCode = exitCode; if (signal === undefined || signal === 0) { this.onTerminalExit(exitCode, undefined); } else { this.onTerminalExit(undefined, signame(signal)); } process.nextTick(() => { if (signal === undefined || signal === 0) { this.emitOnClose(exitCode, undefined); } else { this.emitOnClose(undefined, signame(signal)); } }); }); terminal.onData((data: string) => { ringBuffer.enq(data); }); const inputStream = new Writable({ write: (chunk: string) => { this.write(chunk); }, }); return { terminal, inputStream }; } createOutputStream(): MultiRingBufferReadableStream { return this.ringBuffer.getStream(); } get pid(): number { this.checkTerminal(); return this.terminal!.pid; } get executable(): string { return (this.options as ProcessOptions).command; } get arguments(): string[] { return this.options.args || []; } protected onTerminalExit(code: number | undefined, signal: string | undefined): void { this.emitOnExit(code, signal); this.unregisterProcess(); } unregisterProcess(): void { this.processManager.unregister(this); } kill(signal?: string): void { if (this.terminal && this.killed === false) { this.terminal.kill(signal); } } resize(cols: number, rows: number): void { if (typeof cols !== 'number' || typeof rows !== 'number' || isNaN(cols) || isNaN(rows)) { return; } this.checkTerminal(); try { // Ensure that cols and rows are always >= 1, this prevents a native exception in winpty. cols = Math.max(cols, 1); rows = Math.max(rows, 1); // Delay resize if needed if (this._delayedResizer) { this._delayedResizer.cols = cols; this._delayedResizer.rows = rows; return; } this.terminal!.resize(cols, rows); } catch (error) { // swallow error if the pty has already exited // see also https://github.com/microsoft/vscode/blob/a1c783c/src/vs/platform/terminal/node/terminalProcess.ts#L549 if (this._exitCode !== undefined && error.message !== 'ioctl(2) failed, EBADF' && error.message !== 'Cannot resize a pty that has already exited') { throw error; } } } write(data: string): void { this.checkTerminal(); this.terminal!.write(data); } protected checkTerminal(): void | never { if (!this.terminal) { throw new Error('pty process did not start correctly'); } } } /** * Tracks the latest resize event to be trigger at a later point. */ class DelayedResizer extends DisposableCollection { rows: number | undefined; cols: number | undefined; private _timeout: NodeJS.Timeout; private readonly _onTrigger = new Emitter<{ rows?: number; cols?: number }>(); get onTrigger(): Event<{ rows?: number; cols?: number }> { return this._onTrigger.event; } constructor() { super(); this.push(this._onTrigger); this._timeout = setTimeout(() => this._onTrigger.fire({ rows: this.rows, cols: this.cols }), 1000); this.push(Disposable.create(() => clearTimeout(this._timeout))); } override dispose(): void { super.dispose(); clearTimeout(this._timeout); } }