Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 176 additions & 21 deletions src/common/lockfile.apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,33 @@ export interface AcquiredFileLock {
readonly retain: () => Promise<void>;
}

export const FILE_LOCK_DIR_SUFFIX = '.lock';
export const FILE_LOCK_OWNER_MARKER_PREFIX = 'owner-';
export const FILE_LOCK_RETAINED_MARKER_PREFIX = 'retained-';
/** Legacy retained marker. It remains recognizable but cannot be safely reclaimed. */
export const FILE_LOCK_RETAINED_MARKER = 'retained';

export type ProcessLiveness = 'live' | 'dead' | 'unavailable';
export type FileLockState = 'missing' | 'held' | 'retained' | 'stale' | 'orphaned' | 'malformed' | 'unavailable';

export interface InspectFileLockOptions {
readonly checkProcessLiveness?: (pid: number) => Promise<ProcessLiveness>;
}

type LockState = 'held' | 'released' | 'retained';

export function getFileLockPath(filePath: string): string {
return `${path.resolve(filePath)}${FILE_LOCK_DIR_SUFFIX}`;
}

/** Acquire an atomic lock released only explicitly; interrupted operations remain locked. */
export async function acquireFileLock(filePath: string, options: AcquireFileLockOptions): Promise<AcquiredFileLock> {
const lockPath = `${path.resolve(filePath)}.lock`;
const ownerMarker = path.join(lockPath, `owner-${process.pid}-${crypto.randomBytes(16).toString('hex')}`);
const retainedMarker = path.join(lockPath, 'retained');
const lockPath = getFileLockPath(filePath);
const ownerMarker = path.join(
lockPath,
`${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-${crypto.randomBytes(16).toString('hex')}`,
);
const retainedMarker = path.join(lockPath, getRetainedMarkerName(path.basename(ownerMarker)));
const deadline = Date.now() + options.timeoutMs;

while (true) {
Expand Down Expand Up @@ -51,22 +71,9 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
}
state = 'retained';
try {
await fsapi.writeFile(retainedMarker, '', { flag: 'wx' });
} catch (error) {
if (hasErrorCode(error, 'EEXIST')) {
return;
}
try {
await fsapi.rename(ownerMarker, retainedMarker);
} catch (renameError) {
if (!hasErrorCode(renameError, 'EEXIST')) {
throw createLockError(
'Failed to mark the lock as retained',
'ERETAINFAILED',
lockPath,
);
}
}
await fsapi.rename(ownerMarker, retainedMarker);
} catch (_error) {
throw createLockError('Failed to mark the lock as retained', 'ERETAINFAILED', lockPath);
}
},
release: async () => {
Expand Down Expand Up @@ -100,10 +107,141 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
}
}

async function isRetainedLock(lockPath: string): Promise<boolean> {
export async function inspectFileLock(filePath: string, options?: InspectFileLockOptions): Promise<FileLockState> {
return (await inspectFileLockSnapshot(filePath, options)).state;
}

interface FileLockSnapshot {
readonly state: FileLockState;
readonly marker?: string;
readonly markerKind?: 'owner' | 'retained';
}

async function inspectFileLockSnapshot(
filePath: string,
options?: InspectFileLockOptions,
): Promise<FileLockSnapshot> {
const lockPath = getFileLockPath(filePath);

let stat;
try {
await fsapi.lstat(path.join(lockPath, 'retained'));
stat = await fsapi.lstat(lockPath);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) {
return { state: 'missing' };
}
throw error;
}

if (!stat.isDirectory() || stat.isSymbolicLink()) {
return { state: 'malformed' };
}

const entries = await fsapi.readdir(lockPath);
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 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 !== FILE_LOCK_RETAINED_MARKER,
);

