diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 6f75c4d454ca..d5982201d069 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -39,7 +39,7 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { - patchWebAssembly(); + patchWebAssembly(registerModule); }, processEvent(event: Event): Event { let hasAtLeastOneWasmFrameWithImage = false; @@ -50,8 +50,8 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { event.exception.values.forEach(exception => { if (exception.stacktrace?.frames) { hasAtLeastOneWasmFrameWithImage = - hasAtLeastOneWasmFrameWithImage || - patchFrames(exception.stacktrace.frames, options.applicationKey, existingImagesCount); + patchFrames(exception.stacktrace.frames, options.applicationKey, existingImagesCount) || + hasAtLeastOneWasmFrameWithImage; } }); } @@ -158,62 +158,14 @@ function getWorkerImage(url: string): number { * - `self`: The worker's global scope (self). */ export function registerWebWorkerWasm({ self }: RegisterWebWorkerWasmOptions): void { - patchWebAssemblyWithForwarding(self); -} + patchWebAssembly((module, url) => { + const image = registerModule(module, url); -/** - * Patches the WebAssembly object in the worker scope and forwards - * registered modules to the parent thread. - */ -function patchWebAssemblyWithForwarding(workerSelf: MinimalDedicatedWorkerGlobalScope): void { - if ('instantiateStreaming' in WebAssembly) { - const origInstantiateStreaming = WebAssembly.instantiateStreaming; - WebAssembly.instantiateStreaming = function instantiateStreaming( - response: Response | PromiseLike, - importObject: WebAssembly.Imports, - ): Promise { - return Promise.resolve(response).then(response => { - return origInstantiateStreaming(response, importObject).then(rv => { - if (response.url) { - registerModuleAndForward(rv.module, response.url, workerSelf); - } - return rv; - }); - }); - } as typeof WebAssembly.instantiateStreaming; - } - - if ('compileStreaming' in WebAssembly) { - const origCompileStreaming = WebAssembly.compileStreaming; - WebAssembly.compileStreaming = function compileStreaming( - source: Response | Promise, - ): Promise { - return Promise.resolve(source).then(response => { - return origCompileStreaming(response).then(module => { - if (response.url) { - registerModuleAndForward(module, response.url, workerSelf); - } - return module; - }); + if (image) { + self.postMessage({ + _sentryMessage: true, + _sentryWasmImages: [image], }); - } as typeof WebAssembly.compileStreaming; - } -} - -/** - * Registers a WASM module and forwards its debug image to the parent thread. - */ -function registerModuleAndForward( - module: WebAssembly.Module, - url: string, - workerSelf: MinimalDedicatedWorkerGlobalScope, -): void { - const image = registerModule(module, url); - - if (image) { - workerSelf.postMessage({ - _sentryMessage: true, - _sentryWasmImages: [image], - }); - } + } + }); } diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index 89c15e72b1a7..e4f7b527a2a0 100644 --- a/packages/wasm/src/patchWebAssembly.ts +++ b/packages/wasm/src/patchWebAssembly.ts @@ -1,39 +1,58 @@ -import { registerModule } from './registry'; +export type RegisterModuleCallback = (module: WebAssembly.Module, url: string) => void; /** - * Patches the web assembly runtime. + * Patches the WebAssembly streaming APIs so that every compiled module gets + * registered as a debug image under the URL of the response it was compiled + * from. + * + * @param registerModule callback invoked for every successfully compiled module */ -export function patchWebAssembly(): void { +export function patchWebAssembly(registerModule: RegisterModuleCallback): void { if ('instantiateStreaming' in WebAssembly) { - const origInstantiateStreaming = WebAssembly.instantiateStreaming; + const origInstantiateStreaming = WebAssembly.instantiateStreaming as ( + response: unknown, + ...rest: unknown[] + ) => Promise; WebAssembly.instantiateStreaming = function instantiateStreaming( response: Response | PromiseLike, - importObject: WebAssembly.Imports, - ): Promise { + ...rest: unknown[] + ): Promise { return Promise.resolve(response).then(response => { - return origInstantiateStreaming(response, importObject).then(rv => { + return origInstantiateStreaming(response, ...rest).then(rv => { if (response.url) { - registerModule(rv.module, response.url); + registerSafely(registerModule, rv.module, response.url); } return rv; }); }); - } as typeof WebAssembly.instantiateStreaming; + }; } if ('compileStreaming' in WebAssembly) { - const origCompileStreaming = WebAssembly.compileStreaming; + const origCompileStreaming = WebAssembly.compileStreaming as ( + source: unknown, + ...rest: unknown[] + ) => Promise; WebAssembly.compileStreaming = function compileStreaming( - source: Response | Promise, + source: Response | PromiseLike, + ...rest: unknown[] ): Promise { return Promise.resolve(source).then(response => { - return origCompileStreaming(response).then(module => { + return origCompileStreaming(response, ...rest).then(module => { if (response.url) { - registerModule(module, response.url); + registerSafely(registerModule, module, response.url); } return module; }); }); - } as typeof WebAssembly.compileStreaming; + }; + } +} + +function registerSafely(registerModule: RegisterModuleCallback, module: WebAssembly.Module, url: string): void { + try { + registerModule(module, url); + } catch { + // a registration failure must never break the user's WebAssembly call } } diff --git a/packages/wasm/test/patchWebAssembly.test.ts b/packages/wasm/test/patchWebAssembly.test.ts new file mode 100644 index 000000000000..6a4e46c9364f --- /dev/null +++ b/packages/wasm/test/patchWebAssembly.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { patchWebAssembly } from '../src/patchWebAssembly'; + +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; + + afterEach(() => { + WebAssembly.instantiateStreaming = originalInstantiateStreaming; + WebAssembly.compileStreaming = originalCompileStreaming; + }); + + it('forwards every argument to instantiateStreaming and registers the module', async () => { + const orig = vi.fn().mockResolvedValue({ module: MODULE, instance: {} }); + WebAssembly.instantiateStreaming = orig as unknown as typeof WebAssembly.instantiateStreaming; + const registered: Array<[WebAssembly.Module, string]> = []; + + patchWebAssembly((module, url) => registered.push([module, url])); + + const importObject = { env: {} }; + const compileOptions = { builtins: ['js-string'] }; + await (WebAssembly.instantiateStreaming as unknown as (...args: unknown[]) => Promise)( + RESPONSE, + importObject, + compileOptions, + ); + + expect(orig).toHaveBeenCalledWith(RESPONSE, importObject, compileOptions); + expect(registered).toEqual([[MODULE, RESPONSE.url]]); + }); + + it('forwards every argument to compileStreaming and registers the module', async () => { + const orig = vi.fn().mockResolvedValue(MODULE); + WebAssembly.compileStreaming = orig as unknown as typeof WebAssembly.compileStreaming; + const registered: Array<[WebAssembly.Module, string]> = []; + + patchWebAssembly((module, url) => registered.push([module, url])); + + const compileOptions = { builtins: ['js-string'] }; + await (WebAssembly.compileStreaming as unknown as (...args: unknown[]) => Promise)( + RESPONSE, + compileOptions, + ); + + expect(orig).toHaveBeenCalledWith(RESPONSE, compileOptions); + expect(registered).toEqual([[MODULE, RESPONSE.url]]); + }); + + it('does not register modules of responses without a url', async () => { + WebAssembly.compileStreaming = vi.fn().mockResolvedValue(MODULE) as unknown as typeof WebAssembly.compileStreaming; + const registered: string[] = []; + + patchWebAssembly((_module, url) => registered.push(url)); + + await WebAssembly.compileStreaming({ url: '' } as Response); + + expect(registered).toEqual([]); + }); + + it('resolves the original result even if registration throws', async () => { + WebAssembly.compileStreaming = vi.fn().mockResolvedValue(MODULE) as unknown as typeof WebAssembly.compileStreaming; + + patchWebAssembly(() => { + throw new Error('registration failed'); + }); + + await expect(WebAssembly.compileStreaming(RESPONSE)).resolves.toBe(MODULE); + }); +}); diff --git a/packages/wasm/test/processEvent.test.ts b/packages/wasm/test/processEvent.test.ts new file mode 100644 index 000000000000..d855b97b8185 --- /dev/null +++ b/packages/wasm/test/processEvent.test.ts @@ -0,0 +1,38 @@ +import type { Event } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { wasmIntegration } from '../src/index'; +import { IMAGES } from '../src/registry'; + +const WASM_FILENAME = 'http://localhost:8001/main.wasm:wasm-function[10]:0x1234'; + +function exceptionValue(): NonNullable['values']>[number] { + return { stacktrace: { frames: [{ filename: WASM_FILENAME, function: 'run', in_app: true }] } }; +} + +describe('processEvent()', () => { + afterEach(() => { + IMAGES.length = 0; + }); + + it('patches frames of all exception values, not only the first matching one', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/main.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { exception: { values: [exceptionValue(), exceptionValue()] } }, + {}, + {} as never, + ) as Event; + + const frames = event.exception?.values?.map(value => value.stacktrace?.frames?.[0]); + expect(frames?.[0]?.addr_mode).toBe('rel:0'); + expect(frames?.[1]?.addr_mode).toBe('rel:0'); + expect(event.debug_meta?.images).toHaveLength(1); + }); +});