Skip to content

Commit 4aaf742

Browse files
Fix deactivate: command not found when deactivating restored or half-activated terminals (#1529)
## Description ### TL;DR In some scenarios a terminal ends up with `VIRTUAL_ENV` still set but the `deactivate` shell function undefined. When the extension then issues a deactivate against that terminal, the shell prints `deactivate: command not found`. This PR makes the extension's deactivation command **safe to send even when the function isn't there**: it now checks first, and only calls `deactivate` if it actually exists. This is a small, contained fix that removes the user-visible error in #1490. It does not redesign the underlying activation tracking — that's a separate, larger piece of work outlined at the bottom of this description. --- ### Background — why this bug happens The immediate cause of the error is simple: **the `deactivate` shell function is not defined in the terminal when the extension tries to call it.** The shell then prints `command not found`. How does that happen? When the extension activates a Python venv, two different things get set up inside the shell: 1. An **environment variable** called `VIRTUAL_ENV` is set, and the venv's `bin`/`Scripts` directory is prepended to `PATH`. These are **exported**, so they're carried into child processes. 2. A **shell function** called `deactivate` is defined. It knows how to undo step 1 — restore the old `PATH`, unset `VIRTUAL_ENV`, fix the prompt, and remove itself. **Shell functions are not exported** and don't carry across process boundaries the way env vars do. That asymmetry means there are several real-world scenarios in which `VIRTUAL_ENV` is still set but `deactivate` has gone missing — the shell looks "activated" but can't undo it. Known triggers include: - The user (or a script) ran `unset -f deactivate`, which is the easiest way to reproduce the bug deliberately. - The user ran a subshell (`bash` inside `zsh`, etc.). The subshell inherits exported env vars but not the parent's function definitions. - A VS Code terminal restore path that actually re-spawns the shell process (for example, certain remote / dev-container reconnection scenarios). VS Code's normal persistent-terminal feature keeps the shell process alive across window reloads, in which case the function *is* preserved; the buggy paths are the ones where a fresh shell process is started but `VIRTUAL_ENV` is reintroduced via the new shell's environment. In every one of those scenarios, the resulting state is the same: `VIRTUAL_ENV` set, `PATH` modified, prompt still shows `(.venv)`, but `deactivate` is gone. The wrap added by this PR fixes the user-visible error in **all** of them — it doesn't need to know which trigger fired, only whether `deactivate` is callable right now. --- ### What this PR changes This PR fixes the symptom at the smallest possible code surface: the **one function** the extension uses to generate the deactivation command before sending it to the terminal. **Files changed:** - `src/features/terminal/shells/common/shellUtils.ts` — adds `wrapDeactivationCommand(shell, command)`. - `src/features/common/activation.ts` — calls the wrap from `getDeactivationCommand`. - `src/test/features/terminal/shells/common/shellUtils.unit.test.ts` — 21 new unit tests covering every supported shell and command shape. **What `wrapDeactivationCommand` does:** For shells where the deactivation command is the bare token `deactivate` (bash, zsh, fish, pwsh, gitbash), the wrap rewrites the command into a **guarded call**: check whether `deactivate` exists, and only run it if it does. For example, for bash/zsh/gitbash the command becomes: ```bash command -v deactivate >/dev/null 2>&1 && deactivate ``` (The leading space is preserved so the command stays out of shell history, just like before.) For shells where the deactivation command is something else — `conda deactivate`, `pyenv shell --unset`, `deactivate.bat` on cmd, fish's `overlay hide`, or anything we don't recognize — the wrap returns the command **unchanged**. We only protect bare-token cases where we're confident a missing function is the problem. **Where the wrap hooks in:** In exactly one place: `getDeactivationCommand` in `src/features/common/activation.ts`. That function is the single chokepoint called by both deactivation paths in `terminalActivationState.ts` (`deactivateLegacy` and `deactivateUsingShellIntegration`), so every extension-initiated deactivation now flows through the wrap. No other code path is touched. Activation paths are completely unaffected. **Result:** - On `main`, deactivating a half-activated terminal produces a visible `bash: deactivate: command not found` error. - With this PR, the same scenario silently no-ops — no error message, no state change. --- ### What this PR does NOT fix This is important to call out this is a "surgical" fix so reviewers and users have the right expectations. The PR removes the visible error but **does not clean up the half-activated state**. After deactivating a terminal that was in the broken state: - `$VIRTUAL_ENV` is still set. - `$PATH` still has the venv directory prepended. - The prompt still shows `(.venv)`. - The extension's internal tracker considers the terminal "deactivated," so subsequent deactivate commands are no-ops. The terminal is in a recoverable but mildly confusing state. The user can recover by closing the terminal and opening a new one (which auto-activates cleanly), or by manually unsetting `VIRTUAL_ENV` and resetting `PS1`. The reason we don't address this in the same PR is that fully cleaning up the half-state requires changes that are **fundamentally bigger and riskier** — the entire section below explains why. --- ### Why a structural fix is needed (and why it's a separate piece of work) The wrap removes the visible error, but it doesn't prevent the underlying half-activated state from arising. To prevent it, the rc snippet that the extension installs in `shellStartup` mode needs to be smarter than it is today. Today's snippet uses an exported environment variable, `VSCODE_PYTHON_AUTOACTIVATE_GUARD`, as a boolean "I've already activated, skip" flag. The design has two practical problems: - **The guard is exported, so it propagates to places the `deactivate` function does not.** A child shell or a re-spawned shell can inherit the guard, decide it's "already activated," and skip activation — including the part that defines `deactivate`. That's one way the half-state can arise on its own, without the user doing anything. - **The guard tracks a synthetic boolean rather than the real state.** Even if `deactivate` is missing for an unrelated reason (a plugin unset it, the user ran `unset -f`, an `exec` replaced the process), the guard still says "activated" and the snippet declines to re-define the function. So while the guard isn't the *only* way `deactivate` can go missing, it's a design that lets the half-state stick around even when the shell has a fresh chance to fix itself. #### Why this is a separate, larger PR There are three real costs that make this not a casual one-line change: **1. It changes behavior in interactive subshells.** Today, opening `bash` inside an activated `zsh` silently inherits the guard and skips activation. After the structural fix, the subshell would re-run `activate` because it doesn't see `VIRTUAL_ENV` and `deactivate` together. That means the venv's `bin` directory gets prepended to `PATH` a second time, leading to visible PATH duplication, and `deactivate` in the subshell only partially undoes things. Manageable but real. **2. Existing users wouldn't get the fix without a migration.** The code that installs the rc snippet — `editUtils.ts hasStartupCode` — only checks for region markers and an environment-key string. It does not compare the script version. So if a user already has the old snippet in their `.bashrc`/`.zshrc`/etc., the extension thinks setup is done and never rewrites it. The version comment in the snippet today is for humans, not code. Shipping a new snippet without a migration path means **only new users get the fix**. Everyone who already enabled `shellStartup` mode would still have the broken guard in their rc file. A full structural fix needs: - A parseable version line inside the managed region. - A `getStartupCodeVersion` helper that reads it. - A `setupStartup` path that detects a version mismatch and rewrites the block. - Atomic write-with-rename to avoid corrupting rc files mid-migration. - Careful preservation of everything outside the managed markers. Editing user rc files automatically is a sensitive operation. It needs its own PR with its own review and its own tests. That's a multi-PR effort — appropriate for the root-cause fix, inappropriate to bolt onto a small bug-fix PR. ---
1 parent 39a1998 commit 4aaf742

3 files changed

Lines changed: 158 additions & 1 deletion

File tree

src/features/common/activation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getShellActivationCommand,
55
getShellCommandAsString,
66
getShellDeactivationCommand,
7+
wrapDeactivationCommand,
78
} from '../terminal/shells/common/shellUtils';
89
import { identifyTerminalShell } from './shellDetector';
910

