From b108f4901815e1d0f62b599f6d022f87439891b9 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 26 Aug 2026 17:45:45 +0200 Subject: [PATCH 1/2] fix(wasm): Register modules loaded via non-streaming WebAssembly APIs Co-Authored-By: Claude Opus 5 --- .../suites/wasm/instantiateBuffer/init.js | 15 + .../suites/wasm/instantiateBuffer/subject.js | 52 +++ .../suites/wasm/instantiateBuffer/test.ts | 108 +++++++ packages/wasm/src/index.ts | 33 +- packages/wasm/src/patchWebAssembly.ts | 83 ++++- packages/wasm/src/registry.ts | 54 +++- packages/wasm/src/syntheticUrl.ts | 229 +++++++++++++ packages/wasm/test/nonstreaming.test.ts | 302 ++++++++++++++++++ 8 files changed, 847 insertions(+), 29 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/init.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts create mode 100644 packages/wasm/src/syntheticUrl.ts create mode 100644 packages/wasm/test/nonstreaming.test.ts diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/init.js b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/init.js new file mode 100644 index 000000000000..602bdd6b9a16 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/init.js @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/browser'; +import { wasmIntegration } from '@sentry/wasm'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [wasmIntegration()], + beforeSend: event => { + window.events.push(event); + return null; + }, +}); +window.events = []; diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/subject.js b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/subject.js new file mode 100644 index 000000000000..0a0d3ddd5a75 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/subject.js @@ -0,0 +1,52 @@ +function leb128(n) { + const out = []; + do { + let byte = n & 0x7f; + n >>>= 7; + if (n !== 0) { + byte |= 0x80; + } + out.push(byte); + } while (n !== 0); + return out; +} + +// Appends a custom section with `padding` payload bytes so the module wire +// bytes cross V8's 16383-byte content-hashing cutoff. +function pad(bytes, padding) { + const payload = new Uint8Array(padding); + for (let i = 0; i < padding; i++) { + payload[i] = (i * 31 + 7) & 0xff; + } + const content = [1, 0x70, ...leb128(payload.length)]; + const header = [0x00, ...leb128(2 + payload.length)]; + const out = new Uint8Array(bytes.length + header.length + 2 + payload.length); + out.set(bytes, 0); + out.set(header, bytes.length); + out.set([1, 0x70], bytes.length + header.length); + out.set(payload, bytes.length + header.length + 2); + return out; +} + +window.getEvent = async padding => { + function crash() { + throw new Error('whoops'); + } + + const response = await fetch('https://localhost:5887/simple.wasm'); + const buffer = await response.arrayBuffer(); + const bytes = padding ? pad(new Uint8Array(buffer), padding) : new Uint8Array(buffer); + + const { instance } = await WebAssembly.instantiate(bytes, { + env: { + external_func: crash, + }, + }); + + try { + instance.exports.internal_func(); + } catch (err) { + Sentry.captureException(err); + return { event: window.events.pop(), byteLength: bytes.byteLength }; + } +}; diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts new file mode 100644 index 000000000000..ee0b43c57300 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts @@ -0,0 +1,108 @@ +import type { Page, Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipWASMTests } from '../../../utils/wasmHelpers'; + +function serveWasmFixture(page: Page): Promise { + return page.route('**/simple.wasm', (route: Route) => { + const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'simple.wasm')); + + return route.fulfill({ + status: 200, + body: wasmModule, + headers: { + 'Content-Type': 'application/wasm', + }, + }); + }); +} + +const IMAGE_MATCHER = { + code_file: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/), + code_id: '0ba020cdd2444f7eafdd25999a8e9010', + debug_file: null, + debug_id: '0ba020cdd2444f7eafdd25999a8e90100', + type: 'wasm', +}; + +const FRAME_MATCHER = { + function: 'internal_func', + in_app: true, + instruction_addr: '0x8c', + addr_mode: 'rel:0', + platform: 'native', +}; + +sentryTest( + 'captured exception should include modified frames and debug_meta for non-streaming instantiation', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName) || browserName === 'firefox') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await serveWasmFixture(page); + await page.goto(url); + + const { event } = await page.evaluate(async () => { + // @ts-expect-error this function exists + return window.getEvent(); + }); + + expect(event.exception.values[0].stacktrace.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ...FRAME_MATCHER, + filename: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/), + }), + ]), + ); + + expect(event.debug_meta).toMatchObject({ images: [IMAGE_MATCHER] }); + + // On V8 the small-module (content-hashed) synthetic name must match + // exactly, frames and image alike. + const wasmFrame = event.exception.values[0].stacktrace.frames.find( + (frame: { platform?: string }) => frame.platform === 'native', + ); + expect(event.debug_meta.images[0].code_file).toBe(wasmFrame.filename); + }, +); + +sentryTest( + 'exactly matches the length-derived synthetic name for modules above the content-hash cutoff', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName) || browserName === 'firefox') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await serveWasmFixture(page); + await page.goto(url); + + const { event, byteLength } = await page.evaluate(async () => { + // @ts-expect-error this function exists + return window.getEvent(17000); + }); + + // V8 does not content-hash modules above 16383 bytes; the synthetic name + // derives from the byte length alone on every V8 version. + expect(byteLength).toBeGreaterThan(16383); + const expectedUrl = `wasm://wasm/${(byteLength * 4 + 2).toString(16).padStart(8, '0')}`; + + expect(event.exception.values[0].stacktrace.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ...FRAME_MATCHER, + filename: expectedUrl, + }), + ]), + ); + + expect(event.debug_meta).toMatchObject({ + images: [{ ...IMAGE_MATCHER, code_file: expectedUrl }], + }); + }, +); diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index d5982201d069..ec3ef6a3b696 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -1,7 +1,8 @@ import type { DebugImage, Event, IntegrationFn, StackFrame } from '@sentry/core'; import { defineIntegration, GLOBAL_OBJ } from '@sentry/core'; import { patchWebAssembly } from './patchWebAssembly'; -import { getImage, getImages, registerModule } from './registry'; +import type { WasmDebugImage } from './registry'; +import { getImage, getImages, imageMatchesUrl, registerModule } from './registry'; const INTEGRATION_NAME = 'Wasm'; @@ -32,7 +33,7 @@ interface WasmIntegrationOptions { // Access WINDOW with proper typing for _sentryWasmImages const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryWasmImages?: Array; + _sentryWasmImages?: Array; }; const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { @@ -59,8 +60,12 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { if (hasAtLeastOneWasmFrameWithImage) { event.debug_meta = event.debug_meta || {}; const mainThreadImages = getImages(); - const workerImages = WINDOW._sentryWasmImages || []; - event.debug_meta.images = [...(event.debug_meta.images || []), ...mainThreadImages, ...workerImages]; + const workerImages = getWorkerImages(); + event.debug_meta.images = [ + ...(event.debug_meta.images || []), + ...mainThreadImages.map(stripMatchUrls), + ...workerImages.map(stripMatchUrls), + ]; } return event; @@ -137,29 +142,35 @@ export function patchFrames( return hasAtLeastOneWasmFrameWithImage; } +function getWorkerImages(): Array { + return WINDOW._sentryWasmImages || []; +} + +function stripMatchUrls(image: WasmDebugImage): DebugImage { + const { _matchUrls, ...rest } = image; + return rest; +} + /** * Looks up an image by URL in worker images. */ function getWorkerImage(url: string): number { - const workerImages = WINDOW._sentryWasmImages || []; - return workerImages.findIndex(image => { - return image.type === 'wasm' && image.code_file === url; - }); + return getWorkerImages().findIndex(image => imageMatchesUrl(image, url)); } /** * Use this function to register WASM support in a web worker. * * This function will: - * - Patch WebAssembly.instantiateStreaming and WebAssembly.compileStreaming in the worker + * - Patch the WebAssembly compilation APIs in the worker * - Forward WASM debug images to the parent thread for symbolication * * @param options {RegisterWebWorkerWasmOptions} Options: * - `self`: The worker's global scope (self). */ export function registerWebWorkerWasm({ self }: RegisterWebWorkerWasmOptions): void { - patchWebAssembly((module, url) => { - const image = registerModule(module, url); + patchWebAssembly((module, url, matchUrls) => { + const image = registerModule(module, url, matchUrls); if (image) { self.postMessage({ diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index e4f7b527a2a0..9ea694f73d90 100644 --- a/packages/wasm/src/patchWebAssembly.ts +++ b/packages/wasm/src/patchWebAssembly.ts @@ -1,9 +1,15 @@ -export type RegisterModuleCallback = (module: WebAssembly.Module, url: string) => void; +import { getHashCandidates, getSyntheticUrls, toByteView } from './syntheticUrl'; + +export type RegisterModuleCallback = (module: WebAssembly.Module, url: string, matchUrls?: string[]) => void; /** - * Patches the WebAssembly streaming APIs so that every compiled module gets - * registered as a debug image under the URL of the response it was compiled - * from. + * Patches the WebAssembly APIs that compile modules so that every compiled + * module gets registered as a debug image. + * + * Streaming APIs register the module under the response URL. Non-streaming + * APIs receive raw bytes without any URL, so those modules are registered + * under the synthetic `wasm://wasm/` script name the engine uses in + * stack frames (see `syntheticUrl.ts`). * * @param registerModule callback invoked for every successfully compiled module */ @@ -47,11 +53,76 @@ export function patchWebAssembly(registerModule: RegisterModuleCallback): void { }); }; } + + const registerFromBuffer = (module: WebAssembly.Module, hashCandidates: string[]): void => { + const urls = getSyntheticUrls(module, hashCandidates); + const url = urls[0]; + if (url) { + registerSafely(registerModule, module, url, urls); + } + }; + + // Double-cast, because the overloaded native signature (buffer vs. module + // first argument) cannot be widened to a pass-through shape in one step. + const origInstantiate = WebAssembly.instantiate as unknown as ( + source: unknown, + ...rest: unknown[] + ) => Promise; + WebAssembly.instantiate = function instantiate(source: unknown, ...rest: unknown[]): Promise { + const bytes = toByteView(source); + // Hash candidates must be captured before calling the original function, + // since the caller is free to mutate or transfer the buffer afterwards. + const hashCandidates = bytes && getHashCandidates(bytes); + const result = origInstantiate(source, ...rest); + if (hashCandidates) { + // Chaining (instead of attaching a side listener) keeps rejections of + // fire-and-forget calls observable as unhandledrejection events. + return result.then(rv => { + registerFromBuffer(rv.module, hashCandidates); + return rv; + }); + } + return result; + } as typeof WebAssembly.instantiate; + + const origCompile = WebAssembly.compile as (source: unknown, ...rest: unknown[]) => Promise; + WebAssembly.compile = function compile(source: unknown, ...rest: unknown[]): Promise { + const bytes = toByteView(source); + const hashCandidates = bytes && getHashCandidates(bytes); + const result = origCompile(source, ...rest); + if (hashCandidates) { + return result.then(module => { + registerFromBuffer(module, hashCandidates); + return module; + }); + } + return result; + }; + + // `new WebAssembly.Module(bytes)` compiles synchronously. The Proxy keeps + // statics (customSections, exports, imports), prototype, and instanceof + // behavior intact. + WebAssembly.Module = new Proxy(WebAssembly.Module, { + construct(target, args: unknown[], newTarget) { + const bytes = toByteView(args[0]); + const hashCandidates = bytes && getHashCandidates(bytes); + const module = Reflect.construct(target, args, newTarget) as WebAssembly.Module; + if (hashCandidates) { + registerFromBuffer(module, hashCandidates); + } + return module; + }, + }); } -function registerSafely(registerModule: RegisterModuleCallback, module: WebAssembly.Module, url: string): void { +function registerSafely( + registerModule: RegisterModuleCallback, + module: WebAssembly.Module, + url: string, + matchUrls?: string[], +): void { try { - registerModule(module, url); + registerModule(module, url, matchUrls); } catch { // a registration failure must never break the user's WebAssembly call } diff --git a/packages/wasm/src/registry.ts b/packages/wasm/src/registry.ts index 2ca6d66754dc..d06dfa3b1bbe 100644 --- a/packages/wasm/src/registry.ts +++ b/packages/wasm/src/registry.ts @@ -1,6 +1,14 @@ import type { DebugImage } from '@sentry/core'; -export const IMAGES: Array = []; +/** + * A debug image with the additional synthetic script names the engine may use + * for the module in stack frames. Only set for modules compiled from raw + * bytes. The field crosses worker boundaries via postMessage and is stripped + * before images are attached to an event. + */ +export type WasmDebugImage = Extract & { _matchUrls?: string[] }; + +export const IMAGES: Array = []; export interface ModuleInfo { buildId: string | null; @@ -39,8 +47,14 @@ export function getModuleInfo(module: WebAssembly.Module): ModuleInfo { /** * Records a module and returns the created debug image. + * + * @param module the compiled module + * @param url the URL the module was loaded from, or the engine's synthetic + * script name for modules compiled from raw bytes + * @param matchUrls additional synthetic script names the engine may use for + * this module in stack frames */ -export function registerModule(module: WebAssembly.Module, url: string): DebugImage | null { +export function registerModule(module: WebAssembly.Module, url: string, matchUrls?: string[]): DebugImage | null { const { buildId, debugFile } = getModuleInfo(module); if (!buildId) { return null; @@ -53,15 +67,21 @@ export function registerModule(module: WebAssembly.Module, url: string): DebugIm let debugFileUrl = null; if (debugFile) { - try { - debugFileUrl = new URL(debugFile, url).href; - } catch { - // debugFile could be a blob URL which causes the URL constructor to throw - // for now we just ignore this case + if (url.startsWith('wasm://')) { + // A synthetic script name is no meaningful base to resolve against, so + // keep the raw value from the external_debug_info section. + debugFileUrl = debugFile; + } else { + try { + debugFileUrl = new URL(debugFile, url).href; + } catch { + // debugFile could be a blob URL which causes the URL constructor to throw + // for now we just ignore this case + } } } - const image: DebugImage = { + const image: WasmDebugImage = { type: 'wasm', code_id: buildId, code_file: url, @@ -69,6 +89,10 @@ export function registerModule(module: WebAssembly.Module, url: string): DebugIm debug_id: `${buildId.padEnd(32, '0').slice(0, 32)}0`, }; + if (matchUrls?.length) { + image._matchUrls = matchUrls; + } + IMAGES.push(image); return image; } @@ -76,17 +100,23 @@ export function registerModule(module: WebAssembly.Module, url: string): DebugIm /** * Returns all known images. */ -export function getImages(): Array { +export function getImages(): Array { return IMAGES; } +/** + * Checks whether an image matches the given frame URL, either via its + * `code_file` or one of the synthetic script names. + */ +export function imageMatchesUrl(image: WasmDebugImage, url: string): boolean { + return image.type === 'wasm' && (image.code_file === url || !!image._matchUrls?.includes(url)); +} + /** * Looks up an image by URL. * * @param url the URL of the WebAssembly module. */ export function getImage(url: string): number { - return IMAGES.findIndex(image => { - return image.type === 'wasm' && image.code_file === url; - }); + return IMAGES.findIndex(image => imageMatchesUrl(image, url)); } diff --git a/packages/wasm/src/syntheticUrl.ts b/packages/wasm/src/syntheticUrl.ts new file mode 100644 index 000000000000..4bb5076b9e5b --- /dev/null +++ b/packages/wasm/src/syntheticUrl.ts @@ -0,0 +1,229 @@ +/* eslint-disable no-bitwise */ +// V8 gives WebAssembly modules that are compiled from raw bytes (instead of +// via the streaming APIs, which carry the response URL) a synthetic script +// name of the form `wasm://wasm/` or `wasm://wasm/-` when +// the module has a module name in its "name" section. Stack frames of such +// modules use that synthetic name as their "url", so registering the debug +// image under the same name is the only way to associate frames with the +// image. The hash is V8's internal string hash field of the wire bytes: +// - for byte lengths above kMaxHashCalcLength (16383), V8 does not hash the +// content at all and derives the hash field from the length alone, which +// has been stable across all V8 versions in use, +// - for smaller modules, the content is hashed. V8 <= 13.3 (Chrome <= 133, +// Node <= 24) uses a Jenkins one-at-a-time hash, V8 >= 13.4 uses rapidhash +// (with the seed and secret pinned to their defaults for wasm script names, +// so the output is never process-randomized). We register both candidates +// since we cannot detect the engine version. +// If V8 ever changes this scheme, matching degrades to the single-image +// fallback in `patchFrames` and streaming modules stay unaffected. + +const V8_MAX_HASH_CALC_LENGTH = 16383; + +// V8 tags hash fields with 2 bits (hash << 2 | kHashTag). +function toHashField(hash: number): number { + return (hash * 4 + 2) >>> 0; +} + +function toHex(hashField: number): string { + return hashField.toString(16).padStart(8, '0'); +} + +// Jenkins one-at-a-time with V8's finalization and zero seed, masked to the +// 30 hash bits V8 stores (V8 < 13.x). +function jenkinsHashField(bytes: Uint8Array): number { + let h = 0; + for (const byte of bytes) { + h = (h + byte) >>> 0; + h = (h + ((h << 10) >>> 0)) >>> 0; + h = (h ^ (h >>> 6)) >>> 0; + } + h = (h + ((h << 3) >>> 0)) >>> 0; + h = (h ^ (h >>> 11)) >>> 0; + h = (h + ((h << 15) >>> 0)) >>> 0; + h = h & 0x3fffffff; + if (h === 0) { + h = 27; // V8 kZeroHash + } + return toHashField(h); +} + +// V8 does not hash the content of strings longer than kMaxHashCalcLength but +// uses the length itself as the hash. +function lengthHashField(byteLength: number): number { + return toHashField(byteLength); +} + +const MASK_64 = (1n << 64n) - 1n; +const RAPIDHASH_SECRET_0 = 0x2d358dccaa6c78a5n; +const RAPIDHASH_SECRET_1 = 0x8bb84b93962eacc9n; +const RAPIDHASH_SECRET_2 = 0x4b33a62ed433d4a3n; + +function rapidMix(a: bigint, b: bigint): bigint { + const product = a * b; + return (product & MASK_64) ^ (product >> 64n); +} + +function read64(bytes: Uint8Array, offset: number): bigint { + let value = 0n; + for (let i = 7; i >= 0; i--) { + value = (value << 8n) | BigInt(bytes[offset + i] ?? 0); + } + return value; +} + +function read32(bytes: Uint8Array, offset: number): bigint { + let value = 0n; + for (let i = 3; i >= 0; i--) { + value = (value << 8n) | BigInt(bytes[offset + i] ?? 0); + } + return value; +} + +// V8's rapidhash flavor (third_party/rapidhash-v8) with seed 0 and the +// default secret, as used for wasm script names (V8 >= 13.4). Only ever +// called for inputs of at most kMaxHashCalcLength bytes. Valid wasm is at +// least 8 bytes, so the sub-4-byte input branch of the original is omitted. +function rapidhashHashField(bytes: Uint8Array): number { + const length = bytes.length; + const length64 = BigInt(length); + let seed = (rapidMix(RAPIDHASH_SECRET_0, RAPIDHASH_SECRET_1) ^ length64) & MASK_64; + let a: bigint; + let b: bigint; + if (length <= 16) { + const plast = length - 4; + const delta = (length & 24) >> (length >> 3); + a = ((read32(bytes, 0) << 32n) | read32(bytes, plast)) & MASK_64; + b = ((read32(bytes, delta) << 32n) | read32(bytes, plast - delta)) & MASK_64; + } else { + let remaining = length; + let p = 0; + if (remaining > 48) { + let see1 = seed; + let see2 = seed; + do { + seed = rapidMix(read64(bytes, p) ^ RAPIDHASH_SECRET_0, read64(bytes, p + 8) ^ seed); + see1 = rapidMix(read64(bytes, p + 16) ^ RAPIDHASH_SECRET_1, read64(bytes, p + 24) ^ see1); + see2 = rapidMix(read64(bytes, p + 32) ^ RAPIDHASH_SECRET_2, read64(bytes, p + 40) ^ see2); + p += 48; + remaining -= 48; + } while (remaining >= 48); + seed = (seed ^ see1 ^ see2) & MASK_64; + } + if (remaining > 16) { + seed = rapidMix(read64(bytes, p) ^ RAPIDHASH_SECRET_2, read64(bytes, p + 8) ^ seed ^ RAPIDHASH_SECRET_1); + if (remaining > 32) { + seed = rapidMix(read64(bytes, p + 16) ^ RAPIDHASH_SECRET_2, read64(bytes, p + 24) ^ seed); + } + } + a = read64(bytes, p + remaining - 16); + b = read64(bytes, p + remaining - 8); + } + a = (a ^ RAPIDHASH_SECRET_1) & MASK_64; + b = (b ^ seed) & MASK_64; + const product = a * b; + a = product & MASK_64; + b = (product >> 64n) & MASK_64; + const raw = rapidMix((a ^ RAPIDHASH_SECRET_0 ^ length64) & MASK_64, (b ^ RAPIDHASH_SECRET_1) & MASK_64); + let hash = Number(raw & 0x3fffffffn); + if (hash === 0) { + hash = 27; // V8 kZeroHash + } + return toHashField(hash); +} + +/** + * Returns a normalized byte view over a BufferSource, or undefined if the + * value is not a BufferSource or cannot be read (e.g. a detached buffer, + * which must reject asynchronously through the original API instead of + * throwing synchronously here). + */ +export function toByteView(source: unknown): Uint8Array | undefined { + try { + // instanceof is realm-bound, the toString check also catches ArrayBuffers + // from other realms (e.g. iframes) + if (source instanceof ArrayBuffer || Object.prototype.toString.call(source) === '[object ArrayBuffer]') { + return new Uint8Array(source as ArrayBuffer); + } + if (ArrayBuffer.isView(source)) { + return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + } + } catch { + // fall through + } + return undefined; +} + +/** + * Computes the hex hash-field candidates V8 may use in the synthetic script + * name for a module with the given wire bytes. + * + * Must be called synchronously when the buffer is received, before the caller + * has a chance to mutate or detach it (engines capture the bytes at call time + * as well). + */ +export function getHashCandidates(bytes: Uint8Array): string[] { + if (bytes.byteLength > V8_MAX_HASH_CALC_LENGTH) { + return [toHex(lengthHashField(bytes.byteLength))]; + } + if (bytes.byteLength < 8) { + // shorter than the wasm header, compilation will fail anyway + return []; + } + const candidates = [toHex(jenkinsHashField(bytes))]; + // Engines without BigInt predate V8's switch to rapidhash, so skipping the + // rapidhash candidate there loses nothing. + if (typeof BigInt === 'function') { + candidates.unshift(toHex(rapidhashHashField(bytes))); + } + return candidates; +} + +/** + * Extracts the module name from the "name" custom section, if present. + */ +export function getModuleName(module: WebAssembly.Module): string | undefined { + try { + const nameSection = WebAssembly.Module.customSections(module, 'name')[0]; + if (!nameSection) { + return undefined; + } + const bytes = new Uint8Array(nameSection); + let pos = 0; + const readLeb128 = (): number => { + let result = 0; + let shift = 0; + let byte; + do { + byte = bytes[pos++] ?? 0; + result |= (byte & 0x7f) << shift; + shift += 7; + } while (byte & 0x80); + return result >>> 0; + }; + while (pos < bytes.length) { + const subsectionId = bytes[pos++]; + const subsectionLength = readLeb128(); + if (subsectionId === 0) { + const nameLength = readLeb128(); + // fatal, because V8 ignores names that are not valid UTF-8 + const name = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(pos, pos + nameLength)); + return name || undefined; + } + pos += subsectionLength; + } + } catch { + // malformed name section, fall through + } + return undefined; +} + +/** + * Builds the synthetic script names V8 may report in stack frames for a + * buffer-compiled module. The first entry is used as the debug image's + * `code_file`, all entries are used for frame matching. + */ +export function getSyntheticUrls(module: WebAssembly.Module, hashCandidates: string[]): string[] { + const moduleName = getModuleName(module); + const prefix = moduleName ? `${moduleName}-` : ''; + return hashCandidates.map(hash => `wasm://wasm/${prefix}${hash}`); +} diff --git a/packages/wasm/test/nonstreaming.test.ts b/packages/wasm/test/nonstreaming.test.ts new file mode 100644 index 000000000000..733a4d282f3b --- /dev/null +++ b/packages/wasm/test/nonstreaming.test.ts @@ -0,0 +1,302 @@ +/* eslint-disable no-bitwise */ +import type { Event, StackFrame } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { patchFrames, registerWebWorkerWasm, wasmIntegration } from '../src/index'; +import { patchWebAssembly } from '../src/patchWebAssembly'; +import type { WasmDebugImage } from '../src/registry'; +import { getImages, IMAGES, registerModule } from '../src/registry'; +import { getHashCandidates, getModuleName, toByteView } from '../src/syntheticUrl'; + +const BUILD_ID_BYTES = [0x0b, 0xa0, 0x20, 0xcd, 0xd2, 0x44, 0x4f, 0x7e, 0xaf, 0xdd, 0x25, 0x99, 0x9a, 0x8e, 0x90, 0x10]; +const BUILD_ID_HEX = '0ba020cdd2444f7eafdd25999a8e9010'; + +function leb128(value: number): number[] { + const out = []; + let n = value; + do { + let byte = n & 0x7f; + n >>>= 7; + if (n !== 0) { + byte |= 0x80; + } + out.push(byte); + } while (n !== 0); + return out; +} + +function customSection(name: string, payload: number[]): number[] { + const nameBytes = [...name].map(c => c.charCodeAt(0)); + const content = [...leb128(nameBytes.length), ...nameBytes, ...payload]; + return [0x00, ...leb128(content.length), ...content]; +} + +interface BuildWasmOptions { + buildId?: boolean; + moduleName?: string; + padding?: number; + padSeed?: number; +} + +const WASM_HEADER = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; +const TYPE_SECTION = [0x01, 0x04, 0x01, 0x60, 0x00, 0x00]; // () -> () +const FUNCTION_SECTION = [0x03, 0x02, 0x01, 0x00]; +const EXPORT_SECTION = [0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00]; // exports func 0 as "f" +const CODE_SECTION = [0x0a, 0x05, 0x01, 0x03, 0x00, 0x00, 0x0b]; // body: unreachable + +// A minimal module exporting a function "f" whose body traps (unreachable), +// optionally with build_id / name sections and a padding custom section. +function buildWasm({ + buildId = true, + moduleName, + padding = 0, + padSeed = 0, +}: BuildWasmOptions = {}): Uint8Array { + const bytes = [...WASM_HEADER, ...TYPE_SECTION, ...FUNCTION_SECTION, ...EXPORT_SECTION, ...CODE_SECTION]; + if (buildId) { + bytes.push(...customSection('build_id', BUILD_ID_BYTES)); + } + if (moduleName) { + const nameBytes = [...moduleName].map(c => c.charCodeAt(0)); + const subsection = [0x00, ...leb128(nameBytes.length + 1), ...leb128(nameBytes.length), ...nameBytes]; + bytes.push(...customSection('name', subsection)); + } + if (padding > 0) { + const payload = []; + for (let i = 0; i < padding; i++) { + payload.push((i * 31 + 7 + padSeed) & 0xff); + } + bytes.push(...customSection('p', payload)); + } + return new Uint8Array(bytes); +} + +// Extracts the synthetic script name the engine actually reports for the +// module by trapping it. The `:wasm-function[i]:0xaddr` suffix gets mangled +// by vitest's stack rewriting, so the frame filename is rebuilt from the +// engine-reported url; suffix parsing is covered by the parsing tests. +function trapAndGetWasmFilename(instance: WebAssembly.Instance): string { + try { + (instance.exports.f as () => void)(); + } catch (e) { + const match = (e as Error).stack?.match(/(wasm:\/\/wasm\/[^):\s]+)/); + if (match?.[1]) { + return `${match[1]}:wasm-function[0]:0x1e`; + } + } + throw new Error('could not extract wasm frame filename'); +} + +function frameForFilename(filename: string): StackFrame { + return { filename, function: 'f', in_app: true }; +} + +beforeAll(() => { + patchWebAssembly(registerModule); +}); + +afterEach(() => { + IMAGES.length = 0; +}); + +describe('non-streaming WebAssembly patching', () => { + it('registers modules compiled via WebAssembly.instantiate(buffer) and matches real stack frames', async () => { + // Two modules with different content and sizes on both sides of V8's + // 16383-byte content-hashing cutoff, so that matching must go through the + // computed synthetic names and cannot silently succeed via the + // single-image fallback. + const small = buildWasm({ padding: 100 }); + const large = buildWasm({ padding: 20000 }); + + const { instance: smallInstance } = await WebAssembly.instantiate(small); + const { instance: largeInstance } = await WebAssembly.instantiate(large); + + expect(getImages()).toHaveLength(2); + expect(getImages()[0]?.code_id).toBe(BUILD_ID_HEX); + expect(getImages()[0]?.code_file).toMatch(/^wasm:\/\/wasm\/[0-9a-f]{8}$/); + + const smallFilename = trapAndGetWasmFilename(smallInstance); + const largeFilename = trapAndGetWasmFilename(largeInstance); + + const frames = [frameForFilename(smallFilename), frameForFilename(largeFilename)]; + const result = patchFrames(frames); + + expect(result).toBe(true); + expect(frames[0]?.platform).toBe('native'); + expect(frames[0]?.addr_mode).toBe('rel:0'); + expect(frames[1]?.addr_mode).toBe('rel:1'); + }); + + it('registers modules compiled via new WebAssembly.Module() synchronously', () => { + const bytes = buildWasm({ padding: 17000 }); + const module = new WebAssembly.Module(bytes); + + expect(getImages()).toHaveLength(1); + expect(module).toBeInstanceOf(WebAssembly.Module); + expect(WebAssembly.Module.customSections(module, 'build_id')).toHaveLength(1); + + const instance = new WebAssembly.Instance(module); + const filename = trapAndGetWasmFilename(instance); + const frames = [frameForFilename(filename)]; + + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + it('registers modules compiled via WebAssembly.compile()', async () => { + const module = await WebAssembly.compile(buildWasm({ padding: 18000 })); + + expect(getImages()).toHaveLength(1); + + const instance = new WebAssembly.Instance(module); + const filename = trapAndGetWasmFilename(instance); + const frames = [frameForFilename(filename)]; + + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + it('includes the module name from the name section in the synthetic url', async () => { + const bytes = buildWasm({ moduleName: 'mymod', padding: 20000 }); + const { instance } = await WebAssembly.instantiate(bytes); + + expect(getImages()[0]?.code_file).toMatch(/^wasm:\/\/wasm\/mymod-[0-9a-f]{8}$/); + + const filename = trapAndGetWasmFilename(instance); + const frames = [frameForFilename(filename)]; + + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + it('supports the WebAssembly.instantiate(module) overload without re-registering', async () => { + const module = await WebAssembly.compile(buildWasm({ padding: 17500 })); + const instance = await WebAssembly.instantiate(module); + + expect(instance).toBeInstanceOf(WebAssembly.Instance); + expect(getImages()).toHaveLength(1); + }); + + it('accepts typed-array views over a larger buffer', async () => { + const bytes = buildWasm({ padding: 17000 }); + const oversized = new Uint8Array(bytes.length + 64); + oversized.set(bytes, 32); + const view = oversized.subarray(32, 32 + bytes.length); + + const { instance } = await WebAssembly.instantiate(view); + const filename = trapAndGetWasmFilename(instance); + const frames = [frameForFilename(filename)]; + + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + it('does not register modules without a build_id', async () => { + await WebAssembly.instantiate(buildWasm({ buildId: false, padding: 17000 })); + expect(getImages()).toHaveLength(0); + }); + + it('rejects like the original on invalid bytes', async () => { + await expect(WebAssembly.instantiate(new Uint8Array([0, 1, 2, 3]))).rejects.toThrow(); + expect(getImages()).toHaveLength(0); + }); + + it('rejects asynchronously instead of throwing when the buffer is detached', async () => { + const bytes = buildWasm({ padding: 17000 }); + const buffer = bytes.buffer; + structuredClone(buffer, { transfer: [buffer] }); + + await expect(WebAssembly.instantiate(buffer)).rejects.toThrow(); + await expect(WebAssembly.compile(buffer)).rejects.toThrow(); + expect(getImages()).toHaveLength(0); + }); +}); + +describe('registerWebWorkerWasm()', () => { + it('forwards buffer images with their match urls and matches frames against them', async () => { + const messages: Array<{ _sentryWasmImages?: WasmDebugImage[] }> = []; + registerWebWorkerWasm({ self: { postMessage: (message: unknown) => messages.push(message as never) } }); + + const bytes = buildWasm({ padding: 17000 }); + const { instance } = await WebAssembly.instantiate(bytes); + + expect(messages).toHaveLength(1); + const forwarded = messages[0]?._sentryWasmImages?.[0]; + expect(forwarded?._matchUrls).toEqual(expect.arrayContaining([forwarded?.code_file])); + + // simulate the main thread: the image only exists as a forwarded worker + // image, exactly as webWorkerIntegration stores it + const filename = trapAndGetWasmFilename(instance); + IMAGES.length = 0; + (GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages = [forwarded as WasmDebugImage]; + try { + const frames = [frameForFilename(filename)]; + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + } finally { + delete (GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages; + } + }); +}); + +describe('processEvent', () => { + it('strips internal match urls from attached debug images', () => { + const bytes = buildWasm({ padding: 17000 }); + new WebAssembly.Module(bytes); + const candidates = getHashCandidates(bytes); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [frameForFilename(`wasm://wasm/${candidates[0]}:wasm-function[0]:0x1e`)], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + const images = event.debug_meta?.images as WasmDebugImage[] | undefined; + expect(images).toHaveLength(1); + expect(images?.[0]).not.toHaveProperty('_matchUrls'); + expect(images?.[0]?.code_id).toBe(BUILD_ID_HEX); + }); +}); + +describe('syntheticUrl helpers', () => { + it('computes the stable length-based hash for modules above the content-hash cutoff', () => { + const bytes = new Uint8Array(20038); + expect(getHashCandidates(bytes)).toEqual(['0001391a']); + }); + + it('normalizes BufferSource values', () => { + const buffer = new ArrayBuffer(8); + expect(toByteView(buffer)?.byteLength).toBe(8); + expect(toByteView(new DataView(buffer, 2, 4))?.byteLength).toBe(4); + expect(toByteView('nope')).toBeUndefined(); + expect(toByteView(undefined)).toBeUndefined(); + }); + + it('extracts the module name from the name section', () => { + const module = new WebAssembly.Module(buildWasm({ moduleName: 'my_module' })); + expect(getModuleName(module)).toBe('my_module'); + }); + + it('returns undefined for modules without a name section', () => { + const module = new WebAssembly.Module(buildWasm()); + expect(getModuleName(module)).toBeUndefined(); + }); + + it('ignores module names that are not valid UTF-8, like V8 does', () => { + const bytes = [...WASM_HEADER, ...TYPE_SECTION, ...FUNCTION_SECTION, ...EXPORT_SECTION, ...CODE_SECTION]; + bytes.push(...customSection('name', [0x00, 0x03, 0x02, 0xff, 0xfe])); + const module = new WebAssembly.Module(new Uint8Array(bytes)); + expect(getModuleName(module)).toBeUndefined(); + }); +}); From 599115cd7a5e72921426889a5a5e2f70b12348dd Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 27 Aug 2026 14:58:21 +0200 Subject: [PATCH 2/2] Derive synthetic names from the byte length instead of hashing content --- .../suites/wasm/instantiateBuffer/test.ts | 36 ---- packages/wasm/src/index.ts | 16 +- packages/wasm/src/patchWebAssembly.ts | 39 ++-- packages/wasm/src/registry.ts | 30 ++- packages/wasm/src/syntheticUrl.ts | 186 ++++-------------- packages/wasm/test/nonstreaming.test.ts | 59 +++--- 6 files changed, 105 insertions(+), 261 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts index ee0b43c57300..4ba420eb54cd 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts @@ -35,42 +35,6 @@ const FRAME_MATCHER = { platform: 'native', }; -sentryTest( - 'captured exception should include modified frames and debug_meta for non-streaming instantiation', - async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipWASMTests(browserName) || browserName === 'firefox') { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - await serveWasmFixture(page); - await page.goto(url); - - const { event } = await page.evaluate(async () => { - // @ts-expect-error this function exists - return window.getEvent(); - }); - - expect(event.exception.values[0].stacktrace.frames).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - ...FRAME_MATCHER, - filename: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/), - }), - ]), - ); - - expect(event.debug_meta).toMatchObject({ images: [IMAGE_MATCHER] }); - - // On V8 the small-module (content-hashed) synthetic name must match - // exactly, frames and image alike. - const wasmFrame = event.exception.values[0].stacktrace.frames.find( - (frame: { platform?: string }) => frame.platform === 'native', - ); - expect(event.debug_meta.images[0].code_file).toBe(wasmFrame.filename); - }, -); - sentryTest( 'exactly matches the length-derived synthetic name for modules above the content-hash cutoff', async ({ getLocalTestUrl, page, browserName }) => { diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index ec3ef6a3b696..f028dbcf265d 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -2,7 +2,7 @@ import type { DebugImage, Event, IntegrationFn, StackFrame } from '@sentry/core' import { defineIntegration, GLOBAL_OBJ } from '@sentry/core'; import { patchWebAssembly } from './patchWebAssembly'; import type { WasmDebugImage } from './registry'; -import { getImage, getImages, imageMatchesUrl, registerModule } from './registry'; +import { getImage, getImages, registerModule } from './registry'; const INTEGRATION_NAME = 'Wasm'; @@ -63,8 +63,8 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { const workerImages = getWorkerImages(); event.debug_meta.images = [ ...(event.debug_meta.images || []), - ...mainThreadImages.map(stripMatchUrls), - ...workerImages.map(stripMatchUrls), + ...mainThreadImages.map(stripInternalFields), + ...workerImages.map(stripInternalFields), ]; } @@ -146,8 +146,8 @@ function getWorkerImages(): Array { return WINDOW._sentryWasmImages || []; } -function stripMatchUrls(image: WasmDebugImage): DebugImage { - const { _matchUrls, ...rest } = image; +function stripInternalFields(image: WasmDebugImage): DebugImage { + const { _fromBuffer, ...rest } = image; return rest; } @@ -155,7 +155,7 @@ function stripMatchUrls(image: WasmDebugImage): DebugImage { * Looks up an image by URL in worker images. */ function getWorkerImage(url: string): number { - return getWorkerImages().findIndex(image => imageMatchesUrl(image, url)); + return getWorkerImages().findIndex(image => image.type === 'wasm' && image.code_file === url); } /** @@ -169,8 +169,8 @@ function getWorkerImage(url: string): number { * - `self`: The worker's global scope (self). */ export function registerWebWorkerWasm({ self }: RegisterWebWorkerWasmOptions): void { - patchWebAssembly((module, url, matchUrls) => { - const image = registerModule(module, url, matchUrls); + patchWebAssembly((module, url, fromBuffer) => { + const image = registerModule(module, url, fromBuffer); if (image) { self.postMessage({ diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index 9ea694f73d90..fd94ee5f165d 100644 --- a/packages/wasm/src/patchWebAssembly.ts +++ b/packages/wasm/src/patchWebAssembly.ts @@ -1,6 +1,6 @@ -import { getHashCandidates, getSyntheticUrls, toByteView } from './syntheticUrl'; +import { getSyntheticUrl, toByteView } from './syntheticUrl'; -export type RegisterModuleCallback = (module: WebAssembly.Module, url: string, matchUrls?: string[]) => void; +export type RegisterModuleCallback = (module: WebAssembly.Module, url: string, fromBuffer?: boolean) => void; /** * Patches the WebAssembly APIs that compile modules so that every compiled @@ -54,12 +54,8 @@ export function patchWebAssembly(registerModule: RegisterModuleCallback): void { }; } - const registerFromBuffer = (module: WebAssembly.Module, hashCandidates: string[]): void => { - const urls = getSyntheticUrls(module, hashCandidates); - const url = urls[0]; - if (url) { - registerSafely(registerModule, module, url, urls); - } + const registerFromBuffer = (module: WebAssembly.Module, byteLength: number): void => { + registerSafely(registerModule, module, getSyntheticUrl(module, byteLength), true); }; // Double-cast, because the overloaded native signature (buffer vs. module @@ -70,15 +66,15 @@ export function patchWebAssembly(registerModule: RegisterModuleCallback): void { ) => Promise; WebAssembly.instantiate = function instantiate(source: unknown, ...rest: unknown[]): Promise { const bytes = toByteView(source); - // Hash candidates must be captured before calling the original function, - // since the caller is free to mutate or transfer the buffer afterwards. - const hashCandidates = bytes && getHashCandidates(bytes); + // The length must be read before calling the original function, since the + // caller is free to mutate or transfer the buffer afterwards. + const byteLength = bytes?.byteLength; const result = origInstantiate(source, ...rest); - if (hashCandidates) { + if (byteLength !== undefined) { // Chaining (instead of attaching a side listener) keeps rejections of // fire-and-forget calls observable as unhandledrejection events. return result.then(rv => { - registerFromBuffer(rv.module, hashCandidates); + registerFromBuffer(rv.module, byteLength); return rv; }); } @@ -88,11 +84,11 @@ export function patchWebAssembly(registerModule: RegisterModuleCallback): void { const origCompile = WebAssembly.compile as (source: unknown, ...rest: unknown[]) => Promise; WebAssembly.compile = function compile(source: unknown, ...rest: unknown[]): Promise { const bytes = toByteView(source); - const hashCandidates = bytes && getHashCandidates(bytes); + const byteLength = bytes?.byteLength; const result = origCompile(source, ...rest); - if (hashCandidates) { + if (byteLength !== undefined) { return result.then(module => { - registerFromBuffer(module, hashCandidates); + registerFromBuffer(module, byteLength); return module; }); } @@ -104,11 +100,10 @@ export function patchWebAssembly(registerModule: RegisterModuleCallback): void { // behavior intact. WebAssembly.Module = new Proxy(WebAssembly.Module, { construct(target, args: unknown[], newTarget) { - const bytes = toByteView(args[0]); - const hashCandidates = bytes && getHashCandidates(bytes); + const byteLength = toByteView(args[0])?.byteLength; const module = Reflect.construct(target, args, newTarget) as WebAssembly.Module; - if (hashCandidates) { - registerFromBuffer(module, hashCandidates); + if (byteLength !== undefined) { + registerFromBuffer(module, byteLength); } return module; }, @@ -119,10 +114,10 @@ function registerSafely( registerModule: RegisterModuleCallback, module: WebAssembly.Module, url: string, - matchUrls?: string[], + fromBuffer?: boolean, ): void { try { - registerModule(module, url, matchUrls); + registerModule(module, url, fromBuffer); } catch { // a registration failure must never break the user's WebAssembly call } diff --git a/packages/wasm/src/registry.ts b/packages/wasm/src/registry.ts index d06dfa3b1bbe..2688f9468d2a 100644 --- a/packages/wasm/src/registry.ts +++ b/packages/wasm/src/registry.ts @@ -1,12 +1,13 @@ import type { DebugImage } from '@sentry/core'; /** - * A debug image with the additional synthetic script names the engine may use - * for the module in stack frames. Only set for modules compiled from raw - * bytes. The field crosses worker boundaries via postMessage and is stripped - * before images are attached to an event. + * A debug image with a marker for modules that were compiled from raw bytes. + * The engine names those modules itself, so their `code_file` is only a + * prediction and frames may have to be matched against them by elimination. + * The field crosses worker boundaries via postMessage and is stripped before + * images are attached to an event. */ -export type WasmDebugImage = Extract & { _matchUrls?: string[] }; +export type WasmDebugImage = Extract & { _fromBuffer?: true }; export const IMAGES: Array = []; @@ -51,10 +52,9 @@ export function getModuleInfo(module: WebAssembly.Module): ModuleInfo { * @param module the compiled module * @param url the URL the module was loaded from, or the engine's synthetic * script name for modules compiled from raw bytes - * @param matchUrls additional synthetic script names the engine may use for - * this module in stack frames + * @param fromBuffer whether the module was compiled from raw bytes */ -export function registerModule(module: WebAssembly.Module, url: string, matchUrls?: string[]): DebugImage | null { +export function registerModule(module: WebAssembly.Module, url: string, fromBuffer?: boolean): DebugImage | null { const { buildId, debugFile } = getModuleInfo(module); if (!buildId) { return null; @@ -89,8 +89,8 @@ export function registerModule(module: WebAssembly.Module, url: string, matchUrl debug_id: `${buildId.padEnd(32, '0').slice(0, 32)}0`, }; - if (matchUrls?.length) { - image._matchUrls = matchUrls; + if (fromBuffer) { + image._fromBuffer = true; } IMAGES.push(image); @@ -104,19 +104,11 @@ export function getImages(): Array { return IMAGES; } -/** - * Checks whether an image matches the given frame URL, either via its - * `code_file` or one of the synthetic script names. - */ -export function imageMatchesUrl(image: WasmDebugImage, url: string): boolean { - return image.type === 'wasm' && (image.code_file === url || !!image._matchUrls?.includes(url)); -} - /** * Looks up an image by URL. * * @param url the URL of the WebAssembly module. */ export function getImage(url: string): number { - return IMAGES.findIndex(image => imageMatchesUrl(image, url)); + return IMAGES.findIndex(image => image.type === 'wasm' && image.code_file === url); } diff --git a/packages/wasm/src/syntheticUrl.ts b/packages/wasm/src/syntheticUrl.ts index 4bb5076b9e5b..b384d0600322 100644 --- a/packages/wasm/src/syntheticUrl.ts +++ b/packages/wasm/src/syntheticUrl.ts @@ -1,135 +1,30 @@ /* eslint-disable no-bitwise */ // V8 gives WebAssembly modules that are compiled from raw bytes (instead of // via the streaming APIs, which carry the response URL) a synthetic script -// name of the form `wasm://wasm/` or `wasm://wasm/-` when +// name of the form `wasm://wasm/`, or `wasm://wasm/-` when // the module has a module name in its "name" section. Stack frames of such // modules use that synthetic name as their "url", so registering the debug // image under the same name is the only way to associate frames with the -// image. The hash is V8's internal string hash field of the wire bytes: -// - for byte lengths above kMaxHashCalcLength (16383), V8 does not hash the -// content at all and derives the hash field from the length alone, which -// has been stable across all V8 versions in use, -// - for smaller modules, the content is hashed. V8 <= 13.3 (Chrome <= 133, -// Node <= 24) uses a Jenkins one-at-a-time hash, V8 >= 13.4 uses rapidhash -// (with the seed and secret pinned to their defaults for wasm script names, -// so the output is never process-randomized). We register both candidates -// since we cannot detect the engine version. -// If V8 ever changes this scheme, matching degrades to the single-image -// fallback in `patchFrames` and streaming modules stay unaffected. +// image. +// +// The hash is V8's internal string hash of the wire bytes, and it is only a +// content hash for inputs up to 16383 bytes. Above that V8 skips hashing and +// derives the value from the byte length alone, which is what this module +// reproduces. That rule has been stable across every V8 version since 8.0, +// and real modules are practically always above the cutoff. +// +// Modules at or below the cutoff get a placeholder name instead. Their frames +// are matched by the single-module fallback in `patchFrames`, which does not +// need the name at all. The same fallback covers Firefox, which derives +// script names from the compile call site for modules of any size. +// +// See `CreateWasmScript` in src/wasm/wasm-engine.cc and `GetTrivialHash` in +// src/strings/string-hasher-inl.h in https://github.com/v8/v8. const V8_MAX_HASH_CALC_LENGTH = 16383; -// V8 tags hash fields with 2 bits (hash << 2 | kHashTag). -function toHashField(hash: number): number { - return (hash * 4 + 2) >>> 0; -} - -function toHex(hashField: number): string { - return hashField.toString(16).padStart(8, '0'); -} - -// Jenkins one-at-a-time with V8's finalization and zero seed, masked to the -// 30 hash bits V8 stores (V8 < 13.x). -function jenkinsHashField(bytes: Uint8Array): number { - let h = 0; - for (const byte of bytes) { - h = (h + byte) >>> 0; - h = (h + ((h << 10) >>> 0)) >>> 0; - h = (h ^ (h >>> 6)) >>> 0; - } - h = (h + ((h << 3) >>> 0)) >>> 0; - h = (h ^ (h >>> 11)) >>> 0; - h = (h + ((h << 15) >>> 0)) >>> 0; - h = h & 0x3fffffff; - if (h === 0) { - h = 27; // V8 kZeroHash - } - return toHashField(h); -} - -// V8 does not hash the content of strings longer than kMaxHashCalcLength but -// uses the length itself as the hash. -function lengthHashField(byteLength: number): number { - return toHashField(byteLength); -} - -const MASK_64 = (1n << 64n) - 1n; -const RAPIDHASH_SECRET_0 = 0x2d358dccaa6c78a5n; -const RAPIDHASH_SECRET_1 = 0x8bb84b93962eacc9n; -const RAPIDHASH_SECRET_2 = 0x4b33a62ed433d4a3n; - -function rapidMix(a: bigint, b: bigint): bigint { - const product = a * b; - return (product & MASK_64) ^ (product >> 64n); -} - -function read64(bytes: Uint8Array, offset: number): bigint { - let value = 0n; - for (let i = 7; i >= 0; i--) { - value = (value << 8n) | BigInt(bytes[offset + i] ?? 0); - } - return value; -} - -function read32(bytes: Uint8Array, offset: number): bigint { - let value = 0n; - for (let i = 3; i >= 0; i--) { - value = (value << 8n) | BigInt(bytes[offset + i] ?? 0); - } - return value; -} - -// V8's rapidhash flavor (third_party/rapidhash-v8) with seed 0 and the -// default secret, as used for wasm script names (V8 >= 13.4). Only ever -// called for inputs of at most kMaxHashCalcLength bytes. Valid wasm is at -// least 8 bytes, so the sub-4-byte input branch of the original is omitted. -function rapidhashHashField(bytes: Uint8Array): number { - const length = bytes.length; - const length64 = BigInt(length); - let seed = (rapidMix(RAPIDHASH_SECRET_0, RAPIDHASH_SECRET_1) ^ length64) & MASK_64; - let a: bigint; - let b: bigint; - if (length <= 16) { - const plast = length - 4; - const delta = (length & 24) >> (length >> 3); - a = ((read32(bytes, 0) << 32n) | read32(bytes, plast)) & MASK_64; - b = ((read32(bytes, delta) << 32n) | read32(bytes, plast - delta)) & MASK_64; - } else { - let remaining = length; - let p = 0; - if (remaining > 48) { - let see1 = seed; - let see2 = seed; - do { - seed = rapidMix(read64(bytes, p) ^ RAPIDHASH_SECRET_0, read64(bytes, p + 8) ^ seed); - see1 = rapidMix(read64(bytes, p + 16) ^ RAPIDHASH_SECRET_1, read64(bytes, p + 24) ^ see1); - see2 = rapidMix(read64(bytes, p + 32) ^ RAPIDHASH_SECRET_2, read64(bytes, p + 40) ^ see2); - p += 48; - remaining -= 48; - } while (remaining >= 48); - seed = (seed ^ see1 ^ see2) & MASK_64; - } - if (remaining > 16) { - seed = rapidMix(read64(bytes, p) ^ RAPIDHASH_SECRET_2, read64(bytes, p + 8) ^ seed ^ RAPIDHASH_SECRET_1); - if (remaining > 32) { - seed = rapidMix(read64(bytes, p + 16) ^ RAPIDHASH_SECRET_2, read64(bytes, p + 24) ^ seed); - } - } - a = read64(bytes, p + remaining - 16); - b = read64(bytes, p + remaining - 8); - } - a = (a ^ RAPIDHASH_SECRET_1) & MASK_64; - b = (b ^ seed) & MASK_64; - const product = a * b; - a = product & MASK_64; - b = (product >> 64n) & MASK_64; - const raw = rapidMix((a ^ RAPIDHASH_SECRET_0 ^ length64) & MASK_64, (b ^ RAPIDHASH_SECRET_1) & MASK_64); - let hash = Number(raw & 0x3fffffffn); - if (hash === 0) { - hash = 27; // V8 kZeroHash - } - return toHashField(hash); -} +// Never collides with a real name, which always ends in 8 hex characters. +const UNKNOWN_HASH_PLACEHOLDER = 'unknown'; /** * Returns a normalized byte view over a BufferSource, or undefined if the @@ -153,31 +48,6 @@ export function toByteView(source: unknown): Uint8Array | undefined { return undefined; } -/** - * Computes the hex hash-field candidates V8 may use in the synthetic script - * name for a module with the given wire bytes. - * - * Must be called synchronously when the buffer is received, before the caller - * has a chance to mutate or detach it (engines capture the bytes at call time - * as well). - */ -export function getHashCandidates(bytes: Uint8Array): string[] { - if (bytes.byteLength > V8_MAX_HASH_CALC_LENGTH) { - return [toHex(lengthHashField(bytes.byteLength))]; - } - if (bytes.byteLength < 8) { - // shorter than the wasm header, compilation will fail anyway - return []; - } - const candidates = [toHex(jenkinsHashField(bytes))]; - // Engines without BigInt predate V8's switch to rapidhash, so skipping the - // rapidhash candidate there loses nothing. - if (typeof BigInt === 'function') { - candidates.unshift(toHex(rapidhashHashField(bytes))); - } - return candidates; -} - /** * Extracts the module name from the "name" custom section, if present. */ @@ -218,12 +88,22 @@ export function getModuleName(module: WebAssembly.Module): string | undefined { } /** - * Builds the synthetic script names V8 may report in stack frames for a - * buffer-compiled module. The first entry is used as the debug image's - * `code_file`, all entries are used for frame matching. + * Builds the script name to register a buffer-compiled module under. + * + * The name is the one V8 reports in stack frames when the wire bytes are + * above the content-hashing cutoff, and a placeholder otherwise. + * + * @param module the compiled module + * @param byteLength length of the wire bytes, read before the caller had a + * chance to mutate or detach the buffer */ -export function getSyntheticUrls(module: WebAssembly.Module, hashCandidates: string[]): string[] { +export function getSyntheticUrl(module: WebAssembly.Module, byteLength: number): string { const moduleName = getModuleName(module); const prefix = moduleName ? `${moduleName}-` : ''; - return hashCandidates.map(hash => `wasm://wasm/${prefix}${hash}`); + // V8 stores string hashes in the upper 30 bits of a tagged field. + const suffix = + byteLength > V8_MAX_HASH_CALC_LENGTH + ? ((byteLength * 4 + 2) >>> 0).toString(16).padStart(8, '0') + : UNKNOWN_HASH_PLACEHOLDER; + return `wasm://wasm/${prefix}${suffix}`; } diff --git a/packages/wasm/test/nonstreaming.test.ts b/packages/wasm/test/nonstreaming.test.ts index 733a4d282f3b..5a7a19bf32fc 100644 --- a/packages/wasm/test/nonstreaming.test.ts +++ b/packages/wasm/test/nonstreaming.test.ts @@ -6,7 +6,7 @@ import { patchFrames, registerWebWorkerWasm, wasmIntegration } from '../src/inde import { patchWebAssembly } from '../src/patchWebAssembly'; import type { WasmDebugImage } from '../src/registry'; import { getImages, IMAGES, registerModule } from '../src/registry'; -import { getHashCandidates, getModuleName, toByteView } from '../src/syntheticUrl'; +import { getModuleName, getSyntheticUrl, toByteView } from '../src/syntheticUrl'; const BUILD_ID_BYTES = [0x0b, 0xa0, 0x20, 0xcd, 0xd2, 0x44, 0x4f, 0x7e, 0xaf, 0xdd, 0x25, 0x99, 0x9a, 0x8e, 0x90, 0x10]; const BUILD_ID_HEX = '0ba020cdd2444f7eafdd25999a8e9010'; @@ -101,24 +101,24 @@ afterEach(() => { describe('non-streaming WebAssembly patching', () => { it('registers modules compiled via WebAssembly.instantiate(buffer) and matches real stack frames', async () => { - // Two modules with different content and sizes on both sides of V8's - // 16383-byte content-hashing cutoff, so that matching must go through the - // computed synthetic names and cannot silently succeed via the - // single-image fallback. - const small = buildWasm({ padding: 100 }); - const large = buildWasm({ padding: 20000 }); + // Two modules of different sizes, both above V8's 16383-byte + // content-hashing cutoff, so that matching must go through the computed + // synthetic names and cannot silently succeed via the single-module + // fallback. + const first = buildWasm({ padding: 17000 }); + const second = buildWasm({ padding: 20000 }); - const { instance: smallInstance } = await WebAssembly.instantiate(small); - const { instance: largeInstance } = await WebAssembly.instantiate(large); + const { instance: firstInstance } = await WebAssembly.instantiate(first); + const { instance: secondInstance } = await WebAssembly.instantiate(second); expect(getImages()).toHaveLength(2); expect(getImages()[0]?.code_id).toBe(BUILD_ID_HEX); expect(getImages()[0]?.code_file).toMatch(/^wasm:\/\/wasm\/[0-9a-f]{8}$/); - const smallFilename = trapAndGetWasmFilename(smallInstance); - const largeFilename = trapAndGetWasmFilename(largeInstance); - - const frames = [frameForFilename(smallFilename), frameForFilename(largeFilename)]; + const frames = [ + frameForFilename(trapAndGetWasmFilename(firstInstance)), + frameForFilename(trapAndGetWasmFilename(secondInstance)), + ]; const result = patchFrames(frames); expect(result).toBe(true); @@ -127,6 +127,15 @@ describe('non-streaming WebAssembly patching', () => { expect(frames[1]?.addr_mode).toBe('rel:1'); }); + it('registers modules below the content-hash cutoff under a placeholder name', async () => { + await WebAssembly.instantiate(buildWasm({ padding: 100 })); + + expect(getImages()).toHaveLength(1); + expect(getImages()[0]?.code_id).toBe(BUILD_ID_HEX); + expect(getImages()[0]?.code_file).toBe('wasm://wasm/unknown'); + expect(getImages()[0]?._fromBuffer).toBe(true); + }); + it('registers modules compiled via new WebAssembly.Module() synchronously', () => { const bytes = buildWasm({ padding: 17000 }); const module = new WebAssembly.Module(bytes); @@ -213,7 +222,7 @@ describe('non-streaming WebAssembly patching', () => { }); describe('registerWebWorkerWasm()', () => { - it('forwards buffer images with their match urls and matches frames against them', async () => { + it('forwards buffer images and matches frames against them', async () => { const messages: Array<{ _sentryWasmImages?: WasmDebugImage[] }> = []; registerWebWorkerWasm({ self: { postMessage: (message: unknown) => messages.push(message as never) } }); @@ -222,7 +231,7 @@ describe('registerWebWorkerWasm()', () => { expect(messages).toHaveLength(1); const forwarded = messages[0]?._sentryWasmImages?.[0]; - expect(forwarded?._matchUrls).toEqual(expect.arrayContaining([forwarded?.code_file])); + expect(forwarded?._fromBuffer).toBe(true); // simulate the main thread: the image only exists as a forwarded worker // image, exactly as webWorkerIntegration stores it @@ -240,10 +249,9 @@ describe('registerWebWorkerWasm()', () => { }); describe('processEvent', () => { - it('strips internal match urls from attached debug images', () => { + it('strips internal fields from attached debug images', () => { const bytes = buildWasm({ padding: 17000 }); - new WebAssembly.Module(bytes); - const candidates = getHashCandidates(bytes); + const module = new WebAssembly.Module(bytes); const integration = wasmIntegration(); const event = integration.processEvent?.( @@ -252,7 +260,7 @@ describe('processEvent', () => { values: [ { stacktrace: { - frames: [frameForFilename(`wasm://wasm/${candidates[0]}:wasm-function[0]:0x1e`)], + frames: [frameForFilename(`${getSyntheticUrl(module, bytes.byteLength)}:wasm-function[0]:0x1e`)], }, }, ], @@ -264,15 +272,20 @@ describe('processEvent', () => { const images = event.debug_meta?.images as WasmDebugImage[] | undefined; expect(images).toHaveLength(1); - expect(images?.[0]).not.toHaveProperty('_matchUrls'); + expect(images?.[0]).not.toHaveProperty('_fromBuffer'); expect(images?.[0]?.code_id).toBe(BUILD_ID_HEX); }); }); describe('syntheticUrl helpers', () => { - it('computes the stable length-based hash for modules above the content-hash cutoff', () => { - const bytes = new Uint8Array(20038); - expect(getHashCandidates(bytes)).toEqual(['0001391a']); + it('derives the name from the byte length above the content-hash cutoff', () => { + const module = new WebAssembly.Module(buildWasm({ padding: 17000 })); + expect(getSyntheticUrl(module, 20038)).toBe('wasm://wasm/0001391a'); + }); + + it('falls back to a placeholder name at or below the content-hash cutoff', () => { + const module = new WebAssembly.Module(buildWasm({ moduleName: 'mymod' })); + expect(getSyntheticUrl(module, 16383)).toBe('wasm://wasm/mymod-unknown'); }); it('normalizes BufferSource values', () => {