Skip to content

Commit dc7f9bf

Browse files
Register test commands before first await to prevent activation race condition (#25654)
fixes #22783 and #21865 The `python.configureTests` command was registered after multiple async operations during extension activation, creating a race condition where users could invoke the command before it was registered. ## Changes - **Extract command registration**: Created standalone `registerTestCommands()` function in `testing/main.ts` containing all test command handlers (`Tests_Configure`, `Tests_CopilotSetup`, `CopyTestId`) - **Register synchronously before first await**: Moved `unitTestsRegisterTypes()` and `registerTestCommands()` to `extension.ts` immediately after `initializeStandard()`, before `experimentService.activate()` - **Remove duplicate registrations**: Cleaned up original registration in `UnitTestManagementService.activate()` and `activateLegacy()` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent 832a9aa commit dc7f9bf

File tree

3 files changed

+93
-71
lines changed

3 files changed

+93
-71
lines changed

src/client/extension.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import { buildProposedApi } from './proposedApi';
4545
import { GLOBAL_PERSISTENT_KEYS } from './common/persistentState';
4646
import { registerTools } from './chat';
4747
import { IRecommendedEnvironmentService } from './interpreter/configuration/types';
48+
import { registerTypes as unitTestsRegisterTypes } from './testing/serviceRegistry';
49+
import { registerTestCommands } from './testing/main';
4850

4951
durations.codeLoadingTime = stopWatch.elapsedTime;
5052

@@ -121,6 +123,11 @@ async function activateUnsafe(
121123
// Note standard utils especially experiment and platform code are fundamental to the extension
122124
// and should be available before we activate anything else.Hence register them first.
123125
initializeStandard(ext);
126+
127+
// Register test services and commands early to prevent race conditions.
128+
unitTestsRegisterTypes(ext.legacyIOC.serviceManager);
129+
registerTestCommands(activatedServiceContainer);
130+
124131
// We need to activate experiments before initializing components as objects are created or not created based on experiments.
125132
const experimentService = activatedServiceContainer.get<IExperimentService>(IExperimentService);
126133
// This guarantees that all experiment information has loaded & all telemetry will contain experiment info.

src/client/extensionActivation.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import { setExtensionInstallTelemetryProperties } from './telemetry/extensionIns
3333
import { registerTypes as tensorBoardRegisterTypes } from './tensorBoard/serviceRegistry';
3434
import { registerTypes as commonRegisterTerminalTypes } from './terminals/serviceRegistry';
3535
import { ICodeExecutionHelper, ICodeExecutionManager, ITerminalAutoActivation } from './terminals/types';
36-
import { registerTypes as unitTestsRegisterTypes } from './testing/serviceRegistry';
3736

3837
// components
3938
import * as pythonEnvironments from './pythonEnvironments';
@@ -144,7 +143,6 @@ async function activateLegacy(ext: ExtensionState, startupStopWatch: StopWatch):
144143
const { enableProposedApi } = applicationEnv.packageJson;
145144
serviceManager.addSingletonInstance<boolean>(UseProposedApi, enableProposedApi);
146145
// Feature specific registrations.
147-
unitTestsRegisterTypes(serviceManager);
148146
installerRegisterTypes(serviceManager);
149147
commonRegisterTerminalTypes(serviceManager);
150148
debugConfigurationRegisterTypes(serviceManager);

src/client/testing/main.ts

Lines changed: 86 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { IDisposableRegistry, Product } from '../common/types';
1818
import { IInterpreterService } from '../interpreter/contracts';
1919
import { IServiceContainer } from '../ioc/types';
2020
import { EventName } from '../telemetry/constants';
21-
import { captureTelemetry, sendTelemetryEvent } from '../telemetry/index';
21+
import { sendTelemetryEvent } from '../telemetry/index';
2222
import { selectTestWorkspace } from './common/testUtils';
2323
import { TestSettingsPropertyNames } from './configuration/types';
2424
import { ITestConfigurationService, ITestsHelper } from './common/types';
@@ -42,6 +42,91 @@ export class TestingService implements ITestingService {
4242
}
4343
}
4444

45+
/**
46+
* Registers command handlers but defers service resolution until the commands are actually invoked,
47+
* allowing registration to happen before all services are fully initialized.
48+
*/
49+
export function registerTestCommands(serviceContainer: IServiceContainer): void {
50+
// Resolve only the essential services needed for command registration itself
51+
const disposableRegistry = serviceContainer.get<Disposable[]>(IDisposableRegistry);
52+
const commandManager = serviceContainer.get<ICommandManager>(ICommandManager);
53+
54+
// Helper function to configure tests - services are resolved when invoked, not at registration time
55+
const configureTestsHandler = async (resource?: Uri) => {
56+
sendTelemetryEvent(EventName.UNITTEST_CONFIGURE);
57+
58+
// Resolve services lazily when the command is invoked
59+
const workspaceService = serviceContainer.get<IWorkspaceService>(IWorkspaceService);
60+
61+
let wkspace: Uri | undefined;
62+
if (resource) {
63+
const wkspaceFolder = workspaceService.getWorkspaceFolder(resource);
64+
wkspace = wkspaceFolder ? wkspaceFolder.uri : undefined;
65+
} else {
66+
const appShell = serviceContainer.get<IApplicationShell>(IApplicationShell);
67+
wkspace = await selectTestWorkspace(appShell);
68+
}
69+
if (!wkspace) {
70+
return;
71+
}
72+
const interpreterService = serviceContainer.get<IInterpreterService>(IInterpreterService);
73+
const cmdManager = serviceContainer.get<ICommandManager>(ICommandManager);
74+
if (!(await interpreterService.getActiveInterpreter(wkspace))) {
75+
cmdManager.executeCommand(constants.Commands.TriggerEnvironmentSelection, wkspace);
76+
return;
77+
}
78+
const configurationService = serviceContainer.get<ITestConfigurationService>(ITestConfigurationService);
79+
await configurationService.promptToEnableAndConfigureTestFramework(wkspace);
80+
};
81+
82+
disposableRegistry.push(
83+
// Command: python.configureTests - prompts user to configure test framework
84+
commandManager.registerCommand(
85+
constants.Commands.Tests_Configure,
86+
(_, _cmdSource: constants.CommandSource = constants.CommandSource.commandPalette, resource?: Uri) => {
87+
// Invoke configuration handler (errors are ignored as this can be called from multiple places)
88+
configureTestsHandler(resource).ignoreErrors();
89+
traceVerbose('Testing: Trigger refresh after config change');
90+
// Refresh test data if test controller is available (resolved lazily)
91+
if (tests && !!tests.createTestController) {
92+
const testController = serviceContainer.get<ITestController>(ITestController);
93+
testController?.refreshTestData(resource, { forceRefresh: true });
94+
}
95+
},
96+
),
97+
// Command: python.tests.copilotSetup - Copilot integration for test setup
98+
commandManager.registerCommand(constants.Commands.Tests_CopilotSetup, (resource?: Uri):
99+
| { message: string; command: Command }
100+
| undefined => {
101+
// Resolve services lazily when the command is invoked
102+
const workspaceService = serviceContainer.get<IWorkspaceService>(IWorkspaceService);
103+
const wkspaceFolder =
104+
workspaceService.getWorkspaceFolder(resource) || workspaceService.workspaceFolders?.at(0);
105+
if (!wkspaceFolder) {
106+
return undefined;
107+
}
108+
109+
const configurationService = serviceContainer.get<ITestConfigurationService>(ITestConfigurationService);
110+
if (configurationService.hasConfiguredTests(wkspaceFolder.uri)) {
111+
return undefined;
112+
}
113+
114+
return {
115+
message: Testing.copilotSetupMessage,
116+
command: {
117+
title: Testing.configureTests,
118+
command: constants.Commands.Tests_Configure,
119+
arguments: [undefined, constants.CommandSource.ui, resource],
120+
},
121+
};
122+
}),
123+
// Command: python.copyTestId - copies test ID to clipboard
124+
commandManager.registerCommand(constants.Commands.CopyTestId, async (testItem: TestItem) => {
125+
writeTestIdToClipboard(testItem);
126+
}),
127+
);
128+
}
129+
45130
@injectable()
46131
export class UnitTestManagementService implements IExtensionActivationService {
47132
private activatedOnce: boolean = false;
@@ -80,7 +165,6 @@ export class UnitTestManagementService implements IExtensionActivationService {
80165
this.activatedOnce = true;
81166

82167
this.registerHandlers();
83-
this.registerCommands();
84168

85169
if (!!tests.testResults) {
86170
await this.updateTestUIButtons();
@@ -130,73 +214,6 @@ export class UnitTestManagementService implements IExtensionActivationService {
130214
await Promise.all(changedWorkspaces.map((u) => this.testController?.refreshTestData(u)));
131215
}
132216

133-
@captureTelemetry(EventName.UNITTEST_CONFIGURE, undefined, false)
134-
private async configureTests(resource?: Uri) {
135-
let wkspace: Uri | undefined;
136-
if (resource) {
137-
const wkspaceFolder = this.workspaceService.getWorkspaceFolder(resource);
138-
wkspace = wkspaceFolder ? wkspaceFolder.uri : undefined;
139-
} else {
140-
const appShell = this.serviceContainer.get<IApplicationShell>(IApplicationShell);
141-
wkspace = await selectTestWorkspace(appShell);
142-
}
143-
if (!wkspace) {
144-
return;
145-
}
146-
const interpreterService = this.serviceContainer.get<IInterpreterService>(IInterpreterService);
147-
const commandManager = this.serviceContainer.get<ICommandManager>(ICommandManager);
148-
if (!(await interpreterService.getActiveInterpreter(wkspace))) {
149-
commandManager.executeCommand(constants.Commands.TriggerEnvironmentSelection, wkspace);
150-
return;
151-
}
152-
const configurationService = this.serviceContainer.get<ITestConfigurationService>(ITestConfigurationService);
153-
await configurationService.promptToEnableAndConfigureTestFramework(wkspace!);
154-
}
155-
156-
private registerCommands(): void {
157-
const commandManager = this.serviceContainer.get<ICommandManager>(ICommandManager);
158-
this.disposableRegistry.push(
159-
commandManager.registerCommand(
160-
constants.Commands.Tests_Configure,
161-
(_, _cmdSource: constants.CommandSource = constants.CommandSource.commandPalette, resource?: Uri) => {
162-
// Ignore the exceptions returned.
163-
// This command will be invoked from other places of the extension.
164-
this.configureTests(resource).ignoreErrors();
165-
traceVerbose('Testing: Trigger refresh after config change');
166-
this.testController?.refreshTestData(resource, { forceRefresh: true });
167-
},
168-
),
169-
commandManager.registerCommand(constants.Commands.Tests_CopilotSetup, (resource?: Uri):
170-
| { message: string; command: Command }
171-
| undefined => {
172-
const wkspaceFolder =
173-
this.workspaceService.getWorkspaceFolder(resource) || this.workspaceService.workspaceFolders?.at(0);
174-
if (!wkspaceFolder) {
175-
return undefined;
176-
}
177-
178-
const configurationService = this.serviceContainer.get<ITestConfigurationService>(
179-
ITestConfigurationService,
180-
);
181-
if (configurationService.hasConfiguredTests(wkspaceFolder.uri)) {
182-
return undefined;
183-
}
184-
185-
return {
186-
message: Testing.copilotSetupMessage,
187-
command: {
188-
title: Testing.configureTests,
189-
command: constants.Commands.Tests_Configure,
190-
arguments: [undefined, constants.CommandSource.ui, resource],
191-
},
192-
};
193-
}),
194-
commandManager.registerCommand(constants.Commands.CopyTestId, async (testItem: TestItem) => {
195-
writeTestIdToClipboard(testItem);
196-
}),
197-
);
198-
}
199-
200217
private registerHandlers() {
201218
const interpreterService = this.serviceContainer.get<IInterpreterService>(IInterpreterService);
202219
this.disposableRegistry.push(

0 commit comments

Comments
 (0)