Skip to content

Commit 7866fe9

Browse files
authored
Manage potential conflicts with other running sessions (#696)
Start debug * Check in configuration provider if a new session could conflict with an already running one. For example if starting attach config for CPU already connected in a launch. User needs to confirm. * Let's exact matches pass and leaves them to existing VS Code handling. Stop debug sesion * Detect on terminate, disconnect requests if other related sessions are still running and in danger of becoming unstable. For example if a stopped launch connection closes the server for a still running attach connection. * Only informs the user, no way to stop VS Code from ending it (worst case by terminating process).
1 parent e71bf2c commit 7866fe9

13 files changed

Lines changed: 696 additions & 127 deletions

__mocks__/vscode.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ module.exports = {
7272
error: jest.fn(),
7373
})),
7474
registerTreeDataProvider: jest.fn(() => ({ dispose: jest.fn() })),
75-
showWarningMessage: jest.fn(),
7675
showErrorMessage: jest.fn(),
76+
showInformationMessage: jest.fn(),
77+
showWarningMessage: jest.fn(),
7778
createStatusBarItem: jest.fn(),
7879
showQuickPick: jest.fn(),
7980
},

src/__test__/vscode.factory.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,11 @@ export function extensionContextFactory(): jest.Mocked<vscode.ExtensionContext>
7070
};
7171

7272
export function debugSessionFactory(
73-
configuration: vscode.DebugConfiguration
73+
configuration: vscode.DebugConfiguration,
74+
id: string = '{session-id}'
7475
): jest.Mocked<vscode.DebugSession> {
7576
return {
76-
id: '{session-id}',
77+
id,
7778
name: configuration.name,
7879
type: configuration.type,
7980
workspaceFolder: undefined,

src/debug-configuration/gdbtarget-configuration-provider.test.ts

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,21 @@
1515
*/
1616

1717
import { GDBTargetConfiguration, GDBTargetConfigurationProvider } from '.'; // use index.ts to cover it in one test
18-
import { extensionContextFactory } from '../__test__/vscode.factory';
18+
import { debugSessionFactory, extensionContextFactory } from '../__test__/vscode.factory';
1919

2020
import * as vscode from 'vscode';
2121
import { URI } from 'vscode-uri';
22-
import { debugConfigurationFactory } from './debug-configuration.factory';
22+
import { debugConfigurationFactory, gdbTargetConfiguration } from './debug-configuration.factory';
2323
import { BuiltinToolPath } from '../desktop/builtin-tool-path';
24-
import { isWindows } from '../utils';
24+
import { isWindows, waitForMs } from '../utils';
25+
import { GDBTargetDebugSession, GDBTargetDebugTracker } from '../debug-session';
2526

2627
jest.mock('../desktop/builtin-tool-path');
2728
const BuiltinToolPathMock = BuiltinToolPath as jest.MockedClass<typeof BuiltinToolPath>;
2829

2930
describe('GDBTargetConfigurationProvider', () => {
3031

31-
it('should activate', async () => {
32+
it('should activate', () => {
3233
const configProvider = new GDBTargetConfigurationProvider([]);
3334
const contextMock = extensionContextFactory();
3435

@@ -38,6 +39,18 @@ describe('GDBTargetConfigurationProvider', () => {
3839
expect(vscode.debug.registerDebugConfigurationProvider as jest.Mock).toHaveBeenCalledWith('gdbtarget', configProvider);
3940
});
4041

42+
it('should activate with debug tracker', () => {
43+
const debugTracker = new GDBTargetDebugTracker();
44+
const configProvider = new GDBTargetConfigurationProvider([]);
45+
const contextMock = extensionContextFactory();
46+
47+
configProvider.activate(contextMock, debugTracker);
48+
49+
// 1 for the config provider, 2 for the debug tracker
50+
expect(contextMock.subscriptions).toHaveLength(1+2);
51+
expect(vscode.debug.registerDebugConfigurationProvider as jest.Mock).toHaveBeenCalledWith('gdbtarget', configProvider);
52+
});
53+
4154
it('resolveDebugConfiguration', async () => {
4255
const configProvider = new GDBTargetConfigurationProvider([]);
4356
const debugConfig = debugConfigurationFactory();
@@ -97,4 +110,112 @@ describe('GDBTargetConfigurationProvider', () => {
97110
expect(resolvedDebugConfig).toBeDefined();
98111
expect(resolvedDebugConfig.gdb).toEqual(expectedGdbPath);
99112
});
113+
114+
describe('tests with sessions', () => {
115+
let debugTracker: GDBTargetDebugTracker;
116+
let configProvider: GDBTargetConfigurationProvider;
117+
let contextMock: vscode.ExtensionContext;
118+
119+
const createLaunchConfig = (name: string) => gdbTargetConfiguration({
120+
name,
121+
request: 'launch'
122+
});
123+
124+
const createAttachConfig = (name: string) => gdbTargetConfiguration({
125+
name,
126+
request: 'attach'
127+
});
128+
129+
const createLaunchSession = (name: string) => new GDBTargetDebugSession(
130+
debugSessionFactory(createLaunchConfig(name))
131+
);
132+
133+
const createAttachSession = (name: string) => new GDBTargetDebugSession(
134+
debugSessionFactory(createAttachConfig(name))
135+
);
136+
137+
beforeEach(() => {
138+
debugTracker = new GDBTargetDebugTracker();
139+
configProvider = new GDBTargetConfigurationProvider([]);
140+
contextMock = extensionContextFactory();
141+
142+
configProvider.activate(contextMock, debugTracker);
143+
144+
(vscode.window.showInformationMessage as jest.Mock).mockClear();
145+
});
146+
147+
it('can add and remove sessions', async () => {
148+
const debugSession = createLaunchSession('testSession');
149+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
150+
(debugTracker as any)._onWillStartSession.fire(debugSession);
151+
await waitForMs(0);
152+
expect(configProvider['activeSessions'].size).toBe(1);
153+
154+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
155+
(debugTracker as any)._onWillStopSession.fire(debugSession);
156+
await waitForMs(0);
157+
expect(configProvider['activeSessions'].size).toBe(0);
158+
});
159+
160+
it('starts independent sessions', async () => {
161+
const launchSession = createLaunchSession('pname session@1 (launch)');
162+
const attachSession = createAttachSession('pname session@2 (attach)');
163+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
164+
(debugTracker as any)._onWillStartSession.fire(launchSession);
165+
await waitForMs(0);
166+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
167+
(debugTracker as any)._onWillStartSession.fire(attachSession);
168+
await waitForMs(0);
169+
});
170+
171+
it.each([
172+
{ dialogResponse: undefined, undefinedResult: true },
173+
{ dialogResponse: 'Yes', undefinedResult: false },
174+
])('asks for confirmation if starting sessions with same config base name (dialog response $dialogResponse)', async ({ dialogResponse, undefinedResult }) => {
175+
(vscode.window.showInformationMessage as jest.Mock).mockResolvedValueOnce(dialogResponse);
176+
const launchSession = createLaunchSession('pname session@1 (launch)');
177+
const attachConfig = createAttachConfig('pname session@1 (attach)');
178+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
179+
(debugTracker as any)._onWillStartSession.fire(launchSession);
180+
await waitForMs(0);
181+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
182+
const result = await configProvider.resolveDebugConfigurationWithSubstitutedVariables(
183+
undefined,
184+
attachConfig
185+
);
186+
expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1);
187+
if (undefinedResult) {
188+
expect(result).toBeUndefined();
189+
} else {
190+
expect(result).toBeDefined();
191+
}
192+
});
193+
194+
it('does not ask for confirmation if same config starts again (VS Code will do later)', async () => {
195+
const launchSession = createLaunchSession('pname session@1 (launch)');
196+
const otherLaunchConfig = createLaunchConfig('pname session@1 (launch)');
197+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
198+
(debugTracker as any)._onWillStartSession.fire(launchSession);
199+
await waitForMs(0);
200+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
201+
const result = await configProvider.resolveDebugConfigurationWithSubstitutedVariables(
202+
undefined,
203+
otherLaunchConfig
204+
);
205+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
206+
expect(result).toBeDefined();
207+
});
208+
209+
it('does not ask for confirmation if no other session running', async () => {
210+
const launchConfig = createLaunchConfig('pname session@1 (launch)');
211+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
212+
const result = await configProvider.resolveDebugConfigurationWithSubstitutedVariables(
213+
undefined,
214+
launchConfig
215+
);
216+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
217+
expect(result).toBeDefined();
218+
});
219+
});
220+
100221
});

src/debug-configuration/gdbtarget-configuration-provider.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
} from './subproviders';
2727
import { BuiltinToolPath } from '../desktop/builtin-tool-path';
2828
import { resolveToolPath } from '../desktop/tool-path-utils';
29+
import { GDBTargetDebugSession, GDBTargetDebugTracker } from '../debug-session';
30+
import { getManagedConfigBaseName, hasManagedConfigEnding } from './managed-config-utils';
2931

3032
const GDB_TARGET_DEBUGGER_TYPE = 'gdbtarget';
3133
const ARM_NONE_EABI_GDB_NAME = 'arm-none-eabi-gdb';
@@ -49,15 +51,22 @@ const GENERIC_SUBPROVIDER: GDBTargetConfigurationSubProvider = { serverRegExp: /
4951

5052
export class GDBTargetConfigurationProvider implements vscode.DebugConfigurationProvider {
5153
protected builtinArmNoneEabiGdb = new BuiltinToolPath(ARM_NONE_EABI_GDB_BUILTIN_PATH);
54+
protected activeSessions = new Set<GDBTargetDebugSession>();
5255

5356
public constructor(
5457
protected subProviders: GDBTargetConfigurationSubProvider[] = SUPPORTED_SUBPROVIDERS
5558
) {}
5659

57-
public activate(context: vscode.ExtensionContext) {
60+
public activate(context: vscode.ExtensionContext, debugTracker?: GDBTargetDebugTracker) {
5861
context.subscriptions.push(
5962
vscode.debug.registerDebugConfigurationProvider(GDB_TARGET_DEBUGGER_TYPE, this)
6063
);
64+
if (debugTracker) {
65+
context.subscriptions.push(
66+
debugTracker.onWillStartSession(session => this.activeSessions.add(session)),
67+
debugTracker.onWillStopSession(session => this.activeSessions.delete(session))
68+
);
69+
}
6170
}
6271

6372
private logDebugConfiguration(resolverType: ResolverType, config: vscode.DebugConfiguration, message = '') {
@@ -111,6 +120,33 @@ export class GDBTargetConfigurationProvider implements vscode.DebugConfiguration
111120
return relevantProvider;
112121
}
113122

123+
private async shouldCancel(debugConfiguration: vscode.DebugConfiguration): Promise<boolean> {
124+
if (!hasManagedConfigEnding(debugConfiguration.name)) {
125+
// Not a managed config
126+
return false;
127+
}
128+
const managedSessions = Array.from(this.activeSessions).filter(session => hasManagedConfigEnding(session.session.name));
129+
if (!managedSessions.length) {
130+
// No other running managed sessions
131+
return false;
132+
}
133+
const configNameBase = getManagedConfigBaseName(debugConfiguration.name);
134+
const alreadyRunning = managedSessions.find(session => {
135+
return getManagedConfigBaseName(session.session.name) === configNameBase;
136+
})?.session.name;
137+
if (!alreadyRunning || alreadyRunning === debugConfiguration.name) {
138+
// Nothing suitable running, or exact match which should be handled by VS Code built-in mechanism
139+
return false;
140+
}
141+
const continueOption = 'Yes';
142+
const result = await vscode.window.showInformationMessage(
143+
`'${alreadyRunning}' is already running and may conflict with new session. Do you want to start '${debugConfiguration.name}' anyway?`,
144+
{ modal: true },
145+
continueOption
146+
);
147+
return result !== continueOption;
148+
}
149+
114150
private async resolveDebugConfigurationByResolverType(
115151
resolverType: ResolverType,
116152
folder: vscode.WorkspaceFolder | undefined,
@@ -146,11 +182,15 @@ export class GDBTargetConfigurationProvider implements vscode.DebugConfiguration
146182
return this.resolveDebugConfigurationByResolverType('resolveDebugConfiguration', folder, debugConfiguration, token);
147183
}
148184

149-
public resolveDebugConfigurationWithSubstitutedVariables(
185+
public async resolveDebugConfigurationWithSubstitutedVariables(
150186
folder: vscode.WorkspaceFolder | undefined,
151187
debugConfiguration: vscode.DebugConfiguration,
152188
token?: vscode.CancellationToken
153189
): Promise<vscode.DebugConfiguration | null | undefined> {
190+
// Check only with substituted variables in case name contains one
191+
if (await this.shouldCancel(debugConfiguration)) {
192+
return undefined;
193+
}
154194
// Only resolve GDB path once, otherwise regexp check will fail
155195
logger.debug('resolveDebugConfigurationWithSubstitutedVariables: Resolve GDB path');
156196
debugConfiguration.gdb = resolveToolPath(debugConfiguration.gdb, ARM_NONE_EABI_GDB_NAME, ARM_NONE_EABI_GDB_EXECUTABLE_ONLY_REGEXP, this.builtinArmNoneEabiGdb);

src/debug-configuration/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@
1616

1717
export * from './gdbtarget-configuration';
1818
export * from './gdbtarget-configuration-provider';
19+
export * from './managed-config-utils';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Copyright 2025 Arm Limited
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { getManagedConfigBaseName, hasManagedConfigEnding } from './managed-config-utils';
18+
19+
describe('Managed Config Utils', () => {
20+
21+
describe('hasManagedConfigEnding', () => {
22+
23+
it('returns true for \'(launch)\' and \'(attach)\' ending', () => {
24+
expect(hasManagedConfigEnding('My Config (launch)')).toBe(true);
25+
expect(hasManagedConfigEnding('My Config (attach)')).toBe(true);
26+
});
27+
28+
it('returns false without \'(launch)\' or \'(attach)\' ending', () => {
29+
expect(hasManagedConfigEnding('My Config')).toBe(false);
30+
});
31+
32+
it('returns false with \'(launch)\' or \'(attach)\' but not at the end', () => {
33+
expect(hasManagedConfigEnding('My Config (launch something)')).toBe(false);
34+
expect(hasManagedConfigEnding('My Config (attach) xyz')).toBe(false);
35+
});
36+
37+
});
38+
39+
describe('getManagedConfigBaseName', () => {
40+
41+
it('returns base name without \'(launch)\' or \'(attach)\' ending', () => {
42+
expect(getManagedConfigBaseName('My Launch Config (launch)')).toBe('My Launch Config');
43+
expect(getManagedConfigBaseName('My Attach Config (attach)')).toBe('My Attach Config');
44+
});
45+
46+
it('returns full name if no \'(launch)\' or \'(attach)\' ending', () => {
47+
expect(getManagedConfigBaseName('My (launch) Config')).toBe('My (launch) Config');
48+
expect(getManagedConfigBaseName('My Config (attach')).toBe('My Config (attach');
49+
expect(getManagedConfigBaseName('My Config')).toBe('My Config');
50+
});
51+
52+
});
53+
54+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Copyright 2025 Arm Limited
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
const DEBUG_CONFIG_REQTYPES = [ '(launch)', '(attach)' ];
18+
19+
/**
20+
* Check if configuration name ends with typical ending for CMSIS managed debug configs.
21+
*
22+
* @param configName debug configuration name
23+
* @returns true if configuration name ends with managed config type endings
24+
*/
25+
export const hasManagedConfigEnding = (configName: string): boolean => DEBUG_CONFIG_REQTYPES.some(req => configName.endsWith(req));
26+
27+
/**
28+
* Get base name of managed configuration by removing the request type ending.
29+
*
30+
* @param configName debug configuration name
31+
* @returns base name of managed configuration
32+
*/
33+
export const getManagedConfigBaseName = (configName: string): string => {
34+
if (!hasManagedConfigEnding(configName)) {
35+
return configName;
36+
}
37+
return configName.split(' ').slice(0, -1).join(' ');
38+
};

src/debug-session/__snapshots__/gdbtarget-debug-session.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
22

3-
exports[`GDBTargetDebugSession returns a cbuild object after parsing one 1`] = `
3+
exports[`GDBTargetDebugSession with launch configuration returns a cbuild object and cbuild run path after parsing one 1`] = `
44
CbuildRunReader {
55
"cbuildRun": {
66
"compiler": "AC6",

0 commit comments

Comments
 (0)