Skip to content

Commit e4b4716

Browse files
committed
[DI] Fix race condition when applying probe at app boot
A race condition exists where the tracer receives a probe via RC, before Node.js has had a chance to load all the JS files from disk. If this race condition is triggered, it results in the tracer either not being able to find any script to attach the probe to, or, if the probe filename is a bit generic, it finds an incorrect match. Therefore, once new scripts has been loaded, all probes are re-evaluated. If the matched `scriptId` has changed, we simply remove the old probe (if it was added to the wrong script) and apply it again. This is only really an issue if Node.js is using the ESM loader, as this is really slow. If the application is purely a CommonJS application, this race condtion would probably never be triggered.
1 parent ed02dd9 commit e4b4716

File tree

9 files changed

+228
-4
lines changed

9 files changed

+228
-4
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
'use strict'
2+
3+
const { EOL } = require('node:os')
4+
const { spawn } = require('node:child_process')
5+
const { randomUUID } = require('node:crypto')
6+
7+
const { assert } = require('chai')
8+
const getPort = require('get-port')
9+
const Axios = require('axios')
10+
11+
const { createSandbox, FakeAgent, assertObjectContains } = require('../helpers')
12+
const { generateProbeConfig } = require('../../packages/dd-trace/test/debugger/devtools_client/utils')
13+
14+
// A race condition exists where the tracer receives a probe via RC, before Node.js has had a chance to load all the JS
15+
// files from disk. If this race condition is triggered, it results in the tracer either not being able to find any
16+
// script to attach the probe to, or, if the probe filename is a bit generic, it finds an incorrect match.
17+
//
18+
// If it can't find any script matching the expected filename, instead of emitting an `INSTALLED` event, it emits an
19+
// `ERROR` event with the message: `No loaded script found for <file>`.
20+
//
21+
// If it finds any incorrect match, where it's possible to attach a breakpoint to the line requested, the race
22+
// condition is a little less obvious, as the tracer just attaches to this wrong script. In this case, the test
23+
// behavior depends on where the breakpoint ends up being attached. In most cases, the test just times out, as the
24+
// breakpoint is never exercised during the test, and the tracer therefore never emits the `EMITTING` event.
25+
//
26+
// This is only really an issue if Node.js is using the ESM loader, as this is really slow. If the application is
27+
// purely a CommonJS application, this race condtion will probably never be triggered.
28+
//
29+
// This test tries to trigger the race condition. However, it doesn't always happen, so it runs multiple times.
30+
describe('Dynamic Instrumentation Probe Re-Evaluation', function () {
31+
let sandbox
32+
33+
before(async function () {
34+
sandbox = await createSandbox(
35+
undefined,
36+
undefined,
37+
// Ensure the test scripts live in the root of the sandbox so they are always the shortest path when
38+
// `findScriptFromPartialPath` is called
39+
['./integration-tests/debugger/target-app/re-evaluation/*']
40+
)
41+
})
42+
43+
after(async function () {
44+
await sandbox?.remove()
45+
})
46+
47+
describe('Could not find source file', genTestsForSourceFile('unique-filename.js'))
48+
49+
describe('Initially finds the wrong source file', genTestsForSourceFile('index.js'))
50+
51+
function genTestsForSourceFile (sourceFile) {
52+
return function () {
53+
let rcConfig, appPort, agent, proc, axios
54+
55+
beforeEach(async function () {
56+
rcConfig = {
57+
product: 'LIVE_DEBUGGING',
58+
id: `logProbe_${randomUUID()}`,
59+
config: generateProbeConfig({ sourceFile, line: 4 })
60+
}
61+
appPort = await getPort()
62+
agent = await new FakeAgent().start()
63+
proc = spawn(
64+
process.execPath,
65+
['--import', 'dd-trace/initialize.mjs', sourceFile],
66+
{
67+
cwd: sandbox.folder,
68+
env: {
69+
APP_PORT: appPort,
70+
DD_DYNAMIC_INSTRUMENTATION_ENABLED: true,
71+
DD_TRACE_AGENT_PORT: agent.port,
72+
DD_TRACE_DEBUG: process.env.DD_TRACE_DEBUG // inherit to make debugging the sandbox easier
73+
}
74+
}
75+
)
76+
proc
77+
.on('exit', (code) => {
78+
if (code !== 0) {
79+
throw new Error(`Child process exited with code ${code}`)
80+
}
81+
})
82+
.on('error', (error) => {
83+
throw error
84+
})
85+
proc.stdout.on('data', log.bind(null, '[child process stdout]'))
86+
proc.stderr.on('data', log.bind(null, '[child process stderr]'))
87+
axios = Axios.create({
88+
baseURL: `http://localhost:${appPort}`
89+
})
90+
})
91+
92+
afterEach(async function () {
93+
proc?.kill(0)
94+
await agent?.stop()
95+
})
96+
97+
for (let attempt = 1; attempt <= 5; attempt++) {
98+
const testName = 'should attach probe to the right script, ' +
99+
'even if it is not loaded when the probe is received ' +
100+
`(attempt ${attempt})`
101+
102+
it(testName, function (done) {
103+
this.timeout(5000)
104+
105+
const probeId = rcConfig.config.id
106+
const expectedPayloads = [{
107+
ddsource: 'dd_debugger',
108+
service: 're-evaluation-test',
109+
debugger: { diagnostics: { probeId, probeVersion: 0, status: 'RECEIVED' } }
110+
}, {
111+
ddsource: 'dd_debugger',
112+
service: 're-evaluation-test',
113+
debugger: { diagnostics: { probeId, probeVersion: 0, status: 'INSTALLED' } }
114+
}, {
115+
ddsource: 'dd_debugger',
116+
service: 're-evaluation-test',
117+
debugger: { diagnostics: { probeId, probeVersion: 0, status: 'EMITTING' } }
118+
}]
119+
120+
agent.on('debugger-diagnostics', async ({ payload }) => {
121+
await Promise.all(payload.map(async (event) => {
122+
if (event.debugger.diagnostics.status === 'ERROR') {
123+
// shortcut to fail with a more relevent error message in case the target script could not be found,
124+
// instead of asserting the entire expected event.
125+
assert.fail(event.debugger.diagnostics.exception.message)
126+
}
127+
128+
const expected = expectedPayloads.shift()
129+
assertObjectContains(event, expected)
130+
131+
if (event.debugger.diagnostics.status === 'INSTALLED') {
132+
const response = await axios.get('/')
133+
assert.strictEqual(response.status, 200)
134+
}
135+
}))
136+
137+
if (expectedPayloads.length === 0) done()
138+
})
139+
140+
agent.addRemoteConfig(rcConfig)
141+
})
142+
}
143+
}
144+
}
145+
})
146+
147+
function log (prefix, data) {
148+
const msg = data.toString().trim().split(EOL).map((line) => `${prefix} ${line}`).join(EOL)
149+
console.log(msg) // eslint-disable-line no-console
150+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createServer } from 'node:http'
2+
3+
const server = createServer((req, res) => {
4+
res.end('Hello, World!') // This needs to be line 4
5+
})
6+
7+
server.listen(process.env.APP_PORT)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "re-evaluation-test",
3+
"version": "0.0.0",
4+
"type": "module"
5+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createServer } from 'node:http'
2+
3+
const server = createServer((req, res) => {
4+
res.end('Hello, World!') // This needs to be line 4
5+
})
6+
7+
server.listen(process.env.APP_PORT)

