Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The following environment managers are supported out of the box:
| conda | ✅ | ✅ | ✅ |
| pyenv | ✅ | | |
| poetry | ✅ | | |
| system | ✅ | | |
| system | ✅ | ✅ | |
| pipenv | ✅ | | |

**Legend:**
Expand Down
5 changes: 4 additions & 1 deletion src/common/inlineScript/routingRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ export type InlineScriptSetupOutcome =
readonly kind: 'failed';
readonly category: InlineScriptEnvErrorCategory;
readonly requiresPython?: string;
/** Optional installer-specific explanation, already localized for the setup UI. */
readonly message?: string;
readonly alreadyReported?: boolean;
}
| { readonly kind: 'cancelled' }
| { readonly kind: 'cancelled'; readonly message?: string }
| { readonly kind: 'skipped' };

interface ScriptRoutingState {
Expand Down
264 changes: 264 additions & 0 deletions src/common/installerProcess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { ChildProcessWithoutNullStreams } from 'child_process';
import { StringDecoder } from 'string_decoder';
import { CancellationError, CancellationToken, Disposable, l10n } from 'vscode';
import { spawnProcess } from './childProcess.apis';
import { traceVerbose } from './logging';

const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
const TERMINATION_GRACE_MS = 1000;

export interface InstallerProcessOptions {
readonly timeoutMs?: number;
readonly cancellationToken?: CancellationToken;
readonly onOutput?: (text: string) => void;
readonly maxOutputBytes?: number;
}

export interface InstallerProcessResult {
readonly stdout: string;
readonly stderr: string;
}

export class InstallerProcessError extends Error {
/** Diagnostic output, kept out of the user-facing message and telemetry. */
public stderr = '';
/**
* Describes a process failure without including arguments or potentially sensitive output.
* Uncertainty also covers descendants or external work that can survive the direct child.
*/
constructor(
readonly kind: 'spawn' | 'exit' | 'timeout' | 'output-limit',
readonly exitCode?: number | null,
readonly processMayStillBeRunning = false,
readonly cause?: unknown,
) {
const messages = {
spawn: l10n.t('The Python installer process could not be started.'),
exit: l10n.t('The Python installer process failed.'),
timeout: l10n.t('The Python installer process timed out.'),
'output-limit': l10n.t('The Python installer process exceeded its output limit.'),
};
super(messages[kind]);
this.name = 'InstallerProcessError';
}
}

class InstallerCancellationError extends CancellationError {
constructor(readonly processMayStillBeRunning: boolean) {
super();
}
}

function traceLateError(): void {
traceVerbose('Received an installer process or stream error after completion.');
}

/**
* Runs an executable directly with piped stdio, bounded output and a finite timeout.
* @param executable Executable path or command; never interpreted by a shell.
* @param args Separate, unmodified arguments.
* @param options Limits, cancellation, and opt-in raw output forwarding.
* @returns Separate UTF-8 stdout/stderr after the child and its streams close.
* Cancellation and forced stops wait briefly for close, but cannot guarantee that
* descendants or external work (such as downloads) have stopped.
*/
export async function runInstallerProcess(
executable: string,
args: readonly string[],
options: InstallerProcessOptions = {},
): Promise<InstallerProcessResult> {
const { cancellationToken, onOutput } = options;
if (cancellationToken?.isCancellationRequested) {
throw new InstallerCancellationError(false);
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 2147483647) {
throw new RangeError(l10n.t('The installer timeout must be a finite, non-negative number of milliseconds.'));
}
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0) {
throw new RangeError(l10n.t('The installer output limit must be a finite, non-negative number of bytes.'));
}

let child: ChildProcessWithoutNullStreams;
try {
child = spawnProcess(executable, [...args], { shell: false, windowsHide: true, stdio: 'pipe' });
} catch (error) {
throw new InstallerProcessError('spawn', undefined, false, error);
}

return new Promise<InstallerProcessResult>((resolve, reject) => {
const stdout = { decoder: new StringDecoder('utf8'), chunks: [] as string[] };
const stderr = { decoder: new StringDecoder('utf8'), chunks: [] as string[] };
let bytes = 0;
let settled = false;
let closed = false;
let exited = false;
let exitCode: number | null | undefined;
let failure: InstallerProcessError | InstallerCancellationError | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
let grace: ReturnType<typeof setTimeout> | undefined;
let cancellation: Disposable | undefined;

function cleanup(): void {
clearTimeout(timeout);
clearTimeout(grace);
cancellation?.dispose();
child.removeListener('exit', onExit);
child.removeListener('close', onClose);
child.stdout.removeListener('data', onStdout);
child.stderr.removeListener('data', onStderr);
// Keep only stateless error guards: queued errors (notably stdin EPIPE)
// can arrive after settlement or while the local pipes are destroyed.
child.on('error', traceLateError);
child.removeListener('error', onProcessError);
child.stdin.on('error', traceLateError);
child.stdin.removeListener('error', onStdinError);
for (const stream of [child.stdout, child.stderr]) {
stream.on('error', traceLateError);
stream.removeListener('error', onStreamError);
}
child.stdin.destroy();
child.stdout.destroy();
child.stderr.destroy();
}

function finish(): void {
if (settled) {
return;
}
settled = true;
cleanup();
if (failure) {
if (failure instanceof InstallerProcessError) {
failure.stderr = stderr.chunks.join('');
}
reject(failure);
} else {
resolve({ stdout: stdout.chunks.join(''), stderr: stderr.chunks.join('') });
}
stdout.chunks.length = 0;
stderr.chunks.length = 0;
}

function stop(error: InstallerProcessError | InstallerCancellationError, signalChild = true): void {
if (settled || failure) {
return;
}
failure = error;
clearTimeout(timeout);
if (closed) {
return;
}
grace = setTimeout(finish, TERMINATION_GRACE_MS);
if (signalChild && !exited) {
try {
if (!child.kill()) {
traceVerbose('Installer termination signal was not accepted; waiting for close.');
}
} catch (_error) {
traceVerbose('Installer termination signaling failed; waiting for close.');
}
}
}

function append(target: typeof stdout, text: string): void {
if (!text || failure || settled) {
return;
}
target.chunks.push(text);
try {
onOutput?.(text);
} catch (error) {
stop(new InstallerProcessError('exit', exitCode, !closed, error));
}
}

function capture(target: typeof stdout, data: Buffer | string): void {
if (failure || settled) {
return;
}
const buffer = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
bytes += buffer.length;
if (bytes > maxOutputBytes) {
stop(new InstallerProcessError('output-limit', exitCode, true));
return;
}
append(target, target.decoder.write(buffer));
}

function onStdout(data: Buffer | string): void {
capture(stdout, data);
}

function onStderr(data: Buffer | string): void {
capture(stderr, data);
}

function onExit(code: number | null): void {
exited = true;
exitCode = code;
}

function onClose(code: number | null, signal: NodeJS.Signals | null): void {
closed = true;
exitCode = code;
if (!failure && (code !== 0 || signal)) {
failure = new InstallerProcessError('exit', code, Boolean(signal));
}
if (!failure) {
append(stdout, stdout.decoder.end());
append(stderr, stderr.decoder.end());
}
finish();
}

function onProcessError(error: Error): void {
const mayBeRunning = child.pid !== undefined;
stop(new InstallerProcessError('spawn', exitCode, mayBeRunning, error), mayBeRunning);
}

function onStreamError(error: Error): void {
stop(new InstallerProcessError('exit', exitCode, !closed, error));
}

function onStdinError(error: Error): void {
if ('code' in error && (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED')) {
traceVerbose('Installer closed stdin before consuming all input; waiting for close.');
return;
}
onStreamError(error);
}

child.on('error', onProcessError);
child.on('exit', onExit);
child.on('close', onClose);
child.stdin.on('error', onStdinError);
child.stdout.on('error', onStreamError);
child.stderr.on('error', onStreamError);
child.stdout.on('data', onStdout);
child.stderr.on('data', onStderr);
timeout = setTimeout(() => stop(new InstallerProcessError('timeout', exitCode, true)), timeoutMs);
cancellation = cancellationToken?.onCancellationRequested(() => stop(new InstallerCancellationError(true)));
// Cover cancellation between the initial check and subscribing, including
// tokens whose registration invokes the callback synchronously.
if (settled) {
cancellation?.dispose();
return;
}
if (cancellationToken?.isCancellationRequested) {
stop(new InstallerCancellationError(true));
}
if (!failure) {
try {
child.stdin.end();
} catch (error) {
onStdinError(error instanceof Error ? error : new Error(l10n.t('Installer input could not be written.')));
}
}
});
}
55 changes: 55 additions & 0 deletions src/common/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,61 @@ export namespace ActivationStrings {
);
}

export namespace PythonInstallStrings {
export const pymanager = l10n.t('Python Install Manager');
export const fetchingVersions = l10n.t('Finding available Python versions...');
export const selectVersion = l10n.t('Select Python version to install');
export const installed = l10n.t('installed');
export const installFailed = l10n.t('Python installation did not complete. See the Python Environments output for details.');
export const managerUnusable = l10n.t(
'Python Install Manager is unavailable or could not be verified. Check its installation and configuration, then try again. uv was not started.',
);
export const catalogueFailed = l10n.t(
'Could not find Python versions using Python Install Manager. Check its configured source or network access and try again. uv was not started.',
);
export const noCompatiblePython = l10n.t('No compatible Python was found in the selected installer\'s catalogue.');
export const verificationFailed = l10n.t(
'A usable Python interpreter matching the request could not be verified. See the Python Environments output.',
);
export const discoveryFailed = l10n.t(
'Python was installed but could not be verified. Refresh environments and try again.',
);
export const timedOut = l10n.t(
'Python installation timed out. Background work may still be running. No alternate installer was started; check the Python Environments output before retrying.',
);
export const cancelled = l10n.t(
'Python installation was canceled. Any runtime already installed is kept, and background downloads may still finish. No alternate installer was started.',
);
export function installPrompt(version: string, requirement?: string): string {
return requirement
? l10n.t(
'No installed Python satisfies this script\'s requirement ({0}). Install Python {1} using Python Install Manager? An older Python Install Manager runtime in the same version family may be updated, affecting environments that use it. Newer runtimes will not be downgraded.',
requirement,
version,
)
: l10n.t(
'Install Python {0} using Python Install Manager? An older Python Install Manager runtime in the same version family may be updated, affecting environments that use it. Newer runtimes will not be downgraded.',
version,
);
}
export function installAction(version: string): string {
return l10n.t('Install / Update Python {0}', version);
}
export function installing(version: string): string {
return l10n.t('Installing Python {0} using Python Install Manager...', version);
}
export function runtimeConflict(installed: string, requested: string): string {
return l10n.t(
'Python Install Manager already has Python {0} in the slot required for Python {1}. The existing runtime will not be downgraded or forcibly replaced. Select another compatible interpreter or manage that runtime explicitly, then try again.',
installed,
requested,
);
}
export function complete(executable: string): string {
return l10n.t('Python is ready at {0}', executable);
}
}

