deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,31 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - PROCESS EXTENSION</h2>
<hr />
</div>
## Description
The `@theia/process` extension allows the management of processes started with or without a `terminal`.
## Additional Information
- [API documentation for `@theia/process`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_process.html)
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,54 @@
{
"name": "@theia/process",
"version": "1.68.0",
"description": "Theia process support.",
"dependencies": {
"@theia/core": "1.68.0",
"node-pty": "1.1.0-beta27",
"string-argv": "^0.1.1",
"tslib": "^2.6.2"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"backend": "lib/common/process-common-module",
"frontend": "lib/common/process-common-module"
},
{
"backend": "lib/node/process-backend-module"
}
],
"keywords": [
"theia-extension"
],
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,22 @@
// *****************************************************************************
// Copyright (C) 2020 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 { ContainerModule } from '@theia/core/shared/inversify';
import { ShellCommandBuilder } from './shell-command-builder';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(ShellCommandBuilder).toSelf().inSingletonScope();
});

View File

@@ -0,0 +1,58 @@
// *****************************************************************************
// Copyright (C) 2022 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 { Event } from '@theia/core';
export interface ManagedProcessManager {
register(process: ManagedProcess): number;
unregister(process: ManagedProcess): void;
get(id: number): ManagedProcess | undefined;
}
export interface ManagedProcess {
readonly id: number;
readonly onStart: Event<IProcessStartEvent>;
readonly onExit: Event<IProcessExitEvent>;
readonly onClose: Event<IProcessExitEvent>;
readonly onError: Event<ProcessErrorEvent>;
readonly killed: boolean;
kill(): void;
}
export interface IProcessExitEvent {
// Exactly one of code and signal will be set.
readonly code?: number,
readonly signal?: string
}
/**
* Data emitted when a process has been successfully started.
*/
export interface IProcessStartEvent {
}
/**
* Data emitted when a process has failed to start.
*/
export interface ProcessErrorEvent extends Error {
/** An errno-like error string (e.g. ENOENT). */
code: string;
}
export enum ProcessType {
'Raw',
'Terminal'
}

View File

