diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a674f4a01..dcd08f9944 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,13 @@ jobs: - name: Build node tools run: yarn build-node-tools + - name: Upload node-tools-dist artifact + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v7 + with: + name: node-tools-dist + path: node-tools-dist/ + licence-check: runs-on: ${{ matrix.os }} strategy: diff --git a/package.json b/package.json index 0af1c64337..ed771bdc22 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^3.1.0", "reselect": "^4.1.8", + "smol-toml": "^1.6.1", "url": "^0.11.4", "valibot": "^1.3.1", "weaktuplemap": "^1.0.0", diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 961df029b3..2399a0341d 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -7,6 +7,7 @@ import minimist from 'minimist'; import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-logic/process-profile'; import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; import { compress } from 'firefox-profiler/utils/gz'; +import { insertStackLabels } from 'firefox-profiler/profile-logic/insert-stack-labels'; import { SymbolStore } from 'firefox-profiler/profile-logic/symbol-store'; import { symbolicateProfile, @@ -14,8 +15,30 @@ import { } from 'firefox-profiler/profile-logic/symbolication'; import type { SymbolicationStepInfo } from 'firefox-profiler/profile-logic/symbolication'; import * as MozillaSymbolicationAPI from 'firefox-profiler/profile-logic/mozilla-symbolication-api'; -import type { Profile } from 'firefox-profiler/types/profile'; +import { + applyWasmSymbolication, + type WasmSymbolicationSpec, +} from 'firefox-profiler/profile-logic/wasm-symbolication'; +import { mergeThreads } from 'firefox-profiler/profile-logic/merge-compare'; +import { getTimeRangeForThread } from 'firefox-profiler/profile-logic/profile-data'; +import { + correlateIPCMarkers, + deriveMarkersFromRawMarkerTable, + getSearchFilteredMarkerIndexes, + stringsToMarkerRegExps, +} from 'firefox-profiler/profile-logic/marker-data'; +import { markerSchemaFrontEndOnly } from 'firefox-profiler/profile-logic/marker-schema'; +import { getDefaultCategories } from 'firefox-profiler/profile-logic/data-structures'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { splitSearchString } from 'firefox-profiler/utils/string'; +import type { MarkerSchemaByName } from 'firefox-profiler/types/markers'; +import type { Profile, RawThread } from 'firefox-profiler/types/profile'; +import type { StartEndRange } from 'firefox-profiler/types/units'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { + parseLabelToml, + resolveAllLabels, +} from 'firefox-profiler/utils/label-templates'; /** * A CLI tool for editing profiles. @@ -29,6 +52,17 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; * Examples: * node node-tools-dist/profiler-edit.js -i samply-profile.json -o out.json \ * --symbolicate-with-server http://localhost:8001/abcdef/ + * + * node node-tools-dist/profiler-edit.js -i input.json.gz -o out.json.gz \ + * --symbolicate-wasm http://host/a.wasm=./a-unstripped.wasm \ + * --symbolicate-wasm http://host/b.wasm=./b-unstripped.wasm + * + * node node-tools-dist/profiler-edit.js --from-hash w1spyw917hg... -o out.json.gz \ + * --insert-label-frames known-functions.toml + * + * node node-tools-dist/profiler-edit.js -i big.json.gz -o small.json.gz \ + * --only-keep-threads-with-markers-matching '-async,-sync' \ + * --merge-non-overlapping-threads-by-name */ type ProfileSource = @@ -36,10 +70,311 @@ type ProfileSource = | { type: 'URL'; url: string } | { type: 'HASH'; hash: string }; +interface WasmSymbolicationCliSpec { + path: string; + url?: string; +} + export interface CliOptions { input: ProfileSource; output: string; symbolicateWithServer?: string; + symbolicateWasm?: WasmSymbolicationCliSpec[]; + insertLabelFrames?: string; + onlyKeepThreadsWithMarkersMatching?: string; + mergeNonOverlappingThreadsByName?: boolean; + setName?: string; +} + +function loadWasmSymbolicationSpecs( + cliSpecs: WasmSymbolicationCliSpec[] +): WasmSymbolicationSpec[] { + return cliSpecs.map((spec) => { + console.log(`Reading wasm symbols from ${spec.path}`); + const buf = fs.readFileSync(spec.path); + return { + bytes: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), + url: spec.url, + label: spec.path, + }; + }); +} + +/** + * Reconstruct the func-name strings used by insertStackLabels' prefix matcher + * (mirrors getLabelIndexForFunc in insert-stack-labels.ts), so auto-discovery + * sees the same strings the labeler will compare against. + */ +function collectFuncNames(profile: Profile): string[] { + const { funcTable, sources, stringArray } = profile.shared; + const result: string[] = []; + for (let i = 0; i < funcTable.length; i++) { + let name = stringArray[funcTable.name[i]]; + const sourceIndex = funcTable.source[i]; + if (sourceIndex !== null) { + const filename = stringArray[sources.filename[sourceIndex]]; + const line = funcTable.lineNumber[i]; + const col = funcTable.columnNumber[i]; + if (line !== null && col !== null) { + name += ` (${filename}:${line}:${col})`; + } else if (line !== null) { + name += ` (${filename}:${line})`; + } else { + name += ` (${filename})`; + } + } + result.push(name); + } + return result; +} + +/** + * Keep only the threads that have at least one marker matching the given + * marker search string (using the same syntax as the front-end: comma- + * separated terms, optional `field:value` and `-field:value` qualifiers). + * We derive markers and run the standard search filter so that string-table + * indexed payload fields (UserTiming.name, IPC fields, ...) are resolved + * correctly. + */ +function filterThreadsByMarkerSearch( + profile: Profile, + search: string +): Profile { + const searchRegExps = stringsToMarkerRegExps(splitSearchString(search)); + if (searchRegExps === null) { + return profile; + } + + const stringTable = StringTable.withBackingArray(profile.shared.stringArray); + const categoryList = profile.meta.categories ?? getDefaultCategories(); + + const frontEndSchemaNames = new Set( + markerSchemaFrontEndOnly.map((schema) => schema.name) + ); + const schemaList = [ + ...(profile.meta.markerSchema ?? []).filter( + (schema) => !frontEndSchemaNames.has(schema.name) + ), + ...markerSchemaFrontEndOnly, + ]; + const markerSchemaByName: MarkerSchemaByName = Object.create(null); + for (const schema of schemaList) { + markerSchemaByName[schema.name] = schema; + } + + const ipcCorrelations = correlateIPCMarkers(profile.threads, profile.shared); + + const threads = profile.threads.filter((thread) => { + const { markers } = deriveMarkersFromRawMarkerTable( + thread.markers, + profile.shared.stringArray, + thread.tid, + getTimeRangeForThread(thread, profile.meta.interval), + ipcCorrelations + ); + if (markers.length === 0) { + return false; + } + const markerIndexes = markers.map((_, i) => i); + const filtered = getSearchFilteredMarkerIndexes( + (i) => markers[i], + markerIndexes, + markerSchemaByName, + searchRegExps, + stringTable, + categoryList + ); + return filtered.length > 0; + }); + + return { ...profile, threads }; +} + +/** + * First-fit interval coloring: partition `items` (sorted by start time) into + * subgroups such that within each subgroup no two items overlap. + */ +function partitionNonOverlapping( + itemsSortedByStart: T[], + rangeOf: (item: T) => StartEndRange +): T[][] { + const subgroups: { items: T[]; lastEnd: number }[] = []; + for (const item of itemsSortedByStart) { + const range = rangeOf(item); + let placed = false; + for (const sg of subgroups) { + if (sg.lastEnd <= range.start) { + sg.items.push(item); + sg.lastEnd = range.end; + placed = true; + break; + } + } + if (!placed) { + subgroups.push({ items: [item], lastEnd: range.end }); + } + } + return subgroups.map((sg) => sg.items); +} + +/** + * Merges threads from sequential runs of the same logical workload. + * + * Two-stage approach: + * + * 1. Group processes (i.e. all threads sharing a pid) by (processName, + * processType, mainThreadName) and partition each group into matched + * bundles of non-overlapping processes via first-fit interval coloring. + * Each non-singleton bundle represents one logical process whose + * lifetime spans multiple runs. + * + * 2. Within each matched bundle, merge same-named threads across the + * bundled processes. Same-named threads inside a single process are + * not merged (they may overlap), so we again partition by non-overlap + * before merging. + * + * Threads belonging to a singleton process bundle are passed through + * unchanged. + */ +function mergeNonOverlappingThreadsByName(profile: Profile): Profile { + const interval = profile.meta.interval; + const threads = profile.threads; + + const threadRanges = threads.map((t) => getTimeRangeForThread(t, interval)); + + type ProcessInfo = { + pid: RawThread['pid']; + threadIndices: number[]; + range: StartEndRange; + processName: string | undefined; + processType: string; + mainThreadName: string; + }; + + const processesByPid = new Map(); + for (let i = 0; i < threads.length; i++) { + const t = threads[i]; + let proc = processesByPid.get(t.pid); + if (proc === undefined) { + proc = { + pid: t.pid, + threadIndices: [], + range: { start: Infinity, end: -Infinity }, + processName: t.processName, + processType: t.processType, + mainThreadName: t.name, + }; + processesByPid.set(t.pid, proc); + } + proc.threadIndices.push(i); + if (t.isMainThread) { + proc.mainThreadName = t.name; + if (t.processName !== undefined) { + proc.processName = t.processName; + } + } + const r = threadRanges[i]; + if (r.start < proc.range.start) { + proc.range.start = r.start; + } + if (r.end > proc.range.end) { + proc.range.end = r.end; + } + } + + const processGroups = new Map(); + for (const proc of processesByPid.values()) { + const key = `${proc.processName ?? ''}\u0000${proc.processType}\u0000${proc.mainThreadName}`; + let g = processGroups.get(key); + if (g === undefined) { + g = []; + processGroups.set(key, g); + } + g.push(proc); + } + + const mergedIndexes = new Set(); + const mergeReplacements = new Map(); + let mergedProcessBundles = 0; + + for (const procs of processGroups.values()) { + if (procs.length <= 1) { + continue; + } + procs.sort((a, b) => a.range.start - b.range.start); + const bundles = partitionNonOverlapping(procs, (p) => p.range); + + for (const bundle of bundles) { + if (bundle.length <= 1) { + continue; + } + mergedProcessBundles++; + + // Group threads in this bundle by name, partition each by non-overlap, + // and merge subgroups of size > 1. + const threadsByName = new Map(); + for (const proc of bundle) { + for (const tIdx of proc.threadIndices) { + const name = threads[tIdx].name; + let arr = threadsByName.get(name); + if (arr === undefined) { + arr = []; + threadsByName.set(name, arr); + } + arr.push(tIdx); + } + } + + for (const tIndices of threadsByName.values()) { + if (tIndices.length <= 1) { + continue; + } + tIndices.sort((a, b) => threadRanges[a].start - threadRanges[b].start); + const tBundles = partitionNonOverlapping( + tIndices, + (i) => threadRanges[i] + ); + for (const tb of tBundles) { + if (tb.length <= 1) { + continue; + } + const sourceThreads = tb.map((i) => threads[i]); + const original = sourceThreads[0]; + const merged = mergeThreads(sourceThreads); + merged.name = original.name; + merged.pid = original.pid; + merged.tid = original.tid; + merged.processType = original.processType; + merged.processName = original.processName; + merged.isMainThread = original.isMainThread; + + mergeReplacements.set(tb[0], merged); + for (let k = 1; k < tb.length; k++) { + mergedIndexes.add(tb[k]); + } + } + } + } + } + + if (mergeReplacements.size === 0) { + return profile; + } + + const newThreads: RawThread[] = []; + for (let i = 0; i < threads.length; i++) { + if (mergedIndexes.has(i)) { + continue; + } + const replacement = mergeReplacements.get(i); + newThreads.push(replacement ?? threads[i]); + } + + console.log( + `Matched ${mergedProcessBundles} non-overlapping process bundles. Merged ${mergedIndexes.size + mergeReplacements.size} threads into ${mergeReplacements.size}, going from ${threads.length} to ${newThreads.length} threads.` + ); + + return { ...profile, threads: newThreads }; } async function loadProfile(source: ProfileSource): Promise { @@ -94,7 +429,7 @@ async function loadProfile(source: ProfileSource): Promise { } export async function run(options: CliOptions) { - const profile = await loadProfile(options.input); + let profile = await loadProfile(options.input); if (options.symbolicateWithServer !== undefined) { const server = options.symbolicateWithServer; @@ -143,6 +478,44 @@ export async function run(options: CliOptions) { profile.meta.symbolicated = true; } + if (options.symbolicateWasm !== undefined) { + applyWasmSymbolication( + profile, + loadWasmSymbolicationSpecs(options.symbolicateWasm) + ); + } + + if (options.insertLabelFrames !== undefined) { + console.log('Inserting label frames...'); + const tomlText = fs.readFileSync(options.insertLabelFrames, 'utf8'); + const parsed = parseLabelToml(tomlText); + const funcNames = collectFuncNames(profile); + const labels = resolveAllLabels(parsed, funcNames); + profile = insertStackLabels(profile, labels); + } + + if ( + options.onlyKeepThreadsWithMarkersMatching !== undefined && + options.onlyKeepThreadsWithMarkersMatching !== '' + ) { + const before = profile.threads.length; + profile = filterThreadsByMarkerSearch( + profile, + options.onlyKeepThreadsWithMarkersMatching + ); + console.log( + `Kept ${profile.threads.length} of ${before} threads with markers matching ${JSON.stringify(options.onlyKeepThreadsWithMarkersMatching)}.` + ); + } + + if (options.mergeNonOverlappingThreadsByName) { + profile = mergeNonOverlappingThreadsByName(profile); + } + + if (options.setName !== undefined) { + profile.meta.product = options.setName; + } + console.log(`Saving profile to ${options.output}`); if (options.output.endsWith('.gz')) { fs.writeFileSync(options.output, await compress(JSON.stringify(profile))); @@ -155,6 +528,7 @@ export async function run(options: CliOptions) { export function makeOptionsFromArgv(processArgv: string[]): CliOptions { const argv = minimist(processArgv.slice(2), { alias: { i: 'input', o: 'output' }, + boolean: ['merge-non-overlapping-threads-by-name'], }); const sources: ProfileSource[] = []; @@ -191,6 +565,55 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { throw new Error('An output path must be supplied with --output / -o'); } + const symbolicateWasm: WasmSymbolicationCliSpec[] = []; + const rawWasmArg = argv['symbolicate-wasm']; + let wasmArgs: unknown[]; + if (rawWasmArg === undefined) { + wasmArgs = []; + } else if (Array.isArray(rawWasmArg)) { + wasmArgs = rawWasmArg; + } else { + wasmArgs = [rawWasmArg]; + } + for (const arg of wasmArgs) { + if (typeof arg !== 'string' || arg === '') { + throw new Error('--symbolicate-wasm requires a value'); + } + // Accept "=" if the LHS looks like a URL, otherwise treat the + // whole string as a path and infer the URL from the profile. Split on + // the last `=` so URLs containing `=` (e.g. in query strings) survive + // intact; this assumes file paths don't contain `=`. + const eqIndex = arg.lastIndexOf('='); + if (eqIndex !== -1 && /^[a-z]+:\/\//i.test(arg.slice(0, eqIndex))) { + symbolicateWasm.push({ + url: arg.slice(0, eqIndex), + path: arg.slice(eqIndex + 1), + }); + } else { + symbolicateWasm.push({ path: arg }); + } + } + + const rawMarkerArg = argv['only-keep-threads-with-markers-matching']; + let onlyKeepThreadsWithMarkersMatching: string | undefined; + if (rawMarkerArg !== undefined) { + if (typeof rawMarkerArg !== 'string' || rawMarkerArg === '') { + throw new Error( + '--only-keep-threads-with-markers-matching requires a value (use `=` syntax for values starting with `-`, e.g. --only-keep-threads-with-markers-matching=-async,-sync)' + ); + } + onlyKeepThreadsWithMarkersMatching = rawMarkerArg; + } + + const rawSetName = argv['set-name']; + let setName: string | undefined; + if (rawSetName !== undefined) { + if (typeof rawSetName !== 'string' || rawSetName === '') { + throw new Error('--set-name requires a non-empty value'); + } + setName = rawSetName; + } + return { input: sources[0], output: argv.output, @@ -199,6 +622,16 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { argv['symbolicate-with-server'] !== '' ? argv['symbolicate-with-server'] : undefined, + symbolicateWasm, + insertLabelFrames: + typeof argv['insert-label-frames'] === 'string' && + argv['insert-label-frames'] !== '' + ? argv['insert-label-frames'] + : undefined, + onlyKeepThreadsWithMarkersMatching, + mergeNonOverlappingThreadsByName: + argv['merge-non-overlapping-threads-by-name'] === true, + setName, }; } diff --git a/src/profile-logic/insert-stack-labels.ts b/src/profile-logic/insert-stack-labels.ts new file mode 100644 index 0000000000..c2dd358779 --- /dev/null +++ b/src/profile-logic/insert-stack-labels.ts @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { + IndexIntoFrameTable, + IndexIntoStackTable, + RawStackTable, + IndexIntoFuncTable, + Profile, +} from '../types/profile'; +import { + shallowCloneFrameTable, + shallowCloneFuncTable, +} from 'firefox-profiler/profile-logic/data-structures'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { updateRawThreadStacks } from 'firefox-profiler/profile-logic/profile-data'; + +export type LabelDescription = { + name: string; + funcPrefixes: string[]; +}; + +export function insertStackLabels( + profile: Profile, + labelDescriptions: LabelDescription[] +): Profile { + const labelCategory = profile.meta.categories!.length; + profile.meta.categories!.push({ + name: 'Label', + color: 'blue', + subcategories: ['Other'], + }); + + const { + funcTable: oldFuncTable, + frameTable: oldFrameTable, + stackTable: oldStackTable, + sources, + stringArray, + } = profile.shared; + const frameTable = shallowCloneFrameTable(oldFrameTable); + const funcTable = shallowCloneFuncTable(oldFuncTable); + const stringTable = StringTable.withBackingArray(stringArray); + const unaccountedLabelFrameIndex = frameTable.length; + const labelFramesStartIndex = unaccountedLabelFrameIndex + 1; + const allLabelNames = [ + 'Unaccounted', + ...labelDescriptions.map((label) => label.name), + ]; + for (let i = 0; i < allLabelNames.length; i++) { + const labelName = allLabelNames[i]; + const funcIndex = funcTable.length++; + funcTable.name[funcIndex] = stringTable.indexForString(labelName); + funcTable.resource[funcIndex] = -1; + funcTable.source[funcIndex] = null; + funcTable.lineNumber[funcIndex] = null; + funcTable.columnNumber[funcIndex] = null; + funcTable.isJS[funcIndex] = false; + funcTable.relevantForJS[funcIndex] = true; + + const frameIndex = frameTable.length++; + frameTable.func[frameIndex] = funcIndex; + frameTable.category[frameIndex] = labelCategory; + frameTable.subcategory[frameIndex] = 0; + frameTable.nativeSymbol[frameIndex] = null; + frameTable.address[frameIndex] = 0; + frameTable.inlineDepth[frameIndex] = 0; + frameTable.line[frameIndex] = null; + frameTable.column[frameIndex] = null; + frameTable.innerWindowID[frameIndex] = null; + } + + function getLabelIndexForFunc(funcIndex: IndexIntoFuncTable): number | null { + let nameString = stringArray[funcTable.name[funcIndex]]; + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex !== null) { + const filenameString = stringArray[sources.filename[sourceIndex]]; + const line = funcTable.lineNumber[funcIndex]; + const col = funcTable.columnNumber[funcIndex]; + if (line !== null && col !== null) { + nameString += ` (${filenameString}:${line}:${col})`; + } else if (line !== null) { + nameString += ` (${filenameString}:${line})`; + } else { + nameString += ` (${filenameString})`; + } + } + for ( + let labelIndex = 0; + labelIndex < labelDescriptions.length; + labelIndex++ + ) { + const labelDescription = labelDescriptions[labelIndex]; + for ( + let prefixIndex = 0; + prefixIndex < labelDescription.funcPrefixes.length; + prefixIndex++ + ) { + const funcNamePrefix = labelDescription.funcPrefixes[prefixIndex]; + if (nameString.startsWith(funcNamePrefix)) { + return labelIndex; + } + } + } + return null; + } + + const funcIndexToLabelFrameIndex = new Array(funcTable.length); + for (let funcIndex = 0; funcIndex < funcTable.length; funcIndex++) { + const labelIndex = getLabelIndexForFunc(funcIndex); + const labelFrameIndex = + labelIndex !== null ? labelFramesStartIndex + labelIndex : null; + funcIndexToLabelFrameIndex[funcIndex] = labelFrameIndex; + } + + const labelFrameIndexToInsertAtStack = new Array( + oldStackTable.length + ); + const inheritedLabelFrameIndexAtStack = new Array( + oldStackTable.length + ); + let stacksToInsertCount = 0; + for (let stackIndex = 0; stackIndex < oldStackTable.length; stackIndex++) { + const parentStackIndex = oldStackTable.prefix[stackIndex]; + const inheritedLabelFrameIndex = + parentStackIndex !== null + ? inheritedLabelFrameIndexAtStack[parentStackIndex] + : null; + const frameIndex = oldStackTable.frame[stackIndex]; + const funcIndex = oldFrameTable.func[frameIndex]; + const labelFrameIndex = funcIndexToLabelFrameIndex[funcIndex]; + if ( + labelFrameIndex !== null && + labelFrameIndex !== inheritedLabelFrameIndex + ) { + labelFrameIndexToInsertAtStack[stackIndex] = labelFrameIndex; + inheritedLabelFrameIndexAtStack[stackIndex] = labelFrameIndex; + stacksToInsertCount++; + } else if ( + funcTable.isJS[funcIndex] || + funcTable.relevantForJS[funcIndex] + ) { + labelFrameIndexToInsertAtStack[stackIndex] = null; + inheritedLabelFrameIndexAtStack[stackIndex] = null; + } else if (parentStackIndex === null) { + labelFrameIndexToInsertAtStack[stackIndex] = unaccountedLabelFrameIndex; + inheritedLabelFrameIndexAtStack[stackIndex] = unaccountedLabelFrameIndex; + stacksToInsertCount++; + } else { + labelFrameIndexToInsertAtStack[stackIndex] = null; + inheritedLabelFrameIndexAtStack[stackIndex] = inheritedLabelFrameIndex; + } + } + + const newStackCount = oldStackTable.length + stacksToInsertCount; + const newPrefixCol = new Array(newStackCount); + const newFrameCol = new Array(newStackCount); + const oldStackToNewStackPlusOne = new Int32Array(oldStackTable.length); + let nextNewStackIndex = 0; + for ( + let oldStackIndex = 0; + oldStackIndex < oldStackTable.length; + oldStackIndex++ + ) { + const labelFrameIndexToInsert = + labelFrameIndexToInsertAtStack[oldStackIndex]; + const oldPrefix = oldStackTable.prefix[oldStackIndex]; + let newPrefix = + oldPrefix !== null ? oldStackToNewStackPlusOne[oldPrefix] - 1 : null; + const frameIndex = oldStackTable.frame[oldStackIndex]; + if (labelFrameIndexToInsert !== null) { + const insertedStackIndex = nextNewStackIndex++; + newPrefixCol[insertedStackIndex] = newPrefix; + newFrameCol[insertedStackIndex] = labelFrameIndexToInsert; + newPrefix = insertedStackIndex; + } + const newStackIndex = nextNewStackIndex++; + newPrefixCol[newStackIndex] = newPrefix; + newFrameCol[newStackIndex] = frameIndex; + oldStackToNewStackPlusOne[oldStackIndex] = newStackIndex + 1; + } + + if (nextNewStackIndex !== newStackCount) { + console.error('Unexpected new stack count!', { + nextNewStackIndex, + newStackCount, + stacksToInsertCount, + }); + } + + const stackTable: RawStackTable = { + prefix: newPrefixCol, + frame: newFrameCol, + length: newStackCount, + }; + + const newShared = { ...profile.shared, stackTable, frameTable, funcTable }; + const newThreads = updateRawThreadStacks(profile.threads, (oldStack) => + oldStack !== null ? oldStackToNewStackPlusOne[oldStack] - 1 : null + ); + + return { + ...profile, + shared: newShared, + threads: newThreads, + }; +} diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index 269fc4fc46..6a87f013b4 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -1275,6 +1275,16 @@ function combineSamplesForMerging(threads: RawThread[]): RawSamplesTable { threadId: newThreadId, }; + // If every source thread has threadCPUDelta, carry the per-sample values + // through unchanged. For non-overlapping inputs the resulting deltas remain + // meaningful; for overlapping inputs the values are nonsensical but harmless + // (still numerically valid). + const allHaveThreadCPUDelta = samplesPerThread.every( + (s) => s.threadCPUDelta !== undefined + ); + const newThreadCPUDelta: Array | undefined = + allHaveThreadCPUDelta ? [] : undefined; + while (true) { let earliestNextSampleThreadIndex: number | null = null; let earliestNextSampleTime = Infinity; @@ -1324,11 +1334,21 @@ function combineSamplesForMerging(threads: RawThread[]): RawSamplesTable { ? sourceThreadSamples.threadId[sourceThreadSampleIndex] : threads[sourceThreadIndex].tid ); + if (newThreadCPUDelta !== undefined) { + newThreadCPUDelta.push( + ensureExists(sourceThreadSamples.threadCPUDelta)[ + sourceThreadSampleIndex + ] + ); + } newSamples.length++; nextSampleIndexPerThread[sourceThreadIndex]++; } + if (newThreadCPUDelta !== undefined) { + return { ...newSamples, threadCPUDelta: newThreadCPUDelta }; + } return newSamples; } diff --git a/src/profile-logic/wasm-symbolication.ts b/src/profile-logic/wasm-symbolication.ts new file mode 100644 index 0000000000..510a68f50b --- /dev/null +++ b/src/profile-logic/wasm-symbolication.ts @@ -0,0 +1,206 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Applies wasm symbols to a profile after the fact. This is useful for +// profiles captured against a stripped wasm bundle: when the loaded wasm +// has no "name" custom section, Firefox synthesizes function names of the +// shape `wasm-function[123]`. Given an unstripped copy of the same wasm, +// we can recover real names from its name section and replace those +// placeholders in the profile's funcTable. + +import type { Profile } from 'firefox-profiler/types/profile'; +import { StringTable } from 'firefox-profiler/utils/string-table'; + +export interface WasmSymbolicationSpec { + // Raw bytes of an unstripped .wasm file containing a "name" custom section. + bytes: Uint8Array; + // The wasm URL in the profile to apply names to. If undefined, the profile + // must contain exactly one .wasm source and that source is used. + url?: string; + // Human-readable label (e.g. file path) used only for diagnostic messages. + label?: string; +} + +// Parses the function-name subsection of a wasm "name" custom section and +// returns a map from function index to name. Returns an empty map if the +// module has no name section. The function index space includes imports +// (imports come first) — same numbering used in `wasm-function[N]` strings. +export function parseWasmFunctionNames(bytes: Uint8Array): Map { + if ( + bytes.length < 8 || + bytes[0] !== 0x00 || + bytes[1] !== 0x61 || + bytes[2] !== 0x73 || + bytes[3] !== 0x6d + ) { + throw new Error('Not a wasm file (bad magic)'); + } + + let pos = 8; + const decoder = new TextDecoder('utf-8'); + + const readVarUint = (): number => { + let result = 0; + let shift = 0; + while (true) { + if (pos >= bytes.length) { + throw new Error('Unexpected end of wasm'); + } + const byte = bytes[pos++]; + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) { + return result >>> 0; + } + shift += 7; + if (shift >= 32) { + throw new Error('varuint too large'); + } + } + }; + + const readBytes = (n: number): Uint8Array => { + const out = bytes.subarray(pos, pos + n); + pos += n; + return out; + }; + + const readName = (): string => { + const len = readVarUint(); + return decoder.decode(readBytes(len)); + }; + + while (pos < bytes.length) { + const sectionId = bytes[pos++]; + const sectionSize = readVarUint(); + const sectionEnd = pos + sectionSize; + if (sectionId !== 0) { + pos = sectionEnd; + continue; + } + const sectionName = readName(); + if (sectionName !== 'name') { + pos = sectionEnd; + continue; + } + + const result = new Map(); + while (pos < sectionEnd) { + const subId = bytes[pos++]; + const subSize = readVarUint(); + const subEnd = pos + subSize; + if (subId === 1) { + const count = readVarUint(); + for (let i = 0; i < count; i++) { + const funcIdx = readVarUint(); + const name = readName(); + result.set(funcIdx, name); + } + } + pos = subEnd; + } + return result; + } + + return new Map(); +} + +const WASM_FUNCTION_NAME_RE = /^wasm-function\[(\d+)\]$/; + +// Renames `wasm-function[N]` entries in the profile's funcTable to the +// symbolicated names found in each spec's `bytes`. Mutates `profile.shared` +// in place. Throws if a spec's URL cannot be resolved or if a wasm has no +// name section. +export function applyWasmSymbolication( + profile: Profile, + specs: WasmSymbolicationSpec[] +): void { + if (specs.length === 0) { + return; + } + + const { sources, funcTable, stringArray } = profile.shared; + const stringTable = StringTable.withBackingArray(stringArray); + + // Build url -> source-index map from the profile. + const sourceIndicesByUrl = new Map(); + const wasmUrlsInProfile: string[] = []; + for (let s = 0; s < sources.length; s++) { + const filenameIdx = sources.filename[s]; + if (filenameIdx === null) { + continue; + } + const url = stringArray[filenameIdx]; + if (!url.endsWith('.wasm')) { + continue; + } + wasmUrlsInProfile.push(url); + let arr = sourceIndicesByUrl.get(url); + if (arr === undefined) { + arr = []; + sourceIndicesByUrl.set(url, arr); + } + arr.push(s); + } + + for (const spec of specs) { + const tag = spec.label ?? spec.url ?? ''; + let url = spec.url; + if (url === undefined) { + if (wasmUrlsInProfile.length === 0) { + throw new Error( + `${tag}: profile contains no .wasm sources to apply symbols to` + ); + } + const unique = new Set(wasmUrlsInProfile); + if (unique.size !== 1) { + throw new Error( + `${tag}: profile contains multiple wasm URLs ` + + `(${[...unique].join(', ')}). Specify which one explicitly.` + ); + } + url = [...unique][0]; + } + + const sourceIndices = sourceIndicesByUrl.get(url); + if (sourceIndices === undefined) { + throw new Error(`${tag}: no source with URL "${url}" in profile`); + } + + const namesByIndex = parseWasmFunctionNames(spec.bytes); + if (namesByIndex.size === 0) { + throw new Error( + `${tag} has no function names — is this an unstripped wasm file?` + ); + } + + const sourceIndexSet = new Set(sourceIndices); + let updated = 0; + let missingNames = 0; + for (let f = 0; f < funcTable.length; f++) { + const sourceIdx = funcTable.source[f]; + if (sourceIdx === null || !sourceIndexSet.has(sourceIdx)) { + continue; + } + const oldName = stringArray[funcTable.name[f]]; + const m = WASM_FUNCTION_NAME_RE.exec(oldName); + if (m === null) { + continue; + } + const wasmFuncIndex = Number(m[1]); + const newName = namesByIndex.get(wasmFuncIndex); + if (newName === undefined) { + missingNames++; + continue; + } + funcTable.name[f] = stringTable.indexForString(newName); + updated++; + } + console.log( + `Renamed ${updated} wasm function(s) for ${url}` + + (missingNames > 0 + ? ` (${missingNames} index(es) had no name in the wasm file)` + : '') + ); + } +} diff --git a/src/test/fixtures/wasm/README.md b/src/test/fixtures/wasm/README.md new file mode 100644 index 0000000000..7af3d83e6d --- /dev/null +++ b/src/test/fixtures/wasm/README.md @@ -0,0 +1,16 @@ +# Wasm test fixtures + +Fixtures used by `src/test/unit/wasm-symbolication.test.ts`. + +`named.wasm` is generated from `named.wat` with [wabt](https://github.com/WebAssembly/wabt): + +``` +wat2wasm --debug-names src/test/fixtures/wasm/named.wat -o src/test/fixtures/wasm/named.wasm +``` + +The `--debug-names` flag is required so the resulting binary contains a +`name` custom section with function names; without it, the parser would +have nothing to extract. + +Both files are committed so the tests don't depend on `wabt` being +installed locally. diff --git a/src/test/fixtures/wasm/named.wasm b/src/test/fixtures/wasm/named.wasm new file mode 100644 index 0000000000..802157a5ee Binary files /dev/null and b/src/test/fixtures/wasm/named.wasm differ diff --git a/src/test/fixtures/wasm/named.wat b/src/test/fixtures/wasm/named.wat new file mode 100644 index 0000000000..9a27ea67c4 --- /dev/null +++ b/src/test/fixtures/wasm/named.wat @@ -0,0 +1,19 @@ +;; Tiny wasm module used as a test fixture for parseWasmFunctionNames / +;; applyWasmSymbolication. The function index space starts with imports, +;; so the indices recorded in the "name" custom section are: +;; 0 -> log (imported) +;; 1 -> add +;; 2 -> sub +;; This is the same numbering Firefox uses for `wasm-function[N]`. +(module + (import "env" "log" (func $log (param i32))) + (func $add (param i32 i32) (result i32) + local.get 0 + local.get 1 + i32.add) + (func $sub (param i32 i32) (result i32) + local.get 0 + local.get 1 + i32.sub) + (export "add" (func $add)) + (export "sub" (func $sub))) diff --git a/src/test/unit/insert-stack-labels.test.ts b/src/test/unit/insert-stack-labels.test.ts new file mode 100644 index 0000000000..a3dcc93486 --- /dev/null +++ b/src/test/unit/insert-stack-labels.test.ts @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { insertStackLabels } from '../../profile-logic/insert-stack-labels'; +import { callTreeFromProfile, formatTree } from '../fixtures/utils'; + +describe('insertStackLabels', function () { + it('inserts a label before the first frame matching a label', function () { + const { profile } = getProfileFromTextSamples(` + A + B + mozilla::layout::Reflow + mozilla::layout::FrameReflow + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['mozilla::layout::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - B (total: 1, self: —)', + ' - Layout (total: 1, self: —)', + ' - mozilla::layout::Reflow (total: 1, self: —)', + ' - mozilla::layout::FrameReflow (total: 1, self: 1)', + ]); + }); + + it('does not repeat the label for consecutive frames in the same label', function () { + const { profile } = getProfileFromTextSamples(` + A + gfx::Composite + gfx::LayerManager::Render + gfx::DrawQuad + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: —)', + ' - gfx::LayerManager::Render (total: 1, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ]); + }); + + it('does not re-insert the label when re-entering a label after an unmatched frame', function () { + // A non-JS, non-label frame (helper) propagates the inherited label context, + // so gfx::DrawQuad is still considered inside the Graphics label and no new + // label is inserted. + const { profile } = getProfileFromTextSamples(` + gfx::Composite + helper + gfx::DrawQuad + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: —)', + ' - helper (total: 1, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ]); + }); + + it('inserts the right label when multiple labels are configured', function () { + const { profile } = getProfileFromTextSamples(` + A A + layout::Reflow gfx::Composite + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Unaccounted (total: 2, self: —)', + ' - A (total: 2, self: —)', + ' - Layout (total: 1, self: —)', + ' - layout::Reflow (total: 1, self: 1)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: 1)', + ]); + }); + + it('inserts a new label when switching from one label to another', function () { + const { profile } = getProfileFromTextSamples(` + layout::Reflow + gfx::Composite + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Layout (total: 1, self: —)', + ' - layout::Reflow (total: 1, self: —)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: 1)', + ]); + }); + + it('wraps unmatched root frames in an Unaccounted label', function () { + const { profile } = getProfileFromTextSamples(` + A + B + C + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - B (total: 1, self: —)', + ' - C (total: 1, self: 1)', + ]); + }); + + it('handles multiple samples sharing common prefixes correctly', function () { + const { profile } = getProfileFromTextSamples(` + A A A + B B B + gfx::Composite gfx::Composite C + gfx::DrawQuad gfx::LayerManager::Render + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Unaccounted (total: 3, self: —)', + ' - A (total: 3, self: —)', + ' - B (total: 3, self: —)', + ' - Graphics (total: 2, self: —)', + ' - gfx::Composite (total: 2, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ' - gfx::LayerManager::Render (total: 1, self: 1)', + ' - C (total: 1, self: 1)', + ]); + }); +}); diff --git a/src/test/unit/profile-insert-labels.test.ts b/src/test/unit/profile-insert-labels.test.ts new file mode 100644 index 0000000000..bf314853e5 --- /dev/null +++ b/src/test/unit/profile-insert-labels.test.ts @@ -0,0 +1,810 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + applyModifier, + expandPattern, + reverseModifier, + reverseBlinkSnake, + compilePatternToRegex, + discoverAutoLabels, + resolveAllLabels, + parseLabelToml, +} from 'firefox-profiler/utils/label-templates'; +import type { AutoLabel } from 'firefox-profiler/utils/label-templates'; +import { insertStackLabels } from 'firefox-profiler/profile-logic/insert-stack-labels'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { callTreeFromProfile, formatTree } from '../fixtures/utils'; + +// --------------------------------------------------------------------------- +// applyModifier +// --------------------------------------------------------------------------- + +describe('applyModifier', function () { + describe(':pascal', function () { + it('uppercases the first letter of a camelCase name', function () { + expect(applyModifier('querySelector', 'pascal')).toBe('QuerySelector'); + }); + + it('handles multi-word camelCase', function () { + expect(applyModifier('addEventListener', 'pascal')).toBe( + 'AddEventListener' + ); + }); + + it('preserves interior uppercase (innerHTML)', function () { + expect(applyModifier('innerHTML', 'pascal')).toBe('InnerHTML'); + }); + + it('handles a single lowercase letter', function () { + expect(applyModifier('fill', 'pascal')).toBe('Fill'); + }); + }); + + describe(':blink_snake', function () { + it('lowercases a simple PascalCase word', function () { + expect(applyModifier('Element', 'blink_snake')).toBe('element'); + }); + + it('handles HTML* class names', function () { + expect(applyModifier('HTMLInputElement', 'blink_snake')).toBe( + 'html_input_element' + ); + }); + + it('handles DOM* class names', function () { + expect(applyModifier('DOMTokenList', 'blink_snake')).toBe( + 'dom_token_list' + ); + }); + + it('handles CSS* class names', function () { + expect(applyModifier('CSSStyleSheet', 'blink_snake')).toBe( + 'css_style_sheet' + ); + }); + + it('handles compound PascalCase names', function () { + expect(applyModifier('DocumentFragment', 'blink_snake')).toBe( + 'document_fragment' + ); + expect(applyModifier('EventTarget', 'blink_snake')).toBe('event_target'); + expect(applyModifier('ShadowRoot', 'blink_snake')).toBe('shadow_root'); + }); + + it('handles all-uppercase acronyms', function () { + expect(applyModifier('URL', 'blink_snake')).toBe('url'); + expect(applyModifier('DOMParser', 'blink_snake')).toBe('dom_parser'); + }); + + it('keeps a mixed-case special token together when listed', function () { + expect( + applyModifier('WebGLRenderingContext', 'blink_snake', ['WebGL']) + ).toBe('webgl_rendering_context'); + expect(applyModifier('XPathEvaluator', 'blink_snake', ['XPath'])).toBe( + 'xpath_evaluator' + ); + }); + + it('keeps a digit-bearing special token together when listed', function () { + expect( + applyModifier('WebGL2RenderingContext', 'blink_snake', ['WebGL2']) + ).toBe('webgl2_rendering_context'); + }); + + it('prefers the longer of two prefix-overlapping special tokens', function () { + // WebGL2 must match before WebGL, regardless of list order. + expect( + applyModifier('WebGL2RenderingContext', 'blink_snake', [ + 'WebGL', + 'WebGL2', + ]) + ).toBe('webgl2_rendering_context'); + expect( + applyModifier('WebGLRenderingContext', 'blink_snake', [ + 'WebGL', + 'WebGL2', + ]) + ).toBe('webgl_rendering_context'); + }); + }); + + it('returns the value unchanged when no modifier is given', function () { + expect(applyModifier('querySelector', undefined)).toBe('querySelector'); + expect(applyModifier('Element', undefined)).toBe('Element'); + }); + + it('throws on an unknown modifier', function () { + expect(() => applyModifier('foo', 'upper')).toThrow( + 'Unknown template modifier: upper' + ); + }); +}); + +// --------------------------------------------------------------------------- +// expandPattern +// --------------------------------------------------------------------------- + +describe('expandPattern', function () { + it('substitutes a plain variable', function () { + expect( + expandPattern('mozilla::dom::{Class}_Binding::{method}(', { + Class: 'Element', + method: 'querySelector', + }) + ).toBe('mozilla::dom::Element_Binding::querySelector('); + }); + + it('applies :pascal to the variable value', function () { + expect( + expandPattern('v8_{Class:blink_snake}::{method:pascal}Operation', { + Class: 'Element', + method: 'querySelector', + }) + ).toBe('v8_element::QuerySelectorOperation'); + }); + + it('applies :blink_snake to the variable value', function () { + expect( + expandPattern('v8_{Class:blink_snake}::Callback', { + Class: 'HTMLInputElement', + }) + ).toBe('v8_html_input_element::Callback'); + }); + + it('leaves unrelated text intact', function () { + expect(expandPattern('no_vars_here', {})).toBe('no_vars_here'); + }); + + it('throws when a referenced variable is not provided', function () { + expect(() => + expandPattern('{Class}_Binding::{method}(', { Class: 'Element' }) + ).toThrow('Template variable "method" not provided'); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: auto_labels → insertStackLabels → formatTree +// +// resolveAllLabels-driven coverage for each engine's func-name shape. Each +// test seeds funcs from one engine and confirms auto-discovery synthesizes +// a label whose forward-expanded prefixes match across all engines. +// --------------------------------------------------------------------------- + +const DOM_OPERATION: AutoLabel = { + label: '{Class}.{method}', + patterns: [ + 'mozilla::dom::{Class}_Binding::{method}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation', + 'WebCore::js{Class}PrototypeFunction_{method}', + ], +}; + +const CANVAS2D_OPERATION: AutoLabel = { + label: 'CanvasRenderingContext2D.{method}', + patterns: [ + 'mozilla::dom::CanvasRenderingContext2D_Binding::{method}(', + 'mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::{method}(', + "blink::`anonymous namespace'::v8_canvas_rendering_context_2d::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_canvas_rendering_context_2d::{method:pascal}Operation', + 'blink::Canvas2DRecorderContext::{method}(', + 'WebCore::jsCanvasRenderingContext2DPrototypeFunction_{method}(', + ], +}; + +describe('auto_labels integration with insertStackLabels', function () { + it('matches Gecko-style function names generated from a dom_operation template', function () { + const { profile } = getProfileFromTextSamples(` + A + mozilla::dom::Element_Binding::querySelector(elem) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::querySelector(elem)'] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::querySelector(elem) (total: 1, self: 1)', + ]); + }); + + it('matches both anonymous-namespace forms for Blink', function () { + const { profile } = getProfileFromTextSamples(` + blink::\`anonymous namespace'::v8_element::QuerySelectorOperation + blink::(anonymous namespace)::v8_element::QuerySelectorOperation + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + [ + "blink::`anonymous namespace'::v8_element::QuerySelectorOperation", + 'blink::(anonymous namespace)::v8_element::QuerySelectorOperation', + ] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Element.querySelector (total: 1, self: —)', + " - blink::`anonymous namespace'::v8_element::QuerySelectorOperation (total: 1, self: —)", + ' - blink::(anonymous namespace)::v8_element::QuerySelectorOperation (total: 1, self: 1)', + ]); + }); + + it('matches WebKit-style function names', function () { + const { profile } = getProfileFromTextSamples(` + A + WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + ['WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*)'] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*) (total: 1, self: 1)', + ]); + }); + + it('canvas2d_operation matches Gecko, Offscreen, Blink, and WebKit variants', function () { + const { profile } = getProfileFromTextSamples(` + mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args) + mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args) + blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation + WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [CANVAS2D_OPERATION], + blinkSpecialTokens: [], + }, + [ + 'mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args)', + 'mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args)', + 'blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation', + 'WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx)', + ] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- CanvasRenderingContext2D.fillRect (total: 1, self: —)', + ' - mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args) (total: 1, self: —)', + ' - mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args) (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation (total: 1, self: —)', + ' - WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx) (total: 1, self: 1)', + ]); + }); + + it('merges an explicit `[[labels]]` entry with the auto-discovered prefixes when names collide', function () { + // The blink::bindings::PerformAttributeSetCEReactionsReflectTypeString + // frame is the generic className-setter runtime — auto-discovery can't + // synthesize it from any template. The explicit `[[labels]]` block adds + // it on top of the auto-discovered "set Element.className" prefixes. + const { profile } = getProfileFromTextSamples(` + blink::bindings::PerformAttributeSetCEReactionsReflectTypeString + mozilla::dom::Element_Binding::set_className(args) + `); + + const DOM_SETTER: AutoLabel = { + label: 'set {Class}.{prop}', + patterns: [ + 'mozilla::dom::{Class}_Binding::set_{prop}(', + ], + }; + + const labels = resolveAllLabels( + { + labels: [ + { + name: 'set Element.className', + funcPrefixes: [ + 'blink::bindings::PerformAttributeSetCEReactionsReflectTypeString', + ], + }, + ], + autoLabels: [DOM_SETTER], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_className(args)'] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- set Element.className (total: 1, self: —)', + ' - blink::bindings::PerformAttributeSetCEReactionsReflectTypeString (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::set_className(args) (total: 1, self: 1)', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// :blink_snake digit-boundary handling +// --------------------------------------------------------------------------- + +describe(':blink_snake digit handling', function () { + it('separates trailing digit acronyms (Context2D)', function () { + expect(applyModifier('CanvasRenderingContext2D', 'blink_snake')).toBe( + 'canvas_rendering_context_2d' + ); + }); + + it('keeps a digit-then-uppercase acronym joined when no lowercase follows', function () { + expect(applyModifier('Canvas2D', 'blink_snake')).toBe('canvas_2d'); + }); + + it('splits a digit-then-uppercase pair when followed by lowercase', function () { + expect(applyModifier('WebGL2RenderingContext', 'blink_snake')).toBe( + 'web_gl_2_rendering_context' + ); + }); +}); + +// --------------------------------------------------------------------------- +// reverseSnake / reverseModifier +// --------------------------------------------------------------------------- + +describe('reverseBlinkSnake', function () { + it('reverses a single Pascal word', function () { + expect(reverseBlinkSnake('element')).toBe('Element'); + }); + + it('reverses a multi-word PascalCase name', function () { + expect(reverseBlinkSnake('document_fragment')).toBe('DocumentFragment'); + expect(reverseBlinkSnake('event_target')).toBe('EventTarget'); + }); + + it('uses the special-tokens list to recover all-uppercase fragments', function () { + expect(reverseBlinkSnake('html_element', ['HTML'])).toBe('HTMLElement'); + expect(reverseBlinkSnake('html_image_element', ['HTML'])).toBe( + 'HTMLImageElement' + ); + expect(reverseBlinkSnake('css_style_sheet', ['CSS'])).toBe('CSSStyleSheet'); + expect(reverseBlinkSnake('xml_http_request', ['XML'])).toBe( + 'XMLHttpRequest' + ); + }); + + it('uses the special-tokens list to recover mixed-case fragments', function () { + expect(reverseBlinkSnake('webgl_rendering_context', ['WebGL'])).toBe( + 'WebGLRenderingContext' + ); + expect(reverseBlinkSnake('webgl2_rendering_context', ['WebGL2'])).toBe( + 'WebGL2RenderingContext' + ); + expect(reverseBlinkSnake('xpath_evaluator', ['XPath'])).toBe( + 'XPathEvaluator' + ); + }); + + it('without a special-tokens list, falls back to first-letter capitalization', function () { + // This is the lossy case: HtmlElement and HTMLElement both snake to the + // same string, and without special tokens we can't recover the original. + expect(reverseBlinkSnake('html_element')).toBe('HtmlElement'); + }); + + it('handles trailing digit-bearing tokens', function () { + expect(reverseBlinkSnake('canvas_rendering_context_2d', ['2D'])).toBe( + 'CanvasRenderingContext2D' + ); + expect(reverseBlinkSnake('canvas_2d', ['2D'])).toBe('Canvas2D'); + }); + + it('handles multiple tokens separated by Pascal words', function () { + // The special-tokens list applies independently at each segment; here + // HTML is recovered, then `dom` becomes `Dom` (no DOM in the list), then + // URL is recovered. (Two adjacent special tokens cannot be expressed in + // snake form — `HTMLDOMParser` snakes to `htmldom_parser`, not + // `html_dom_parser`.) + expect(reverseBlinkSnake('html_dom_event_url', ['HTML', 'URL'])).toBe( + 'HTMLDomEventURL' + ); + }); + + it('handles a trailing special token with no follow-up segment', function () { + expect(reverseBlinkSnake('parse_url', ['URL'])).toBe('ParseURL'); + }); + + it('does case-insensitive matching against the special-tokens list', function () { + expect(reverseBlinkSnake('html_element', ['html'])).toBe('htmlElement'); + }); +}); + +describe('reverseModifier', function () { + it('reverses :pascal by lowercasing the first letter', function () { + expect(reverseModifier('QuerySelector', 'pascal')).toBe('querySelector'); + expect(reverseModifier('Fill', 'pascal')).toBe('fill'); + }); + + it('reverses :blink_snake using the special-tokens list', function () { + expect(reverseModifier('html_input_element', 'blink_snake', ['HTML'])).toBe( + 'HTMLInputElement' + ); + }); + + it('returns the value unchanged when no modifier is given', function () { + expect(reverseModifier('Element', undefined)).toBe('Element'); + }); + + it('throws on an unknown modifier', function () { + expect(() => reverseModifier('foo', 'upper')).toThrow( + 'Unknown template modifier: upper' + ); + }); +}); + +// --------------------------------------------------------------------------- +// compilePatternToRegex +// --------------------------------------------------------------------------- + +describe('compilePatternToRegex', function () { + it('compiles a Mozilla-style operation pattern', function () { + const { regex, vars } = compilePatternToRegex( + 'mozilla::dom::{Class}_Binding::{method}(' + ); + expect(vars).toEqual([ + { name: 'Class', modifier: undefined }, + { name: 'method', modifier: undefined }, + ]); + const m = 'mozilla::dom::Element_Binding::querySelector(args)'.match(regex); + expect(m).not.toBeNull(); + expect(m![1]).toBe('Element'); + expect(m![2]).toBe('querySelector'); + }); + + it('compiles a Blink-style pattern with :blink_snake and :pascal', function () { + const { regex, vars } = compilePatternToRegex( + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation' + ); + expect(vars).toEqual([ + { name: 'Class', modifier: 'blink_snake' }, + { name: 'method', modifier: 'pascal' }, + ]); + const m = + 'blink::(anonymous namespace)::v8_html_image_element::SetSrcOperation'.match( + regex + ); + expect(m).not.toBeNull(); + expect(m![1]).toBe('html_image_element'); + expect(m![2]).toBe('SetSrc'); + }); + + it('compiles a WebKit-style pattern', function () { + const { regex } = compilePatternToRegex( + 'WebCore::js{Class}PrototypeFunction_{method}' + ); + const m = 'WebCore::jsElementPrototypeFunction_querySelector(args)'.match( + regex + ); + expect(m).not.toBeNull(); + expect(m![1]).toBe('Element'); + expect(m![2]).toBe('querySelector'); + }); + + it('refuses to match when the literal text does not appear', function () { + const { regex } = compilePatternToRegex( + 'mozilla::dom::{Class}_Binding::{method}(' + ); + expect('mozilla::dom::Element_Other::foo('.match(regex)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// discoverAutoLabels / resolveAllLabels +// --------------------------------------------------------------------------- + +const DOM_OPERATION_AUTO: AutoLabel = { + label: '{Class}.{method}', + patterns: [ + 'mozilla::dom::{Class}_Binding::{method}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation', + 'WebCore::js{Class}PrototypeFunction_{method}', + ], +}; + +const DOM_SETTER_AUTO: AutoLabel = { + label: 'set {Class}.{prop}', + patterns: [ + 'mozilla::dom::{Class}_Binding::set_{prop}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{prop:pascal}AttributeSetCallback", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{prop:pascal}AttributeSetCallback', + 'WebCore::setJS{Class}_{prop}(', + ], +}; + +describe('discoverAutoLabels', function () { + it('discovers a Mozilla-style operation', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::querySelector(args)'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('Element.querySelector'); + expect(labels[0].funcPrefixes).toContain( + 'mozilla::dom::Element_Binding::querySelector(' + ); + expect(labels[0].funcPrefixes).toContain( + 'WebCore::jsElementPrototypeFunction_querySelector' + ); + expect(labels[0].funcPrefixes).toContain( + 'blink::(anonymous namespace)::v8_element::QuerySelectorOperation' + ); + }); + + it('discovers a Blink-style operation, recovering Class via the special-tokens list', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: ['HTML'], + }, + ['blink::(anonymous namespace)::v8_html_image_element::ClickOperation'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('HTMLImageElement.click'); + // forward-expanded prefixes for all engines: + expect(labels[0].funcPrefixes).toEqual([ + 'mozilla::dom::HTMLImageElement_Binding::click(', + "blink::`anonymous namespace'::v8_html_image_element::ClickOperation", + 'blink::(anonymous namespace)::v8_html_image_element::ClickOperation', + 'WebCore::jsHTMLImageElementPrototypeFunction_click', + ]); + }); + + it('dedupes when the same (Class, method) is observed in multiple engine forms', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + [ + 'mozilla::dom::Element_Binding::querySelector(args)', + 'WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*)', + ] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('Element.querySelector'); + }); + + it('discovers a setter using a separate auto_labels entry', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_SETTER_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_id(args)'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('set Element.id'); + }); + + it('rejects matches whose round-trip does not agree with the observed name', function () { + // funcName has Class part that is NOT a valid PascalCase identifier under + // our regex (starts lowercase), so there should be no match. + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::element_Binding::querySelector(args)'] + ); + expect(labels).toHaveLength(0); + }); + + it('does not match a binding setter as a dom_operation method', function () { + // `{method}` (camelCase, no modifier) must not swallow the `set_` of + // `set_innerHTML`, otherwise dom_operation would synthesize a stray + // "Element.set_innerHTML" label alongside dom_setter's "set Element.innerHTML". + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO, DOM_SETTER_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_innerHTML(args)'] + ); + expect(labels.map((l) => l.name)).toEqual(['set Element.innerHTML']); + }); +}); + +describe('resolveAllLabels', function () { + it('merges an explicit `[[labels]]` block into the auto-discovered prefixes when names collide', function () { + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["HTML"] + +[[labels]] +name = "Element.querySelector" +funcPrefixes = ["extra::prefix"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "WebCore::js{Class}PrototypeFunction_{method}", +] +`); + const labels = resolveAllLabels(parsed, [ + 'mozilla::dom::Element_Binding::querySelector(args)', + 'mozilla::dom::Element_Binding::getAttribute(args)', + ]); + + const byName = new Map(labels.map((l) => [l.name, l])); + // querySelector: auto-discovered prefixes plus the extra one from [[labels]] + expect(byName.get('Element.querySelector')!.funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::querySelector(', + 'WebCore::jsElementPrototypeFunction_querySelector', + 'extra::prefix', + ]); + // getAttribute: pure auto-discovered, no explicit override + expect(byName.get('Element.getAttribute')!.funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::getAttribute(', + 'WebCore::jsElementPrototypeFunction_getAttribute', + ]); + }); + + it('keeps an explicit-only label whose name does not collide with any auto-discovered one', function () { + const parsed = parseLabelToml(` +[[labels]] +name = "GC" +funcPrefixes = ["js::gc::GCRuntime::collect("] +`); + const labels = resolveAllLabels(parsed, []); + expect(labels).toEqual([ + { name: 'GC', funcPrefixes: ['js::gc::GCRuntime::collect('] }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end auto-discovery → insertStackLabels +// --------------------------------------------------------------------------- + +describe('auto_labels end-to-end', function () { + it('synthesizes a label from an observed Mozilla func and inserts it', function () { + const { profile } = getProfileFromTextSamples(` + A + mozilla::dom::Element_Binding::querySelector(args) + `); + + const parsed = parseLabelToml(` +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::querySelector(args) (total: 1, self: 1)', + ]); + }); + + it('synthesizes a label for HTMLImageElement from a Blink-only profile using the special-tokens list', function () { + const { profile } = getProfileFromTextSamples(` + A + blink::(anonymous namespace)::v8_html_image_element::ClickOperation + `); + + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["HTML"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - HTMLImageElement.click (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_html_image_element::ClickOperation (total: 1, self: 1)', + ]); + }); + + it('synthesizes a label for WebGL2RenderingContext using a mixed-case special token', function () { + // Blink's NameStyleConverter snakes `WebGL2RenderingContext` to + // `webgl2_rendering_context` (one token `WebGL2`), not + // `web_gl_2_rendering_context`. With `WebGL2` in the special-tokens list + // the recovery agrees with how Blink actually spells the binding name. + const { profile } = getProfileFromTextSamples(` + A + blink::(anonymous namespace)::v8_webgl2_rendering_context::DrawArraysOperation + `); + + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["WebGL2"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - WebGL2RenderingContext.drawArrays (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_webgl2_rendering_context::DrawArraysOperation (total: 1, self: 1)', + ]); + }); +}); diff --git a/src/test/unit/profiler-edit.test.ts b/src/test/unit/profiler-edit.test.ts index 82205160fa..20a83c0dad 100644 --- a/src/test/unit/profiler-edit.test.ts +++ b/src/test/unit/profiler-edit.test.ts @@ -20,6 +20,7 @@ describe('makeOptionsFromArgv', function () { }); expect(options.output).toEqual('/path/to/output.json'); expect(options.symbolicateWithServer).toBeUndefined(); + expect(options.insertLabelFrames).toBeUndefined(); }); it('recognizes -i with an http URL', function () { @@ -113,6 +114,19 @@ describe('makeOptionsFromArgv', function () { expect(options.symbolicateWithServer).toEqual('http://localhost:8001/'); }); + it('recognizes optional --insert-label-frames', function () { + const options = makeOptionsFromArgv([ + ...commonArgs, + '-i', + '/path/to/profile.json', + '-o', + '/path/to/output.json', + '--insert-label-frames', + '/path/to/labels.toml', + ]); + expect(options.insertLabelFrames).toEqual('/path/to/labels.toml'); + }); + it('throws when no input is provided', function () { expect(() => makeOptionsFromArgv([...commonArgs, '-o', '/path/to/output.json']) diff --git a/src/test/unit/wasm-symbolication.test.ts b/src/test/unit/wasm-symbolication.test.ts new file mode 100644 index 0000000000..1619da2638 --- /dev/null +++ b/src/test/unit/wasm-symbolication.test.ts @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import fs from 'fs'; + +import { + applyWasmSymbolication, + parseWasmFunctionNames, +} from '../../profile-logic/wasm-symbolication'; +import { getEmptyProfile } from '../../profile-logic/data-structures'; +import { StringTable } from '../../utils/string-table'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; + +const FIXTURE_PATH = 'src/test/fixtures/wasm/named.wasm'; + +function readFixture(): Uint8Array { + const buf = fs.readFileSync(FIXTURE_PATH); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); +} + +describe('parseWasmFunctionNames', function () { + it('extracts function names by index, with imports occupying the low indices', function () { + const names = parseWasmFunctionNames(readFixture()); + expect(names.get(0)).toBe('log'); + expect(names.get(1)).toBe('add'); + expect(names.get(2)).toBe('sub'); + expect(names.size).toBe(3); + }); + + it('throws on a non-wasm input', function () { + expect(() => + parseWasmFunctionNames(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])) + ).toThrow(/bad magic/); + }); +}); + +// Builds a profile with two funcs whose source is the given wasm URL plus a +// third unrelated JS func, then injects `wasm-function[1]` / `wasm-function[2]` +// placeholders over the first two — mimicking what Firefox produces when it +// loads a stripped wasm bundle. The text-samples helper rejects literal +// `wasm-function[N]` names (`[…]` is reserved for modifiers like `[file:…]`), +// so we let it create the funcs under stand-in names and rename them after. +function buildProfileWithWasmPlaceholders(wasmUrl: string) { + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples( + `a.js[file:${wasmUrl}] b.js[file:${wasmUrl}] c.js` + ); + const dict = funcNamesDictPerThread[0]; + const stringTable = StringTable.withBackingArray(profile.shared.stringArray); + profile.shared.funcTable.name[dict['a.js']] = + stringTable.indexForString('wasm-function[1]'); + profile.shared.funcTable.name[dict['b.js']] = + stringTable.indexForString('wasm-function[2]'); + return { profile, dict }; +} + +describe('applyWasmSymbolication', function () { + it('rewrites wasm-function[N] names in the funcTable using names from the wasm', function () { + jest.spyOn(console, 'log').mockImplementation(() => {}); + + const wasmUrl = 'http://example.com/named.wasm'; + const { profile, dict } = buildProfileWithWasmPlaceholders(wasmUrl); + + applyWasmSymbolication(profile, [ + { bytes: readFixture(), url: wasmUrl, label: 'named.wasm' }, + ]); + + const { stringArray, funcTable } = profile.shared; + expect(stringArray[funcTable.name[dict['a.js']]]).toBe('add'); + expect(stringArray[funcTable.name[dict['b.js']]]).toBe('sub'); + // The unrelated non-wasm func is untouched. + expect(stringArray[funcTable.name[dict['c.js']]]).toBe('c.js'); + }); + + it('auto-detects the wasm URL when the profile has exactly one wasm source', function () { + jest.spyOn(console, 'log').mockImplementation(() => {}); + + const { profile, dict } = buildProfileWithWasmPlaceholders( + 'http://example.com/only.wasm' + ); + + applyWasmSymbolication(profile, [{ bytes: readFixture() }]); + + expect( + profile.shared.stringArray[profile.shared.funcTable.name[dict['a.js']]] + ).toBe('add'); + }); + + it('throws when the URL cannot be resolved', function () { + const profile = getEmptyProfile(); + expect(() => + applyWasmSymbolication(profile, [ + { bytes: readFixture(), url: 'http://nope/x.wasm' }, + ]) + ).toThrow(/no source with URL/); + }); +}); diff --git a/src/utils/label-templates.ts b/src/utils/label-templates.ts new file mode 100644 index 0000000000..342dabac5b --- /dev/null +++ b/src/utils/label-templates.ts @@ -0,0 +1,426 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// --------------------------------------------------------------------------- +// Engine coupling — read this before adding modifiers +// +// Blink derives V8 binding func names from IDL by snake_case-ing the +// interface name (e.g. `HTMLCanvasElement` → `v8_html_canvas_element::...`). +// To match those frames in profiles we have to mirror Blink's exact +// snake_case algorithm — see Blink's `NameStyleConverter` +// (third_party/blink/renderer/build/scripts/blinkbuild/name_style_converter.py). +// That coupling is encoded by the `:blink_snake` modifier and its +// companion `blink_special_tokens` list in the TOML's `[global]` section, +// not by a generic `:snake` — because there is no engine-neutral snake +// convention this translation could appeal to. WebKit and Mozilla bindings +// keep PascalCase in their generated symbols, so they don't need an +// equivalent. If a future engine needs its own convention, add a new +// modifier (e.g. `:webkit_snake`) rather than overloading this one. +// --------------------------------------------------------------------------- + +import { parse as parseToml } from 'smol-toml'; + +/** + * A template-driven label produced by auto-discovery. `label` is the name + * template (e.g. "set {Class}.{prop}"), `patterns` are the per-engine + * funcPrefix templates whose vars are recovered from observed func names. + */ +export type AutoLabel = { + label: string; + patterns: string[]; +}; + +export type LabelConfig = { + name: string; + funcPrefixes?: string[]; +}; + +export type LabelDescription = { + name: string; + funcPrefixes: string[]; +}; + +export type ParsedLabelToml = { + labels: LabelConfig[]; + autoLabels: AutoLabel[]; + blinkSpecialTokens: string[]; +}; + +// --------------------------------------------------------------------------- +// Blink-style tokenization +// --------------------------------------------------------------------------- + +const BLINK_TOKEN_PATTERNS = [ + '[A-Z]?[a-z]+', + '[A-Z]+(?![a-z])', + '[0-9][Dd](?![a-z])', + '[0-9]+', +]; + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildBlinkTokenRegex(blinkSpecialTokens: string[]): RegExp { + // Sort by length desc so longer entries that share a prefix with shorter + // ones (e.g. `WebGL2` vs `WebGL`) match first. JS regex alternation picks + // the leftmost successful alternative, not the longest match. + const sorted = [...blinkSpecialTokens].sort((a, b) => b.length - a.length); + const escaped = sorted.map(escapeRegex); + return new RegExp([...escaped, ...BLINK_TOKEN_PATTERNS].join('|'), 'g'); +} + +function buildBlinkLeadingNumberTokenRegex( + blinkSpecialTokens: string[] +): RegExp | null { + const withNumbers = blinkSpecialTokens.filter((t) => /[0-9]/.test(t)); + if (withNumbers.length === 0) { + return null; + } + const sorted = [...withNumbers].sort((a, b) => b.length - a.length); + return new RegExp('^(' + sorted.map(escapeRegex).join('|') + ')', 'i'); +} + +/** + * Tokenize a name following Blink's NameStyleConverter. Special tokens + * (e.g. `HTML`, `WebGL2`, `XPath`, `2D`) are matched first as single units; + * otherwise the default patterns capture camelCase boundaries and runs of + * capitals. + */ +export function tokenizeBlinkName( + name: string, + blinkSpecialTokens: string[] +): string[] { + const tokens: string[] = []; + let remaining = name; + + // Case-insensitive leading match for digit-bearing special tokens. Lets us + // tokenize lowerCamelCase like `webgl2RenderingContext` (where the leading + // special token has been lowercased). + const leadingRe = buildBlinkLeadingNumberTokenRegex(blinkSpecialTokens); + if (leadingRe !== null) { + const m = remaining.match(leadingRe); + if (m !== null) { + tokens.push(m[1]); + remaining = remaining.slice(m[1].length); + } + } + + const tokenRe = buildBlinkTokenRegex(blinkSpecialTokens); + for (const m of remaining.matchAll(tokenRe)) { + tokens.push(m[0]); + } + + return tokens; +} + +// --------------------------------------------------------------------------- +// Forward modifiers +// --------------------------------------------------------------------------- + +export function applyModifier( + value: string, + modifier: string | undefined, + blinkSpecialTokens: string[] = [] +): string { + switch (modifier) { + case 'pascal': + return value.charAt(0).toUpperCase() + value.slice(1); + case 'blink_snake': + return tokenizeBlinkName(value, blinkSpecialTokens) + .map((t) => t.toLowerCase()) + .join('_'); + case undefined: + return value; + default: + throw new Error(`Unknown template modifier: ${modifier}`); + } +} + +export function expandPattern( + pattern: string, + vars: Record, + blinkSpecialTokens: string[] = [] +): string { + return pattern.replace( + /\{(\w+)(?::(\w+))?\}/g, + (_match, name: string, modifier: string | undefined) => { + if (!(name in vars)) { + throw new Error(`Template variable "${name}" not provided`); + } + return applyModifier(vars[name], modifier, blinkSpecialTokens); + } + ); +} + +// --------------------------------------------------------------------------- +// Reverse modifiers +// --------------------------------------------------------------------------- + +function capitalizeFirstAlpha(seg: string): string { + const idx = seg.search(/[A-Za-z]/); + if (idx < 0) { + return seg; + } + return seg.slice(0, idx) + seg.charAt(idx).toUpperCase() + seg.slice(idx + 1); +} + +/** + * Reverse a `:blink_snake`-formed string back to its PascalCase original. + * + * Lowercasing during `:blink_snake` is one-way: `html_element` could come + * from either `HtmlElement` or `HTMLElement`. The caller supplies + * `blinkSpecialTokens` — canonical-cased fragments expected in the + * original — to disambiguate. + * + * Blink's `to_upper_camel_case` only consults SPECIAL_TOKENS for the first + * token, relying on tokenization to preserve the casing of subsequent + * tokens. We extend that to every position because by the time a name has + * been emitted as snake_case in a V8 binding func name, the tokenization is + * already gone. + */ +export function reverseBlinkSnake( + value: string, + blinkSpecialTokens: string[] = [] +): string { + const tokenByLower = new Map(); + for (const t of blinkSpecialTokens) { + tokenByLower.set(t.toLowerCase(), t); + } + return value + .split('_') + .map((seg) => tokenByLower.get(seg) ?? capitalizeFirstAlpha(seg)) + .join(''); +} + +export function reverseModifier( + value: string, + modifier: string | undefined, + blinkSpecialTokens: string[] = [] +): string { + switch (modifier) { + case 'pascal': + return value.charAt(0).toLowerCase() + value.slice(1); + case 'blink_snake': + return reverseBlinkSnake(value, blinkSpecialTokens); + case undefined: + return value; + default: + throw new Error(`Unknown template modifier: ${modifier}`); + } +} + +// --------------------------------------------------------------------------- +// Pattern → regex compilation (used for auto-discovery) +// --------------------------------------------------------------------------- + +const VAR_PATTERN_RE = /\{(\w+)(?::(\w+))?\}/g; + +function regexCharClassForVar( + name: string, + modifier: string | undefined +): string { + if (modifier === 'blink_snake') { + // snake_case identifier: starts with lowercase letter or digit, may + // contain `_`-separated alnum runs. + return '[a-z][a-z0-9]*(?:_[a-z0-9]+)*'; + } + // No modifier or :pascal — matches the case-style expected at the + // expansion site. PascalCase if the var name starts with uppercase, + // camelCase otherwise. `:pascal` always emits a PascalCase result. + // Underscores are excluded from camelCase: DOM method/property names + // are camelCase, and allowing `_` would let `{method}` swallow the + // `set_`/`get_` prefix of binding setters/getters (matching + // `mozilla::dom::Element_Binding::set_innerHTML(` as method= + // `set_innerHTML` instead of leaving it for the dom_setter template). + if (modifier === 'pascal' || /^[A-Z]/.test(name)) { + return '[A-Z][A-Za-z0-9]*'; + } + return '[a-z][A-Za-z0-9]*'; +} + +export function compilePatternToRegex(pattern: string): { + regex: RegExp; + vars: Array<{ name: string; modifier: string | undefined }>; +} { + const vars: Array<{ name: string; modifier: string | undefined }> = []; + let regexStr = ''; + let lastIndex = 0; + for (const m of pattern.matchAll(VAR_PATTERN_RE)) { + regexStr += escapeRegex(pattern.slice(lastIndex, m.index)); + const name = m[1]; + const modifier = m[2] ?? undefined; + regexStr += '(' + regexCharClassForVar(name, modifier) + ')'; + vars.push({ name, modifier }); + lastIndex = m.index! + m[0].length; + } + regexStr += escapeRegex(pattern.slice(lastIndex)); + return { regex: new RegExp('^' + regexStr), vars }; +} + +// --------------------------------------------------------------------------- +// Auto-discovery +// --------------------------------------------------------------------------- + +type CompiledAutoEntry = { + auto: AutoLabel; + patterns: Array<{ + pattern: string; + regex: RegExp; + vars: Array<{ name: string; modifier: string | undefined }>; + }>; +}; + +function compileAutoLabels(parsed: ParsedLabelToml): CompiledAutoEntry[] { + return parsed.autoLabels.map((auto) => { + const patterns = auto.patterns.map((pattern) => { + const { regex, vars } = compilePatternToRegex(pattern); + return { pattern, regex, vars }; + }); + return { auto, patterns }; + }); +} + +/** + * Walk `funcNames` and synthesize a label entry for each unique + * (auto-label, recovered-vars) tuple matched by an `[[auto_labels]]` entry. + * Each entry's funcPrefixes is the forward-expansion of every pattern in + * the entry, so the label still matches across engines even if only + * one engine's funcs were observed. + */ +export function discoverAutoLabels( + parsed: ParsedLabelToml, + funcNames: Iterable +): LabelDescription[] { + const compiled = compileAutoLabels(parsed); + if (compiled.length === 0) { + return []; + } + + type Discovered = { + auto: AutoLabel; + vars: Record; + labelName: string; + }; + const discovered = new Map(); + + for (const funcName of funcNames) { + for (const { auto, patterns } of compiled) { + for (const c of patterns) { + const m = funcName.match(c.regex); + if (!m) { + continue; + } + + const vars: Record = {}; + let recoverFailed = false; + for (let i = 0; i < c.vars.length; i++) { + const { name, modifier } = c.vars[i]; + try { + vars[name] = reverseModifier( + m[i + 1], + modifier, + parsed.blinkSpecialTokens + ); + } catch { + recoverFailed = true; + break; + } + } + if (recoverFailed) { + continue; + } + + // Round-trip verification: forward-expand the same pattern with the + // recovered vars and confirm it's a prefix of the observed func name. + // Filters out cases where the reverse modifier produced something the + // forward modifier doesn't agree with (e.g. special-token mismatch). + let roundTrip: string; + try { + roundTrip = expandPattern(c.pattern, vars, parsed.blinkSpecialTokens); + } catch { + continue; + } + if (!funcName.startsWith(roundTrip)) { + continue; + } + + let labelName: string; + try { + labelName = expandPattern( + auto.label, + vars, + parsed.blinkSpecialTokens + ); + } catch { + continue; + } + + const key = auto.label + '\0' + labelName; + if (!discovered.has(key)) { + discovered.set(key, { auto, vars, labelName }); + } + break; // first matching pattern wins for this (auto, funcName) + } + } + } + + const result: LabelDescription[] = []; + for (const d of discovered.values()) { + const funcPrefixes = d.auto.patterns.map((p) => + expandPattern(p, d.vars, parsed.blinkSpecialTokens) + ); + result.push({ name: d.labelName, funcPrefixes }); + } + return result; +} + +// --------------------------------------------------------------------------- +// TOML entry points +// --------------------------------------------------------------------------- + +export function parseLabelToml(tomlText: string): ParsedLabelToml { + const data = parseToml(tomlText) as unknown as { + global?: { blink_special_tokens?: string[] }; + labels?: LabelConfig[]; + auto_labels?: AutoLabel[]; + }; + return { + labels: data.labels ?? [], + autoLabels: data.auto_labels ?? [], + blinkSpecialTokens: data.global?.blink_special_tokens ?? [], + }; +} + +/** + * Resolve `[[auto_labels]]` against `funcNames`, then merge in `[[labels]]`. + * On a name collision between an auto-discovered label and an explicit one, + * funcPrefixes are concatenated (auto first, explicit second) — explicit + * labels can extend an auto-discovered label with extra prefixes the + * template can't synthesize. + */ +export function resolveAllLabels( + parsed: ParsedLabelToml, + funcNames: Iterable +): LabelDescription[] { + const auto = discoverAutoLabels(parsed, funcNames); + + const byName = new Map(); + for (const l of auto) { + byName.set(l.name, l); + } + for (const l of parsed.labels) { + const extras = l.funcPrefixes ?? []; + const existing = byName.get(l.name); + if (existing) { + byName.set(l.name, { + name: l.name, + funcPrefixes: [...existing.funcPrefixes, ...extras], + }); + } else { + byName.set(l.name, { name: l.name, funcPrefixes: extras }); + } + } + return [...byName.values()]; +} diff --git a/yarn.lock b/yarn.lock index 1b03757811..5e6945b93f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10027,6 +10027,11 @@ smob@^1.0.0: resolved "https://registry.yarnpkg.com/smob/-/smob-1.5.0.tgz#85d79a1403abf128d24d3ebc1cdc5e1a9548d3ab" integrity sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig== +smol-toml@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.6.1.tgz#4fceb5f7c4b86c2544024ef686e12ff0983465be" + integrity sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg== + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -10201,7 +10206,16 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10331,7 +10345,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10345,6 +10359,13 @@ strip-ansi@^0.3.0: dependencies: ansi-regex "^0.2.1" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -11713,7 +11734,16 @@ workbox-window@7.4.0, workbox-window@^7.4.0: "@types/trusted-types" "^2.0.2" workbox-core "7.4.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==