Skip to content

Commit b108f49

Browse files
andreiborzaclaude
andcommitted
fix(wasm): Register modules loaded via non-streaming WebAssembly APIs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d3b088d commit b108f49

8 files changed

Lines changed: 847 additions & 29 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { wasmIntegration } from '@sentry/wasm';
3+
4+
window.Sentry = Sentry;
5+
6+
Sentry.init({
7+
traceLifecycle: 'static',
8+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
9+
integrations: [wasmIntegration()],
10+
beforeSend: event => {
11+
window.events.push(event);
12+
return null;
13+
},
14+
});
15+
window.events = [];
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
function leb128(n) {
2+
const out = [];
3+
do {
4+
let byte = n & 0x7f;
5+
n >>>= 7;
6+
if (n !== 0) {
7+
byte |= 0x80;
8+
}
9+
out.push(byte);
10+
} while (n !== 0);
11+
return out;
12+
}
13+
14+
// Appends a custom section with `padding` payload bytes so the module wire
15+
// bytes cross V8's 16383-byte content-hashing cutoff.
16+
function pad(bytes, padding) {
17+
const payload = new Uint8Array(padding);
18+
for (let i = 0; i < padding; i++) {
19+
payload[i] = (i * 31 + 7) & 0xff;
20+
}
21+
const content = [1, 0x70, ...leb128(payload.length)];
22+
const header = [0x00, ...leb128(2 + payload.length)];
23+
const out = new Uint8Array(bytes.length + header.length + 2 + payload.length);
24+
out.set(bytes, 0);
25+
out.set(header, bytes.length);
26+
out.set([1, 0x70], bytes.length + header.length);
27+
out.set(payload, bytes.length + header.length + 2);
28+
return out;
29+
}
30+
31+
window.getEvent = async padding => {
32+
function crash() {
33+
throw new Error('whoops');
34+
}
35+
36+
const response = await fetch('https://localhost:5887/simple.wasm');
37+
const buffer = await response.arrayBuffer();
38+
const bytes = padding ? pad(new Uint8Array(buffer), padding) : new Uint8Array(buffer);
39+
40+
const { instance } = await WebAssembly.instantiate(bytes, {
41+
env: {
42+
external_func: crash,
43+
},
44+
});
45+
46+
try {
47+
instance.exports.internal_func();
48+
} catch (err) {
49+
Sentry.captureException(err);
50+
return { event: window.events.pop(), byteLength: bytes.byteLength };
51+
}
52+
};
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Page, Route } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import fs from 'fs';
4+
import path from 'path';
5+
import { sentryTest } from '../../../utils/fixtures';
6+
import { shouldSkipWASMTests } from '../../../utils/wasmHelpers';
7+
8+
function serveWasmFixture(page: Page): Promise<void> {
9+
return page.route('**/simple.wasm', (route: Route) => {
10+
const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'simple.wasm'));
11+
12+
return route.fulfill({
13+
status: 200,
14+
body: wasmModule,
15+
headers: {
16+
'Content-Type': 'application/wasm',
17+
},
18+
});
19+
});
20+
}
21+
22+
const IMAGE_MATCHER = {
23+
code_file: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/),
24+
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
25+
debug_file: null,
26+
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
27+
type: 'wasm',
28+
};
29+
30+
const FRAME_MATCHER = {
31+
function: 'internal_func',
32+
in_app: true,
33+
instruction_addr: '0x8c',
34+
addr_mode: 'rel:0',
35+
platform: 'native',
36+
};
37+
38+
sentryTest(
39+
'captured exception should include modified frames and debug_meta for non-streaming instantiation',
40+
async ({ getLocalTestUrl, page, browserName }) => {
41+
if (shouldSkipWASMTests(browserName) || browserName === 'firefox') {
42+
sentryTest.skip();
43+
}
44+
45+
const url = await getLocalTestUrl({ testDir: __dirname });
46+
await serveWasmFixture(page);
47+
await page.goto(url);
48+
49+
const { event } = await page.evaluate(async () => {
50+
// @ts-expect-error this function exists
51+
return window.getEvent();
52+
});
53+
54+
expect(event.exception.values[0].stacktrace.frames).toEqual(
55+
expect.arrayContaining([
56+
expect.objectContaining({
57+
...FRAME_MATCHER,
58+
filename: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/),
59+
}),
60+
]),
61+
);
62+
63+
expect(event.debug_meta).toMatchObject({ images: [IMAGE_MATCHER] });
64+
65+
// On V8 the small-module (content-hashed) synthetic name must match
66+
// exactly, frames and image alike.
67+
const wasmFrame = event.exception.values[0].stacktrace.frames.find(
68+
(frame: { platform?: string }) => frame.platform === 'native',
69+
);
70+
expect(event.debug_meta.images[0].code_file).toBe(wasmFrame.filename);
71+
},
72+
);
73+
74+
sentryTest(
75+
'exactly matches the length-derived synthetic name for modules above the content-hash cutoff',
76+
async ({ getLocalTestUrl, page, browserName }) => {
77+
if (shouldSkipWASMTests(browserName) || browserName === 'firefox') {
78+
sentryTest.skip();
79+
}
80+
81+
const url = await getLocalTestUrl({ testDir: __dirname });
82+
await serveWasmFixture(page);
83+
await page.goto(url);
84+
85+
const { event, byteLength } = await page.evaluate(async () => {
86+
// @ts-expect-error this function exists
87+
return window.getEvent(17000);
88+
});
89+
90+
// V8 does not content-hash modules above 16383 bytes; the synthetic name
91+
// derives from the byte length alone on every V8 version.
92+
expect(byteLength).toBeGreaterThan(16383);
93+
const expectedUrl = `wasm://wasm/${(byteLength * 4 + 2).toString(16).padStart(8, '0')}`;
94+
95+
expect(event.exception.values[0].stacktrace.frames).toEqual(
96+
expect.arrayContaining([
97+
expect.objectContaining({
98+
...FRAME_MATCHER,
99+
filename: expectedUrl,
100+
}),
101+
]),
102+
);
103+
104+
expect(event.debug_meta).toMatchObject({
105+
images: [{ ...IMAGE_MATCHER, code_file: expectedUrl }],
106+
});
107+
},
108+
);