@@ -0,0 +1,486 @@
// *****************************************************************************
// Copyright (C) 2020 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
// *****************************************************************************
/**
* This test suite assumes that we run in a NodeJS environment!
*/
import { spawn, execSync, SpawnOptions, ChildProcess, spawnSync } from 'child_process';
import { Readable } from 'stream';
import { join } from 'path';
import { ShellCommandBuilder, CommandLineOptions, ProcessInfo } from './shell-command-builder';
import * as chalk from 'chalk'; // tslint:disable-line:no-implicit-dependencies
export interface TestProcessInfo extends ProcessInfo {
shell: ChildProcess
}
const isWindows = process.platform === 'win32';
/**
* Extra debugging info (very verbose).
*/
const _debug: boolean = Boolean(process.env['THEIA_PROCESS_TEST_DEBUG']);
/**
* On Windows, some shells simply mess up the terminal's output.
* Enable if you still want to test those.
*/
const _runWeirdShell: true | undefined = Boolean(process.env['THEIA_PROCESS_TEST_WEIRD_SHELL']) || undefined;
/**
* You might only have issues with a specific shell (`cmd.exe` I am looking at you).
*/
const _onlyTestShell: string | undefined = process.env['THEIA_PROCESS_TEST_ONLY'] || undefined;
/**
* Only log if environment variable is set.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debug(...parts: any[]): void {
if (_debug) {
console.debug(...parts);
}
}
const testResources = join(__dirname, '../../src/common/tests');
const spawnOptions: SpawnOptions = {
// We do our own quoting, don't rely on the one done by NodeJS:
windowsVerbatimArguments: true,
stdio: ['pipe', 'pipe', 'pipe'],
};
// Formatting options, used with `scanLines` for debugging.
const stdoutFormat = (prefix: string) => (data: string) =>
`${chalk.bold(chalk.yellow(`${prefix} STDOUT:`))} ${chalk.bgYellow(chalk.black(data))}`;
const stderrFormat = (prefix: string) => (data: string) =>
`${chalk.bold(chalk.red(`${prefix} STDERR:`))} ${chalk.bgRed(chalk.white(data))}`;
// Default error scanner
const errorScanner = (handle: ScanLineHandle<void>) => {
if (
/^\s*\w+Error:/.test(handle.line) ||
/^\s*Cannot find /.test(handle.line)
) {
throw new Error(handle.text);
}
};
// Yarn mangles the PATH and creates some proxy script around node(.exe),
// which messes up our environment, failing the tests.
const hostNodePath =
process.env['npm_node_execpath'] ||
process.env['NODE'];
if (!hostNodePath) {
throw new Error('Could not determine the real node path.');
}
const shellCommandBuilder = new ShellCommandBuilder();
const shellConfigs = [{
name: 'bash',
path: isWindows
? _runWeirdShell && execShellCommand('where bash.exe')
: execShellCommand('command -v bash'),
nodePath:
isWindows && 'node' // Good enough
}, {
name: 'wsl',
path: isWindows
? _runWeirdShell && execShellCommand('where wsl.exe')
: undefined,
nodePath:
isWindows && 'node' // Good enough
}, {
name: 'cmd',
path: isWindows
? execShellCommand('where cmd.exe')
: undefined,
}, {
name: 'powershell',
path: execShellCommand(isWindows
? 'where powershell'
: 'command -v pwsh'),
}];
/* eslint-disable max-len */
// 18d/12m/19y - Ubuntu 16.04:
// Powershell sometimes fails when running as part of an npm lifecycle script.
// See following error:
//
//
// FailFast:
// The type initializer for 'Microsoft.PowerShell.ApplicationInsightsTelemetry' threw an exception.
//
// at System.Environment.FailFast(System.String, System.Exception)
// at System.Environment.FailFast(System.String, System.Exception)
// at Microsoft.PowerShell.UnmanagedPSEntry.Start(System.String, System.String[], Int32)
// at Microsoft.PowerShell.ManagedPSEntry.Main(System.String[])
//
// Exception details:
// System.TypeInitializationException: The type initializer for 'Microsoft.PowerShell.ApplicationInsightsTelemetry' threw an exception. ---> System.ArgumentException: Item has already been added. Key in dictionary: 'SPAWN_WRAP_SHIM_ROOT' Key being added: 'SPAWN_WRAP_SHIM_ROOT'
// at System.Collections.Hashtable.Insert(Object key, Object nvalue, Boolean add)
// at System.Environment.ToHashtable(IEnumerable`1 pairs)
// at System.Environment.GetEnvironmentVariables()
// at Microsoft.ApplicationInsights.Extensibility.Implementation.Platform.PlatformImplementation..ctor()
// at Microsoft.ApplicationInsights.Extensibility.Implementation.Platform.PlatformSingleton.get_Current()
// at Microsoft.ApplicationInsights.Extensibility.Implementation.TelemetryConfigurationFactory.Initialize(TelemetryConfiguration configuration, TelemetryModules modules)
// at Microsoft.ApplicationInsights.Extensibility.TelemetryConfiguration.get_Active()
// at Microsoft.PowerShell.ApplicationInsightsTelemetry..cctor()
// --- End of inner exception stack trace ---
// at Microsoft.PowerShell.ApplicationInsightsTelemetry.SendPSCoreStartupTelemetry()
// at Microsoft.PowerShell.ConsoleHost.Start(String bannerText, String helpText, String[] args)
// at Microsoft.PowerShell.ConsoleShell.Start(String bannerText, String helpText, String[] args)
// at Microsoft.PowerShell.UnmanagedPSEntry.Start(String consoleFilePath, String[] args, Int32 argc)
/* eslint-enable max-len */
let id = 0;
for (const shellConfig of shellConfigs) {
let skipMessage: string | undefined;
if (typeof _onlyTestShell === 'string' && shellConfig.name !== _onlyTestShell) {
skipMessage = `only testing ${_onlyTestShell}`;
} else if (!shellConfig.path) {
// For each shell, skip if we could not find the executable path.
skipMessage = 'cannot find shell';
} else {
// Run a test in the shell to catch runtime issues.
// CI seems to have issues with some shells depending on the environment...
try {
const debugName = `${shellConfig.name}/test`;
const shellTest = spawnSync(shellConfig.path, {
input: 'echo abcdefghijkl\n\n',
timeout: 5_000,
});
debug(stdoutFormat(debugName)(shellTest.stdout.toString()));
debug(stderrFormat(debugName)(shellTest.stderr.toString()));
if (!/abcdefghijkl/m.test(shellTest.output.toString())) {
skipMessage = 'wrong test output';
}
} catch (error) {
console.error(error);
skipMessage = 'error occurred';
}
}
/**
* If skipMessage is set, we should skip the test and explain why.
*/
const describeOrSkip = (callback: (this: Mocha.Suite) => void) => {
const describeMessage = `test ${shellConfig.name} commands`;
if (typeof skipMessage === 'undefined') {
describe(describeMessage, callback);
} else {
describe.skip(`${describeMessage} - skip: ${skipMessage}`, callback);
}
};
describeOrSkip(function (): void {
this.timeout(10_000);
let nodePath: string;
let cwd: string;
let submit: string | undefined;
let processInfo: TestProcessInfo;
let context: TestCaseContext;
beforeEach(() => {
// In WSL, the node path is different than the host one (Windows vs Linux).
nodePath = shellConfig.nodePath || hostNodePath;
// On windows, when running bash we need to convert paths from Windows
// to their mounting point, assuming bash is running within WSL.
if (isWindows && /bash|wsl/.test(shellConfig.name)) {
cwd = convertWindowsPath(testResources);
} else {
cwd = testResources;
}
// When running powershell, it seems like good measure to send `\n` twice...
if (shellConfig.name === 'powershell') {
submit = '\n\n';
}
// TestContext holds all state for a given test.
const testContextName = `${shellConfig.name}/${++id}`;
context = new TestCaseContext(testContextName, submit);
processInfo = createShell(context, shellConfig.path!);
});
afterEach(() => {
processInfo.shell.kill();
context.finalize();
});
it('use simple environment variables', async () => {
const envName = 'SIMPLE_NAME';
const envValue = 'SIMPLE_VALUE';
await testCommandLine(
context, processInfo,
{
cwd, args: [nodePath, '-p', `\`[\${process.env['${envName}']}]\``],
env: {
[envName]: envValue,
}
}, [
// stderr
scanLines<void>(context, processInfo.shell.stderr!, errorScanner, stderrFormat(context.name)),
// stdout
scanLines<void>(context, processInfo.shell.stdout!, handle => {
errorScanner(handle);
if (handle.line.includes(`[${envValue}]`)) {
handle.resolve();
}
}, stdoutFormat(context.name)),
]);
});
it('use problematic environment variables', async () => {
const envName = 'A?B_C | D $PATH';
const envValue = 'SUCCESS';
await testCommandLine(
context, processInfo,
{
cwd, args: [nodePath, '-p', `\`[\${process.env['${envName}']}]\``],
env: {
[envName]: envValue,
}
}, [
// stderr
scanLines<void>(context, processInfo.shell.stderr!, errorScanner, stderrFormat(context.name)),
// stdout
scanLines<void>(context, processInfo.shell.stdout!, handle => {
errorScanner(handle);
if (handle.line.includes(`[${envValue}]`)) {
handle.resolve();
}
if (handle.line.includes('[undefined]')) {
handle.reject(new Error(handle.text));
}
}, stdoutFormat(context.name)),
]);
});
it('command with complex arguments', async () => {
const left = 'ABC';
const right = 'DEF';
await testCommandLine(
context, processInfo,
{
cwd, args: [nodePath, '-e', `{
const left = '${left}';
const right = '${right}';
console.log(\`[\${left}|\${right}]\`);
}`],
}, [
// stderr
scanLines<void>(context, processInfo.shell.stderr!, errorScanner, stderrFormat(context.name)),
// stdout
scanLines<void>(context, processInfo.shell.stdout!, handle => {
errorScanner(handle);
if (handle.line.includes(`[${left}|${right}]`)) {
handle.resolve();
}
}, stdoutFormat(context.name)),
]);
});
});
}
/**
* Allow `command` to fail and return undefined instead.
*/
function execShellCommand(command: string): string | undefined {
try {
// If trimmed output is an empty string, return `undefined` instead:
return execSync(command).toString().trim() || undefined;
} catch (error) {
console.error(command, error);
return undefined;
}
}
/**
* When executing `bash.exe` on Windows, the `C:`, `D:`, etc drives are mounted under `/mnt/<drive>/...`
*/
function convertWindowsPath(windowsPath: string): string {
return windowsPath
// Convert back-slashes to forward-slashes
.replace(/\\/g, '/')
// Convert drive-letter to usual mounting point in WSL
.replace(/^[A-Za-z]:\//, s => `/mnt/${s[0].toLowerCase()}/`);
}
/**
* Display trailing whitespace in a string, such as \r and \n.
*/
function displayWhitespaces(line: string): string {
return line
.replace(/\r?\n/, s => s.length === 2 ? '<\\r\\n>\r\n' : '<\\n>\n');
}
/**
* Actually run `prepareCommandLine`.
*/
async function testCommandLine(
context: TestCaseContext,
processInfo: TestProcessInfo,
options: CommandLineOptions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
firstOf: Array<Promise<any>>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<any> {
const commandLine = shellCommandBuilder.buildCommand(processInfo, options);
debug(`${chalk.bold(chalk.white(`${context.name} STDIN:`))} ${chalk.bgWhite(chalk.black(displayWhitespaces(commandLine)))}`);
processInfo.shell.stdin!.write(commandLine + context.submit);
return Promise.race(firstOf);
}
/**
* Creates a `(Test)ProcessInfo` object by spawning the specified shell.
*/
function createShell(
context: TestCaseContext,
shellExecutable: string,
shellArguments: string[] = []
): TestProcessInfo {
const shell = spawn(shellExecutable, shellArguments, spawnOptions);
debug(chalk.magenta(`${chalk.bold(`${context.name} SPAWN:`)} ${shellExecutable} ${shellArguments.join(' ')}`));
shell.on('close', (code, signal) => debug(chalk.magenta(
`${chalk.bold(`${context.name} CLOSE:`)} ${shellExecutable} code(${code}) signal(${signal})`
)));
return {
executable: shellExecutable,
arguments: [],
shell,
};
}
/**
* Fire `callback` once per new detected line.
*/
async function scanLines<T = void>(
context: TestCaseContext,
stream: Readable,
callback: (handle: ScanLineHandle<T>) => void,
debugFormat = (s: string) => s,
): Promise<T> {
return new Promise((resolve, reject) => {
let line = '';
let text = '';
stream.on('close', () => {
debug(debugFormat('<CLOSED>'));
});
// The `data` listener will be collected on 'close', which will happen
// once we kill the process.
stream.on('data', data => {
if (context.resolved) {
return;
}
const split = data.toString().split('\n');
while (!context.resolved && split.length > 1) {
line += split.shift()! + '\n';
text += line;
debug(debugFormat(displayWhitespaces(line)));
try {
callback({
resolve: (value: T) => {
if (!context.resolved) {
context.resolve();
resolve(value);
debug(chalk.bold(chalk.green(`${context.name} SCANLINES RESOLVED`)));
}
},
reject: (reason?: Error) => {
if (!context.resolved) {
context.resolve();
reject(reason);
debug(chalk.bold(chalk.red(`${context.name} SCANLINES REJECTED`)));
}
},
line,
text,
});
} catch (error) {
debug(chalk.bold(chalk.red(`${context.name} SCANLINES THROWED`)));
context.resolve();
reject(error);
break;
}
line = '';
}
line += split[0];
});
});
}
interface ScanLineHandle<T> {
/**
* Finish listening to new events with a return value.
*/
resolve: (value: T) => void
/**
* Finish listening to new events with an error.
*/
reject: (reason?: Error) => void
/**
* Currently parsed line.
*/
line: string
/**
* The whole output buffer, containing all lines.
*/
text: string
}
/**
* We need a test case context to help with catching listeners that timed-out,
* and synchronize multiple listeners so that when one resolves the test case,
* the others can be put in "sleep mode" until destruction.
*/
class TestCaseContext {
constructor(
/**
* A name associated with this context, to help with debugging.
*/
readonly name: string,
/**
* The characters to send in order to submit a command (mostly
* powershell is causing issues).
*/
public submit = '\n',
/**
* @internal Current state of the test case, if it is finished or not.
*/
public resolved = false
) { }
resolve(): void {
this.resolved = true;
}
finalize(): void {
if (!this.resolved) {
this.resolve();
debug(chalk.red(`${chalk.bold(`${this.name} CONTEXT:`)} context wasn't resolved when finalizing, resolving!`));
}
}
}

View File

@@ -0,0 +1,187 @@
// *****************************************************************************
// Copyright (C) 2020 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/* eslint-disable no-null/no-null */
import { injectable } from '@theia/core/shared/inversify';
import {
createShellCommandLine, BashQuotingFunctions, PowershellQuotingFunctions, CmdQuotingFunctions, ShellQuoting, ShellQuotedString, escapeForShell, ShellQuotingFunctions
} from '../common/shell-quoting';
export interface ProcessInfo {
executable: string
arguments: string[]
}
export interface CommandLineOptions {
cwd: string
args: string[]
env?: {
[key: string]: string | null
}
}
/**
* Create command lines ready to be sent to a shell's stdin for evaluation.
*/
@injectable()
export class ShellCommandBuilder {
/**
* Constructs a command line to run in a shell. The shell could be
* re-used/long-lived, this means we cannot spawn a new process with a nice
* and fresh environment, we need to encode environment modifications into
* the returned command.
*
* Inspired by VS Code implementation, see:
* https://github.com/microsoft/vscode/blob/f395cac4fff0721a8099126172c01411812bcb4a/src/vs/workbench/contrib/debug/node/terminals.ts#L79
*
* @param hostProcessInfo the host terminal process infos
* @param commandOptions program to execute in the host terminal
*/
buildCommand(hostProcessInfo: ProcessInfo | undefined, commandOptions: CommandLineOptions): string {
const host = hostProcessInfo && hostProcessInfo.executable;
const cwd = commandOptions.cwd;
const args = commandOptions.args.map(value => ({
value, quoting: ShellQuoting.Strong,
} as ShellQuotedString));
const env: Array<[string, string | null]> = [];
if (commandOptions.env) {
for (const key of Object.keys(commandOptions.env)) {
env.push([key, commandOptions.env[key]]);
}
}
if (host) {
if (/(bash|wsl)(.exe)?$/.test(host)) {
return this.buildForBash(args, cwd, env);
} else if (/(ps|pwsh|powershell)(.exe)?$/i.test(host)) {
return this.buildForPowershell(args, cwd, env);
} else if (/cmd(.exe)?$/i.test(host)) {
return this.buildForCmd(args, cwd, env);
}
}
return this.buildForDefault(args, cwd, env);
}
protected buildForBash(args: Array<string | ShellQuotedString>, cwd?: string, env?: Array<[string, string | null]>): string {
let command = '';
if (cwd) {
command += `cd ${BashQuotingFunctions.strong(cwd)} && `;
}
if (env?.length) {
command += 'env';
for (const [key, value] of env) {
if (value === null) {
command += ` -u ${BashQuotingFunctions.strong(key)}`;
} else {
command += ` ${BashQuotingFunctions.strong(`${key}=${value}`)}`;
}
}
command += ' ';
}
command += this.createShellCommandLine(args, BashQuotingFunctions);
return command;
}
protected buildForPowershell(args: Array<string | ShellQuotedString>, cwd?: string, env?: Array<[string, string | null]>): string {
let command = '';
if (cwd) {
command += `cd ${PowershellQuotingFunctions.strong(cwd)}; `;
}
if (env?.length) {
for (const [key, value] of env) {
// Powershell requires special quoting when dealing with
// environment variable names.
const quotedKey = key
.replace(/`/g, '````')
.replace(/\?/g, '``?');
if (value === null) {
command += `Remove-Item \${env:${quotedKey}}; `;
} else {
command += `\${env:${quotedKey}}=${PowershellQuotingFunctions.strong(value)}; `;
}
}
}
command += '& ' + this.createShellCommandLine(args, PowershellQuotingFunctions);
return command;
}
protected buildForCmd(args: Array<string | ShellQuotedString>, cwd?: string, env?: Array<[string, string | null]>): string {
let command = '';
if (cwd) {
command += `cd ${CmdQuotingFunctions.strong(cwd)} && `;
}
// Current quoting mechanism only works within a nested `cmd` call:
command += 'cmd /C "';
if (env?.length) {
for (const [key, value] of env) {
if (value === null) {
command += `set ${CmdQuotingFunctions.strong(key)}="" && `;
} else {
command += `set ${CmdQuotingFunctions.strong(`${key}=${value}`)} && `;
}
}
}
command += this.createShellCommandLine(args, CmdQuotingFunctions);
command += '"';
return command;
}
protected buildForDefault(args: Array<string | ShellQuotedString>, cwd?: string, env?: Array<[string, string | null]>): string {
return this.buildForBash(args, cwd, env);
}
/**
* This method will try to leave `arg[0]` unescaped if possible. The reason
* is that shells like `cmd` expect their own commands like `dir` to be
* unescaped.
*
* @returns empty string if `args` is empty, otherwise an escaped command.
*/
protected createShellCommandLine(args: (string | ShellQuotedString)[], quotingFunctions: ShellQuotingFunctions): string {
let command = '';
if (args.length > 0) {
const [exec, ...execArgs] = args;
// Some commands like `dir` should not be quoted for `cmd` to understand:
command += this.quoteExecutableIfNecessary(exec, quotingFunctions);
if (execArgs.length > 0) {
command += ' ' + createShellCommandLine(execArgs, quotingFunctions);
}
}
return command;
}
protected quoteExecutableIfNecessary(exec: string | ShellQuotedString, quotingFunctions: ShellQuotingFunctions): string {
return typeof exec === 'string' && !this.needsQuoting(exec) ? exec : escapeForShell(exec, quotingFunctions);
}
/**
* If this method returns `false` then we definitely need quoting.
*
* May return false positives.
*/
protected needsQuoting(arg: string): boolean {
return /\W/.test(arg);
}
}

View File

@@ -0,0 +1,176 @@
// *****************************************************************************
// Copyright (C) 2020 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 { expect } from 'chai';
import { escapeForShell, BashQuotingFunctions, ShellQuoting, CmdQuotingFunctions, PowershellQuotingFunctions } from './shell-quoting';
describe('Shell arguments escaping:', () => {
// Procedurally execute tests from this list of data.
const testData = {
bash: {
// https://www.gnu.org/software/bash/manual/html_node/Quoting.html
quotingFunctions: BashQuotingFunctions,
data: {
[ShellQuoting.Escape]: [
{ input: 'abc', expected: 'abc' },
{ input: 'ab c', expected: 'ab\\ c' },
{ input: 'ab"c', expected: 'ab\\"c' },
{ input: 'ab\'c', expected: 'ab\\\'c' },
{ input: 'ab\\ c\\', expected: 'ab\\\\\\ c\\\\' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: 'setTimeout\\(\\(\\)\\ =\\>\\ \\{\\ console.log\\(1,\\ \\"2\\\'3\\"\\)\\;\\ \\},\\ 100\\)',
},
],
[ShellQuoting.Strong]: [
{ input: 'abc', expected: '\'abc\'' },
{ input: 'ab c', expected: '\'ab c\'' },
{ input: 'ab"c', expected: '\'ab"c\'' },
{ input: 'ab\'c', expected: '\'ab\'"\'"\'c\'' },
{ input: 'ab\\ c\\', expected: '\'ab\\ c\\\'' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: '\'setTimeout(() => { console.log(1, "2\'"\'"\'3"); }, 100)\'',
},
],
[ShellQuoting.Weak]: [
{ input: 'abc', expected: '"abc"' },
{ input: 'ab c', expected: '"ab c"' },
{ input: 'ab"c', expected: '"ab\\"c"' },
{ input: 'ab\'c', expected: '"ab\'c"' },
{ input: 'ab\\ c\\', expected: '"ab\\ c\\\\"' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: '"setTimeout(() => { console.log(1, \\"2\'3\\"); }, 100)"',
},
]
},
},
cmd: {
// https://ss64.com/nt/syntax-esc.html
quotingFunctions: CmdQuotingFunctions,
data: {
[ShellQuoting.Escape]: [
{ input: 'abc', expected: 'abc' },
{ input: 'ab c', expected: 'ab" "c' },
{ input: 'ab"c', expected: 'ab\\"c' },
{ input: 'ab\'c', expected: 'ab\'c' },
{ input: 'ab^ c^', expected: 'ab^^" "c^^' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: 'setTimeout^(^(^)" "=^>" "{" "console.log^(1," "\\"2\'3\\"^);" "}," "100^)',
},
{
input: 'console.log("%PATH%")',
expected: 'console.log^(\\"^%PATH^%\\"^)',
},
],
[ShellQuoting.Strong]: [
{ input: 'abc', expected: '"abc"' },
{ input: 'ab c', expected: '"ab c"' },
{ input: 'ab"c', expected: '"ab\\"c"' },
{ input: 'ab\'c', expected: '"ab\'c"' },
{ input: 'ab^ c^', expected: '"ab^^ c^^"' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: '"setTimeout^(^(^) =^> { console.log^(1, \\"2\'3\\"^); }, 100^)"',
},
{
input: 'console.log("%PATH%")',
expected: '"console.log^(\\""%"PATH"%"\\"^)"',
},
],
[ShellQuoting.Weak]: [
{ input: 'abc', expected: '"abc"' },
{ input: 'ab c', expected: '"ab c"' },
{ input: 'ab"c', expected: '"ab\\"c"' },
{ input: 'ab\'c', expected: '"ab\'c"' },
{ input: 'ab^ c^', expected: '"ab^^ c^^"' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: '"setTimeout^(^(^) =^> { console.log^(1, \\"2\'3\\"^); }, 100^)"',
},
{
input: 'console.log("%PATH%")',
expected: '"console.log^(\\"%PATH%\\"^)"',
},
]
},
},
powershell: {
// https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-6
quotingFunctions: PowershellQuotingFunctions,
data: {
[ShellQuoting.Escape]: [
{ input: 'abc', expected: 'abc' },
{ input: 'ab c', expected: 'ab` c' },
{ input: 'ab"c', expected: 'ab`"c' },
{ input: 'ab\'c', expected: 'ab`\'c' },
{ input: 'ab` c`', expected: 'ab``` c``' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: 'setTimeout`(`(`)` =`>` `{` console.log`(1,` `"2`\'3`"`)`;` `},` 100`)',
},
],
[ShellQuoting.Strong]: [
{ input: 'abc', expected: '\'abc\'' },
{ input: 'ab c', expected: '\'ab c\'' },
{ input: 'ab"c', expected: '\'ab"c\'' },
{ input: 'ab\'c', expected: '\'ab\'\'c\'' },
{ input: 'ab` c`', expected: '\'ab` c`\'' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: '\'setTimeout(() => { console.log(1, "2\'\'3"); }, 100)\'',
},
],
[ShellQuoting.Weak]: [
{ input: 'abc', expected: '"abc"' },
{ input: 'ab c', expected: '"ab c"' },
{ input: 'ab"c', expected: '"ab`"c"' },
{ input: 'ab\'c', expected: '"ab\'c"' },
{ input: 'ab` c`', expected: '"ab` c``"' },
{
input: 'setTimeout(() => { console.log(1, "2\'3"); }, 100)',
expected: '"setTimeout(() => { console.log(1, `"2\'3`"); }, 100)"',
},
]
},
}
} as const;
// Iter through each runtime (bash/cmd/powershell):
for (const runtime of Object.keys(testData)) {
const testInfo = testData[runtime as keyof typeof testData];
// Get all quoting types (escape/strong/weak):
for (const quotingType of Object.keys(testInfo.data)) {
const testInput = testInfo.data[quotingType as keyof typeof testInfo['data']];
// Run the test for each input:
it(`${runtime}/${quotingType}`, () => {
for (const test of testInput) {
expect(escapeForShell({
quoting: quotingType as ShellQuoting,
value: test.input,
}, testInfo.quotingFunctions)).equal(test.expected);
}
});
}
}
});

View File

@@ -0,0 +1,236 @@
// *****************************************************************************
// Copyright (C) 2020 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
// *****************************************************************************
// #region vscode
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// See: https://github.com/microsoft/vscode/blob/9ebb7c43bc99fd6e1a295040674d1f8e5831b9be/src/vs/vscode.d.ts#L5326-L5370
/**
* Defines how an argument should be quoted if it contains
* spaces or unsupported characters.
*/
export const enum ShellQuoting {
/**
* Character escaping should be used. This for example
* uses \ on bash and ` on PowerShell.
*/
Escape = 'escape',
/**
* Strong string quoting should be used. This for example
* uses " for Windows cmd and ' for bash and PowerShell.
* Strong quoting treats arguments as literal strings.
* Under PowerShell echo 'The value is $(2 * 3)' will
* print `The value is $(2 * 3)`
*/
Strong = 'strong',
/**
* Weak string quoting should be used. This for example
* uses " for Windows cmd, bash and PowerShell. Weak quoting
* still performs some kind of evaluation inside the quoted
* string. Under PowerShell echo "The value is $(2 * 3)"
* will print `The value is 6`
*/
Weak = 'weak'
}
/**
* A string that will be quoted depending on the used shell.
*/
export interface ShellQuotedString {
/**
* The actual string value.
*/
value: string;
/**
* The quoting style to use.
*/
quoting: ShellQuoting;
}
// #endregion vscode
/**
* Functions that provide shell quoting capabilities.
*/
export interface ShellQuotingFunctions {
characters: {
/** Characters that require quotes, white space is always implied. */
needQuotes?: string
/** The character used to escape sequences. */
escape?: string
/** The character used to quote sequences, preventing variable expansion. */
strong?: string
/** The character used to quote sequences, allowing variable expansion. */
weak?: string
}
/**
* Should add escape-characters in front of forbidden characters.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
escape?(this: any, arg: string): string
/**
* Should quote the argument in such a way that variables CANNOT be expanded.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
strong?(this: any, arg: string): string;
/**
* Should quote the argument in such a way that variables CAN be expanded.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
weak?(this: any, arg: string): string;
}
/**
* Converts a list of args into an escaped shell command.
*
* There are two main use cases when handling command/arguments for a shell:
* 1. User already wrote the escaped commandline, then just use that.
* 2. User wants a specific process to be invoked with some arguments.
*
* The `createShellCommandLine` function is useful for the latter.
*
* @param args Standard list of spawn/exec arguments, first item is the command.
* @param quotingFunctions Collection of functions to process arguments.
*/
export function createShellCommandLine(args: Array<string | ShellQuotedString>, quotingFunctions?: ShellQuotingFunctions): string {
return args.map(arg => escapeForShell(arg, quotingFunctions)).join(' ');
}
/**
* Escape (or quote) a given input.
*
* @param arg Input to escape.
* @param quotingFunctions Collection of functions to process the given `arg`.
* @param quotingType Override the quoting type specified by the given `arg`.
*/
export function escapeForShell(arg: string | ShellQuotedString, quotingFunctions?: ShellQuotingFunctions, quotingType?: ShellQuoting): string {
let value: string;
let quoting: ShellQuoting | undefined = quotingType;
if (typeof arg === 'string') {
if (!quoting) {
return arg;
}
value = arg;
} else {
if (!quoting) {
quoting = arg.quoting;
}
value = arg.value;
}
if (quotingFunctions && typeof quotingFunctions[quoting] === 'function') {
return quotingFunctions[quoting]!(value);
}
return value;
}
export const BashQuotingFunctions: Required<ShellQuotingFunctions> = {
characters: {
needQuotes: '()',
escape: '\\',
strong: '\'',
weak: '"',
},
escape(arg): string {
return arg
.replace(/[\s\\|(){}<>$&;"']/g, '\\$&');
},
strong(arg): string {
// ('+) becomes ('"'+"')
return `'${arg
.replace(/'+/g, '\'"$&"\'')}'`;
},
weak(arg): string {
return `"${arg
// Escape escape-characters.
.replace(/\\"/g, '\\\\"')
// Escape user-specified double-quotes.
.replace(/"/g, '\\"')
// Escape trailing (\), we don't want the user to escape our last quote.
.replace(/\\$/g, '\\\\')}"`;
},
};
export const CmdQuotingFunctions: Required<ShellQuotingFunctions> = {
characters: {
weak: '"',
},
escape(arg): string {
return arg
// Escape forbidden characters (see: cmd /?).
.replace(/[%&<>()@^|]/g, '^$&')
// Some characters must be escaped using `\`.
.replace(/[\\"]/g, '\\$&')
// Double-quote whitespaces, else we cannot escape it.
.replace(/\s+/g, '"$&"');
},
strong(arg): string {
return this.weak(arg)
// Try to prevent variable expansion.
.replace(/%/g, '"%"');
},
weak(arg): string {
return `"${arg
// Escape double quotes.
.replace(/\\"/g, '\\\\"')
.replace(/"/g, '\\"')
// Escape forbidden characters (see: cmd /?)
.replace(/[&<>()@^|]/g, '^$&')
// Escape trailing backslash, we don't want the user to escape our last quote.
.replace(/\\$/g, '\\\\')
// Escape line returns
.replace(/\r?\n/g, '^$&')}"`;
},
};
export const PowershellQuotingFunctions: Required<ShellQuotingFunctions> = {
characters: {
needQuotes: '()',
escape: '`',
strong: '\'',
weak: '"',
},
escape(arg): string {
return arg.replace(/[`|{}()<>;"' ]/g, '`$&');
},
strong(arg): string {
// In powershell, one must write ('') for a single quote to be displayed
// within a single quoted string.
return `'${arg
.replace(/'/g, '\'\'')}'`;
},
weak(arg): string {
return `"${arg
// Escape escape-characters.
.replace(/`"/g, '``"')
// Escape user-specified backticks.
.replace(/"/g, '`"')
// Escape trailing (`), we don't want the user to escape our last quote.
.replace(/`$/g, '``')}"`;
},
};

View File

@@ -0,0 +1 @@
console.log('FORBIDDEN_OK')

View File

@@ -0,0 +1 @@
console.log('WHITESPACE_OK')

View File

@@ -0,0 +1,47 @@
// *****************************************************************************
// Copyright (C) 2019 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 stream = require('stream');
/**
* A Node stream like `/dev/null`.
*
* Writing goes to a black hole, reading returns `EOF`.
*/
export class DevNullStream extends stream.Duplex {
constructor(options: {
/**
* Makes this stream call `destroy` on itself, emitting the `close` event.
*/
autoDestroy?: boolean,
} = {}) {
super();
if (options.autoDestroy) {
this.destroy();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override _write(chunk: any, encoding: string, callback: (err?: Error) => void): void {
callback();
}
override _read(size: number): void {
// eslint-disable-next-line no-null/no-null
this.push(null);
}
}

View File

@@ -0,0 +1,22 @@
// *****************************************************************************
// 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
// *****************************************************************************
export * from './process-manager';
export * from './process';
export * from './raw-process';
export * from './terminal-process';
export * from './task-terminal-process';
export * from './multi-ring-buffer';

View File

@@ -0,0 +1,486 @@
// *****************************************************************************
// 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 * as chai from 'chai';
import { MultiRingBuffer } from './multi-ring-buffer';
const expect = chai.expect;
describe('MultiRingBuffer', function (): void {
it('expect buffer to be empty initialized', function (): void {
const size = 2;
const compareTo = Buffer.from('0000', 'hex');
const ringBuffer = new MultiRingBuffer({ size });
expect(ringBuffer['buffer'].equals(compareTo)).to.be.true;
});
it('expect enq and deq a string with unicode characters > 1 byte and no wrap around', function (): void {
const ringBufferSize = 15;
const ringBuffer = new MultiRingBuffer({ size: ringBufferSize });
const buffer = '\u00bd + \u00bc = \u00be';
const bufferByteLength = Buffer.byteLength(buffer, 'utf8');
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(bufferByteLength);
});
it('expect enq and deq a string with unicode characters > 1 byte and wrap around', function (): void {
const buffer = '\u00bd + \u00bc = \u00be';
const ringBufferSize = Buffer.byteLength(buffer[buffer.length - 1]);
const ringBuffer = new MultiRingBuffer({ size: ringBufferSize });
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(ringBufferSize);
const reader = ringBuffer.getReader();
const readBuffer = ringBuffer.deq(reader);
expect(ringBuffer).to.not.be.equal(undefined);
if (readBuffer !== undefined) {
expect(readBuffer).to.be.equal(buffer[buffer.length - 1].toString());
}
});
it('expect enq a string < ring buffer size ', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffer = 'test';
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(buffer.length);
expect(ringBuffer.empty()).to.be.equal(false);
const reader = ringBuffer.getReader();
expect(ringBuffer.sizeForReader(reader)).to.be.equal(buffer.length);
expect(ringBuffer.emptyForReader(reader)).to.be.equal(false);
});
it('expect deq a string < ring buffer size ', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5 });
const buffer = 'test';
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(4);
const reader = ringBuffer.getReader();
const readBuffer = ringBuffer.deq(reader);
expect(ringBuffer.size()).to.be.equal(4);
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
expect(readBuffer).to.equal(buffer);
});
it('expect deq a string > ring buffer size ', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffer = 'testabcd';
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(size);
const reader = ringBuffer.getReader();
const readBuffer = ringBuffer.deq(reader);
expect(ringBuffer.size()).to.be.equal(size);
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
expect(readBuffer).to.equal(buffer.substring(buffer.length - size));
});
it('expect enq deq enq deq a string > ring buffer size ', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffer = '12345678';
for (let i = 0; i < 2; i++) {
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(size);
const reader = ringBuffer.getReader();
const readBuffer = ringBuffer.deq(reader);
expect(ringBuffer.size()).to.be.equal(size);
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
expect(readBuffer).to.equal(buffer.substring(buffer.length - size));
}
});
it('expect enq a string == ring buffer size then one > ring buffer size and dequeue them ', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffers = ['12345', '12345678'];
for (const buffer of buffers) {
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(size);
}
const reader = ringBuffer.getReader();
let i = 0;
for (const _ of buffers) {
const readBuffer = ringBuffer.deq(reader);
if (i === 0) {
expect(readBuffer).to.equal(buffers[buffers.length - 1].substring(buffers[buffers.length - 1].length - size));
} else {
expect(readBuffer).to.equal(undefined);
}
i++;
}
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect enq a string == ring buffer size then one < ring buffer size and dequeue them ', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffers = ['12345', '123'];
for (const buffer of buffers) {
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(size);
}
const reader = ringBuffer.getReader();
let i = 0;
for (const _ of buffers) {
const readBuffer = ringBuffer.deq(reader);
if (i === 0) {
expect(readBuffer).to.equal('45123');
} else {
expect(readBuffer).to.equal(undefined);
}
i++;
}
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect enq a string == ring buffer size then one < ring buffer then one < buffer size and dequeue ', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffers = ['12345', '123', '678'];
for (const buffer of buffers) {
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(size);
}
const reader = ringBuffer.getReader();
let i = 0;
for (const _ of buffers) {
const readBuffer = ringBuffer.deq(reader);
if (i === 0) {
expect(readBuffer).to.equal('23678');
} else {
expect(readBuffer).to.equal(undefined);
}
i++;
}
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect enq buffer size then enq 1 to dequeue the right value', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffers = ['12345', '1'];
for (const buffer of buffers) {
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(size);
}
const reader = ringBuffer.getReader();
let i = 0;
for (const _ of buffers) {
const readBuffer = ringBuffer.deq(reader);
if (i === 0) {
expect(readBuffer).to.equal('23451');
} else {
expect(readBuffer).to.equal(undefined);
}
i++;
}
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect enq buffer size then enq 1 twice to dequeue the right value', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffers = ['12345', '1', '12345', '1'];
for (const buffer of buffers) {
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(size);
}
const reader = ringBuffer.getReader();
let i = 0;
for (const _ of buffers) {
const readBuffer = ringBuffer.deq(reader);
if (i === 0) {
expect(readBuffer).to.equal('23451');
} else {
expect(readBuffer).to.equal(undefined);
}
i++;
}
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect enq buffer size of various sizes dequeue the right value', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffers = ['12345', '123', '678', '12345', '1', '12345', '123', '12', '12', '1', '12', '123', '1234', '12345', '1', '12'];
for (const buffer of buffers) {
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(size);
}
const reader = ringBuffer.getReader();
let i = 0;
for (const _ of buffers) {
const readBuffer = ringBuffer.deq(reader);
if (i === 0) {
expect(readBuffer).to.equal('45112');
} else {
expect(readBuffer).to.equal(undefined);
}
i++;
}
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect enq buffer sizes < buffer size to dequeue normally', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffers = ['1', '1'];
for (const buffer of buffers) {
ringBuffer.enq(buffer);
}
expect(ringBuffer.size()).to.be.equal(2);
const reader = ringBuffer.getReader();
let i = 0;
for (const _ of buffers) {
const readBuffer = ringBuffer.deq(reader);
if (i === 0) {
expect(readBuffer).to.equal('11');
} else {
expect(readBuffer).to.equal(undefined);
}
i++;
}
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect enq buffer size of various sizes dequeue the right value', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffers = ['1', '1', '12', '12'];
for (const buffer of buffers) {
ringBuffer.enq(buffer);
}
expect(ringBuffer.size()).to.be.equal(size);
const reader = ringBuffer.getReader();
let i = 0;
for (const _ of buffers) {
const readBuffer = ringBuffer.deq(reader);
if (i === 0) {
expect(readBuffer).to.equal('11212');
} else {
expect(readBuffer).to.equal(undefined);
}
i++;
}
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect multiple enq and deq to deq the right values', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
ringBuffer.enq('12345');
expect(ringBuffer.size()).to.be.equal(size);
const reader = ringBuffer.getReader();
let readBuffer = ringBuffer.deq(reader);
expect(readBuffer).to.equal('12345');
ringBuffer.enq('123');
readBuffer = ringBuffer.deq(reader);
expect(readBuffer).to.equal('123');
ringBuffer.enq('12345');
readBuffer = ringBuffer.deq(reader);
expect(readBuffer).to.equal('12345');
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
});
it('expect data from stream on enq', async function (): Promise<void> {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffer = 'abc';
const astream = ringBuffer.getStream();
const p = new Promise<void>(resolve => {
astream.on('data', (chunk: string) => {
expect(chunk).to.be.equal(buffer);
resolve();
});
});
ringBuffer.enq(buffer);
await p;
});
it('expect data from stream when data is already ended', async function (): Promise<void> {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffer = 'abc';
ringBuffer.enq(buffer);
const astream = ringBuffer.getStream();
const p = new Promise<void>(resolve => {
astream.on('data', (chunk: string) => {
expect(chunk).to.be.equal(buffer);
resolve();
});
});
await p;
});
it('expect disposing of a stream to delete it from the ringbuffer', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const astream = ringBuffer.getStream();
astream.dispose();
expect(ringBuffer.streamsSize()).to.be.equal(0);
expect(ringBuffer.readersSize()).to.be.equal(0);
});
it('expect disposing of a reader to delete it from the ringbuffer', function (): void {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const reader = ringBuffer.getReader();
ringBuffer.closeReader(reader);
expect(ringBuffer.readersSize()).to.be.equal(0);
});
it('expect enq a string in utf8 and get it in hex', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5 });
const buffer = 'test';
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(4);
const reader = ringBuffer.getReader();
const readBuffer = ringBuffer.deq(reader, -1, 'hex');
expect(readBuffer).to.equal('74657374');
});
it('expect enq a string in hex and get it in utf8', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5 });
const buffer = '74657374';
ringBuffer.enq(buffer, 'hex');
expect(ringBuffer.size()).to.be.equal(4);
const reader = ringBuffer.getReader();
const readBuffer = ringBuffer.deq(reader);
expect(readBuffer).to.equal('test');
});
it('expect data from stream in hex when enq in uf8', async function (): Promise<void> {
const size = 5;
const ringBuffer = new MultiRingBuffer({ size });
const buffer = 'test';
ringBuffer.enq(buffer);
const astream = ringBuffer.getStream('hex');
const p = new Promise<void>(resolve => {
astream.on('data', (chunk: string) => {
expect(chunk).to.be.equal('74657374');
resolve();
});
});
await p;
});
it('expect deq a string < ring buffer size with the internal encoding in hex ', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5, encoding: 'hex' });
const buffer = 'test';
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(4);
const reader = ringBuffer.getReader();
const readBuffer = ringBuffer.deq(reader);
expect(ringBuffer.size()).to.be.equal(4);
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
expect(readBuffer).to.equal(buffer);
});
it('expect the ringbuffer to be empty if we enq an empty string', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5 });
const buffer = '';
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(0);
expect(ringBuffer.empty()).to.be.equal(true);
});
it('expect an invalid reader count to be zero', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5 });
expect(ringBuffer.sizeForReader(1)).to.be.equal(0);
});
it('expect an invalid reader to be empty', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5 });
expect(ringBuffer.emptyForReader(1)).to.be.equal(true);
});
it('expect partially deq a string < ring buffer size ', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5 });
const buffer = 'test';
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(4);
const reader = ringBuffer.getReader();
let readBuffer = ringBuffer.deq(reader, 2);
expect(ringBuffer.size()).to.be.equal(4);
expect(ringBuffer.sizeForReader(reader)).to.be.equal(2);
expect(readBuffer).to.equal('te');
readBuffer = ringBuffer.deq(reader, 2);
expect(ringBuffer.size()).to.be.equal(4);
expect(ringBuffer.sizeForReader(reader)).to.be.equal(0);
expect(readBuffer).to.equal('st');
});
it('expect partially deq a string < ring buffer size then enq and deq again ', function (): void {
const ringBuffer = new MultiRingBuffer({ size: 5 });
const buffer = 'test';
const secondBuffer = 'abcd';
ringBuffer.enq(buffer);
expect(ringBuffer.size()).to.be.equal(4);
const reader = ringBuffer.getReader();
let readBuffer = ringBuffer.deq(reader, 2);
expect(ringBuffer.size()).to.be.equal(4);
expect(ringBuffer.sizeForReader(reader)).to.be.equal(2);
expect(readBuffer).to.equal('te');
ringBuffer.enq(secondBuffer);
readBuffer = ringBuffer.deq(reader, 4);
expect(ringBuffer.size()).to.be.equal(5);
expect(readBuffer).to.equal('tabc');
expect(ringBuffer.sizeForReader(reader)).to.be.equal(1);
});
});

