Skip to content

Commit f2201d0

Browse files
edvilmeCopilot
andcommitted
fix: only show install prompt after manager timeout, not during startup
Previously, checkExtension would show the 'install extension' prompt inside a setImmediate callback — just one tick after startup. If the extension host hadn't fully initialized yet, getExtension() could return undefined even for installed extensions, causing a false prompt. Now the install prompt is only shown after the full 30-second timeout expires AND getExtension() still returns undefined. This gives the extension host ample time to initialize. Additionally, if the extension IS installed but the manager never registered (activation failure), we log a warning instead of prompting to install. Changes: - Move install prompt logic from checkExtension into promptInstallExtensionIfMissing, called only after timeout - checkExtension now only attempts activation (no install prompts) - Add guard: if extension is installed but manager never registered, log a warning instead of prompting to install - Add 5 regression tests for the race condition scenarios - Add _resetManagerReadyForTesting() helper for test isolation Fixes #1465 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c5a1430 commit f2201d0

2 files changed

Lines changed: 244 additions & 41 deletions

File tree

src/features/common/managerReady.ts

Lines changed: 74 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Disposable, l10n, Uri } from 'vscode';
2-
import { allExtensions, getExtension } from '../../common/extension.apis';
2+
import { getExtension } from '../../common/extension.apis';
33
import { WorkbenchStrings } from '../../common/localize';
44
import { traceError, traceInfo, traceWarn } from '../../common/logging';
55
import { EventNames } from '../../common/telemetry/constants';
@@ -30,7 +30,8 @@ function getExtensionId(managerId: string): string | undefined {
3030
/**
3131
* Wraps a deferred with a timeout so a missing/dead manager cannot block the API forever.
3232
* On timeout the deferred is resolved (not rejected) so callers proceed with degraded results
33-
* instead of hanging.
33+
* instead of hanging. If the manager's extension is not installed when the timeout fires,
34+
* the user is prompted to install it.
3435
*/
3536
export function withManagerTimeout(
3637
deferred: Deferred<void>,
@@ -53,6 +54,7 @@ export function withManagerTimeout(
5354
managerKind: kind,
5455
});
5556
deferred.resolve();
57+
promptInstallExtensionIfMissing(managerId);
5658
}
5759
}, MANAGER_READY_TIMEOUT_MS);
5860

@@ -69,6 +71,55 @@ export function withManagerTimeout(
6971
});
7072
}
7173

74+
/**
75+
* Shows an install prompt only if the extension for the given manager ID
76+
* is genuinely not available. Called after the timeout expires, giving the
77+
* extension host ample time to initialize.
78+
*/
79+
async function promptInstallExtensionIfMissing(managerId: string): Promise<void> {
80+
const extId = getExtensionId(managerId);
81+
if (!extId) {
82+
showErrorMessage(l10n.t(`Extension for {0} is not installed or enabled for this workspace.`, managerId));
83+
return;
84+
}
85+
86+
const ext = getExtension(extId);
87+
if (ext) {
88+
// Extension is installed but the manager never registered — don't prompt to install.
89+
// This can happen if the extension activated but failed to register its manager.
90+
traceWarn(
91+
`Extension ${extId} is installed but manager "${managerId}" never registered. ` +
92+
`The extension may have failed during activation or manager registration.`,
93+
);
94+
return;
95+
}
96+
97+
traceError(`Extension for manager ${managerId} is not installed. Looked up extId="${extId}" via getExtension().`);
98+
const result = await showErrorMessage(
99+
l10n.t(`Do you want to install extension {0} to enable {1} support.`, extId, managerId),
100+
WorkbenchStrings.installExtension,
101+
);
102+
if (result === WorkbenchStrings.installExtension) {
103+
traceInfo(`Installing extension: ${extId}`);
104+
try {
105+
await installExtension(extId);
106+
traceInfo(`Extension ${extId} installed.`);
107+
} catch (err) {
108+
traceError(`Failed to install extension: ${extId}`, err);
109+
}
110+
111+
try {
112+
const installedExt = getExtension(extId);
113+
if (installedExt && !installedExt.isActive) {
114+
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
115+
await installedExt.activate();
116+
}
117+
} catch (err) {
118+
traceError(`Failed to activate extension ${extId}, required for: ${managerId}`, err);
119+
}
120+
}
121+
}
122+
72123
class ManagerReadyImpl implements ManagerReady {
73124
private readonly envManagers: Map<string, Deferred<void>> = new Map();
74125
private readonly pkgManagers: Map<string, Deferred<void>> = new Map();
@@ -101,55 +152,30 @@ class ManagerReadyImpl implements ManagerReady {
101152
);
102153
}
103154

