-
Notifications
You must be signed in to change notification settings - Fork 479
Add a --symbolicate-wasm arg to profiler-edit. #6008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mstange
merged 2 commits into
firefox-devtools:main
from
mstange:symbolicate-wasm-script
May 11, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,17 +34,47 @@ 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 = | ||
| | { type: 'FILE'; path: string } | ||
| | { 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<Profile> { | ||
|
|
@@ -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 "<url>=<path>" 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 `=`. | ||
|
mstange marked this conversation as resolved.
|
||
| 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 }); | ||
| } | ||
| } | ||
|
Comment on lines
+239
to
+266
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not for this PR, but now that we have commander in the package.json, we can possibly simplify these things by using it. |
||
|
|
||
| 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, | ||
| }; | ||
| } | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number, string> { | ||
| 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); | ||
|
mstange marked this conversation as resolved.
|
||
| 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') { | ||
|
mstange marked this conversation as resolved.
|
||
| pos = sectionEnd; | ||
| continue; | ||
| } | ||
|
|
||
| const result = new Map<number, string>(); | ||
| 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<string, number[]>(); | ||
| 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 ?? '<wasm>'; | ||
| 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)` | ||
| : '') | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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))) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.