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', + }, + ]); + }, +); diff --git a/packages/wasm/src/patchWasmResponse.ts b/packages/wasm/src/patchWasmResponse.ts new file mode 100644 index 000000000000..e4117117a118 --- /dev/null +++ b/packages/wasm/src/patchWasmResponse.ts @@ -0,0 +1,103 @@ +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 + * 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; + } + + 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 buffer; + }); + }; + }); + + 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 bytes; + }); + }; + }); + + addNonEnumerableProperty(responseProto, PATCHED_SYMBOL, true); +} + +/** @internal */ +export function _resetResponsePatchForTests(): void { + if (typeof Response !== 'undefined') { + addNonEnumerableProperty(Response.prototype, PATCHED_SYMBOL, false); + } +} diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index e4f7b527a2a0..005a749c618b 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,64 @@ 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) { + registerSafely(registerModule, module, url); + } +} + +/** + * Patches the non-streaming web assembly runtime. + */ +function patchNonStreamingWebAssembly(registerModule: RegisterModuleCallback): void { + if (nonStreamingPatched) { + return; + } + + nonStreamingPatched = true; + + // 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(source, ...rest); + } + + return origInstantiate(source, ...rest).then(result => { + registerFromBufferSource(registerModule, result.module, source); + return result; + }); + } as typeof WebAssembly.instantiate; + + 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; + }); + }; +} + +/** + * 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/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']); + }); +}); diff --git a/packages/wasm/test/patchWebAssembly.test.ts b/packages/wasm/test/patchWebAssembly.test.ts index 6a4e46c9364f..29eaef416d56 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,104 @@ 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); + }); +}); + +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); + }); +}); 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; }); });