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..4ba420eb54cd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts @@ -0,0 +1,72 @@ +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( + '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..f028dbcf265d 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -1,6 +1,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, 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(stripInternalFields), + ...workerImages.map(stripInternalFields), + ]; } return event; @@ -137,29 +142,35 @@ export function patchFrames( return hasAtLeastOneWasmFrameWithImage; } +function getWorkerImages(): Array { + return WINDOW._sentryWasmImages || []; +} + +function stripInternalFields(image: WasmDebugImage): DebugImage { + const { _fromBuffer, ...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 => image.type === 'wasm' && image.code_file === 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, 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 e4f7b527a2a0..fd94ee5f165d 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 { getSyntheticUrl, toByteView } from './syntheticUrl'; + +export type RegisterModuleCallback = (module: WebAssembly.Module, url: string, fromBuffer?: boolean) => 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,71 @@ export function patchWebAssembly(registerModule: RegisterModuleCallback): void { }); }; } + + 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 + // 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); + // 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 (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, byteLength); + 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 byteLength = bytes?.byteLength; + const result = origCompile(source, ...rest); + if (byteLength !== undefined) { + return result.then(module => { + registerFromBuffer(module, byteLength); + 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 byteLength = toByteView(args[0])?.byteLength; + const module = Reflect.construct(target, args, newTarget) as WebAssembly.Module; + if (byteLength !== undefined) { + registerFromBuffer(module, byteLength); + } + return module; + }, + }); } -function registerSafely(registerModule: RegisterModuleCallback, module: WebAssembly.Module, url: string): void { +function registerSafely( + registerModule: RegisterModuleCallback, + module: WebAssembly.Module, + url: string, + fromBuffer?: boolean, +): void { try { - registerModule(module, url); + 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 2ca6d66754dc..2688f9468d2a 100644 --- a/packages/wasm/src/registry.ts +++ b/packages/wasm/src/registry.ts @@ -1,6 +1,15 @@ import type { DebugImage } from '@sentry/core'; -export const IMAGES: Array = []; +/** + * 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 & { _fromBuffer?: true }; + +export const IMAGES: Array = []; export interface ModuleInfo { buildId: string | null; @@ -39,8 +48,13 @@ 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 fromBuffer whether the module was compiled from raw bytes */ -export function registerModule(module: WebAssembly.Module, url: string): DebugImage | null { +export function registerModule(module: WebAssembly.Module, url: string, fromBuffer?: boolean): 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 (fromBuffer) { + image._fromBuffer = true; + } + IMAGES.push(image); return image; } @@ -76,7 +100,7 @@ export function registerModule(module: WebAssembly.Module, url: string): DebugIm /** * Returns all known images. */ -export function getImages(): Array { +export function getImages(): Array { return IMAGES; } @@ -86,7 +110,5 @@ export function getImages(): Array { * @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 => image.type === 'wasm' && image.code_file === url); } diff --git a/packages/wasm/src/syntheticUrl.ts b/packages/wasm/src/syntheticUrl.ts new file mode 100644 index 000000000000..b384d0600322 --- /dev/null +++ b/packages/wasm/src/syntheticUrl.ts @@ -0,0 +1,109 @@ +/* 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 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; + +// 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 + * 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; +} + +/** + * 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 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 getSyntheticUrl(module: WebAssembly.Module, byteLength: number): string { + const moduleName = getModuleName(module); + const prefix = moduleName ? `${moduleName}-` : ''; + // 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 new file mode 100644 index 000000000000..5a7a19bf32fc --- /dev/null +++ b/packages/wasm/test/nonstreaming.test.ts @@ -0,0 +1,315 @@ +/* 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 { 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'; + +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 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: 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 frames = [ + frameForFilename(trapAndGetWasmFilename(firstInstance)), + frameForFilename(trapAndGetWasmFilename(secondInstance)), + ]; + 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 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); + + 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 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?._fromBuffer).toBe(true); + + // 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 fields from attached debug images', () => { + const bytes = buildWasm({ padding: 17000 }); + const module = new WebAssembly.Module(bytes); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [frameForFilename(`${getSyntheticUrl(module, bytes.byteLength)}: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('_fromBuffer'); + expect(images?.[0]?.code_id).toBe(BUILD_ID_HEX); + }); +}); + +describe('syntheticUrl helpers', () => { + 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', () => { + 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(); + }); +});