packages/wasm/src/index.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { DebugImage, Event, IntegrationFn, StackFrame } from '@sentry/core';
22
import { defineIntegration, GLOBAL_OBJ } from '@sentry/core';
33
import { patchWebAssembly } from './patchWebAssembly';
4-
import { getImage, getImages, registerModule } from './registry';
4+
import type { WasmDebugImage } from './registry';
5+
import { getImage, getImages, imageMatchesUrl, registerModule } from './registry';
56

67
const INTEGRATION_NAME = 'Wasm';
78

@@ -32,7 +33,7 @@ interface WasmIntegrationOptions {
3233

3334
// Access WINDOW with proper typing for _sentryWasmImages
3435
const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
35-
_sentryWasmImages?: Array<DebugImage>;
36+
_sentryWasmImages?: Array<WasmDebugImage>;
3637
};
3738

3839
const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => {
@@ -59,8 +60,12 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => {
5960
if (hasAtLeastOneWasmFrameWithImage) {
6061
event.debug_meta = event.debug_meta || {};
6162
const mainThreadImages = getImages();
62-
const workerImages = WINDOW._sentryWasmImages || [];
63-
event.debug_meta.images = [...(event.debug_meta.images || []), ...mainThreadImages, ...workerImages];
63+
const workerImages = getWorkerImages();
64+
event.debug_meta.images = [
65+
...(event.debug_meta.images || []),
66+
...mainThreadImages.map(stripMatchUrls),
67+
...workerImages.map(stripMatchUrls),
68+
];
6469
}
6570

6671
return event;
@@ -137,29 +142,35 @@ export function patchFrames(
137142
return hasAtLeastOneWasmFrameWithImage;
138143
}
139144

145+
function getWorkerImages(): Array<WasmDebugImage> {
146+
return WINDOW._sentryWasmImages || [];
147+
}
148+
149+
function stripMatchUrls(image: WasmDebugImage): DebugImage {
150+
const { _matchUrls, ...rest } = image;
151+
return rest;
152+
}
153+
140154
/**
141155
* Looks up an image by URL in worker images.
142156
*/
143157
function getWorkerImage(url: string): number {
144-
const workerImages = WINDOW._sentryWasmImages || [];
145-
return workerImages.findIndex(image => {
146-
return image.type === 'wasm' && image.code_file === url;
147-
});
158+
return getWorkerImages().findIndex(image => imageMatchesUrl(image, url));
148159
}
149160

150161
/**
151162
* Use this function to register WASM support in a web worker.
152163
*
153164
* This function will:
154-
* - Patch WebAssembly.instantiateStreaming and WebAssembly.compileStreaming in the worker
165+
* - Patch the WebAssembly compilation APIs in the worker
155166
* - Forward WASM debug images to the parent thread for symbolication
156167
*
157168
* @param options {RegisterWebWorkerWasmOptions} Options:
158169
* - `self`: The worker's global scope (self).
159170
*/
160171
export function registerWebWorkerWasm({ self }: RegisterWebWorkerWasmOptions): void {
161-
patchWebAssembly((module, url) => {
162-
const image = registerModule(module, url);
172+
patchWebAssembly((module, url, matchUrls) => {
173+
const image = registerModule(module, url, matchUrls);
163174

164175
if (image) {
165176
self.postMessage({

packages/wasm/src/patchWebAssembly.ts

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
export type RegisterModuleCallback = (module: WebAssembly.Module, url: string) => void;
1+
import { getHashCandidates, getSyntheticUrls, toByteView } from './syntheticUrl';
2+
3+
export type RegisterModuleCallback = (module: WebAssembly.Module, url: string, matchUrls?: string[]) => void;
24

35
/**
4-
* Patches the WebAssembly streaming APIs so that every compiled module gets
5-
* registered as a debug image under the URL of the response it was compiled
6-
* from.
6+
* Patches the WebAssembly APIs that compile modules so that every compiled
7+
* module gets registered as a debug image.
8+
*
9+
* Streaming APIs register the module under the response URL. Non-streaming
10+
* APIs receive raw bytes without any URL, so those modules are registered
11+
* under the synthetic `wasm://wasm/<hash>` script name the engine uses in
12+
* stack frames (see `syntheticUrl.ts`).
713
*
814
* @param registerModule callback invoked for every successfully compiled module
915
*/
@@ -47,11 +53,76 @@ export function patchWebAssembly(registerModule: RegisterModuleCallback): void {
4753
});
4854
};
4955
}
56+
57+
const registerFromBuffer = (module: WebAssembly.Module, hashCandidates: string[]): void => {
58+
const urls = getSyntheticUrls(module, hashCandidates);
59+
const url = urls[0];
60+
if (url) {
61+
registerSafely(registerModule, module, url, urls);
62+
}
63+
};
64+
65+
// Double-cast, because the overloaded native signature (buffer vs. module
66+
// first argument) cannot be widened to a pass-through shape in one step.
67+
const origInstantiate = WebAssembly.instantiate as unknown as (
68+
source: unknown,
69+
...rest: unknown[]
70+
) => Promise<WebAssembly.WebAssemblyInstantiatedSource>;
71+
WebAssembly.instantiate = function instantiate(source: unknown, ...rest: unknown[]): Promise<unknown> {
72+
const bytes = toByteView(source);
73+
// Hash candidates must be captured before calling the original function,
74+
// since the caller is free to mutate or transfer the buffer afterwards.
75+
const hashCandidates = bytes && getHashCandidates(bytes);
76+
const result = origInstantiate(source, ...rest);
77+
if (hashCandidates) {
78+
// Chaining (instead of attaching a side listener) keeps rejections of
79+
// fire-and-forget calls observable as unhandledrejection events.
80+
return result.then(rv => {
81+
registerFromBuffer(rv.module, hashCandidates);
82+
return rv;
83+
});
84+
}
85+
return result;
86+
} as typeof WebAssembly.instantiate;
87+
88+
const origCompile = WebAssembly.compile as (source: unknown, ...rest: unknown[]) => Promise<WebAssembly.Module>;
89+
WebAssembly.compile = function compile(source: unknown, ...rest: unknown[]): Promise<WebAssembly.Module> {
90+
const bytes = toByteView(source);
91+
const hashCandidates = bytes && getHashCandidates(bytes);
92+
const result = origCompile(source, ...rest);
93+
if (hashCandidates) {
94+
return result.then(module => {
95+
registerFromBuffer(module, hashCandidates);
96+
return module;
97+
});
98+
}
99+
return result;
100+
};
101+
102+
// `new WebAssembly.Module(bytes)` compiles synchronously. The Proxy keeps
103+
// statics (customSections, exports, imports), prototype, and instanceof
104+
// behavior intact.
105+
WebAssembly.Module = new Proxy(WebAssembly.Module, {
106+
construct(target, args: unknown[], newTarget) {
107+
const bytes = toByteView(args[0]);
108+
const hashCandidates = bytes && getHashCandidates(bytes);
109+
const module = Reflect.construct(target, args, newTarget) as WebAssembly.Module;
110+
if (hashCandidates) {
111+
registerFromBuffer(module, hashCandidates);
112+
}
113+
return module;
114+
},
115+
});
50116
}
51117

52-
function registerSafely(registerModule: RegisterModuleCallback, module: WebAssembly.Module, url: string): void {
118+
function registerSafely(
119+
registerModule: RegisterModuleCallback,
120+
module: WebAssembly.Module,
121+
url: string,
122+
matchUrls?: string[],
123+
): void {
53124
try {
54-
registerModule(module, url);
125+
registerModule(module, url, matchUrls);
55126
} catch {
56127
// a registration failure must never break the user's WebAssembly call
57128
}

0 commit comments

Comments
 (0)