Skip to content

Commit 8d174a8

Browse files
author
Kartik Raj
authored
Fix shell integration API reliability (#22446)
#22440 It leads to terminals activating forever.
1 parent 1b3c1ea commit 8d174a8

File tree

11 files changed

+200
-45
lines changed

11 files changed

+200
-45
lines changed

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"testObserver",
2424
"quickPickItemTooltip",
2525
"saveEditor",
26-
"terminalDataWriteEvent"
26+
"terminalDataWriteEvent",
27+
"terminalExecuteCommandEvent"
2728
],
2829
"author": {
2930
"name": "Microsoft Corporation"

src/client/common/application/applicationShell.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
WorkspaceFolderPickOptions,
4040
} from 'vscode';
4141
import { traceError } from '../../logging';
42-
import { IApplicationShell, TerminalDataWriteEvent } from './types';
42+
import { IApplicationShell, TerminalDataWriteEvent, TerminalExecutedCommand } from './types';
4343

4444
@injectable()
4545
export class ApplicationShell implements IApplicationShell {
@@ -182,4 +182,12 @@ export class ApplicationShell implements IApplicationShell {
182182
return new EventEmitter<TerminalDataWriteEvent>().event;
183183
}
184184
}
185+
public get onDidExecuteTerminalCommand(): Event<TerminalExecutedCommand> | undefined {
186+
try {
187+
return window.onDidExecuteTerminalCommand;
188+
} catch (ex) {
189+
traceError('Failed to get proposed API TerminalExecutedCommand', ex);
190+
return undefined;
191+
}
192+
}
185193
}

src/client/common/application/types.ts

+34
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,42 @@ export interface TerminalDataWriteEvent {
7878
readonly data: string;
7979
}
8080

