From 5ae3418c2e2eb2224e62e6a4bf7ba1674ac580ae Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Mon, 31 Aug 2026 14:36:20 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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(); + }); +});