Skip to content

Commit fa47921

Browse files
Add clear script environment cache command (PEP 723 PR 13/16) (#1724)
> Part of #1602 (PEP 723 inline script env support). Design doc: #1601. > > Builds on the merged persistence work in #1697 and is rebased on current `main`. ### Roadmap context This is **PR 13 of 16** in the PEP 723 inline-script roadmap. It adds the explicit, user-confirmed cache lifecycle operation that the later TTL work will reuse. | Phase 5: Lifecycle and polish | PR | Status | |---|---|---| | | PR 7: persistence (`get` / `set` + Memento) | merged (#1697) | | | **PR 13: clear inline-script cache** | **this PR** | | | PR 14: opportunistic 14-day TTL eviction | follow-up | | | PR 15: lifecycle telemetry | separate | | | PR 16: status-bar decision | resolved; no code PR | ### Why this PR The extension can create extension-owned inline-script environments and persist script associations, but it has no complete way to remove that state. Clearing only the files would leave Memento associations and `pythonProjects` entries pointing at deleted interpreters; clearing only settings would leave disk usage behind. This PR adds one coordinated lifecycle operation covering: - extension-owned cache entries; - persisted and in-memory script associations; - active selection events; and - generated inline-script project settings. Because this is destructive and the cache is shared by extension-host processes, the implementation is intentionally fail-closed around path ownership and locks. ### What this PR does **Adds an internal, confirmation-gated clear command** - Registers `python-envs.clearScriptEnvCache` only while the hidden inline-script feature flag is enabled. - Does not contribute the command to `package.json` or the Command Palette before rollout. - Shows a modal warning covering cached environments, associations, and project entries. - Cancelling the prompt performs no filesystem, state, or settings changes. - Runs cache cleanup before settings removal, so failed/partial cache cleanup does not silently rewrite project configuration. **Keeps generic cache clearing behavior safe** - The existing public `python-envs.clearCache` command continues to clear existing non-inline managers. - It skips the preview inline manager because the generic path has no inline-specific confirmation or project-settings lifecycle. - The dedicated command invokes the inline manager directly and performs the complete cleanup transaction. **Serializes in-process maintenance** - Adds a manager-local maintenance queue and barrier. - `create()`, `get()`, and `set()` cannot observe or mutate half-cleared state. - A clear request refuses to begin when creation already started. - A creation request that arrives after clear begins waits for maintenance to settle. - Multiple maintenance requests are serialized without globally serializing unrelated managers. **Coordinates deletion across extension hosts** - Acquires and holds each cache entry's cross-process lock through deletion. - Classifies locks as missing, held, retained, stale, orphaned, malformed, or unavailable. - Uses PID liveness to distinguish a live owner from a stale owner. - Makes retained markers generation-specific by preserving the owner's PID/nonce. - Reclaims only the exact stale/retained generation marker that was inspected. - If another process replaces that generation before the atomic claim, reclamation loses safely and touches nothing. - Ambiguous legacy fixed `retained` markers remain recognizable but are conservatively not reclaimed. **Validates every destructive path** Before deleting an entry, cleanup verifies that: - global storage and `script-envs-v1` are normal directories rather than symlinks/junctions; - the versioned cache root is the expected direct child of global storage; - neither path is a filesystem root or dangerously shallow; - physical `realpath` containment matches the expected ownership boundary; - the cache root has not changed since the cleanup snapshot; and - the target entry is a normal direct-child directory inside that same physical root. The entry lock is acquired first, then root and entry ownership are revalidated immediately before removal. **Keeps state consistent through partial failures** - Attempts cache entries independently and records successful removals. - Aggregates and surfaces deletion/persistence failures rather than returning success-shaped output. - Invalidates only associations whose environment was removed or is definitively missing. - Preserves associations for cache entries that could not safely be removed. - Cancels pending rehydration for invalidated scripts. - Clears warm environment and validation caches. - Advances association revisions so stale async work cannot restore removed selections. - Emits `onDidChangeEnvironment` only for selections actually invalidated. **Removes generated inline project settings safely** - Resolves `pythonProjects` entries independently from global, workspace, and workspace-folder sources. - Removes only entries whose manager is the inline-script manager. - Preserves non-inline duplicate entries and higher-precedence overrides. - Handles same relative paths across multiple workspace roots. - Aggregates global/workspace updates so each shared scope is written once. - Unloads only loaded projects that have no remaining configuration source. ### Cleanup semantics | Condition | Behavior | |---|---| | Entry is unlocked and physically owned | Lock, revalidate, delete | | Lock belongs to a live process | Refuse that deletion | | Exact stale/retained generation can be claimed | Reclaim, acquire a fresh lock, delete | | Lock is unavailable, malformed, orphaned, or legacy-ambiguous | Preserve entry and surface failure | | Root or entry is redirected/outside ownership boundary | Refuse deletion | | One entry fails after another was removed | Preserve valid survivors; invalidate removed associations; report aggregate failure | | Persistence update fails after disk cleanup | Keep in-memory state consistent and surface the persistence error | | Cache is already absent | Clear stale associations safely; remain idempotent | ### Example ```text Clear Script Environment Cache → modal confirmation → enter manager maintenance barrier → verify physical cache root → acquire exact per-entry lock → revalidate ownership immediately before deletion → delete safe entries → reconcile Memento + in-memory selections + events → remove generated inline pythonProjects settings ``` ### Tests Coverage includes: - prompt cancellation and command ordering; - generic clear behavior with the preview manager absent/present; - in-process create/clear ordering; - live, stale, retained, orphaned, malformed, unavailable, and legacy lock states; - exact-generation reclamation and delayed-reclaimer/new-creator races; - holding entry locks through deletion; - unsafe, shallow, redirected, symlinked, and root-swapped paths; - successful, missing-cache, idempotent, and partial-failure cleanup; - persistence failures and pending-rehydration races; - global/workspace/workspace-folder setting precedence; - multi-root projects and same-path entries; and - default-off command registration. Validation on the rebased branch: - `npm run compile-tests` - `npm run compile` - `npm run lint` - focused lock/cache-clear/settings/command suites: 38 passing The full Windows unit run reaches 1638 passing and 5 pending; the existing concurrent `writeMetaJson` rename test can still intermittently fail with `EPERM` on Windows. That writer is unchanged by this PR and the same failure is reproducible on `main`. ### Performance - No activation scan, timer, or background maintenance is added. - All work is initiated by the internal clear command. - Per-entry locks avoid globally serializing independent environment creation across extension hosts. - The maintenance barrier exists only inside the enabled inline manager and is active only during cleanup. ### User impact **No default-path user impact.** The manager and command remain behind the undeclared, default-off `python-envs.inlineScripts.enabled` flag, and the command is not publicly contributed. When the internal flag is manually enabled, the existing generic cache command still behaves as before for non-inline managers. Inline cleanup is available only through the dedicated confirmed lifecycle. ### Scope and follow-up This PR intentionally does **not** implement: - automatic routing or project registration; - activation-time discovery; - silent/opportunistic deletion; - TTL expiration; or - lifecycle telemetry. PR 14 will reuse this safety and state-cleanup foundation to remove entries whose `lastUsedAt` exceeds the planned 14-day TTL. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cb82ae9-7424-40a4-9156-8c54ac6e0895 Copilot-Session: 6b12d843-8011-4bfc-9ba9-f75761eadee2
1 parent 655b075 commit fa47921

15 files changed

Lines changed: 2545 additions & 83 deletions

src/common/lockfile.apis.ts

Lines changed: 176 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,33 @@ export interface AcquiredFileLock {
1616
readonly retain: () => Promise<void>;
1717
}
1818

19+
export const FILE_LOCK_DIR_SUFFIX = '.lock';
20+
export const FILE_LOCK_OWNER_MARKER_PREFIX = 'owner-';
21+
export const FILE_LOCK_RETAINED_MARKER_PREFIX = 'retained-';
22+
/** Legacy retained marker. It remains recognizable but cannot be safely reclaimed. */
23+
export const FILE_LOCK_RETAINED_MARKER = 'retained';
24+
25+
export type ProcessLiveness = 'live' | 'dead' | 'unavailable';
26+
export type FileLockState = 'missing' | 'held' | 'retained' | 'stale' | 'orphaned' | 'malformed' | 'unavailable';
27+
28+
export interface InspectFileLockOptions {
29+
readonly checkProcessLiveness?: (pid: number) => Promise<ProcessLiveness>;
30+
}
31+
1932
type LockState = 'held' | 'released' | 'retained';
2033

34+
export function getFileLockPath(filePath: string): string {
35+
return `${path.resolve(filePath)}${FILE_LOCK_DIR_SUFFIX}`;
36+
}
37+
2138
/** Acquire an atomic lock released only explicitly; interrupted operations remain locked. */
2239
export async function acquireFileLock(filePath: string, options: AcquireFileLockOptions): Promise<AcquiredFileLock> {
23-
const lockPath = `${path.resolve(filePath)}.lock`;
24-
const ownerMarker = path.join(lockPath, `owner-${process.pid}-${crypto.randomBytes(16).toString('hex')}`);
25-
const retainedMarker = path.join(lockPath, 'retained');
40+
const lockPath = getFileLockPath(filePath);
41+
const ownerMarker = path.join(
42+
lockPath,
43+
`${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-${crypto.randomBytes(16).toString('hex')}`,
44+
);
45+
const retainedMarker = path.join(lockPath, getRetainedMarkerName(path.basename(ownerMarker)));
2646
const deadline = Date.now() + options.timeoutMs;
2747

2848
while (true) {
@@ -51,22 +71,9 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
5171
}
5272
state = 'retained';
5373
try {
54-
await fsapi.writeFile(retainedMarker, '', { flag: 'wx' });
55-
} catch (error) {
56-
if (hasErrorCode(error, 'EEXIST')) {
57-
return;
58-
}
59-
try {
60-
await fsapi.rename(ownerMarker, retainedMarker);
61-
} catch (renameError) {
62-
if (!hasErrorCode(renameError, 'EEXIST')) {
63-
throw createLockError(
64-
'Failed to mark the lock as retained',
65-
'ERETAINFAILED',
66-
lockPath,
67-
);
68-
}
69-
}
74+
await fsapi.rename(ownerMarker, retainedMarker);
75+
} catch (_error) {
76+
throw createLockError('Failed to mark the lock as retained', 'ERETAINFAILED', lockPath);
7077
}
7178
},
7279
release: async () => {
@@ -100,10 +107,141 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
100107
}
101108
}
102109

