@theia/process
Version:
Theia process support.
295 lines (254 loc) • 11.5 kB
text/typescript
// *****************************************************************************
// 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...)
*/
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
protected override readonly options: TerminalProcessOptions,
processManager: ProcessManager,
protected readonly ringBuffer: MultiRingBuffer,
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);
}
}