Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/common/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export enum EventNames {
* - errorType: string (error class name, on failure only)
*/
ENVIRONMENT_DISCOVERY = 'ENVIRONMENT_DISCOVERY',
/**
* One event per terminal activation/deactivation command attempt.
* Legacy sendText cannot confirm command completion and is reported as unverified.
*/
TERMINAL_ACTIVATION_OUTCOME = 'TERMINAL.ACTIVATION_OUTCOME',
MANAGER_READY_TIMEOUT = 'MANAGER_READY.TIMEOUT',
/**
* Telemetry event for individual manager registration failure.
Expand Down Expand Up @@ -234,6 +239,23 @@ export enum EventNames {

// Map all events to their properties
export interface IEventNamePropertyMapping {
/* __GDPR__
"terminal.activation_outcome": {
"operation": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"method": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"shell": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
}
*/
[EventNames.TERMINAL_ACTIVATION_OUTCOME]: {
operation: 'activate' | 'deactivate';
outcome: 'succeeded' | 'failed' | 'timedOut' | 'unknown' | 'unverified' | 'noCommand';
method: 'shellIntegration' | 'sendText';
shell: string;
trigger: 'terminalOpen' | 'preExisting' | 'explicit' | 'environmentSwitch' | 'unknown';
};
/* __GDPR__
"extension.activation_duration": {
"duration" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
Expand Down
165 changes: 75 additions & 90 deletions src/features/terminal/terminalActivationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ import {
} from 'vscode';
import { PythonEnvironment } from '../../api';
import { traceError, traceInfo, traceVerbose } from '../../common/logging';
import { StopWatch } from '../../common/stopWatch';
import { EventNames } from '../../common/telemetry/constants';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
import { onDidEndTerminalShellExecution, onDidStartTerminalShellExecution } from '../../common/window.apis';
import { getActivationCommand, getDeactivationCommand } from '../common/activation';
import { identifyTerminalShell } from '../common/shellDetector';
import { getShellIntegrationTimeout, isTaskTerminal, shouldSkipTerminalActivation } from './utils';

type ActivationTrigger = 'terminalOpen' | 'preExisting' | 'explicit' | 'environmentSwitch' | 'unknown';
type CommandOutcome = 'succeeded' | 'failed' | 'timedOut' | 'unknown' | 'unverified' | 'noCommand';

export interface DidChangeTerminalActivationStateEvent {
terminal: Terminal;
environment: PythonEnvironment;
Expand All @@ -21,8 +28,8 @@ export interface DidChangeTerminalActivationStateEvent {

export interface TerminalActivation {
isActivated(terminal: Terminal, environment?: PythonEnvironment): boolean;
activate(terminal: Terminal, environment: PythonEnvironment): Promise<void>;
deactivate(terminal: Terminal): Promise<void>;
activate(terminal: Terminal, environment: PythonEnvironment, trigger?: ActivationTrigger): Promise<void>;
deactivate(terminal: Terminal, trigger?: ActivationTrigger): Promise<void>;
onDidChangeTerminalActivationState: Event<DidChangeTerminalActivationStateEvent>;
}

Expand Down Expand Up @@ -50,8 +57,8 @@ export class TerminalActivationImpl implements TerminalActivationInternal {
private onTerminalClosed = this.onTerminalClosedEmitter.event;

private activatedTerminals = new Map<Terminal, PythonEnvironment>();
private activatingTerminals = new Map<Terminal, Promise<void>>();
private deactivatingTerminals = new Map<Terminal, Promise<void>>();
private activatingTerminals = new Map<Terminal, Promise<CommandOutcome>>();
private deactivatingTerminals = new Map<Terminal, Promise<CommandOutcome>>();

constructor() {
this.disposables.push(
Expand Down Expand Up @@ -85,7 +92,11 @@ export class TerminalActivationImpl implements TerminalActivationInternal {
return this.activatedTerminals.get(terminal);
}

async activate(terminal: Terminal, environment: PythonEnvironment): Promise<void> {
async activate(
terminal: Terminal,
environment: PythonEnvironment,
trigger: ActivationTrigger = 'unknown',
): Promise<void> {
if (shouldSkipTerminalActivation(terminal)) {
traceVerbose('Skipping activation for this terminal');
return;
Expand All @@ -98,12 +109,14 @@ export class TerminalActivationImpl implements TerminalActivationInternal {

if (this.deactivatingTerminals.has(terminal)) {
traceVerbose('Terminal is being deactivated, cannot activate.');
return this.deactivatingTerminals.get(terminal);
await this.deactivatingTerminals.get(terminal);
return;
}

if (this.activatingTerminals.has(terminal)) {
traceVerbose('Terminal is being activated, skipping.');
return this.activatingTerminals.get(terminal);
await this.activatingTerminals.get(terminal);
return;
}

const terminalEnv = this.activatedTerminals.get(terminal);
Expand All @@ -115,50 +128,57 @@ export class TerminalActivationImpl implements TerminalActivationInternal {
traceInfo(
`Terminal is activated with a different environment, deactivating: ${terminalEnv.environmentPath.fsPath}`,
);
await this.deactivate(terminal);
await this.deactivate(terminal, 'environmentSwitch');
}
}

try {
const promise = this.activateInternal(terminal, environment);
const promise = this.runCommand(terminal, environment, 'activate', trigger);
traceVerbose(`Activating terminal: ${environment.environmentPath.fsPath}`);
this.activatingTerminals.set(terminal, promise);
await promise;
const outcome = await promise;
this.activatingTerminals.delete(terminal);
this.updateActivationState(terminal, environment, true);
traceInfo(`Terminal is activated: ${environment.environmentPath.fsPath}`);
if (outcome === 'succeeded' || outcome === 'unverified') {
// sendText has no completion signal; preserve its existing optimistic UI behavior.
this.updateActivationState(terminal, environment, true);
traceInfo(`Terminal activation sent: ${environment.environmentPath.fsPath} (${outcome})`);
}
} catch (ex) {
this.activatingTerminals.delete(terminal);
traceError('Failed to activate environment:\r\n', ex);
}
}

async deactivate(terminal: Terminal): Promise<void> {
async deactivate(terminal: Terminal, trigger: ActivationTrigger = 'unknown'): Promise<void> {
if (isTaskTerminal(terminal)) {
traceVerbose('Cannot deactivate environment in a task terminal');
return;
}

if (this.activatingTerminals.has(terminal)) {
traceVerbose('Terminal is being activated, cannot deactivate.');
return this.activatingTerminals.get(terminal);
await this.activatingTerminals.get(terminal);
return;
}

if (this.deactivatingTerminals.has(terminal)) {
traceVerbose('Terminal is being deactivated, skipping.');
return this.deactivatingTerminals.get(terminal);
await this.deactivatingTerminals.get(terminal);
return;
}

const terminalEnv = this.activatedTerminals.get(terminal);
if (terminalEnv) {
try {
const promise = this.deactivateInternal(terminal, terminalEnv);
const promise = this.runCommand(terminal, terminalEnv, 'deactivate', trigger);
traceVerbose(`Deactivating terminal: ${terminalEnv.environmentPath.fsPath}`);
this.deactivatingTerminals.set(terminal, promise);
await promise;
const outcome = await promise;
this.deactivatingTerminals.delete(terminal);
this.updateActivationState(terminal, terminalEnv, false);
traceInfo(`Terminal is deactivated: ${terminalEnv.environmentPath.fsPath}`);
if (outcome === 'succeeded' || outcome === 'unverified') {
this.updateActivationState(terminal, terminalEnv, false);
traceInfo(`Terminal deactivation sent: ${terminalEnv.environmentPath.fsPath} (${outcome})`);
}
} catch (ex) {
this.deactivatingTerminals.delete(terminal);
traceError('Failed to deactivate environment:\r\n', ex);
Expand All @@ -183,93 +203,62 @@ export class TerminalActivationImpl implements TerminalActivationInternal {
this.disposables.forEach((d) => d.dispose());
}

private async activateInternal(terminal: Terminal, environment: PythonEnvironment): Promise<void> {
if (terminal.shellIntegration) {
await this.activateUsingShellIntegration(terminal.shellIntegration, terminal, environment);
} else {
this.activateLegacy(terminal, environment);
}
}

private async deactivateInternal(terminal: Terminal, environment: PythonEnvironment): Promise<void> {
if (terminal.shellIntegration) {
await this.deactivateUsingShellIntegration(terminal.shellIntegration, terminal, environment);
} else {
this.deactivateLegacy(terminal, environment);
}
}

private activateLegacy(terminal: Terminal, environment: PythonEnvironment) {
const activationCommands = getActivationCommand(terminal, environment);
if (activationCommands) {
terminal.sendText(activationCommands);
this.activatedTerminals.set(terminal, environment);
}
}

private deactivateLegacy(terminal: Terminal, environment: PythonEnvironment) {
const deactivationCommands = getDeactivationCommand(terminal, environment);
if (deactivationCommands) {
terminal.sendText(deactivationCommands);
this.activatedTerminals.delete(terminal);
}
}

private async activateUsingShellIntegration(
shellIntegration: TerminalShellIntegration,
private async runCommand(
terminal: Terminal,
environment: PythonEnvironment,
): Promise<void> {
const activationCommand = getActivationCommand(terminal, environment);
if (activationCommand) {
try {
await this.executeTerminalShellCommandInternal(shellIntegration, activationCommand);
this.activatedTerminals.set(terminal, environment);
} catch {
traceError('Failed to activate environment using shell integration');
operation: 'activate' | 'deactivate',
trigger: ActivationTrigger,
): Promise<CommandOutcome> {
const watch = new StopWatch();
const method = terminal.shellIntegration ? 'shellIntegration' : 'sendText';
let outcome: CommandOutcome = 'failed';
try {
const command =
operation === 'activate'
? getActivationCommand(terminal, environment)
: getDeactivationCommand(terminal, environment);
if (!command) {
outcome = 'noCommand';
} else if (terminal.shellIntegration) {
outcome = await this.executeTerminalShellCommandInternal(terminal.shellIntegration, command);
} else {
terminal.sendText(command);
outcome = 'unverified';
}
} else {
traceVerbose('No activation commands found for terminal.');
} catch (error) {
traceError(`Failed to ${operation} terminal environment`, error);
}
}

private async deactivateUsingShellIntegration(
shellIntegration: TerminalShellIntegration,
terminal: Terminal,
environment: PythonEnvironment,
): Promise<void> {
const deactivationCommand = getDeactivationCommand(terminal, environment);
if (deactivationCommand) {
try {
await this.executeTerminalShellCommandInternal(shellIntegration, deactivationCommand);
this.activatedTerminals.delete(terminal);
} catch {
traceError('Failed to deactivate environment using shell integration');
}
} else {
traceVerbose('No deactivation commands found for terminal.');
if (outcome !== 'succeeded' && outcome !== 'unverified') {
traceError(`Terminal ${operation} outcome: ${outcome}`);
}
sendTelemetryEvent(EventNames.TERMINAL_ACTIVATION_OUTCOME, watch.elapsedTime, {
operation,
outcome,
method,
shell: identifyTerminalShell(terminal),
trigger,
});
return outcome;
}

private async executeTerminalShellCommandInternal(
shellIntegration: TerminalShellIntegration,
command: string,
): Promise<boolean> {
): Promise<CommandOutcome> {
const execution = shellIntegration.executeCommand(command);
const disposables: Disposable[] = [];
const timeoutMs = getShellIntegrationTimeout();

const promise = new Promise<void>((resolve) => {
const promise = new Promise<CommandOutcome>((resolve) => {
const timer = setTimeout(() => {
traceError(`Shell execution timed out: ${command}`);
resolve();
resolve('timedOut');
}, timeoutMs);

disposables.push(
new Disposable(() => clearTimeout(timer)),
this.onTerminalShellExecutionEnd((e: TerminalShellExecutionEndEvent) => {
if (e.execution === execution) {
resolve();
resolve(e.exitCode === 0 ? 'succeeded' : e.exitCode === undefined ? 'unknown' : 'failed');
}
}),
this.onTerminalShellExecutionStart((e: TerminalShellExecutionStartEvent) => {
Expand All @@ -281,11 +270,7 @@ export class TerminalActivationImpl implements TerminalActivationInternal {
});

try {
await promise;
return true;
} catch {
traceError(`Failed to execute shell command: ${command}`);
return false;
return await promise;
} finally {
disposables.forEach((d) => d.dispose());
}
Expand Down
8 changes: 4 additions & 4 deletions src/features/terminal/terminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export class TerminalManagerImpl implements TerminalManager {
},
async () => {
await waitForShellIntegration(terminal);
await this.activate(terminal, environment);
await this.ta.activate(terminal, environment, 'terminalOpen');
},
);
} else {
Expand Down Expand Up @@ -402,7 +402,7 @@ export class TerminalManagerImpl implements TerminalManager {
const env = this.ta.getEnvironment(t) ?? (await getEnvironmentForTerminal(api, t));

if (env && isActivatableEnvironment(env)) {
await this.activate(t, env);
await this.ta.activate(t, env, 'preExisting');
}
}

Expand Down Expand Up @@ -456,11 +456,11 @@ export class TerminalManagerImpl implements TerminalManager {
}

public activate(terminal: Terminal, environment: PythonEnvironment): Promise<void> {
return this.ta.activate(terminal, environment);
return this.ta.activate(terminal, environment, 'explicit');
}

public deactivate(terminal: Terminal): Promise<void> {
return this.ta.deactivate(terminal);
return this.ta.deactivate(terminal, 'explicit');
}

isActivated(terminal: Terminal, environment?: PythonEnvironment): boolean {
Expand Down
Loading
Loading