From aa7a427272f8d7e45ad7888dbcee0e4e5d1ca418 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 7 May 2026 15:14:57 -0400 Subject: [PATCH 1/2] Add a --symbolicate-wasm arg to profiler-edit. This allows applying wasm symbols to existing profiles that were captured with a stripped wasm bundle. The script looks for functions with names of the shape `wasm-function[123]`, which is what Firefox uses when the wasm file doesn't have a names section. Usage: ``` yarn build-node-tools && \ node node-tools-dist/profiler-edit.js -i input.json.gz \ --symbolicate-wasm http://host/a.wasm=./a-unstripped.wasm \ --symbolicate-wasm http://host/b.wasm=./b-unstripped.wasm \ -o out.json.gz ``` --- src/node-tools/profiler-edit.ts | 69 ++++++ src/profile-logic/wasm-symbolication.ts | 218 ++++++++++++++++++ src/test/fixtures/wasm/README.md | 16 ++ src/test/fixtures/wasm/named.wasm | Bin 0 -> 107 bytes src/test/fixtures/wasm/named.wat | 19 ++ .../profiler-edit/profiler-edit.test.ts | 5 + src/test/unit/wasm-symbolication.test.ts | 97 ++++++++ 7 files changed, 424 insertions(+) create mode 100644 src/profile-logic/wasm-symbolication.ts create mode 100644 src/test/fixtures/wasm/README.md create mode 100644 src/test/fixtures/wasm/named.wasm create mode 100644 src/test/fixtures/wasm/named.wat create mode 100644 src/test/unit/wasm-symbolication.test.ts diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index a934bc0e99..07c56c6133 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -15,6 +15,10 @@ 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 { + applyWasmSymbolication, + type WasmSymbolicationSpec, +} from 'firefox-profiler/profile-logic/wasm-symbolication'; import type { Profile } from 'firefox-profiler/types/profile'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; @@ -30,6 +34,10 @@ 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 */ type ProfileSource = @@ -37,10 +45,36 @@ type ProfileSource = | { type: 'URL'; url: string } | { type: 'HASH'; hash: string }; +// Describes one --symbolicate-wasm argument: a local unstripped wasm file that +// supplies symbol names, plus (optionally) the URL of the stripped wasm in the +// profile to which those names should be applied. If `strippedWasmUrl` is +// omitted, the profile must contain exactly one .wasm source, which is used. +interface WasmSymbolicationCliSpec { + // Path to the local unstripped .wasm file (with a "name" custom section). + unstrippedWasmPath: string; + // URL of the matching stripped wasm as it appears in the profile. + strippedWasmUrl?: string; +} + export interface CliOptions { input: ProfileSource; output: string; symbolicateWithServer?: string; + symbolicateWasm: WasmSymbolicationCliSpec[]; +} + +function loadWasmSymbolicationSpecs( + cliSpecs: WasmSymbolicationCliSpec[] +): WasmSymbolicationSpec[] { + return cliSpecs.map((spec) => { + console.log(`Reading wasm symbols from ${spec.unstrippedWasmPath}`); + const buf = fs.readFileSync(spec.unstrippedWasmPath); + return { + bytes: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), + url: spec.strippedWasmUrl, + label: spec.unstrippedWasmPath, + }; + }); } async function loadProfile(source: ProfileSource): Promise { @@ -144,6 +178,11 @@ export async function run(options: CliOptions) { profile.meta.symbolicated = true; } + applyWasmSymbolication( + profile, + loadWasmSymbolicationSpecs(options.symbolicateWasm) + ); + const { profile: compactedProfile } = computeCompactedProfile(profile); console.log(`Saving profile to ${options.output}`); @@ -197,6 +236,35 @@ 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({ + strippedWasmUrl: arg.slice(0, eqIndex), + unstrippedWasmPath: arg.slice(eqIndex + 1), + }); + } else { + symbolicateWasm.push({ unstrippedWasmPath: arg }); + } + } + return { input: sources[0], output: argv.output, @@ -205,6 +273,7 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { argv['symbolicate-with-server'] !== '' ? argv['symbolicate-with-server'] : undefined, + symbolicateWasm, }; } diff --git a/src/profile-logic/wasm-symbolication.ts b/src/profile-logic/wasm-symbolication.ts new file mode 100644 index 0000000000..a28d227e4f --- /dev/null +++ b/src/profile-logic/wasm-symbolication.ts @@ -0,0 +1,218 @@ +/* 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; +} + +// Section IDs in the wasm binary format. See +// https://webassembly.github.io/spec/core/binary/modules.html#sections +const CUSTOM_SECTION_ID = 0; +// Subsection ID for function names inside the "name" custom section. See +// https://webassembly.github.io/spec/core/appendix/custom.html#name-section +const FUNC_NAMES_SUB_ID = 1; + +// 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 +// generated by Firefox (see +// https://searchfox.org/firefox-main/rev/69a6da6b2f5f8f6bdb90cd6dd70a024f460f12ca/js/src/wasm/WasmMetadata.cpp#209 ). +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 => { + if (pos + n > bytes.length) { + throw new Error('Unexpected end of wasm'); + } + 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 !== CUSTOM_SECTION_ID) { + 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 === FUNC_NAMES_SUB_ID) { + 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 0000000000000000000000000000000000000000..802157a5ee3ce5851dffc09f035e03c3645fbf9c GIT binary patch literal 107 zcmXAhK?;B{5X5F?g@Q%UAM|Nf6hUo0h+g}3EuL~of_NnYpx4NPQKhiR)0X_$ajt-w mNY}~YJcD-K6VwiJ2ngK#&)p+5Ii`iZp?0jgsx4>V9>NQ!Oc0F# literal 0 HcmV?d00001 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/integration/profiler-edit/profiler-edit.test.ts b/src/test/integration/profiler-edit/profiler-edit.test.ts index 26baa85eac..8ba46c0937 100644 --- a/src/test/integration/profiler-edit/profiler-edit.test.ts +++ b/src/test/integration/profiler-edit/profiler-edit.test.ts @@ -57,6 +57,7 @@ describe('profiler-edit tool', function () { }, output: '', symbolicateWithServer: 'http://symbol.server', + symbolicateWasm: [], }; const result = await runToTempFileAndReturnOutput(options); @@ -85,6 +86,7 @@ describe('profiler-edit tool', function () { }, output: '', symbolicateWithServer: 'http://symbol.server', + symbolicateWasm: [], }; const result = await runToTempFileAndReturnOutput(options); @@ -109,6 +111,7 @@ describe('profiler-edit tool', function () { path: 'src/test/integration/profiler-edit/unsymbolicated.json', }, output: tempFile, + symbolicateWasm: [], }; await run(options); @@ -148,6 +151,7 @@ describe('profiler-edit tool', function () { }, output: '', symbolicateWithServer: 'http://symbol.server', + symbolicateWasm: [], }; const result = await runToTempFileAndReturnOutput(options); @@ -184,6 +188,7 @@ describe('profiler-edit tool', function () { }, output: '', symbolicateWithServer: 'http://symbol.server', + symbolicateWasm: [], }; const result = await runToTempFileAndReturnOutput(options); 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/); + }); +}); From b4054d5ae2b9c962b09d9265add8ff74b4c54704 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 7 May 2026 16:54:33 -0400 Subject: [PATCH 2/2] Allow downloading profiler-node-tools zip files from PRs. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) 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: