Skip to content
Closed
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
11 changes: 11 additions & 0 deletions src/common/errors/AggregateEnvironmentError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Minimal stand-in for `AggregateError` (absent from the ES2020 lib the extension targets); carries
// the aggregated `errors` without requiring a tsconfig lib bump.
export class AggregateEnvironmentError extends Error {
public readonly errors: unknown[];

constructor(message: string, errors: unknown[]) {
super(message);
this.name = 'AggregateEnvironmentError';
this.errors = [...errors];
Comment thread
StellaHuang95 marked this conversation as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Warning · Non-blocking recommendation

AggregateEnvironmentError is thrown by public getEnvironments and refreshEnvironments paths but is not exported through the public API. Consumers therefore cannot type-safely recognize or inspect the new errors payload. Please export and document this error contract, or keep the public failure shape to a standard Error.

}
}
72 changes: 48 additions & 24 deletions src/common/pickers/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { sendTelemetryEvent } from '../telemetry/sender';
import { isWindows } from '../utils/platformUtils';
import { handlePythonPath } from '../utils/pythonPath';
import {
QuickPickController,
showErrorMessage,
showOpenDialog,
showQuickPick,
Expand Down Expand Up @@ -120,16 +121,20 @@ async function createEnvironment(
}
}

type EnvironmentPickItem = QuickPickItem | (QuickPickItem & { result: PythonEnvironment });