if (
unknownEntries.length > 0 ||
ownerEntries.length > 1 ||
generationRetainedEntries.length > 1 ||
retainedEntries.length > 1 ||
generationRetainedEntries.length + retainedEntries.length > 1 ||
generationRetainedEntries.length + ownerEntries.length > 1
) {
return { state: 'malformed' };
}
if (retainedEntries.length === 1) {
return { state: 'retained' };
}
if (generationRetainedEntries.length === 1) {
const retainedPid = parseMarkerPid(generationRetainedEntries[0], FILE_LOCK_RETAINED_MARKER_PREFIX);
if (retainedPid === undefined) {
return { state: 'malformed' };
}
return { state: 'retained', marker: generationRetainedEntries[0], markerKind: 'retained' };
}
if (ownerEntries.length === 1) {
const ownerPid = parseMarkerPid(ownerEntries[0], FILE_LOCK_OWNER_MARKER_PREFIX);
if (ownerPid === undefined) {
return { state: 'malformed' };
}
const liveness = await (options?.checkProcessLiveness ?? getProcessLiveness)(ownerPid);
if (liveness === 'dead') {
return { state: 'stale', marker: ownerEntries[0], markerKind: 'owner' };
}
return { state: liveness === 'live' ? 'held' : 'unavailable', marker: ownerEntries[0], markerKind: 'owner' };
}
return { state: 'orphaned' };
}

/**
* Claim and remove the exact observed stale or retained generation without releasing the lock directory.
*/
export async function reclaimFileLock(filePath: string, options?: InspectFileLockOptions): Promise<boolean> {
const lockPath = getFileLockPath(filePath);
const snapshot = await inspectFileLockSnapshot(filePath, options);
if (
(snapshot.state !== 'stale' && snapshot.state !== 'retained') ||
!snapshot.marker ||
!snapshot.markerKind
) {
return false;
}

const claimedMarker = path.join(
lockPath,
`.reclaim-${process.pid}-${crypto.randomBytes(16).toString('hex')}-${snapshot.marker}`,
);
try {
await fsapi.rename(path.join(lockPath, snapshot.marker), claimedMarker);
} catch (error) {
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'EEXIST')) {
return false;
}
throw error;
}

try {
await fsapi.unlink(claimedMarker);
await fsapi.rmdir(lockPath);
return true;
} catch (error) {
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTEMPTY')) {
return false;
}
throw error;
}
}

export async function getProcessLiveness(pid: number): Promise<ProcessLiveness> {
try {
process.kill(pid, 0);
return 'live';
} catch (error) {
if (hasErrorCode(error, 'ESRCH')) {
return 'dead';
}
if (hasErrorCode(error, 'EPERM') || hasErrorCode(error, 'EACCES')) {
return 'unavailable';
}
return 'unavailable';
}
}

async function isRetainedLock(lockPath: string): Promise<boolean> {
try {
const entries = await fsapi.readdir(lockPath);
return entries.some(
(entry) => entry === FILE_LOCK_RETAINED_MARKER || entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX),
);
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) {
return false;
Expand All @@ -118,6 +256,23 @@ function hasErrorCode(error: unknown, code: string): boolean {
);
}

function getRetainedMarkerName(ownerMarker: string): string {
return `${FILE_LOCK_RETAINED_MARKER_PREFIX}${ownerMarker.slice(FILE_LOCK_OWNER_MARKER_PREFIX.length)}`;
}

function parseMarkerPid(entry: string, prefix: string): number | undefined {
const match = entry.match(new RegExp(`^${escapeRegExp(prefix)}(\\d+)-.+$`));
if (!match) {
return undefined;
}
const pid = Number(match[1]);
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
}

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 });
}
Expand Down
15 changes: 14 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { ProjectCreatorsImpl } from './features/creators/projectCreators';
import {
addPythonProjectCommand,
copyPathToClipboard,
clearScriptEnvironmentCacheCommand,
createAnyEnvironmentCommand,
createEnvironmentCommand,
createTerminalCommand,
Expand Down Expand Up @@ -94,7 +95,12 @@ import { PythonStatusBarImpl } from './features/views/pythonStatusBar';
import { updateViewsAndStatus } from './features/views/revealHandler';
import { TemporaryStateManager } from './features/views/temporaryStateManager';
import { PythonEnvTreeItem } from './features/views/treeViewItems';
import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers';
import {
collectEnvironmentInfo,
getEnvManagerAndPackageManagerConfigLevels,
isInlineScriptsFeatureEnabled,
runPetInTerminalImpl,
} from './helpers';
import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api';
import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main';
import { registerSystemPythonFeatures } from './managers/builtin/main';
Expand Down Expand Up @@ -386,6 +392,13 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
await envManagers.clearCache(undefined);
await clearShellProfileCache(shellStartupProviders);
}),
...(isInlineScriptsFeatureEnabled()
? [
commands.registerCommand('python-envs.clearScriptEnvCache', async () => {
await clearScriptEnvironmentCacheCommand(envManagers, projectManager);
}),
]
: []),
commands.registerCommand('python-envs.runInTerminal', (item) => {
return runInTerminalCommand(item, api, terminalManager);
}),
Expand Down
39 changes: 38 additions & 1 deletion src/features/envCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ import {
ProjectCreators,
PythonProjectManager,
} from '../internal.api';
import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers';
import {
removeInlineScriptPythonProjectSettings,
removePythonProjectSetting,
setEnvironmentManager,
setPackageManager,
} from './settings/settingHelpers';

