Archived
1
0
This repository has been archived on 2024-09-09. You can view files and clone it, but cannot push or open issues or pull requests.
2021-03-11 10:27:10 -07:00

427 lines
16 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, toDisposable } from 'vs/base/common/lifecycle';
import { IProcessEnvironment } from 'vs/base/common/platform';
import { IPtyService, IProcessDataEvent, IShellLaunchConfig, ITerminalDimensionsOverride, ITerminalLaunchError, LocalReconnectConstants, ITerminalsLayoutInfo, IRawTerminalInstanceLayoutInfo, ITerminalTabLayoutInfoById, ITerminalInstanceLayoutInfoById } from 'vs/platform/terminal/common/terminal';
import { AutoOpenBarrier, Queue, RunOnceScheduler } from 'vs/base/common/async';
import { Emitter } from 'vs/base/common/event';
import { TerminalRecorder } from 'vs/platform/terminal/common/terminalRecorder';
import { TerminalProcess } from 'vs/platform/terminal/node/terminalProcess';
import { ISetTerminalLayoutInfoArgs, ITerminalTabLayoutInfoDto, IPtyHostDescriptionDto, IGetTerminalLayoutInfoArgs, IPtyHostProcessReplayEvent } from 'vs/platform/terminal/common/terminalProcess';
import { ILogService } from 'vs/platform/log/common/log';
type WorkspaceId = string;
export class PtyService extends Disposable implements IPtyService {
declare readonly _serviceBrand: undefined;
private readonly _ptys: Map<number, PersistentTerminalProcess> = new Map();
private readonly _workspaceLayoutInfos = new Map<WorkspaceId, ISetTerminalLayoutInfoArgs>();
private readonly _onHeartbeat = this._register(new Emitter<void>());
readonly onHeartbeat = this._onHeartbeat.event;
private readonly _onProcessData = this._register(new Emitter<{ id: number, event: IProcessDataEvent | string }>());
readonly onProcessData = this._onProcessData.event;
private readonly _onProcessReplay = this._register(new Emitter<{ id: number, event: IPtyHostProcessReplayEvent }>());
readonly onProcessReplay = this._onProcessReplay.event;
private readonly _onProcessExit = this._register(new Emitter<{ id: number, event: number | undefined }>());
readonly onProcessExit = this._onProcessExit.event;
private readonly _onProcessReady = this._register(new Emitter<{ id: number, event: { pid: number, cwd: string } }>());
readonly onProcessReady = this._onProcessReady.event;
private readonly _onProcessTitleChanged = this._register(new Emitter<{ id: number, event: string }>());
readonly onProcessTitleChanged = this._onProcessTitleChanged.event;
private readonly _onProcessOverrideDimensions = this._register(new Emitter<{ id: number, event: ITerminalDimensionsOverride | undefined }>());
readonly onProcessOverrideDimensions = this._onProcessOverrideDimensions.event;
private readonly _onProcessResolvedShellLaunchConfig = this._register(new Emitter<{ id: number, event: IShellLaunchConfig }>());
readonly onProcessResolvedShellLaunchConfig = this._onProcessResolvedShellLaunchConfig.event;
constructor(
private _lastPtyId: number,
private readonly _logService: ILogService
) {
super();
this._register(toDisposable(() => {
for (const pty of this._ptys.values()) {
pty.shutdown(true);
}
this._ptys.clear();
}));
}
async shutdownAll(): Promise<void> {
this.dispose();
}
async createProcess(
shellLaunchConfig: IShellLaunchConfig,
cwd: string,
cols: number,
rows: number,
env: IProcessEnvironment,
executableEnv: IProcessEnvironment,
windowsEnableConpty: boolean,
shouldPersist: boolean,
workspaceId: string,
workspaceName: string
): Promise<number> {
if (shellLaunchConfig.attachPersistentTerminal) {
throw new Error('Attempt to create a process when attach object was provided');
}
const id = ++this._lastPtyId;
const process = new TerminalProcess(shellLaunchConfig, cwd, cols, rows, env, executableEnv, windowsEnableConpty, this._logService);
process.onProcessData(event => this._onProcessData.fire({ id, event }));
process.onProcessExit(event => this._onProcessExit.fire({ id, event }));
if (process.onProcessOverrideDimensions) {
process.onProcessOverrideDimensions(event => this._onProcessOverrideDimensions.fire({ id, event }));
}
if (process.onProcessResolvedShellLaunchConfig) {
process.onProcessResolvedShellLaunchConfig(event => this._onProcessResolvedShellLaunchConfig.fire({ id, event }));
}
const persistentTerminalProcess = new PersistentTerminalProcess(id, process, workspaceId, workspaceName, shouldPersist, cols, rows, this._logService);
process.onProcessExit(() => {
persistentTerminalProcess.dispose();
this._ptys.delete(id);
});
persistentTerminalProcess.onProcessReplay(event => this._onProcessReplay.fire({ id, event }));
persistentTerminalProcess.onProcessReady(event => this._onProcessReady.fire({ id, event }));
persistentTerminalProcess.onProcessTitleChanged(event => this._onProcessTitleChanged.fire({ id, event }));
this._ptys.set(id, persistentTerminalProcess);
return id;
}
async attachToProcess(id: number): Promise<void> {
try {
this._throwIfNoPty(id).attach();
this._logService.trace(`Persistent terminal reconnection "${id}"`);
} catch (e) {
this._logService.trace(`Persistent terminal reconnection "${id}" failed`, e.message);
}
}
async detachFromProcess(id: number): Promise<void> {
this._throwIfNoPty(id).detach();
}
async start(id: number): Promise<ITerminalLaunchError | undefined> {
return this._throwIfNoPty(id).start();
}
async shutdown(id: number, immediate: boolean): Promise<void> {
return this._throwIfNoPty(id).shutdown(immediate);
}
async input(id: number, data: string): Promise<void> {
return this._throwIfNoPty(id).input(data);
}
async resize(id: number, cols: number, rows: number): Promise<void> {
return this._throwIfNoPty(id).resize(cols, rows);
}
async getInitialCwd(id: number): Promise<string> {
return this._throwIfNoPty(id).getInitialCwd();
}
async getCwd(id: number): Promise<string> {
return this._throwIfNoPty(id).getCwd();
}
async acknowledgeDataEvent(id: number, charCount: number): Promise<void> {
return this._throwIfNoPty(id).acknowledgeDataEvent(charCount);
}
async getLatency(id: number): Promise<number> {
return 0;
}
async setTerminalLayoutInfo(args: ISetTerminalLayoutInfoArgs): Promise<void> {
this._workspaceLayoutInfos.set(args.workspaceId, args);
}
async getTerminalLayoutInfo(args: IGetTerminalLayoutInfoArgs): Promise<ITerminalsLayoutInfo | undefined> {
const layout = this._workspaceLayoutInfos.get(args.workspaceId);
if (layout) {
const expandedTabs = await Promise.all(layout.tabs.map(async tab => this._expandTerminalTab(tab)));
const filtered = expandedTabs.filter(t => t.terminals.length > 0);
return {
tabs: filtered
};
}
return undefined;
}
private async _expandTerminalTab(tab: ITerminalTabLayoutInfoById): Promise<ITerminalTabLayoutInfoDto> {
const expandedTerminals = (await Promise.all(tab.terminals.map(t => this._expandTerminalInstance(t))));
const filtered = expandedTerminals.filter(term => term.terminal !== null) as IRawTerminalInstanceLayoutInfo<IPtyHostDescriptionDto>[];
return {
isActive: tab.isActive,
activePersistentTerminalId: tab.activePersistentTerminalId,
terminals: filtered
};
}
private async _expandTerminalInstance(t: ITerminalInstanceLayoutInfoById): Promise<IRawTerminalInstanceLayoutInfo<IPtyHostDescriptionDto | null>> {
try {
const persistentTerminalProcess = this._throwIfNoPty(t.terminal);
const termDto = persistentTerminalProcess && await this._terminalToDto(t.terminal, persistentTerminalProcess);
return {
terminal: termDto ?? null,
relativeSize: t.relativeSize
};
} catch (e) {
this._logService.trace(`Couldn't get layout info, a terminal was probably disconnected`, e.message);
// this will be filtered out and not reconnected
return {
terminal: null,
relativeSize: t.relativeSize
};
}
}
private async _terminalToDto(id: number, persistentTerminalProcess: PersistentTerminalProcess): Promise<IPtyHostDescriptionDto> {
const [cwd, isOrphan] = await Promise.all([persistentTerminalProcess.getCwd(), persistentTerminalProcess.isOrphaned()]);
return {
id,
title: persistentTerminalProcess.title,
pid: persistentTerminalProcess.pid,
workspaceId: persistentTerminalProcess.workspaceId,
workspaceName: persistentTerminalProcess.workspaceName,
cwd,
isOrphan
};
}
private _throwIfNoPty(id: number): PersistentTerminalProcess {
const pty = this._ptys.get(id);
if (!pty) {
throw new Error(`Could not find pty with id "${id}"`);
}
return pty;
}
}
export class PersistentTerminalProcess extends Disposable {
// private readonly _bufferer: TerminalDataBufferer;
private readonly _pendingCommands = new Map<number, { resolve: (data: any) => void; reject: (err: any) => void; }>();
private readonly _recorder: TerminalRecorder;
private _isStarted: boolean = false;
private _orphanQuestionBarrier: AutoOpenBarrier | null;
private _orphanQuestionReplyTime: number;
private _orphanRequestQueue = new Queue<boolean>();
private _disconnectRunner1: RunOnceScheduler;
private _disconnectRunner2: RunOnceScheduler;
private readonly _onProcessReplay = this._register(new Emitter<IPtyHostProcessReplayEvent>());
readonly onProcessReplay = this._onProcessReplay.event;
private readonly _onProcessReady = this._register(new Emitter<{ pid: number, cwd: string }>());
readonly onProcessReady = this._onProcessReady.event;
private readonly _onProcessTitleChanged = this._register(new Emitter<string>());
readonly onProcessTitleChanged = this._onProcessTitleChanged.event;
private readonly _onProcessOverrideDimensions = this._register(new Emitter<ITerminalDimensionsOverride | undefined>());
readonly onProcessOverrideDimensions = this._onProcessOverrideDimensions.event;
private readonly _onProcessData = this._register(new Emitter<IProcessDataEvent>());
readonly onProcessData = this._onProcessData.event;
private _inReplay = false;
private _pid = -1;
private _cwd = '';
get pid(): number { return this._pid; }
get title(): string { return this._terminalProcess.currentTitle; }
constructor(
private _persistentTerminalId: number,
private readonly _terminalProcess: TerminalProcess,
public readonly workspaceId: string,
public readonly workspaceName: string,
public readonly shouldPersistTerminal: boolean,
cols: number, rows: number,
private readonly _logService: ILogService
) {
super();
this._recorder = new TerminalRecorder(cols, rows);
this._orphanQuestionBarrier = null;
this._orphanQuestionReplyTime = 0;
this._disconnectRunner1 = this._register(new RunOnceScheduler(() => {
this._logService.info(`Persistent terminal "${this._persistentTerminalId}": The reconnection grace time of ${printTime(LocalReconnectConstants.ReconnectionGraceTime)} has expired, so the process (pid=${this._pid}) will be shutdown.`);
this.shutdown(true);
}, LocalReconnectConstants.ReconnectionGraceTime));
this._disconnectRunner2 = this._register(new RunOnceScheduler(() => {
this._logService.info(`Persistent terminal "${this._persistentTerminalId}": The short reconnection grace time of ${printTime(LocalReconnectConstants.ReconnectionShortGraceTime)} has expired, so the process (pid=${this._pid}) will be shutdown.`);
this.shutdown(true);
}, LocalReconnectConstants.ReconnectionShortGraceTime));
// TODO: Bring back bufferer
// this._bufferer = new TerminalDataBufferer((id, data) => {
// const ev: IPtyHostProcessDataEvent = {
// type: 'data',
// data: data
// };
// this._events.fire(ev);
// });
this._register(this._terminalProcess.onProcessReady(e => {
this._pid = e.pid;
this._cwd = e.cwd;
this._onProcessReady.fire(e);
}));
this._register(this._terminalProcess.onProcessTitleChanged(e => this._onProcessTitleChanged.fire(e)));
// Buffer data events to reduce the amount of messages going to the renderer
// this._register(this._bufferer.startBuffering(this._persistentTerminalId, this._terminalProcess.onProcessData));
this._register(this._terminalProcess.onProcessData(e => this._recorder.recordData(e)));
this._register(this._terminalProcess.onProcessExit(exitCode => {
// this._bufferer.stopBuffering(this._persistentTerminalId);
}));
}
attach(): void {
this._disconnectRunner1.cancel();
}
async detach(): Promise<void> {
if (this.shouldPersistTerminal) {
this._disconnectRunner1.schedule();
} else {
this.shutdown(true);
}
}
async start(): Promise<ITerminalLaunchError | undefined> {
if (!this._isStarted) {
const result = await this._terminalProcess.start();
if (result) {
// it's a terminal launch error
return result;
}
this._isStarted = true;
} else {
this._onProcessReady.fire({ pid: this._pid, cwd: this._cwd });
this._onProcessTitleChanged.fire(this._terminalProcess.currentTitle);
this.triggerReplay();
}
return undefined;
}
shutdown(immediate: boolean): void {
return this._terminalProcess.shutdown(immediate);
}
input(data: string): void {
if (this._inReplay) {
return;
}
return this._terminalProcess.input(data);
}
resize(cols: number, rows: number): void {
if (this._inReplay) {
return;
}
this._recorder.recordResize(cols, rows);
return this._terminalProcess.resize(cols, rows);
}
acknowledgeDataEvent(charCount: number): void {
if (this._inReplay) {
return;
}
return this._terminalProcess.acknowledgeDataEvent(charCount);
}
getInitialCwd(): Promise<string> {
return this._terminalProcess.getInitialCwd();
}
getCwd(): Promise<string> {
return this._terminalProcess.getCwd();
}
getLatency(): Promise<number> {
return this._terminalProcess.getLatency();
}
triggerReplay(): void {
const ev = this._recorder.generateReplayEvent();
let dataLength = 0;
for (const e of ev.events) {
dataLength += e.data.length;
}
this._logService.info(`Persistent terminal "${this._persistentTerminalId}": Replaying ${dataLength} chars and ${ev.events.length} size events`);
this._onProcessReplay.fire(ev);
this._terminalProcess.clearUnacknowledgedChars();
}
sendCommandResult(reqId: number, isError: boolean, serializedPayload: any): void {
const data = this._pendingCommands.get(reqId);
if (!data) {
return;
}
this._pendingCommands.delete(reqId);
}
async orphanQuestionReply(): Promise<void> {
this._orphanQuestionReplyTime = Date.now();
if (this._orphanQuestionBarrier) {
const barrier = this._orphanQuestionBarrier;
this._orphanQuestionBarrier = null;
barrier.open();
}
}
reduceGraceTime(): void {
if (this._disconnectRunner2.isScheduled()) {
// we are disconnected and already running the short reconnection timer
return;
}
if (this._disconnectRunner1.isScheduled()) {
// we are disconnected and running the long reconnection timer
this._disconnectRunner2.schedule();
}
}
async isOrphaned(): Promise<boolean> {
return await this._orphanRequestQueue.queue(async () => this._isOrphaned());
}
private async _isOrphaned(): Promise<boolean> {
if (this._disconnectRunner1.isScheduled() || this._disconnectRunner2.isScheduled()) {
return true;
}
if (!this._orphanQuestionBarrier) {
// the barrier opens after 4 seconds with or without a reply
this._orphanQuestionBarrier = new AutoOpenBarrier(4000);
this._orphanQuestionReplyTime = 0;
// TODO: Fire?
// const ev: IPtyHostProcessOrphanQuestionEvent = {
// type: 'orphan?'
// };
// this._events.fire(ev);
}
await this._orphanQuestionBarrier.wait();
return (Date.now() - this._orphanQuestionReplyTime > 500);
}
}
function printTime(ms: number): string {
let h = 0;
let m = 0;
let s = 0;
if (ms >= 1000) {
s = Math.floor(ms / 1000);
ms -= s * 1000;
}
if (s >= 60) {
m = Math.floor(s / 60);
s -= m * 60;
}
if (m >= 60) {
h = Math.floor(m / 60);
m -= h * 60;
}
const _h = h ? `${h}h` : ``;
const _m = m ? `${m}m` : ``;
const _s = s ? `${s}s` : ``;
const _ms = ms ? `${ms}ms` : ``;
return `${_h}${_m}${_s}${_ms}`;
}