sussudio
Version:
An unofficial VS Code Internal API
613 lines (612 loc) • 27.7 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { exec } from 'child_process';
import { promises as fs } from 'fs';
import { timeout } from "../../../base/common/async.mjs";
import { Emitter } from "../../../base/common/event.mjs";
import { Disposable } from "../../../base/common/lifecycle.mjs";
import * as path from "../../../base/common/path.mjs";
import { isLinux, isMacintosh, isWindows } from "../../../base/common/platform.mjs";
import { URI } from "../../../base/common/uri.mjs";
import { Promises } from "../../../base/node/pfs.mjs";
import { localize } from "../../../nls.mjs";
import { ILogService } from "../../log/common/log.mjs";
import { IProductService } from "../../product/common/productService.mjs";
import { ChildProcessMonitor } from "./childProcessMonitor.mjs";
import { findExecutable, getShellIntegrationInjection, getWindowsBuildNumber } from "./terminalEnvironment.mjs";
import { WindowsShellHelper } from "./windowsShellHelper.mjs";
var ShutdownConstants;
(function (ShutdownConstants) {
/**
* The amount of ms that must pass between data events after exit is queued before the actual
* kill call is triggered. This data flush mechanism works around an [issue in node-pty][1]
* where not all data is flushed which causes problems for task problem matchers. Additionally
* on Windows under conpty, killing a process while data is being output will cause the [conhost
* flush to hang the pty host][2] because [conhost should be hosted on another thread][3].
*
* [1]: https://github.com/Tyriar/node-pty/issues/72
* [2]: https://github.com/microsoft/vscode/issues/71966
* [3]: https://github.com/microsoft/node-pty/pull/415
*/
ShutdownConstants[ShutdownConstants["DataFlushTimeout"] = 250] = "DataFlushTimeout";
/**
* The maximum ms to allow after dispose is called because forcefully killing the process.
*/
ShutdownConstants[ShutdownConstants["MaximumShutdownTime"] = 5000] = "MaximumShutdownTime";
})(ShutdownConstants || (ShutdownConstants = {}));
var Constants;
(function (Constants) {
/**
* The minimum duration between kill and spawn calls on Windows/conpty as a mitigation for a
* hang issue. See:
* - https://github.com/microsoft/vscode/issues/71966
* - https://github.com/microsoft/vscode/issues/117956
* - https://github.com/microsoft/vscode/issues/121336
*/
Constants[Constants["KillSpawnThrottleInterval"] = 250] = "KillSpawnThrottleInterval";
/**
* The amount of time to wait when a call is throttles beyond the exact amount, this is used to
* try prevent early timeouts causing a kill/spawn call to happen at double the regular
* interval.
*/
Constants[Constants["KillSpawnSpacingDuration"] = 50] = "KillSpawnSpacingDuration";
/**
* Writing large amounts of data can be corrupted for some reason, after looking into this is
* appears to be a race condition around writing to the FD which may be based on how powerful
* the hardware is. The workaround for this is to space out when large amounts of data is being
* written to the terminal. See https://github.com/microsoft/vscode/issues/38137
*/
Constants[Constants["WriteMaxChunkSize"] = 50] = "WriteMaxChunkSize";
/**
* How long to wait between chunk writes.
*/
Constants[Constants["WriteInterval"] = 5] = "WriteInterval";
})(Constants || (Constants = {}));
const posixShellTypeMap = new Map([
['bash', "bash" /* PosixShellType.Bash */],
['csh', "csh" /* PosixShellType.Csh */],
['fish', "fish" /* PosixShellType.Fish */],
['ksh', "ksh" /* PosixShellType.Ksh */],
['sh', "sh" /* PosixShellType.Sh */],
['pwsh', "pwsh" /* PosixShellType.PowerShell */],
['zsh', "zsh" /* PosixShellType.Zsh */]
]);
let TerminalProcess = class TerminalProcess extends Disposable {
shellLaunchConfig;
_executableEnv;
_options;
_logService;
_productService;
id = 0;
shouldPersist = false;
_properties = {
cwd: '',
initialCwd: '',
fixedDimensions: { cols: undefined, rows: undefined },
title: '',
shellType: undefined,
hasChildProcesses: true,
resolvedShellLaunchConfig: {},
overrideDimensions: undefined,
failedShellIntegrationActivation: false,
usedShellIntegrationInjection: undefined
};
static _lastKillOrStart = 0;
_exitCode;
_exitMessage;
_closeTimeout;
_ptyProcess;
_currentTitle = '';
_processStartupComplete;
_isDisposed = false;
_windowsShellHelper;
_childProcessMonitor;
_titleInterval = null;
_writeQueue = [];
_writeTimeout;
_delayedResizer;
_initialCwd;
_ptyOptions;
_isPtyPaused = false;
_unacknowledgedCharCount = 0;
get exitMessage() { return this._exitMessage; }
get currentTitle() { return this._windowsShellHelper?.shellTitle || this._currentTitle; }
get shellType() { return isWindows ? this._windowsShellHelper?.shellType : posixShellTypeMap.get(this._currentTitle); }
get hasChildProcesses() { return this._childProcessMonitor?.hasChildProcesses || false; }
_onProcessData = this._register(new Emitter());
onProcessData = this._onProcessData.event;
_onProcessReady = this._register(new Emitter());
onProcessReady = this._onProcessReady.event;
_onDidChangeProperty = this._register(new Emitter());
onDidChangeProperty = this._onDidChangeProperty.event;
_onProcessExit = this._register(new Emitter());
onProcessExit = this._onProcessExit.event;
constructor(shellLaunchConfig, cwd, cols, rows, env,
/**
* environment used for `findExecutable`
*/
_executableEnv, _options, _logService, _productService) {
super();
this.shellLaunchConfig = shellLaunchConfig;
this._executableEnv = _executableEnv;
this._options = _options;
this._logService = _logService;
this._productService = _productService;
let name;
if (isWindows) {
name = path.basename(this.shellLaunchConfig.executable || '');
}
else {
// Using 'xterm-256color' here helps ensure that the majority of Linux distributions will use a
// color prompt as defined in the default ~/.bashrc file.
name = 'xterm-256color';
}
this._initialCwd = cwd;
this._properties["initialCwd" /* ProcessPropertyType.InitialCwd */] = this._initialCwd;
this._properties["cwd" /* ProcessPropertyType.Cwd */] = this._initialCwd;
const useConpty = this._options.windowsEnableConpty && process.platform === 'win32' && getWindowsBuildNumber() >= 18309;
this._ptyOptions = {
name,
cwd,
// TODO: When node-pty is updated this cast can be removed
env: env,
cols,
rows,
useConpty,
// This option will force conpty to not redraw the whole viewport on launch
conptyInheritCursor: useConpty && !!shellLaunchConfig.initialText
};
// Delay resizes to avoid conpty not respecting very early resize calls
if (isWindows) {
if (useConpty && cols === 0 && rows === 0 && this.shellLaunchConfig.executable?.endsWith('Git\\bin\\bash.exe')) {
this._delayedResizer = new DelayedResizer();
this._register(this._delayedResizer.onTrigger(dimensions => {
this._delayedResizer?.dispose();
this._delayedResizer = undefined;
if (dimensions.cols && dimensions.rows) {
this.resize(dimensions.cols, dimensions.rows);
}
}));
}
// WindowsShellHelper is used to fetch the process title and shell type
this.onProcessReady(e => {
this._windowsShellHelper = this._register(new WindowsShellHelper(e.pid));
this._register(this._windowsShellHelper.onShellTypeChanged(e => this._onDidChangeProperty.fire({ type: "shellType" /* ProcessPropertyType.ShellType */, value: e })));
this._register(this._windowsShellHelper.onShellNameChanged(e => this._onDidChangeProperty.fire({ type: "title" /* ProcessPropertyType.Title */, value: e })));
});
}
}
async start() {
const results = await Promise.all([this._validateCwd(), this._validateExecutable()]);
const firstError = results.find(r => r !== undefined);
if (firstError) {
return firstError;
}
let injection;
if (this._options.shellIntegration.enabled) {
injection = getShellIntegrationInjection(this.shellLaunchConfig, { shellIntegration: this._options.shellIntegration, windowsEnableConpty: this._options.windowsEnableConpty }, this._ptyOptions.env, this._logService, this._productService);
if (injection) {
this._onDidChangeProperty.fire({ type: "usedShellIntegrationInjection" /* ProcessPropertyType.UsedShellIntegrationInjection */, value: true });
if (injection.envMixin) {
for (const [key, value] of Object.entries(injection.envMixin)) {
this._ptyOptions.env ||= {};
this._ptyOptions.env[key] = value;
}
}
if (injection.filesToCopy) {
for (const f of injection.filesToCopy) {
await fs.mkdir(path.dirname(f.dest), { recursive: true });
try {
await fs.copyFile(f.source, f.dest);
}
catch {
// Swallow error, this should only happen when multiple users are on the same
// machine. Since the shell integration scripts rarely change, plus the other user
// should be using the same version of the server in this case, assume the script is
// fine if copy fails and swallow the error.
}
}
}
}
else {
this._onDidChangeProperty.fire({ type: "failedShellIntegrationActivation" /* ProcessPropertyType.FailedShellIntegrationActivation */, value: true });
}
}
try {
await this.setupPtyProcess(this.shellLaunchConfig, this._ptyOptions, injection);
return undefined;
}
catch (err) {
this._logService.trace('IPty#spawn native exception', err);
return { message: `A native exception occurred during launch (${err.message})` };
}
}
async _validateCwd() {
try {
const result = await Promises.stat(this._initialCwd);
if (!result.isDirectory()) {
return { message: localize('launchFail.cwdNotDirectory', "Starting directory (cwd) \"{0}\" is not a directory", this._initialCwd.toString()) };
}
}
catch (err) {
if (err?.code === 'ENOENT') {
return { message: localize('launchFail.cwdDoesNotExist', "Starting directory (cwd) \"{0}\" does not exist", this._initialCwd.toString()) };
}
}
this._onDidChangeProperty.fire({ type: "initialCwd" /* ProcessPropertyType.InitialCwd */, value: this._initialCwd });
return undefined;
}
async _validateExecutable() {
const slc = this.shellLaunchConfig;
if (!slc.executable) {
throw new Error('IShellLaunchConfig.executable not set');
}
const cwd = slc.cwd instanceof URI ? slc.cwd.path : slc.cwd;
const envPaths = (slc.env && slc.env.PATH) ? slc.env.PATH.split(path.delimiter) : undefined;
const executable = await findExecutable(slc.executable, cwd, envPaths, this._executableEnv);
if (!executable) {
return { message: localize('launchFail.executableDoesNotExist', "Path to shell executable \"{0}\" does not exist", slc.executable) };
}
try {
const result = await Promises.stat(executable);
if (!result.isFile() && !result.isSymbolicLink()) {
return { message: localize('launchFail.executableIsNotFileOrSymlink', "Path to shell executable \"{0}\" is not a file or a symlink", slc.executable) };
}
// Set the executable explicitly here so that node-pty doesn't need to search the
// $PATH too.
slc.executable = executable;
}
catch (err) {
if (err?.code === 'EACCES') {
// Swallow
}
else {
throw err;
}
}
return undefined;
}
async setupPtyProcess(shellLaunchConfig, options, shellIntegrationInjection) {
const args = shellIntegrationInjection?.newArgs || shellLaunchConfig.args || [];
await this._throttleKillSpawn();
this._logService.trace('IPty#spawn', shellLaunchConfig.executable, args, options);
const ptyProcess = (await import('node-pty')).spawn(shellLaunchConfig.executable, args, options);
this._ptyProcess = ptyProcess;
this._childProcessMonitor = this._register(new ChildProcessMonitor(ptyProcess.pid, this._logService));
this._childProcessMonitor.onDidChangeHasChildProcesses(value => this._onDidChangeProperty.fire({ type: "hasChildProcesses" /* ProcessPropertyType.HasChildProcesses */, value }));
this._processStartupComplete = new Promise(c => {
this.onProcessReady(() => c());
});
ptyProcess.onData(data => {
// Handle flow control
this._unacknowledgedCharCount += data.length;
if (!this._isPtyPaused && this._unacknowledgedCharCount > 100000 /* FlowControlConstants.HighWatermarkChars */) {
this._logService.trace(`Flow control: Pause (${this._unacknowledgedCharCount} > ${100000 /* FlowControlConstants.HighWatermarkChars */})`);
this._isPtyPaused = true;
ptyProcess.pause();
}
// Refire the data event
this._logService.trace('IPty#onData', data);
this._onProcessData.fire(data);
if (this._closeTimeout) {
this._queueProcessExit();
}
this._windowsShellHelper?.checkShell();
this._childProcessMonitor?.handleOutput();
});
ptyProcess.onExit(e => {
this._exitCode = e.exitCode;
this._queueProcessExit();
});
this._sendProcessId(ptyProcess.pid);
this._setupTitlePolling(ptyProcess);
}
dispose() {
this._isDisposed = true;
if (this._titleInterval) {
clearInterval(this._titleInterval);
}
this._titleInterval = null;
super.dispose();
}
_setupTitlePolling(ptyProcess) {
// Send initial timeout async to give event listeners a chance to init
setTimeout(() => this._sendProcessTitle(ptyProcess));
// Setup polling for non-Windows, for Windows `process` doesn't change
if (!isWindows) {
this._titleInterval = setInterval(() => {
if (this._currentTitle !== ptyProcess.process) {
this._sendProcessTitle(ptyProcess);
}
}, 200);
}
}
// Allow any trailing data events to be sent before the exit event is sent.
// See https://github.com/Tyriar/node-pty/issues/72
_queueProcessExit() {
if (this._closeTimeout) {
clearTimeout(this._closeTimeout);
}
this._closeTimeout = setTimeout(() => {
this._closeTimeout = undefined;
this._kill();
}, 250 /* ShutdownConstants.DataFlushTimeout */);
}
async _kill() {
// Wait to kill to process until the start up code has run. This prevents us from firing a process exit before a
// process start.
await this._processStartupComplete;
if (this._isDisposed) {
return;
}
// Attempt to kill the pty, it may have already been killed at this
// point but we want to make sure
try {
if (this._ptyProcess) {
await this._throttleKillSpawn();
this._logService.trace('IPty#kill');
this._ptyProcess.kill();
}
}
catch (ex) {
// Swallow, the pty has already been killed
}
this._onProcessExit.fire(this._exitCode || 0);
this.dispose();
}
async _throttleKillSpawn() {
// Only throttle on Windows/conpty
if (!isWindows || !('useConpty' in this._ptyOptions) || !this._ptyOptions.useConpty) {
return;
}
// Use a loop to ensure multiple calls in a single interval space out
while (Date.now() - TerminalProcess._lastKillOrStart < 250 /* Constants.KillSpawnThrottleInterval */) {
this._logService.trace('Throttling kill/spawn call');
await timeout(250 /* Constants.KillSpawnThrottleInterval */ - (Date.now() - TerminalProcess._lastKillOrStart) + 50 /* Constants.KillSpawnSpacingDuration */);
}
TerminalProcess._lastKillOrStart = Date.now();
}
_sendProcessId(pid) {
this._onProcessReady.fire({ pid, cwd: this._initialCwd, requiresWindowsMode: isWindows && getWindowsBuildNumber() < 21376 });
}
_sendProcessTitle(ptyProcess) {
if (this._isDisposed) {
return;
}
this._currentTitle = ptyProcess.process;
this._onDidChangeProperty.fire({ type: "title" /* ProcessPropertyType.Title */, value: this._currentTitle });
// If fig is installed it may change the title of the process
const sanitizedTitle = this.currentTitle.replace(/ \(figterm\)$/g, '');
this._onDidChangeProperty.fire({ type: "shellType" /* ProcessPropertyType.ShellType */, value: posixShellTypeMap.get(sanitizedTitle) });
}
shutdown(immediate) {
// don't force immediate disposal of the terminal processes on Windows as an additional
// mitigation for https://github.com/microsoft/vscode/issues/71966 which causes the pty host
// to become unresponsive, disconnecting all terminals across all windows.
if (immediate && !isWindows) {
this._kill();
}
else {
if (!this._closeTimeout && !this._isDisposed) {
this._queueProcessExit();
// Allow a maximum amount of time for the process to exit, otherwise force kill it
setTimeout(() => {
if (this._closeTimeout && !this._isDisposed) {
this._closeTimeout = undefined;
this._kill();
}
}, 5000 /* ShutdownConstants.MaximumShutdownTime */);
}
}
}
input(data, isBinary) {
if (this._isDisposed || !this._ptyProcess) {
return;
}
for (let i = 0; i <= Math.floor(data.length / 50 /* Constants.WriteMaxChunkSize */); i++) {
const obj = {
isBinary: isBinary || false,
data: data.substr(i * 50 /* Constants.WriteMaxChunkSize */, 50 /* Constants.WriteMaxChunkSize */)
};
this._writeQueue.push(obj);
}
this._startWrite();
}
async processBinary(data) {
this.input(data, true);
}
async refreshProperty(type) {
switch (type) {
case "cwd" /* ProcessPropertyType.Cwd */: {
const newCwd = await this.getCwd();
if (newCwd !== this._properties.cwd) {
this._properties.cwd = newCwd;
this._onDidChangeProperty.fire({ type: "cwd" /* ProcessPropertyType.Cwd */, value: this._properties.cwd });
}
return newCwd;
}
case "initialCwd" /* ProcessPropertyType.InitialCwd */: {
const initialCwd = await this.getInitialCwd();
if (initialCwd !== this._properties.initialCwd) {
this._properties.initialCwd = initialCwd;
this._onDidChangeProperty.fire({ type: "initialCwd" /* ProcessPropertyType.InitialCwd */, value: this._properties.initialCwd });
}
return initialCwd;
}
case "title" /* ProcessPropertyType.Title */:
return this.currentTitle;
default:
return this.shellType;
}
}
async updateProperty(type, value) {
if (type === "fixedDimensions" /* ProcessPropertyType.FixedDimensions */) {
this._properties.fixedDimensions = value;
}
}
_startWrite() {
// Don't write if it's already queued of is there is nothing to write
if (this._writeTimeout !== undefined || this._writeQueue.length === 0) {
return;
}
this._doWrite();
// Don't queue more writes if the queue is empty
if (this._writeQueue.length === 0) {
this._writeTimeout = undefined;
return;
}
// Queue the next write
this._writeTimeout = setTimeout(() => {
this._writeTimeout = undefined;
this._startWrite();
}, 5 /* Constants.WriteInterval */);
}
_doWrite() {
const object = this._writeQueue.shift();
this._logService.trace('IPty#write', object.data);
if (object.isBinary) {
this._ptyProcess.write(Buffer.from(object.data, 'binary'));
}
else {
this._ptyProcess.write(object.data);
}
this._childProcessMonitor?.handleInput();
}
resize(cols, rows) {
if (this._isDisposed) {
return;
}
if (typeof cols !== 'number' || typeof rows !== 'number' || isNaN(cols) || isNaN(rows)) {
return;
}
// Ensure that cols and rows are always >= 1, this prevents a native
// exception in winpty.
if (this._ptyProcess) {
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._logService.trace('IPty#resize', cols, rows);
try {
this._ptyProcess.resize(cols, rows);
}
catch (e) {
// Swallow error if the pty has already exited
this._logService.trace('IPty#resize exception ' + e.message);
if (this._exitCode !== undefined &&
e.message !== 'ioctl(2) failed, EBADF' &&
e.message !== 'Cannot resize a pty that has already exited') {
throw e;
}
}
}
}
acknowledgeDataEvent(charCount) {
// Prevent lower than 0 to heal from errors
this._unacknowledgedCharCount = Math.max(this._unacknowledgedCharCount - charCount, 0);
this._logService.trace(`Flow control: Ack ${charCount} chars (unacknowledged: ${this._unacknowledgedCharCount})`);
if (this._isPtyPaused && this._unacknowledgedCharCount < 5000 /* FlowControlConstants.LowWatermarkChars */) {
this._logService.trace(`Flow control: Resume (${this._unacknowledgedCharCount} < ${5000 /* FlowControlConstants.LowWatermarkChars */})`);
this._ptyProcess?.resume();
this._isPtyPaused = false;
}
}
clearUnacknowledgedChars() {
this._unacknowledgedCharCount = 0;
this._logService.trace(`Flow control: Cleared all unacknowledged chars, forcing resume`);
if (this._isPtyPaused) {
this._ptyProcess?.resume();
this._isPtyPaused = false;
}
}
async setUnicodeVersion(version) {
// No-op
}
getInitialCwd() {
return Promise.resolve(this._initialCwd);
}
async getCwd() {
if (isMacintosh) {
// From Big Sur (darwin v20) there is a spawn blocking thread issue on Electron,
// this is fixed in VS Code's internal Electron.
// https://github.com/Microsoft/vscode/issues/105446
return new Promise(resolve => {
if (!this._ptyProcess) {
resolve(this._initialCwd);
return;
}
this._logService.trace('IPty#pid');
exec('lsof -OPln -p ' + this._ptyProcess.pid + ' | grep cwd', { env: { ...process.env, LANG: 'en_US.UTF-8' } }, (error, stdout, stderr) => {
if (!error && stdout !== '') {
resolve(stdout.substring(stdout.indexOf('/'), stdout.length - 1));
}
else {
this._logService.error('lsof did not run successfully, it may not be on the $PATH?', error, stdout, stderr);
resolve(this._initialCwd);
}
});
});
}
if (isLinux) {
if (!this._ptyProcess) {
return this._initialCwd;
}
this._logService.trace('IPty#pid');
try {
return await Promises.readlink(`/proc/${this._ptyProcess.pid}/cwd`);
}
catch (error) {
return this._initialCwd;
}
}
return this._initialCwd;
}
getLatency() {
return Promise.resolve(0);
}
};
TerminalProcess = __decorate([
__param(7, ILogService),
__param(8, IProductService)
], TerminalProcess);
export { TerminalProcess };
/**
* Tracks the latest resize event to be trigger at a later point.
*/
class DelayedResizer extends Disposable {
rows;
cols;
_timeout;
_onTrigger = this._register(new Emitter());
get onTrigger() { return this._onTrigger.event; }
constructor() {
super();
this._timeout = setTimeout(() => {
this._onTrigger.fire({ rows: this.rows, cols: this.cols });
}, 1000);
this._register({
dispose: () => {
clearTimeout(this._timeout);
}
});
}
dispose() {
super.dispose();
clearTimeout(this._timeout);
}
}