From 5ae3418c2e2eb2224e62e6a4bf7ba1674ac580ae Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Mon, 31 Aug 2026 14:36:20 +0200 Subject: [PATCH 01/10] feat(wasm): patch non-streaming load paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Patch `Response.prototype.arrayBuffer` and `bytes` to tag wasm buffers with `response.url` in a `WeakMap` - Hook `WebAssembly.instantiate` and `compile` to use tagged URL to register module - Skip registration when `instantiate` receives an already-compiled `WebAssembly.Module` - Split `patchWebAssembly` into response, non-streaming, and streaming setup; guard non-streaming with `nonStreamingPatched` - Add `patchWebAssembly.test.ts` for fetch → arrayBuffer → instantiate/compile - Extend `webworker.test.ts` to restore patched globals and assert `instantiate` is hooked --- packages/wasm/src/patchWasmResponse.ts | 103 ++++++++++++++++++++ packages/wasm/src/patchWebAssembly.ts | 75 +++++++++++++- packages/wasm/test/patchWebAssembly.test.ts | 96 ++++++++++++++++-- packages/wasm/test/wasmTestHelpers.ts | 39 ++++++++ packages/wasm/test/webworker.test.ts | 16 ++- 5 files changed, 317 insertions(+), 12 deletions(-) create mode 100644 packages/wasm/src/patchWasmResponse.ts create mode 100644 packages/wasm/test/wasmTestHelpers.ts diff --git a/packages/wasm/src/patchWasmResponse.ts b/packages/wasm/src/patchWasmResponse.ts new file mode 100644 index 000000000000..ba0571e5a1c0 --- /dev/null +++ b/packages/wasm/src/patchWasmResponse.ts @@ -0,0 +1,103 @@ +/** + * Streaming wasm registration (`instantiateStreaming` / `compileStreaming`) reads the module URL + * from `Response.url`. Non-streaming paths (`WebAssembly.instantiate` / `compile` with bytes) only + * receive a buffer — no URL — so registration would otherwise be skipped. + * + * This module patches `Response.prototype.arrayBuffer` and `bytes` so that when wasm is fetched + * and then loaded from bytes, we can map the resulting `ArrayBuffer` back to the fetch URL via + * `getWasmSourceUrl()` and register the module in `patchNonStreamingWebAssembly`. + */ +const wasmSourceUrls = new WeakMap(); + +const PATCHED_SYMBOL = Symbol.for('__sentryWasmPatched'); + +type MaybePatched = { [PATCHED_SYMBOL]?: boolean }; + +/** + * Resolves a wasm source buffer back to its fetch URL, when known. + */ +export function getWasmSourceUrl(source: BufferSource): string | undefined { + const buffer = toArrayBuffer(source); + if (!buffer) { + return undefined; + } + + return wasmSourceUrls.get(buffer); +} + +function toArrayBuffer(source: BufferSource): ArrayBuffer | undefined { + if (source instanceof ArrayBuffer) { + return source; + } + + if (ArrayBuffer.isView(source)) { + const { buffer } = source; + return buffer instanceof ArrayBuffer ? buffer : undefined; + } + + return undefined; +} + +function looksLikeWasmResponse(response: Response): boolean { + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/wasm')) { + return true; + } + + const { url } = response; + return Boolean(url && /\.wasm(?:\?|#|$)/i.test(url)); +} + +function tagResponseBuffer(response: Response, buffer: ArrayBuffer): void { + if (looksLikeWasmResponse(response) && response.url) { + wasmSourceUrls.set(buffer, response.url); + } +} + +/** + * Patches Response body readers so wasm bytes remember their fetch URL. + */ +export function patchWasmResponseBodyReaders(): void { + if (typeof Response === 'undefined') { + return; + } + + const responseProto = Response.prototype as MaybePatched; + if (responseProto[PATCHED_SYMBOL]) { + return; + } + + responseProto[PATCHED_SYMBOL] = true; + + // oxlint-disable-next-line typescript/unbound-method + const origArrayBuffer: (this: Response) => Promise = Response.prototype.arrayBuffer; + Response.prototype.arrayBuffer = function arrayBuffer(this: Response): Promise { + const bufferPromise: Promise = origArrayBuffer.call(this); + return bufferPromise.then((buffer: ArrayBuffer) => { + tagResponseBuffer(this, buffer); + return buffer; + }); + }; + + if ('bytes' in Response.prototype) { + // oxlint-disable-next-line typescript/unbound-method + const origBytes: (this: Response) => Promise = Response.prototype.bytes; + Response.prototype.bytes = function bytes(this: Response) { + const bytesPromise: Promise = origBytes.call(this); + return bytesPromise.then((bytes: Uint8Array) => { + const { buffer } = bytes; + if (buffer instanceof ArrayBuffer) { + tagResponseBuffer(this, buffer); + } + return bytes; + }); + } as typeof Response.prototype.bytes; + } +} + +/** @internal */ +export function _resetResponsePatchForTests(): void { + if (typeof Response !== 'undefined') { + (Response.prototype as MaybePatched)[PATCHED_SYMBOL] = false; + } +} diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index e4f7b527a2a0..64a6e4318411 100644 --- a/packages/wasm/src/patchWebAssembly.ts +++ b/packages/wasm/src/patchWebAssembly.ts @@ -1,5 +1,9 @@ +import { getWasmSourceUrl, patchWasmResponseBodyReaders } from './patchWasmResponse'; + export type RegisterModuleCallback = (module: WebAssembly.Module, url: string) => void; +let nonStreamingPatched = false; + /** * 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 @@ -7,7 +11,7 @@ export type RegisterModuleCallback = (module: WebAssembly.Module, url: string) = * * @param registerModule callback invoked for every successfully compiled module */ -export function patchWebAssembly(registerModule: RegisterModuleCallback): void { +export function patchStreamingWebAssembly(registerModule: RegisterModuleCallback): void { if ('instantiateStreaming' in WebAssembly) { const origInstantiateStreaming = WebAssembly.instantiateStreaming as ( response: unknown, @@ -56,3 +60,72 @@ function registerSafely(registerModule: RegisterModuleCallback, module: WebAssem // a registration failure must never break the user's WebAssembly call } } + +function registerFromBufferSource( + registerModule: RegisterModuleCallback, + module: WebAssembly.Module, + source: BufferSource, +): void { + const url = getWasmSourceUrl(source); + if (url) { + registerModule(module, url); + } +} + +/** + * Patches the non-streaming web assembly runtime. + */ +function patchNonStreamingWebAssembly(registerModule: RegisterModuleCallback): void { + if (nonStreamingPatched) { + return; + } + + nonStreamingPatched = true; + + const origInstantiate = WebAssembly.instantiate; + WebAssembly.instantiate = function instantiate( + source: BufferSource | WebAssembly.Module, + importObject?: WebAssembly.Imports, + ) { + if (source instanceof WebAssembly.Module) { + return ( + origInstantiate as ( + moduleObject: WebAssembly.Module, + importObject?: WebAssembly.Imports, + ) => Promise + )(source, importObject); + } + + return ( + origInstantiate as ( + bytes: BufferSource, + importObject?: WebAssembly.Imports, + ) => Promise + )(source, importObject).then(result => { + registerFromBufferSource(registerModule, result.module, source); + return result; + }); + } as typeof WebAssembly.instantiate; + + const origCompile = WebAssembly.compile; + WebAssembly.compile = function compile(source: BufferSource): Promise { + return origCompile(source).then(module => { + registerFromBufferSource(registerModule, module, source); + return module; + }); + }; +} + +/** + * Patches the web assembly runtime. + */ +export function patchWebAssembly(registerModule: RegisterModuleCallback): void { + patchWasmResponseBodyReaders(); + patchNonStreamingWebAssembly(registerModule); + patchStreamingWebAssembly(registerModule); +} + +/** @internal */ +export function _resetNonStreamingPatchForTests(): void { + nonStreamingPatched = false; +} diff --git a/packages/wasm/test/patchWebAssembly.test.ts b/packages/wasm/test/patchWebAssembly.test.ts index 6a4e46c9364f..1893635f8209 100644 --- a/packages/wasm/test/patchWebAssembly.test.ts +++ b/packages/wasm/test/patchWebAssembly.test.ts @@ -1,16 +1,47 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { patchWebAssembly } from '../src/patchWebAssembly'; +import { getImage, IMAGES, registerModule } from '../src/registry'; +import { restoreWasmGlobals, saveWasmGlobals } from './wasmTestHelpers'; const RESPONSE = { url: 'http://localhost:8001/main.wasm' } as Response; const MODULE = {} as WebAssembly.Module; -describe('patchWebAssembly()', () => { - const originalInstantiateStreaming = WebAssembly.instantiateStreaming; - const originalCompileStreaming = WebAssembly.compileStreaming; +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const SIMPLE_WASM_PATH = path.resolve( + testDir, + '../../../dev-packages/browser-integration-tests/suites/wasm/simple.wasm', +); + +const WASM_URL = 'https://example.com/simple.wasm'; + +const WASM_IMPORTS = { + env: { + external_func: () => {}, + }, +}; + +async function loadWasmBytes(): Promise { + return new Uint8Array(fs.readFileSync(SIMPLE_WASM_PATH)); +} + +async function fetchWasmBytes(): Promise { + const bytes = await loadWasmBytes(); + const response = new Response(bytes, { + headers: { 'Content-Type': 'application/wasm' }, + }); + Object.defineProperty(response, 'url', { value: WASM_URL }); + + return response.arrayBuffer(); +} + +describe('patchWebAssembly() streaming registration', () => { + const savedGlobals = saveWasmGlobals(); afterEach(() => { - WebAssembly.instantiateStreaming = originalInstantiateStreaming; - WebAssembly.compileStreaming = originalCompileStreaming; + restoreWasmGlobals(savedGlobals); }); it('forwards every argument to instantiateStreaming and registers the module', async () => { @@ -70,3 +101,56 @@ describe('patchWebAssembly()', () => { await expect(WebAssembly.compileStreaming(RESPONSE)).resolves.toBe(MODULE); }); }); + +describe('patchWebAssembly() non-streaming registration', () => { + const savedGlobals = saveWasmGlobals(); + + beforeAll(() => { + patchWebAssembly(registerModule); + }); + + afterAll(() => { + restoreWasmGlobals(savedGlobals); + }); + + beforeEach(() => { + IMAGES.length = 0; + }); + + it('registers modules loaded via fetch → arrayBuffer → instantiate', async () => { + const buffer = await fetchWasmBytes(); + + await WebAssembly.instantiate(buffer, WASM_IMPORTS); + + expect(getImage(WASM_URL)).toBe(0); + expect(IMAGES[0]?.code_file).toBe(WASM_URL); + expect(IMAGES[0]?.code_id).toBe('0ba020cdd2444f7eafdd25999a8e9010'); + }); + + it('registers modules loaded via fetch → arrayBuffer → Uint8Array → instantiate', async () => { + const buffer = await fetchWasmBytes(); + const view = new Uint8Array(buffer); + + await WebAssembly.instantiate(view, WASM_IMPORTS); + + expect(getImage(WASM_URL)).toBe(0); + expect(IMAGES[0]?.code_file).toBe(WASM_URL); + }); + + it('registers modules loaded via fetch → arrayBuffer → compile', async () => { + const buffer = await fetchWasmBytes(); + + await WebAssembly.compile(buffer); + + expect(getImage(WASM_URL)).toBe(0); + expect(IMAGES[0]?.code_file).toBe(WASM_URL); + }); + + it('does not register modules when the buffer has no tagged URL', async () => { + const bytes = await loadWasmBytes(); + + await WebAssembly.instantiate(bytes, WASM_IMPORTS); + + expect(IMAGES).toHaveLength(0); + }); +}); diff --git a/packages/wasm/test/wasmTestHelpers.ts b/packages/wasm/test/wasmTestHelpers.ts new file mode 100644 index 000000000000..c8d343806037 --- /dev/null +++ b/packages/wasm/test/wasmTestHelpers.ts @@ -0,0 +1,39 @@ +import { _resetResponsePatchForTests } from '../src/patchWasmResponse'; +import { _resetNonStreamingPatchForTests } from '../src/patchWebAssembly'; + +export type SavedWasmGlobals = { + instantiate: typeof WebAssembly.instantiate; + compile: typeof WebAssembly.compile; + instantiateStreaming?: typeof WebAssembly.instantiateStreaming; + compileStreaming?: typeof WebAssembly.compileStreaming; + arrayBuffer: typeof Response.prototype.arrayBuffer; + bytes?: typeof Response.prototype.bytes; +}; + +export function saveWasmGlobals(): SavedWasmGlobals { + return { + instantiate: WebAssembly.instantiate, + compile: WebAssembly.compile, + instantiateStreaming: WebAssembly.instantiateStreaming, + compileStreaming: WebAssembly.compileStreaming, + arrayBuffer: Response.prototype.arrayBuffer, + bytes: 'bytes' in Response.prototype ? Response.prototype.bytes : undefined, + }; +} + +export function restoreWasmGlobals(saved: SavedWasmGlobals): void { + WebAssembly.instantiate = saved.instantiate; + WebAssembly.compile = saved.compile; + if (saved.instantiateStreaming) { + WebAssembly.instantiateStreaming = saved.instantiateStreaming; + } + if (saved.compileStreaming) { + WebAssembly.compileStreaming = saved.compileStreaming; + } + Response.prototype.arrayBuffer = saved.arrayBuffer; + if (saved.bytes) { + Response.prototype.bytes = saved.bytes; + } + _resetNonStreamingPatchForTests(); + _resetResponsePatchForTests(); +} diff --git a/packages/wasm/test/webworker.test.ts b/packages/wasm/test/webworker.test.ts index afaa5d999966..a4b42aefd77c 100644 --- a/packages/wasm/test/webworker.test.ts +++ b/packages/wasm/test/webworker.test.ts @@ -1,14 +1,22 @@ import type { DebugImage, StackFrame } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { patchFrames, registerWebWorkerWasm } from '../src/index'; +import { restoreWasmGlobals, saveWasmGlobals } from './wasmTestHelpers'; const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryWasmImages?: Array; }; describe('registerWebWorkerWasm()', () => { + let savedGlobals = saveWasmGlobals(); + + beforeEach(() => { + savedGlobals = saveWasmGlobals(); + }); + afterEach(() => { + restoreWasmGlobals(savedGlobals); delete WINDOW._sentryWasmImages; vi.restoreAllMocks(); }); @@ -18,12 +26,12 @@ describe('registerWebWorkerWasm()', () => { const mockSelf = { postMessage: mockPostMessage }; const originalInstantiateStreaming = WebAssembly.instantiateStreaming; + const originalInstantiate = WebAssembly.instantiate; registerWebWorkerWasm({ self: mockSelf }); expect(WebAssembly.instantiateStreaming).not.toBe(originalInstantiateStreaming); - - WebAssembly.instantiateStreaming = originalInstantiateStreaming; + expect(WebAssembly.instantiate).not.toBe(originalInstantiate); }); it('should patch WebAssembly.compileStreaming when available', () => { @@ -35,8 +43,6 @@ describe('registerWebWorkerWasm()', () => { registerWebWorkerWasm({ self: mockSelf }); expect(WebAssembly.compileStreaming).not.toBe(originalCompileStreaming); - - WebAssembly.compileStreaming = originalCompileStreaming; }); }); From 99a2a604a6e05a5e267af276f6998c6adfbf6c42 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 1 Sep 2026 00:15:39 +0200 Subject: [PATCH 02/10] Forward extra arguments and guard buffer registration --- packages/wasm/src/patchWebAssembly.ts | 34 ++++++--------- packages/wasm/test/patchWebAssembly.test.ts | 48 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index 64a6e4318411..005a749c618b 100644 --- a/packages/wasm/src/patchWebAssembly.ts +++ b/packages/wasm/src/patchWebAssembly.ts @@ -68,7 +68,7 @@ function registerFromBufferSource( ): void { const url = getWasmSourceUrl(source); if (url) { - registerModule(module, url); + registerSafely(registerModule, module, url); } } @@ -82,34 +82,26 @@ function patchNonStreamingWebAssembly(registerModule: RegisterModuleCallback): v nonStreamingPatched = true; - const origInstantiate = WebAssembly.instantiate; - WebAssembly.instantiate = function instantiate( - source: BufferSource | WebAssembly.Module, - importObject?: WebAssembly.Imports, - ) { + // 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: BufferSource | WebAssembly.Module, ...rest: unknown[]) { if (source instanceof WebAssembly.Module) { - return ( - origInstantiate as ( - moduleObject: WebAssembly.Module, - importObject?: WebAssembly.Imports, - ) => Promise - )(source, importObject); + return origInstantiate(source, ...rest); } - return ( - origInstantiate as ( - bytes: BufferSource, - importObject?: WebAssembly.Imports, - ) => Promise - )(source, importObject).then(result => { + return origInstantiate(source, ...rest).then(result => { registerFromBufferSource(registerModule, result.module, source); return result; }); } as typeof WebAssembly.instantiate; - const origCompile = WebAssembly.compile; - WebAssembly.compile = function compile(source: BufferSource): Promise { - return origCompile(source).then(module => { + const origCompile = WebAssembly.compile as (source: unknown, ...rest: unknown[]) => Promise; + WebAssembly.compile = function compile(source: BufferSource, ...rest: unknown[]): Promise { + return origCompile(source, ...rest).then(module => { registerFromBufferSource(registerModule, module, source); return module; }); diff --git a/packages/wasm/test/patchWebAssembly.test.ts b/packages/wasm/test/patchWebAssembly.test.ts index 1893635f8209..29eaef416d56 100644 --- a/packages/wasm/test/patchWebAssembly.test.ts +++ b/packages/wasm/test/patchWebAssembly.test.ts @@ -154,3 +154,51 @@ describe('patchWebAssembly() non-streaming registration', () => { expect(IMAGES).toHaveLength(0); }); }); + +describe('patchWebAssembly() non-streaming argument forwarding', () => { + const savedGlobals = saveWasmGlobals(); + + afterEach(() => { + restoreWasmGlobals(savedGlobals); + }); + + it('forwards every argument to instantiate', async () => { + const orig = vi.fn().mockResolvedValue({ module: MODULE, instance: {} }); + WebAssembly.instantiate = orig as unknown as typeof WebAssembly.instantiate; + + patchWebAssembly(registerModule); + + const bytes = new Uint8Array(8); + const compileOptions = { builtins: ['js-string'] }; + await (WebAssembly.instantiate as unknown as (...args: unknown[]) => Promise)( + bytes, + WASM_IMPORTS, + compileOptions, + ); + + expect(orig).toHaveBeenCalledWith(bytes, WASM_IMPORTS, compileOptions); + }); + + it('forwards every argument to compile', async () => { + const orig = vi.fn().mockResolvedValue(MODULE); + WebAssembly.compile = orig as unknown as typeof WebAssembly.compile; + + patchWebAssembly(registerModule); + + const bytes = new Uint8Array(8); + const compileOptions = { builtins: ['js-string'] }; + await (WebAssembly.compile as unknown as (...args: unknown[]) => Promise)(bytes, compileOptions); + + expect(orig).toHaveBeenCalledWith(bytes, compileOptions); + }); + + it('resolves the original result even if registration throws', async () => { + patchWebAssembly(() => { + throw new Error('registration failed'); + }); + + const buffer = await fetchWasmBytes(); + + await expect(WebAssembly.compile(buffer)).resolves.toBeInstanceOf(WebAssembly.Module); + }); +}); From 232753811da94bff2edf8c55347a63a3cd8784ad Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 1 Sep 2026 09:25:55 +0200 Subject: [PATCH 03/10] Guard the Response prototype patching --- packages/wasm/src/patchWasmResponse.ts | 42 ++++++++++++----------- packages/wasm/test/frozenResponse.test.ts | 21 ++++++++++++ 2 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 packages/wasm/test/frozenResponse.test.ts diff --git a/packages/wasm/src/patchWasmResponse.ts b/packages/wasm/src/patchWasmResponse.ts index ba0571e5a1c0..c7b234c2f061 100644 --- a/packages/wasm/src/patchWasmResponse.ts +++ b/packages/wasm/src/patchWasmResponse.ts @@ -1,3 +1,5 @@ +import { addNonEnumerableProperty, fill } from '@sentry/core'; + /** * Streaming wasm registration (`instantiateStreaming` / `compileStreaming`) reads the module URL * from `Response.url`. Non-streaming paths (`WebAssembly.instantiate` / `compile` with bytes) only @@ -67,23 +69,21 @@ export function patchWasmResponseBodyReaders(): void { return; } - responseProto[PATCHED_SYMBOL] = true; - - // oxlint-disable-next-line typescript/unbound-method - const origArrayBuffer: (this: Response) => Promise = Response.prototype.arrayBuffer; - Response.prototype.arrayBuffer = function arrayBuffer(this: Response): Promise { - const bufferPromise: Promise = origArrayBuffer.call(this); - return bufferPromise.then((buffer: ArrayBuffer) => { - tagResponseBuffer(this, buffer); - return buffer; - }); - }; - - if ('bytes' in Response.prototype) { - // oxlint-disable-next-line typescript/unbound-method - const origBytes: (this: Response) => Promise = Response.prototype.bytes; - Response.prototype.bytes = function bytes(this: Response) { - const bytesPromise: Promise = origBytes.call(this); + const proto = Response.prototype as unknown as Record; + + fill(proto, 'arrayBuffer', (original: (this: Response) => Promise) => { + return function arrayBuffer(this: Response): Promise { + const bufferPromise: Promise = original.call(this); + return bufferPromise.then((buffer: ArrayBuffer) => { + tagResponseBuffer(this, buffer); + return buffer; + }); + }; + }); + + fill(proto, 'bytes', (original: (this: Response) => Promise) => { + return function bytes(this: Response): Promise { + const bytesPromise: Promise = original.call(this); return bytesPromise.then((bytes: Uint8Array) => { const { buffer } = bytes; if (buffer instanceof ArrayBuffer) { @@ -91,13 +91,15 @@ export function patchWasmResponseBodyReaders(): void { } return bytes; }); - } as typeof Response.prototype.bytes; - } + }; + }); + + addNonEnumerableProperty(responseProto, PATCHED_SYMBOL, true); } /** @internal */ export function _resetResponsePatchForTests(): void { if (typeof Response !== 'undefined') { - (Response.prototype as MaybePatched)[PATCHED_SYMBOL] = false; + addNonEnumerableProperty(Response.prototype, PATCHED_SYMBOL, false); } } diff --git a/packages/wasm/test/frozenResponse.test.ts b/packages/wasm/test/frozenResponse.test.ts new file mode 100644 index 000000000000..a74a67d6b44b --- /dev/null +++ b/packages/wasm/test/frozenResponse.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import { patchWebAssembly } from '../src/patchWebAssembly'; + +// Kept in its own file because freezing `Response.prototype` cannot be undone +// and would leak into every other test sharing the environment. +describe('patchWebAssembly() with a frozen Response.prototype', () => { + it('does not throw and still installs the streaming patch', async () => { + Object.freeze(Response.prototype); + + const module = {} as WebAssembly.Module; + WebAssembly.compileStreaming = vi.fn().mockResolvedValue(module) as unknown as typeof WebAssembly.compileStreaming; + + const registered: string[] = []; + + expect(() => patchWebAssembly((_module, url) => registered.push(url))).not.toThrow(); + + await WebAssembly.compileStreaming({ url: 'http://localhost:8001/main.wasm' } as Response); + + expect(registered).toEqual(['http://localhost:8001/main.wasm']); + }); +}); From 8bfb52c5fd2fcc7f55f7856aa341c8de493a15cf Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 1 Sep 2026 09:38:41 +0200 Subject: [PATCH 04/10] Drop the unnecessary double-cast --- packages/wasm/src/patchWasmResponse.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/wasm/src/patchWasmResponse.ts b/packages/wasm/src/patchWasmResponse.ts index c7b234c2f061..e4117117a118 100644 --- a/packages/wasm/src/patchWasmResponse.ts +++ b/packages/wasm/src/patchWasmResponse.ts @@ -69,9 +69,7 @@ export function patchWasmResponseBodyReaders(): void { return; } - const proto = Response.prototype as unknown as Record; - - fill(proto, 'arrayBuffer', (original: (this: Response) => Promise) => { + fill(Response.prototype, 'arrayBuffer', (original: (this: Response) => Promise) => { return function arrayBuffer(this: Response): Promise { const bufferPromise: Promise = original.call(this); return bufferPromise.then((buffer: ArrayBuffer) => { @@ -81,7 +79,7 @@ export function patchWasmResponseBodyReaders(): void { }; }); - fill(proto, 'bytes', (original: (this: Response) => Promise) => { + fill(Response.prototype, 'bytes', (original: (this: Response) => Promise) => { return function bytes(this: Response): Promise { const bytesPromise: Promise = original.call(this); return bytesPromise.then((bytes: Uint8Array) => { From 7799bee8b7f531e576f14544e2ffa191e8b196cf Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 1 Sep 2026 11:25:03 +0200 Subject: [PATCH 05/10] Add a browser test for non-streaming registration --- .../instantiateBufferRegistration/init.js | 20 ++++++++ .../instantiateBufferRegistration/subject.js | 12 +++++ .../instantiateBufferRegistration/test.ts | 48 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/init.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/test.ts diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/init.js b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/init.js new file mode 100644 index 000000000000..d5c0d011b788 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; +import { registerWebWorkerWasm } from '@sentry/wasm'; + +window.Sentry = Sentry; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +// `registerWebWorkerWasm` installs the same patches a worker would, and reports +// every registered module to the scope it is given. Collecting them here is the +// only way to observe registration from the page, since main-thread images stay +// module-internal until a frame matches one. +window.registeredImages = []; +registerWebWorkerWasm({ + self: { + postMessage: message => window.registeredImages.push(...(message._sentryWasmImages || [])), + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/subject.js b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/subject.js new file mode 100644 index 000000000000..d58714c72fca --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/subject.js @@ -0,0 +1,12 @@ +window.loadWasmFromBuffer = async () => { + const response = await fetch('https://localhost:5887/simple.wasm'); + const buffer = await response.arrayBuffer(); + + await WebAssembly.instantiate(new Uint8Array(buffer), { + env: { + external_func: () => {}, + }, + }); + + return window.registeredImages; +}; diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/test.ts b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/test.ts new file mode 100644 index 000000000000..df95edceaf52 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/test.ts @@ -0,0 +1,48 @@ +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', + }, + }); + }); +} + +sentryTest( + 'registers a module loaded via fetch, arrayBuffer and instantiate under its response url', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await serveWasmFixture(page); + await page.goto(url); + + const images = await page.evaluate(async () => { + // @ts-expect-error this function exists + return window.loadWasmFromBuffer(); + }); + + expect(images).toEqual([ + { + type: 'wasm', + code_file: 'https://localhost:5887/simple.wasm', + code_id: '0ba020cdd2444f7eafdd25999a8e9010', + debug_file: null, + debug_id: '0ba020cdd2444f7eafdd25999a8e90100', + }, + ]); + }, +); From 3211543cbbb13b888f2756e8a2577fe74522cd2a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 13:24:21 +0200 Subject: [PATCH 06/10] Guard every patch seam so the wasm integration never throws into user code --- packages/wasm/src/patchWasmResponse.ts | 51 +++++++++---------- packages/wasm/src/patchWebAssembly.ts | 50 ++++++++++++------ packages/wasm/test/patchWebAssembly.test.ts | 19 +++++++ .../wasm/test/patchWebAssemblyGuards.test.ts | 25 +++++++++ 4 files changed, 102 insertions(+), 43 deletions(-) create mode 100644 packages/wasm/test/patchWebAssemblyGuards.test.ts diff --git a/packages/wasm/src/patchWasmResponse.ts b/packages/wasm/src/patchWasmResponse.ts index e4117117a118..3aaa19e4f9f3 100644 --- a/packages/wasm/src/patchWasmResponse.ts +++ b/packages/wasm/src/patchWasmResponse.ts @@ -1,9 +1,9 @@ -import { addNonEnumerableProperty, fill } from '@sentry/core'; +import { fill } from '@sentry/core'; /** * Streaming wasm registration (`instantiateStreaming` / `compileStreaming`) reads the module URL * from `Response.url`. Non-streaming paths (`WebAssembly.instantiate` / `compile` with bytes) only - * receive a buffer — no URL — so registration would otherwise be skipped. + * receive a buffer, no URL, so registration would otherwise be skipped. * * This module patches `Response.prototype.arrayBuffer` and `bytes` so that when wasm is fetched * and then loaded from bytes, we can map the resulting `ArrayBuffer` back to the fetch URL via @@ -11,14 +11,12 @@ import { addNonEnumerableProperty, fill } from '@sentry/core'; */ const wasmSourceUrls = new WeakMap(); -const PATCHED_SYMBOL = Symbol.for('__sentryWasmPatched'); - -type MaybePatched = { [PATCHED_SYMBOL]?: boolean }; +let responseReadersPatched = false; /** * Resolves a wasm source buffer back to its fetch URL, when known. */ -export function getWasmSourceUrl(source: BufferSource): string | undefined { +export function getWasmSourceUrl(source: unknown): string | undefined { const buffer = toArrayBuffer(source); if (!buffer) { return undefined; @@ -27,7 +25,7 @@ export function getWasmSourceUrl(source: BufferSource): string | undefined { return wasmSourceUrls.get(buffer); } -function toArrayBuffer(source: BufferSource): ArrayBuffer | undefined { +function toArrayBuffer(source: unknown): ArrayBuffer | undefined { if (source instanceof ArrayBuffer) { return source; } @@ -50,9 +48,18 @@ function looksLikeWasmResponse(response: Response): boolean { return Boolean(url && /\.wasm(?:\?|#|$)/i.test(url)); } -function tagResponseBuffer(response: Response, buffer: ArrayBuffer): void { - if (looksLikeWasmResponse(response) && response.url) { - wasmSourceUrls.set(buffer, response.url); +/** + * Runs inside the caller's `arrayBuffer()` / `bytes()` promise chain, so it must never throw: + * a failure here would reject a body read that has nothing to do with wasm. + */ +function tagResponseSource(response: Response, source: unknown): void { + try { + const buffer = toArrayBuffer(source); + if (buffer && response.url && looksLikeWasmResponse(response)) { + wasmSourceUrls.set(buffer, response.url); + } + } catch { + // see above } } @@ -60,20 +67,17 @@ function tagResponseBuffer(response: Response, buffer: ArrayBuffer): void { * Patches Response body readers so wasm bytes remember their fetch URL. */ export function patchWasmResponseBodyReaders(): void { - if (typeof Response === 'undefined') { + if (responseReadersPatched || typeof Response === 'undefined') { return; } - const responseProto = Response.prototype as MaybePatched; - if (responseProto[PATCHED_SYMBOL]) { - return; - } + responseReadersPatched = true; fill(Response.prototype, 'arrayBuffer', (original: (this: Response) => Promise) => { return function arrayBuffer(this: Response): Promise { const bufferPromise: Promise = original.call(this); - return bufferPromise.then((buffer: ArrayBuffer) => { - tagResponseBuffer(this, buffer); + return bufferPromise.then(buffer => { + tagResponseSource(this, buffer); return buffer; }); }; @@ -82,22 +86,15 @@ export function patchWasmResponseBodyReaders(): void { fill(Response.prototype, 'bytes', (original: (this: Response) => Promise) => { return function bytes(this: Response): Promise { const bytesPromise: Promise = original.call(this); - return bytesPromise.then((bytes: Uint8Array) => { - const { buffer } = bytes; - if (buffer instanceof ArrayBuffer) { - tagResponseBuffer(this, buffer); - } + return bytesPromise.then(bytes => { + tagResponseSource(this, bytes); return bytes; }); }; }); - - addNonEnumerableProperty(responseProto, PATCHED_SYMBOL, true); } /** @internal */ export function _resetResponsePatchForTests(): void { - if (typeof Response !== 'undefined') { - addNonEnumerableProperty(Response.prototype, PATCHED_SYMBOL, false); - } + responseReadersPatched = false; } diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index 005a749c618b..7559733ac321 100644 --- a/packages/wasm/src/patchWebAssembly.ts +++ b/packages/wasm/src/patchWebAssembly.ts @@ -11,7 +11,7 @@ let nonStreamingPatched = false; * * @param registerModule callback invoked for every successfully compiled module */ -export function patchStreamingWebAssembly(registerModule: RegisterModuleCallback): void { +function patchStreamingWebAssembly(registerModule: RegisterModuleCallback): void { if ('instantiateStreaming' in WebAssembly) { const origInstantiateStreaming = WebAssembly.instantiateStreaming as ( response: unknown, @@ -61,14 +61,25 @@ function registerSafely(registerModule: RegisterModuleCallback, module: WebAssem } } +/** + * Registers a module compiled from bytes under the URL those bytes were fetched from, when known. + * Runs inside the caller's promise chain, so nothing in here may throw. + */ function registerFromBufferSource( registerModule: RegisterModuleCallback, - module: WebAssembly.Module, - source: BufferSource, + compiled: WebAssembly.Module | WebAssembly.WebAssemblyInstantiatedSource | WebAssembly.Instance, + source: unknown, ): void { - const url = getWasmSourceUrl(source); - if (url) { - registerSafely(registerModule, module, url); + try { + // `instantiate(module)` resolves to a bare Instance, which carries nothing new to register + const module = + compiled instanceof WebAssembly.Module ? compiled : 'module' in compiled ? compiled.module : undefined; + const url = getWasmSourceUrl(source); + if (module && url) { + registerModule(module, url); + } + } catch { + // a registration failure must never break the user's WebAssembly call } } @@ -87,14 +98,10 @@ function patchNonStreamingWebAssembly(registerModule: RegisterModuleCallback): v const origInstantiate = WebAssembly.instantiate as unknown as ( source: unknown, ...rest: unknown[] - ) => Promise; - WebAssembly.instantiate = function instantiate(source: BufferSource | WebAssembly.Module, ...rest: unknown[]) { - if (source instanceof WebAssembly.Module) { - return origInstantiate(source, ...rest); - } - + ) => Promise; + WebAssembly.instantiate = function instantiate(source: unknown, ...rest: unknown[]) { return origInstantiate(source, ...rest).then(result => { - registerFromBufferSource(registerModule, result.module, source); + registerFromBufferSource(registerModule, result, source); return result; }); } as typeof WebAssembly.instantiate; @@ -110,11 +117,22 @@ function patchNonStreamingWebAssembly(registerModule: RegisterModuleCallback): v /** * Patches the web assembly runtime. + * + * Every patch is guarded on its own: a missing or frozen global must neither throw out of + * `Sentry.init()` / `registerWebWorkerWasm()` nor keep the remaining patches from installing. */ export function patchWebAssembly(registerModule: RegisterModuleCallback): void { - patchWasmResponseBodyReaders(); - patchNonStreamingWebAssembly(registerModule); - patchStreamingWebAssembly(registerModule); + tryPatch(() => patchWasmResponseBodyReaders()); + tryPatch(() => patchNonStreamingWebAssembly(registerModule)); + tryPatch(() => patchStreamingWebAssembly(registerModule)); +} + +function tryPatch(patch: () => void): void { + try { + patch(); + } catch { + // see patchWebAssembly() + } } /** @internal */ diff --git a/packages/wasm/test/patchWebAssembly.test.ts b/packages/wasm/test/patchWebAssembly.test.ts index 29eaef416d56..ae5a4d4a3485 100644 --- a/packages/wasm/test/patchWebAssembly.test.ts +++ b/packages/wasm/test/patchWebAssembly.test.ts @@ -153,6 +153,25 @@ describe('patchWebAssembly() non-streaming registration', () => { expect(IMAGES).toHaveLength(0); }); + + it('resolves instantiate(module) to a bare instance and registers nothing', async () => { + const module = await WebAssembly.compile(await fetchWasmBytes()); + IMAGES.length = 0; + + await expect(WebAssembly.instantiate(module, WASM_IMPORTS)).resolves.toBeInstanceOf(WebAssembly.Instance); + expect(IMAGES).toHaveLength(0); + }); + + it('does not reject the body read when tagging throws', async () => { + const response = new Response(new Uint8Array(8)); + Object.defineProperty(response, 'url', { + get: () => { + throw new Error('url accessor'); + }, + }); + + await expect(response.arrayBuffer()).resolves.toBeInstanceOf(ArrayBuffer); + }); }); describe('patchWebAssembly() non-streaming argument forwarding', () => { diff --git a/packages/wasm/test/patchWebAssemblyGuards.test.ts b/packages/wasm/test/patchWebAssemblyGuards.test.ts new file mode 100644 index 000000000000..66e0e65c7496 --- /dev/null +++ b/packages/wasm/test/patchWebAssemblyGuards.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { patchWebAssembly } from '../src/patchWebAssembly'; +import { registerModule } from '../src/registry'; +import { restoreWasmGlobals, saveWasmGlobals } from './wasmTestHelpers'; + +describe('patchWebAssembly() guards', () => { + const savedGlobals = saveWasmGlobals(); + + afterEach(() => { + vi.unstubAllGlobals(); + restoreWasmGlobals(savedGlobals); + }); + + it('does not throw when WebAssembly is frozen', () => { + vi.stubGlobal('WebAssembly', Object.freeze(Object.create(WebAssembly))); + + expect(() => patchWebAssembly(registerModule)).not.toThrow(); + }); + + it('does not throw when WebAssembly is missing', () => { + vi.stubGlobal('WebAssembly', undefined); + + expect(() => patchWebAssembly(registerModule)).not.toThrow(); + }); +}); From 26c7182c7ce9bd2bbc44322a50ccc578ed72d2d5 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 2 Sep 2026 15:44:02 +0200 Subject: [PATCH 07/10] fix(wasm): map Chrome wasm:// frames to debug images - Chrome may emit `wasm://wasm/-` for buffer-compiled modules (non-streaming / workers) instead of the fetch URL stored as `code_file` - Exact URL lookup then fails, so frames stay unlinked (`unknown_image`, no `debug_meta.images`) even when the module is registered - Fall back to a unique basename match on page + worker images; rewrite `filename` to `code_file` and set `addr_mode` - Same `code_file` on page and worker counts as one module (worker crash while the page also loaded the wasm) - Do not guess when two different URLs share a filename - Bare `wasm://` frames still need `instruction_addr` from the JS parser - Only handles `wasm://wasm/-`; unnamed `wasm://` hashes and other browsers are unchanged --- packages/wasm/src/index.ts | 68 +++++++++++- packages/wasm/test/processEvent.test.ts | 136 +++++++++++++++++++++++- packages/wasm/test/webworker.test.ts | 56 ++++++++++ 3 files changed, 257 insertions(+), 3 deletions(-) diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index d5982201d069..ed7c6125a1ad 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -109,9 +109,11 @@ export function patchFrames( match = frame.filename.match(PARSER_REGEX) as null | [string, string, string]; } + // `:wasm-function[N]:0xADDR` — address is still in filename (JS parser did not split it). + // `` is usually the fetch URL (`http://…/app.wasm`); Chrome may instead use `wasm://wasm/-`. if (match) { - const index = getImage(match[1]); - const workerImageIndex = getWorkerImage(match[1]); + let index = getImage(match[1]); + let workerImageIndex = getWorkerImage(match[1]); frame.instruction_addr = match[2]; frame.filename = match[1]; frame.platform = 'native'; @@ -123,6 +125,19 @@ export function patchFrames( }; } + // Exact `code_file` miss: `match[1]` is `wasm://wasm/…`, not the registered http URL. + if (index < 0 && workerImageIndex < 0) { + const unique = uniqueImageForSyntheticFilename(match[1]); + if (unique) { + frame.filename = unique.codeFile; + if (unique.worker) { + workerImageIndex = unique.index; + } else { + index = unique.index; + } + } + } + if (index >= 0) { frame.addr_mode = `rel:${existingImagesOffset + index}`; hasAtLeastOneWasmFrameWithImage = true; @@ -131,6 +146,23 @@ export function patchFrames( frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`; hasAtLeastOneWasmFrameWithImage = true; } + } else { + // Bare `wasm://wasm/-` — JS parser already set `instruction_addr`. + const unique = uniqueImageForSyntheticFilename(frame.filename); + if (unique && frame.instruction_addr) { + frame.filename = unique.codeFile; + frame.platform = 'native'; + if (applicationKey) { + frame.module_metadata = { + ...frame.module_metadata, + [`${BUNDLER_PLUGIN_APP_KEY_PREFIX}${applicationKey}`]: true, + }; + } + frame.addr_mode = unique.worker + ? `rel:${existingImagesOffset + getImages().length + unique.index}` + : `rel:${existingImagesOffset + unique.index}`; + hasAtLeastOneWasmFrameWithImage = true; + } } }); @@ -147,6 +179,38 @@ function getWorkerImage(url: string): number { }); } +function fileBasename(url: string): string | undefined { + try { + return new URL(url).pathname.split('/').pop() || undefined; + } catch { + return url.split('/').pop(); + } +} + +/** Chrome may label buffer-compiled modules `wasm://wasm/-` (window and workers). */ +function uniqueImageForSyntheticFilename( + filename: string, +): { index: number; worker: boolean; codeFile: string } | undefined { + const body = filename.match(/^wasm:\/\/wasm\/(.+)$/i)?.[1]; + if (!body) { + return undefined; + } + const basename = body.replace(/-[0-9a-fA-F]{6,16}$/, ''); + const hits: Array<{ index: number; worker: boolean; codeFile: string }> = []; + const consider = (images: Array, worker: boolean): void => { + images.forEach((image, index) => { + if (image.type === 'wasm' && typeof image.code_file === 'string' && fileBasename(image.code_file) === basename) { + hits.push({ index, worker, codeFile: image.code_file }); + } + }); + }; + consider(getImages(), false); + consider(WINDOW._sentryWasmImages || [], true); + // Page + worker often register the same URL; that is one module, not two. + const codeFiles = new Set(hits.map(hit => hit.codeFile)); + return codeFiles.size === 1 ? hits[0] : undefined; +} + /** * Use this function to register WASM support in a web worker. * diff --git a/packages/wasm/test/processEvent.test.ts b/packages/wasm/test/processEvent.test.ts index d855b97b8185..7e7ee868f778 100644 --- a/packages/wasm/test/processEvent.test.ts +++ b/packages/wasm/test/processEvent.test.ts @@ -1,8 +1,13 @@ -import type { Event } from '@sentry/core'; +import type { DebugImage, Event } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { wasmIntegration } from '../src/index'; import { IMAGES } from '../src/registry'; +const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryWasmImages?: Array; +}; + const WASM_FILENAME = 'http://localhost:8001/main.wasm:wasm-function[10]:0x1234'; function exceptionValue(): NonNullable['values']>[number] { @@ -12,6 +17,7 @@ function exceptionValue(): NonNullable['values'] describe('processEvent()', () => { afterEach(() => { IMAGES.length = 0; + delete WINDOW._sentryWasmImages; }); it('patches frames of all exception values, not only the first matching one', () => { @@ -35,4 +41,132 @@ describe('processEvent()', () => { expect(frames?.[1]?.addr_mode).toBe('rel:0'); expect(event.debug_meta?.images).toHaveLength(1); }); + + it('attaches images for wasm:// frames on the main thread when the basename is unique', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/maze.split.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + ); + expect(event.debug_meta?.images).toHaveLength(1); + }); + + it('attaches a wasm:// image when page and worker registered the same code_file', () => { + const image: DebugImage = { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }; + IMAGES.push(image); + WINDOW._sentryWasmImages = [image]; + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/maze.split.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + ); + expect(event.debug_meta?.images).toHaveLength(2); + }); + + it('does not guess a wasm:// image when two registered modules share the filename', () => { + IMAGES.push( + { + type: 'wasm', + code_id: 'aaa', + code_file: 'http://localhost:8001/v1/app.wasm', + debug_file: null, + debug_id: 'aaa00000000000000000000000000000', + }, + { + type: 'wasm', + code_id: 'bbb', + code_file: 'http://localhost:8001/v2/app.wasm', + debug_file: null, + debug_id: 'bbb00000000000000000000000000000', + }, + ); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/app.wasm-abc123', + function: 'run', + instruction_addr: '0x10', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBeUndefined(); + expect(event.debug_meta?.images).toBeUndefined(); + }); }); diff --git a/packages/wasm/test/webworker.test.ts b/packages/wasm/test/webworker.test.ts index a4b42aefd77c..6fb9a6ea767a 100644 --- a/packages/wasm/test/webworker.test.ts +++ b/packages/wasm/test/webworker.test.ts @@ -2,6 +2,7 @@ import type { DebugImage, StackFrame } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { patchFrames, registerWebWorkerWasm } from '../src/index'; +import { IMAGES } from '../src/registry'; import { restoreWasmGlobals, saveWasmGlobals } from './wasmTestHelpers'; const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { @@ -48,6 +49,7 @@ describe('registerWebWorkerWasm()', () => { describe('patchFrames() with worker images', () => { afterEach(() => { + IMAGES.length = 0; delete WINDOW._sentryWasmImages; }); @@ -150,4 +152,58 @@ describe('patchFrames() with worker images', () => { expect(result).toBe(true); expect(frames[0]?.addr_mode).toBe('rel:3'); }); + + it('should match wasm:// frames to a unique worker image by filename', () => { + WINDOW._sentryWasmImages = [ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + ]; + + const frames: StackFrame[] = [ + { + filename: 'wasm://wasm/maze.split.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ]; + + const result = patchFrames(frames); + + expect(result).toBe(true); + expect(frames[0]?.filename).toBe('http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm'); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + it('should match wasm:// frames when page and worker registered the same code_file', () => { + const image: DebugImage = { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }; + IMAGES.push(image); + WINDOW._sentryWasmImages = [image]; + + const frames: StackFrame[] = [ + { + filename: 'wasm://wasm/maze.split.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ]; + + const result = patchFrames(frames); + + expect(result).toBe(true); + expect(frames[0]?.filename).toBe('http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm'); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); }); From 364598203cbe86dac46479484ec82a3eb7c57d78 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 2 Sep 2026 15:53:06 +0200 Subject: [PATCH 08/10] fix(wasm): treat same debug_id as one wasm:// image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wasm:// matching required a unique `code_file`, so the same binary registered under two URLs (page + worker, CDN vs origin) was skipped - Uniqueness is now `debug_id` — Symbolicator keys off the build, not URL - Still skip when two binaries share a filename but differ in `debug_id`; Chrome's wasm:// hash cannot tell them apart --- packages/wasm/src/index.ts | 12 ++++--- packages/wasm/test/processEvent.test.ts | 47 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index ed7c6125a1ad..1954b818443a 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -196,19 +196,21 @@ function uniqueImageForSyntheticFilename( return undefined; } const basename = body.replace(/-[0-9a-fA-F]{6,16}$/, ''); - const hits: Array<{ index: number; worker: boolean; codeFile: string }> = []; + const hits: Array<{ index: number; worker: boolean; codeFile: string; debugId: string }> = []; const consider = (images: Array, worker: boolean): void => { images.forEach((image, index) => { if (image.type === 'wasm' && typeof image.code_file === 'string' && fileBasename(image.code_file) === basename) { - hits.push({ index, worker, codeFile: image.code_file }); + hits.push({ index, worker, codeFile: image.code_file, debugId: image.debug_id }); } }); }; consider(getImages(), false); consider(WINDOW._sentryWasmImages || [], true); - // Page + worker often register the same URL; that is one module, not two. - const codeFiles = new Set(hits.map(hit => hit.codeFile)); - return codeFiles.size === 1 ? hits[0] : undefined; + // Same binary may be registered under several URLs (page + worker, CDN vs origin). + // Chrome's wasm:// hash is not a debug_id, so different binaries that share a + // filename still cannot be told apart. + const debugIds = new Set(hits.map(hit => hit.debugId)); + return debugIds.size === 1 ? hits[0] : undefined; } /** diff --git a/packages/wasm/test/processEvent.test.ts b/packages/wasm/test/processEvent.test.ts index 7e7ee868f778..a85a907a1735 100644 --- a/packages/wasm/test/processEvent.test.ts +++ b/packages/wasm/test/processEvent.test.ts @@ -124,6 +124,53 @@ describe('processEvent()', () => { expect(event.debug_meta?.images).toHaveLength(2); }); + it('attaches a wasm:// image when the same debug_id is registered under two URLs', () => { + IMAGES.push( + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/v1/app.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://cdn.example/app.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + ); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/app.wasm-abc123', + function: 'run', + instruction_addr: '0x10', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe('http://localhost:8001/v1/app.wasm'); + expect(event.debug_meta?.images).toHaveLength(2); + }); + it('does not guess a wasm:// image when two registered modules share the filename', () => { IMAGES.push( { From 11b91d291a92c1d847bed864e4534ccdc951414e Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Thu, 3 Sep 2026 12:41:18 +0200 Subject: [PATCH 09/10] fix(wasm): match wasm:// frames via name section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse wasm `name` custom section at registration into internal `moduleName` - Extract `matchSyntheticWasmFilename` — prefer `moduleName`, then URL basename/`_bg` alias - Accept synthetic matches only when all candidates share one `debug_id` - Reject hash-only `wasm://wasm/` labels (#23781) - Strip `moduleName` via `toProtocolDebugImage` before attaching `debug_meta` --- packages/wasm/src/index.ts | 49 ++----- .../wasm/src/matchSyntheticWasmFilename.ts | 125 ++++++++++++++++++ packages/wasm/src/registry.ts | 41 +++++- packages/wasm/src/wasmNameSection.ts | 86 ++++++++++++ 4 files changed, 254 insertions(+), 47 deletions(-) create mode 100644 packages/wasm/src/matchSyntheticWasmFilename.ts create mode 100644 packages/wasm/src/wasmNameSection.ts diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 1954b818443a..1550ba770865 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 type { Event, IntegrationFn, StackFrame } from '@sentry/core'; import { defineIntegration, GLOBAL_OBJ } from '@sentry/core'; +import { uniqueImageForSyntheticFilename } from './matchSyntheticWasmFilename'; import { patchWebAssembly } from './patchWebAssembly'; -import { getImage, getImages, registerModule } from './registry'; +import { getImage, getImages, registerModule, toProtocolDebugImage, type RegisteredWasmImage } 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 = {}) => { @@ -58,8 +59,8 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { if (hasAtLeastOneWasmFrameWithImage) { event.debug_meta = event.debug_meta || {}; - const mainThreadImages = getImages(); - const workerImages = WINDOW._sentryWasmImages || []; + const mainThreadImages = getImages().map(toProtocolDebugImage); + const workerImages = (WINDOW._sentryWasmImages || []).map(toProtocolDebugImage); event.debug_meta.images = [...(event.debug_meta.images || []), ...mainThreadImages, ...workerImages]; } @@ -127,7 +128,7 @@ export function patchFrames( // Exact `code_file` miss: `match[1]` is `wasm://wasm/…`, not the registered http URL. if (index < 0 && workerImageIndex < 0) { - const unique = uniqueImageForSyntheticFilename(match[1]); + const unique = uniqueImageForSyntheticFilename(match[1], getImages(), WINDOW._sentryWasmImages || []); if (unique) { frame.filename = unique.codeFile; if (unique.worker) { @@ -148,7 +149,7 @@ export function patchFrames( } } else { // Bare `wasm://wasm/-` — JS parser already set `instruction_addr`. - const unique = uniqueImageForSyntheticFilename(frame.filename); + const unique = uniqueImageForSyntheticFilename(frame.filename, getImages(), WINDOW._sentryWasmImages || []); if (unique && frame.instruction_addr) { frame.filename = unique.codeFile; frame.platform = 'native'; @@ -179,40 +180,6 @@ function getWorkerImage(url: string): number { }); } -function fileBasename(url: string): string | undefined { - try { - return new URL(url).pathname.split('/').pop() || undefined; - } catch { - return url.split('/').pop(); - } -} - -/** Chrome may label buffer-compiled modules `wasm://wasm/-` (window and workers). */ -function uniqueImageForSyntheticFilename( - filename: string, -): { index: number; worker: boolean; codeFile: string } | undefined { - const body = filename.match(/^wasm:\/\/wasm\/(.+)$/i)?.[1]; - if (!body) { - return undefined; - } - const basename = body.replace(/-[0-9a-fA-F]{6,16}$/, ''); - const hits: Array<{ index: number; worker: boolean; codeFile: string; debugId: string }> = []; - const consider = (images: Array, worker: boolean): void => { - images.forEach((image, index) => { - if (image.type === 'wasm' && typeof image.code_file === 'string' && fileBasename(image.code_file) === basename) { - hits.push({ index, worker, codeFile: image.code_file, debugId: image.debug_id }); - } - }); - }; - consider(getImages(), false); - consider(WINDOW._sentryWasmImages || [], true); - // Same binary may be registered under several URLs (page + worker, CDN vs origin). - // Chrome's wasm:// hash is not a debug_id, so different binaries that share a - // filename still cannot be told apart. - const debugIds = new Set(hits.map(hit => hit.debugId)); - return debugIds.size === 1 ? hits[0] : undefined; -} - /** * Use this function to register WASM support in a web worker. * diff --git a/packages/wasm/src/matchSyntheticWasmFilename.ts b/packages/wasm/src/matchSyntheticWasmFilename.ts new file mode 100644 index 000000000000..a6a3807e0782 --- /dev/null +++ b/packages/wasm/src/matchSyntheticWasmFilename.ts @@ -0,0 +1,125 @@ +import type { DebugImage } from '@sentry/core'; +import type { RegisteredWasmImage } from './registry'; + +/** + * Maps Chrome `wasm://wasm/-` frames to a registered `code_file`. + * + * Prefer the wasm `name` section (`moduleName`). If that section is missing or + * does not match the stack label, guess from the fetch URL basename (including + * wasm-bindgen `_bg.wasm` → `.wasm`). Hits are accepted only when every + * candidate shares one `debug_id`. + * + * Hash-only `wasm://wasm/` is not mapped (see #23781). + * + * Fetch-URL frames (`http://…/file.wasm:wasm-function[…]`) still use exact + * `code_file` lookup in `patchFrames`, not this matcher. + */ + +export type SyntheticWasmImageHit = { + index: number; + worker: boolean; + codeFile: string; +}; + +type Hit = SyntheticWasmImageHit & { debugId: string }; + +/** Last path segment of a registered wasm URL (`http://…/demo_bg.wasm` → `demo_bg.wasm`). */ +export function fileBasename(url: string): string | undefined { + try { + return new URL(url).pathname.split('/').pop() || undefined; + } catch { + return url.split('/').pop(); + } +} + +/** + * Chrome's module label without the `wasm://wasm/` prefix or trailing isolate hash. + * `wasm://wasm/demo.wasm-000197f6` → `demo.wasm`. Hash-only `wasm://wasm/0bee4c4e` → `0bee4c4e`. + */ +export function syntheticModuleName(filename: string): string | undefined { + const body = filename.match(/^wasm:\/\/wasm\/(.+)$/i)?.[1]; + if (!body) { + return undefined; + } + return body.replace(/-[0-9a-fA-F]{6,16}$/, ''); +} + +/** + * Fetch filename plus known packaging aliases. + * + * wasm-bindgen writes `foo_bg.wasm` next to `foo.js` but the stack label is often + * `foo.wasm`. Used when the name section is missing or does not match. + */ +export function namesForRegisteredWasm(codeFile: string): string[] { + const basename = fileBasename(codeFile); + if (!basename) { + return []; + } + + const names = [basename]; + const withoutBindgenBg = basename.replace(/_bg\.wasm$/i, '.wasm'); + if (withoutBindgenBg !== basename) { + names.push(withoutBindgenBg); + } + return names; +} + +export function registeredWasmMatchesSyntheticName(codeFile: string, syntheticName: string): boolean { + return namesForRegisteredWasm(codeFile).includes(syntheticName); +} + +function wasmNameSectionName(image: DebugImage): string | undefined { + const moduleName = (image as RegisteredWasmImage).moduleName; + return typeof moduleName === 'string' && moduleName.length > 0 ? moduleName : undefined; +} + +export function imageMatchesSyntheticName(image: DebugImage, syntheticName: string): boolean { + if (wasmNameSectionName(image) === syntheticName) { + return true; + } + return typeof image.code_file === 'string' && registeredWasmMatchesSyntheticName(image.code_file, syntheticName); +} + +/** + * Multiple URLs may register the same binary. Only use a hit when every candidate + * shares one `debug_id`. Different binaries with the same name stay unmatched. + */ +export function uniqueHitByDebugId(hits: T[]): T | undefined { + const debugIds = new Set(hits.map(hit => hit.debugId)); + return debugIds.size === 1 ? hits[0] : undefined; +} + +/** + * Chrome's isolate hash is not a debug_id and is not on `WebAssembly.Module`. + * `wasm://wasm/` with no module name must not pick an image (see #23781). + */ +function isHashOnlySyntheticName(name: string): boolean { + return /^[0-9a-fA-F]{6,16}$/.test(name); +} + +export function uniqueImageForSyntheticFilename( + filename: string, + pageImages: ReadonlyArray, + workerImages: ReadonlyArray, +): SyntheticWasmImageHit | undefined { + const name = syntheticModuleName(filename); + if (!name || isHashOnlySyntheticName(name)) { + return undefined; + } + + const hits: Hit[] = []; + const consider = (images: ReadonlyArray, worker: boolean): void => { + images.forEach((image, index) => { + if (image.type === 'wasm' && typeof image.code_file === 'string' && imageMatchesSyntheticName(image, name)) { + hits.push({ index, worker, codeFile: image.code_file, debugId: image.debug_id }); + } + }); + }; + consider(pageImages, false); + consider(workerImages, true); + const hit = uniqueHitByDebugId(hits); + if (!hit) { + return undefined; + } + return { index: hit.index, worker: hit.worker, codeFile: hit.codeFile }; +} diff --git a/packages/wasm/src/registry.ts b/packages/wasm/src/registry.ts index 2ca6d66754dc..0b606451d7fa 100644 --- a/packages/wasm/src/registry.ts +++ b/packages/wasm/src/registry.ts @@ -1,10 +1,17 @@ import type { DebugImage } from '@sentry/core'; +import { parseNameSectionModuleName } from './wasmNameSection'; -export const IMAGES: Array = []; +export type RegisteredWasmImage = Extract & { + /** Internal: wasm `name` section. Used to link `wasm://` stacks; stripped before the event is sent. */ + moduleName?: string; +}; + +export const IMAGES: Array = []; export interface ModuleInfo { buildId: string | null; debugFile: string | null; + moduleName: string | null; } /** @@ -34,14 +41,25 @@ export function getModuleInfo(module: WebAssembly.Module): ModuleInfo { debugFile = decoder.decode(firstExternalDebugInfo); } - return { buildId, debugFile }; + let moduleName = null; + try { + const nameSections = WebAssembly.Module.customSections(module, 'name'); + const nameSection0 = nameSections[0]; + if (nameSection0) { + moduleName = parseNameSectionModuleName(nameSection0); + } + } catch { + moduleName = null; + } + + return { buildId, debugFile, moduleName }; } /** * Records a module and returns the created debug image. */ -export function registerModule(module: WebAssembly.Module, url: string): DebugImage | null { - const { buildId, debugFile } = getModuleInfo(module); +export function registerModule(module: WebAssembly.Module, url: string): RegisteredWasmImage | null { + const { buildId, debugFile, moduleName } = getModuleInfo(module); if (!buildId) { return null; } @@ -61,13 +79,16 @@ export function registerModule(module: WebAssembly.Module, url: string): DebugIm } } - const image: DebugImage = { + const image: RegisteredWasmImage = { type: 'wasm', code_id: buildId, code_file: url, debug_file: debugFileUrl, debug_id: `${buildId.padEnd(32, '0').slice(0, 32)}0`, }; + if (moduleName) { + image.moduleName = moduleName; + } IMAGES.push(image); return image; @@ -76,10 +97,18 @@ export function registerModule(module: WebAssembly.Module, url: string): DebugIm /** * Returns all known images. */ -export function getImages(): Array { +export function getImages(): Array { return IMAGES; } +/** + * Debug image payload for Sentry: protocol fields only (no internal `moduleName`). + */ +export function toProtocolDebugImage(image: RegisteredWasmImage): DebugImage { + const { moduleName: _moduleName, ...protocol } = image; + return protocol; +} + /** * Looks up an image by URL. * diff --git a/packages/wasm/src/wasmNameSection.ts b/packages/wasm/src/wasmNameSection.ts new file mode 100644 index 000000000000..14878655cc3c --- /dev/null +++ b/packages/wasm/src/wasmNameSection.ts @@ -0,0 +1,86 @@ +/* eslint-disable no-bitwise -- LEB128 is a bitwise encoding */ + +/** + * Parses the wasm `name` custom section payload — the bytes + * `WebAssembly.Module.customSections(module, 'name')` returns. + * + * Chrome's buffer-compiled stacks use this module name as `wasm://wasm/-`, + * which often differs from the fetch URL (wasm-bindgen `demo.wasm` vs `demo_bg.wasm`). + * Missing, stripped, or malformed sections return null. Callers then guess from + * the fetch URL basename if the name is missing or does not match the stack. + * + * @see https://webassembly.github.io/spec/core/appendix/custom.html#name-section + */ +export function parseNameSectionModuleName(source: ArrayBuffer | Uint8Array): string | null { + try { + return readModuleName(toBytes(source)); + } catch { + return null; + } +} + +function toBytes(source: ArrayBuffer | Uint8Array): Uint8Array { + if (source instanceof ArrayBuffer) { + return new Uint8Array(source); + } + return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); +} + +function readModuleName(bytes: Uint8Array): string | null { + const cursor = { offset: 0 }; + while (cursor.offset < bytes.length) { + const id = bytes[cursor.offset]; + cursor.offset += 1; + if (id === undefined) { + return null; + } + + const size = readU32Leb(bytes, cursor); + if (size === undefined || cursor.offset + size > bytes.length) { + return null; + } + + const start = cursor.offset; + cursor.offset += size; + if (id === 0) { + return readName(bytes.subarray(start, start + size)); + } + } + + return null; +} + +function readName(bytes: Uint8Array): string | null { + const cursor = { offset: 0 }; + const length = readU32Leb(bytes, cursor); + if (length === undefined || length === 0 || cursor.offset + length !== bytes.length) { + return null; + } + + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(cursor.offset)); + } catch { + return null; + } +} + +function readU32Leb(bytes: Uint8Array, cursor: { offset: number }): number | undefined { + let result = 0; + let shift = 0; + + while (cursor.offset < bytes.length) { + const byte = bytes[cursor.offset]; + cursor.offset += 1; + if (byte === undefined || shift >= 35) { + return undefined; + } + + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) { + return result >>> 0; + } + shift += 7; + } + + return undefined; +} From a7e7efc5e465510fde636dd5f277ee1818b4d4c1 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Thu, 3 Sep 2026 12:41:45 +0200 Subject: [PATCH 10/10] test(wasm): cover name-section wasm:// frame matching - Add `wasmNameSection`, `matchSyntheticWasmFilename`, and `registry` unit tests - Add wasm module fixtures with `build_id` and optional `name` section - Extend `processEvent` for bindgen `_bg` alias, ambiguous names, hash-only frames - Extend `webworker` for worker images matched by `moduleName` --- .../test/matchSyntheticWasmFilename.test.ts | 156 +++++++++++++ packages/wasm/test/processEvent.test.ts | 221 +++++++++++++++++- packages/wasm/test/registry.test.ts | 62 +++++ packages/wasm/test/wasmModuleFixtures.ts | 56 +++++ packages/wasm/test/wasmNameSection.test.ts | 65 ++++++ packages/wasm/test/webworker.test.ts | 37 ++- 6 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 packages/wasm/test/matchSyntheticWasmFilename.test.ts create mode 100644 packages/wasm/test/registry.test.ts create mode 100644 packages/wasm/test/wasmModuleFixtures.ts create mode 100644 packages/wasm/test/wasmNameSection.test.ts diff --git a/packages/wasm/test/matchSyntheticWasmFilename.test.ts b/packages/wasm/test/matchSyntheticWasmFilename.test.ts new file mode 100644 index 000000000000..917dab0b6edf --- /dev/null +++ b/packages/wasm/test/matchSyntheticWasmFilename.test.ts @@ -0,0 +1,156 @@ +import type { DebugImage } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { + fileBasename, + namesForRegisteredWasm, + syntheticModuleName, + uniqueHitByDebugId, + uniqueImageForSyntheticFilename, +} from '../src/matchSyntheticWasmFilename'; + +const DEMO_BG_URL = 'http://localhost:8080/web/assets/rust/demo_bg.wasm'; +const DEBUG_ID_A = 'aaa00000000000000000000000000000'; +const DEBUG_ID_B = 'bbb00000000000000000000000000000'; + +function wasmImage(overrides: Partial> & { moduleName?: string }): DebugImage { + return { + type: 'wasm', + code_id: 'aaa', + code_file: DEMO_BG_URL, + debug_file: null, + debug_id: DEBUG_ID_A, + ...overrides, + }; +} + +describe('syntheticModuleName()', () => { + it('strips the Chrome isolate hash', () => { + expect(syntheticModuleName('wasm://wasm/demo.wasm-000197f6')).toBe('demo.wasm'); + }); + + it('returns a hash-only label unchanged', () => { + expect(syntheticModuleName('wasm://wasm/0bee4c4e')).toBe('0bee4c4e'); + }); + + it('returns undefined for a fetch URL', () => { + expect(syntheticModuleName(DEMO_BG_URL)).toBeUndefined(); + }); +}); + +describe('namesForRegisteredWasm()', () => { + it('includes the bindgen _bg alias', () => { + expect(namesForRegisteredWasm(DEMO_BG_URL)).toEqual(['demo_bg.wasm', 'demo.wasm']); + }); + + it('does not invent an alias for a plain .wasm file', () => { + expect(namesForRegisteredWasm('http://localhost:8080/maze.split.wasm')).toEqual(['maze.split.wasm']); + }); +}); + +describe('fileBasename()', () => { + it('falls back when the value is not a URL', () => { + expect(fileBasename('not a url/demo.wasm')).toBe('demo.wasm'); + }); +}); + +describe('uniqueHitByDebugId()', () => { + it('returns the first hit when every debug_id matches', () => { + const hits = [ + { debugId: DEBUG_ID_A, codeFile: 'http://localhost:8001/v1/app.wasm' }, + { debugId: DEBUG_ID_A, codeFile: 'http://cdn.example/app.wasm' }, + ]; + expect(uniqueHitByDebugId(hits)).toEqual(hits[0]); + }); + + it('returns undefined when debug_ids differ', () => { + expect( + uniqueHitByDebugId([ + { debugId: DEBUG_ID_A, codeFile: 'http://localhost:8001/v1/app.wasm' }, + { debugId: DEBUG_ID_B, codeFile: 'http://localhost:8001/v2/app.wasm' }, + ]), + ).toBeUndefined(); + }); +}); + +describe('uniqueImageForSyntheticFilename()', () => { + it('matches wasm://wasm/demo.wasm when the image has moduleName demo.wasm', () => { + const image = wasmImage({ moduleName: 'demo.wasm' }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [image], [])).toEqual({ + index: 0, + worker: false, + codeFile: DEMO_BG_URL, + }); + }); + + it('does not guess when two modules share a moduleName but not debug_id', () => { + const pageImages = [ + wasmImage({ moduleName: 'demo.wasm', debug_id: DEBUG_ID_A, code_file: 'http://localhost:8001/v1/demo_bg.wasm' }), + wasmImage({ + moduleName: 'demo.wasm', + code_id: 'bbb', + debug_id: DEBUG_ID_B, + code_file: 'http://localhost:8001/v2/demo_bg.wasm', + }), + ]; + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', pageImages, [])).toBeUndefined(); + }); + + it('falls back to the fetch basename when the name section is absent', () => { + const image = wasmImage({ + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/maze.split.wasm-000197f6', [image], [])).toEqual({ + index: 0, + worker: false, + codeFile: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + }); + }); + + it('falls back to the bindgen _bg alias when the name section is absent', () => { + const image = wasmImage({}); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [image], [])).toEqual({ + index: 0, + worker: false, + codeFile: DEMO_BG_URL, + }); + }); + + it('falls back to the fetch filename when stored moduleName does not match the stack', () => { + const image = wasmImage({ moduleName: 'crate' }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [image], [])).toEqual({ + index: 0, + worker: false, + codeFile: DEMO_BG_URL, + }); + }); + + it('does not match when neither moduleName nor the fetch filename aliases the stack', () => { + const image = wasmImage({ + moduleName: 'crate', + code_file: 'http://localhost:8080/web/assets/other.wasm', + }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [image], [])).toBeUndefined(); + }); + + it('does not map a hash-only wasm:// label', () => { + const image = wasmImage({ moduleName: 'demo.wasm' }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/0bee4c4e', [image], [])).toBeUndefined(); + }); + + it('matches a worker image by module name', () => { + const image = wasmImage({ moduleName: 'demo.wasm' }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [], [image])).toEqual({ + index: 0, + worker: true, + codeFile: DEMO_BG_URL, + }); + }); +}); diff --git a/packages/wasm/test/processEvent.test.ts b/packages/wasm/test/processEvent.test.ts index a85a907a1735..547d66c6b4a6 100644 --- a/packages/wasm/test/processEvent.test.ts +++ b/packages/wasm/test/processEvent.test.ts @@ -2,7 +2,8 @@ import type { DebugImage, Event } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { wasmIntegration } from '../src/index'; -import { IMAGES } from '../src/registry'; +import { IMAGES, registerModule } from '../src/registry'; +import { wasmWithBuildIdAndModuleName, compileFixture } from './wasmModuleFixtures'; const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryWasmImages?: Array; @@ -82,13 +83,14 @@ describe('processEvent()', () => { expect(event.debug_meta?.images).toHaveLength(1); }); - it('attaches a wasm:// image when page and worker registered the same code_file', () => { - const image: DebugImage = { - type: 'wasm', + it('attaches a wasm:// image when page and worker registered the same moduleName and debug_id', () => { + const image = { + type: 'wasm' as const, code_id: 'abc123', code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', debug_file: null, debug_id: 'abc12300000000000000000000000000', + moduleName: 'maze.split.wasm', }; IMAGES.push(image); WINDOW._sentryWasmImages = [image]; @@ -132,6 +134,7 @@ describe('processEvent()', () => { code_file: 'http://localhost:8001/v1/app.wasm', debug_file: null, debug_id: 'abc12300000000000000000000000000', + moduleName: 'app.wasm', }, { type: 'wasm', @@ -139,6 +142,7 @@ describe('processEvent()', () => { code_file: 'http://cdn.example/app.wasm', debug_file: null, debug_id: 'abc12300000000000000000000000000', + moduleName: 'app.wasm', }, ); @@ -171,7 +175,7 @@ describe('processEvent()', () => { expect(event.debug_meta?.images).toHaveLength(2); }); - it('does not guess a wasm:// image when two registered modules share the filename', () => { + it('does not guess a wasm:// image from code_file when two modules share a filename', () => { IMAGES.push( { type: 'wasm', @@ -216,4 +220,211 @@ describe('processEvent()', () => { expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBeUndefined(); expect(event.debug_meta?.images).toBeUndefined(); }); + + it('attaches a wasm:// image when the name section differs from the fetch basename', () => { + const module = compileFixture(wasmWithBuildIdAndModuleName([0xaa, 0xbb], 'demo.wasm')); + registerModule(module, 'http://localhost:8080/web/assets/rust/demo_bg.wasm'); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/demo.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + ); + expect(event.debug_meta?.images).toHaveLength(1); + expect(event.debug_meta?.images?.[0]).toEqual({ + type: 'wasm', + code_id: 'aabb', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + }); + }); + + it('attaches a wasm:// image via the bindgen _bg alias when no name section is stored', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'aabb', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/demo.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + ); + expect(event.debug_meta?.images).toHaveLength(1); + }); + + it('does not guess a wasm:// image when two modules share a name-section name', () => { + IMAGES.push( + { + type: 'wasm', + code_id: 'aaa', + code_file: 'http://localhost:8001/v1/demo_bg.wasm', + debug_file: null, + debug_id: 'aaa00000000000000000000000000000', + moduleName: 'demo.wasm', + }, + { + type: 'wasm', + code_id: 'bbb', + code_file: 'http://localhost:8001/v2/demo_bg.wasm', + debug_file: null, + debug_id: 'bbb00000000000000000000000000000', + moduleName: 'demo.wasm', + }, + ); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/demo.wasm-000197f6', + function: 'run', + instruction_addr: '0x10', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBeUndefined(); + expect(event.debug_meta?.images).toBeUndefined(); + }); + + it('does not attach debug_meta for a hash-only wasm:// frame', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + moduleName: 'demo.wasm', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/0bee4c4e', + function: 'run', + instruction_addr: '0x10', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe('wasm://wasm/0bee4c4e'); + expect(event.debug_meta?.images).toBeUndefined(); + }); + + it('matches http://…/demo_bg.wasm:wasm-function frames via exact code_file', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'aabb', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'http://localhost:8080/web/assets/rust/demo_bg.wasm:wasm-function[10]:0x283d', + function: 'trigger_crash_divzero', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + ); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.instruction_addr).toBe('0x283d'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.debug_meta?.images).toHaveLength(1); + }); }); diff --git a/packages/wasm/test/registry.test.ts b/packages/wasm/test/registry.test.ts new file mode 100644 index 000000000000..ae0b030148de --- /dev/null +++ b/packages/wasm/test/registry.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getModuleInfo, IMAGES, registerModule, toProtocolDebugImage } from '../src/registry'; +import { + compileFixture, + wasmWithBuildIdAndFunctionNamesOnly, + wasmWithBuildIdAndModuleName, + wasmWithBuildIdOnly, +} from './wasmModuleFixtures'; + +const CODE_FILE = 'http://localhost:8080/web/assets/rust/demo_bg.wasm'; + +describe('registerModule() name section', () => { + afterEach(() => { + IMAGES.length = 0; + }); + + it('stores the name-section module name on the debug image', () => { + const module = compileFixture(wasmWithBuildIdAndModuleName([0xaa, 0xbb], 'demo.wasm')); + + const image = registerModule(module, CODE_FILE); + + expect(image).toEqual({ + type: 'wasm', + code_id: 'aabb', + code_file: CODE_FILE, + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + moduleName: 'demo.wasm', + }); + }); + + it('omits moduleName when the name section is stripped', () => { + const module = compileFixture(wasmWithBuildIdOnly([0xaa, 0xbb])); + + const image = registerModule(module, CODE_FILE); + + expect(image?.moduleName).toBeUndefined(); + expect(getModuleInfo(module).moduleName).toBeNull(); + }); + + it('omits moduleName when the name section has no module name', () => { + const module = compileFixture(wasmWithBuildIdAndFunctionNamesOnly([0xaa, 0xbb])); + + const image = registerModule(module, CODE_FILE); + + expect(image?.moduleName).toBeUndefined(); + }); + + it('omits moduleName from the protocol debug image', () => { + const module = compileFixture(wasmWithBuildIdAndModuleName([0xaa, 0xbb], 'demo.wasm')); + const image = registerModule(module, CODE_FILE); + + expect(image).not.toBeNull(); + expect(toProtocolDebugImage(image!)).toEqual({ + type: 'wasm', + code_id: 'aabb', + code_file: CODE_FILE, + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + }); + }); +}); diff --git a/packages/wasm/test/wasmModuleFixtures.ts b/packages/wasm/test/wasmModuleFixtures.ts new file mode 100644 index 000000000000..ed5a648ce1d8 --- /dev/null +++ b/packages/wasm/test/wasmModuleFixtures.ts @@ -0,0 +1,56 @@ +/* eslint-disable no-bitwise -- LEB128 is a bitwise encoding */ + +const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + +export function u32leb(n: number): number[] { + const bytes: number[] = []; + let value = n >>> 0; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) { + byte |= 0x80; + } + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +export function encodeUtf8(text: string): number[] { + return Array.from(new TextEncoder().encode(text)); +} + +export function customSection(sectionName: string, payload: number[]): number[] { + const nameBytes = encodeUtf8(sectionName); + const body = [...u32leb(nameBytes.length), ...nameBytes, ...payload]; + return [0, ...u32leb(body.length), ...body]; +} + +/** Payload of a `name` custom section containing only the module-name subsection. */ +export function nameSectionPayload(moduleName: string): number[] { + const nameBytes = encodeUtf8(moduleName); + const subsection = [...u32leb(nameBytes.length), ...nameBytes]; + return [0, ...u32leb(subsection.length), ...subsection]; +} + +export function wasmModuleBytes(sections: number[][]): Uint8Array { + return Uint8Array.from([...WASM_MAGIC, ...sections.flat()]); +} + +export function compileFixture(bytes: Uint8Array): WebAssembly.Module { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return new WebAssembly.Module(buffer); +} + +export function wasmWithBuildIdAndModuleName(buildId: number[], moduleName: string): Uint8Array { + return wasmModuleBytes([customSection('build_id', buildId), customSection('name', nameSectionPayload(moduleName))]); +} + +export function wasmWithBuildIdOnly(buildId: number[]): Uint8Array { + return wasmModuleBytes([customSection('build_id', buildId)]); +} + +export function wasmWithBuildIdAndFunctionNamesOnly(buildId: number[]): Uint8Array { + return wasmModuleBytes([customSection('build_id', buildId), customSection('name', [1, ...u32leb(1), 0])]); +} diff --git a/packages/wasm/test/wasmNameSection.test.ts b/packages/wasm/test/wasmNameSection.test.ts new file mode 100644 index 000000000000..3f21c7792cc5 --- /dev/null +++ b/packages/wasm/test/wasmNameSection.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { parseNameSectionModuleName } from '../src/wasmNameSection'; +import { + compileFixture, + customSection, + nameSectionPayload, + u32leb, + wasmModuleBytes, + wasmWithBuildIdAndFunctionNamesOnly, + wasmWithBuildIdAndModuleName, + wasmWithBuildIdOnly, +} from './wasmModuleFixtures'; + +describe('parseNameSectionModuleName()', () => { + it('reads the module name from fixture bytes', () => { + expect(parseNameSectionModuleName(Uint8Array.from(nameSectionPayload('demo.wasm')))).toBe('demo.wasm'); + }); + + it('reads the name section Chrome would see on a compiled module', () => { + const module = compileFixture(wasmWithBuildIdAndModuleName([0xaa, 0xbb], 'demo.wasm')); + const sections = WebAssembly.Module.customSections(module, 'name'); + + expect(sections).toHaveLength(1); + expect(parseNameSectionModuleName(sections[0] as ArrayBuffer)).toBe('demo.wasm'); + }); + + it('returns null when the name section is missing', () => { + const module = compileFixture(wasmWithBuildIdOnly([0xaa])); + const sections = WebAssembly.Module.customSections(module, 'name'); + + expect(sections).toHaveLength(0); + expect(parseNameSectionModuleName(new Uint8Array())).toBeNull(); + }); + + it('returns null when only function names are present', () => { + const module = compileFixture(wasmWithBuildIdAndFunctionNamesOnly([0xaa])); + const sections = WebAssembly.Module.customSections(module, 'name'); + + expect(sections).toHaveLength(1); + expect(parseNameSectionModuleName(sections[0] as ArrayBuffer)).toBeNull(); + }); + + it('skips later subsections after the module name', () => { + const moduleName = nameSectionPayload('demo.wasm'); + const functionNames = [1, ...u32leb(1), 0]; + expect(parseNameSectionModuleName(Uint8Array.from([...moduleName, ...functionNames]))).toBe('demo.wasm'); + }); + + it('returns null for a truncated subsection', () => { + expect(parseNameSectionModuleName(Uint8Array.from([0, 10]))).toBeNull(); + }); + + it('returns null for an empty module name', () => { + expect(parseNameSectionModuleName(Uint8Array.from([0, ...u32leb(1), 0]))).toBeNull(); + }); + + it('returns null for invalid UTF-8', () => { + expect(parseNameSectionModuleName(Uint8Array.from([0, ...u32leb(2), 1, 0xff]))).toBeNull(); + }); + + it('does not throw on a full wasm binary passed by mistake', () => { + const bytes = wasmModuleBytes([customSection('build_id', [0xaa])]); + expect(parseNameSectionModuleName(bytes)).toBeNull(); + }); +}); diff --git a/packages/wasm/test/webworker.test.ts b/packages/wasm/test/webworker.test.ts index 6fb9a6ea767a..7321af439892 100644 --- a/packages/wasm/test/webworker.test.ts +++ b/packages/wasm/test/webworker.test.ts @@ -153,7 +153,7 @@ describe('patchFrames() with worker images', () => { expect(frames[0]?.addr_mode).toBe('rel:3'); }); - it('should match wasm:// frames to a unique worker image by filename', () => { + it('matches wasm:// frames to a unique worker image by filename', () => { WINDOW._sentryWasmImages = [ { type: 'wasm', @@ -180,13 +180,14 @@ describe('patchFrames() with worker images', () => { expect(frames[0]?.addr_mode).toBe('rel:0'); }); - it('should match wasm:// frames when page and worker registered the same code_file', () => { - const image: DebugImage = { - type: 'wasm', + it('matches wasm:// frames when page and worker registered the same moduleName', () => { + const image = { + type: 'wasm' as const, code_id: 'abc123', code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', debug_file: null, debug_id: 'abc12300000000000000000000000000', + moduleName: 'maze.split.wasm', }; IMAGES.push(image); WINDOW._sentryWasmImages = [image]; @@ -206,4 +207,32 @@ describe('patchFrames() with worker images', () => { expect(frames[0]?.filename).toBe('http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm'); expect(frames[0]?.addr_mode).toBe('rel:0'); }); + + it('matches wasm:// frames to a worker image by name-section module name', () => { + WINDOW._sentryWasmImages = [ + { + type: 'wasm', + code_id: 'aabb', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + moduleName: 'demo.wasm', + }, + ]; + + const frames: StackFrame[] = [ + { + filename: 'wasm://wasm/demo.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ]; + + const result = patchFrames(frames); + + expect(result).toBe(true); + expect(frames[0]?.filename).toBe('http://localhost:8080/web/assets/rust/demo_bg.wasm'); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); });