View File

@@ -0,0 +1,348 @@
// *****************************************************************************
// 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 * as stream from 'stream';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Disposable } from '@theia/core/lib/common';
/**
* The MultiRingBuffer is a ring buffer implementation that allows
* multiple independent readers.
*
* These readers are created using the getReader or getStream functions
* to create a reader that can be read using deq() or one that is a readable stream.
*/
export class MultiRingBufferReadableStream extends stream.Readable implements Disposable {
protected more = false;
protected disposed = false;
constructor(protected readonly ringBuffer: MultiRingBuffer,
protected readonly reader: number,
protected readonly encoding: BufferEncoding = 'utf8'
) {
super();
this.setEncoding(encoding);
}
override _read(size: number): void {
this.more = true;
this.deq(size);
}
override _destroy(err: Error | null, callback: (err: Error | null) => void): void {
this.ringBuffer.closeStream(this);
this.ringBuffer.closeReader(this.reader);
this.disposed = true;
this.removeAllListeners();
callback(err);
}
onData(): void {
if (this.more === true) {
this.deq(-1);
}
}
deq(size: number): void {
if (this.disposed === true) {
return;
}
let buffer = undefined;
do {
buffer = this.ringBuffer.deq(this.reader, size, this.encoding);
if (buffer !== undefined) {
this.more = this.push(buffer, this.encoding);
}
}
while (buffer !== undefined && this.more === true && this.disposed === false);
}
dispose(): void {
this.destroy();
}
}
export const MultiRingBufferOptions = Symbol('MultiRingBufferOptions');
export interface MultiRingBufferOptions {
readonly size: number,
readonly encoding?: BufferEncoding,
}
export interface WrappedPosition { newPos: number, wrap: boolean }
@injectable()
export class MultiRingBuffer implements Disposable {
protected readonly buffer: Buffer;
protected head: number = -1;
protected tail: number = -1;
protected readonly maxSize: number;
protected readonly encoding: BufferEncoding;
/* <id, position> */
protected readonly readers: Map<number, number>;
/* <stream : id> */
protected readonly streams: Map<MultiRingBufferReadableStream, number>;
protected readerId = 0;
constructor(
@inject(MultiRingBufferOptions) protected readonly options: MultiRingBufferOptions
) {
this.maxSize = options.size;
if (options.encoding !== undefined) {
this.encoding = options.encoding;
} else {
this.encoding = 'utf8';
}
this.buffer = Buffer.alloc(this.maxSize);
this.readers = new Map();
this.streams = new Map();
}
enq(str: string, encoding = 'utf8'): void {
let buffer: Buffer = Buffer.from(str, encoding as BufferEncoding);
// Take the last elements of string if it's too big, drop the rest
if (buffer.length > this.maxSize) {
buffer = buffer.slice(buffer.length - this.maxSize);
}
if (buffer.length === 0) {
return;
}
// empty
if (this.head === -1 && this.tail === -1) {
this.head = 0;
this.tail = 0;
buffer.copy(this.buffer, this.head, 0, buffer.length);
this.head = buffer.length - 1;
this.onData(0);
return;
}
const startHead = this.inc(this.head, 1).newPos;
if (this.inc(startHead, buffer.length).wrap === true) {
buffer.copy(this.buffer, startHead, 0, this.maxSize - startHead);
buffer.copy(this.buffer, 0, this.maxSize - startHead);
} else {
buffer.copy(this.buffer, startHead);
}
this.incTails(buffer.length);
this.head = this.inc(this.head, buffer.length).newPos;
this.onData(startHead);
}
getReader(): number {
this.readers.set(this.readerId, this.tail);
return this.readerId++;
}
closeReader(id: number): void {
this.readers.delete(id);
}
getStream(encoding?: BufferEncoding): MultiRingBufferReadableStream {
const reader = this.getReader();
const readableStream = new MultiRingBufferReadableStream(this, reader, encoding);
this.streams.set(readableStream, reader);
return readableStream;
}
closeStream(readableStream: MultiRingBufferReadableStream): void {
this.streams.delete(<MultiRingBufferReadableStream>readableStream);
}
protected onData(start: number): void {
/* Any stream that has read everything already
* Should go back to the last buffer in start offset */
for (const [id, pos] of this.readers) {
if (pos === -1) {
this.readers.set(id, start);
}
}
/* Notify the streams there's new data. */
for (const [readableStream] of this.streams) {
readableStream.onData();
}
}
deq(id: number, size = -1, encoding: BufferEncoding = 'utf8'): string | undefined {
const pos = this.readers.get(id);
if (pos === undefined || pos === -1) {
return undefined;
}
if (size === 0) {
return undefined;
}
let buffer = '';
const maxDeqSize = this.sizeForReader(id);
const wrapped = this.isWrapped(pos, this.head);
let deqSize;
if (size === -1) {
deqSize = maxDeqSize;
} else {
deqSize = Math.min(size, maxDeqSize);
}
if (wrapped === false) { // no wrap
buffer = this.buffer.toString(encoding, pos, pos + deqSize);
} else { // wrap
buffer = buffer.concat(this.buffer.toString(encoding, pos, this.maxSize),
this.buffer.toString(encoding, 0, deqSize - (this.maxSize - pos)));
}
const lastIndex = this.inc(pos, deqSize - 1).newPos;
// everything is read
if (lastIndex === this.head) {
this.readers.set(id, -1);
} else {
this.readers.set(id, this.inc(pos, deqSize).newPos);
}
return buffer;
}
sizeForReader(id: number): number {
const pos = this.readers.get(id);
if (pos === undefined) {
return 0;
}
return this.sizeFrom(pos, this.head, this.isWrapped(pos, this.head));
}
size(): number {
return this.sizeFrom(this.tail, this.head, this.isWrapped(this.tail, this.head));
}
protected isWrapped(from: number, to: number): boolean {
if (to < from) {
return true;
} else {
return false;
}
}
protected sizeFrom(from: number, to: number, wrap: boolean): number {
if (from === -1 || to === -1) {
return 0;
} else {
if (wrap === false) {
return to - from + 1;
} else {
return to + 1 + this.maxSize - from;
}
}
}
emptyForReader(id: number): boolean {
const pos = this.readers.get(id);
if (pos === undefined || pos === -1) {
return true;
} else {
return false;
}
}
empty(): boolean {
if (this.head === -1 && this.tail === -1) {
return true;
} else {
return false;
}
}
streamsSize(): number {
return this.streams.size;
}
readersSize(): number {
return this.readers.size;
}
/**
* Dispose all the attached readers/streams.
*/
dispose(): void {
for (const readableStream of this.streams.keys()) {
readableStream.dispose();
}
}
/* Position should be incremented if it goes pass end. */
protected shouldIncPos(pos: number, end: number, size: number): boolean {
const { newPos: newHead, wrap } = this.inc(end, size);
/* Tail Head */
if (this.isWrapped(pos, end) === false) {
// Head needs to wrap to push the tail
if (wrap === true && newHead >= pos) {
return true;
}
} else { /* Head Tail */
// If we wrap head is pushing tail, or if it goes over pos
if (wrap === true || newHead >= pos) {
return true;
}
}
return false;
}
protected incTailSize(pos: number, head: number, size: number): WrappedPosition {
const { newPos: newHead } = this.inc(head, size);
/* New tail is 1 past newHead. */
return this.inc(newHead, 1);
}
protected incTail(pos: number, size: number): WrappedPosition {
if (this.shouldIncPos(pos, this.head, size) === false) {
return { newPos: pos, wrap: false };
}
return this.incTailSize(pos, this.head, size);
}
/* Increment the main tail and all the reader positions. */
protected incTails(size: number): void {
this.tail = this.incTail(this.tail, size).newPos;
for (const [id, pos] of this.readers) {
if (pos !== -1) {
if (this.shouldIncPos(pos, this.tail, size) === true) {
this.readers.set(id, this.tail);
}
}
}
}
protected inc(pos: number, size: number): WrappedPosition {
if (size === 0) {
return { newPos: pos, wrap: false };
}
const newPos = (pos + size) % this.maxSize;
const wrap = newPos <= pos;
return { newPos, wrap };
}
}