155+
/**
156+
* Attempts to activate the extension for a manager if it's installed but not active.
157+
* Does NOT show install prompts — that is handled by withManagerTimeout after the
158+
* manager has had a full timeout period to register.
159+
*/
104160
private checkExtension(managerId: string) {
105-
const installed = allExtensions().some((ext) => managerId.startsWith(`${ext.id}:`));
106161
if (this.checked.has(managerId)) {
107162
return;
108163
}
109164
this.checked.add(managerId);
110165
const extId = getExtensionId(managerId);
111166
if (extId) {
112167
setImmediate(async () => {
113-
if (installed) {
114-
const ext = getExtension(extId);
115-
if (ext && !ext.isActive) {
116-
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
117-
try {
118-
await ext.activate();
119-
traceInfo(`Extension for manager ${managerId} is now active.`);
120-
} catch (err) {
121-
traceError(`Failed to activate extension ${extId}, required for: ${managerId}`, err);
122-
}
123-
}
124-
} else {
125-
traceError(`Extension for manager ${managerId} is not installed.`);
126-
const result = await showErrorMessage(
127-
l10n.t(`Do you want to install extension {0} to enable {1} support.`, extId, managerId),
128-
WorkbenchStrings.installExtension,
129-
);
130-
if (result === WorkbenchStrings.installExtension) {
131-
traceInfo(`Installing extension: ${extId}`);
132-
try {
133-
await installExtension(extId);
134-
traceInfo(`Extension ${extId} installed.`);
135-
} catch (err) {
136-
traceError(`Failed to install extension: ${extId}`, err);
137-
}
138-
139-
try {
140-
const ext = getExtension(extId);
141-
if (ext && !ext.isActive) {
142-
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
143-
await ext.activate();
144-
}
145-
} catch (err) {
146-
traceError(`Failed to activate extension ${extId}, required for: ${managerId}`, err);
147-
}
168+
const ext = getExtension(extId);
169+
if (ext && !ext.isActive) {
170+
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
171+
try {
172+
await ext.activate();
173+
traceInfo(`Extension for manager ${managerId} is now active.`);
174+
} catch (err) {
175+
traceError(`Failed to activate extension ${extId}, required for: ${managerId}`, err);
148176
}
149177
}
150178
});
151-
} else {
152-
showErrorMessage(l10n.t(`Extension for {0} is not installed or enabled for this workspace.`, managerId));
153179
}
154180
}
155181

@@ -251,6 +277,14 @@ export function createManagerReady(em: EnvironmentManagers, pm: PythonProjectMan
251277
}
252278
}
253279

280+
/**
281+
* Reset the module-level singleton so `createManagerReady` can be called again.
282+
* Only for use in tests.
283+
*/
284+
export function _resetManagerReadyForTesting(): void {
285+
_deferred = createDeferred<ManagerReady>();
286+
}
287+
254288
export async function waitForEnvManager(uris?: Uri[]): Promise<void> {
255289
const mr = await _deferred.promise;
256290
return mr.waitForEnvManager(uris);

src/test/features/common/managerReady.unit.test.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
11
import assert from 'assert';
22
import * as sinon from 'sinon';
3+
import { Disposable, EventEmitter } from 'vscode';
4+
import * as extensionApis from '../../../common/extension.apis';
35
import * as logging from '../../../common/logging';
46
import { EventNames } from '../../../common/telemetry/constants';
57
import * as telemetrySender from '../../../common/telemetry/sender';
68
import { createDeferred } from '../../../common/utils/deferred';
7-
import { MANAGER_READY_TIMEOUT_MS, withManagerTimeout } from '../../../features/common/managerReady';
9+
import * as windowApis from '../../../common/window.apis';
10+
import {
11+
MANAGER_READY_TIMEOUT_MS,
12+
_resetManagerReadyForTesting,
13+
createManagerReady,
14+
waitForEnvManagerId,
15+
waitForPkgManagerId,
16+
withManagerTimeout,
17+
} from '../../../features/common/managerReady';
18+
import * as settingHelpers from '../../../features/settings/settingHelpers';
19+
import {
20+
DidChangeEnvironmentManagerEventArgs,
21+
DidChangePackageManagerEventArgs,
22+
EnvironmentManagers,
23+
InternalEnvironmentManager,
24+
InternalPackageManager,
25+
PythonProjectManager,
26+
} from '../../../internal.api';
827

928
suite('withManagerTimeout', () => {
1029
let clock: sinon.SinonFakeTimers;
@@ -14,7 +33,11 @@ suite('withManagerTimeout', () => {
1433
setup(() => {
1534
clock = sinon.useFakeTimers();
1635
traceWarnStub = sinon.stub(logging, 'traceWarn');
36+
sinon.stub(logging, 'traceError');
1737
sendTelemetryStub = sinon.stub(telemetrySender, 'sendTelemetryEvent');
38+
// Stub dependencies used by promptInstallExtensionIfMissing (called on timeout)
39+
sinon.stub(extensionApis, 'getExtension').returns(undefined);
40+
sinon.stub(windowApis, 'showErrorMessage').returns(Promise.resolve(undefined));
1841
});
1942

2043
teardown(() => {
@@ -104,3 +127,149 @@ suite('withManagerTimeout', () => {
104127
assert.strictEqual(properties.managerKind, 'package');
105128
});
106129
});
130+
131+
suite('ManagerReady - race condition handling', () => {
132+
let envManagerEmitter: EventEmitter<DidChangeEnvironmentManagerEventArgs>;
133+
let pkgManagerEmitter: EventEmitter<DidChangePackageManagerEventArgs>;
134+
let clock: sinon.SinonFakeTimers;
135+
let disposables: Disposable[];
136+
137+
setup(() => {
138+
clock = sinon.useFakeTimers();
139+
disposables = [];
140+
141+
_resetManagerReadyForTesting();
142+
143+
envManagerEmitter = new EventEmitter<DidChangeEnvironmentManagerEventArgs>();
144+
pkgManagerEmitter = new EventEmitter<DidChangePackageManagerEventArgs>();
145+
146+
// Stub logging and telemetry to keep test output clean
147+
sinon.stub(logging, 'traceWarn');
148+
sinon.stub(logging, 'traceError');
149+
sinon.stub(logging, 'traceInfo');
150+
sinon.stub(telemetrySender, 'sendTelemetryEvent');
151+
sinon.stub(windowApis, 'showErrorMessage').returns(Promise.resolve(undefined));
152+
sinon.stub(extensionApis, 'getExtension').returns({
153+
id: 'ms-python.python',
154+
isActive: true,
155+
} as unknown as ReturnType<typeof extensionApis.getExtension>);
156+
sinon.stub(settingHelpers, 'getDefaultEnvManagerSetting').returns('ms-python.python:venv');
157+
sinon.stub(settingHelpers, 'getDefaultPkgManagerSetting').returns('ms-python.python:pip');
158+
159+
const mockEm = {
160+
onDidChangeEnvironmentManager: envManagerEmitter.event,
161+
onDidChangePackageManager: pkgManagerEmitter.event,
162+
} as unknown as EnvironmentManagers;
163+
164+
const mockPm = {
165+
getProjects: () => [],
166+
} as unknown as PythonProjectManager;
167+
168+
createManagerReady(mockEm, mockPm, disposables);
169+
});
170+
171+
teardown(() => {
172+
clock.restore();
173+
disposables.forEach((d) => d.dispose());
174+
envManagerEmitter.dispose();
175+
pkgManagerEmitter.dispose();
176+
sinon.restore();
177+
_resetManagerReadyForTesting();
178+
});
179+
180+
test('no install prompt when manager registers before timeout', async () => {
181+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
182+
// Flush microtasks so the internal await _deferred.promise completes
183+
// and the timeout/deferred is set up
184+
await clock.tickAsync(0);
185+
186+
// Manager registers before timeout
187+
envManagerEmitter.fire({
188+
kind: 'registered',
189+
manager: { id: 'ms-python.python:venv' } as unknown as InternalEnvironmentManager,
190+
});
191+
192+
await clock.tickAsync(0);
193+
await waitPromise;
194+
195+
// Advance past timeout to ensure no late prompt
196+
clock.tick(MANAGER_READY_TIMEOUT_MS);
197+
await clock.tickAsync(0);
198+
199+
const showErrorStub = windowApis.showErrorMessage as sinon.SinonStub;
200+
assert.ok(!showErrorStub.called, 'should not prompt to install when manager registered successfully');
201+
});
202+
203+
test('no install prompt on timeout when extension is installed but manager never registered', async () => {
204+
// Extension IS installed (getExtension returns it), but manager never fires registration event
205+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
206+
// Flush microtasks so internal await completes and timeout is armed
207+
await clock.tickAsync(0);
208+
209+
// Advance past timeout — manager never registers
210+
clock.tick(MANAGER_READY_TIMEOUT_MS);
211+
await clock.tickAsync(0);
212+
await waitPromise;
213+
214+
// Should NOT prompt to install because getExtension finds the extension
215+
const showErrorStub = windowApis.showErrorMessage as sinon.SinonStub;
216+
assert.ok(!showErrorStub.called, 'should not prompt to install when extension is installed');
217+
218+
// Should log a warning instead
219+
const traceWarnStub = logging.traceWarn as sinon.SinonStub;
220+
const warnAboutManager = traceWarnStub.getCalls().find(
221+
(c: sinon.SinonSpyCall) => typeof c.args[0] === 'string' && c.args[0].includes('never registered'),
222+
);
223+
assert.ok(warnAboutManager, 'should warn that manager never registered despite extension being installed');
224+
});
225+
226+
test('install prompt shown on timeout only when extension is genuinely not installed', async () => {
227+
const getExtensionStub = extensionApis.getExtension as sinon.SinonStub;
228+
getExtensionStub.returns(undefined); // Extension not installed
229+
230+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
231+
// Flush microtasks so internal await completes and timeout is armed
232+
await clock.tickAsync(0);
233+
234+
// Advance past timeout — manager never registers
235+
clock.tick(MANAGER_READY_TIMEOUT_MS);
236+
await clock.tickAsync(0);
237+
await waitPromise;
238+
239+
// NOW the install prompt should appear (after 30s, not immediately)
240+
const showErrorStub = windowApis.showErrorMessage as sinon.SinonStub;
241+
assert.ok(showErrorStub.called, 'should prompt to install after timeout when extension is missing');
242+
});
243+
244+
test('manager registered before wait resolves immediately without prompt', async () => {
245+
envManagerEmitter.fire({
246+
kind: 'registered',
247+
manager: { id: 'ms-python.python:venv' } as unknown as InternalEnvironmentManager,
248+
});
249+
250+
await clock.tickAsync(0);
251+
252+
// Wait should resolve immediately since the manager already registered
253+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
254+
await clock.tickAsync(0);
255+
await waitPromise;
256+
257+
const showErrorStub = windowApis.showErrorMessage as sinon.SinonStub;
258+
assert.ok(!showErrorStub.called, 'should not prompt when manager already registered');
259+
});
260+
261+
test('pkg manager wait resolves when registration event fires', async () => {
262+
const waitPromise = waitForPkgManagerId(['ms-python.python:pip']);
263+
264+
pkgManagerEmitter.fire({
265+
kind: 'registered',
266+
manager: { id: 'ms-python.python:pip' } as unknown as InternalPackageManager,
267+
});
268+
269+
await clock.tickAsync(0);
270+
await waitPromise;
271+
272+
const showErrorStub = windowApis.showErrorMessage as sinon.SinonStub;
273+
assert.ok(!showErrorStub.called, 'should not prompt when pkg manager registered');
274+
});
275+
});

0 commit comments

Comments
 (0)