Skip to content

Commit 238ab51

Browse files
BridgeARleoromanovsky
authored andcommitted
test(coverage): warm source maps before process exit (#9330)
Node can hang when native coverage resolves a large ESM source-map cache during isolate teardown. Resolve maps as scripts load so the final V8 snapshot retains all execution counters and foreign coverage directories remain untouched. Refs: nodejs/node#49344 * test(coverage): disconnect source-map observer before teardown Drain the last queued URLs and disconnect the Debugger session from the JavaScript exit event before Node runs native coverage serialization. This keeps late exit-time maps while avoiding a live observer during isolate teardown. * test(coverage): avoid renderer and exit-time source-map lookups Electron renderers lack a usable Node inspector, so connecting a Session prevents renderer preload and IPC instrumentation from starting. Explicit process.exit skips the scheduled warm-up, moving the full source-map backlog into the exit event where Node's teardown can hang. Skip only renderer observers and drain pending maps before explicit exit; the exit hook now only disconnects.
1 parent f54fa7b commit 238ab51

3 files changed

Lines changed: 332 additions & 6 deletions

File tree

scripts/c8-ci.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ const { rmSync } = require('node:fs')
2121
const path = require('node:path')
2222

2323
const { convertV8DirToReport } = require('../integration-tests/coverage/merge-lcov')
24+
const baseNycConfig = require('../nyc.config')
25+
const { markCoverageDirectory } = require('./c8-source-map-cache')
2426

2527
const repoRoot = path.resolve(__dirname, '..')
28+
const sourceMapCacheFile = require.resolve('./c8-source-map-cache')
2629

2730
const script = process.argv[2]
2831
if (!script) {
@@ -31,17 +34,19 @@ if (!script) {
3134
}
3235
const extraArgs = process.argv.slice(3)
3336

34-
let event = process.env.npm_lifecycle_event ?? ''
35-
if (process.env.PLUGINS) event += `-${process.env.PLUGINS}`
36-
const label = `-${event.replaceAll(/[^a-zA-Z0-9._-]+/g, '-')}`
37-
const v8Dir = path.join(repoRoot, '.nyc_output', `node-${process.version}${label}`, 'v8')
38-
const reportDir = path.join(repoRoot, 'coverage', `node-${process.version}${label}`)
37+
const v8Dir = path.join(repoRoot, baseNycConfig.tempDir, 'v8')
38+
const reportDir = path.join(repoRoot, baseNycConfig.reportDir)
3939

4040
// Fresh collector each run so a previous run's profiles don't leak in.
4141
rmSync(v8Dir, { force: true, recursive: true })
4242
rmSync(reportDir, { force: true, recursive: true })
43+
markCoverageDirectory(v8Dir)
4344

44-
const coverageEnv = { ...process.env, NODE_V8_COVERAGE: v8Dir }
45+
const sourceMapCacheRequire = `--require=${sourceMapCacheFile}`
46+
const nodeOptions = process.env.NODE_OPTIONS
47+
? `${sourceMapCacheRequire} ${process.env.NODE_OPTIONS}`
48+
: sourceMapCacheRequire
49+
const coverageEnv = { ...process.env, NODE_OPTIONS: nodeOptions, NODE_V8_COVERAGE: v8Dir }
4550

4651
/**
4752
* @param {string[]} command command + args to run with V8 coverage enabled

scripts/c8-source-map-cache.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
'use strict'
2+
3+
const { existsSync, mkdirSync, writeFileSync } = require('node:fs')
4+
const path = require('node:path')
5+
6+
const OWNERSHIP_FILE = '.dd-c8-coverage'
7+
8+
const coverageDir = process.env.NODE_V8_COVERAGE
9+
if (coverageDir && existsSync(path.join(coverageDir, OWNERSHIP_FILE))) {
10+
const preloadList = require('node-preload')
11+
12+
if (!preloadList.includes(__filename)) preloadList.push(__filename)
13+
warmSourceMaps()
14+
}
15+
16+
/**
17+
* @param {string} coverageDir
18+
*/
19+
function markCoverageDirectory (coverageDir) {
20+
mkdirSync(coverageDir, { recursive: true })
21+
writeFileSync(path.join(coverageDir, OWNERSHIP_FILE), '')
22+
}
23+
24+
function warmSourceMaps () {
25+
// Electron renderers are initialized without a usable Node inspector.
26+
if (process.versions.electron && process.type === 'renderer') return
27+
28+
const inspector = require('node:inspector')
29+
const { findSourceMap } = require('node:module')
30+
const { debuglog } = require('node:util')
31+
32+
// Avoid resolving the whole source-map cache during coverage teardown.
33+
// https://github.com/nodejs/node/issues/49344
34+
const debug = debuglog('dd-trace:coverage')
35+
const pendingUrls = new Set()
36+
const session = new inspector.Session()
37+
let warmingScheduled = false
38+
39+
/**
40+
* @param {{ params: { url: string } }} message
41+
*/
42+
function onScriptParsed ({ params: { url } }) {
43+
if (!url.startsWith('file:')) return
44+
45+
pendingUrls.add(url)
46+
if (!warmingScheduled) {
47+
warmingScheduled = true
48+
setImmediate(warmPendingSourceMaps)
49+
}
50+
}
51+
52+
function warmPendingSourceMaps () {
53+
warmingScheduled = false
54+
for (const url of pendingUrls) {
55+
pendingUrls.delete(url)
56+
try {
57+
findSourceMap(url)
58+
} catch (error) {
59+
debug('Failed to warm source map for %s: %s', url, error.stack || error.message)
60+
}
61+
}
62+
}
63+
64+
function disconnectSourceMapObserver () {
65+
session.disconnect()
66+
}
67+
68+
const originalExit = process.exit
69+
70+
/**
71+
* @param {number|string|undefined} code
72+
* @returns {never}
73+
*/
74+
function exitWithWarmedSourceMaps (code) {
75+
warmPendingSourceMaps()
76+
originalExit(code)
77+
}
78+
79+
session.connect()
80+
session.on('Debugger.scriptParsed', onScriptParsed)
81+
session.post('Debugger.enable')
82+
process.exit = exitWithWarmedSourceMaps
83+
process.once('exit', disconnectSourceMapObserver)
84+
}
85+
86+
module.exports = { markCoverageDirectory }
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import assert from 'node:assert/strict'
2+
import { execFile } from 'node:child_process'
3+
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
4+
import { createRequire } from 'node:module'
5+
import os from 'node:os'
6+
import path from 'node:path'
7+
import { fileURLToPath, pathToFileURL } from 'node:url'
8+
import { promisify } from 'node:util'
9+
10+
import { afterEach, beforeEach, describe, it } from 'mocha'
11+
12+
const execFileAsync = promisify(execFile)
13+
const require = createRequire(import.meta.url)
14+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
15+
const nodePreloadFile = require.resolve('node-preload')
16+
const preloadFile = path.join(repoRoot, 'scripts', 'c8-source-map-cache.js')
17+
const { markCoverageDirectory } = require('./c8-source-map-cache')
18+
19+
/**
20+
* @param {string | undefined} coverageDir
21+
* @param {string} fixtureDir
22+
* @param {object} [options]
23+
* @param {string} [options.childCoverageDir]
24+
* @param {boolean} [options.exitImmediately]
25+
* @param {boolean} [options.failLookup]
26+
* @param {number} [options.moduleCount]
27+
*/
28+
async function runFixture (coverageDir, fixtureDir, options = {}) {
29+
const childCoverageDir = options.childCoverageDir ?? coverageDir
30+
const moduleCount = options.moduleCount ?? 0
31+
const childCoverageLog = path.join(fixtureDir, 'child-coverage-dir.txt')
32+
const exitMarker = path.join(fixtureDir, 'exit-started')
33+
const lookupLog = path.join(fixtureDir, 'source-map-lookups.txt')
34+
const modulesDir = path.join(fixtureDir, 'modules')
35+
const observerFile = path.join(fixtureDir, 'observe-source-map-lookups.js')
36+
const parentFile = path.join(fixtureDir, 'parent.js')
37+
const targetFile = path.join(fixtureDir, 'target.mjs')
38+
39+
await mkdir(modulesDir)
40+
const moduleFiles = new Array(moduleCount)
41+
const importLines = new Array(moduleCount)
42+
for (let i = 0; i < moduleCount; i++) {
43+
const moduleFile = path.join(modulesDir, `${i}.mjs`)
44+
moduleFiles[i] = writeFile(moduleFile, `export default ${i}\n//# sourceMappingURL=${i}.mjs.map\n`)
45+
importLines[i] = `import ${JSON.stringify(pathToFileURL(moduleFile).href)}`
46+
}
47+
await Promise.all(moduleFiles)
48+
49+
await writeFile(observerFile, String.raw`
50+
'use strict'
51+
const fs = require('node:fs')
52+
const inspector = require('node:inspector')
53+
const Module = require('node:module')
54+
const originalDisconnect = inspector.Session.prototype.disconnect
55+
const originalFindSourceMap = Module.findSourceMap
56+
inspector.Session.prototype.disconnect = function () {
57+
originalDisconnect.call(this)
58+
fs.appendFileSync(${JSON.stringify(lookupLog)}, 'disconnect\n')
59+
}
60+
Module.findSourceMap = function (url) {
61+
const phase = fs.existsSync(${JSON.stringify(exitMarker)}) ? 'exit:' : ''
62+
fs.appendFileSync(${JSON.stringify(lookupLog)}, phase + url + '\n')
63+
${options.failLookup
64+
? `const error = new Error('source map lookup failed')
65+
if (url.endsWith('/target.mjs')) error.stack = undefined
66+
throw error`
67+
: ''}
68+
return originalFindSourceMap(url)
69+
}
70+
`)
71+
await writeFile(parentFile, `
72+
'use strict'
73+
const { spawnSync } = require('node:child_process')
74+
const preloadList = require(${JSON.stringify(nodePreloadFile)})
75+
preloadList.unshift(${JSON.stringify(observerFile)})
76+
require(${JSON.stringify(preloadFile)})
77+
const childEnv = {
78+
...process.env,
79+
NODE_OPTIONS: '',
80+
npm_lifecycle_event: 'nested-script',
81+
}
82+
${childCoverageDir === undefined
83+
? "childEnv.NODE_V8_COVERAGE = ''"
84+
: `childEnv.NODE_V8_COVERAGE = ${JSON.stringify(childCoverageDir)}`}
85+
const result = spawnSync(process.execPath, [${JSON.stringify(targetFile)}], {
86+
env: childEnv,
87+
stdio: 'inherit',
88+
})
89+
process.exitCode = result.status ?? 1
90+
`)
91+
await writeFile(targetFile, `
92+
import { writeFileSync } from 'node:fs'
93+
import { createRequire } from 'node:module'
94+
${importLines.join('\n')}
95+
const require = createRequire(import.meta.url)
96+
writeFileSync(
97+
${JSON.stringify(childCoverageLog)},
98+
process.env.NODE_V8_COVERAGE ?? ''
99+
)
100+
function markCoverageTail () {
101+
globalThis.coverageTail = true
102+
}
103+
${options.exitImmediately
104+
? `process.prependOnceListener('exit', () => writeFileSync(${JSON.stringify(exitMarker)}, ''))
105+
markCoverageTail()
106+
process.exit()`
107+
: "process.once('beforeExit', markCoverageTail)"}
108+
`)
109+
110+
const env = {
111+
...process.env,
112+
NODE_V8_COVERAGE: coverageDir === undefined ? '' : coverageDir,
113+
}
114+
await execFileAsync(process.execPath, [parentFile], { cwd: repoRoot, env })
115+
116+
return {
117+
childCoverageLog,
118+
lookupLog,
119+
targetFile: await realpath(targetFile),
120+
}
121+
}
122+
123+
/**
124+
* @param {string} coverageDir
125+
* @param {string} targetFile
126+
*/
127+
async function assertTailCovered (coverageDir, targetFile) {
128+
const targetUrl = pathToFileURL(targetFile).href
129+
const profileFiles = []
130+
for (const filename of await readdir(coverageDir)) {
131+
if (filename.endsWith('.json')) profileFiles.push(readFile(path.join(coverageDir, filename), 'utf8'))
132+
}
133+
134+
let targetCoverage
135+
for (const profileFile of await Promise.all(profileFiles)) {
136+
const profile = JSON.parse(profileFile)
137+
for (const entry of profile.result) {
138+
if (entry.url === targetUrl) {
139+
targetCoverage = entry
140+
break
141+
}
142+
}
143+
if (targetCoverage) break
144+
}
145+
146+
assert.ok(targetCoverage, `No coverage found for ${targetUrl}`)
147+
let functionCoverage
148+
for (const entry of targetCoverage.functions) {
149+
if (entry.functionName === 'markCoverageTail') {
150+
functionCoverage = entry
151+
break
152+
}
153+
}
154+
assert.ok(functionCoverage, 'No coverage found for markCoverageTail')
155+
assert.strictEqual(functionCoverage.ranges[0].count, 1)
156+
}
157+
158+
describe('c8 source map cache', () => {
159+
let fixtureDir
160+
161+
beforeEach(async () => {
162+
fixtureDir = await mkdtemp(path.join(os.tmpdir(), 'dd-c8-source-map-cache-'))
163+
})
164+
165+
afterEach(async () => {
166+
await rm(fixtureDir, { force: true, recursive: true })
167+
})
168+
169+
it('warms source maps for c8-owned coverage through custom child environments', async () => {
170+
const ownCoverageDir = path.join(fixtureDir, 'own-coverage')
171+
markCoverageDirectory(ownCoverageDir)
172+
const { childCoverageLog, lookupLog, targetFile } =
173+
await runFixture(ownCoverageDir, fixtureDir, { moduleCount: 200 })
174+
175+
assert.strictEqual(await readFile(childCoverageLog, 'utf8'), ownCoverageDir)
176+
const lookups = await readFile(lookupLog, 'utf8')
177+
assert.ok(lookups.includes(pathToFileURL(targetFile).href), `No source map lookup found for ${targetFile}`)
178+
let moduleLookups = 0
179+
for (const lookup of lookups.split('\n')) {
180+
if (lookup.includes('/modules/')) moduleLookups++
181+
}
182+
assert.strictEqual(moduleLookups, 200)
183+
await assertTailCovered(ownCoverageDir, targetFile)
184+
})
185+
186+
it('does not warm source maps for a foreign coverage directory', async () => {
187+
const ownCoverageDir = path.join(fixtureDir, 'own-coverage')
188+
const foreignCoverageDir = path.join(fixtureDir, 'foreign-coverage')
189+
markCoverageDirectory(ownCoverageDir)
190+
const { childCoverageLog, lookupLog, targetFile } = await runFixture(ownCoverageDir, fixtureDir, {
191+
childCoverageDir: foreignCoverageDir,
192+
})
193+
194+
assert.strictEqual(await readFile(childCoverageLog, 'utf8'), foreignCoverageDir)
195+
await assert.rejects(readFile(lookupLog), { code: 'ENOENT' })
196+
await assertTailCovered(foreignCoverageDir, targetFile)
197+
})
198+
199+
it('does not warm source maps without coverage', async () => {
200+
const { childCoverageLog, lookupLog } = await runFixture(undefined, fixtureDir)
201+
202+
assert.strictEqual(await readFile(childCoverageLog, 'utf8'), '')
203+
await assert.rejects(readFile(lookupLog), { code: 'ENOENT' })
204+
})
205+
206+
it('continues when a source map lookup fails', async () => {
207+
const ownCoverageDir = path.join(fixtureDir, 'own-coverage')
208+
markCoverageDirectory(ownCoverageDir)
209+
const { lookupLog, targetFile } = await runFixture(ownCoverageDir, fixtureDir, { failLookup: true })
210+
211+
const lookups = await readFile(lookupLog, 'utf8')
212+
assert.ok(lookups.includes(pathToFileURL(targetFile).href), `No source map lookup found for ${targetFile}`)
213+
await assertTailCovered(ownCoverageDir, targetFile)
214+
})
215+
216+
it('warms pending source maps before explicit exit', async () => {
217+
const ownCoverageDir = path.join(fixtureDir, 'own-coverage')
218+
markCoverageDirectory(ownCoverageDir)
219+
const { lookupLog, targetFile } =
220+
await runFixture(ownCoverageDir, fixtureDir, { exitImmediately: true, moduleCount: 200 })
221+
222+
const lookupLogContent = await readFile(lookupLog, 'utf8')
223+
const entries = lookupLogContent.trimEnd().split('\n')
224+
const disconnectIndex = entries.indexOf('disconnect')
225+
let moduleLookups = 0
226+
for (const entry of entries) {
227+
assert.strictEqual(entry.startsWith('exit:'), false, `Source map lookup ran during exit: ${entry}`)
228+
if (entry.includes('/modules/')) moduleLookups++
229+
}
230+
assert.strictEqual(moduleLookups, 200)
231+
assert.strictEqual(disconnectIndex, entries.length - 1)
232+
assert.strictEqual(entries.lastIndexOf('disconnect'), disconnectIndex)
233+
await assertTailCovered(ownCoverageDir, targetFile)
234+
})
235+
})

0 commit comments

Comments
 (0)