View File

@@ -0,0 +1,62 @@
// *****************************************************************************
// 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 { ContainerModule, Container } from '@theia/core/shared/inversify';
import { RawProcess, RawProcessOptions, RawProcessFactory, RawForkOptions } from './raw-process';
import { TerminalProcess, TerminalProcessOptions, TerminalProcessFactory } from './terminal-process';
import { TaskTerminalProcess, TaskTerminalProcessFactory } from './task-terminal-process';
import { BackendApplicationContribution } from '@theia/core/lib/node';
import { ProcessManager } from './process-manager';
import { MultiRingBuffer, MultiRingBufferOptions } from './multi-ring-buffer';
export default new ContainerModule(bind => {
bind(RawProcess).toSelf().inTransientScope();
bind(ProcessManager).toSelf().inSingletonScope();
bind(BackendApplicationContribution).toService(ProcessManager);
bind(RawProcessFactory).toFactory(ctx =>
(options: RawProcessOptions | RawForkOptions) => {
const child = new Container({ defaultScope: 'Singleton' });
child.parent = ctx.container;
child.bind(RawProcessOptions).toConstantValue(options);
return child.get(RawProcess);
}
);
bind(TerminalProcess).toSelf().inTransientScope();
bind(TerminalProcessFactory).toFactory(ctx =>
(options: TerminalProcessOptions) => {
const child = new Container({ defaultScope: 'Singleton' });
child.parent = ctx.container;
child.bind(TerminalProcessOptions).toConstantValue(options);
return child.get(TerminalProcess);
}
);
bind(TaskTerminalProcess).toSelf().inTransientScope();
bind(TaskTerminalProcessFactory).toFactory(ctx =>
(options: TerminalProcessOptions) => {
const child = ctx.container.createChild();
child.bind(TerminalProcessOptions).toConstantValue(options);
return child.get(TaskTerminalProcess);
}
);
bind(MultiRingBuffer).toSelf().inTransientScope();
/* 1MB size, TODO should be a user preference. */
bind(MultiRingBufferOptions).toConstantValue({ size: 1048576 });
});