async function pickEnvironmentImpl(
items: (QuickPickItem | (QuickPickItem & { result: PythonEnvironment }))[],
items: EnvironmentPickItem[],
managers: InternalEnvironmentManager[],
projectEnvManagers: InternalEnvironmentManager[],
options: EnvironmentPickOptions,
onDidShow?: (controller: QuickPickController<EnvironmentPickItem>) => void,
): Promise<PythonEnvironment | undefined> {
const selected = await showQuickPickWithButtons(items, {
placeHolder: Pickers.Environments.selectEnvironment,
ignoreFocusOut: true,
showBackButton: options?.showBackButton,
onDidShow,
});

if (selected && !Array.isArray(selected)) {
Expand All @@ -152,7 +157,7 @@ export async function pickEnvironment(
projectEnvManagers: InternalEnvironmentManager[],
options: EnvironmentPickOptions,
): Promise<PythonEnvironment | undefined> {
const items: (QuickPickItem | (QuickPickItem & { result: PythonEnvironment }))[] = [
const items: EnvironmentPickItem[] = [
{
label: Interpreter.browsePath,
iconPath: new ThemeIcon('folder'),
Expand Down Expand Up @@ -188,30 +193,49 @@ export async function pickEnvironment(
);
}

for (const manager of managers) {
items.push({
label: manager.displayName,
kind: QuickPickItemKind.Separator,
// Load every manager's environments concurrently after the picker is shown so opening never waits
// on the slowest manager, and a single manager that rejects can't hide the others' environments.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Issue · Please address or respond

manager.getEnvironments('all') is invoked while constructing the Promise.allSettled input. A synchronous throw therefore escapes before allSettled is created, rejects onDidShow, and closes the picker. Wrap each invocation in an async callback and add a synchronous-throw picker regression test.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Warning · Non-blocking recommendation

Items are applied only after every manager settles. If one manager never resolves, the picker remains busy and successful managers' environments never appear. Consider cancellation/timeout handling or progressively publishing settled sections, with a never-settling-manager regression test.

const onDidShow = (controller: QuickPickController<EnvironmentPickItem>) => {
controller.setBusy(true);
void Promise.allSettled(managers.map((manager) => manager.getEnvironments('all'))).then((results) => {
const withEnvironments: EnvironmentPickItem[] = [...items];
results.forEach((outcome, index) => {
const manager = managers[index];
if (outcome.status === 'rejected') {
traceError(
`[pickEnvironment] Failed to load environments for manager "${manager.id}"; section skipped.`,
outcome.reason,
);
return;
}
withEnvironments.push({
label: manager.displayName,
kind: QuickPickItemKind.Separator,
});
withEnvironments.push(
...outcome.value.map((e) => {
const pathDescription = e.displayPath;
const description =
e.description && e.description.trim()
? `${e.description} (${pathDescription})`
: pathDescription;

return {
label: e.displayName ?? e.name,
description: description,
result: e,
manager: manager,
iconPath: getIconPath(e.iconPath),
};
}),
);
});
controller.setItems(withEnvironments);
controller.setBusy(false);
});
const envs = await manager.getEnvironments('all');
items.push(
...envs.map((e) => {
const pathDescription = e.displayPath;
const description =
e.description && e.description.trim() ? `${e.description} (${pathDescription})` : pathDescription;

return {
label: e.displayName ?? e.name,
description: description,
result: e,
manager: manager,
iconPath: getIconPath(e.iconPath),
};
}),
);
}
};

return pickEnvironmentImpl(items, managers, projectEnvManagers, options);
return pickEnvironmentImpl(items, managers, projectEnvManagers, options, onDidShow);
}

export async function pickEnvironmentFrom(environments: PythonEnvironment[]): Promise<PythonEnvironment | undefined> {
Expand Down
33 changes: 32 additions & 1 deletion src/common/window.apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ export interface QuickPickButtonEvent<T extends QuickPickItem> {
readonly button: QuickInputButton;
}

/** Populates items and toggles the busy indicator on a shown quick pick; no-ops once it settles. */
export interface QuickPickController<T extends QuickPickItem> {
setItems(items: readonly T[]): void;
setBusy(busy: boolean): void;
}

export function showQuickPick<T extends QuickPickItem>(
items: readonly T[] | Thenable<readonly T[]>,
options?: QuickPickOptions,
Expand All @@ -167,13 +173,19 @@ export function withProgress<R>(

export async function showQuickPickWithButtons<T extends QuickPickItem>(
items: readonly T[],
options?: QuickPickOptions & { showBackButton?: boolean; buttons?: QuickInputButton[]; selected?: T[] },
options?: QuickPickOptions & {
showBackButton?: boolean;
buttons?: QuickInputButton[];
selected?: T[];
onDidShow?: (controller: QuickPickController<T>) => void;
},
token?: CancellationToken,
itemButtonHandler?: (e: QuickPickItemButtonEvent<T>) => void,
): Promise<T | T[] | undefined> {
const quickPick: QuickPick<T> = window.createQuickPick<T>();
const disposables: Disposable[] = [quickPick];
const deferred = createDeferred<T | T[] | undefined>();
let disposed = false;

quickPick.items = items;
quickPick.canSelectMany = options?.canPickMany ?? false;
Expand Down Expand Up @@ -234,8 +246,27 @@ export async function showQuickPickWithButtons<T extends QuickPickItem>(
quickPick.show();

try {
if (options?.onDidShow) {
const controller: QuickPickController<T> = {
setBusy(busy: boolean) {
if (deferred.completed || disposed) {
return;
}
quickPick.busy = busy;
},
setItems(newItems: readonly T[]) {
if (deferred.completed || disposed) {
return;
}
quickPick.items = newItems;
},
};
options.onDidShow(controller);
}

return await deferred.promise;
} finally {
disposed = true;
disposables.forEach((d) => d.dispose());
}
}
Expand Down
51 changes: 48 additions & 3 deletions src/features/pythonApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
ResolveEnvironmentContext,
SetEnvironmentScope,
} from '../api';
import { AggregateEnvironmentError } from '../common/errors/AggregateEnvironmentError';
import { traceError, traceInfo } from '../common/logging';
import { pickEnvironmentManager } from '../common/pickers/managers';
import { timeout } from '../common/utils/asyncUtils';
Expand Down Expand Up @@ -60,6 +61,45 @@ import { TerminalManager } from './terminal/terminalManager';
const GET_ENVIRONMENT_TIMEOUT_MS = 1000;
const GET_ENVIRONMENT_TIMED_OUT = Symbol('getEnvironmentTimedOut');

// Runs `operation` on every manager concurrently, returns the successful results in manager order,
// logs each failure, and throws AggregateEnvironmentError only when all fail (empty list -> []).
async function collectFromManagers<T>(
managers: readonly InternalEnvironmentManager[],
context: string,
operation: (manager: InternalEnvironmentManager) => Promise<T>,
): Promise<T[]> {
if (managers.length === 0) {
return [];
}

// Wrap each call so a manager that throws synchronously is isolated as a rejected outcome rather
// than escaping Promise.allSettled and hiding the other managers' results.
const settled = await Promise.allSettled(managers.map(async (manager) => operation(manager)));

const results: T[] = [];
const errors: unknown[] = [];
settled.forEach((outcome, index) => {
if (outcome.status === 'fulfilled') {
results.push(outcome.value);
} else {
errors.push(outcome.reason);
traceError(
`[${context}] Environment manager "${managers[index].id}" failed and was skipped.`,
outcome.reason,
);
}
});

if (errors.length === managers.length) {
throw new AggregateEnvironmentError(
`[${context}] All ${managers.length} environment manager(s) failed.`,
errors,
);
}

Comment thread
StellaHuang95 marked this conversation as resolved.
return results;
}

export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
private readonly _onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
private readonly _onDidChangeEnvironment = new EventEmitter<DidChangeEnvironmentEventArgs>();
Expand Down Expand Up @@ -209,7 +249,9 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {

if (currentScope === undefined) {
await waitForAllEnvManagers();
await Promise.all(this.envManagers.managers.map((manager) => manager.refresh(currentScope)));
await collectFromManagers(this.envManagers.managers, 'refreshEnvironments(all)', (manager) =>
manager.refresh(currentScope),
);
return Promise.resolve();
Comment thread
StellaHuang95 marked this conversation as resolved.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Warning · Non-blocking recommendation

This Promise<void> completes successfully when one or more managers fail to refresh, leaving their environment state stale without exposing partial-failure information to callers. Please separate concurrent settlement from refresh policy so callers can distinguish a fully refreshed result from a partial failure.

}

Expand All @@ -224,8 +266,11 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
const currentScope = checkUri(scope) as GetEnvironmentsScope;
if (currentScope === 'all' || currentScope === 'global') {
await waitForAllEnvManagers();
const promises = this.envManagers.managers.map((manager) => manager.getEnvironments(currentScope));
const items = await Promise.all(promises);
const items = await collectFromManagers(
this.envManagers.managers,
`getEnvironments(${currentScope})`,
(manager) => manager.getEnvironments(currentScope),
);
Comment thread
StellaHuang95 marked this conversation as resolved.
return items.flat();
}

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

import { when } from 'ts-mockito';
import {
EventEmitter,
QuickInputButton,
QuickPick,
QuickPickItem,
QuickPickItemButtonEvent,
} from 'vscode';
import { mockedVSCodeNamespaces } from '../unittests';

export class FakeQuickPick<T extends QuickPickItem> {
private _items: readonly T[] = [];
// Models VS Code: assigning `items` moves focus to the first item and clears the selection.
public get items(): readonly T[] {
return this._items;
}
public set items(value: readonly T[]) {
this._items = value;
this.activeItems = value.length > 0 ? [value[0]] : [];
this.selectedItems = [];
}
public activeItems: readonly T[] = [];
public selectedItems: readonly T[] = [];
public value = '';
public placeholder: string | undefined;
public title: string | undefined;
public busy = false;
public enabled = true;
public canSelectMany = false;
public ignoreFocusOut = false;
public matchOnDescription = false;
public matchOnDetail = false;
public keepScrollPosition = false;
public buttons: readonly QuickInputButton[] = [];
public step: number | undefined;
public totalSteps: number | undefined;

public shown = false;
public disposed = false;

private readonly _onDidAccept = new EventEmitter<void>();
private readonly _onDidHide = new EventEmitter<void>();
private readonly _onDidChangeValue = new EventEmitter<string>();
private readonly _onDidChangeActive = new EventEmitter<readonly T[]>();
private readonly _onDidChangeSelection = new EventEmitter<readonly T[]>();
private readonly _onDidTriggerButton = new EventEmitter<QuickInputButton>();
private readonly _onDidTriggerItemButton = new EventEmitter<QuickPickItemButtonEvent<T>>();

public readonly onDidAccept = this._onDidAccept.event;
public readonly onDidHide = this._onDidHide.event;
public readonly onDidChangeValue = this._onDidChangeValue.event;
public readonly onDidChangeActive = this._onDidChangeActive.event;
public readonly onDidChangeSelection = this._onDidChangeSelection.event;
public readonly onDidTriggerButton = this._onDidTriggerButton.event;
public readonly onDidTriggerItemButton = this._onDidTriggerItemButton.event;

public show(): void {
this.shown = true;
}

public hide(): void {
this._onDidHide.fire();
}

public dispose(): void {
this.disposed = true;
}

public accept(item?: T): void {
if (item) {
this.selectedItems = [item];
}
this._onDidAccept.fire();
}

public triggerButton(button: QuickInputButton): void {
this._onDidTriggerButton.fire(button);
}

public cancel(): void {
this.hide();
}

public asQuickPick(): QuickPick<T> {
return this as unknown as QuickPick<T>;
}
}

export function useFakeQuickPick<T extends QuickPickItem>(): FakeQuickPick<T> {
const fake = new FakeQuickPick<T>();
when(mockedVSCodeNamespaces.window!.createQuickPick<T>()).thenReturn(fake.asQuickPick());
return fake;
}

export function flush(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
Loading
Loading