Skip to content

Fix five PEP 723 inline-script defects: cancellation, rename, shared builds, terminal scope, and package drift - #1772

Merged
Stella Huang (StellaHuang95) merged 6 commits into
microsoft:mainfrom
StellaHuang95:fix/inline-script-cancel-recovery
Sep 9, 2026
Merged

Fix five PEP 723 inline-script defects: cancellation, rename, shared builds, terminal scope, and package drift#1772
Stella Huang (StellaHuang95) merged 6 commits into
microsoft:mainfrom
StellaHuang95:fix/inline-script-cancel-recovery

Conversation

@StellaHuang95

@StellaHuang95 Stella Huang (StellaHuang95) commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Five fixes to the PEP 723 inline-script feature, found while working through reported issues against the testbed. Each is independently reviewable and has unit coverage that was checked to fail when the corresponding guard is removed, not merely to pass.

The feature remains behind the undeclared python-envs.inlineScripts.enabled flag (default false), so nothing here is user-visible until that is enabled.

Commits and the issue each addresses

Commit Issue addressed
0a21a2c1 Cancelling setup retained the cache lock with no cleanup, so the retry failed with Lock was retained after an interrupted operation
887548d8 Renaming a script dropped its environment association even though nothing about the environment changed
eaa71423 Cancelling a build shared by two scripts reported cancellation to only one of them
22628e1f Selecting a script environment could leak into general workspace terminals
669cb8ed Editing packages on a shared environment silently broke other scripts

Two earlier commits on this branch (9acf5a21, cd7d214a) predate this work and are unrelated to the above.


0a21a2c1 — Discard cancelled environments instead of quarantining them

Cancelling package installation retained the cache-entry lock and made no cleanup attempt. The cancellation surfaced as a generic "Failed to set up the environment for this script", and the next attempt failed with ELOCKRETAINED. Recovery required clearing the entire cache.

Cancellation now cleans up automatically. discardCacheEntry deletes .meta.json and any .meta.json.backup-* first, then removes the directory with bounded retry.

The key invariant for reviewers: the sidecar is the correctness guarantee, not the directory. inspectCacheEntry treats a missing sidecar as stale, and writeMetaJson is the only writer of that file — four call sites, all under the cache-entry lock (see the comment above withCacheEntryLock). A surviving installer writes into site-packages and cannot recreate a sidecar. So an entry whose directory survives deletion is inert, and gets rebuilt or TTL-swept rather than reused.

Retry exists because a just-stopped installer can hold file handles briefly, most visibly on Windows.

Also fixes two bulk-setup bugs: setUpInlineScriptEnvironmentsInWorkspace only counted successes and never surfaced outcomes, so a mid-run cancellation was invisible and the next install started immediately. It now stops the run on cancellation and reports failures distinctly.

887548d8 — Follow renames instead of dropping associations

A cache entry is keyed by normalized dependencies and base interpreter — the script path is not an input — so a rename cannot invalidate it. Neither the metadata block nor the environment changes.

This also left two subsystems disagreeing. PythonProjectManagerImpl already follows renames via updatePythonProjectSettingPath, which rewrites the pythonProjects entry and preserves its _inlineScriptRegistration marker. Clearing the association therefore produced a managed inline-script project entry pointing at a file with no environment behind it.

The record is transferred in one persistence transaction and then re-validated rather than trusted: its metadata binding is content-derived, so if the file at the new path no longer matches, ordinary validation clears it and the CodeLens returns.

Guards: destination must still be a routable local .py; renaming onto an already-associated script replaces it; deletes are unchanged.

Known gap: directory renames are not covered — VS Code reports one event per folder rather than one per contained file. Moving an individual file already works, since VS Code reports a move as a rename.

eaa71423 — Cancellation reaches every script sharing a build

Scripts whose dependencies normalize to the same list resolve to the same cache key, so a second request joins the in-flight build via pendingCreations instead of starting its own. Cancelling recorded an outcome only against the initiating URI. The joined script fell through to a generic failure, and in a bulk run its outcome was not a cancellation — so the run kept installing.

The failure is now recorded on the shared PendingCreationContext and translated per caller after awaiting the shared promise.

Note for reviewers: same-file coalescing (pendingSetups) and different-file shared builds (pendingCreations) are separate mechanisms. Only the second was broken. Bulk setup is sequential, so selecting both files in one bulk run does not reproduce it — the second request must overlap the first build.

22628e1f — Shell startup variables resolve at folder scope

Shell-startup activation keeps a single activation command per workspace folder (for example VSCODE_PYTHON_PWSH_ACTIVATE), injected into every terminal opened there. handleEnvironmentChange wrote whichever environment the event carried into that slot after collapsing the event's URI to its containing folder — so a per-file selection became the folder default.

The handler now resolves the folder's own environment via getEnvironment(workspaceFolder.uri). This matches what initializeInternal already did, so the two paths no longer disagree, and it fixes the whole class rather than special-casing the inline-script manager: any file-scoped environment is excluded. A folder already polluted by an earlier session self-repairs on the next environment change.

Only reachable with python-envs.terminal.autoActivationType: "shellStartup"; the default is "command". Adds the first unit coverage for this manager.

669cb8ed — Invalidate an environment when its packages are edited

Cache entries are shared by design. Editing packages there through the package UI silently affected every bound script: nothing validated installed versions, so the entry stayed valid, the CodeLens stayed hidden, and a script the user never opened would run the wrong version while its own header still declared the original pin. Re-running setup reused the modified entry rather than repairing it.

A package change on an owned entry now records manuallyModified in the sidecar and un-routes every associated script, so the CodeLens returns for each. inspectCacheEntry treats a marked entry as stale, so the next setup rebuilds from declared metadata.

Please look closely at the in-flight guard. Setup installs through managePackages, which fires this same event. Without the guard, every setup would mark the environment it had just built and rebuild endlessly. The guard relies on VS Code's EventEmitter being synchronous, so a build still registered in pendingCreations is recognised before any await; the check is repeated once the lock is held.

Known gaps, deliberately not addressed: sharing is still not surfaced before an edit; a deliberate ad-hoc install is discarded by the next setup without explanation; package changes made outside VS Code fire no event and remain undetected.


@StellaHuang95 Stella Huang (StellaHuang95) added the bug Issue identified by VS Code Team member as probable bug label Sep 9, 2026
…them

Cancelling a PEP 723 environment setup used to retain the cache-entry lock
with no cleanup attempt, so the cancellation surfaced as a generic "Failed to
set up the environment for this script" error and the next attempt failed with
"Lock was retained after an interrupted operation". Recovering required
clearing the entire inline-script cache.

Cancellation now cleans up automatically:

- `discardCacheEntry` removes `.meta.json` and any `.meta.json.backup-*` first,
  which is the correctness guarantee: `inspectCacheEntry` treats a missing
  sidecar as stale, so an entry whose directory survives is inert and gets
  rebuilt or swept by TTL eviction rather than reused.
- Directory removal is retried with a short backoff, because a just-stopped
  installer can briefly hold file handles (most visibly on Windows).
- No lock is retained on cancellation, so `ELOCKRETAINED` can no longer be
  reached from this path and retrying the CodeLens simply rebuilds.

The user-visible result is a single informational "Environment setup was
canceled." message with nothing to clean up or confirm.

Also fixes two bulk-setup bugs. `setUpInlineScriptEnvironmentsInWorkspace`
only counted successes and never reported outcomes, so a cancellation mid-run
was invisible and the next script's install started immediately. It now stops
the run on cancellation and reports failures distinctly. This matters because
cache keys are shared by design: scripts whose dependencies normalize to the
same list resolve to the same cache entry, so one cancellation could
previously poison a sibling script in the same run.