View File

@@ -0,0 +1,107 @@
// *****************************************************************************
// 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 { Emitter, Event } from '@theia/core/lib/common';
import { ILogger } from '@theia/core/lib/common/logger';
import { BackendApplicationContribution } from '@theia/core/lib/node';
import { ManagedProcessManager, ManagedProcess } from '../common/process-manager-types';
import { MAX_SAFE_INTEGER } from '@theia/core/lib/common/numbers';
import { Process } from './process';
@injectable()
export class ProcessManager implements ManagedProcessManager, BackendApplicationContribution {
protected readonly processes: Map<number, Process>;
protected readonly deleteEmitter: Emitter<number>;
constructor(
@inject(ILogger) @named('process') protected logger: ILogger
) {
this.processes = new Map();
this.deleteEmitter = new Emitter<number>();
}
/**
* Registers the given process into this manager. Both on process termination and on error,
* the process will be automatically removed from the manager.
*
* @param process the process to register.
*/
register(process: Process): number {
const id = this.generateId();
this.processes.set(id, process);
process.onError(() => this.unregister(process));
return id;
}
/**
* @returns a random id for a process that is not assigned to a different process yet.
*/
protected generateId(): number {
let id = undefined;
while (id === undefined) {
const candidate = Math.floor(Math.random() * MAX_SAFE_INTEGER);
if (!this.processes.has(candidate)) {
id = candidate;
}
}
return id;
}
/**
* Removes the process from this process manager. Invoking this method, will make
* sure that the process is terminated before eliminating it from the manager's cache.
*
* @param process the process to unregister from this process manager.
*/
unregister(process: ManagedProcess): void {
const processLabel = this.getProcessLabel(process);
this.logger.debug(`Unregistering process. ${processLabel}`);
if (!process.killed) {
this.logger.debug(`Ensuring process termination. ${processLabel}`);
process.kill();
}
if (this.processes.delete(process.id)) {
this.deleteEmitter.fire(process.id);
this.logger.debug(`The process was successfully unregistered. ${processLabel}`);
} else {
this.logger.warn(`This process was not registered or was already unregistered. ${processLabel}`);
}
}
get(id: number): ManagedProcess | undefined {
return this.processes.get(id);
}
get onDelete(): Event<number> {
return this.deleteEmitter.event;
}
onStop(): void {
for (const process of this.processes.values()) {
try {
this.unregister(process);
} catch (error) {
this.logger.error(`Error occurred when unregistering process. ${this.getProcessLabel(process)}`, error);
}
}
}
private getProcessLabel(process: ManagedProcess): string {
return `[ID: ${process.id}]`;
}
}

