Skip to content

Commit 49e05cb

Browse files
edvilmeCopilot
andcommitted
fix: defer extension lookup in checkExtension to avoid stale state race condition
The checkExtension method previously captured the extension install state synchronously (via allExtensions().some()) before the setImmediate callback ran. By the time the callback executed, the extension state could have changed, leading to false 'install extension' prompts. Changes: - Replace allExtensions().some() with getExtension() inside the setImmediate callback, so the lookup uses fresh state - Remove unused allExtensions import - Add _resetManagerReadyForTesting() helper for test isolation - Add 4 regression tests for the race condition behavior Fixes #1465 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c5a1430 commit 49e05cb

2 files changed

Lines changed: 166 additions & 7 deletions

File tree

src/features/common/managerReady.ts

Lines changed: 16 additions & 6 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';
@@ -102,17 +102,16 @@ class ManagerReadyImpl implements ManagerReady {
102102
}
103103

104104
private checkExtension(managerId: string) {
105-
const installed = allExtensions().some((ext) => managerId.startsWith(`${ext.id}:`));
106105
if (this.checked.has(managerId)) {
107106
return;
108107
}
109108
this.checked.add(managerId);
110109
const extId = getExtensionId(managerId);
111110
if (extId) {
112111
setImmediate(async () => {
113-
if (installed) {
114-
const ext = getExtension(extId);
115-
if (ext && !ext.isActive) {
112+
const ext = getExtension(extId);
113+
if (ext) {
114+
if (!ext.isActive) {
116115
traceInfo(`Extension for manager ${managerId} is not active: Activating...`);
117116
try {
118117
await ext.activate();
@@ -122,7 +121,10 @@ class ManagerReadyImpl implements ManagerReady {
122121
}
123122
}
124123
} else {
125-
traceError(`Extension for manager ${managerId} is not installed.`);
124+
traceError(
125+
`Extension for manager ${managerId} is not installed. ` +
126+
`Looked up extId="${extId}" via getExtension().`,
127+
);
126128
const result = await showErrorMessage(
127129
l10n.t(`Do you want to install extension {0} to enable {1} support.`, extId, managerId),
128130
WorkbenchStrings.installExtension,
@@ -251,6 +253,14 @@ export function createManagerReady(em: EnvironmentManagers, pm: PythonProjectMan
251253
}
252254
}
253255

256+
/**
257+
* Reset the module-level singleton so `createManagerReady` can be called again.
258+
* Only for use in tests.
259+
*/
260+
export function _resetManagerReadyForTesting(): void {
261+
_deferred = createDeferred<ManagerReady>();
262+
}
263+
254264
export async function waitForEnvManager(uris?: Uri[]): Promise<void> {
255265
const mr = await _deferred.promise;
256266
return mr.waitForEnvManager(uris);

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

Lines changed: 150 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;
@@ -104,3 +123,133 @@ suite('withManagerTimeout', () => {
104123
assert.strictEqual(properties.managerKind, 'package');
105124
});
106125
});
126+
127+
suite('ManagerReady - race condition handling', () => {
128+
let envManagerEmitter: EventEmitter<DidChangeEnvironmentManagerEventArgs>;
129+
let pkgManagerEmitter: EventEmitter<DidChangePackageManagerEventArgs>;
130+
let clock: sinon.SinonFakeTimers;
131+
let disposables: Disposable[];
132+
133+
setup(() => {
134+
clock = sinon.useFakeTimers();
135+
disposables = [];
136+
137+
_resetManagerReadyForTesting();
138+
139+
envManagerEmitter = new EventEmitter<DidChangeEnvironmentManagerEventArgs>();
140+
pkgManagerEmitter = new EventEmitter<DidChangePackageManagerEventArgs>();
141+
142+
// Stub logging and telemetry to keep test output clean
143+
sinon.stub(logging, 'traceWarn');
144+
sinon.stub(logging, 'traceError');
145+
sinon.stub(logging, 'traceInfo');
146+
sinon.stub(telemetrySender, 'sendTelemetryEvent');
147+
sinon.stub(windowApis, 'showErrorMessage');
148+
sinon.stub(extensionApis, 'getExtension').returns({
149+
id: 'ms-python.python',
150+
isActive: true,
151+
} as unknown as ReturnType<typeof extensionApis.getExtension>);
152+
sinon.stub(settingHelpers, 'getDefaultEnvManagerSetting').returns('ms-python.python:venv');
153+
sinon.stub(settingHelpers, 'getDefaultPkgManagerSetting').returns('ms-python.python:pip');
154+
155+
const mockEm = {
156+
onDidChangeEnvironmentManager: envManagerEmitter.event,
157+
onDidChangePackageManager: pkgManagerEmitter.event,
158+
} as unknown as EnvironmentManagers;
159+
160+
const mockPm = {
161+
getProjects: () => [],
162+
} as unknown as PythonProjectManager;
163+
164+
createManagerReady(mockEm, mockPm, disposables);
165+
});
166+
167+
teardown(() => {
168+
clock.restore();
169+
disposables.forEach((d) => d.dispose());
170+
envManagerEmitter.dispose();
171+
pkgManagerEmitter.dispose();
172+
sinon.restore();
173+
_resetManagerReadyForTesting();
174+
});
175+
176+
test('checkExtension defers getExtension lookup to avoid stale state at startup', async () => {
177+
const getExtensionStub = extensionApis.getExtension as sinon.SinonStub;
178+
// Initially the extension is not available (simulating early startup)
179+
getExtensionStub.withArgs('ms-python.python').returns(undefined);
180+
181+
// Trigger checkExtension via waitForEnvManagerId
182+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
183+
184+
// Extension becomes available before setImmediate fires
185+
getExtensionStub.withArgs('ms-python.python').returns({
186+
id: 'ms-python.python',
187+
isActive: true,
188+
});
189+
190+
// Resolve the manager deferred
191+
envManagerEmitter.fire({
192+
kind: 'registered',
193+
manager: { id: 'ms-python.python:venv' } as unknown as InternalEnvironmentManager,
194+
});
195+
196+
// Flush setImmediate and microtasks
197+
clock.tick(0);
198+
await clock.tickAsync(0);
199+
await waitPromise;
200+
201+
// showErrorMessage should NOT have been called because getExtension is
202+
// evaluated inside setImmediate (after the extension became available)
203+
const showErrorStub = windowApis.showErrorMessage as sinon.SinonStub;
204+
assert.ok(!showErrorStub.called, 'should not prompt to install when extension becomes available before setImmediate fires');
205+
});
206+
207+
test('checkExtension does not show install prompt when extension is found via getExtension', async () => {
208+
const getExtensionStub = extensionApis.getExtension as sinon.SinonStub;
209+
getExtensionStub.withArgs('ms-python.python').returns({
210+
id: 'ms-python.python',
211+
isActive: true,
212+
});
213+
214+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
215+
216+
envManagerEmitter.fire({
217+
kind: 'registered',
218+
manager: { id: 'ms-python.python:venv' } as unknown as InternalEnvironmentManager,
219+
});
220+
221+
clock.tick(0);
222+
await clock.tickAsync(0);
223+
await waitPromise;
224+
225+
assert.ok(getExtensionStub.called, 'getExtension should be used for extension lookup');
226+
const showErrorStub = windowApis.showErrorMessage as sinon.SinonStub;
227+
assert.ok(!showErrorStub.called, 'should not prompt to install when extension is found');
228+
});
229+
230+
test('manager registered before wait resolves immediately', async () => {
231+
envManagerEmitter.fire({
232+
kind: 'registered',
233+
manager: { id: 'ms-python.python:venv' } as unknown as InternalEnvironmentManager,
234+
});
235+
236+
await clock.tickAsync(0);
237+
238+
// Wait should resolve immediately since the manager already registered
239+
const waitPromise = waitForEnvManagerId(['ms-python.python:venv']);
240+
await clock.tickAsync(0);
241+
await waitPromise;
242+
});
243+
244+
test('pkg manager wait resolves when registration event fires', async () => {
245+
const waitPromise = waitForPkgManagerId(['ms-python.python:pip']);
246+
247+
pkgManagerEmitter.fire({
248+
kind: 'registered',
249+
manager: { id: 'ms-python.python:pip' } as unknown as InternalPackageManager,
250+
});
251+
252+
await clock.tickAsync(0);
253+
await waitPromise;
254+
});
255+
});

0 commit comments

Comments
 (0)