@@ -28,7 +29,7 @@ export function getDeactivationCommand(terminal: Terminal, environment: PythonEn
2829
const shell = identifyTerminalShell(terminal);
2930
const command = getShellDeactivationCommand(shell, environment);
3031
if (command) {
31-
return getShellCommandAsString(shell, command);
32+
return wrapDeactivationCommand(shell, getShellCommandAsString(shell, command));
3233
}
3334
return undefined;
3435
}

src/features/terminal/shells/common/shellUtils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,68 @@ export function getShellCommandAsString(shell: string, command: PythonCommandRun
5050
return commandStr;
5151
}
5252

53+
// Shells whose bare `deactivate` command is a shell function/alias defined by venv's
54+
// `activate` script. These can disappear (e.g. on session restore where VIRTUAL_ENV
55+
// persists but the shell function does not), so we must guard the call with an
56+
// existence check before sending it to the terminal — otherwise the shell prints
57+
// `deactivate: command not found`.
58+
const bareDeactivateGuardByShell = new Map<string, (cmd: string) => string>([
59+
// POSIX-family shells: `command -v <name>` is the portable existence check.
60+
[ShellConstants.BASH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
61+
[ShellConstants.SH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
62+
[ShellConstants.ZSH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
63+
[ShellConstants.KSH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
64+
[ShellConstants.GITBASH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
65+
// fish uses `functions -q` for function existence.
66+
[ShellConstants.FISH, (cmd) => `functions -q deactivate; and ${cmd.trimStart()}`],
67+
// PowerShell: Get-Command returns silently if not found with -ErrorAction SilentlyContinue.
68+
[ShellConstants.PWSH, (cmd) => `if (Get-Command deactivate -ErrorAction SilentlyContinue) { ${cmd.trimStart()} }`],
69+
]);
70+
71+
/**
72+
* Returns the bare `deactivate` token if and only if `command` represents a single,
73+
* bare invocation of a shell function/alias literally named `deactivate` — meaning
74+
* it is safe and meaningful to gate on the existence of that function in the shell.
75+
*
76+
* Returns `undefined` for anything else (full paths like `path/to/deactivate.bat`,
77+
* multi-token forms like `conda deactivate`, `pyenv shell --unset`, `overlay hide ...`,
78+
* etc.), since those have different failure modes that should not be silently swallowed.
79+
*
80+
* The token is normalized to lowercase so the generated guard is consistent across
81+
* shells (notably PowerShell, which is case-insensitive).
82+
*/
83+
function bareDeactivateInvocation(command: string): string | undefined {
84+
const trimmed = command.trim();
85+
return trimmed.toLowerCase() === 'deactivate' ? 'deactivate' : undefined;
86+
}
87+
88+
/**
89+
* Wraps a deactivation command in a shell-specific existence guard so that sending
90+
* it to a terminal where the `deactivate` shell function no longer exists does not
91+
* print `deactivate: command not found`.
92+
*
93+
* Only applies when the command is a single bare `deactivate` token and the shell
94+
* has a known guard template. All other deactivation forms (cmd's `deactivate.bat`
95+
* path, `conda deactivate`, `pyenv shell --unset`, nu's `overlay hide ...`, etc.)
96+
* are returned unchanged — their failure modes are legitimate and should surface.
97+
*/
98+
export function wrapDeactivationCommand(shell: string, command: string): string {
99+
const bare = bareDeactivateInvocation(command);
100+
if (!bare) {
101+
return command;
102+
}
103+
const guard = bareDeactivateGuardByShell.get(shell);
104+
if (!guard) {
105+
return command;
106+
}
107+
const wrapped = guard(bare);
108+
// Preserve the leading-space history-ignore behavior for shells that honor it.
109+
if (shellsWithLeadingSpaceHistorySupport.has(shell)) {
110+
return ` ${wrapped}`;
111+
}
112+
return wrapped;
113+
}
114+
53115
export function normalizeShellPath(filePath: string, shellType?: string): string {
54116
if (isWindows() && shellType) {
55117
if (shellType.toLowerCase() === ShellConstants.GITBASH || shellType.toLowerCase() === 'git-bash') {

src/test/features/terminal/shells/common/shellUtils.unit.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
PROFILE_TAG_END,
88
PROFILE_TAG_START,
99
shellsWithLeadingSpaceHistorySupport,
10+
wrapDeactivationCommand,
1011
} from '../../../../../features/terminal/shells/common/shellUtils';
1112

1213
suite('Shell Utils', () => {
@@ -205,4 +206,97 @@ suite('Shell Utils', () => {
205206
});
206207
});
207208
});
209+
210+
suite('wrapDeactivationCommand', () => {
211+
// Each tuple: [shell, expected guard substring]. Guards must include the bare
212+
// `deactivate` token so the wrapped command still deactivates when the function
213+
// exists. See issue #1490.
214+
const posixShellsCases: Array<[string, string]> = [
215+
[ShellConstants.BASH, 'command -v deactivate >/dev/null 2>&1 && deactivate'],
216+
[ShellConstants.SH, 'command -v deactivate >/dev/null 2>&1 && deactivate'],
217+
[ShellConstants.ZSH, 'command -v deactivate >/dev/null 2>&1 && deactivate'],
218+
[ShellConstants.KSH, 'command -v deactivate >/dev/null 2>&1 && deactivate'],
219+
[ShellConstants.GITBASH, 'command -v deactivate >/dev/null 2>&1 && deactivate'],
220+
];
221+
222+
posixShellsCases.forEach(([shell, expected]) => {
223+
test(`wraps bare deactivate with existence guard for ${shell}`, () => {
224+
const result = wrapDeactivationCommand(shell, 'deactivate');
225+
assert.ok(
226+
result.includes(expected),
227+
`Expected wrapped command to contain '${expected}', got '${result}'`,
228+
);
229+
});
230+
231+
test(`tolerates a leading space (history-ignore) on input for ${shell}`, () => {
232+
const result = wrapDeactivationCommand(shell, ' deactivate');
233+
assert.ok(
234+
result.includes(expected),
235+
`Expected wrapped command to contain '${expected}', got '${result}'`,
236+
);
237+
});
238+
});
239+
240+
test('preserves leading space for shells that support history-ignore', () => {
241+
// bash/zsh/gitbash get a leading space so HISTCONTROL=ignorespace works.
242+
assert.ok(wrapDeactivationCommand(ShellConstants.BASH, 'deactivate').startsWith(' '));
243+
assert.ok(wrapDeactivationCommand(ShellConstants.ZSH, 'deactivate').startsWith(' '));
244+
assert.ok(wrapDeactivationCommand(ShellConstants.GITBASH, 'deactivate').startsWith(' '));
245+
});
246+
247+
test('does not add leading space for shells without history-ignore support', () => {
248+
assert.ok(!wrapDeactivationCommand(ShellConstants.PWSH, 'deactivate').startsWith(' '));
249+
assert.ok(!wrapDeactivationCommand(ShellConstants.FISH, 'deactivate').startsWith(' '));
250+
});
251+
252+
test('wraps bare deactivate with functions -q for fish', () => {
253+
const result = wrapDeactivationCommand(ShellConstants.FISH, 'deactivate');
254+
assert.strictEqual(result, 'functions -q deactivate; and deactivate');
255+
});
256+
257+
test('wraps bare deactivate with Get-Command for pwsh', () => {
258+
const result = wrapDeactivationCommand(ShellConstants.PWSH, 'deactivate');
259+
assert.strictEqual(result, 'if (Get-Command deactivate -ErrorAction SilentlyContinue) { deactivate }');
260+
});
261+
262+
test('passes through non-bare deactivate commands unchanged (cmd full path)', () => {
263+
const cmd = 'C:\\envs\\myenv\\Scripts\\deactivate.bat';
264+
const result = wrapDeactivationCommand(ShellConstants.CMD, cmd);
265+
assert.strictEqual(result, cmd);
266+
});
267+
268+
test('passes through conda deactivate unchanged (legitimate failure if conda missing)', () => {
269+
const result = wrapDeactivationCommand(ShellConstants.BASH, 'conda deactivate');
270+
assert.strictEqual(result, 'conda deactivate');
271+
});
272+
273+
test('passes through pyenv shell --unset unchanged', () => {
274+
const result = wrapDeactivationCommand(ShellConstants.BASH, 'pyenv shell --unset');
275+
assert.strictEqual(result, 'pyenv shell --unset');
276+
});
277+
278+
test('passes through nu overlay hide unchanged', () => {
279+
const result = wrapDeactivationCommand(ShellConstants.NU, 'overlay hide activate');
280+
assert.strictEqual(result, 'overlay hide activate');
281+
});
282+
283+
test('passes through unknown shell unchanged even for bare deactivate', () => {
284+
const result = wrapDeactivationCommand('unknown', 'deactivate');
285+
assert.strictEqual(result, 'deactivate');
286+
});
287+
288+
test('passes through case-mismatched tokens unchanged (no false-positive wraps)', () => {
289+
// Only an exact `deactivate` token triggers wrapping; anything containing other
290+
// tokens (e.g. wrapper functions in user shells) is the user's responsibility.
291+
const result = wrapDeactivationCommand(ShellConstants.BASH, 'my-deactivate');
292+
assert.strictEqual(result, 'my-deactivate');
293+
});
294+
295+
test('wraps DEACTIVATE token case-insensitively (pwsh)', () => {
296+
// PowerShell is case-insensitive; the activation script's function may be
297+
// reported in any case. The wrap should still apply.
298+
const result = wrapDeactivationCommand(ShellConstants.PWSH, 'DEACTIVATE');
299+
assert.strictEqual(result, 'if (Get-Command deactivate -ErrorAction SilentlyContinue) { deactivate }');
300+
});
301+
});
208302
});

0 commit comments

Comments
 (0)