diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 7b3f0ec9..c104ddf7 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -101,6 +101,8 @@ function serialize( const functionIdMap = new Map(); const locationIdMap = new Map(); + let hasMissingMapFiles = false; + const entries: Array> = (root.children as T[]).map((n: T) => ({ node: n, stack: [], @@ -131,6 +133,10 @@ function serialize( profile.function = functions; profile.stringTable = stringTable; + if (hasMissingMapFiles) { + profile.comment = [stringTable.dedup('dd:has-missing-map-files')]; + } + function getLocation( node: ProfileNode, scriptName: string, @@ -146,6 +152,9 @@ function serialize( if (profLoc.line) { if (sourceMapper && isGeneratedLocation(profLoc)) { profLoc = sourceMapper.mappingInfo(profLoc); + if (profLoc.missingMapFile) { + hasMissingMapFiles = true; + } } } const keyStr = `${node.scriptId}:${profLoc.line}:${profLoc.column}:${profLoc.name}`; diff --git a/ts/src/sourcemapper/sourcemapper.ts b/ts/src/sourcemapper/sourcemapper.ts index 91342a57..5ccb476b 100644 --- a/ts/src/sourcemapper/sourcemapper.ts +++ b/ts/src/sourcemapper/sourcemapper.ts @@ -161,6 +161,8 @@ export interface SourceLocation { name?: string; line?: number; column?: number; + /** True when the file declares a sourceMappingURL but the map could not be found. */ + missingMapFile?: boolean; } /** @@ -287,6 +289,8 @@ async function processSourceMap( export class SourceMapper { infoMap: Map; + /** JS files that declared a sourceMappingURL but no map was ultimately found. */ + private declaredMissingMap = new Set(); debug: boolean; static async create( @@ -351,6 +355,9 @@ export class SourceMapper { const limit = createLimiter(CONCURRENCY); + // JS files that declared a sourceMappingURL but Phase 1 couldn't load the map. + const annotatedNotLoaded = new Set(); + // Phase 1: Check sourceMappingURL annotations in JS files (higher priority). await Promise.all( jsFiles.map(jsPath => @@ -383,6 +390,7 @@ export class SourceMapper { ); } catch { // Map file doesn't exist or is unreadable; fall through to Phase 2. + annotatedNotLoaded.add(jsPath); } } }), @@ -395,6 +403,14 @@ export class SourceMapper { limit(() => processSourceMap(this.infoMap, mapPath, this.debug)), ), ); + + // Any file whose annotation pointed to a missing map and that still has no + // entry after Phase 2 is tracked as "declared but missing". + for (const jsPath of annotatedNotLoaded) { + if (!this.infoMap.has(jsPath)) { + this.declaredMissingMap.add(jsPath); + } + } } private async loadMapContent( @@ -480,6 +496,9 @@ export class SourceMapper { `Source map lookup failed: no map found for ${location.file} (normalized: ${inputPath})`, ); } + if (this.declaredMissingMap.has(inputPath)) { + return {...location, missingMapFile: true}; + } return location; } diff --git a/ts/test/oom.ts b/ts/test/oom.ts index f5a25cac..d028afa7 100644 --- a/ts/test/oom.ts +++ b/ts/test/oom.ts @@ -1,6 +1,5 @@ 'use strict'; -/* eslint-disable no-console */ import {Worker, isMainThread, threadId} from 'worker_threads'; import {heap} from '../src/index'; import path from 'path'; diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index 8f87b0f3..7450e118 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -238,6 +238,130 @@ describe('profile-serializer', () => { }); }); + describe('missing source map file reporting', () => { + let sourceMapper: SourceMapper; + let missingJsPath: string; + + before(async () => { + const fs = await import('fs'); + const path = await import('path'); + const testDir = tmp.dirSync().name; + missingJsPath = path.join(testDir, 'missing-map.js'); + // JS file that declares a sourceMappingURL but the map file doesn't exist. + fs.writeFileSync( + missingJsPath, + '//# sourceMappingURL=nonexistent.js.map\n', + ); + sourceMapper = await SourceMapper.create([testDir]); + }); + + function makeSingleNodeTimeProfile(scriptName: string) { + return { + startTime: 0, + endTime: 1000000, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children: [ + { + name: 'foo', + scriptName, + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + hitCount: 1, + children: [], + }, + ], + }, + }; + } + + function assertHasMissingMapToken(profile: Profile) { + const st = profile.stringTable; + const tokenId = st.dedup('dd:has-missing-map-files'); + const comments = profile.comment as number[]; + assert.ok( + Array.isArray(comments) && comments.includes(tokenId), + 'expected dd:has-missing-map-files token in profile comments', + ); + } + + it('serializeTimeProfile emits missing-map token when a map is declared but absent', () => { + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + sourceMapper, + ); + assertHasMissingMapToken(profile); + }); + + it('serializeHeapProfile emits missing-map token when a map is declared but absent', () => { + // serialize() iterates root.children, so the node with allocations must + // be a child of the root passed to serializeHeapProfile. + const heapNode = { + name: '(root)', + scriptName: '', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + allocations: [], + children: [ + { + name: 'foo', + scriptName: missingJsPath, + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + allocations: [{sizeBytes: 100, count: 1}], + children: [], + }, + ], + }; + const profile = serializeHeapProfile( + heapNode, + 0, + 512 * 1024, + undefined, + sourceMapper, + ); + assertHasMissingMapToken(profile); + }); + + it('does not emit missing-map token when no source mapper is used', () => { + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + ); + assert.ok( + !profile.comment || profile.comment.length === 0, + 'expected no comments when no source mapper is provided', + ); + }); + + it('does not emit missing-map token when all maps are found', () => { + const { + mapDirPath, + v8TimeGeneratedProfile, + } = require('./profiles-for-tests'); + return SourceMapper.create([mapDirPath]).then(sm => { + const profile = serializeTimeProfile(v8TimeGeneratedProfile, 1000, sm); + assert.ok( + !profile.comment || profile.comment.length === 0, + 'expected no comments when all maps are resolved', + ); + }); + }); + + after(() => { + tmp.setGracefulCleanup(); + }); + }); + describe('source map with column 0 (LineTick simulation)', () => { // This tests the LEAST_UPPER_BOUND fallback for when V8's LineTick // doesn't provide column information (column=0) diff --git a/ts/test/test-sourcemapper.ts b/ts/test/test-sourcemapper.ts index 350df364..6febeca4 100644 --- a/ts/test/test-sourcemapper.ts +++ b/ts/test/test-sourcemapper.ts @@ -271,4 +271,58 @@ describe('SourceMapper.loadDirectory', () => { 'expected no mapping to be loaded', ); }); + + it('sets missingMapFile=true when sourceMappingURL declares a missing map', async () => { + write('declared-missing.js', '//# sourceMappingURL=nonexistent.js.map\n'); + // No declared-missing.js.map written — nothing to fall back to. + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'declared-missing.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.strictEqual( + loc.missingMapFile, + true, + 'expected missingMapFile to be true for a file with a declared but missing map', + ); + }); + + it('does not set missingMapFile when file has no sourceMappingURL', async () => { + write('plain.js', 'console.log("hello");\n'); + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'plain.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.ok( + !loc.missingMapFile, + 'expected missingMapFile to be falsy for a file with no sourceMappingURL', + ); + }); + + it('does not set missingMapFile when map was found via .map fallback', async () => { + // JS with annotation pointing to nonexistent path, but a .map file exists + // alongside it (Phase 2 fallback). + const {SourceMapGenerator} = await import('source-map'); + const gen = new SourceMapGenerator({file: 'fallback.js'}); + gen.addMapping({ + source: path.join(tmpDir, 'source.ts'), + generated: {line: 1, column: 0}, + original: {line: 10, column: 0}, + }); + write('fallback.js', '//# sourceMappingURL=nowhere.js.map\n'); + write('fallback.js.map', gen.toString()); + + const sm = new SourceMapper(); + await sm.loadDirectory(tmpDir); + + const jsPath = path.join(tmpDir, 'fallback.js'); + const loc = sm.mappingInfo({file: jsPath, line: 1, column: 0, name: 'foo'}); + assert.ok( + !loc.missingMapFile, + 'expected missingMapFile to be falsy when map was found via Phase 2 fallback', + ); + }); }); diff --git a/ts/test/test-worker-threads.ts b/ts/test/test-worker-threads.ts index efb8cfaf..93d8e04b 100644 --- a/ts/test/test-worker-threads.ts +++ b/ts/test/test-worker-threads.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line n/no-unsupported-features/node-builtins import {execFile} from 'child_process'; import {promisify} from 'util'; import {Worker} from 'worker_threads';