Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Seamless terminal relaunch #119092

Merged
merged 9 commits into from
Mar 16, 2021
7 changes: 2 additions & 5 deletions src/vs/platform/terminal/common/terminalRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IPtyHostProcessReplayEvent } from 'vs/platform/terminal/common/terminalProcess';
import { IPtyHostProcessReplayEvent, ReplayEntry } from 'vs/platform/terminal/common/terminalProcess';

const MAX_RECORDER_DATA_SIZE = 1024 * 1024; // 1MB

Expand All @@ -13,20 +13,17 @@ interface RecorderEntry {
data: string[];
}

export interface ReplayEntry { cols: number; rows: number; data: string; }

export interface IRemoteTerminalProcessReplayEvent {
events: ReplayEntry[];
}

export class TerminalRecorder {

private _entries: RecorderEntry[];
private _totalDataLength: number;
private _totalDataLength: number = 0;

constructor(cols: number, rows: number) {
this._entries = [{ cols, rows, data: [] }];
this._totalDataLength = 0;
}

public recordResize(cols: number, rows: number): void {
Expand Down
52 changes: 52 additions & 0 deletions src/vs/platform/terminal/test/common/terminalRecorder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { TerminalRecorder } from 'vs/platform/terminal/common/terminalRecorder';
import { ReplayEntry } from 'vs/platform/terminal/common/terminalProcess';

function eventsEqual(recorder: TerminalRecorder, expected: ReplayEntry[]) {
const actual = recorder.generateReplayEvent().events;
for (let i = 0; i < expected.length; i++) {
assert.deepStrictEqual(actual[i], expected[i]);
}
}

suite('TerminalRecorder', () => {
test('should record dimensions', () => {
const recorder = new TerminalRecorder(1, 2);
eventsEqual(recorder, [
{ cols: 1, rows: 2, data: '' }
]);
recorder.recordData('a');
recorder.recordResize(3, 4);
eventsEqual(recorder, [
{ cols: 1, rows: 2, data: 'a' },
{ cols: 3, rows: 4, data: '' }
]);
});
test('should ignore resize events without data', () => {
const recorder = new TerminalRecorder(1, 2);
eventsEqual(recorder, [
{ cols: 1, rows: 2, data: '' }
]);
recorder.recordResize(3, 4);
eventsEqual(recorder, [
{ cols: 3, rows: 4, data: '' }
]);
});
test('should record data and combine it into the previous resize event', () => {
const recorder = new TerminalRecorder(1, 2);
recorder.recordData('a');
recorder.recordData('b');
recorder.recordResize(3, 4);
recorder.recordData('c');
recorder.recordData('d');
eventsEqual(recorder, [
{ cols: 1, rows: 2, data: 'ab' },
{ cols: 3, rows: 4, data: 'cd' }
]);
});
});
8 changes: 3 additions & 5 deletions src/vs/workbench/contrib/terminal/browser/terminalInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,9 +1133,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
this._pressAnyKeyToCloseListener = undefined;

if (this._xterm) {
if (reset) {
this._xterm.reset();
} else {
if (!reset) {
// Ensure new processes' output starts at start of new line
this._xterm.write('\n\x1b[G');
}
Expand Down Expand Up @@ -1167,12 +1165,12 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
// Set the new shell launch config
this._shellLaunchConfig = shell; // Must be done before calling _createProcess()

this._processManager.relaunch(this._shellLaunchConfig, this._cols, this._rows, this._accessibilityService.isScreenReaderOptimized());
this._processManager.relaunch(this._shellLaunchConfig, this._cols, this._rows, this._accessibilityService.isScreenReaderOptimized(), reset);

this._xtermTypeAhead?.reset(this._processManager);
}

@debounce(500)
@debounce(1000)
public relaunch(): void {
this.reuseTerminal(this._shellLaunchConfig, true);
}
Expand Down
203 changes: 179 additions & 24 deletions src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { IPathService } from 'vs/workbench/services/path/common/pathService';
import { URI } from 'vs/base/common/uri';
import { IEnvironmentVariableInfo, IEnvironmentVariableService, IMergedEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariable';
import { IProcessDataEvent, IShellLaunchConfig, ITerminalChildProcess, ITerminalDimensionsOverride, ITerminalEnvironment, ITerminalLaunchError, FlowControlConstants, TerminalShellType, ILocalTerminalService, IOffProcessTerminalService } from 'vs/platform/terminal/common/terminal';
import { TerminalRecorder } from 'vs/platform/terminal/common/terminalRecorder';

/** The amount of time to consider terminal errors to be related to the launch */
const LAUNCHING_DURATION = 500;
Expand Down Expand Up @@ -72,6 +73,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce
private _hasWrittenData: boolean = false;
private _ptyResponsiveListener: IDisposable | undefined;
private _ptyListenersAttached: boolean = false;
private _dataFilter: SeamlessRelaunchDataFilter;

private readonly _onPtyDisconnect = this._register(new Emitter<void>());
public get onPtyDisconnect(): Event<void> { return this._onPtyDisconnect.event; }
Expand Down Expand Up @@ -124,14 +126,19 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce
super();
this._localTerminalService = localTerminalService;

this.ptyProcessReady = new Promise<void>(c => {
this.onProcessReady(() => {
this._logService.debug(`Terminal process ready (shellProcessId: ${this.shellProcessId})`);
c(undefined);
});
});
this.ptyProcessReady = this._createPtyProcessReadyPromise();
this.getLatency();
this._ackDataBufferer = new AckDataBufferer(e => this._process?.acknowledgeDataEvent(e));
this._dataFilter = this._instantiationService.createInstance(SeamlessRelaunchDataFilter);
this._dataFilter.onProcessData(ev => {
const data = (typeof ev === 'string' ? ev : ev.data);
const sync = (typeof ev === 'string' ? false : ev.sync);
const beforeProcessDataEvent: IBeforeProcessDataEvent = { data };
this._onBeforeProcessData.fire(beforeProcessDataEvent);
if (beforeProcessDataEvent.data && beforeProcessDataEvent.data.length > 0) {
this._onProcessData.fire({ data: beforeProcessDataEvent.data, sync });
}
});
}

public dispose(immediate: boolean = false): void {
Expand All @@ -147,6 +154,16 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce
super.dispose();
}

private _createPtyProcessReadyPromise(): Promise<void> {
return new Promise<void>(c => {
const listener = this.onProcessReady(() => {
this._logService.debug(`Terminal process ready (shellProcessId: ${this.shellProcessId})`);
listener.dispose();
c(undefined);
});
});
}

public detachFromProcess(): void {
if (this._process?.detach) {
this._process.detach();
Expand All @@ -157,7 +174,8 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce
shellLaunchConfig: IShellLaunchConfig,
cols: number,
rows: number,
isScreenReaderModeEnabled: boolean
isScreenReaderModeEnabled: boolean,
reset: boolean = true
): Promise<ITerminalLaunchError | undefined> {
if (shellLaunchConfig.isExtensionCustomPtyTerminal) {
this._processType = ProcessType.ExtensionTerminal;
Expand Down Expand Up @@ -238,15 +256,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce

this.processState = ProcessState.LAUNCHING;

this._process.onProcessData(ev => {
const data = (typeof ev === 'string' ? ev : ev.data);
const sync = (typeof ev === 'string' ? false : ev.sync);
const beforeProcessDataEvent: IBeforeProcessDataEvent = { data };
this._onBeforeProcessData.fire(beforeProcessDataEvent);
if (beforeProcessDataEvent.data && beforeProcessDataEvent.data.length > 0) {
this._onProcessData.fire({ data: beforeProcessDataEvent.data, sync });
}
});
this._dataFilter.newProcess(this._process, reset);

this._process.onProcessReady((e: { pid: number, cwd: string }) => {
this.shellProcessId = e.pid;
Expand Down Expand Up @@ -285,14 +295,10 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce
return undefined;
}

public async relaunch(shellLaunchConfig: IShellLaunchConfig, cols: number, rows: number, isScreenReaderModeEnabled: boolean): Promise<ITerminalLaunchError | undefined> {
this.ptyProcessReady = new Promise<void>(c => {
this.onProcessReady(() => {
this._logService.debug(`Terminal process ready (shellProcessId: ${this.shellProcessId})`);
c(undefined);
});
});
return this.createProcess(shellLaunchConfig, cols, rows, isScreenReaderModeEnabled);
public async relaunch(shellLaunchConfig: IShellLaunchConfig, cols: number, rows: number, isScreenReaderModeEnabled: boolean, reset: boolean): Promise<ITerminalLaunchError | undefined> {
this.ptyProcessReady = this._createPtyProcessReadyPromise();
this._logService.trace(`Relaunching terminal instance ${this._instanceId}`);
return this.createProcess(shellLaunchConfig, cols, rows, isScreenReaderModeEnabled, reset);
}

// Fetch any extension environment additions and apply them
Expand Down Expand Up @@ -431,6 +437,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce

public async write(data: string): Promise<void> {
await this.ptyProcessReady;
this._dataFilter.triggerSwap();
this._hasWrittenData = true;
if (this.shellProcessId || this._processType === ProcessType.ExtensionTerminal) {
if (this._process) {
Expand Down Expand Up @@ -516,3 +523,151 @@ class AckDataBufferer {
}
}
}

const enum SeamlessRelaunchConstants {
/**
* How long to record data events for new terminals.
*/
RecordTerminalDuration = 10000,
/**
* The maximum duration after a relaunch occurs to trigger a swap.
*/
SwapWaitMaximumDuration = 3000
}

/**
* Filters data events from the process and supports seamlessly restarting swapping out the process
* with another, delaying the swap in output in order to minimize flickering/clearing of the
* terminal.
*/
class SeamlessRelaunchDataFilter extends Disposable {
private _firstRecorder?: TerminalRecorder;
private _secondRecorder?: TerminalRecorder;
private _firstDisposable?: IDisposable;
private _secondDisposable?: IDisposable;
private _dataListener?: IDisposable;
private _activeProcess?: ITerminalChildProcess;

private _recordingTimeout?: number;
private _swapTimeout?: number;

private readonly _onProcessData = this._register(new Emitter<string | IProcessDataEvent>());
public get onProcessData(): Event<string | IProcessDataEvent> { return this._onProcessData.event; }

constructor(
@ILogService private readonly _logService: ILogService
) {
super();
}

newProcess(process: ITerminalChildProcess, reset: boolean) {
// Stop listening to the old process
this._dataListener?.dispose();
this._activeProcess = process;

// If the process is new, relaunch has timed out or the terminal should not reset, start
// listening and firing data events immediately
if (!this._firstRecorder || !reset) {
this._firstDisposable?.dispose();
[this._firstRecorder, this._firstDisposable] = this._createRecorder(process);
this._dataListener = process.onProcessData(e => this._onProcessData.fire(e));
if (this._recordingTimeout) {
window.clearTimeout(this._recordingTimeout);
}
this._recordingTimeout = window.setTimeout(() => this._stopRecording(), SeamlessRelaunchConstants.RecordTerminalDuration);
return;
}

// Trigger a swap if there was a recent relaunch
if (this._secondRecorder) {
this.triggerSwap();
}

this._swapTimeout = window.setTimeout(() => this.triggerSwap(), SeamlessRelaunchConstants.SwapWaitMaximumDuration);

// Pause all outgoing data events
this._dataListener?.dispose();

this._firstDisposable?.dispose();
const recorder = this._createRecorder(process);
[this._secondRecorder, this._secondDisposable] = recorder;
}

/**
* Trigger the swap of the processes if needed (eg. timeout, input)
*/
triggerSwap() {
// Clear the swap timeout if it exists
if (this._swapTimeout) {
window.clearTimeout(this._swapTimeout);
this._swapTimeout = undefined;
}

// Do nothing if there's nothing being recorder
if (!this._firstRecorder) {
return;
}

// Clear the first recorder if no second process was attached before the swap trigger
if (!this._secondRecorder) {
this._firstRecorder = undefined;
this._firstDisposable?.dispose();
return;
}

// Generate data for each recorder
const firstData = this._getDataFromRecorder(this._firstRecorder);
const secondData = this._getDataFromRecorder(this._secondRecorder);

// Re-write the terminal if the data differs
if (firstData === secondData) {
this._logService.trace(`Seamless terminal relaunch - identical content`);
} else {
this._logService.trace(`Seamless terminal relaunch - resetting content`);
// Fire full reset (RIS) followed by the new data so the update happens in the same frame
this._onProcessData.fire({ data: `\x1bc${secondData}`, sync: false });
}

// Set up the new data listener
this._dataListener?.dispose();
this._dataListener = this._activeProcess!.onProcessData(e => this._onProcessData.fire(e));

// Replace first recorder with second
this._firstRecorder = this._secondRecorder;
this._firstDisposable?.dispose();
this._firstDisposable = this._secondDisposable;
if (this._recordingTimeout) {
window.clearTimeout(this._recordingTimeout);
}
this._recordingTimeout = window.setTimeout(() => this._stopRecording(), SeamlessRelaunchConstants.RecordTerminalDuration);
}

private _stopRecording() {
// Continue recording if a swap is coming
if (this._swapTimeout) {
return;
}

// Clear the timeout
if (this._recordingTimeout) {
window.clearTimeout(this._recordingTimeout);
this._recordingTimeout = undefined;
}

// Stop recording
this._firstRecorder = undefined;
this._firstDisposable?.dispose();
this._secondRecorder = undefined;
this._secondDisposable?.dispose();
}

private _createRecorder(process: ITerminalChildProcess): [TerminalRecorder, IDisposable] {
const recorder = new TerminalRecorder(0, 0);
const disposable = process.onProcessData(e => recorder.recordData(typeof e === 'string' ? e : e.data));
return [recorder, disposable];
}

private _getDataFromRecorder(recorder: TerminalRecorder): string {
return recorder.generateReplayEvent().events.filter(e => !!e.data).map(e => e.data).join('');
}
}
2 changes: 1 addition & 1 deletion src/vs/workbench/contrib/terminal/common/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export interface ITerminalProcessManager extends IDisposable {
dispose(immediate?: boolean): void;
detachFromProcess(): void;
createProcess(shellLaunchConfig: IShellLaunchConfig, cols: number, rows: number, isScreenReaderModeEnabled: boolean): Promise<ITerminalLaunchError | undefined>;
relaunch(shellLaunchConfig: IShellLaunchConfig, cols: number, rows: number, isScreenReaderModeEnabled: boolean): Promise<ITerminalLaunchError | undefined>;
relaunch(shellLaunchConfig: IShellLaunchConfig, cols: number, rows: number, isScreenReaderModeEnabled: boolean, reset: boolean): Promise<ITerminalLaunchError | undefined>;
write(data: string): void;
setDimensions(cols: number, rows: number): Promise<void>;
setDimensions(cols: number, rows: number, sync: false): Promise<void>;
Expand Down