Skip to content

Commit 9fbeb75

Browse files
edvilmeCopilot
andauthored
fix: defer install prompt until after manager timeout to prevent false positives (#1496)
## Fix: Only show install prompt after manager timeout, not during startup Fixes #1465 ### Problem Users consistently see a false "Do you want to install extension ms-python.python to enable ms-python.python:venv support" prompt despite having the extension installed. This happens across Windows/Linux, persists despite reinstalls, and is a race condition during startup. ### Root Cause The race condition: - **A)** The extension host is loading all extensions (takes variable time) - **B)** Our code calls `checkExtension()` which queries `getExtension()` after just 1 tick (`setImmediate`) B can execute BEFORE A finishes — `getExtension()` returns `undefined` even though the extension IS installed, triggering a false install prompt. ### The Fix Separate the two concerns: 1. **`checkExtension()`** — fires early, but only does activation (harmless if it sees stale state — worst case it's a no-op). **Never prompts to install.** 2. **`promptInstallExtensionIfMissing()`** — fires ONLY after the 30s timeout in `withManagerTimeout()`, when `getExtension()` is guaranteed to be reliable. The install prompt can no longer race against extension host initialization because it's gated behind the same 30s timeout that already exists for "manager didn't register." ### Flow (annotated) ``` // BEFORE (broken): waitForEnvManagerId(["ms-python.python:venv"]) ├─ checkExtension() │ └─ setImmediate (1ms) → getExtension() → undefined (host still loading!) │ └─ ❌ showErrorMessage("Do you want to install...?") // FALSE PROMPT └─ setTimeout(30s) → ... // AFTER (fixed): waitForEnvManagerId(["ms-python.python:venv"]) ├─ checkExtension() │ └─ setImmediate (1ms) → getExtension() │ └─ ✅ Only activates if found inactive. Never prompts. └─ withManagerTimeout(30s) ├─ Manager registers at ~200ms → deferred resolves → timer cleared → done ✅ └─ 30s expire → promptInstallExtensionIfMissing() ├─ getExtension() → found? → log warning, NO prompt ✅ └─ getExtension() → not found? → show install prompt ✅ (genuinely missing) ``` ### Changes - **`withManagerTimeout`**: Now calls `promptInstallExtensionIfMissing()` on timeout expiry - **`promptInstallExtensionIfMissing`** (new): Checks `getExtension()` after 30s; only prompts if extension truly missing; logs warning if installed but manager never registered - **`checkExtension`**: Simplified to only attempt activation — no install prompts - **Added 5 regression tests** covering: timeout with/without extension installed, manager registering before timeout, pre-registered managers, package manager wait --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1eb7b67 commit 9fbeb75

2 files changed

Lines changed: 268 additions & 42 deletions

File tree

src/features/common/managerReady.ts

Lines changed: 98 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,9 @@ export function withManagerTimeout(
5355
managerKind: kind,
5456
});
5557
deferred.resolve();
58+
void promptInstallExtensionIfMissing(managerId).catch((err) => {
59+
traceError(`Failed to prompt installation for manager extension "${managerId}".`, err);
60+
});
5661
}
5762
}, MANAGER_READY_TIMEOUT_MS);
5863

@@ -69,6 +74,74 @@ export function withManagerTimeout(
6974
});
7075
}
7176

