-
Notifications
You must be signed in to change notification settings - Fork 0
fix: isolate environment consumers from single-manager failures #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import { sendTelemetryEvent } from '../telemetry/sender'; | |
| import { isWindows } from '../utils/platformUtils'; | ||
| import { handlePythonPath } from '../utils/pythonPath'; | ||
| import { | ||
| QuickPickController, | ||
| showErrorMessage, | ||
| showOpenDialog, | ||
| showQuickPick, | ||
|
|
@@ -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)) { | ||
|
|
@@ -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'), | ||
|
|
@@ -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. | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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, | ||
| ); | ||
| } | ||
|
|
||
|
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>(); | ||
|
|
@@ -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(); | ||
|
StellaHuang95 marked this conversation as resolved.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This |
||
| } | ||
|
|
||
|
|
@@ -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), | ||
| ); | ||
|
StellaHuang95 marked this conversation as resolved.
|
||
| return items.flat(); | ||
| } | ||
|
|
||
|
|
||
| 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)); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.