View File

@@ -0,0 +1,207 @@
// *****************************************************************************
// 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, unmanaged } from '@theia/core/shared/inversify';
import { ILogger, Emitter, Event, isObject } from '@theia/core/lib/common';
import { FileUri } from '@theia/core/lib/node';
import { isOSX, isWindows } from '@theia/core';
import { Readable, Writable } from 'stream';
import { exec } from 'child_process';
import * as fs from 'fs';
import { IProcessStartEvent, IProcessExitEvent, ProcessErrorEvent, ProcessType, ManagedProcessManager, ManagedProcess } from '../common/process-manager-types';
export { IProcessStartEvent, IProcessExitEvent, ProcessErrorEvent, ProcessType };
/**
* Options to spawn a new process (`spawn`).
*
* For more information please refer to the spawn function of Node's
* child_process module:
*
* https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options
*/
export interface ProcessOptions {
readonly command: string,
args?: string[],
options?: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any
}
}
/**
* Options to fork a new process using the current Node interpreter (`fork`).
*
* For more information please refer to the fork function of Node's
* child_process module:
*
* https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options
*/
export interface ForkOptions {
readonly modulePath: string,
args?: string[],
options?: object
}
@injectable()
export abstract class Process implements ManagedProcess {
readonly id: number;
protected readonly startEmitter: Emitter<IProcessStartEvent> = new Emitter<IProcessStartEvent>();
protected readonly exitEmitter: Emitter<IProcessExitEvent> = new Emitter<IProcessExitEvent>();
protected readonly closeEmitter: Emitter<IProcessExitEvent> = new Emitter<IProcessExitEvent>();
protected readonly errorEmitter: Emitter<ProcessErrorEvent> = new Emitter<ProcessErrorEvent>();
protected _killed = false;
/**
* The OS process id.
*/
abstract readonly pid: number;
/**
* The stdout stream.
*/
abstract readonly outputStream: Readable;
/**
* The stderr stream.
*/
abstract readonly errorStream: Readable;
/**
* The stdin stream.
*/
abstract readonly inputStream: Writable;
constructor(
@unmanaged() protected readonly processManager: ManagedProcessManager,
@unmanaged() protected readonly logger: ILogger,
@unmanaged() protected readonly type: ProcessType,
@unmanaged() protected readonly options: ProcessOptions | ForkOptions
) {
this.id = this.processManager.register(this);
this.initialCwd = options && options.options && 'cwd' in options.options && options.options['cwd'].toString() || __dirname;
}
abstract kill(signal?: string): void;
get killed(): boolean {
return this._killed;
}
get onStart(): Event<IProcessStartEvent> {
return this.startEmitter.event;
}
/**
* Wait for the process to exit, streams can still emit data.
*/
get onExit(): Event<IProcessExitEvent> {
return this.exitEmitter.event;
}
get onError(): Event<ProcessErrorEvent> {
return this.errorEmitter.event;
}
/**
* Waits for both process exit and for all the streams to be closed.
*/
get onClose(): Event<IProcessExitEvent> {
return this.closeEmitter.event;
}
protected emitOnStarted(): void {
this.startEmitter.fire({});
}
/**
* Emit the onExit event for this process. Only one of code and signal
* should be defined.
*/
protected emitOnExit(code?: number, signal?: string): void {
const exitEvent: IProcessExitEvent = { code, signal };
this.handleOnExit(exitEvent);
this.exitEmitter.fire(exitEvent);
}
/**
* Emit the onClose event for this process. Only one of code and signal
* should be defined.
*/
protected emitOnClose(code?: number, signal?: string): void {
this.closeEmitter.fire({ code, signal });
}
protected handleOnExit(event: IProcessExitEvent): void {
this._killed = true;
const signalSuffix = event.signal ? `, signal: ${event.signal}` : '';
const executable = this.isForkOptions(this.options) ? this.options.modulePath : this.options.command;
this.logger.debug(`Process ${this.pid} has exited with code ${event.code}${signalSuffix}.`,
executable, this.options.args);
}
protected emitOnError(err: ProcessErrorEvent): void {
this.handleOnError(err);
this.errorEmitter.fire(err);
}
protected async emitOnErrorAsync(error: ProcessErrorEvent): Promise<void> {
process.nextTick(this.emitOnError.bind(this), error);
}
protected handleOnError(error: ProcessErrorEvent): void {
this._killed = true;
this.logger.error(error);
}
protected isForkOptions(options: unknown): options is ForkOptions {
return isObject<ForkOptions>(options) && !!options.modulePath;
}
protected readonly initialCwd: string;
/**
* @returns the current working directory as a URI (usually file:// URI)
*/
public getCwdURI(): Promise<string> {
if (isOSX) {
return new Promise<string>(resolve => {
exec('lsof -OPln -p ' + this.pid + ' | grep cwd', (error, stdout, stderr) => {
if (stdout !== '') {
resolve(FileUri.create(stdout.substring(stdout.indexOf('/'), stdout.length - 1)).toString());
} else {
resolve(FileUri.create(this.initialCwd).toString());
}
});
});
} else if (!isWindows) {
return new Promise<string>(resolve => {
fs.readlink('/proc/' + this.pid + '/cwd', (err, linkedstr) => {
if (err || !linkedstr) {
resolve(FileUri.create(this.initialCwd).toString());
} else {
resolve(FileUri.create(linkedstr).toString());
}
});
});
} else {
return new Promise<string>(resolve => {
resolve(FileUri.create(this.initialCwd).toString());
});
}
}
}