integration-tests/debugger/utils.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ function setup ({ env, testApp, testAppSource } = {}) {
100100
DD_TRACE_AGENT_PORT: t.agent.port,
101101
DD_TRACE_DEBUG: process.env.DD_TRACE_DEBUG, // inherit to make debugging the sandbox easier
102102
DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: pollInterval,
103+
DD_INTERNAL_DEBUGGER_SCRIPT_LOADING_STABILIZED_DELAY: 0, // avoid having to set a higher poll interval
103104
...env
104105
}
105106
})

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

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@ const { findScriptFromPartialPath, locationToBreakpoint, breakpointToProbes, pro
99
const log = require('../../log')
1010

1111
let sessionStarted = false
12+
const probes = new Map()
13+
let scriptLoadingStabilizedResolve
14+
const scriptLoadingStabilized = new Promise((resolve) => { scriptLoadingStabilizedResolve = resolve })
15+
16+
// There's a race condition when a probe is first added, where the actual script that the probe is supposed to match
17+
// hasn't been loaded yet. This will result in either the probe not being added at all, or an incorrect script being
18+
// matched as the probe target.
19+
//
20+
// Therefore, once new scripts has been loaded, all probes are re-evaluated. If the matched `scriptId` has changed, we
21+
// simply remove the old probe (if it was added to the wrong script) and apply it again.
22+
session.on('scriptLoadingStabilized', () => {
23+
log.debug('[debugger:devtools_client] Re-evaluating probes')
24+
scriptLoadingStabilizedResolve()
25+
for (const probe of probes.values()) {
26+
reEvaluateProbe(probe).catch(err => {
27+
log.error('[debugger:devtools_client] Error re-evaluating probe %s', probe.id, err)
28+
})
29+
}
30+
})
1231

1332
module.exports = {
1433
addBreakpoint,
@@ -18,13 +37,14 @@ module.exports = {
1837
async function addBreakpoint (probe) {
1938
if (!sessionStarted) await start()
2039

40+
probes.set(probe.id, probe)
41+
2142
const file = probe.where.sourceFile
2243
let lineNumber = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints
2344
let columnNumber = 0 // Probes do not contain/support column information
2445

2546
// Optimize for sending data to /debugger/v1/input endpoint
2647
probe.location = { file, lines: [String(lineNumber)] }
27-
delete probe.where
2848

2949
// Optimize for fast calculations when probe is hit
3050
probe.templateRequiresEvaluation = templateRequiresEvaluation(probe.segments)
@@ -47,6 +67,8 @@ async function addBreakpoint (probe) {
4767
if (!script) throw new Error(`No loaded script found for ${file} (probe: ${probe.id}, version: ${probe.version})`)
4868
const { url, scriptId, sourceMapURL, source } = script
4969

70+
probe.scriptId = scriptId // Needed for detecting script changes during re-evaluation
71+
5072
if (sourceMapURL) {
5173
log.debug(
5274
'[debugger:devtools_client] Translating location using source map for %s:%d:%d (probe: %s, version: %d)',
@@ -109,6 +131,8 @@ async function removeBreakpoint ({ id }) {
109131
throw Error(`Unknown probe id: ${id}`)
110132
}
111133

134+
probes.delete(id)
135+
112136
const release = await lock()
113137

114138
try {
@@ -173,10 +197,29 @@ async function updateBreakpoint (breakpoint, probe) {
173197
}
174198
}
175199

176-
function start () {
200+
async function reEvaluateProbe (probe) {
201+
const script = findScriptFromPartialPath(probe.where.sourceFile)
202+
log.debug('[debugger:devtools_client] re-evaluating probe %s: %s => %s', probe.id, probe.scriptId, script?.scriptId)
203+
204+
if (probe.scriptId !== script?.scriptId) {
205+
log.debug('[debugger:devtools_client] Better match found for probe %s, re-evaluating', probe.id)
206+
if (probeToLocation.has(probe.id)) {
207+
await removeBreakpoint(probe)
208+
}
209+
await addBreakpoint(probe)
210+
}
211+
}
212+
213+
async function start () {
177214
sessionStarted = true
178215
log.debug('[debugger:devtools_client] Starting debugger')
179-
return session.post('Debugger.enable')
216+
await session.post('Debugger.enable')
217+
218+
// Wait until there's a pause in script-loading to avoid accidentally adding probes to incorrect scripts. This is not
219+
// a guarantee, but best effort.
220+
log.debug('[debugger:devtools_client] Waiting for script-loading to stabilize')
221+
await scriptLoadingStabilized
222+
log.debug('[debugger:devtools_client] Script loading stabilized')
180223
}
181224

182225
function stop () {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const WINDOWS_DRIVE_LETTER_REGEX = /[a-zA-Z]/
99

1010
const loadedScripts = []
1111
const scriptUrls = new Map()
12+
let reEvaluateProbesTimer = null
13+
const SCRIPT_LOADING_STABILIZED_DELAY = Number(process.env.DD_INTERNAL_DEBUGGER_SCRIPT_LOADING_STABILIZED_DELAY || 1000)
1214

1315
module.exports = {
1416
locationToBreakpoint: new Map(),
@@ -142,5 +144,10 @@ session.on('Debugger.scriptParsed', ({ params }) => {
142144
} else {
143145
loadedScripts.push(params)
144146
}
147+
148+
clearTimeout(reEvaluateProbesTimer)
149+
reEvaluateProbesTimer = setTimeout(() => {
150+
session.emit('scriptLoadingStabilized')
151+
}, SCRIPT_LOADING_STABILIZED_DELAY)
145152
}
146153
})

packages/dd-trace/test/debugger/devtools_client/breakpoints.spec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ describe('breakpoints', function () {
1717
}
1818
return Promise.resolve({})
1919
}),
20+
on (event, callback) {
21+
if (event === 'scriptLoadingStabilized') callback()
22+
},
2023
'@noCallThru': true
2124
}
2225

packages/dd-trace/test/debugger/devtools_client/state.spec.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ describe('findScriptFromPartialPath', function () {
5050
}
5151
})
5252
}
53-
}
53+
},
54+
emit () {}
5455
}
5556
})
5657
})

0 commit comments

Comments
 (0)