`getSetupOutcome` is added as a non-consuming read so callers coalesced onto a
single `create` attempt all observe the same outcome; `create` already clears
it on entry, so it stays scoped to one attempt.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Renaming a PEP 723 script cleared its environment association, so the file
needed setting up again even though nothing about the environment had changed.
A cache entry is keyed by the script's normalized dependencies and its base
interpreter (the script path is not an input), so a rename cannot invalidate
it, and neither the inline metadata block nor the cached environment is
touched by the rename.

This also left two subsystems disagreeing. PythonProjectManagerImpl already
follows renames via updatePythonProjectSettingPath, which rewrites the
python-envs.pythonProjects entry to the new path and preserves its
_inlineScriptRegistration marker. Clearing the association here therefore
produced a managed inline-script project entry pointing at a file with no
environment behind it.

The rename handler now transfers the persisted record from the old path to the
new one in a single persistence transaction, and re-validates it afterwards
rather than trusting it: the record's metadata binding is content-derived, so
if the file at the new path no longer matches, ordinary validation clears the
association and the setup CodeLens returns.

Guards:

- The destination must still be a routable local .py file. Renaming to another
  extension, or off the local filesystem, drops the association as before.
- Renaming onto an already-associated script replaces that association, since
  the moved file's contents are what now live at the destination.
- Deletes are unchanged and still clear the association.

Directory renames are not covered here. VS Code reports a single event for the
folder rather than one per file, so associations under a moved folder are
still stranded; that needs its own change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Scripts whose dependencies normalize to the same list resolve to the same
cache entry, so a second script requesting setup while a build is in flight
joins that build instead of starting its own. Cancelling the build only
recorded an outcome for the script that started it. The joined script fell
through to the generic "Failed to set up the environment for this script"
error, and in a bulk run its outcome was not a cancellation, so the run kept
installing its remaining selections instead of stopping.

The shared PendingCreationContext now carries the build's failure, and each
caller translates it into its own routing outcome after awaiting the shared
promise. Cancellation therefore reaches every joined script, and the other
failure paths (uncertain entry, undeletable stale entry, lock errors) now
reach joiners as well instead of being reported only to the initiator.

Verified by removing the joiner-side propagation and confirming the new test
fails: the joined script's outcome was undefined.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ayload

Shell-startup activation keeps a single activation command per workspace
folder (for example VSCODE_PYTHON_PWSH_ACTIVATE), which VS Code injects into
every terminal opened in that folder. handleEnvironmentChange wrote whichever
environment the change event carried into that slot, after collapsing the
event's uri to its containing folder.

Selecting a PEP 723 inline-script environment fires that event with the `.py`
file's uri and the script's own environment, so a per-file selection became
the folder default. A general workspace terminal opened afterwards would
activate the last configured script's environment instead of the folder's,
and installs run there would land in the inline-script cache.

The handler now resolves the folder's own environment via
getEnvironment(workspaceFolder.uri) rather than trusting the payload. This
matches what initializeInternal already did, so the two paths no longer
disagree, and it fixes the whole class rather than special-casing the
inline-script manager: any file-scoped environment is excluded. A folder whose
startup variables were already written from a file-scoped selection is
repaired on the next environment change.

Removal semantics are unchanged: variables are cleared only when the folder
genuinely has no environment.

Adds the first unit coverage for this manager. Verified the tests fail when
the handler is reverted to writing the event payload.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cache entries are shared: scripts whose dependencies normalize to the same
list resolve to the same key and therefore the same physical environment.
Changing packages there through the package manager UI silently affected every
script bound to it. Nothing validated installed versions, so the entry stayed
"valid", the setup CodeLens stayed hidden, and a script the user never opened
would quietly run the wrong version while its own header still declared the
original pin. Re-running setup reused the modified entry rather than repairing
it, so the only recovery was clearing the whole cache.

A package change on an owned entry now records `manuallyModified` in the
sidecar and un-routes every script associated with it, so the setup CodeLens
returns for each affected script. `inspectCacheEntry` treats a marked entry as
stale, so the next setup discards and rebuilds it from the script's declared
metadata instead of handing back the drifted environment.