View File

@@ -0,0 +1,56 @@
// *****************************************************************************
// Copyright (C) 2020 Alibaba Inc. 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 { IPty } from 'node-pty';
import { Event } from '@theia/core';
export class PseudoPty implements IPty {
readonly pid: number = -1;
readonly cols: number = -1;
readonly rows: number = -1;
readonly process: string = '';
handleFlowControl = false;
readonly onData: Event<string> = Event.None;
readonly onExit: Event<{ exitCode: number, signal?: number }> = Event.None;
on(event: string, listener: (data: string) => void): void;
on(event: string, listener: (exitCode: number, signal?: number) => void): void;
on(event: string, listener: (error?: string) => void): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on(event: string, listener: (...args: any[]) => void): void { }
resize(columns: number, rows: number): void { }
write(data: string): void { }
kill(signal?: string): void { }
pause(): void { }
resume(): void { }
clear(): void { }
}

View File

@@ -0,0 +1,197 @@
// *****************************************************************************
// 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 * as chai from 'chai';
import * as process from 'process';
import * as stream from 'stream';
import { createProcessTestContainer } from './test/process-test-container';
import { RawProcessFactory } from './raw-process';
import * as temp from 'temp';
import * as fs from 'fs';
import * as path from 'path';
import { IProcessStartEvent, ProcessErrorEvent } from './process';
/* Allow to create temporary files, but delete them when we're done. */
const track = temp.track();
/**
* Globals
*/
const expect = chai.expect;
const FORK_TEST_FILE = path.join(__dirname, '../../src/node/test/process-fork-test.js');
describe('RawProcess', function (): void {
this.timeout(20_000);
let rawProcessFactory: RawProcessFactory;
beforeEach(() => {
rawProcessFactory = createProcessTestContainer().get<RawProcessFactory>(RawProcessFactory);
});
after(() => {
track.cleanupSync();
});
it('test error on non-existent path', async function (): Promise<void> {
const error = await new Promise<ProcessErrorEvent>((resolve, reject) => {
const proc = rawProcessFactory({ command: '/non-existent' });
proc.onStart(reject);
proc.onError(resolve);
proc.onExit(reject);
});
expect(error.code).eq('ENOENT');
});
it('test error on non-executable path', async function (): Promise<void> {
// Create a non-executable file.
const f = track.openSync('non-executable');
fs.writeSync(f.fd, 'echo bob');
// Make really sure it's non-executable.
let mode = fs.fstatSync(f.fd).mode;
mode &= ~fs.constants.S_IXUSR;
mode &= ~fs.constants.S_IXGRP;
mode &= ~fs.constants.S_IXOTH;
fs.fchmodSync(f.fd, mode);
fs.closeSync(f.fd);
const error = await new Promise<ProcessErrorEvent>((resolve, reject) => {
const proc = rawProcessFactory({ command: f.path });
proc.onStart(reject);
proc.onError(resolve);
proc.onExit(reject);
});
// do not check the exact error code as this seems to change between nodejs version
expect(error).to.exist;
});
it('test start event', function (): Promise<IProcessStartEvent> {
return new Promise<IProcessStartEvent>(async (resolve, reject) => {
const args = ['-e', 'process.exit(3)'];
const rawProcess = rawProcessFactory({ command: process.execPath, 'args': args });
rawProcess.onStart(resolve);
rawProcess.onError(reject);
rawProcess.onExit(reject);
});
});
it('test exit', async function (): Promise<void> {
const args = ['--version'];
const rawProcess = rawProcessFactory({ command: process.execPath, 'args': args });
const p = new Promise<number>((resolve, reject) => {
rawProcess.onError(reject);
rawProcess.onExit(event => {
if (event.code === undefined) {
reject(new Error('event.code is undefined'));
} else {
resolve(event.code);
}
});
});
const exitCode = await p;
expect(exitCode).equal(0);
});
it('test pipe stdout stream', async function (): Promise<void> {
const output = await new Promise<string>(async (resolve, reject) => {
const args = ['-e', 'console.log("text to stdout")'];
const outStream = new stream.PassThrough();
const rawProcess = rawProcessFactory({ command: process.execPath, 'args': args });
rawProcess.onError(reject);
rawProcess.outputStream.pipe(outStream);
let buf = '';
outStream.on('data', data => {
buf += data.toString();
});
outStream.on('end', () => {
resolve(buf.trim());
});
});
expect(output).to.be.equal('text to stdout');
});
it('test pipe stderr stream', async function (): Promise<void> {
const output = await new Promise<string>(async (resolve, reject) => {
const args = ['-e', 'console.error("text to stderr")'];
const outStream = new stream.PassThrough();
const rawProcess = rawProcessFactory({ command: process.execPath, 'args': args });
rawProcess.onError(reject);
rawProcess.errorStream.pipe(outStream);
let buf = '';
outStream.on('data', data => {
buf += data.toString();
});
outStream.on('end', () => {
resolve(buf.trim());
});
});
expect(output).to.be.equal('text to stderr');
});
it('test forked pipe stdout stream', async function (): Promise<void> {
const args = ['version'];
const rawProcess = rawProcessFactory({ modulePath: FORK_TEST_FILE, args, options: { stdio: 'pipe' } });
const outStream = new stream.PassThrough();
const p = new Promise<string>((resolve, reject) => {
let version = '';
outStream.on('data', data => {
version += data.toString();
});
outStream.on('end', () => {
resolve(version.trim());
});
});
rawProcess.outputStream.pipe(outStream);
expect(await p).to.be.equal('1.0.0');
});
it('test forked pipe stderr stream', async function (): Promise<void> {
const rawProcess = rawProcessFactory({ modulePath: FORK_TEST_FILE, args: [], options: { stdio: 'pipe' } });
const outStream = new stream.PassThrough();
const p = new Promise<string>((resolve, reject) => {
let version = '';
outStream.on('data', data => {
version += data.toString();
});
outStream.on('end', () => {
resolve(version.trim());
});
});
rawProcess.errorStream.pipe(outStream);
expect(await p).to.have.string('Error');
});
});

View File

