From 94646d2ee01a4f4c19cfe5cd93e7563f25ac3a3b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 13 Aug 2026 15:33:40 +0200 Subject: [PATCH] fix(wasm): Register modules loaded via non-streaming WebAssembly APIs Co-Authored-By: Claude Fable 5 --- .../suites/wasm/instantiateBuffer/init.js | 15 + .../suites/wasm/instantiateBuffer/subject.js | 52 +++ .../suites/wasm/instantiateBuffer/test.ts | 132 ++++++ packages/wasm/src/index.ts | 140 +++--- packages/wasm/src/patchWebAssembly.ts | 116 ++++- packages/wasm/src/registry.ts | 54 ++- packages/wasm/src/syntheticUrl.ts | 227 ++++++++++ packages/wasm/test/nonstreaming.test.ts | 406 ++++++++++++++++++ 8 files changed, 1049 insertions(+), 93 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..a945d27eb515 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts @@ -0,0 +1,132 @@ +import { expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipWASMTests } from '../../../utils/wasmHelpers'; + +sentryTest( + 'captured exception should include modified frames and debug_meta for non-streaming instantiation @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/simple.wasm', route => { + const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'simple.wasm')); + + return route.fulfill({ + status: 200, + body: wasmModule, + headers: { + 'Content-Type': 'application/wasm', + }, + }); + }); + + 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({ + filename: + browserName === 'firefox' + ? expect.stringContaining('> WebAssembly.instantiate') + : expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/), + function: 'internal_func', + in_app: true, + instruction_addr: '0x8c', + addr_mode: 'rel:0', + platform: 'native', + }), + ]), + ); + + expect(event.debug_meta).toMatchObject({ + images: [ + { + code_file: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/), + code_id: '0ba020cdd2444f7eafdd25999a8e9010', + debug_file: null, + debug_id: '0ba020cdd2444f7eafdd25999a8e90100', + type: 'wasm', + }, + ], + }); + + // On V8 the small-module (content-hashed) synthetic name must match + // exactly, frames and image alike. + if (browserName === 'chromium') { + 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 @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/simple.wasm', route => { + const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'simple.wasm')); + + return route.fulfill({ + status: 200, + body: wasmModule, + headers: { + 'Content-Type': 'application/wasm', + }, + }); + }); + + 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({ + filename: browserName === 'firefox' ? expect.stringContaining('> WebAssembly.instantiate') : expectedUrl, + function: 'internal_func', + in_app: true, + instruction_addr: '0x8c', + addr_mode: 'rel:0', + platform: 'native', + }), + ]), + ); + + expect(event.debug_meta).toMatchObject({ + images: [ + { + code_file: expectedUrl, + code_id: '0ba020cdd2444f7eafdd25999a8e9010', + debug_file: null, + debug_id: '0ba020cdd2444f7eafdd25999a8e90100', + type: 'wasm', + }, + ], + }); + }, +); diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 6f75c4d454ca..061b34ab5504 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,14 +33,14 @@ 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 = {}) => { return { name: INTEGRATION_NAME, setupOnce() { - patchWebAssembly(); + patchWebAssembly(registerModule); }, processEvent(event: Event): Event { let hasAtLeastOneWasmFrameWithImage = false; @@ -50,8 +51,8 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { event.exception.values.forEach(exception => { if (exception.stacktrace?.frames) { hasAtLeastOneWasmFrameWithImage = - hasAtLeastOneWasmFrameWithImage || - patchFrames(exception.stacktrace.frames, options.applicationKey, existingImagesCount); + patchFrames(exception.stacktrace.frames, options.applicationKey, existingImagesCount) || + hasAtLeastOneWasmFrameWithImage; } }); } @@ -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; @@ -130,6 +135,19 @@ export function patchFrames( const mainThreadImagesCount = getImages().length; frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`; hasAtLeastOneWasmFrameWithImage = true; + } else if (isCallSiteDerivedWasmFilename(match[1])) { + // Firefox derives script names for buffer-compiled modules from the + // compile call site, which cannot be predicted at registration time. + // If exactly one distinct module was registered from raw bytes, the + // frame can only belong to that module. Unmatched `wasm://` names are + // deliberately NOT handled here: those come from modules that were + // compiled before the SDK was initialized, and attributing them to an + // unrelated image would mis-symbolicate. + const fallbackIndex = getSingleBufferImageIndex(); + if (fallbackIndex >= 0) { + frame.addr_mode = `rel:${existingImagesOffset + fallbackIndex}`; + hasAtLeastOneWasmFrameWithImage = true; + } } } }); @@ -137,83 +155,71 @@ 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)); +} + +function isCallSiteDerivedWasmFilename(filename: string): boolean { + return filename.includes('> WebAssembly.'); +} + +/** + * Returns the index (across main-thread and worker images) of the only + * distinct image that was registered from raw bytes, or -1 if there is none + * or more than one. The same module registered on several threads counts + * once, since the images share their (content-derived) `code_file`. + */ +function getSingleBufferImageIndex(): number { + const mainImages = getImages(); + const workerImages = getWorkerImages(); + let index = -1; + const codeFiles = new Set(); + mainImages.forEach((image, i) => { + if (image._matchUrls && !codeFiles.has(image.code_file)) { + codeFiles.add(image.code_file); + index = i; + } }); + workerImages.forEach((image, i) => { + if (image._matchUrls && !codeFiles.has(image.code_file)) { + codeFiles.add(image.code_file); + index = mainImages.length + i; + } + }); + return codeFiles.size === 1 ? index : -1; } /** * 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 { - patchWebAssemblyWithForwarding(self); -} + patchWebAssembly((module, url, matchUrls) => { + const image = registerModule(module, url, matchUrls); -/** - * Patches the WebAssembly object in the worker scope and forwards - * registered modules to the parent thread. - */ -function patchWebAssemblyWithForwarding(workerSelf: MinimalDedicatedWorkerGlobalScope): void { - if ('instantiateStreaming' in WebAssembly) { - const origInstantiateStreaming = WebAssembly.instantiateStreaming; - WebAssembly.instantiateStreaming = function instantiateStreaming( - response: Response | PromiseLike, - importObject: WebAssembly.Imports, - ): Promise { - return Promise.resolve(response).then(response => { - return origInstantiateStreaming(response, importObject).then(rv => { - if (response.url) { - registerModuleAndForward(rv.module, response.url, workerSelf); - } - return rv; - }); - }); - } as typeof WebAssembly.instantiateStreaming; - } - - if ('compileStreaming' in WebAssembly) { - const origCompileStreaming = WebAssembly.compileStreaming; - WebAssembly.compileStreaming = function compileStreaming( - source: Response | Promise, - ): Promise { - return Promise.resolve(source).then(response => { - return origCompileStreaming(response).then(module => { - if (response.url) { - registerModuleAndForward(module, response.url, workerSelf); - } - return module; - }); + if (image) { + self.postMessage({ + _sentryMessage: true, + _sentryWasmImages: [image], }); - } as typeof WebAssembly.compileStreaming; - } -} - -/** - * Registers a WASM module and forwards its debug image to the parent thread. - */ -function registerModuleAndForward( - module: WebAssembly.Module, - url: string, - workerSelf: MinimalDedicatedWorkerGlobalScope, -): void { - const image = registerModule(module, url); - - if (image) { - workerSelf.postMessage({ - _sentryMessage: true, - _sentryWasmImages: [image], - }); - } + } + }); } diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index 89c15e72b1a7..d353b35bc7e0 100644 --- a/packages/wasm/src/patchWebAssembly.ts +++ b/packages/wasm/src/patchWebAssembly.ts @@ -1,39 +1,127 @@ -import { registerModule } from './registry'; +import { getHashCandidates, getSyntheticUrls, toByteView } from './syntheticUrl'; + +export type RegisterModuleCallback = (module: WebAssembly.Module, url: string, matchUrls?: string[]) => void; /** - * Patches the web assembly runtime. + * 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 */ -export function patchWebAssembly(): void { +export function patchWebAssembly(registerModule: RegisterModuleCallback): void { if ('instantiateStreaming' in WebAssembly) { - const origInstantiateStreaming = WebAssembly.instantiateStreaming; + const origInstantiateStreaming = WebAssembly.instantiateStreaming as ( + response: unknown, + ...rest: unknown[] + ) => Promise; WebAssembly.instantiateStreaming = function instantiateStreaming( response: Response | PromiseLike, - importObject: WebAssembly.Imports, - ): Promise { + ...rest: unknown[] + ): Promise { return Promise.resolve(response).then(response => { - return origInstantiateStreaming(response, importObject).then(rv => { + return origInstantiateStreaming(response, ...rest).then(rv => { if (response.url) { - registerModule(rv.module, response.url); + registerSafely(registerModule, rv.module, response.url); } return rv; }); }); - } as typeof WebAssembly.instantiateStreaming; + }; } if ('compileStreaming' in WebAssembly) { - const origCompileStreaming = WebAssembly.compileStreaming; + const origCompileStreaming = WebAssembly.compileStreaming as ( + source: unknown, + ...rest: unknown[] + ) => Promise; WebAssembly.compileStreaming = function compileStreaming( - source: Response | Promise, + source: Response | PromiseLike, + ...rest: unknown[] ): Promise { return Promise.resolve(source).then(response => { - return origCompileStreaming(response).then(module => { + return origCompileStreaming(response, ...rest).then(module => { if (response.url) { - registerModule(module, response.url); + registerSafely(registerModule, module, response.url); } return module; }); }); - } as typeof WebAssembly.compileStreaming; + }; + } + + 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 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, + matchUrls?: string[], +): void { + try { + 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..2609e1c35e9a --- /dev/null +++ b/packages/wasm/src/syntheticUrl.ts @@ -0,0 +1,227 @@ +/* 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 = [0x2d358dccaa6c78a5n, 0x8bb84b93962eacc9n, 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]!); + } + 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]!); + } + 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++]!; + 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..77ff2ad9f60e --- /dev/null +++ b/packages/wasm/test/nonstreaming.test.ts @@ -0,0 +1,406 @@ +/* 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('single-buffer-image fallback', () => { + // The patched Module constructor registers the module as a buffer image. + function registerBufferImage(bytes: Uint8Array): void { + new WebAssembly.Module(bytes); + } + + it('matches unpredicted synthetic names when exactly one buffer image exists', () => { + registerBufferImage(buildWasm({ padding: 17000 })); + + const frames = [ + frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'), + ]; + + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + expect(frames[0]?.platform).toBe('native'); + }); + + it('does not fall back when the filename is a regular url', () => { + registerBufferImage(buildWasm({ padding: 17000 })); + + const frames = [frameForFilename('http://localhost:8001/other.wasm:wasm-function[0]:0x1e')]; + + expect(patchFrames(frames)).toBe(false); + expect(frames[0]?.addr_mode).toBeUndefined(); + }); + + it('does not fall back when multiple buffer images exist', () => { + registerBufferImage(buildWasm({ padding: 17000 })); + registerBufferImage(buildWasm({ padding: 18000 })); + + const frames = [ + frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'), + ]; + + expect(patchFrames(frames)).toBe(false); + }); + + it('does not fall back for images registered from streaming urls', () => { + const module = new WebAssembly.Module(buildWasm({ padding: 17000 })); + IMAGES.length = 0; // drop the auto-registered buffer image + registerModule(module, 'http://localhost:8001/main.wasm'); + + const frames = [ + frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'), + ]; + + expect(patchFrames(frames)).toBe(false); + }); + + it('does not fall back for unmatched wasm:// names of modules compiled before SDK init', () => { + registerBufferImage(buildWasm({ padding: 17000 })); + + const frames = [frameForFilename('wasm://wasm/ffffffff:wasm-function[0]:0x1e')]; + + expect(patchFrames(frames)).toBe(false); + expect(frames[0]?.addr_mode).toBeUndefined(); + }); + + it('falls back when the same module is registered on the main thread and in a worker', () => { + const bytes = buildWasm({ padding: 17000 }); + registerBufferImage(bytes); + (GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages = [ + { ...(getImages()[0] as WasmDebugImage) }, + ]; + + try { + const frames = [ + frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'), + ]; + + 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); + }); + + it('patches frames of all exception values, not only the first matching one', () => { + const bytes = buildWasm({ padding: 17000 }); + new WebAssembly.Module(bytes); + const candidates = getHashCandidates(bytes); + const wasmFilename = `wasm://wasm/${candidates[0]}:wasm-function[0]:0x1e`; + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { stacktrace: { frames: [frameForFilename(wasmFilename)] } }, + { stacktrace: { frames: [frameForFilename(wasmFilename)] } }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + const frames = event.exception?.values?.map(value => value.stacktrace?.frames?.[0]); + expect(frames?.[0]?.addr_mode).toBe('rel:0'); + expect(frames?.[1]?.addr_mode).toBe('rel:0'); + }); +}); + +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(); + }); +});