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 4ba420eb54cd..5d7ba38c6e2a 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts @@ -20,13 +20,16 @@ function serveWasmFixture(page: Page): Promise { } const IMAGE_MATCHER = { - code_file: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/), code_id: '0ba020cdd2444f7eafdd25999a8e9010', debug_file: null, debug_id: '0ba020cdd2444f7eafdd25999a8e90100', type: 'wasm', }; +// Modules at or below V8's content-hashing cutoff are registered under a +// placeholder name, since the name the engine picks cannot be predicted. +const SMALL_IMAGE_MATCHER = { ...IMAGE_MATCHER, code_file: 'wasm://wasm/unknown' }; + const FRAME_MATCHER = { function: 'internal_func', in_app: true, @@ -35,6 +38,66 @@ const FRAME_MATCHER = { platform: 'native', }; +sentryTest( + 'falls back to the single buffer module below the content-hash cutoff', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName) || browserName === 'firefox') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + await serveWasmFixture(page); + await page.goto(url); + + const { event } = await page.evaluate(async () => { + // @ts-expect-error this function exists + return window.getEvent(); + }); + + expect(event.exception.values[0].stacktrace.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ...FRAME_MATCHER, + filename: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/), + }), + ]), + ); + + expect(event.debug_meta).toMatchObject({ images: [SMALL_IMAGE_MATCHER] }); + }, +); + +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: [SMALL_IMAGE_MATCHER] }); + }, +); + sentryTest( 'exactly matches the length-derived synthetic name for modules above the content-hash cutoff', async ({ getLocalTestUrl, page, browserName }) => { @@ -70,3 +133,33 @@ 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, byteLength } = 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'), + }), + ]), + ); + + const expectedUrl = `wasm://wasm/${(byteLength * 4 + 2).toString(16).padStart(8, '0')}`; + expect(event.debug_meta).toMatchObject({ images: [{ ...IMAGE_MATCHER, code_file: expectedUrl }] }); + }, +); diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index f028dbcf265d..3eb5936f2455 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -135,6 +135,17 @@ export function patchFrames( const mainThreadImagesCount = getImages().length; frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`; hasAtLeastOneWasmFrameWithImage = true; + } else if (isEngineNamedWasmFilename(match[1])) { + // The engine names modules that were compiled from raw bytes itself: + // V8 hashes the content of modules below its hashing cutoff, Firefox + // derives the name from the compile call site. Neither can be + // predicted at registration time. If exactly one distinct module was + // registered from raw bytes, the frame can only belong to it. + const fallbackIndex = getSingleBufferImageIndex(); + if (fallbackIndex >= 0) { + frame.addr_mode = `rel:${existingImagesOffset + fallbackIndex}`; + hasAtLeastOneWasmFrameWithImage = true; + } } } }); @@ -158,6 +169,33 @@ function getWorkerImage(url: string): number { return getWorkerImages().findIndex(image => image.type === 'wasm' && image.code_file === url); } +function isEngineNamedWasmFilename(filename: string): boolean { + return filename.startsWith('wasm://') || 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 build id. + */ +function getSingleBufferImageIndex(): number { + const mainImages = getImages(); + const workerImages = getWorkerImages(); + let index = -1; + const buildIds = new Set(); + const collect = (image: WasmDebugImage, imageIndex: number): void => { + const buildId = image.code_id; + if (image._fromBuffer && buildId && !buildIds.has(buildId)) { + buildIds.add(buildId); + index = imageIndex; + } + }; + mainImages.forEach((image, i) => collect(image, i)); + workerImages.forEach((image, i) => collect(image, mainImages.length + i)); + return buildIds.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 5a7a19bf32fc..24aaffcd1579 100644 --- a/packages/wasm/test/nonstreaming.test.ts +++ b/packages/wasm/test/nonstreaming.test.ts @@ -36,6 +36,7 @@ interface BuildWasmOptions { moduleName?: string; padding?: number; padSeed?: number; + buildIdSeed?: number; } const WASM_HEADER = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; @@ -48,13 +49,14 @@ const CODE_SECTION = [0x0a, 0x05, 0x01, 0x03, 0x00, 0x00, 0x0b]; // body: unreac // optionally with build_id / name sections and a padding custom section. function buildWasm({ buildId = true, + buildIdSeed = 0, moduleName, padding = 0, padSeed = 0, }: BuildWasmOptions = {}): Uint8Array { const bytes = [...WASM_HEADER, ...TYPE_SECTION, ...FUNCTION_SECTION, ...EXPORT_SECTION, ...CODE_SECTION]; if (buildId) { - bytes.push(...customSection('build_id', BUILD_ID_BYTES)); + bytes.push(...customSection('build_id', [...BUILD_ID_BYTES.slice(0, 15), BUILD_ID_BYTES[15]! + buildIdSeed])); } if (moduleName) { const nameBytes = [...moduleName].map(c => c.charCodeAt(0)); @@ -248,6 +250,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 call-site-derived 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, buildIdSeed: 1 })); + + 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('matches unpredicted wasm:// names, which is how modules below the hashing cutoff are found', () => { + registerBufferImage(buildWasm({ padding: 100 })); + + const frames = [frameForFilename('wasm://wasm/ffffffff:wasm-function[0]:0x1e')]; + + expect(patchFrames(frames)).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + 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 fields from attached debug images', () => { const bytes = buildWasm({ padding: 17000 });