import { valid as pep440Valid } from '@renovatebot/pep440';
import { executeCommand } from '../common/command.api';
Expand All @@ -50,8 +55,10 @@ import {
showInputBox,
showOpenDialog,
showQuickPick,
showWarningMessage,
withProgress,
} from '../common/window.apis';
import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants';
import { runAsTask } from './execution/runAsTask';
import { runInTerminal } from './terminal/runInTerminal';
import { TerminalManager } from './terminal/terminalManager';
Expand Down Expand Up @@ -662,6 +669,36 @@ export async function removePythonProject(
wm.remove(item.project);
}

export async function clearScriptEnvironmentCacheCommand(
em: EnvironmentManagers,
wm: PythonProjectManager,
): Promise<void> {
const manager = em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID);
if (!manager || !manager.supportsClearCache()) {
throw new Error(
l10n.t('Inline-script environment cache is unavailable because the inline-script manager is not registered.'),
);
}

const clearLabel = l10n.t('Clear Cache');
const confirmation = await showWarningMessage(
l10n.t(
'This will delete all cached inline-script environments, forget their script associations, and remove inline-script project entries from settings.',
),
{ modal: true },
clearLabel,
);
if (confirmation !== clearLabel) {
return;
}

await manager.clearCache();
const loadedProjectsToRemove = await removeInlineScriptPythonProjectSettings(wm.getProjects());
if (loadedProjectsToRemove.length > 0) {
wm.remove(loadedProjectsToRemove);
}
}

export async function getPackageCommandOptions(
e: unknown,
em: EnvironmentManagers,
Expand Down
8 changes: 6 additions & 2 deletions src/features/envManagers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,16 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {

public async clearCache(scope: EnvironmentManagerScope): Promise<void> {
if (scope === undefined) {
await Promise.all(this.managers.map((m) => m.clearCache()));
await Promise.all(
this.managers
.filter((manager) => manager.id !== INLINE_SCRIPT_MANAGER_ID)
.map((manager) => manager.clearCache()),
);
return;
}

const manager = this.getEnvironmentManager(scope);
if (manager) {
if (manager && manager.id !== INLINE_SCRIPT_MANAGER_ID) {
await manager.clearCache();
}
}
Expand Down
5 changes: 1 addition & 4 deletions src/features/projectManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,20 +130,17 @@ export class PythonProjectManagerImpl implements PythonProjectManager {
// For each override, resolve its path and add as a project if not already present
for (const o of overrides) {
let uriFromWorkspace: Uri | undefined = undefined;
// if override has a workspace property, resolve the path relative to that workspace
if (o.workspace) {
//
const workspaceFolder = workspaces.find((ws) => ws.name === o.workspace);
if (workspaceFolder) {
if (workspaceFolder.uri.toString() !== w.uri.toString()) {
continue; // skip if the workspace is not the same as the current workspace
continue;
}
uriFromWorkspace = Uri.file(path.resolve(workspaceFolder.uri.fsPath, o.path));
}
}
const uri = uriFromWorkspace ? uriFromWorkspace : Uri.file(path.resolve(w.uri.fsPath, o.path));

// Check if the project already exists in the newProjects array
if (!newProjects.some((p) => p.uri.toString() === uri.toString())) {
newProjects.push(new PythonProjectsImpl(o.path, uri));
}
Expand Down
Loading
Loading