103-
async function isRetainedLock(lockPath: string): Promise<boolean> {
110+
export async function inspectFileLock(filePath: string, options?: InspectFileLockOptions): Promise<FileLockState> {
111+
return (await inspectFileLockSnapshot(filePath, options)).state;
112+
}
113+
114+
interface FileLockSnapshot {
115+
readonly state: FileLockState;
116+
readonly marker?: string;
117+
readonly markerKind?: 'owner' | 'retained';
118+
}
119+
120+
async function inspectFileLockSnapshot(
121+
filePath: string,
122+
options?: InspectFileLockOptions,
123+
): Promise<FileLockSnapshot> {
124+
const lockPath = getFileLockPath(filePath);
125+
126+
let stat;
104127
try {
105-
await fsapi.lstat(path.join(lockPath, 'retained'));
128+
stat = await fsapi.lstat(lockPath);
129+
} catch (error) {
130+
if (hasErrorCode(error, 'ENOENT')) {
131+
return { state: 'missing' };
132+
}
133+
throw error;
134+
}
135+
136+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
137+
return { state: 'malformed' };
138+
}
139+
140+
const entries = await fsapi.readdir(lockPath);
141+
const ownerEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX));
142+
const generationRetainedEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX));
143+
const retainedEntries = entries.filter((entry) => entry === FILE_LOCK_RETAINED_MARKER);
144+
const unknownEntries = entries.filter(
145+
(entry) =>
146+
!entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX) &&
147+
!entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX) &&
148+
entry !== FILE_LOCK_RETAINED_MARKER,
149+
);
150+
151+
if (
152+
unknownEntries.length > 0 ||
153+
ownerEntries.length > 1 ||
154+
generationRetainedEntries.length > 1 ||
155+
retainedEntries.length > 1 ||
156+
generationRetainedEntries.length + retainedEntries.length > 1 ||
157+
generationRetainedEntries.length + ownerEntries.length > 1
158+
) {
159+
return { state: 'malformed' };
160+
}
161+
if (retainedEntries.length === 1) {
162+
return { state: 'retained' };
163+
}
164+
if (generationRetainedEntries.length === 1) {
165+
const retainedPid = parseMarkerPid(generationRetainedEntries[0], FILE_LOCK_RETAINED_MARKER_PREFIX);
166+
if (retainedPid === undefined) {
167+
return { state: 'malformed' };
168+
}
169+
return { state: 'retained', marker: generationRetainedEntries[0], markerKind: 'retained' };
170+
}
171+
if (ownerEntries.length === 1) {
172+
const ownerPid = parseMarkerPid(ownerEntries[0], FILE_LOCK_OWNER_MARKER_PREFIX);
173+
if (ownerPid === undefined) {
174+
return { state: 'malformed' };
175+
}
176+
const liveness = await (options?.checkProcessLiveness ?? getProcessLiveness)(ownerPid);
177+
if (liveness === 'dead') {
178+
return { state: 'stale', marker: ownerEntries[0], markerKind: 'owner' };
179+
}
180+
return { state: liveness === 'live' ? 'held' : 'unavailable', marker: ownerEntries[0], markerKind: 'owner' };
181+
}
182+
return { state: 'orphaned' };
183+
}
184+
185+
/**
186+
* Claim and remove the exact observed stale or retained generation without releasing the lock directory.
187+
*/
188+
export async function reclaimFileLock(filePath: string, options?: InspectFileLockOptions): Promise<boolean> {
189+
const lockPath = getFileLockPath(filePath);
190+
const snapshot = await inspectFileLockSnapshot(filePath, options);
191+
if (
192+
(snapshot.state !== 'stale' && snapshot.state !== 'retained') ||
193+
!snapshot.marker ||
194+
!snapshot.markerKind
195+
) {
196+
return false;
197+
}
198+
199+
const claimedMarker = path.join(
200+
lockPath,
201+
`.reclaim-${process.pid}-${crypto.randomBytes(16).toString('hex')}-${snapshot.marker}`,
202+
);
203+
try {
204+
await fsapi.rename(path.join(lockPath, snapshot.marker), claimedMarker);
205+
} catch (error) {
206+
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'EEXIST')) {
207+
return false;
208+
}
209+
throw error;
210+
}
211+
212+
try {
213+
await fsapi.unlink(claimedMarker);
214+
await fsapi.rmdir(lockPath);
106215
return true;
216+
} catch (error) {
217+
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTEMPTY')) {
218+
return false;
219+
}
220+
throw error;
221+
}
222+
}
223+
224+
export async function getProcessLiveness(pid: number): Promise<ProcessLiveness> {
225+
try {
226+
process.kill(pid, 0);
227+
return 'live';
228+
} catch (error) {
229+
if (hasErrorCode(error, 'ESRCH')) {
230+
return 'dead';
231+
}
232+
if (hasErrorCode(error, 'EPERM') || hasErrorCode(error, 'EACCES')) {
233+
return 'unavailable';
234+
}
235+
return 'unavailable';
236+
}
237+
}
238+
239+
async function isRetainedLock(lockPath: string): Promise<boolean> {
240+
try {
241+
const entries = await fsapi.readdir(lockPath);
242+
return entries.some(
243+
(entry) => entry === FILE_LOCK_RETAINED_MARKER || entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX),
244+
);
107245
} catch (error) {
108246
if (hasErrorCode(error, 'ENOENT')) {
109247
return false;
@@ -118,6 +256,23 @@ function hasErrorCode(error: unknown, code: string): boolean {
118256
);
119257
}
120258

259+
function getRetainedMarkerName(ownerMarker: string): string {
260+
return `${FILE_LOCK_RETAINED_MARKER_PREFIX}${ownerMarker.slice(FILE_LOCK_OWNER_MARKER_PREFIX.length)}`;
261+
}
262+
263+
function parseMarkerPid(entry: string, prefix: string): number | undefined {
264+
const match = entry.match(new RegExp(`^${escapeRegExp(prefix)}(\\d+)-.+$`));
265+
if (!match) {
266+
return undefined;
267+
}
268+
const pid = Number(match[1]);
269+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
270+
}
271+
272+
function escapeRegExp(value: string): string {
273+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
274+
}
275+
121276
function createLockError(message: string, code: string, lockPath: string): NodeJS.ErrnoException {
122277
return Object.assign(new Error(message), { code, path: lockPath });
123278
}

src/extension.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { ProjectCreatorsImpl } from './features/creators/projectCreators';
4545
import {
4646
addPythonProjectCommand,
4747
copyPathToClipboard,
48+
clearScriptEnvironmentCacheCommand,
4849
createAnyEnvironmentCommand,
4950
createEnvironmentCommand,
5051
createTerminalCommand,
@@ -94,7 +95,12 @@ import { PythonStatusBarImpl } from './features/views/pythonStatusBar';
9495
import { updateViewsAndStatus } from './features/views/revealHandler';
9596
import { TemporaryStateManager } from './features/views/temporaryStateManager';
9697
import { PythonEnvTreeItem } from './features/views/treeViewItems';
97-
import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers';
98+
import {
99+
collectEnvironmentInfo,
100+
getEnvManagerAndPackageManagerConfigLevels,
101+
isInlineScriptsFeatureEnabled,
102+
runPetInTerminalImpl,
103+
} from './helpers';
98104
import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api';
99105
import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main';
100106
import { registerSystemPythonFeatures } from './managers/builtin/main';
@@ -386,6 +392,13 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
386392
await envManagers.clearCache(undefined);
387393
await clearShellProfileCache(shellStartupProviders);
388394
}),
395+
...(isInlineScriptsFeatureEnabled()
396+
? [
397+
commands.registerCommand('python-envs.clearScriptEnvCache', async () => {
398+
await clearScriptEnvironmentCacheCommand(envManagers, projectManager);
399+
}),
400+
]
401+
: []),
389402
commands.registerCommand('python-envs.runInTerminal', (item) => {
390403
return runInTerminalCommand(item, api, terminalManager);
391404
}),