77+
/**
78+
* Shows an install prompt only if the extension for the given manager ID
79+
* is genuinely not available. Called after the timeout expires, giving the
80+
* extension host ample time to initialize. Deduplicated per extension ID
81+
* so each missing extension only prompts once per session.
82+
*/
83+
const prompted: Set<string> = new Set();
84+
async function promptInstallExtensionIfMissing(managerId: string): Promise<void> {
85+
const extId = getExtensionId(managerId);
86+
if (!extId) {
87+
showErrorMessage(l10n.t(`Extension for {0} is not installed or enabled for this workspace.`, managerId));
88+
return;
89+
}
90+
91+
const ext = getExtension(extId);
92+
if (ext) {
93+
// Extension is installed but the manager never registered — don't prompt to install.
94+
// Attempt activation as a recovery step since the extension host is reliable at this point.
95+
if (!ext.isActive) {
96+
traceWarn(
97+
`Extension ${extId} is installed but manager "${managerId}" never registered. Attempting activation...`,
98+
);
99+
try {
100+
await ext.activate();
101+
traceInfo(`Extension ${extId} activated post-timeout for manager "${managerId}".`);
102+
} catch (err) {
103+
traceError(`Failed to activate extension ${extId} post-timeout for: ${managerId}`, err);
104+
}
105+
} else {
106+
traceWarn(
107+
`Extension ${extId} is installed and active but manager "${managerId}" never registered. ` +
108+
`The extension may have failed during manager registration.`,
109+
);
110+
}
111+
return;
112+
}
113+
114+
if (prompted.has(extId)) {
115+
return;
116+
}
117+
prompted.add(extId);
118+
119+
traceError(`Extension for manager ${managerId} is not installed. Looked up extId="${extId}" via getExtension().`);
120+
const result = await showErrorMessage(
121+
l10n.t(`Do you want to install extension {0} to enable {1} support?`, extId, managerId),
122+
WorkbenchStrings.installExtension,
123+
);
124+
if (result === WorkbenchStrings.installExtension) {
125+
traceInfo(`Installing extension: ${extId}`);
126+
try {
127+
await installExtension(extId);
128+
traceInfo(`Extension ${extId} installed.`);
129+
} catch (err) {
130+
traceError(`Failed to install extension: ${extId}`, err);
131+
}
132+
133+
try {
134+
const installedExt = getExtension(extId);
135+
if (installedExt && !installedExt.isActive) {
136+
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
137+
await installedExt.activate();
138+
}
139+
} catch (err) {
140+
traceError(`Failed to activate extension ${extId}, required for: ${managerId}`, err);
141+
}
142+
}
143+
}
144+
72145
class ManagerReadyImpl implements ManagerReady {
73146
private readonly envManagers: Map<string, Deferred<void>> = new Map();
74147
private readonly pkgManagers: Map<string, Deferred<void>> = new Map();
@@ -101,55 +174,30 @@ class ManagerReadyImpl implements ManagerReady {
101174
);
102175
}
103176

104-
private checkExtension(managerId: string) {
105-
const installed = allExtensions().some((ext) => managerId.startsWith(`${ext.id}:`));
177+
/**
178+
* Best-effort activation nudge for the extension that provides the given manager.
179+
* Uses setImmediate so the extension host has a chance to finish initializing before
180+
* we query it. Deduplicated per manager ID to avoid redundant activation calls.
181+
*/
182+
private checkExtension(managerId: string): void {
106183
if (this.checked.has(managerId)) {
107184
return;
108185
}
109186
this.checked.add(managerId);
110187
const extId = getExtensionId(managerId);
111188
if (extId) {
112189
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-
}
190+
const ext = getExtension(extId);
191+
if (ext && !ext.isActive) {
192+
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
193+
try {
194+
await ext.activate();
195+
traceInfo(`Extension for manager ${managerId} is now active.`);
196+
} catch (err) {
197+
traceError(`Failed to activate extension ${extId}, required for: ${managerId}`, err);
148198
}
149199
}
150200
});
151-
} else {
152-
showErrorMessage(l10n.t(`Extension for {0} is not installed or enabled for this workspace.`, managerId));
153201
}
154202
}
155203

@@ -251,6 +299,15 @@ export function createManagerReady(em: EnvironmentManagers, pm: PythonProjectMan
251299
}
252300
}
253301

302+
/**
303+
* Reset the module-level singleton so `createManagerReady` can be called again.
304+
* Only for use in tests.
305+
*/
306+
export function _resetManagerReadyForTesting(): void {
307+
_deferred = createDeferred<ManagerReady>();
308+
prompted.clear();
309+
}
310+
254311
export async function waitForEnvManager(uris?: Uri[]): Promise<void> {
255312
const mr = await _deferred.promise;
256313
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)