export namespace UvInstallStrings {
export const noPythonFound = l10n.t('No Python installation found');
export const installPythonPrompt = l10n.t(
Expand Down
18 changes: 18 additions & 0 deletions src/common/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum EventNames {
UV_PYTHON_INSTALL_STARTED = 'UV.PYTHON_INSTALL_STARTED',
UV_PYTHON_INSTALL_COMPLETED = 'UV.PYTHON_INSTALL_COMPLETED',
UV_PYTHON_INSTALL_FAILED = 'UV.PYTHON_INSTALL_FAILED',
PYTHON_INSTALLER_OPERATION = 'PYTHON_INSTALLER.OPERATION',

PACKAGE_MANAGEMENT = 'PACKAGE_MANAGEMENT',
ADD_PROJECT = 'ADD_PROJECT',
Expand Down Expand Up @@ -412,6 +413,23 @@ export interface IEventNamePropertyMapping {
stage: 'uvInstall' | 'uvNotOnPath' | 'pythonInstall' | 'findPath';
};

/* __GDPR__
"python_installer.operation": {
"provider": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
"trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
"phase": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
"reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
"duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "StellaHuang95" }
}
*/
[EventNames.PYTHON_INSTALLER_OPERATION]: {
provider: 'pymanager' | 'uv';
trigger: 'activation' | 'createEnvironment' | 'inlineScript' | 'globalCreate';
phase: 'prompted' | 'started' | 'completed' | 'failed' | 'cancelled' | 'declined' | 'reused';
reason?: 'provider-unusable' | 'catalogue-failed' | 'no-compatible-python' | 'runtime-conflict' |
'install-failed' | 'verification-failed';
};

/* __GDPR__
"package_management": {
"managerId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
Expand Down
Loading
Loading