src/features/envCommands.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ import {
2626
ProjectCreators,
2727
PythonProjectManager,
2828
} from '../internal.api';
29-
import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers';
29+
import {
30+
removeInlineScriptPythonProjectSettings,
31+
removePythonProjectSetting,
32+
setEnvironmentManager,
33+
setPackageManager,
34+
} from './settings/settingHelpers';
3035

3136
import { valid as pep440Valid } from '@renovatebot/pep440';
3237
import { executeCommand } from '../common/command.api';
@@ -50,8 +55,10 @@ import {
5055
showInputBox,
5156
showOpenDialog,
5257
showQuickPick,
58+
showWarningMessage,
5359
withProgress,
5460
} from '../common/window.apis';
61+
import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants';
5562
import { runAsTask } from './execution/runAsTask';
5663
import { runInTerminal } from './terminal/runInTerminal';
5764
import { TerminalManager } from './terminal/terminalManager';
@@ -662,6 +669,36 @@ export async function removePythonProject(
662669
wm.remove(item.project);
663670
}
664671

672+
export async function clearScriptEnvironmentCacheCommand(
673+
em: EnvironmentManagers,
674+
wm: PythonProjectManager,
675+
): Promise<void> {
676+
const manager = em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID);
677+
if (!manager || !manager.supportsClearCache()) {
678+
throw new Error(
679+
l10n.t('Inline-script environment cache is unavailable because the inline-script manager is not registered.'),
680+
);
681+
}
682+
683+
const clearLabel = l10n.t('Clear Cache');
684+
const confirmation = await showWarningMessage(
685+
l10n.t(
686+
'This will delete all cached inline-script environments, forget their script associations, and remove inline-script project entries from settings.',
687+
),
688+
{ modal: true },
689+
clearLabel,
690+
);
691+
if (confirmation !== clearLabel) {
692+
return;
693+
}
694+
695+
await manager.clearCache();
696+
const loadedProjectsToRemove = await removeInlineScriptPythonProjectSettings(wm.getProjects());
697+
if (loadedProjectsToRemove.length > 0) {
698+
wm.remove(loadedProjectsToRemove);
699+
}
700+
}
701+
665702
export async function getPackageCommandOptions(
666703
e: unknown,
667704
em: EnvironmentManagers,

src/features/envManagers.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,12 +320,16 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
320320

321321
public async clearCache(scope: EnvironmentManagerScope): Promise<void> {
322322
if (scope === undefined) {
323-
await Promise.all(this.managers.map((m) => m.clearCache()));
323+
await Promise.all(
324+
this.managers
325+
.filter((manager) => manager.id !== INLINE_SCRIPT_MANAGER_ID)
326+
.map((manager) => manager.clearCache()),
327+
);
324328
return;
325329
}
326330

327331
const manager = this.getEnvironmentManager(scope);
328-
if (manager) {
332+
if (manager && manager.id !== INLINE_SCRIPT_MANAGER_ID) {
329333
await manager.clearCache();
330334
}
331335
}

src/features/projectManager.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,20 +130,17 @@ export class PythonProjectManagerImpl implements PythonProjectManager {
130130
// For each override, resolve its path and add as a project if not already present
131131
for (const o of overrides) {
132132
let uriFromWorkspace: Uri | undefined = undefined;
133-
// if override has a workspace property, resolve the path relative to that workspace
134133
if (o.workspace) {
135-
//
136134
const workspaceFolder = workspaces.find((ws) => ws.name === o.workspace);
137135
if (workspaceFolder) {
138136
if (workspaceFolder.uri.toString() !== w.uri.toString()) {
139-
continue; // skip if the workspace is not the same as the current workspace
137+
continue;
140138
}
141139
uriFromWorkspace = Uri.file(path.resolve(workspaceFolder.uri.fsPath, o.path));
142140
}
143141
}
144142
const uri = uriFromWorkspace ? uriFromWorkspace : Uri.file(path.resolve(w.uri.fsPath, o.path));
145143

146-
// Check if the project already exists in the newProjects array
147144
if (!newProjects.some((p) => p.uri.toString() === uri.toString())) {
148145
newProjects.push(new PythonProjectsImpl(o.path, uri));
149146
}

0 commit comments

Comments
 (0)