Fix deactivate: command not found when deactivating restored or half-activated terminals#1529
Fix deactivate: command not found when deactivating restored or half-activated terminals#1529StellaHuang95 wants to merge 1 commit into
deactivate: command not found when deactivating restored or half-activated terminals#1529Conversation
|
Looping the terminal expert @anthonykim1 in for an opinion. For #1490 I went with a surgical workaround: the extension's deactivation command is now safe to send even when the deactivate function isn't defined. It checks first and only calls deactivate if it actually exists. That removes the visible "command not found" error. While digging into it, though, it's clear the activation system is pretty involved, especially around how state persists across terminal sessions and child processes. I wrote down my understanding of what a long-term fix would look like at the bottom of the PR description, but I think we should probably discuss whether we want to invest in it since it's complicated and require careful rewrite. For now, feel free to review the current fix if you think it's good to ship. Thanks! |
Description
TL;DR
In some scenarios a terminal ends up with
VIRTUAL_ENVstill set but thedeactivateshell function undefined. When the extension then issues a deactivate against that terminal, the shell printsdeactivate: 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 callsdeactivateif 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
deactivateshell function is not defined in the terminal when the extension tries to call it. The shell then printscommand not found.How does that happen? When the extension activates a Python venv, two different things get set up inside the shell:
VIRTUAL_ENVis set, and the venv'sbin/Scriptsdirectory is prepended toPATH. These are exported, so they're carried into child processes.deactivateis defined. It knows how to undo step 1 — restore the oldPATH, unsetVIRTUAL_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_ENVis still set butdeactivatehas gone missing — the shell looks "activated" but can't undo it. Known triggers include:unset -f deactivate, which is the easiest way to reproduce the bug deliberately.bashinsidezsh, etc.). The subshell inherits exported env vars but not the parent's function definitions.VIRTUAL_ENVis reintroduced via the new shell's environment.In every one of those scenarios, the resulting state is the same:
VIRTUAL_ENVset,PATHmodified, prompt still shows(.venv), butdeactivateis 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 whetherdeactivateis 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— addswrapDeactivationCommand(shell, command).src/features/common/activation.ts— calls the wrap fromgetDeactivationCommand.src/test/features/terminal/shells/common/shellUtils.unit.test.ts— 21 new unit tests covering every supported shell and command shape.What
wrapDeactivationCommanddoes: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 whetherdeactivateexists, and only run it if it does. For example, for bash/zsh/gitbash the command becomes:(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.baton cmd, fish'soverlay 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:
getDeactivationCommandinsrc/features/common/activation.ts. That function is the single chokepoint called by both deactivation paths interminalActivationState.ts(deactivateLegacyanddeactivateUsingShellIntegration), so every extension-initiated deactivation now flows through the wrap. No other code path is touched. Activation paths are completely unaffected.Result:
main, deactivating a half-activated terminal produces a visiblebash: deactivate: command not founderror.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_ENVis still set.$PATHstill has the venv directory prepended.(.venv).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_ENVand resettingPS1.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
shellStartupmode 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:deactivatefunction 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 definesdeactivate. That's one way the half-state can arise on its own, without the user doing anything.deactivateis missing for an unrelated reason (a plugin unset it, the user ranunset -f, anexecreplaced 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
deactivatecan 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
bashinside an activatedzshsilently inherits the guard and skips activation. After the structural fix, the subshell would re-runactivatebecause it doesn't seeVIRTUAL_ENVanddeactivatetogether. That means the venv'sbindirectory gets prepended toPATHa second time, leading to visible PATH duplication, anddeactivatein 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
shellStartupmode would still have the broken guard in their rc file. A full structural fix needs:getStartupCodeVersionhelper that reads it.setupStartuppath that detects a version mismatch and rewrites the block.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.