81+
export interface TerminalExecutedCommand {
82+
/**
83+
* The {@link Terminal} the command was executed in.
84+
*/
85+
terminal: Terminal;
86+
/**
87+
* The full command line that was executed, including both the command and the arguments.
88+
*/
89+
commandLine: string | undefined;
90+
/**
91+
* The current working directory that was reported by the shell. This will be a {@link Uri}
92+
* if the string reported by the shell can reliably be mapped to the connected machine.
93+
*/
94+
cwd: Uri | string | undefined;
95+
/**
96+
* The exit code reported by the shell.
97+
*/
98+
exitCode: number | undefined;
99+
/**
100+
* The output of the command when it has finished executing. This is the plain text shown in
101+
* the terminal buffer and does not include raw escape sequences. Depending on the shell
102+
* setup, this may include the command line as part of the output.
103+
*/
104+
output: string | undefined;
105+
}
106+
81107
export const IApplicationShell = Symbol('IApplicationShell');
82108
export interface IApplicationShell {
109+
/**
110+
* An event that is emitted when a terminal with shell integration activated has completed
111+
* executing a command.
112+
*
113+
* Note that this event will not fire if the executed command exits the shell, listen to
114+
* {@link onDidCloseTerminal} to handle that case.
115+
*/
116+
readonly onDidExecuteTerminalCommand: Event<TerminalExecutedCommand> | undefined;
83117
/**
84118
* An [event](#Event) which fires when the focus state of the current window
85119
* changes. The value of the event represents whether the window is focused.

src/client/terminals/envCollectionActivation/service.ts

+11-23
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ import { TerminalShellType } from '../../common/terminal/types';
3737
import { OSType } from '../../common/utils/platform';
3838
import { normCase } from '../../common/platform/fs-paths';
3939
import { PythonEnvType } from '../../pythonEnvironments/base/info';
40-
import { ITerminalEnvVarCollectionService } from '../types';
41-
import { ShellIntegrationShells } from './shellIntegration';
40+
import { IShellIntegrationService, ITerminalEnvVarCollectionService } from '../types';
4241
import { ProgressService } from '../../common/application/progressService';
4342

4443
@injectable()
@@ -80,6 +79,7 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ
8079
@inject(IWorkspaceService) private workspaceService: IWorkspaceService,
8180
@inject(IConfigurationService) private readonly configurationService: IConfigurationService,
8281
@inject(IPathUtils) private readonly pathUtils: IPathUtils,
82+
@inject(IShellIntegrationService) private readonly shellIntegrationService: IShellIntegrationService,
8383
) {
8484
this.separator = platform.osType === OSType.Windows ? ';' : ':';
8585
this.progressService = new ProgressService(this.shell);
@@ -121,7 +121,7 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ
121121
this.disposables,
122122
);
123123
const { shell } = this.applicationEnvironment;
124-
const isActive = this.isShellIntegrationActive(shell);
124+
const isActive = await this.shellIntegrationService.isWorking(shell);
125125
const shellType = identifyShellFromShellPath(shell);
126126
if (!isActive && shellType !== TerminalShellType.commandPrompt) {
127127
traceWarn(`Shell integration is not active, environment activated maybe overriden by the shell.`);
@@ -139,7 +139,10 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ
139139
location: ProgressLocation.Window,
140140
title: Interpreters.activatingTerminals,
141141
});
142-
await this._applyCollectionImpl(resource, shell);
142+
await this._applyCollectionImpl(resource, shell).catch((ex) => {
143+
traceError(`Failed to apply terminal env vars`, shell, ex);
144+
return Promise.reject(ex); // Ensures progress indicator does not disappear in case of errors, so we can catch issues faster.
145+
});
143146
this.progressService.hideProgress();
144147
}
145148

@@ -184,7 +187,7 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ
184187

185188
// PS1 in some cases is a shell variable (not an env variable) so "env" might not contain it, calculate it in that case.
186189
env.PS1 = await this.getPS1(shell, resource, env);
187-
const prependOptions = this.getPrependOptions(shell);
190+
const prependOptions = await this.getPrependOptions(shell);
188191

189192
// Clear any previously set env vars from collection
190193
envVarCollection.clear();
@@ -277,7 +280,7 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ
277280
// PS1 should be set but no PS1 was set.
278281
return;
279282
}
280-
const config = this.isShellIntegrationActive(shell);
283+
const config = await this.shellIntegrationService.isWorking(shell);
281284
if (!config) {
282285
traceVerbose('PS1 is not set when shell integration is disabled.');
283286
return;
@@ -332,8 +335,8 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ
332335
}
333336
}
334337

335-
private getPrependOptions(shell: string): EnvironmentVariableMutatorOptions {
336-
const isActive = this.isShellIntegrationActive(shell);
338+
private async getPrependOptions(shell: string): Promise<EnvironmentVariableMutatorOptions> {
339+
const isActive = await this.shellIntegrationService.isWorking(shell);
337340
// Ideally we would want to prepend exactly once, either at shell integration or process creation.
338341
// TODO: Stop prepending altogether once https://github.com/microsoft/vscode/issues/145234 is available.
339342
return isActive
@@ -347,21 +350,6 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ
347350
};
348351
}
349352

350-
private isShellIntegrationActive(shell: string): boolean {
351-
const isEnabled = this.workspaceService
352-
.getConfiguration('terminal')
353-
.get<boolean>('integrated.shellIntegration.enabled')!;
354-
if (isEnabled && ShellIntegrationShells.includes(identifyShellFromShellPath(shell))) {
355-
// Unfortunately shell integration could still've failed in remote scenarios, we can't know for sure:
356-
// https://code.visualstudio.com/docs/terminal/shell-integration#_automatic-script-injection
357-
return true;
358-
}
359-
if (!isEnabled) {
360-
traceVerbose('Shell integrated is disabled in user settings.');
361-
}
362-
return false;
363-
}
364-
365353
private getEnvironmentVariableCollection(scope: EnvironmentVariableScope = {}) {
366354
const envVarCollection = this.context.environmentVariableCollection as GlobalEnvironmentVariableCollection;
367355
return envVarCollection.getScoped(scope);

src/client/terminals/envCollectionActivation/shellIntegration.ts

-13
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { injectable, inject } from 'inversify';
5+
import { IApplicationShell, ITerminalManager, IWorkspaceService } from '../../common/application/types';
6+
import { identifyShellFromShellPath } from '../../common/terminal/shellDetectors/baseShellDetector';
7+
import { TerminalShellType } from '../../common/terminal/types';
8+
import { createDeferred, sleep } from '../../common/utils/async';
9+
import { cache } from '../../common/utils/decorators';
10+
import { traceError, traceVerbose } from '../../logging';
11+
import { IShellIntegrationService } from '../types';
12+
13+
/**
14+
* This is a list of shells which support shell integration:
15+
* https://code.visualstudio.com/docs/terminal/shell-integration
16+
*/
17+
const ShellIntegrationShells = [
18+
TerminalShellType.powershell,
19+
TerminalShellType.powershellCore,
20+
TerminalShellType.bash,
21+
TerminalShellType.zsh,
22+
TerminalShellType.fish,
23+
];
24+
25+
@injectable()
26+
export class ShellIntegrationService implements IShellIntegrationService {
27+
constructor(
28+
@inject(ITerminalManager) private readonly terminalManager: ITerminalManager,
29+
@inject(IApplicationShell) private readonly appShell: IApplicationShell,
30+
@inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService,
31+
) {}
32+
33+
public async isWorking(shell: string): Promise<boolean> {
34+
return this._isWorking(shell).catch((ex) => {
35+
traceError(`Failed to determine if shell supports shell integration`, shell, ex);
36+
return false;
37+
});
38+
}
39+
40+
@cache(-1, true)
41+
public async _isWorking(shell: string): Promise<boolean> {
42+
const isEnabled = this.workspaceService
43+
.getConfiguration('terminal')
44+
.get<boolean>('integrated.shellIntegration.enabled')!;
45+
if (!isEnabled) {
46+
traceVerbose('Shell integrated is disabled in user settings.');
47+
}
48+
const isSupposedToWork = isEnabled && ShellIntegrationShells.includes(identifyShellFromShellPath(shell));
49+
if (!isSupposedToWork) {
50+
return false;
51+
}
52+
const deferred = createDeferred<void>();
53+
const timestamp = new Date().getTime();
54+
const name = `Python ${timestamp}`;
55+
const onDidExecuteTerminalCommand = this.appShell.onDidExecuteTerminalCommand?.bind(this.appShell);
56+
if (!onDidExecuteTerminalCommand) {
57+
// Proposed API is not available, assume shell integration is working at this point.
58+
return true;
59+
}
60+
try {
61+
const disposable = onDidExecuteTerminalCommand((e) => {
62+
if (e.terminal.name === name) {
63+
deferred.resolve();
64+
}
65+
});
66+
const terminal = this.terminalManager.createTerminal({
67+
name,
68+
shellPath: shell,
69+
hideFromUser: true,
70+
});
71+
terminal.sendText(`echo ${shell}`);
72+
const success = await Promise.race([sleep(3000).then(() => false), deferred.promise.then(() => true)]);
73+
disposable.dispose();
74+
return success;
75+
} catch (ex) {
76+
traceVerbose(`Proposed API is not available, failed to subscribe to onDidExecuteTerminalCommand`, ex);
77+
// Proposed API is not available, assume shell integration is working at this point.
78+
return true;
79+
}
80+
}
81+
}

src/client/terminals/serviceRegistry.ts

+3
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import {
1212
ICodeExecutionHelper,
1313
ICodeExecutionManager,
1414
ICodeExecutionService,
15+
IShellIntegrationService,
1516
ITerminalAutoActivation,
1617
ITerminalEnvVarCollectionService,
1718
} from './types';
1819
import { TerminalEnvVarCollectionService } from './envCollectionActivation/service';
1920
import { IExtensionActivationService, IExtensionSingleActivationService } from '../activation/types';
2021
import { TerminalDeactivateLimitationPrompt } from './envCollectionActivation/deactivatePrompt';
2122
import { TerminalIndicatorPrompt } from './envCollectionActivation/indicatorPrompt';
23+
import { ShellIntegrationService } from './envCollectionActivation/shellIntegrationService';
2224

2325
export function registerTypes(serviceManager: IServiceManager): void {
2426
serviceManager.addSingleton<ICodeExecutionHelper>(ICodeExecutionHelper, CodeExecutionHelper);
@@ -50,5 +52,6 @@ export function registerTypes(serviceManager: IServiceManager): void {
5052
IExtensionSingleActivationService,
5153
TerminalDeactivateLimitationPrompt,
5254
);
55+
serviceManager.addSingleton<IShellIntegrationService>(IShellIntegrationService, ShellIntegrationService);
5356
serviceManager.addBinding(ITerminalEnvVarCollectionService, IExtensionActivationService);
5457
}

src/client/terminals/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,8 @@ export interface ITerminalEnvVarCollectionService {
4141
*/
4242
isTerminalPromptSetCorrectly(resource?: Resource): boolean;
4343
}
44+
45+
export const IShellIntegrationService = Symbol('IShellIntegrationService');
46+
export interface IShellIntegrationService {
47+
isWorking(shell: string): Promise<boolean>;
48+
}

src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
GlobalEnvironmentVariableCollection,
1313
ProgressLocation,
1414
Uri,
15-
WorkspaceConfiguration,
1615
WorkspaceFolder,
1716
} from 'vscode';
1817
import {
@@ -38,6 +37,7 @@ import { IInterpreterService } from '../../../client/interpreter/contracts';
3837
import { PathUtils } from '../../../client/common/platform/pathUtils';
3938
import { PythonEnvType } from '../../../client/pythonEnvironments/base/info';
4039
import { PythonEnvironment } from '../../../client/pythonEnvironments/info';
40+
import { IShellIntegrationService } from '../../../client/terminals/types';
4141

4242
suite('Terminal Environment Variable Collection Service', () => {
4343
let platform: IPlatformService;
@@ -50,29 +50,28 @@ suite('Terminal Environment Variable Collection Service', () => {
5050
let applicationEnvironment: IApplicationEnvironment;
5151
let environmentActivationService: IEnvironmentActivationService;
5252
let workspaceService: IWorkspaceService;
53-
let workspaceConfig: WorkspaceConfiguration;
5453
let terminalEnvVarCollectionService: TerminalEnvVarCollectionService;
5554
const progressOptions = {
5655
location: ProgressLocation.Window,
5756
title: Interpreters.activatingTerminals,
5857
};
5958
let configService: IConfigurationService;
59+
let shellIntegrationService: IShellIntegrationService;
6060
const displayPath = 'display/path';
6161
const customShell = 'powershell';
6262
const defaultShell = defaultShells[getOSType()];
6363

6464
setup(() => {
6565
workspaceService = mock<IWorkspaceService>();
66-
workspaceConfig = mock<WorkspaceConfiguration>();
6766
when(workspaceService.getWorkspaceFolder(anything())).thenReturn(undefined);
6867
when(workspaceService.workspaceFolders).thenReturn(undefined);
69-
when(workspaceService.getConfiguration('terminal')).thenReturn(instance(workspaceConfig));
70-
when(workspaceConfig.get<boolean>('integrated.shellIntegration.enabled')).thenReturn(true);
7168
platform = mock<IPlatformService>();
7269
when(platform.osType).thenReturn(getOSType());
7370
interpreterService = mock<IInterpreterService>();
7471
context = mock<IExtensionContext>();
7572
shell = mock<IApplicationShell>();
73+
shellIntegrationService = mock<IShellIntegrationService>();
74+
when(shellIntegrationService.isWorking(anything())).thenResolve(true);
7675
globalCollection = mock<GlobalEnvironmentVariableCollection>();
7776
collection = mock<EnvironmentVariableCollection>();
7877
when(context.environmentVariableCollection).thenReturn(instance(globalCollection));
@@ -108,6 +107,7 @@ suite('Terminal Environment Variable Collection Service', () => {
108107
instance(workspaceService),
109108
instance(configService),
110109
new PathUtils(getOSType() === OSType.Windows),
110+
instance(shellIntegrationService),
111111
);
112112
});
113113

@@ -445,8 +445,8 @@ suite('Terminal Environment Variable Collection Service', () => {
445445
});
446446

447447
test('Correct track that prompt was set for PS1 if shell integration is disabled', async () => {
448-
reset(workspaceConfig);
449-
when(workspaceConfig.get<boolean>('integrated.shellIntegration.enabled')).thenReturn(false);
448+
reset(shellIntegrationService);
449+
when(shellIntegrationService.isWorking(anything())).thenResolve(false);
450450
when(platform.osType).thenReturn(OSType.Linux);
451451
const envVars: NodeJS.ProcessEnv = { VIRTUAL_ENV: 'prefix/to/venv', PS1: '(.venv)', ...process.env };
452452
const ps1Shell = 'bash';

src/test/terminals/serviceRegistry.unit.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import { TerminalCodeExecutionProvider } from '../../client/terminals/codeExecut
1313
import { TerminalDeactivateLimitationPrompt } from '../../client/terminals/envCollectionActivation/deactivatePrompt';
1414
import { TerminalIndicatorPrompt } from '../../client/terminals/envCollectionActivation/indicatorPrompt';
1515
import { TerminalEnvVarCollectionService } from '../../client/terminals/envCollectionActivation/service';
16+
import { ShellIntegrationService } from '../../client/terminals/envCollectionActivation/shellIntegrationService';
1617
import { registerTypes } from '../../client/terminals/serviceRegistry';
1718
import {
1819
ICodeExecutionHelper,
1920
ICodeExecutionManager,
2021
ICodeExecutionService,
22+
IShellIntegrationService,
2123
ITerminalAutoActivation,
2224
ITerminalEnvVarCollectionService,
2325
} from '../../client/terminals/types';
@@ -35,6 +37,7 @@ suite('Terminal - Service Registry', () => {
3537
[ITerminalEnvVarCollectionService, TerminalEnvVarCollectionService],
3638
[IExtensionSingleActivationService, TerminalIndicatorPrompt],
3739
[IExtensionSingleActivationService, TerminalDeactivateLimitationPrompt],
40+
[IShellIntegrationService, ShellIntegrationService],
3841
].forEach((args) => {
3942
if (args.length === 2) {
4043
services

0 commit comments

Comments
 (0)