From cf3c40e6d8c6525c4bc08d9442a5a7d0be889d5f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 26 Aug 2026 17:46:23 +0200 Subject: [PATCH] fix(wasm): Match Firefox call-site-derived wasm frames to the single buffer module Co-Authored-By: Claude Opus 5 --- .../suites/wasm/instantiateBuffer/test.ts | 60 ++++++++++++++ packages/wasm/src/index.ts | 43 ++++++++++ packages/wasm/test/nonstreaming.test.ts | 79 +++++++++++++++++++ 3 files changed, 182 insertions(+) diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts index ee0b43c57300..8c3c2712b94d 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts @@ -71,6 +71,37 @@ sentryTest( }, ); +sentryTest( + 'captured exception should include modified frames and debug_meta for non-streaming instantiation @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName) || browserName !== 'firefox') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await serveWasmFixture(page); + await page.goto(url); + + const { event } = await page.evaluate(async () => { + // @ts-expect-error this function exists + return window.getEvent(); + }); + + // Firefox derives the script name from the compile call site, so the + // frame matches through the single-buffer-module fallback. + expect(event.exception.values[0].stacktrace.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ...FRAME_MATCHER, + filename: expect.stringContaining('> WebAssembly.instantiate'), + }), + ]), + ); + + expect(event.debug_meta).toMatchObject({ images: [IMAGE_MATCHER] }); + }, +); + sentryTest( 'exactly matches the length-derived synthetic name for modules above the content-hash cutoff', async ({ getLocalTestUrl, page, browserName }) => { @@ -106,3 +137,32 @@ sentryTest( }); }, ); + +sentryTest( + 'falls back to the single buffer module for call-site-derived names above the content-hash cutoff @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName) || browserName !== 'firefox') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await serveWasmFixture(page); + await page.goto(url); + + const { event } = await page.evaluate(async () => { + // @ts-expect-error this function exists + return window.getEvent(17000); + }); + + expect(event.exception.values[0].stacktrace.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ...FRAME_MATCHER, + filename: expect.stringContaining('> WebAssembly.instantiate'), + }), + ]), + ); + + expect(event.debug_meta).toMatchObject({ images: [IMAGE_MATCHER] }); + }, +); diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index ec3ef6a3b696..061b34ab5504 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -135,6 +135,19 @@ export function patchFrames( const mainThreadImagesCount = getImages().length; frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`; hasAtLeastOneWasmFrameWithImage = true; + } else if (isCallSiteDerivedWasmFilename(match[1])) { + // Firefox derives script names for buffer-compiled modules from the + // compile call site, which cannot be predicted at registration time. + // If exactly one distinct module was registered from raw bytes, the + // frame can only belong to that module. Unmatched `wasm://` names are + // deliberately NOT handled here: those come from modules that were + // compiled before the SDK was initialized, and attributing them to an + // unrelated image would mis-symbolicate. + const fallbackIndex = getSingleBufferImageIndex(); + if (fallbackIndex >= 0) { + frame.addr_mode = `rel:${existingImagesOffset + fallbackIndex}`; + hasAtLeastOneWasmFrameWithImage = true; + } } } }); @@ -158,6 +171,36 @@ function getWorkerImage(url: string): number { return getWorkerImages().findIndex(image => imageMatchesUrl(image, url)); } +function isCallSiteDerivedWasmFilename(filename: string): boolean { + return filename.includes('> WebAssembly.'); +} + +/** + * Returns the index (across main-thread and worker images) of the only + * distinct image that was registered from raw bytes, or -1 if there is none + * or more than one. The same module registered on several threads counts + * once, since the images share their (content-derived) `code_file`. + */ +function getSingleBufferImageIndex(): number { + const mainImages = getImages(); + const workerImages = getWorkerImages(); + let index = -1; + const codeFiles = new Set(); + mainImages.forEach((image, i) => { + if (image._matchUrls && !codeFiles.has(image.code_file)) { + codeFiles.add(image.code_file); + index = i; + } + }); + workerImages.forEach((image, i) => { + if (image._matchUrls && !codeFiles.has(image.code_file)) { + codeFiles.add(image.code_file); + index = mainImages.length + i; + } + }); + return codeFiles.size === 1 ? index : -1; +} + /** * Use this function to register WASM support in a web worker. * diff --git a/packages/wasm/test/nonstreaming.test.ts b/packages/wasm/test/nonstreaming.test.ts index 733a4d282f3b..2b24ce5b96ff 100644 --- a/packages/wasm/test/nonstreaming.test.ts +++ b/packages/wasm/test/nonstreaming.test.ts @@ -239,6 +239,85 @@ describe('registerWebWorkerWasm()', () => { }); }); +describe('single-buffer-image fallback', () => { + // The patched Module constructor registers the module as a buffer image. + function registerBufferImage(bytes: Uint8Array): void { + new WebAssembly.Module(bytes); + } + + it('matches unpredicted synthetic names when exactly one buffer image exists', () => { + registerBufferImage(buildWasm({ padding: 17000 })); + + const frames = [ + frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'), + ]; + + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + expect(frames[0]?.platform).toBe('native'); + }); + + it('does not fall back when the filename is a regular url', () => { + registerBufferImage(buildWasm({ padding: 17000 })); + + const frames = [frameForFilename('http://localhost:8001/other.wasm:wasm-function[0]:0x1e')]; + + expect(patchFrames(frames)).toBe(false); + expect(frames[0]?.addr_mode).toBeUndefined(); + }); + + it('does not fall back when multiple buffer images exist', () => { + registerBufferImage(buildWasm({ padding: 17000 })); + registerBufferImage(buildWasm({ padding: 18000 })); + + const frames = [ + frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'), + ]; + + expect(patchFrames(frames)).toBe(false); + }); + + it('does not fall back for images registered from streaming urls', () => { + const module = new WebAssembly.Module(buildWasm({ padding: 17000 })); + IMAGES.length = 0; // drop the auto-registered buffer image + registerModule(module, 'http://localhost:8001/main.wasm'); + + const frames = [ + frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'), + ]; + + expect(patchFrames(frames)).toBe(false); + }); + + it('does not fall back for unmatched wasm:// names of modules compiled before SDK init', () => { + registerBufferImage(buildWasm({ padding: 17000 })); + + const frames = [frameForFilename('wasm://wasm/ffffffff:wasm-function[0]:0x1e')]; + + expect(patchFrames(frames)).toBe(false); + expect(frames[0]?.addr_mode).toBeUndefined(); + }); + + it('falls back when the same module is registered on the main thread and in a worker', () => { + const bytes = buildWasm({ padding: 17000 }); + registerBufferImage(bytes); + (GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages = [ + { ...(getImages()[0] as WasmDebugImage) }, + ]; + + try { + const frames = [ + frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'), + ]; + + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + } finally { + delete (GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages; + } + }); +}); + describe('processEvent', () => { it('strips internal match urls from attached debug images', () => { const bytes = buildWasm({ padding: 17000 });