Skip to content

Commit 6857c59

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 6857c59

2 files changed

Lines changed: 246 additions & 42 deletions

File tree

src/features/common/managerReady.ts

Lines changed: 76 additions & 41 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 genuinely not installed when the timeout
34+
* fires, the user is prompted to install it.
3435
*/
3536
export function withManagerTimeout(
3637
deferred: Deferred<void>,
@@ -40,6 +41,7 @@ export function withManagerTimeout(
4041
if (deferred.completed) {
4142
return deferred.promise;
4243
}
44+
4345
return new Promise<void>((resolve) => {
4446
const timer = setTimeout(() => {
4547
if (!deferred.completed) {
@@ -53,6 +55,7 @@ export function withManagerTimeout(
5355
managerKind: kind,
5456
});
5557
deferred.resolve();
58+
promptInstallExtensionIfMissing(managerId);
5659
}
5760
}, MANAGER_READY_TIMEOUT_MS);
5861

@@ -69,6 +72,55 @@ export function withManagerTimeout(
6972
});
7073
}
7174

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

104-
private checkExtension(managerId: string) {
105-
const installed = allExtensions().some((ext) => managerId.startsWith(`${ext.id}:`));
156+
/**
157+
* Best-effort activation nudge for the extension that provides the given manager.
158+
* Uses setImmediate so the extension host has a chance to finish initializing before
159+
* we query it. Deduplicated per manager ID to avoid redundant activation calls.
160+
*/
161+
private checkExtension(managerId: string): void {
106162
if (this.checked.has(managerId)) {
107163
return;
108164
}
109165
this.checked.add(managerId);
110166
const extId = getExtensionId(managerId);
111167
if (extId) {
112168
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-
}
169+
const ext = getExtension(extId);
170+
if (ext && !ext.isActive) {
171+
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
172+
try {
173+
await ext.activate();
174+
traceInfo(`Extension for manager ${managerId} is now active.`);
175+
} catch (err) {
176+
traceError(`Failed to activate extension ${extId}, required for: ${managerId}`, err);
148177
}
149178
}
150179
});
151-
} else {
152-
showErrorMessage(l10n.t(`Extension for {0} is not installed or enabled for this workspace.`, managerId));
153180
}
154181
}
155182

@@ -251,6 +278,14 @@ export function createManagerReady(em: EnvironmentManagers, pm: PythonProjectMan
251278
}
252279
}
253280

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