Skip to content

Commit 38529d5

Browse files
andreiborzaclaude
andcommitted
fix(wasm): Match engine-named wasm frames to the single buffer module
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 599115c commit 38529d5

3 files changed

Lines changed: 214 additions & 2 deletions

File tree

dev-packages/browser-integration-tests/suites/wasm/instantiateBuffer/test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@ function serveWasmFixture(page: Page): Promise<void> {
2020
}
2121

2222
const IMAGE_MATCHER = {
23-
code_file: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/),
2423
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
2524
debug_file: null,
2625
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
2726
type: 'wasm',
2827
};
2928

29+
// Modules at or below V8's content-hashing cutoff are registered under a
30+
// placeholder name, since the name the engine picks cannot be predicted.
31+
const SMALL_IMAGE_MATCHER = { ...IMAGE_MATCHER, code_file: 'wasm://wasm/unknown' };
32+
3033
const FRAME_MATCHER = {
3134
function: 'internal_func',
3235
in_app: true,
@@ -35,6 +38,66 @@ const FRAME_MATCHER = {
3538
platform: 'native',
3639
};
3740

41+
sentryTest(
42+
'falls back to the single buffer module below the content-hash cutoff',
43+
async ({ getLocalTestUrl, page, browserName }) => {
44+
if (shouldSkipWASMTests(browserName) || browserName === 'firefox') {
45+
sentryTest.skip();
46+
}
47+
48+
const url = await getLocalTestUrl({ testDir: __dirname });
49+
await serveWasmFixture(page);
50+
await page.goto(url);
51+
52+
const { event } = await page.evaluate(async () => {
53+
// @ts-expect-error this function exists
54+
return window.getEvent();
55+
});
56+
57+
expect(event.exception.values[0].stacktrace.frames).toEqual(
58+
expect.arrayContaining([
59+
expect.objectContaining({
60+
...FRAME_MATCHER,
61+
filename: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/),
62+
}),
63+
]),
64+
);
65+
66+
expect(event.debug_meta).toMatchObject({ images: [SMALL_IMAGE_MATCHER] });
67+
},
68+
);
69+
70+
sentryTest(
71+
'captured exception should include modified frames and debug_meta for non-streaming instantiation @firefox',
72+
async ({ getLocalTestUrl, page, browserName }) => {
73+
if (shouldSkipWASMTests(browserName) || browserName !== 'firefox') {
74+
sentryTest.skip();
75+
}
76+
77+
const url = await getLocalTestUrl({ testDir: __dirname });
78+
await serveWasmFixture(page);
79+
await page.goto(url);
80+
81+
const { event } = await page.evaluate(async () => {
82+
// @ts-expect-error this function exists
83+
return window.getEvent();
84+
});
85+
86+
// Firefox derives the script name from the compile call site, so the
87+
// frame matches through the single-buffer-module fallback.
88+
expect(event.exception.values[0].stacktrace.frames).toEqual(
89+
expect.arrayContaining([
90+
expect.objectContaining({
91+
...FRAME_MATCHER,
92+
filename: expect.stringContaining('> WebAssembly.instantiate'),
93+
}),
94+
]),
95+
);
96+
97+
expect(event.debug_meta).toMatchObject({ images: [SMALL_IMAGE_MATCHER] });
98+
},
99+
);
100+
38101
sentryTest(
39102
'exactly matches the length-derived synthetic name for modules above the content-hash cutoff',
40103
async ({ getLocalTestUrl, page, browserName }) => {
@@ -70,3 +133,33 @@ sentryTest(
70133
});
71134
},
72135
);
136+
137+
sentryTest(
138+
'falls back to the single buffer module for call-site-derived names above the content-hash cutoff @firefox',
139+
async ({ getLocalTestUrl, page, browserName }) => {
140+
if (shouldSkipWASMTests(browserName) || browserName !== 'firefox') {
141+
sentryTest.skip();
142+
}
143+
144+
const url = await getLocalTestUrl({ testDir: __dirname });
145+
await serveWasmFixture(page);
146+
await page.goto(url);
147+
148+
const { event, byteLength } = await page.evaluate(async () => {
149+
// @ts-expect-error this function exists
150+
return window.getEvent(17000);
151+
});
152+
153+
expect(event.exception.values[0].stacktrace.frames).toEqual(
154+
expect.arrayContaining([
155+
expect.objectContaining({
156+
...FRAME_MATCHER,
157+
filename: expect.stringContaining('> WebAssembly.instantiate'),
158+
}),
159+
]),
160+
);
161+
162+
const expectedUrl = `wasm://wasm/${(byteLength * 4 + 2).toString(16).padStart(8, '0')}`;
163+
expect(event.debug_meta).toMatchObject({ images: [{ ...IMAGE_MATCHER, code_file: expectedUrl }] });
164+
},
165+
);

