Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Comment thread
mstange marked this conversation as resolved.
licence-check:
runs-on: ${{ matrix.os }}
strategy:
Expand Down
69 changes: 69 additions & 0 deletions src/node-tools/profiler-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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> {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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 `=`.
Comment thread
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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,
Expand All @@ -205,6 +273,7 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions {
argv['symbolicate-with-server'] !== ''
? argv['symbolicate-with-server']
: undefined,
symbolicateWasm,
};
}

Expand Down
218 changes: 218 additions & 0 deletions src/profile-logic/wasm-symbolication.ts
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);
Comment thread
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') {
Comment thread
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)`
: '')
);
}
}
16 changes: 16 additions & 0 deletions src/test/fixtures/wasm/README.md
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 added src/test/fixtures/wasm/named.wasm
Binary file not shown.
19 changes: 19 additions & 0 deletions src/test/fixtures/wasm/named.wat
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)))
Loading
Loading