The marker is written under the cache-entry lock. Setup installs through
`managePackages`, which fires this same event, so a build still registered in
`pendingCreations` is skipped: the emitter is synchronous, so a build owning
the change is recognised before any await, and the check is repeated once the
lock is held. Without that guard every setup would mark the environment it had
just built and rebuild it endlessly.

Not addressed here: sharing is still not surfaced before an edit, a deliberate
ad-hoc install is discarded by the next setup without explanation, and package
changes made outside VS Code fire no event and remain undetected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@rchiodo

Rich Chiodo (rchiodo) commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

🔒 Automated review in progress — Rich Chiodo (@rchiodo) is auto-reviewing this PR.

Comment thread src/test/features/inlineScript/setupEnvironment.unit.test.ts
Comment thread src/features/inlineScript/setupEnvironment.ts
Comment thread src/managers/builtin/inlineScript/envManager.ts Outdated
@rchiodo

Copy link
Copy Markdown
Contributor

Result: 🔴 could-not-verify

Verification details

Verification: Isolated verification observed failures that were not classified as caused by this PR: Node dependency and test discovery preflight. The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Summary: Verification could not proceed because the container lacks Node.js (`node: not found`). Consequently, compilation and all three targeted Mocha suites were not run. I identified 19 tests added or substantially rewritten by the PR. Confidence is low until they run in a Node-capable environment.

Test runs: 1 failed, 3 not run

  • ⚠️ Not run | Inline-script setup UI unit tests | node ./node_modules/mocha/bin/mocha.js --config=./build/.mocha.unittests.json out/test/features/inlineScript/setupEnvironment.unit.test.js
  • ⚠️ Not run | Shell startup activation variables unit tests | node ./node_modules/mocha/bin/mocha.js --config=./build/.mocha.unittests.json out/test/features/terminal/shellStartupActivationVariablesManager.unit.test.js
  • ⚠️ Not run | Inline-script environment manager unit tests | node ./node_modules/mocha/bin/mocha.js --config=./build/.mocha.unittests.json out/test/managers/builtin/inlineScript/envManager.unit.test.js
  • Failed | unrelated to this PR | Node dependency and test discovery preflight | printf '%s\n' '== toolchain ==' && node --version && npm --version && printf '%s\n' '== dependencies ==' && if [ -x node_modules/.bin/mocha ] && [ -x node_modules/.bin/tsc ]; then echo 'node_modules ready'; else echo 'node_modules missing or incomplete'; fi && printf '%s\n' '== unit runner ==' && cat build/.mocha.unittests.json && printf '%s\n' '== targeted source tests ==' && find src/test/features/inlineScript src/test/features/terminal src/test/managers/builtin/inlineScript -maxdepth 1 -type f -name '*.unit.test.ts' -print | sort
⚠️ Inline-script setup UI unit tests diagnostic output
Blocked by preflight: /bin/sh: 1: node: not found
⚠️ Shell startup activation variables unit tests diagnostic output
Blocked by preflight: /bin/sh: 1: node: not found
⚠️ Inline-script environment manager unit tests diagnostic output
Blocked by preflight: /bin/sh: 1: node: not found
Node dependency and test discovery preflight diagnostic output
== toolchain ==
/bin/sh: 1: node: not found

@rchiodo Rich Chiodo (rchiodo) added the review-auto:changes-requested Automated review: posted blocking findings to address. label Sep 9, 2026
Retire a stored setup outcome when setup succeeds. Outcomes are read
non-consumingly so that callers coalesced onto one attempt all observe the same
result, but nothing replaced the outcome on success: only the next `create`
cleared it on entry. A cancelled attempt followed by a successful one therefore
left a stale `cancelled` outcome behind, which a later read could report against
the successful setup. `setUpInlineScriptEnvironment` now clears it once the
environment is associated, and a regression test covers cancel-then-success.

Assert complete user-facing strings in the inline-script setup UI tests instead
of matching fragments, so an accidental wording change is caught. The
cancellation test now asserts the exact message, which also subsumes the
separate "does not mention cleanup" assertion that it replaces.

Move the rename handler's documentation back above `handleRenamedScripts`. It
was left attached to `handlePackagesChanged` when that handler was inserted
ahead of it, so each method now documents its own invariant again.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@rchiodo

Copy link
Copy Markdown
Contributor

Result: 🔴 could-not-verify

Verification details

Verification: Isolated verification observed failures that were not classified as caused by this PR: Runtime, dependency, test, and change discovery. The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Summary: Verification could not proceed because the disposable container has no Node.js executable (`node: not found`). Consequently, none of the four targeted unit-test files could be compiled or executed. The PR adds or rewrites 19 focused tests across cancellation, rename, shared builds, terminal scoping, and package drift. Confidence is limited to test discovery and source inspection.

Test runs: 1 failed, 4 not run

  • ⚠️ Not run | Inline-script setup environment unit tests | node ./node_modules/mocha/bin/mocha.js --require source-map-support/register --require out/test/unittests.js --ui tdd --timeout 180000 out/test/features/inlineScript/setupEnvironment.unit.test.js
  • ⚠️ Not run | Shell startup activation variables manager unit tests | node ./node_modules/mocha/bin/mocha.js --require source-map-support/register --require out/test/unittests.js --ui tdd --timeout 180000 out/test/features/terminal/shellStartupActivationVariablesManager.unit.test.js
  • ⚠️ Not run | Inline-script environment manager unit tests | node ./node_modules/mocha/bin/mocha.js --require source-map-support/register --require out/test/unittests.js --ui tdd --timeout 180000 out/test/managers/builtin/inlineScript/envManager.unit.test.js
  • ⚠️ Not run | Inline-script cache layout unit tests | node ./node_modules/mocha/bin/mocha.js --require source-map-support/register --require out/test/unittests.js --ui tdd --timeout 180000 out/test/common/inlineScript/cacheLayout.unit.test.js
  • Failed | unrelated to this PR | Runtime, dependency, test, and change discovery | printf '%s\n' '== runtime ==' && node --version && npm --version && printf '%s\n' '== dependency state ==' && if [ -x node_modules/.bin/mocha ] && [ -x node_modules/.bin/tsc ]; then echo 'node_modules ready'; else echo 'node_modules missing or incomplete'; fi && printf '%s\n' '== test configuration ==' && node -e "const p=require('./package.json'); console.log(JSON.stringify({compileTests:p.scripts['compile-tests'],unittest:p.scripts.unittest},null,2))" && cat build/.mocha.unittests.json && printf '%s\n' '== refs ==' && git status --short && git log -8 --oneline && printf '%s\n' '== changed files against merge base ==' && base=$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main 2>/dev/null) && echo "base=$base" && git diff --name-status "$base"..HEAD
⚠️ Inline-script setup environment unit tests diagnostic output
Preflight failed: /bin/sh: 1: node: not found
⚠️ Shell startup activation variables manager unit tests diagnostic output
Preflight failed: /bin/sh: 1: node: not found
⚠️ Inline-script environment manager unit tests diagnostic output
Preflight failed: /bin/sh: 1: node: not found
⚠️ Inline-script cache layout unit tests diagnostic output
Preflight failed: /bin/sh: 1: node: not found
Runtime, dependency, test, and change discovery diagnostic output
== runtime ==
/bin/sh: 1: node: not found

@rchiodo Rich Chiodo (rchiodo) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Review Center.

@rchiodo Rich Chiodo (rchiodo) added review-auto:approved Automated review: no blocking findings (approval posted). and removed review-auto:changes-requested Automated review: posted blocking findings to address. labels Sep 9, 2026
@StellaHuang95
Stella Huang (StellaHuang95) merged commit df05e69 into microsoft:main Sep 9, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Issue identified by VS Code Team member as probable bug review-auto:approved Automated review: no blocking findings (approval posted).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants