Commit 4aaf742
authored
Fix
## 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.
---deactivate: command not found when deactivating restored or half-activated terminals (#1529)1 parent 39a1998 commit 4aaf742
3 files changed
Lines changed: 158 additions & 1 deletion
File tree
- src
- features
- common
- terminal/shells/common
- test/features/terminal/shells/common
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| |||
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
31 | | - | |
| 32 | + | |
32 | 33 | | |
33 | 34 | | |
34 | 35 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
53 | 115 | | |
54 | 116 | | |
55 | 117 | | |
| |||
Lines changed: 94 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
| |||
205 | 206 | | |
206 | 207 | | |
207 | 208 | | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
208 | 302 | | |
0 commit comments