packages/wasm/src/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ export function patchFrames(
135135
const mainThreadImagesCount = getImages().length;
136136
frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`;
137137
hasAtLeastOneWasmFrameWithImage = true;
138+
} else if (isEngineNamedWasmFilename(match[1])) {
139+
// The engine names modules that were compiled from raw bytes itself:
140+
// V8 hashes the content of modules below its hashing cutoff, Firefox
141+
// derives the name from the compile call site. Neither can be
142+
// predicted at registration time. If exactly one distinct module was
143+
// registered from raw bytes, the frame can only belong to it.
144+
const fallbackIndex = getSingleBufferImageIndex();
145+
if (fallbackIndex >= 0) {
146+
frame.addr_mode = `rel:${existingImagesOffset + fallbackIndex}`;
147+
hasAtLeastOneWasmFrameWithImage = true;
148+
}
138149
}
139150
}
140151
});
@@ -158,6 +169,33 @@ function getWorkerImage(url: string): number {
158169
return getWorkerImages().findIndex(image => image.type === 'wasm' && image.code_file === url);
159170
}
160171

172+
function isEngineNamedWasmFilename(filename: string): boolean {
173+
return filename.startsWith('wasm://') || filename.includes('> WebAssembly.');
174+
}
175+
176+
/**
177+
* Returns the index (across main-thread and worker images) of the only
178+
* distinct image that was registered from raw bytes, or -1 if there is none
179+
* or more than one. The same module registered on several threads counts
180+
* once, since the images share their build id.
181+
*/
182+
function getSingleBufferImageIndex(): number {
183+
const mainImages = getImages();
184+
const workerImages = getWorkerImages();
185+
let index = -1;
186+
const buildIds = new Set<string>();
187+
const collect = (image: WasmDebugImage, imageIndex: number): void => {
188+
const buildId = image.code_id;
189+
if (image._fromBuffer && buildId && !buildIds.has(buildId)) {
190+
buildIds.add(buildId);
191+
index = imageIndex;
192+
}
193+
};
194+
mainImages.forEach((image, i) => collect(image, i));
195+
workerImages.forEach((image, i) => collect(image, mainImages.length + i));
196+
return buildIds.size === 1 ? index : -1;
197+
}
198+
161199
/**
162200
* Use this function to register WASM support in a web worker.
163201
*

packages/wasm/test/nonstreaming.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface BuildWasmOptions {
3636
moduleName?: string;
3737
padding?: number;
3838
padSeed?: number;
39+
buildIdSeed?: number;
3940
}
4041

4142
const WASM_HEADER = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
@@ -48,13 +49,14 @@ const CODE_SECTION = [0x0a, 0x05, 0x01, 0x03, 0x00, 0x00, 0x0b]; // body: unreac
4849
// optionally with build_id / name sections and a padding custom section.
4950
function buildWasm({
5051
buildId = true,
52+
buildIdSeed = 0,
5153
moduleName,
5254
padding = 0,
5355
padSeed = 0,
5456
}: BuildWasmOptions = {}): Uint8Array<ArrayBuffer> {
5557
const bytes = [...WASM_HEADER, ...TYPE_SECTION, ...FUNCTION_SECTION, ...EXPORT_SECTION, ...CODE_SECTION];
5658
if (buildId) {
57-
bytes.push(...customSection('build_id', BUILD_ID_BYTES));
59+
bytes.push(...customSection('build_id', [...BUILD_ID_BYTES.slice(0, 15), BUILD_ID_BYTES[15]! + buildIdSeed]));
5860
}
5961
if (moduleName) {
6062
const nameBytes = [...moduleName].map(c => c.charCodeAt(0));
@@ -248,6 +250,85 @@ describe('registerWebWorkerWasm()', () => {
248250
});
249251
});
250252

253+
describe('single-buffer-image fallback', () => {
254+
// The patched Module constructor registers the module as a buffer image.
255+
function registerBufferImage(bytes: Uint8Array<ArrayBuffer>): void {
256+
new WebAssembly.Module(bytes);
257+
}
258+
259+
it('matches call-site-derived names when exactly one buffer image exists', () => {
260+
registerBufferImage(buildWasm({ padding: 17000 }));
261+
262+
const frames = [
263+
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
264+
];
265+
266+
expect(patchFrames(frames)).toBe(true);
267+
expect(frames[0]?.addr_mode).toBe('rel:0');
268+
expect(frames[0]?.platform).toBe('native');
269+
});
270+
271+
it('does not fall back when the filename is a regular url', () => {
272+
registerBufferImage(buildWasm({ padding: 17000 }));
273+
274+
const frames = [frameForFilename('http://localhost:8001/other.wasm:wasm-function[0]:0x1e')];
275+
276+
expect(patchFrames(frames)).toBe(false);
277+
expect(frames[0]?.addr_mode).toBeUndefined();
278+
});
279+
280+
it('does not fall back when multiple buffer images exist', () => {
281+
registerBufferImage(buildWasm({ padding: 17000 }));
282+
registerBufferImage(buildWasm({ padding: 18000, buildIdSeed: 1 }));
283+
284+
const frames = [
285+
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
286+
];
287+
288+
expect(patchFrames(frames)).toBe(false);
289+
});
290+
291+
it('does not fall back for images registered from streaming urls', () => {
292+
const module = new WebAssembly.Module(buildWasm({ padding: 17000 }));
293+
IMAGES.length = 0; // drop the auto-registered buffer image
294+
registerModule(module, 'http://localhost:8001/main.wasm');
295+
296+
const frames = [
297+
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
298+
];
299+
300+
expect(patchFrames(frames)).toBe(false);
301+
});
302+
303+
it('matches unpredicted wasm:// names, which is how modules below the hashing cutoff are found', () => {
304+
registerBufferImage(buildWasm({ padding: 100 }));
305+
306+
const frames = [frameForFilename('wasm://wasm/ffffffff:wasm-function[0]:0x1e')];
307+
308+
expect(patchFrames(frames)).toBe(true);
309+
expect(frames[0]?.addr_mode).toBe('rel:0');
310+
});
311+
312+
it('falls back when the same module is registered on the main thread and in a worker', () => {
313+
const bytes = buildWasm({ padding: 17000 });
314+
registerBufferImage(bytes);
315+
(GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages = [
316+
{ ...(getImages()[0] as WasmDebugImage) },
317+
];
318+
319+
try {
320+
const frames = [
321+
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
322+
];
323+
324+
expect(patchFrames(frames)).toBe(true);
325+
expect(frames[0]?.addr_mode).toBe('rel:0');
326+
} finally {
327+
delete (GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages;
328+
}
329+
});
330+
});
331+
251332
describe('processEvent', () => {
252333
it('strips internal fields from attached debug images', () => {
253334
const bytes = buildWasm({ padding: 17000 });

0 commit comments

Comments
 (0)