Skip to content

Commit ad4b3c0

Browse files
watsonrochdev
authored andcommitted
[DI] Refactor mutex code guarding race condition (#5728)
When adding, removing or modifying a breakpoint there can be a race condtion if two Remove Config configs are received in quick succession. We already have a mutex to guard against this, but the code was in need of a refactor because: 1. We had duplicate mutex code in two different files, where only one was needed 2. We couldn't easily test the main mutex code in a unit test as it was added in the calling file and therefore not isolated to the function containing the race condition.
1 parent 216e1ef commit ad4b3c0

File tree

3 files changed

+226
-238
lines changed

3 files changed

+226
-238
lines changed

packages/dd-trace/src/debugger/devtools_client/breakpoints.js

Lines changed: 69 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const lock = require('mutexify/promise')()
3+
const mutex = require('mutexify/promise')()
44
const { getGeneratedPosition } = require('./source-maps')
55
const session = require('./session')
66
const { compile: compileCondition, compileSegments, templateRequiresEvaluation } = require('./condition')
@@ -36,8 +36,9 @@ session.on('scriptLoadingStabilized', () => {
3636
})
3737

3838
module.exports = {
39-
addBreakpoint,
40-
removeBreakpoint
39+
addBreakpoint: lock(addBreakpoint),
40+
removeBreakpoint: lock(removeBreakpoint),
41+
modifyBreakpoint: lock(modifyBreakpoint)
4142
}
4243

4344
async function addBreakpoint (probe) {
@@ -89,42 +90,36 @@ async function addBreakpoint (probe) {
8990
throw new Error(`Cannot compile expression: ${probe.when.dsl}`, { cause: err })
9091
}
9192

92-
const release = await lock()
93-
94-
try {
95-
const locationKey = generateLocationKey(scriptId, lineNumber, columnNumber)
96-
const breakpoint = locationToBreakpoint.get(locationKey)
97-
98-
log.debug(
99-
'[debugger:devtools_client] %s breakpoint at %s:%d:%d (probe: %s, version: %d)',
100-
breakpoint ? 'Updating' : 'Adding', url, lineNumber, columnNumber, probe.id, probe.version
101-
)
102-
103-
if (breakpoint) {
104-
// A breakpoint already exists at this location, so we need to add the probe to the existing breakpoint
105-
await updateBreakpoint(breakpoint, probe)
106-
} else {
107-
// No breakpoint exists at this location, so we need to create a new one
108-
const location = {
109-
scriptId,
110-
lineNumber: lineNumber - 1, // Beware! lineNumber is zero-indexed
111-
columnNumber
112-
}
113-
let result
114-
try {
115-
result = await session.post('Debugger.setBreakpoint', {
116-
location,
117-
condition: probe.condition
118-
})
119-
} catch (err) {
120-
throw new Error(`Error setting breakpoint for probe ${probe.id}`, { cause: err })
121-
}
122-
probeToLocation.set(probe.id, locationKey)
123-
locationToBreakpoint.set(locationKey, { id: result.breakpointId, location, locationKey })
124-
breakpointToProbes.set(result.breakpointId, new Map([[probe.id, probe]]))
93+
const locationKey = generateLocationKey(scriptId, lineNumber, columnNumber)
94+
const breakpoint = locationToBreakpoint.get(locationKey)
95+
96+
log.debug(
97+
'[debugger:devtools_client] %s breakpoint at %s:%d:%d (probe: %s, version: %d)',
98+
breakpoint ? 'Updating' : 'Adding', url, lineNumber, columnNumber, probe.id, probe.version
99+
)
100+
101+
if (breakpoint) {
102+
// A breakpoint already exists at this location, so we need to add the probe to the existing breakpoint
103+
await updateBreakpointInternal(breakpoint, probe)
104+
} else {
105+
// No breakpoint exists at this location, so we need to create a new one
106+
const location = {
107+
scriptId,
108+
lineNumber: lineNumber - 1, // Beware! lineNumber is zero-indexed
109+
columnNumber
110+
}
111+
let result
112+
try {
113+
result = await session.post('Debugger.setBreakpoint', {
114+
location,
115+
condition: probe.condition
116+
})
117+
} catch (err) {
118+
throw new Error(`Error setting breakpoint for probe ${probe.id}`, { cause: err })
125119
}
126-
} finally {
127-
release()
120+
probeToLocation.set(probe.id, locationKey)
121+
locationToBreakpoint.set(locationKey, { id: result.breakpointId, location, locationKey })
122+
breakpointToProbes.set(result.breakpointId, new Map([[probe.id, probe]]))
128123
}
129124
}
130125

@@ -139,37 +134,37 @@ async function removeBreakpoint ({ id }) {
139134

140135
probes.delete(id)
141136

142-
const release = await lock()
137+
const locationKey = probeToLocation.get(id)
138+
const breakpoint = locationToBreakpoint.get(locationKey)
139+
const probesAtLocation = breakpointToProbes.get(breakpoint.id)
143140

144-
try {
145-
const locationKey = probeToLocation.get(id)
146-
const breakpoint = locationToBreakpoint.get(locationKey)
147-
const probesAtLocation = breakpointToProbes.get(breakpoint.id)
148-
149-
probesAtLocation.delete(id)
150-
probeToLocation.delete(id)
151-
152-
if (probesAtLocation.size === 0) {
153-
locationToBreakpoint.delete(locationKey)
154-
breakpointToProbes.delete(breakpoint.id)
155-
if (breakpointToProbes.size === 0) {
156-
await stop() // TODO: Will this actually delete the breakpoint?
157-
} else {
158-
try {
159-
await session.post('Debugger.removeBreakpoint', { breakpointId: breakpoint.id })
160-
} catch (err) {
161-
throw new Error(`Error removing breakpoint for probe ${id}`, { cause: err })
162-
}
163-
}
141+
probesAtLocation.delete(id)
142+
probeToLocation.delete(id)
143+
144+
if (probesAtLocation.size === 0) {
145+
locationToBreakpoint.delete(locationKey)
146+
breakpointToProbes.delete(breakpoint.id)
147+
if (breakpointToProbes.size === 0) {
148+
await stop() // This will also remove the breakpoint
164149
} else {
165-
await updateBreakpoint(breakpoint)
150+
try {
151+
await session.post('Debugger.removeBreakpoint', { breakpointId: breakpoint.id })
152+
} catch (err) {
153+
throw new Error(`Error removing breakpoint for probe ${id}`, { cause: err })
154+
}
166155
}
167-
} finally {
168-
release()
156+
} else {
157+
await updateBreakpointInternal(breakpoint)
169158
}
170159
}
171160

172-
async function updateBreakpoint (breakpoint, probe) {
161+
// TODO: Modify existing probe instead of removing it (DEBUG-2817)
162+
async function modifyBreakpoint (probe) {
163+
await removeBreakpoint(probe)
164+
await addBreakpoint(probe)
165+
}
166+
167+
async function updateBreakpointInternal (breakpoint, probe) {
173168
const probesAtLocation = breakpointToProbes.get(breakpoint.id)
174169
const conditionBeforeNewProbe = compileCompoundCondition(Array.from(probesAtLocation.values()))
175170

@@ -235,6 +230,17 @@ function stop () {
235230
return session.post('Debugger.disable')
236231
}
237232

233+
function lock (fn) {
234+
return async function (...args) {
235+
const release = await mutex()
236+
try {
237+
return await fn(...args)
238+
} finally {
239+
release()
240+
}
241+
}
242+
}
243+
238244
// Only if all probes have a condition can we use a compound condition.
239245
// Otherwise, we need to evaluate each probe individually once the breakpoint is hit.
240246
function compileCompoundCondition (probes) {

packages/dd-trace/src/debugger/devtools_client/remote_config.js

Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
'use strict'
22

33
const { workerData: { rcPort } } = require('node:worker_threads')
4-
const lock = require('mutexify/promise')()
5-
const { addBreakpoint, removeBreakpoint } = require('./breakpoints')
4+
const { addBreakpoint, removeBreakpoint, modifyBreakpoint } = require('./breakpoints')
65
const { ackReceived, ackInstalled, ackError } = require('./status')
76
const log = require('../../log')
87

@@ -62,40 +61,21 @@ async function processMsg (action, probe) {
6261
)
6362
}
6463

65-
// This lock is to ensure that we don't get the following race condition:
66-
//
67-
// When a breakpoint is being removed and there are no other breakpoints, we disable the debugger by calling
68-
// `Debugger.disable` to free resources. However, if a new breakpoint is being added around the same time, we might
69-
// have a race condition where the new breakpoint thinks that the debugger is already enabled because the removal of
70-
// the other breakpoint hasn't had a chance to call `Debugger.disable` yet. Then once the code that's adding the new
71-
// breakpoints tries to call `Debugger.setBreakpoint` it fails because in the meantime `Debugger.disable` was called.
72-
//
73-
// If the code is ever refactored to not tear down the debugger if there's no active breakpoints, we can safely remove
74-
// this lock.
75-
const release = await lock()
76-
77-
try {
78-
switch (action) {
79-
case 'unapply':
80-
await removeBreakpoint(probe)
81-
break
82-
case 'apply':
83-
await addBreakpoint(probe)
84-
ackInstalled(probe)
85-
break
86-
case 'modify':
87-
// TODO: Modify existing probe instead of removing it (DEBUG-2817)
88-
await removeBreakpoint(probe)
89-
await addBreakpoint(probe)
90-
ackInstalled(probe) // TODO: Should we also send ackInstalled when modifying a probe?
91-
break
92-
default:
93-
throw new Error(
94-
// eslint-disable-next-line @stylistic/js/max-len
95-
`Cannot process probe ${probe.id} (version: ${probe.version}) - unknown remote configuration action: ${action}`
96-
)
97-
}
98-
} finally {
99-
release()
64+
switch (action) {
65+
case 'unapply':
66+
await removeBreakpoint(probe)
67+
break
68+
case 'apply':
69+
await addBreakpoint(probe)
70+
ackInstalled(probe)
71+
break
72+
case 'modify':
73+
await modifyBreakpoint(probe)
74+
ackInstalled(probe)
75+
break
76+
default:
77+
throw new Error(
78+
`Cannot process probe ${probe.id} (version: ${probe.version}) - unknown remote configuration action: ${action}`
79+
)
10080
}
10181
}

0 commit comments

Comments
 (0)