@@ -0,0 +1,156 @@
// *****************************************************************************
// 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 { ProcessManager } from './process-manager';
import { ILogger } from '@theia/core/lib/common';
import { Process, ProcessType, ProcessOptions, ForkOptions, ProcessErrorEvent } from './process';
import { ChildProcess, spawn, fork } from 'child_process';
import * as stream from 'stream';
// The class was here before, exporting to not break anything.
export { DevNullStream } from './dev-null-stream';
import { DevNullStream } from './dev-null-stream';
export const RawProcessOptions = Symbol('RawProcessOptions');
/**
* Options to spawn a new process (`spawn`).
*
* For more information please refer to the spawn function of Node's
* child_process module:
*
* https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options
*/
export interface RawProcessOptions extends ProcessOptions {
}
/**
* Options to fork a new process using the current Node interpreter (`fork`).
*
* For more information please refer to the fork function of Node's
* `child_process` module:
*
* https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options
*/
export interface RawForkOptions extends ForkOptions {
}
export const RawProcessFactory = Symbol('RawProcessFactory');
export interface RawProcessFactory {
(options: RawProcessOptions | RawForkOptions): RawProcess;
}
@injectable()
export class RawProcess extends Process {
/**
* If the process fails to launch, it will be undefined.
*/
readonly process: ChildProcess | undefined;
readonly outputStream: stream.Readable;
readonly errorStream: stream.Readable;
readonly inputStream: stream.Writable;
constructor( // eslint-disable-next-line @typescript-eslint/indent
@inject(RawProcessOptions) options: RawProcessOptions | RawForkOptions,
@inject(ProcessManager) processManager: ProcessManager,
@inject(ILogger) @named('process') logger: ILogger
) {
super(processManager, logger, ProcessType.Raw, options);
const executable = this.isForkOptions(options) ? options.modulePath : options.command;
this.logger.debug(`Starting raw process: ${executable},`
+ ` with args: ${options.args ? options.args.join(' ') : ''}, `
+ ` with options: ${JSON.stringify(options.options)}`);
// About catching errors: spawn will sometimes throw directly
// (EACCES on Linux), sometimes return a Process object with the pid
// property undefined (ENOENT on Linux) and then emit an 'error' event.
// For now, we try to normalize that into always emitting an 'error'
// event.
try {
if (this.isForkOptions(options)) {
this.process = fork(
options.modulePath,
options.args || [],
options.options || {});
} else {
this.process = spawn(
options.command,
options.args || [],
options.options || {});
}
this.process.on('error', (error: NodeJS.ErrnoException) => {
error.code = error.code || 'Unknown error';
this.emitOnError(error as ProcessErrorEvent);
});
// When no stdio option is passed, it is null by default.
this.outputStream = this.process.stdout || new DevNullStream({ autoDestroy: true });
this.inputStream = this.process.stdin || new DevNullStream({ autoDestroy: true });
this.errorStream = this.process.stderr || new DevNullStream({ autoDestroy: true });
this.process.on('exit', (exitCode, signal) => {
// node's child_process exit sets the unused parameter to null,
// but we want it to be undefined instead.
this.emitOnExit(
typeof exitCode === 'number' ? exitCode : undefined,
typeof signal === 'string' ? signal : undefined,
);
this.processManager.unregister(this);
});
this.process.on('close', (exitCode, signal) => {
// node's child_process exit sets the unused parameter to null,
// but we want it to be undefined instead.
this.emitOnClose(
typeof exitCode === 'number' ? exitCode : undefined,
typeof signal === 'string' ? signal : undefined,
);
});
if (this.process.pid !== undefined) {
process.nextTick(this.emitOnStarted.bind(this));
}
} catch (error) {
/* When an error is thrown, set up some fake streams, so the client
code doesn't break because these field are undefined. */
this.outputStream = new DevNullStream({ autoDestroy: true });
this.inputStream = new DevNullStream({ autoDestroy: true });
this.errorStream = new DevNullStream({ autoDestroy: true });
/* Call the client error handler, but first give them a chance to register it. */
this.emitOnErrorAsync(error);
}
}
get pid(): number {
if (!this.process || !this.process.pid) {
throw new Error('process did not start correctly');
}
return this.process.pid;
}
kill(signal?: string): void {
if (this.process && this.killed === false) {
this.process.kill(signal as NodeJS.Signals);
}
}
}

View File

@@ -0,0 +1,21 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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
// *****************************************************************************
declare module 'string-argv' {
function stringArgv(...args: string[]): string[];
export = stringArgv;
}

View File

@@ -0,0 +1,41 @@
// *****************************************************************************
// Copyright (C) 2021 SAP SE or an SAP affiliate company 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 } from '@theia/core/shared/inversify';
import { TerminalProcess, TerminalProcessOptions } from './terminal-process';
export const TaskTerminalProcessFactory = Symbol('TaskTerminalProcessFactory');
export interface TaskTerminalProcessFactory {
(options: TerminalProcessOptions): TaskTerminalProcess;
}
@injectable()
export class TaskTerminalProcess extends TerminalProcess {
public exited = false;
public attachmentAttempted = false;
protected override onTerminalExit(code: number | undefined, signal: string | undefined): void {
this.emitOnExit(code, signal);
this.exited = true;
// Unregister process only if task terminal already attached (or failed attach),
// Fixes https://github.com/eclipse-theia/theia/issues/2961
if (this.attachmentAttempted) {
this.unregisterProcess();
}
}
}

View File

@@ -0,0 +1,119 @@
// *****************************************************************************
// 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 * as chai from 'chai';
import * as process from 'process';
import * as stream from 'stream';
import { createProcessTestContainer } from './test/process-test-container';
import { TerminalProcessFactory } from './terminal-process';
import { IProcessExitEvent, ProcessErrorEvent } from './process';
import { isWindows } from '@theia/core/lib/common/os';
/**
* Globals
*/
const expect = chai.expect;
let terminalProcessFactory: TerminalProcessFactory;
beforeEach(() => {
terminalProcessFactory = createProcessTestContainer().get<TerminalProcessFactory>(TerminalProcessFactory);
});
describe('TerminalProcess', function (): void {
this.timeout(20_000);
it('test error on non existent path', async function (): Promise<void> {
const error = await new Promise<ProcessErrorEvent | IProcessExitEvent>((resolve, reject) => {
const proc = terminalProcessFactory({ command: '/non-existent' });
proc.onError(resolve);
proc.onExit(resolve);
});
if (isWindows) {
expect(error.code).eq('ENOENT');
} else {
expect(error.code).eq(1);
}
});
it('test implicit .exe (Windows only)', async function (): Promise<void> {
const match = /^(.+)\.exe$/.exec(process.execPath);
if (!isWindows || !match) {
this.skip();
}
const command = match[1];
const args = ['--version'];
const terminal = await new Promise<IProcessExitEvent>((resolve, reject) => {
const proc = terminalProcessFactory({ command, args });
proc.onExit(resolve);
proc.onError(reject);
});
expect(terminal.code).to.exist;
});
it('test error on trying to execute a directory', async function (): Promise<void> {
const error = await new Promise<ProcessErrorEvent | IProcessExitEvent>((resolve, reject) => {
const proc = terminalProcessFactory({ command: __dirname });
proc.onError(resolve);
proc.onExit(resolve);
});
if (isWindows) {
expect(error.code).eq('ENOENT');
} else {
expect(error.code).eq(1);
}
});
it('test exit', async function (): Promise<void> {
const args = ['--version'];
const exit = await new Promise<IProcessExitEvent>((resolve, reject) => {
const proc = terminalProcessFactory({ command: process.execPath, args });
proc.onExit(resolve);
proc.onError(reject);
});
expect(exit.code).eq(0);
});
it('test pipe stream', async function (): Promise<void> {
const v = await new Promise<string>((resolve, reject) => {
const args = ['--version'];
const terminalProcess = terminalProcessFactory({ command: process.execPath, args });
terminalProcess.onError(reject);
const outStream = new stream.PassThrough();
terminalProcess.createOutputStream().pipe(outStream);
let version = '';
outStream.on('data', data => {
version += data.toString();
});
/* node-pty is not sending 'end' on the stream as it quits
only 'exit' is sent on the terminal process. */
terminalProcess.onExit(() => {
resolve(version.trim());
});
});
/* Avoid using equal since terminal characters can be inserted at the end. */
expect(v).to.have.string(process.version);
});
});

View File

@@ -0,0 +1,294 @@
// *****************************************************************************
// 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);
}
}

View File

@@ -0,0 +1,22 @@
// *****************************************************************************
// Copyright (C) 2018 Arm 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
// *****************************************************************************
if (process.argv[2] === 'version') {
console.log('1.0.0');
} else {
process.stderr.write('Error: Argument expected')
process.exit(1)
}

View File

@@ -0,0 +1,27 @@
// *****************************************************************************
// 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 { Container } from '@theia/core/shared/inversify';
import { bindLogger } from '@theia/core/lib/node/logger-backend-module';
import processBackendModule from '../process-backend-module';
export function createProcessTestContainer(): Container {
const testContainer = new Container();
bindLogger(testContainer.bind.bind(testContainer));
testContainer.load(processBackendModule);
return testContainer;
}

View File

@@ -0,0 +1,79 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { isWindows } from '@theia/core';
import * as os from 'os';
const stringArgv = require('string-argv');
/**
* Parses the given line into an array of args respecting escapes and string literals.
* @param line the given line to parse
*/
export function parseArgs(line: string | undefined): string[] {
if (line) {
return stringArgv(line);
}
return [];
}
// Polyfill for Object.entries, until we upgrade to ES2017.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function objectEntries(obj: any): any[] {
const props = Object.keys(obj);
const result = new Array(props.length);
for (let i = 0; i < props.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result[i] = [props[i], obj[props[i]]];
}
return result;
}
/**
* Convert a signal number to its short name (using the signal definitions of
* the current host). Should never be called on Windows. For Linux, this is
* only valid for the x86 and ARM architectures, since other architectures may
* use different numbers, see signal(7).
*/
export function signame(sig: number): string {
// We should never reach this on Windows, since signals are not a thing
// there.
if (isWindows) {
throw new Error('Trying to get a signal name on Windows.');
}
for (const entry of objectEntries(os.constants.signals)) {
if (entry[1] === sig) {
return entry[0];
}
}
// Don't know this signal? Return the number as a string.
return sig.toString(10);
}
/**
* Convert a code number to its short name
*/
export function codename(code: number): string {
for (const entry of objectEntries(os.constants.errno)) {
if (entry[1] === code) {
return entry[0];
}
}
// Return the number as string if we did not find a name for it.
return code.toString(10);
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"strictFunctionTypes": true
},
"include": [
"src"
],
"references": [
{
"path": "../core"
}
]
}