Skip to content

Commit 3eb5ff5

Browse files
andreiborzaclaude
andauthored
fix(wasm): Match Firefox call-site-derived wasm frames to the single buffer module
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ccc54f1 commit 3eb5ff5

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,37 @@ sentryTest(
7171
},
7272
);
7373

74+
sentryTest(
75+
'captured exception should include modified frames and debug_meta for non-streaming instantiation @firefox',
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 } = await page.evaluate(async () => {
86+
// @ts-expect-error this function exists
87+
return window.getEvent();
88+
});
89+
90+
// Firefox derives the script name from the compile call site, so the
91+
// frame matches through the single-buffer-module fallback.
92+
expect(event.exception.values[0].stacktrace.frames).toEqual(
93+
expect.arrayContaining([
94+
expect.objectContaining({
95+
...FRAME_MATCHER,
96+
filename: expect.stringContaining('> WebAssembly.instantiate'),
97+
}),
98+
]),
99+
);
100+
101+
expect(event.debug_meta).toMatchObject({ images: [IMAGE_MATCHER] });
102+
},
103+
);
104+
74105
sentryTest(
75106
'exactly matches the length-derived synthetic name for modules above the content-hash cutoff',
76107
async ({ getLocalTestUrl, page, browserName }) => {
@@ -106,3 +137,32 @@ sentryTest(
106137
});
107138
},
108139
);
140+
141+
sentryTest(
142+
'falls back to the single buffer module for call-site-derived names above the content-hash cutoff @firefox',
143+
async ({ getLocalTestUrl, page, browserName }) => {
144+
if (shouldSkipWASMTests(browserName) || browserName !== 'firefox') {
145+
sentryTest.skip();
146+
}
147+
148+
const url = await getLocalTestUrl({ testDir: __dirname });
149+
await serveWasmFixture(page);
150+
await page.goto(url);
151+
152+
const { event } = await page.evaluate(async () => {
153+
// @ts-expect-error this function exists
154+
return window.getEvent(17000);
155+
});
156+
157+
expect(event.exception.values[0].stacktrace.frames).toEqual(
158+
expect.arrayContaining([
159+
expect.objectContaining({
160+
...FRAME_MATCHER,
161+
filename: expect.stringContaining('> WebAssembly.instantiate'),
162+
}),
163+
]),
164+
);
165+
166+
expect(event.debug_meta).toMatchObject({ images: [IMAGE_MATCHER] });
167+
},
168+
);

packages/wasm/src/index.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@ export function patchFrames(
135135
const mainThreadImagesCount = getImages().length;
136136
frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`;
137137
hasAtLeastOneWasmFrameWithImage = true;
138+
} else if (isCallSiteDerivedWasmFilename(match[1])) {
139+
// Firefox derives script names for buffer-compiled modules from the
140+
// compile call site, which cannot be predicted at registration time.
141+
// If exactly one distinct module was registered from raw bytes, the
142+
// frame can only belong to that module. Unmatched `wasm://` names are
143+
// deliberately NOT handled here: those come from modules that were
144+
// compiled before the SDK was initialized, and attributing them to an
145+
// unrelated image would mis-symbolicate.
146+
const fallbackIndex = getSingleBufferImageIndex();
147+
if (fallbackIndex >= 0) {
148+
frame.addr_mode = `rel:${existingImagesOffset + fallbackIndex}`;
149+
hasAtLeastOneWasmFrameWithImage = true;
150+
}
138151
}
139152
}
140153
});
@@ -158,6 +171,36 @@ function getWorkerImage(url: string): number {
158171
return getWorkerImages().findIndex(image => imageMatchesUrl(image, url));
159172
}
160173

