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/index.ts b/packages/wasm/src/index.ts index d5982201d069..1550ba770865 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -1,7 +1,8 @@ -import type { DebugImage, Event, IntegrationFn, StackFrame } from '@sentry/core'; +import type { Event, IntegrationFn, StackFrame } from '@sentry/core'; import { defineIntegration, GLOBAL_OBJ } from '@sentry/core'; +import { uniqueImageForSyntheticFilename } from './matchSyntheticWasmFilename'; import { patchWebAssembly } from './patchWebAssembly'; -import { getImage, getImages, registerModule } from './registry'; +import { getImage, getImages, registerModule, toProtocolDebugImage, type RegisteredWasmImage } from './registry'; const INTEGRATION_NAME = 'Wasm'; @@ -32,7 +33,7 @@ interface WasmIntegrationOptions { // Access WINDOW with proper typing for _sentryWasmImages const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryWasmImages?: Array; + _sentryWasmImages?: Array; }; const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { @@ -58,8 +59,8 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { if (hasAtLeastOneWasmFrameWithImage) { event.debug_meta = event.debug_meta || {}; - const mainThreadImages = getImages(); - const workerImages = WINDOW._sentryWasmImages || []; + const mainThreadImages = getImages().map(toProtocolDebugImage); + const workerImages = (WINDOW._sentryWasmImages || []).map(toProtocolDebugImage); event.debug_meta.images = [...(event.debug_meta.images || []), ...mainThreadImages, ...workerImages]; } @@ -109,9 +110,11 @@ export function patchFrames( match = frame.filename.match(PARSER_REGEX) as null | [string, string, string]; } + // `:wasm-function[N]:0xADDR` — address is still in filename (JS parser did not split it). + // `` is usually the fetch URL (`http://…/app.wasm`); Chrome may instead use `wasm://wasm/-`. if (match) { - const index = getImage(match[1]); - const workerImageIndex = getWorkerImage(match[1]); + let index = getImage(match[1]); + let workerImageIndex = getWorkerImage(match[1]); frame.instruction_addr = match[2]; frame.filename = match[1]; frame.platform = 'native'; @@ -123,6 +126,19 @@ export function patchFrames( }; } + // Exact `code_file` miss: `match[1]` is `wasm://wasm/…`, not the registered http URL. + if (index < 0 && workerImageIndex < 0) { + const unique = uniqueImageForSyntheticFilename(match[1], getImages(), WINDOW._sentryWasmImages || []); + if (unique) { + frame.filename = unique.codeFile; + if (unique.worker) { + workerImageIndex = unique.index; + } else { + index = unique.index; + } + } + } + if (index >= 0) { frame.addr_mode = `rel:${existingImagesOffset + index}`; hasAtLeastOneWasmFrameWithImage = true; @@ -131,6 +147,23 @@ export function patchFrames( frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`; hasAtLeastOneWasmFrameWithImage = true; } + } else { + // Bare `wasm://wasm/-` — JS parser already set `instruction_addr`. + const unique = uniqueImageForSyntheticFilename(frame.filename, getImages(), WINDOW._sentryWasmImages || []); + if (unique && frame.instruction_addr) { + frame.filename = unique.codeFile; + frame.platform = 'native'; + if (applicationKey) { + frame.module_metadata = { + ...frame.module_metadata, + [`${BUNDLER_PLUGIN_APP_KEY_PREFIX}${applicationKey}`]: true, + }; + } + frame.addr_mode = unique.worker + ? `rel:${existingImagesOffset + getImages().length + unique.index}` + : `rel:${existingImagesOffset + unique.index}`; + hasAtLeastOneWasmFrameWithImage = true; + } } }); diff --git a/packages/wasm/src/matchSyntheticWasmFilename.ts b/packages/wasm/src/matchSyntheticWasmFilename.ts new file mode 100644 index 000000000000..a6a3807e0782 --- /dev/null +++ b/packages/wasm/src/matchSyntheticWasmFilename.ts @@ -0,0 +1,125 @@ +import type { DebugImage } from '@sentry/core'; +import type { RegisteredWasmImage } from './registry'; + +/** + * Maps Chrome `wasm://wasm/-` frames to a registered `code_file`. + * + * Prefer the wasm `name` section (`moduleName`). If that section is missing or + * does not match the stack label, guess from the fetch URL basename (including + * wasm-bindgen `_bg.wasm` → `.wasm`). Hits are accepted only when every + * candidate shares one `debug_id`. + * + * Hash-only `wasm://wasm/` is not mapped (see #23781). + * + * Fetch-URL frames (`http://…/file.wasm:wasm-function[…]`) still use exact + * `code_file` lookup in `patchFrames`, not this matcher. + */ + +export type SyntheticWasmImageHit = { + index: number; + worker: boolean; + codeFile: string; +}; + +type Hit = SyntheticWasmImageHit & { debugId: string }; + +/** Last path segment of a registered wasm URL (`http://…/demo_bg.wasm` → `demo_bg.wasm`). */ +export function fileBasename(url: string): string | undefined { + try { + return new URL(url).pathname.split('/').pop() || undefined; + } catch { + return url.split('/').pop(); + } +} + +/** + * Chrome's module label without the `wasm://wasm/` prefix or trailing isolate hash. + * `wasm://wasm/demo.wasm-000197f6` → `demo.wasm`. Hash-only `wasm://wasm/0bee4c4e` → `0bee4c4e`. + */ +export function syntheticModuleName(filename: string): string | undefined { + const body = filename.match(/^wasm:\/\/wasm\/(.+)$/i)?.[1]; + if (!body) { + return undefined; + } + return body.replace(/-[0-9a-fA-F]{6,16}$/, ''); +} + +/** + * Fetch filename plus known packaging aliases. + * + * wasm-bindgen writes `foo_bg.wasm` next to `foo.js` but the stack label is often + * `foo.wasm`. Used when the name section is missing or does not match. + */ +export function namesForRegisteredWasm(codeFile: string): string[] { + const basename = fileBasename(codeFile); + if (!basename) { + return []; + } + + const names = [basename]; + const withoutBindgenBg = basename.replace(/_bg\.wasm$/i, '.wasm'); + if (withoutBindgenBg !== basename) { + names.push(withoutBindgenBg); + } + return names; +} + +export function registeredWasmMatchesSyntheticName(codeFile: string, syntheticName: string): boolean { + return namesForRegisteredWasm(codeFile).includes(syntheticName); +} + +function wasmNameSectionName(image: DebugImage): string | undefined { + const moduleName = (image as RegisteredWasmImage).moduleName; + return typeof moduleName === 'string' && moduleName.length > 0 ? moduleName : undefined; +} + +export function imageMatchesSyntheticName(image: DebugImage, syntheticName: string): boolean { + if (wasmNameSectionName(image) === syntheticName) { + return true; + } + return typeof image.code_file === 'string' && registeredWasmMatchesSyntheticName(image.code_file, syntheticName); +} + +/** + * Multiple URLs may register the same binary. Only use a hit when every candidate + * shares one `debug_id`. Different binaries with the same name stay unmatched. + */ +export function uniqueHitByDebugId(hits: T[]): T | undefined { + const debugIds = new Set(hits.map(hit => hit.debugId)); + return debugIds.size === 1 ? hits[0] : undefined; +} + +/** + * Chrome's isolate hash is not a debug_id and is not on `WebAssembly.Module`. + * `wasm://wasm/` with no module name must not pick an image (see #23781). + */ +function isHashOnlySyntheticName(name: string): boolean { + return /^[0-9a-fA-F]{6,16}$/.test(name); +} + +export function uniqueImageForSyntheticFilename( + filename: string, + pageImages: ReadonlyArray, + workerImages: ReadonlyArray, +): SyntheticWasmImageHit | undefined { + const name = syntheticModuleName(filename); + if (!name || isHashOnlySyntheticName(name)) { + return undefined; + } + + const hits: Hit[] = []; + const consider = (images: ReadonlyArray, worker: boolean): void => { + images.forEach((image, index) => { + if (image.type === 'wasm' && typeof image.code_file === 'string' && imageMatchesSyntheticName(image, name)) { + hits.push({ index, worker, codeFile: image.code_file, debugId: image.debug_id }); + } + }); + }; + consider(pageImages, false); + consider(workerImages, true); + const hit = uniqueHitByDebugId(hits); + if (!hit) { + return undefined; + } + return { index: hit.index, worker: hit.worker, codeFile: hit.codeFile }; +} diff --git a/packages/wasm/src/patchWasmResponse.ts b/packages/wasm/src/patchWasmResponse.ts new file mode 100644 index 000000000000..3aaa19e4f9f3 --- /dev/null +++ b/packages/wasm/src/patchWasmResponse.ts @@ -0,0 +1,100 @@ +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. + * + * 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(); + +let responseReadersPatched = false; + +/** + * Resolves a wasm source buffer back to its fetch URL, when known. + */ +export function getWasmSourceUrl(source: unknown): string | undefined { + const buffer = toArrayBuffer(source); + if (!buffer) { + return undefined; + } + + return wasmSourceUrls.get(buffer); +} + +function toArrayBuffer(source: unknown): 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)); +} + +/** + * 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 + } +} + +/** + * Patches Response body readers so wasm bytes remember their fetch URL. + */ +export function patchWasmResponseBodyReaders(): void { + if (responseReadersPatched || typeof Response === 'undefined') { + 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 => { + tagResponseSource(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 => { + tagResponseSource(this, bytes); + return bytes; + }); + }; + }); +} + +/** @internal */ +export function _resetResponsePatchForTests(): void { + responseReadersPatched = false; +} diff --git a/packages/wasm/src/patchWebAssembly.ts b/packages/wasm/src/patchWebAssembly.ts index e4f7b527a2a0..7559733ac321 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 { +function patchStreamingWebAssembly(registerModule: RegisterModuleCallback): void { if ('instantiateStreaming' in WebAssembly) { const origInstantiateStreaming = WebAssembly.instantiateStreaming as ( response: unknown, @@ -56,3 +60,82 @@ function registerSafely(registerModule: RegisterModuleCallback, module: WebAssem // a registration failure must never break the user's WebAssembly call } } + +/** + * 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, + compiled: WebAssembly.Module | WebAssembly.WebAssemblyInstantiatedSource | WebAssembly.Instance, + source: unknown, +): void { + 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 + } +} + +/** + * 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: unknown, ...rest: unknown[]) { + return origInstantiate(source, ...rest).then(result => { + registerFromBufferSource(registerModule, result, 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. + * + * 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 { + tryPatch(() => patchWasmResponseBodyReaders()); + tryPatch(() => patchNonStreamingWebAssembly(registerModule)); + tryPatch(() => patchStreamingWebAssembly(registerModule)); +} + +function tryPatch(patch: () => void): void { + try { + patch(); + } catch { + // see patchWebAssembly() + } +} + +/** @internal */ +export function _resetNonStreamingPatchForTests(): void { + nonStreamingPatched = false; +} diff --git a/packages/wasm/src/registry.ts b/packages/wasm/src/registry.ts index 2ca6d66754dc..0b606451d7fa 100644 --- a/packages/wasm/src/registry.ts +++ b/packages/wasm/src/registry.ts @@ -1,10 +1,17 @@ import type { DebugImage } from '@sentry/core'; +import { parseNameSectionModuleName } from './wasmNameSection'; -export const IMAGES: Array = []; +export type RegisteredWasmImage = Extract & { + /** Internal: wasm `name` section. Used to link `wasm://` stacks; stripped before the event is sent. */ + moduleName?: string; +}; + +export const IMAGES: Array = []; export interface ModuleInfo { buildId: string | null; debugFile: string | null; + moduleName: string | null; } /** @@ -34,14 +41,25 @@ export function getModuleInfo(module: WebAssembly.Module): ModuleInfo { debugFile = decoder.decode(firstExternalDebugInfo); } - return { buildId, debugFile }; + let moduleName = null; + try { + const nameSections = WebAssembly.Module.customSections(module, 'name'); + const nameSection0 = nameSections[0]; + if (nameSection0) { + moduleName = parseNameSectionModuleName(nameSection0); + } + } catch { + moduleName = null; + } + + return { buildId, debugFile, moduleName }; } /** * Records a module and returns the created debug image. */ -export function registerModule(module: WebAssembly.Module, url: string): DebugImage | null { - const { buildId, debugFile } = getModuleInfo(module); +export function registerModule(module: WebAssembly.Module, url: string): RegisteredWasmImage | null { + const { buildId, debugFile, moduleName } = getModuleInfo(module); if (!buildId) { return null; } @@ -61,13 +79,16 @@ export function registerModule(module: WebAssembly.Module, url: string): DebugIm } } - const image: DebugImage = { + const image: RegisteredWasmImage = { type: 'wasm', code_id: buildId, code_file: url, debug_file: debugFileUrl, debug_id: `${buildId.padEnd(32, '0').slice(0, 32)}0`, }; + if (moduleName) { + image.moduleName = moduleName; + } IMAGES.push(image); return image; @@ -76,10 +97,18 @@ export function registerModule(module: WebAssembly.Module, url: string): DebugIm /** * Returns all known images. */ -export function getImages(): Array { +export function getImages(): Array { return IMAGES; } +/** + * Debug image payload for Sentry: protocol fields only (no internal `moduleName`). + */ +export function toProtocolDebugImage(image: RegisteredWasmImage): DebugImage { + const { moduleName: _moduleName, ...protocol } = image; + return protocol; +} + /** * Looks up an image by URL. * diff --git a/packages/wasm/src/wasmNameSection.ts b/packages/wasm/src/wasmNameSection.ts new file mode 100644 index 000000000000..14878655cc3c --- /dev/null +++ b/packages/wasm/src/wasmNameSection.ts @@ -0,0 +1,86 @@ +/* eslint-disable no-bitwise -- LEB128 is a bitwise encoding */ + +/** + * Parses the wasm `name` custom section payload — the bytes + * `WebAssembly.Module.customSections(module, 'name')` returns. + * + * Chrome's buffer-compiled stacks use this module name as `wasm://wasm/-`, + * which often differs from the fetch URL (wasm-bindgen `demo.wasm` vs `demo_bg.wasm`). + * Missing, stripped, or malformed sections return null. Callers then guess from + * the fetch URL basename if the name is missing or does not match the stack. + * + * @see https://webassembly.github.io/spec/core/appendix/custom.html#name-section + */ +export function parseNameSectionModuleName(source: ArrayBuffer | Uint8Array): string | null { + try { + return readModuleName(toBytes(source)); + } catch { + return null; + } +} + +function toBytes(source: ArrayBuffer | Uint8Array): Uint8Array { + if (source instanceof ArrayBuffer) { + return new Uint8Array(source); + } + return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); +} + +function readModuleName(bytes: Uint8Array): string | null { + const cursor = { offset: 0 }; + while (cursor.offset < bytes.length) { + const id = bytes[cursor.offset]; + cursor.offset += 1; + if (id === undefined) { + return null; + } + + const size = readU32Leb(bytes, cursor); + if (size === undefined || cursor.offset + size > bytes.length) { + return null; + } + + const start = cursor.offset; + cursor.offset += size; + if (id === 0) { + return readName(bytes.subarray(start, start + size)); + } + } + + return null; +} + +function readName(bytes: Uint8Array): string | null { + const cursor = { offset: 0 }; + const length = readU32Leb(bytes, cursor); + if (length === undefined || length === 0 || cursor.offset + length !== bytes.length) { + return null; + } + + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(cursor.offset)); + } catch { + return null; + } +} + +function readU32Leb(bytes: Uint8Array, cursor: { offset: number }): number | undefined { + let result = 0; + let shift = 0; + + while (cursor.offset < bytes.length) { + const byte = bytes[cursor.offset]; + cursor.offset += 1; + if (byte === undefined || shift >= 35) { + return undefined; + } + + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) { + return result >>> 0; + } + shift += 7; + } + + return undefined; +} 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/matchSyntheticWasmFilename.test.ts b/packages/wasm/test/matchSyntheticWasmFilename.test.ts new file mode 100644 index 000000000000..917dab0b6edf --- /dev/null +++ b/packages/wasm/test/matchSyntheticWasmFilename.test.ts @@ -0,0 +1,156 @@ +import type { DebugImage } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { + fileBasename, + namesForRegisteredWasm, + syntheticModuleName, + uniqueHitByDebugId, + uniqueImageForSyntheticFilename, +} from '../src/matchSyntheticWasmFilename'; + +const DEMO_BG_URL = 'http://localhost:8080/web/assets/rust/demo_bg.wasm'; +const DEBUG_ID_A = 'aaa00000000000000000000000000000'; +const DEBUG_ID_B = 'bbb00000000000000000000000000000'; + +function wasmImage(overrides: Partial> & { moduleName?: string }): DebugImage { + return { + type: 'wasm', + code_id: 'aaa', + code_file: DEMO_BG_URL, + debug_file: null, + debug_id: DEBUG_ID_A, + ...overrides, + }; +} + +describe('syntheticModuleName()', () => { + it('strips the Chrome isolate hash', () => { + expect(syntheticModuleName('wasm://wasm/demo.wasm-000197f6')).toBe('demo.wasm'); + }); + + it('returns a hash-only label unchanged', () => { + expect(syntheticModuleName('wasm://wasm/0bee4c4e')).toBe('0bee4c4e'); + }); + + it('returns undefined for a fetch URL', () => { + expect(syntheticModuleName(DEMO_BG_URL)).toBeUndefined(); + }); +}); + +describe('namesForRegisteredWasm()', () => { + it('includes the bindgen _bg alias', () => { + expect(namesForRegisteredWasm(DEMO_BG_URL)).toEqual(['demo_bg.wasm', 'demo.wasm']); + }); + + it('does not invent an alias for a plain .wasm file', () => { + expect(namesForRegisteredWasm('http://localhost:8080/maze.split.wasm')).toEqual(['maze.split.wasm']); + }); +}); + +describe('fileBasename()', () => { + it('falls back when the value is not a URL', () => { + expect(fileBasename('not a url/demo.wasm')).toBe('demo.wasm'); + }); +}); + +describe('uniqueHitByDebugId()', () => { + it('returns the first hit when every debug_id matches', () => { + const hits = [ + { debugId: DEBUG_ID_A, codeFile: 'http://localhost:8001/v1/app.wasm' }, + { debugId: DEBUG_ID_A, codeFile: 'http://cdn.example/app.wasm' }, + ]; + expect(uniqueHitByDebugId(hits)).toEqual(hits[0]); + }); + + it('returns undefined when debug_ids differ', () => { + expect( + uniqueHitByDebugId([ + { debugId: DEBUG_ID_A, codeFile: 'http://localhost:8001/v1/app.wasm' }, + { debugId: DEBUG_ID_B, codeFile: 'http://localhost:8001/v2/app.wasm' }, + ]), + ).toBeUndefined(); + }); +}); + +describe('uniqueImageForSyntheticFilename()', () => { + it('matches wasm://wasm/demo.wasm when the image has moduleName demo.wasm', () => { + const image = wasmImage({ moduleName: 'demo.wasm' }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [image], [])).toEqual({ + index: 0, + worker: false, + codeFile: DEMO_BG_URL, + }); + }); + + it('does not guess when two modules share a moduleName but not debug_id', () => { + const pageImages = [ + wasmImage({ moduleName: 'demo.wasm', debug_id: DEBUG_ID_A, code_file: 'http://localhost:8001/v1/demo_bg.wasm' }), + wasmImage({ + moduleName: 'demo.wasm', + code_id: 'bbb', + debug_id: DEBUG_ID_B, + code_file: 'http://localhost:8001/v2/demo_bg.wasm', + }), + ]; + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', pageImages, [])).toBeUndefined(); + }); + + it('falls back to the fetch basename when the name section is absent', () => { + const image = wasmImage({ + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/maze.split.wasm-000197f6', [image], [])).toEqual({ + index: 0, + worker: false, + codeFile: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + }); + }); + + it('falls back to the bindgen _bg alias when the name section is absent', () => { + const image = wasmImage({}); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [image], [])).toEqual({ + index: 0, + worker: false, + codeFile: DEMO_BG_URL, + }); + }); + + it('falls back to the fetch filename when stored moduleName does not match the stack', () => { + const image = wasmImage({ moduleName: 'crate' }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [image], [])).toEqual({ + index: 0, + worker: false, + codeFile: DEMO_BG_URL, + }); + }); + + it('does not match when neither moduleName nor the fetch filename aliases the stack', () => { + const image = wasmImage({ + moduleName: 'crate', + code_file: 'http://localhost:8080/web/assets/other.wasm', + }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [image], [])).toBeUndefined(); + }); + + it('does not map a hash-only wasm:// label', () => { + const image = wasmImage({ moduleName: 'demo.wasm' }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/0bee4c4e', [image], [])).toBeUndefined(); + }); + + it('matches a worker image by module name', () => { + const image = wasmImage({ moduleName: 'demo.wasm' }); + + expect(uniqueImageForSyntheticFilename('wasm://wasm/demo.wasm-000197f6', [], [image])).toEqual({ + index: 0, + worker: true, + codeFile: DEMO_BG_URL, + }); + }); +}); diff --git a/packages/wasm/test/patchWebAssembly.test.ts b/packages/wasm/test/patchWebAssembly.test.ts index 6a4e46c9364f..ae5a4d4a3485 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,123 @@ 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); + }); + + 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', () => { + 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/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(); + }); +}); diff --git a/packages/wasm/test/processEvent.test.ts b/packages/wasm/test/processEvent.test.ts index d855b97b8185..547d66c6b4a6 100644 --- a/packages/wasm/test/processEvent.test.ts +++ b/packages/wasm/test/processEvent.test.ts @@ -1,7 +1,13 @@ -import type { Event } from '@sentry/core'; +import type { DebugImage, Event } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { wasmIntegration } from '../src/index'; -import { IMAGES } from '../src/registry'; +import { IMAGES, registerModule } from '../src/registry'; +import { wasmWithBuildIdAndModuleName, compileFixture } from './wasmModuleFixtures'; + +const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryWasmImages?: Array; +}; const WASM_FILENAME = 'http://localhost:8001/main.wasm:wasm-function[10]:0x1234'; @@ -12,6 +18,7 @@ function exceptionValue(): NonNullable['values'] describe('processEvent()', () => { afterEach(() => { IMAGES.length = 0; + delete WINDOW._sentryWasmImages; }); it('patches frames of all exception values, not only the first matching one', () => { @@ -35,4 +42,389 @@ describe('processEvent()', () => { expect(frames?.[1]?.addr_mode).toBe('rel:0'); expect(event.debug_meta?.images).toHaveLength(1); }); + + it('attaches images for wasm:// frames on the main thread when the basename is unique', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/maze.split.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + ); + expect(event.debug_meta?.images).toHaveLength(1); + }); + + it('attaches a wasm:// image when page and worker registered the same moduleName and debug_id', () => { + const image = { + type: 'wasm' as const, + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + moduleName: 'maze.split.wasm', + }; + IMAGES.push(image); + WINDOW._sentryWasmImages = [image]; + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/maze.split.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + ); + expect(event.debug_meta?.images).toHaveLength(2); + }); + + it('attaches a wasm:// image when the same debug_id is registered under two URLs', () => { + IMAGES.push( + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/v1/app.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + moduleName: 'app.wasm', + }, + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://cdn.example/app.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + moduleName: 'app.wasm', + }, + ); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/app.wasm-abc123', + function: 'run', + instruction_addr: '0x10', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe('http://localhost:8001/v1/app.wasm'); + expect(event.debug_meta?.images).toHaveLength(2); + }); + + it('does not guess a wasm:// image from code_file when two modules share a filename', () => { + IMAGES.push( + { + type: 'wasm', + code_id: 'aaa', + code_file: 'http://localhost:8001/v1/app.wasm', + debug_file: null, + debug_id: 'aaa00000000000000000000000000000', + }, + { + type: 'wasm', + code_id: 'bbb', + code_file: 'http://localhost:8001/v2/app.wasm', + debug_file: null, + debug_id: 'bbb00000000000000000000000000000', + }, + ); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/app.wasm-abc123', + function: 'run', + instruction_addr: '0x10', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBeUndefined(); + expect(event.debug_meta?.images).toBeUndefined(); + }); + + it('attaches a wasm:// image when the name section differs from the fetch basename', () => { + const module = compileFixture(wasmWithBuildIdAndModuleName([0xaa, 0xbb], 'demo.wasm')); + registerModule(module, 'http://localhost:8080/web/assets/rust/demo_bg.wasm'); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/demo.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + ); + expect(event.debug_meta?.images).toHaveLength(1); + expect(event.debug_meta?.images?.[0]).toEqual({ + type: 'wasm', + code_id: 'aabb', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + }); + }); + + it('attaches a wasm:// image via the bindgen _bg alias when no name section is stored', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'aabb', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/demo.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + ); + expect(event.debug_meta?.images).toHaveLength(1); + }); + + it('does not guess a wasm:// image when two modules share a name-section name', () => { + IMAGES.push( + { + type: 'wasm', + code_id: 'aaa', + code_file: 'http://localhost:8001/v1/demo_bg.wasm', + debug_file: null, + debug_id: 'aaa00000000000000000000000000000', + moduleName: 'demo.wasm', + }, + { + type: 'wasm', + code_id: 'bbb', + code_file: 'http://localhost:8001/v2/demo_bg.wasm', + debug_file: null, + debug_id: 'bbb00000000000000000000000000000', + moduleName: 'demo.wasm', + }, + ); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/demo.wasm-000197f6', + function: 'run', + instruction_addr: '0x10', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBeUndefined(); + expect(event.debug_meta?.images).toBeUndefined(); + }); + + it('does not attach debug_meta for a hash-only wasm:// frame', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + moduleName: 'demo.wasm', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'wasm://wasm/0bee4c4e', + function: 'run', + instruction_addr: '0x10', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe('wasm://wasm/0bee4c4e'); + expect(event.debug_meta?.images).toBeUndefined(); + }); + + it('matches http://…/demo_bg.wasm:wasm-function frames via exact code_file', () => { + IMAGES.push({ + type: 'wasm', + code_id: 'aabb', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + }); + + const integration = wasmIntegration(); + const event = integration.processEvent?.( + { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + filename: 'http://localhost:8080/web/assets/rust/demo_bg.wasm:wasm-function[10]:0x283d', + function: 'trigger_crash_divzero', + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + {} as never, + ) as Event; + + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + ); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.instruction_addr).toBe('0x283d'); + expect(event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.addr_mode).toBe('rel:0'); + expect(event.debug_meta?.images).toHaveLength(1); + }); }); diff --git a/packages/wasm/test/registry.test.ts b/packages/wasm/test/registry.test.ts new file mode 100644 index 000000000000..ae0b030148de --- /dev/null +++ b/packages/wasm/test/registry.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getModuleInfo, IMAGES, registerModule, toProtocolDebugImage } from '../src/registry'; +import { + compileFixture, + wasmWithBuildIdAndFunctionNamesOnly, + wasmWithBuildIdAndModuleName, + wasmWithBuildIdOnly, +} from './wasmModuleFixtures'; + +const CODE_FILE = 'http://localhost:8080/web/assets/rust/demo_bg.wasm'; + +describe('registerModule() name section', () => { + afterEach(() => { + IMAGES.length = 0; + }); + + it('stores the name-section module name on the debug image', () => { + const module = compileFixture(wasmWithBuildIdAndModuleName([0xaa, 0xbb], 'demo.wasm')); + + const image = registerModule(module, CODE_FILE); + + expect(image).toEqual({ + type: 'wasm', + code_id: 'aabb', + code_file: CODE_FILE, + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + moduleName: 'demo.wasm', + }); + }); + + it('omits moduleName when the name section is stripped', () => { + const module = compileFixture(wasmWithBuildIdOnly([0xaa, 0xbb])); + + const image = registerModule(module, CODE_FILE); + + expect(image?.moduleName).toBeUndefined(); + expect(getModuleInfo(module).moduleName).toBeNull(); + }); + + it('omits moduleName when the name section has no module name', () => { + const module = compileFixture(wasmWithBuildIdAndFunctionNamesOnly([0xaa, 0xbb])); + + const image = registerModule(module, CODE_FILE); + + expect(image?.moduleName).toBeUndefined(); + }); + + it('omits moduleName from the protocol debug image', () => { + const module = compileFixture(wasmWithBuildIdAndModuleName([0xaa, 0xbb], 'demo.wasm')); + const image = registerModule(module, CODE_FILE); + + expect(image).not.toBeNull(); + expect(toProtocolDebugImage(image!)).toEqual({ + type: 'wasm', + code_id: 'aabb', + code_file: CODE_FILE, + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + }); + }); +}); diff --git a/packages/wasm/test/wasmModuleFixtures.ts b/packages/wasm/test/wasmModuleFixtures.ts new file mode 100644 index 000000000000..ed5a648ce1d8 --- /dev/null +++ b/packages/wasm/test/wasmModuleFixtures.ts @@ -0,0 +1,56 @@ +/* eslint-disable no-bitwise -- LEB128 is a bitwise encoding */ + +const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + +export function u32leb(n: number): number[] { + const bytes: number[] = []; + let value = n >>> 0; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) { + byte |= 0x80; + } + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +export function encodeUtf8(text: string): number[] { + return Array.from(new TextEncoder().encode(text)); +} + +export function customSection(sectionName: string, payload: number[]): number[] { + const nameBytes = encodeUtf8(sectionName); + const body = [...u32leb(nameBytes.length), ...nameBytes, ...payload]; + return [0, ...u32leb(body.length), ...body]; +} + +/** Payload of a `name` custom section containing only the module-name subsection. */ +export function nameSectionPayload(moduleName: string): number[] { + const nameBytes = encodeUtf8(moduleName); + const subsection = [...u32leb(nameBytes.length), ...nameBytes]; + return [0, ...u32leb(subsection.length), ...subsection]; +} + +export function wasmModuleBytes(sections: number[][]): Uint8Array { + return Uint8Array.from([...WASM_MAGIC, ...sections.flat()]); +} + +export function compileFixture(bytes: Uint8Array): WebAssembly.Module { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return new WebAssembly.Module(buffer); +} + +export function wasmWithBuildIdAndModuleName(buildId: number[], moduleName: string): Uint8Array { + return wasmModuleBytes([customSection('build_id', buildId), customSection('name', nameSectionPayload(moduleName))]); +} + +export function wasmWithBuildIdOnly(buildId: number[]): Uint8Array { + return wasmModuleBytes([customSection('build_id', buildId)]); +} + +export function wasmWithBuildIdAndFunctionNamesOnly(buildId: number[]): Uint8Array { + return wasmModuleBytes([customSection('build_id', buildId), customSection('name', [1, ...u32leb(1), 0])]); +} diff --git a/packages/wasm/test/wasmNameSection.test.ts b/packages/wasm/test/wasmNameSection.test.ts new file mode 100644 index 000000000000..3f21c7792cc5 --- /dev/null +++ b/packages/wasm/test/wasmNameSection.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { parseNameSectionModuleName } from '../src/wasmNameSection'; +import { + compileFixture, + customSection, + nameSectionPayload, + u32leb, + wasmModuleBytes, + wasmWithBuildIdAndFunctionNamesOnly, + wasmWithBuildIdAndModuleName, + wasmWithBuildIdOnly, +} from './wasmModuleFixtures'; + +describe('parseNameSectionModuleName()', () => { + it('reads the module name from fixture bytes', () => { + expect(parseNameSectionModuleName(Uint8Array.from(nameSectionPayload('demo.wasm')))).toBe('demo.wasm'); + }); + + it('reads the name section Chrome would see on a compiled module', () => { + const module = compileFixture(wasmWithBuildIdAndModuleName([0xaa, 0xbb], 'demo.wasm')); + const sections = WebAssembly.Module.customSections(module, 'name'); + + expect(sections).toHaveLength(1); + expect(parseNameSectionModuleName(sections[0] as ArrayBuffer)).toBe('demo.wasm'); + }); + + it('returns null when the name section is missing', () => { + const module = compileFixture(wasmWithBuildIdOnly([0xaa])); + const sections = WebAssembly.Module.customSections(module, 'name'); + + expect(sections).toHaveLength(0); + expect(parseNameSectionModuleName(new Uint8Array())).toBeNull(); + }); + + it('returns null when only function names are present', () => { + const module = compileFixture(wasmWithBuildIdAndFunctionNamesOnly([0xaa])); + const sections = WebAssembly.Module.customSections(module, 'name'); + + expect(sections).toHaveLength(1); + expect(parseNameSectionModuleName(sections[0] as ArrayBuffer)).toBeNull(); + }); + + it('skips later subsections after the module name', () => { + const moduleName = nameSectionPayload('demo.wasm'); + const functionNames = [1, ...u32leb(1), 0]; + expect(parseNameSectionModuleName(Uint8Array.from([...moduleName, ...functionNames]))).toBe('demo.wasm'); + }); + + it('returns null for a truncated subsection', () => { + expect(parseNameSectionModuleName(Uint8Array.from([0, 10]))).toBeNull(); + }); + + it('returns null for an empty module name', () => { + expect(parseNameSectionModuleName(Uint8Array.from([0, ...u32leb(1), 0]))).toBeNull(); + }); + + it('returns null for invalid UTF-8', () => { + expect(parseNameSectionModuleName(Uint8Array.from([0, ...u32leb(2), 1, 0xff]))).toBeNull(); + }); + + it('does not throw on a full wasm binary passed by mistake', () => { + const bytes = wasmModuleBytes([customSection('build_id', [0xaa])]); + expect(parseNameSectionModuleName(bytes)).toBeNull(); + }); +}); 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..7321af439892 100644 --- a/packages/wasm/test/webworker.test.ts +++ b/packages/wasm/test/webworker.test.ts @@ -1,14 +1,23 @@ 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 { IMAGES } from '../src/registry'; +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 +27,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,13 +44,12 @@ describe('registerWebWorkerWasm()', () => { registerWebWorkerWasm({ self: mockSelf }); expect(WebAssembly.compileStreaming).not.toBe(originalCompileStreaming); - - WebAssembly.compileStreaming = originalCompileStreaming; }); }); describe('patchFrames() with worker images', () => { afterEach(() => { + IMAGES.length = 0; delete WINDOW._sentryWasmImages; }); @@ -144,4 +152,87 @@ describe('patchFrames() with worker images', () => { expect(result).toBe(true); expect(frames[0]?.addr_mode).toBe('rel:3'); }); + + it('matches wasm:// frames to a unique worker image by filename', () => { + WINDOW._sentryWasmImages = [ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + ]; + + const frames: StackFrame[] = [ + { + filename: 'wasm://wasm/maze.split.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ]; + + const result = patchFrames(frames); + + expect(result).toBe(true); + expect(frames[0]?.filename).toBe('http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm'); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + it('matches wasm:// frames when page and worker registered the same moduleName', () => { + const image = { + type: 'wasm' as const, + code_id: 'abc123', + code_file: 'http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + moduleName: 'maze.split.wasm', + }; + IMAGES.push(image); + WINDOW._sentryWasmImages = [image]; + + const frames: StackFrame[] = [ + { + filename: 'wasm://wasm/maze.split.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ]; + + const result = patchFrames(frames); + + expect(result).toBe(true); + expect(frames[0]?.filename).toBe('http://localhost:8080/web/assets/emscripten-raycast/maze.split.wasm'); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + it('matches wasm:// frames to a worker image by name-section module name', () => { + WINDOW._sentryWasmImages = [ + { + type: 'wasm', + code_id: 'aabb', + code_file: 'http://localhost:8080/web/assets/rust/demo_bg.wasm', + debug_file: null, + debug_id: 'aabb00000000000000000000000000000', + moduleName: 'demo.wasm', + }, + ]; + + const frames: StackFrame[] = [ + { + filename: 'wasm://wasm/demo.wasm-000197f6', + function: 'trigger_crash_divzero', + instruction_addr: '0x283d', + in_app: true, + }, + ]; + + const result = patchFrames(frames); + + expect(result).toBe(true); + expect(frames[0]?.filename).toBe('http://localhost:8080/web/assets/rust/demo_bg.wasm'); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); });