Skip to content

Commit 8d22efb

Browse files
authored
Fixing race condition on breakpoints (#536)
1 parent 6d4cc10 commit 8d22efb

4 files changed

Lines changed: 178 additions & 12 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"dependencies": {
6464
"@vscode/debugadapter": "^1.68.0",
6565
"@vscode/debugprotocol": "^1.68.0",
66+
"async-mutex": "^0.5.0",
6667
"node-addon-api": "^8.4.0",
6768
"serialport": "^13.0.0"
6869
},

src/gdb/GDBDebugSessionBase.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
Event,
2828
} from '@vscode/debugadapter';
2929
import { DebugProtocol } from '@vscode/debugprotocol';
30+
import { Mutex } from 'async-mutex';
3031
import { ContinuedEvent } from '../events/continuedEvent';
3132
import { StoppedEvent } from '../events/stoppedEvent';
3233
import { VarObjType } from '../varManager';
@@ -191,6 +192,12 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession {
191192
// first of them pauses, and the last to complete resumes
192193
protected pauseCount = 0;
193194

195+
// Serializes the bodies of the breakpoint request handlers so their
196+
// read-modify-write of GDB's global breakpoint list cannot interleave.
197+
// pauseIfNeeded/continueIfNeeded only coalesces the target pause, it does
198+
// not prevent concurrent handlers from acting on stale snapshots.
199+
protected breakpointMutex = new Mutex();
200+
194201
// Pending requests to pause a thread silently (without sending stopped event).
195202
// At the moment only used with pauseIfRunning().
196203
protected silentPauseRequests: PendingPauseRequest[] = [];
@@ -917,6 +924,15 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession {
917924
protected async setDataBreakpointsRequest(
918925
response: DebugProtocol.SetDataBreakpointsResponse,
919926
args: DebugProtocol.SetDataBreakpointsArguments
927+
): Promise<void> {
928+
await this.breakpointMutex.runExclusive(() =>
929+
this.doSetDataBreakpointsRequest(response, args)
930+
);
931+
}
932+
933+
protected async doSetDataBreakpointsRequest(
934+
response: DebugProtocol.SetDataBreakpointsResponse,
935+
args: DebugProtocol.SetDataBreakpointsArguments
920936
): Promise<void> {
921937
// If this is the first time sending this breakpoint and there are no breakpoints to set,
922938
// do not change state of the target by avoiding an unnecessary pause
@@ -1027,18 +1043,17 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession {
10271043
response: DebugProtocol.SetInstructionBreakpointsResponse,
10281044
args: DebugProtocol.SetInstructionBreakpointsArguments
10291045
): Promise<void> {
1030-
try {
1031-
return await this.doSetInstructionBreakpointsRequest(
1032-
response,
1033-
args
1034-
);
1035-
} catch (err) {
1036-
this.sendErrorResponse(
1037-
response,
1038-
1,
1039-
err instanceof Error ? err.message : String(err)
1040-
);
1041-
}
1046+
await this.breakpointMutex.runExclusive(async () => {
1047+
try {
1048+
await this.doSetInstructionBreakpointsRequest(response, args);
1049+
} catch (err) {
1050+
this.sendErrorResponse(
1051+
response,
1052+
1,
1053+
err instanceof Error ? err.message : String(err)
1054+
);
1055+
}
1056+
});
10421057
}
10431058

10441059
protected async doSetInstructionBreakpointsRequest(
@@ -1166,6 +1181,15 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession {
11661181
protected async setBreakPointsRequest(
11671182
response: DebugProtocol.SetBreakpointsResponse,
11681183
args: DebugProtocol.SetBreakpointsArguments
1184+
): Promise<void> {
1185+
await this.breakpointMutex.runExclusive(() =>
1186+
this.doSetBreakPointsRequest(response, args)
1187+
);
1188+
}
1189+
1190+
protected async doSetBreakPointsRequest(
1191+
response: DebugProtocol.SetBreakpointsResponse,
1192+
args: DebugProtocol.SetBreakpointsArguments
11691193
): Promise<void> {
11701194
// If this is the first time sending this breakpoint and there are no breakpoints to set,
11711195
// do not change state of the target by avoiding an unnecessary pause
@@ -1417,6 +1441,15 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession {
14171441
protected async setFunctionBreakPointsRequest(
14181442
response: DebugProtocol.SetFunctionBreakpointsResponse,
14191443
args: DebugProtocol.SetFunctionBreakpointsArguments
1444+
) {
1445+
await this.breakpointMutex.runExclusive(() =>
1446+
this.doSetFunctionBreakPointsRequest(response, args)
1447+
);
1448+
}
1449+
1450+
protected async doSetFunctionBreakPointsRequest(
1451+
response: DebugProtocol.SetFunctionBreakpointsResponse,
1452+
args: DebugProtocol.SetFunctionBreakpointsArguments
14201453
) {
14211454
// If this is the first time sending this breakpoint and there are no breakpoints to set,
14221455
// do not change state of the target by avoiding an unnecessary pause
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*********************************************************************
2+
* Copyright (c) 2026 Renesas Electronics Corporation and others
3+
*
4+
* This program and the accompanying materials are made
5+
* available under the terms of the Eclipse Public License 2.0
6+
* which is available at https://www.eclipse.org/legal/epl-2.0/
7+
*
8+
* SPDX-License-Identifier: EPL-2.0
9+
*********************************************************************/
10+
11+
import { expect } from 'chai';
12+
import * as sinon from 'sinon';
13+
import { DebugProtocol } from '@vscode/debugprotocol';
14+
import { GDBDebugSessionBase } from '../gdb/GDBDebugSessionBase';
15+
import { IGDBBackendFactory } from '../types/gdb';
16+
17+
/**
18+
* Minimal subclass that only exists to make the protected breakpoint wrapper
19+
* handlers callable from test code. The `do*` implementations are replaced
20+
* per-test via sinon stubs so we can observe how the wrapper-level mutex
21+
* serializes them without coupling the test to a fixed class hierarchy.
22+
*/
23+
class TestSession extends GDBDebugSessionBase {
24+
constructor() {
25+
super({} as IGDBBackendFactory);
26+
}
27+
28+
public callSetBreakPointsRequest(
29+
response: DebugProtocol.SetBreakpointsResponse,
30+
args: DebugProtocol.SetBreakpointsArguments
31+
) {
32+
return this.setBreakPointsRequest(response, args);
33+
}
34+
35+
public callSetInstructionBreakpointsRequest(
36+
response: DebugProtocol.SetInstructionBreakpointsResponse,
37+
args: DebugProtocol.SetInstructionBreakpointsArguments
38+
) {
39+
return this.setInstructionBreakpointsRequest(response, args);
40+
}
41+
}
42+
43+
const emptyResponse = <T>() => ({ body: {} as never }) as T;
44+
45+
describe('breakpoint handler serialization', function () {
46+
let sandbox: sinon.SinonSandbox;
47+
let session: TestSession;
48+
let inFlight: number;
49+
let maxConcurrent: number;
50+
51+
beforeEach(function () {
52+
sandbox = sinon.createSandbox();
53+
session = new TestSession();
54+
inFlight = 0;
55+
maxConcurrent = 0;
56+
57+
const simulateWork = async () => {
58+
inFlight++;
59+
maxConcurrent = Math.max(maxConcurrent, inFlight);
60+
await new Promise((resolve) => setTimeout(resolve, 20));
61+
inFlight--;
62+
};
63+
64+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65+
const s = session as any;
66+
sandbox.stub(s, 'doSetBreakPointsRequest').callsFake(simulateWork);
67+
sandbox
68+
.stub(s, 'doSetFunctionBreakPointsRequest')
69+
.callsFake(simulateWork);
70+
sandbox.stub(s, 'doSetDataBreakpointsRequest').callsFake(simulateWork);
71+
sandbox
72+
.stub(s, 'doSetInstructionBreakpointsRequest')
73+
.callsFake(simulateWork);
74+
});
75+
76+
afterEach(function () {
77+
sandbox.restore();
78+
});
79+
80+
it('serializes two concurrent setBreakPointsRequest calls', async function () {
81+
await Promise.all([
82+
session.callSetBreakPointsRequest(
83+
emptyResponse<DebugProtocol.SetBreakpointsResponse>(),
84+
{
85+
source: { path: 'a.c' },
86+
breakpoints: [{ line: 1 }],
87+
}
88+
),
89+
session.callSetBreakPointsRequest(
90+
emptyResponse<DebugProtocol.SetBreakpointsResponse>(),
91+
{
92+
source: { path: 'a.c' },
93+
breakpoints: [{ line: 2 }],
94+
}
95+
),
96+
]);
97+
98+
expect(maxConcurrent).to.equal(1);
99+
});
100+
101+
it('serializes setBreakPointsRequest with setInstructionBreakpointsRequest', async function () {
102+
await Promise.all([
103+
session.callSetBreakPointsRequest(
104+
emptyResponse<DebugProtocol.SetBreakpointsResponse>(),
105+
{
106+
source: { path: 'a.c' },
107+
breakpoints: [{ line: 1 }],
108+
}
109+
),
110+
session.callSetInstructionBreakpointsRequest(
111+
emptyResponse<DebugProtocol.SetInstructionBreakpointsResponse>(),
112+
{
113+
breakpoints: [{ instructionReference: '0x1000' }],
114+
}
115+
),
116+
]);
117+
118+
expect(maxConcurrent).to.equal(1);
119+
});
120+
});

yarn.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,13 @@ assertion-error@^2.0.1:
10131013
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7"
10141014
integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==
10151015

1016+
async-mutex@^0.5.0:
1017+
version "0.5.0"
1018+
resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.5.0.tgz#353c69a0b9e75250971a64ac203b0ebfddd75482"
1019+
integrity sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==
1020+
dependencies:
1021+
tslib "^2.4.0"
1022+
10161023
available-typed-arrays@^1.0.7:
10171024
version "1.0.7"
10181025
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846"
@@ -4541,6 +4548,11 @@ ts-node@^10.9.2:
45414548
v8-compile-cache-lib "^3.0.1"
45424549
yn "3.1.1"
45434550

4551+
tslib@^2.4.0:
4552+
version "2.8.1"
4553+
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
4554+
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
4555+
45444556
tty-browserify@0.0.1:
45454557
version "0.0.1"
45464558
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811"

0 commit comments

Comments
 (0)