Skip to content
1 change: 1 addition & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const ENVS_EXTENSION_ID = 'ms-python.vscode-python-envs';
export const PYTHON_EXTENSION_ID = 'ms-python.python';
export const CONDA_MANAGER_ID = `${PYTHON_EXTENSION_ID}:conda`;
export const INLINE_SCRIPT_MANAGER_ID = `${PYTHON_EXTENSION_ID}:inline-script`;
export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`;
export const PYENV_MANAGER_ID = `${PYTHON_EXTENSION_ID}:pyenv`;
export const JUPYTER_EXTENSION_ID = 'ms-toolsai.jupyter';
export const EXTENSION_ROOT_DIR = path.dirname(__dirname);
Expand Down
196 changes: 176 additions & 20 deletions src/common/lockfile.apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface AcquiredFileLock {
export const FILE_LOCK_DIR_SUFFIX = '.lock';
export const FILE_LOCK_OWNER_MARKER_PREFIX = 'owner-';
export const FILE_LOCK_RETAINED_MARKER_PREFIX = 'retained-';
export const FILE_LOCK_RELEASE_MARKER_PREFIX = '.release-';
export const FILE_LOCK_RETIRED_DIR_INFIX = '.retired-';
/** Legacy retained marker. It remains recognizable but cannot be safely reclaimed. */
export const FILE_LOCK_RETAINED_MARKER = 'retained';

Expand All @@ -29,7 +31,9 @@ export interface InspectFileLockOptions {
readonly checkProcessLiveness?: (pid: number) => Promise<ProcessLiveness>;
}

type LockState = 'held' | 'released' | 'retained';
type LockState = 'held' | 'releasing' | 'released' | 'retained';
const LOCK_RETIRE_MAX_ATTEMPTS = 3;
const LOCK_RETIRE_RETRY_MS = 10;

export function getFileLockPath(filePath: string): string {
return `${path.resolve(filePath)}${FILE_LOCK_DIR_SUFFIX}`;
Expand All @@ -43,6 +47,8 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
`${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-${crypto.randomBytes(16).toString('hex')}`,
);
const retainedMarker = path.join(lockPath, getRetainedMarkerName(path.basename(ownerMarker)));
const releaseMarkerName =
`${FILE_LOCK_RELEASE_MARKER_PREFIX}${process.pid}-${crypto.randomBytes(16).toString('hex')}-${path.basename(ownerMarker)}`;
const deadline = Date.now() + options.timeoutMs;

while (true) {
Expand All @@ -64,6 +70,56 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
}

let state: LockState = 'held';
let releaseInFlight: Promise<void> | undefined;
const performRelease = async (): Promise<void> => {
if (state === 'released' || state === 'retained') {
return;
}
const releaseMarkerPath = path.join(lockPath, releaseMarkerName);
if (state === 'held') {
try {
await fsapi.rename(ownerMarker, releaseMarkerPath);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) {
throw createLockError('Lock ownership was compromised', 'ECOMPROMISED', lockPath);
}
throw error;
}
state = 'releasing';
}
// state === 'releasing': the release marker exists; retire the canonical
// directory. Resumable: if retirement fails and ownership cannot be restored,
// the handle stays 'releasing' so a later release() retries retirement instead
// of leaving the handle unable to make progress.
const retiredPath = getRetiredLockPath(lockPath);
try {
await retireCanonicalLockDirectory(lockPath, retiredPath);
} catch (error) {
const restored = await fsapi
.rename(releaseMarkerPath, ownerMarker)
.then(
() => true,
() => false,
);
if (restored) {
state = 'held';
throw createLockError(
'Failed to retire the lock directory; ownership was restored',
'ELOCKRELEASEFAILED',
lockPath,
error,
);
}
throw createLockError(
'Failed to retire the lock directory; release can be retried',
'ELOCKRELEASEFAILED',
lockPath,
error,
);
}
state = 'released';
await cleanupRetiredLock(retiredPath, releaseMarkerName);
};
return {
retain: async () => {
if (state !== 'held') {
Expand All @@ -76,20 +132,18 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
throw createLockError('Failed to mark the lock as retained', 'ERETAINFAILED', lockPath);
}
},
release: async () => {
if (state !== 'held') {
return;
}
state = 'released';
try {
await fsapi.unlink(ownerMarker);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) {
throw createLockError('Lock ownership was compromised', 'ECOMPROMISED', lockPath);
}
throw error;
release: () => {
// Serialize concurrent release() calls on the same handle: without this,
// two callers can both observe state === 'held' before either owner-marker
// rename completes, and the loser sees ENOENT and reports ECOMPROMISED even
// though the lock was validly released. Sharing one in-flight promise de-dupes
// concurrent calls; clearing it on settle preserves retry-after-failure.
if (!releaseInFlight) {
releaseInFlight = performRelease().finally(() => {
releaseInFlight = undefined;
});
}
await fsapi.rmdir(lockPath);
return releaseInFlight;
},
};
} catch (error) {
Expand All @@ -114,7 +168,8 @@ export async function inspectFileLock(filePath: string, options?: InspectFileLoc
interface FileLockSnapshot {
readonly state: FileLockState;
readonly marker?: string;
readonly markerKind?: 'owner' | 'retained';
readonly markerKind?: 'owner' | 'retained' | 'release';
readonly generationMarker?: string;
}

async function inspectFileLockSnapshot(
Expand All @@ -137,24 +192,36 @@ async function inspectFileLockSnapshot(
return { state: 'malformed' };
}

const entries = await fsapi.readdir(lockPath);
let entries: string[];
try {
entries = await fsapi.readdir(lockPath);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) {
return { state: 'missing' };
}
throw error;
}
const ownerEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX));
const generationRetainedEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX));
const releaseEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_RELEASE_MARKER_PREFIX));
const retainedEntries = entries.filter((entry) => entry === FILE_LOCK_RETAINED_MARKER);
const unknownEntries = entries.filter(
(entry) =>
!entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX) &&
!entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX) &&
!entry.startsWith(FILE_LOCK_RELEASE_MARKER_PREFIX) &&
entry !== FILE_LOCK_RETAINED_MARKER,
);

if (
unknownEntries.length > 0 ||
ownerEntries.length > 1 ||
generationRetainedEntries.length > 1 ||
releaseEntries.length > 1 ||
retainedEntries.length > 1 ||
generationRetainedEntries.length + retainedEntries.length > 1 ||
generationRetainedEntries.length + ownerEntries.length > 1
(retainedEntries.length === 1 && generationRetainedEntries.length + releaseEntries.length > 0) ||
(retainedEntries.length === 0 &&
ownerEntries.length + generationRetainedEntries.length + releaseEntries.length > 1)
) {
return { state: 'malformed' };
}
Expand All @@ -168,6 +235,27 @@ async function inspectFileLockSnapshot(
}
return { state: 'retained', marker: generationRetainedEntries[0], markerKind: 'retained' };
}
if (releaseEntries.length === 1) {
const releaseMarker = parseTransitionMarker(releaseEntries[0], FILE_LOCK_RELEASE_MARKER_PREFIX);
if (!releaseMarker) {
return { state: 'malformed' };
}
const liveness = await (options?.checkProcessLiveness ?? getProcessLiveness)(releaseMarker.pid);
if (liveness === 'dead') {
return {
state: 'stale',
marker: releaseEntries[0],
markerKind: 'release',
generationMarker: releaseMarker.generationMarker,
};
}
return {
state: liveness === 'live' ? 'held' : 'unavailable',
marker: releaseEntries[0],
markerKind: 'release',
generationMarker: releaseMarker.generationMarker,
};
}
if (ownerEntries.length === 1) {
const ownerPid = parseMarkerPid(ownerEntries[0], FILE_LOCK_OWNER_MARKER_PREFIX);
if (ownerPid === undefined) {
Expand Down Expand Up @@ -269,12 +357,80 @@ function parseMarkerPid(entry: string, prefix: string): number | undefined {
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
}

function parseTransitionMarker(
entry: string,
transitionPrefix: string,
): { readonly pid: number; readonly generationMarker: string } | undefined {
const match = entry.match(
new RegExp(
`^${escapeRegExp(transitionPrefix)}(\\d+)-[0-9a-f]{32}-((?:${escapeRegExp(FILE_LOCK_OWNER_MARKER_PREFIX)}|${escapeRegExp(FILE_LOCK_RETAINED_MARKER_PREFIX)})\\d+-.+)$`,
),
);
if (!match) {
return undefined;
}
const pid = Number(match[1]);
const generationMarker = match[2];
const generationPrefix = generationMarker.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX)
? FILE_LOCK_OWNER_MARKER_PREFIX
: FILE_LOCK_RETAINED_MARKER_PREFIX;
return Number.isSafeInteger(pid) &&
pid > 0 &&
parseMarkerPid(generationMarker, generationPrefix) !== undefined
? { pid, generationMarker }
: undefined;
}

function getRetiredLockPath(lockPath: string): string {
return `${lockPath}${FILE_LOCK_RETIRED_DIR_INFIX}${process.pid}-${crypto.randomBytes(16).toString('hex')}`;
}

Comment thread
StellaHuang95 marked this conversation as resolved.
Comment thread
StellaHuang95 marked this conversation as resolved.
async function cleanupRetiredLock(retiredPath: string, markerName: string): Promise<void> {
try {
await fsapi.unlink(path.join(retiredPath, markerName));
await fsapi.rmdir(retiredPath);
} catch {
await fsapi.remove(retiredPath).catch(() => undefined);
}
}

async function retireCanonicalLockDirectory(lockPath: string, retiredPath: string): Promise<void> {
for (let attempt = 0; attempt < LOCK_RETIRE_MAX_ATTEMPTS; attempt += 1) {
try {
await fsapi.rename(lockPath, retiredPath);
return;
} catch (error) {
if (!isRetirementContentionError(error) || attempt === LOCK_RETIRE_MAX_ATTEMPTS - 1) {
throw error;
}
await delay(LOCK_RETIRE_RETRY_MS);
}
}
}

function isRetirementContentionError(error: unknown): boolean {
return (
hasErrorCode(error, 'EPERM') ||
hasErrorCode(error, 'EBUSY') ||
hasErrorCode(error, 'EACCES')
);
}

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function createLockError(message: string, code: string, lockPath: string): NodeJS.ErrnoException {
return Object.assign(new Error(message), { code, path: lockPath });
function createLockError(
message: string,
code: string,
lockPath: string,
cause?: unknown,
): NodeJS.ErrnoException {
return Object.assign(new Error(message), {
code,
path: lockPath,
...(cause === undefined ? {} : { cause }),
});
}

async function delay(milliseconds: number): Promise<void> {
Expand Down
14 changes: 5 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from '.
import { ENVS_EXTENSION_ID } from './common/constants';
import { ensureCorrectVersion } from './common/extVersion';
import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging';
import { clearPersistentState, setPersistentState } from './common/persistentState';
import { setPersistentState } from './common/persistentState';
import { newProjectSelection } from './common/pickers/managers';
import { StopWatch } from './common/stopWatch';
import { EventNames } from './common/telemetry/constants';
Expand Down Expand Up @@ -45,6 +45,7 @@ import { ProjectCreatorsImpl } from './features/creators/projectCreators';
import {
addPythonProjectCommand,
copyPathToClipboard,
clearEnvironmentCachesCommand,
clearScriptEnvironmentCacheCommand,
createAnyEnvironmentCommand,
createEnvironmentCommand,
Expand Down Expand Up @@ -78,11 +79,7 @@ import { registerCompletionProvider } from './features/settings/settingCompletio
import { migrateGlobalDefaultEnvManagerSetting } from './features/settings/settingHelpers';
import { setActivateMenuButtonContext } from './features/terminal/activateMenuButton';
import { normalizeShellPath } from './features/terminal/shells/common/shellUtils';
import {
clearShellProfileCache,
createShellEnvProviders,
createShellStartupProviders,
} from './features/terminal/shells/providers';
import { createShellEnvProviders, createShellStartupProviders } from './features/terminal/shells/providers';
import { ShellStartupActivationVariablesManagerImpl } from './features/terminal/shellStartupActivationVariablesManager';
import { cleanupStartupScripts } from './features/terminal/shellStartupSetupHandlers';
import { TerminalActivationImpl } from './features/terminal/terminalActivationState';
Expand Down Expand Up @@ -405,9 +402,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
await removePythonProject(item, projectManager, envManagers);
}),
commands.registerCommand('python-envs.clearCache', async () => {
await clearPersistentState();
await envManagers.clearCache(undefined);
await clearShellProfileCache(shellStartupProviders);
await clearEnvironmentCachesCommand(envManagers, shellStartupProviders, context.workspaceState);
}),
...(isInlineScriptsFeatureEnabled()
? [
Expand Down Expand Up @@ -704,6 +699,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
sysMgr,
context.globalStorageUri,
inlineScriptFeatureActivation,
context.workspaceState,
)
: Promise.resolve(),
),
Expand Down
24 changes: 23 additions & 1 deletion src/features/envCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TaskExecution,
TaskRevealKind,
Terminal,
Memento,
Uri,
l10n,
workspace,
Expand All @@ -21,6 +22,7 @@ import {
isPackageVersionLookupNotSupportedError,
} from '../api';
import { traceError, traceInfo, traceVerbose } from '../common/logging';
import * as persistentState from '../common/persistentState';
import {
EnvironmentManagers,
InternalEnvironmentManager,
Expand Down Expand Up @@ -60,9 +62,11 @@ import {
showWarningMessage,
withProgress,
} from '../common/window.apis';
import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants';
import { INLINE_SCRIPT_ENVS_KEY, INLINE_SCRIPT_MANAGER_ID } from '../common/constants';
import { runAsTask } from './execution/runAsTask';
import { runInTerminal } from './terminal/runInTerminal';
import * as shellProviders from './terminal/shells/providers';
import { ShellStartupScriptProvider } from './terminal/shells/startupProvider';
import { TerminalManager } from './terminal/terminalManager';
import { EnvManagerView } from './views/envManagersView';
import {
Expand Down Expand Up @@ -680,6 +684,24 @@ export async function removePythonProject(
wm.remove(item.project);
}

export async function clearEnvironmentCachesCommand(
em: EnvironmentManagers,
startupProviders: ShellStartupScriptProvider[],
workspaceState: Memento,
): Promise<void> {
// Preserve the inline-script association key without changing the shared PersistentState
// implementation: clear every current workspace key except the inline key by passing an
// explicit filtered list to the existing `clear(keys)`, alongside the existing global clear.
const [workspacePersistentState, globalPersistentState] = await Promise.all([
persistentState.getWorkspacePersistentState(),
persistentState.getGlobalPersistentState(),
]);
const workspaceKeys = workspaceState.keys().filter((key) => key !== INLINE_SCRIPT_ENVS_KEY);
await Promise.all([workspacePersistentState.clear(workspaceKeys), globalPersistentState.clear()]);
await em.clearCache(undefined);
await shellProviders.clearShellProfileCache(startupProviders);
}

export async function clearScriptEnvironmentCacheCommand(
em: EnvironmentManagers,
wm: PythonProjectManager,
Expand Down
Loading
Loading