174+
function isCallSiteDerivedWasmFilename(filename: string): boolean {
175+
return filename.includes('> WebAssembly.');
176+
}
177+
178+
/**
179+
* Returns the index (across main-thread and worker images) of the only
180+
* distinct image that was registered from raw bytes, or -1 if there is none
181+
* or more than one. The same module registered on several threads counts
182+
* once, since the images share their (content-derived) `code_file`.
183+
*/
184+
function getSingleBufferImageIndex(): number {
185+
const mainImages = getImages();
186+
const workerImages = getWorkerImages();
187+
let index = -1;
188+
const codeFiles = new Set<string>();
189+
mainImages.forEach((image, i) => {
190+
if (image._matchUrls && !codeFiles.has(image.code_file)) {
191+
codeFiles.add(image.code_file);
192+
index = i;
193+
}
194+
});
195+
workerImages.forEach((image, i) => {
196+
if (image._matchUrls && !codeFiles.has(image.code_file)) {
197+
codeFiles.add(image.code_file);
198+
index = mainImages.length + i;
199+
}
200+
});
201+
return codeFiles.size === 1 ? index : -1;
202+
}
203+
161204
/**
162205
* Use this function to register WASM support in a web worker.
163206
*

packages/wasm/test/nonstreaming.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,85 @@ describe('registerWebWorkerWasm()', () => {
239239
});
240240
});
241241

242+
describe('single-buffer-image fallback', () => {
243+
// The patched Module constructor registers the module as a buffer image.
244+
function registerBufferImage(bytes: Uint8Array<ArrayBuffer>): void {
245+
new WebAssembly.Module(bytes);
246+
}
247+
248+
it('matches unpredicted synthetic names when exactly one buffer image exists', () => {
249+
registerBufferImage(buildWasm({ padding: 17000 }));
250+
251+
const frames = [
252+
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
253+
];
254+
255+
expect(patchFrames(frames)).toBe(true);
256+
expect(frames[0]?.addr_mode).toBe('rel:0');
257+
expect(frames[0]?.platform).toBe('native');
258+
});
259+
260+
it('does not fall back when the filename is a regular url', () => {
261+
registerBufferImage(buildWasm({ padding: 17000 }));
262+
263+
const frames = [frameForFilename('http://localhost:8001/other.wasm:wasm-function[0]:0x1e')];
264+
265+
expect(patchFrames(frames)).toBe(false);
266+
expect(frames[0]?.addr_mode).toBeUndefined();
267+
});
268+
269+
it('does not fall back when multiple buffer images exist', () => {
270+
registerBufferImage(buildWasm({ padding: 17000 }));
271+
registerBufferImage(buildWasm({ padding: 18000 }));
272+
273+
const frames = [
274+
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
275+
];
276+
277+
expect(patchFrames(frames)).toBe(false);
278+
});
279+
280+
it('does not fall back for images registered from streaming urls', () => {
281+
const module = new WebAssembly.Module(buildWasm({ padding: 17000 }));
282+
IMAGES.length = 0; // drop the auto-registered buffer image
283+
registerModule(module, 'http://localhost:8001/main.wasm');
284+
285+
const frames = [
286+
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
287+
];
288+
289+
expect(patchFrames(frames)).toBe(false);
290+
});
291+
292+
it('does not fall back for unmatched wasm:// names of modules compiled before SDK init', () => {
293+
registerBufferImage(buildWasm({ padding: 17000 }));
294+
295+
const frames = [frameForFilename('wasm://wasm/ffffffff:wasm-function[0]:0x1e')];
296+
297+
expect(patchFrames(frames)).toBe(false);
298+
expect(frames[0]?.addr_mode).toBeUndefined();
299+
});
300+
301+
it('falls back when the same module is registered on the main thread and in a worker', () => {
302+
const bytes = buildWasm({ padding: 17000 });
303+
registerBufferImage(bytes);
304+
(GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages = [
305+
{ ...(getImages()[0] as WasmDebugImage) },
306+
];
307+
308+
try {
309+
const frames = [
310+
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
311+
];
312+
313+
expect(patchFrames(frames)).toBe(true);
314+
expect(frames[0]?.addr_mode).toBe('rel:0');
315+
} finally {
316+
delete (GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages;
317+
}
318+
});
319+
});
320+
242321
describe('processEvent', () => {
243322
it('strips internal match urls from attached debug images', () => {
244323
const bytes = buildWasm({ padding: 17000 });

0 commit comments

Comments
 (0)