Skip to content

Commit e6c6089

Browse files
committed
fix(wasm): match wasm:// frames on the name section only
- Drop the fetch-basename and `_bg.wasm` guess. V8 puts a name in the `wasm://wasm/<name>-<hash>` label only when the module has a name section, and that name is parsed at registration, so the guess could not fire - Restore develop's response body tagging; the change was not needed for this fix - Cover a worker that compiles a module from fetched bytes in the existing webWorker browser suite
1 parent 8496e88 commit e6c6089

10 files changed

Lines changed: 206 additions & 358 deletions

File tree

‎dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,30 @@
33
// between worker and main thread independent of SDK implementation details
44
// in production code you would do: registerWebWorkerWasm({ self });
55

6+
// Bytes-compiled modules carry no URL, so remember the fetch URL per buffer
7+
// like the SDK's Response patch does.
8+
const bufferUrls = new WeakMap();
9+
const origArrayBuffer = Response.prototype.arrayBuffer;
10+
Response.prototype.arrayBuffer = function arrayBuffer() {
11+
return origArrayBuffer.call(this).then(buffer => {
12+
if (this.url) {
13+
bufferUrls.set(buffer, this.url);
14+
}
15+
return buffer;
16+
});
17+
};
18+
19+
const origInstantiate = WebAssembly.instantiate;
20+
WebAssembly.instantiate = function instantiate(source, importObject) {
21+
return origInstantiate(source, importObject).then(result => {
22+
const url = bufferUrls.get(source instanceof ArrayBuffer ? source : source.buffer);
23+
if (url && result.module) {
24+
registerModuleAndForward(result.module, url);
25+
}
26+
return result;
27+
});
28+
};
29+
630
const origInstantiateStreaming = WebAssembly.instantiateStreaming;
731
WebAssembly.instantiateStreaming = function instantiateStreaming(response, importObject) {
832
return Promise.resolve(response).then(res => {
@@ -26,6 +50,10 @@ function registerModuleAndForward(module, url) {
2650
debug_file: null,
2751
debug_id: `${`${buildId}00000000000000000000000000000000`.slice(0, 32)}0`,
2852
};
53+
const moduleName = getModuleName(module);
54+
if (moduleName) {
55+
image.moduleName = moduleName;
56+
}
2957

3058
self.postMessage({
3159
_sentryMessage: true,
@@ -46,6 +74,24 @@ function getBuildId(module) {
4674
return null;
4775
}
4876

77+
// Read the module name from the `name` custom section. Chrome labels
78+
// bytes-compiled modules `wasm://wasm/<name>-<hash>`, and the main thread
79+
// links such frames to this image by that name.
80+
function getModuleName(module) {
81+
const sections = WebAssembly.Module.customSections(module, 'name');
82+
if (sections.length === 0) {
83+
return null;
84+
}
85+
const bytes = new Uint8Array(sections[0]);
86+
// Subsection id 0 is the module name: id, payload size, name length, name.
87+
// Sizes are LEB128, but the fixture's fit in one byte each.
88+
if (bytes[0] !== 0) {
89+
return null;
90+
}
91+
const length = bytes[2];
92+
return new TextDecoder().decode(bytes.subarray(3, 3 + length));
93+
}
94+
4995
// Handle messages from the main thread
5096
self.addEventListener('message', async event => {
5197
if (event.origin !== '' && event.origin !== self.location.origin) {
@@ -56,15 +102,23 @@ self.addEventListener('message', async event => {
56102
throw new Error('WASM error from worker');
57103
}
58104

59-
if (event.data.type === 'load-wasm-and-crash') {
105+
if (event.data.type === 'load-wasm-and-crash' || event.data.type === 'load-wasm-bytes-and-crash') {
60106
const wasmUrl = event.data.wasmUrl;
107+
const imports = {
108+
env: {
109+
external_func: crash,
110+
},
111+
};
61112

62113
try {
63-
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {
64-
env: {
65-
external_func: crash,
66-
},
67-
});
114+
let instance;
115+
if (event.data.type === 'load-wasm-bytes-and-crash') {
116+
const response = await fetch(wasmUrl);
117+
const buffer = await response.arrayBuffer();
118+
({ instance } = await WebAssembly.instantiate(new Uint8Array(buffer), imports));
119+
} else {
120+
({ instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports));
121+
}
68122

69123
instance.exports.internal_func();
70124
} catch (err) {

‎dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ window.triggerWasmError = () => {
66
wasmUrl: 'https://localhost:5887/simple.wasm',
77
});
88
};
9+
10+
window.triggerWasmBufferError = () => {
11+
window.wasmWorker.postMessage({
12+
type: 'load-wasm-bytes-and-crash',
13+
wasmUrl: 'https://localhost:5887/named.wasm',
14+
});
15+
};

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ declare global {
99
interface Window {
1010
wasmWorker: Worker;
1111
triggerWasmError: () => void;
12+
triggerWasmBufferError: () => void;
1213
}
1314
}
1415

@@ -137,3 +138,70 @@ sentryTest(
137138
);
138139
},
139140
);
141+
142+
// `named.wasm` is `simple.wasm` plus a module-name subsection (`namedmodule`).
143+
// Chrome labels bytes-compiled modules with a name as `wasm://wasm/namedmodule-<hash>`,
144+
// not with the fetch URL, so the frame has to be linked to the image by that name.
145+
sentryTest(
146+
'WASM frames from a module compiled from fetched bytes in a worker should map to its debug image',
147+
async ({ getLocalTestUrl, page, browserName }) => {
148+
if (shouldSkipWASMTests(browserName)) {
149+
sentryTest.skip();
150+
}
151+
152+
const url = await getLocalTestUrl({ testDir: __dirname });
153+
154+
await page.route('**/named.wasm', route => {
155+
const wasmModule = fs.readFileSync(path.resolve(__dirname, '../named.wasm'));
156+
return route.fulfill({
157+
status: 200,
158+
body: wasmModule,
159+
headers: {
160+
'Content-Type': 'application/wasm',
161+
},
162+
});
163+
});
164+
165+
await page.route('**/worker.js', route => {
166+
return route.fulfill({
167+
path: `${__dirname}/assets/worker.js`,
168+
});
169+
});
170+
171+
const errorEventPromise = waitForErrorRequest(page, e => {
172+
return e.exception?.values?.[0]?.value === 'WASM error from worker';
173+
});
174+
175+
await page.goto(url);
176+
177+
await page.waitForFunction(() => window.wasmWorker !== undefined);
178+
179+
await page.evaluate(() => {
180+
window.triggerWasmBufferError();
181+
});
182+
183+
const errorEvent = envelopeRequestParser(await errorEventPromise);
184+
185+
expect(errorEvent.debug_meta?.images).toEqual([
186+
{
187+
type: 'wasm',
188+
code_file: 'https://localhost:5887/named.wasm',
189+
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
190+
debug_file: null,
191+
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
192+
},
193+
]);
194+
195+
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual(
196+
expect.arrayContaining([
197+
expect.objectContaining({
198+
filename: 'https://localhost:5887/named.wasm',
199+
function: 'namedmodule.internal_func',
200+
platform: 'native',
201+
instruction_addr: '0x8c',
202+
addr_mode: 'rel:0',
203+
}),
204+
]),
205+
);
206+
},
207+
);

‎packages/wasm/src/matchSyntheticWasmFilename.ts‎

Lines changed: 9 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,13 @@ import type { RegisteredWasmImage } from './registry';
33
/**
44
* Maps Chrome `wasm://wasm/<name>-<hash>` frames to a registered `code_file`.
55
*
6-
* V8 builds the label from the wasm `name` section, so an image with a parsed
7-
* `moduleName` matches on that name only. Images without one are guessed from
8-
* the fetch URL basename (including wasm-bindgen `_bg.wasm` → `.wasm`). Hits
9-
* are accepted only when every candidate shares one `debug_id`.
6+
* V8 builds the label from the wasm `name` section, which is parsed into
7+
* `moduleName` at registration. A hit is accepted only when every image with
8+
* that name shares one `debug_id`, since a page and a worker can register the
9+
* same binary under different URLs.
1010
*
1111
* Hash-only `wasm://wasm/<hash>` labels carry no name and are not mapped
1212
* (see #23781).
13-
*
14-
* Fetch-URL frames (`http://…/file.wasm:wasm-function[…]`) still use exact
15-
* `code_file` lookup in `patchFrames`, not this matcher.
1613
*/
1714

1815
export type SyntheticWasmImageHit = {
@@ -21,17 +18,6 @@ export type SyntheticWasmImageHit = {
2118
codeFile: string;
2219
};
2320

24-
type Hit = SyntheticWasmImageHit & { debugId: string };
25-
26-
/** Last path segment of a registered wasm URL (`http://…/demo_bg.wasm` → `demo_bg.wasm`). */
27-
export function fileBasename(url: string): string | undefined {
28-
try {
29-
return new URL(url).pathname.split('/').pop() || undefined;
30-
} catch {
31-
return url.split('/').pop();
32-
}
33-
}
34-
3521
/**
3622
* Module name from Chrome's label: `wasm://wasm/demo.wasm-000197f6` → `demo.wasm`.
3723
*
@@ -48,42 +34,6 @@ export function syntheticModuleName(filename: string): string | undefined {
4834
return name === body ? undefined : name;
4935
}
5036

51-
/**
52-
* Fetch filename plus known packaging aliases.
53-
*
54-
* wasm-bindgen writes `foo_bg.wasm` next to `foo.js` but the stack label is often
55-
* `foo.wasm`. Used only for images without a parsed name section.
56-
*/
57-
export function namesForRegisteredWasm(codeFile: string): string[] {
58-
const basename = fileBasename(codeFile);
59-
if (!basename) {
60-
return [];
61-
}
62-
63-
const names = [basename];
64-
const withoutBindgenBg = basename.replace(/_bg\.wasm$/i, '.wasm');
65-
if (withoutBindgenBg !== basename) {
66-
names.push(withoutBindgenBg);
67-
}
68-
return names;
69-
}
70-
71-
function imageMatchesSyntheticName(image: RegisteredWasmImage, syntheticName: string): boolean {
72-
if (image.moduleName) {
73-
return image.moduleName === syntheticName;
74-
}
75-
return namesForRegisteredWasm(image.code_file).includes(syntheticName);
76-
}
77-
78-
/**
79-
* Multiple URLs may register the same binary. Only use a hit when every candidate
80-
* shares one `debug_id`. Different binaries with the same name stay unmatched.
81-
*/
82-
export function uniqueHitByDebugId<T extends { debugId: string }>(hits: T[]): T | undefined {
83-
const debugIds = new Set(hits.map(hit => hit.debugId));
84-
return debugIds.size === 1 ? hits[0] : undefined;
85-
}
86-
8737
export function uniqueImageForSyntheticFilename(
8838
filename: string,
8939
pageImages: ReadonlyArray<RegisteredWasmImage>,
@@ -94,18 +44,19 @@ export function uniqueImageForSyntheticFilename(
9444
return undefined;
9545
}
9646

97-
const hits: Hit[] = [];
47+
const hits: Array<SyntheticWasmImageHit & { debugId: string }> = [];
9848
const consider = (images: ReadonlyArray<RegisteredWasmImage>, worker: boolean): void => {
9949
images.forEach((image, index) => {
100-
if (imageMatchesSyntheticName(image, name)) {
50+
if (image.moduleName === name) {
10151
hits.push({ index, worker, codeFile: image.code_file, debugId: image.debug_id });
10252
}
10353
});
10454
};
10555
consider(pageImages, false);
10656
consider(workerImages, true);
107-
const hit = uniqueHitByDebugId(hits);
108-
if (!hit) {
57+
58+
const hit = hits[0];
59+
if (!hit || hits.some(other => other.debugId !== hit.debugId)) {
10960
return undefined;
11061
}
11162
return { index: hit.index, worker: hit.worker, codeFile: hit.codeFile };

‎packages/wasm/src/patchWasmResponse.ts‎

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ import { fill } from '@sentry/core';
88
* This module patches `Response.prototype.arrayBuffer` and `bytes` so that when wasm is fetched
99
* and then loaded from bytes, we can map the resulting `ArrayBuffer` back to the fetch URL via
1010
* `getWasmSourceUrl()` and register the module in `patchNonStreamingWebAssembly`.
11-
*
12-
* Every response body is tagged, not only the wasm-looking ones. A tag is read back solely after a
13-
* `WebAssembly` compile already succeeded, so tagging a non-wasm buffer can never be observed,
14-
* whereas guessing from content type or file extension would silently drop modules served as
15-
* `application/octet-stream` or from extension-less URLs.
1611
*/
1712
const wasmSourceUrls = new WeakMap<ArrayBuffer, string>();
1813

@@ -43,27 +38,25 @@ function toArrayBuffer(source: unknown): ArrayBuffer | undefined {
4338
return undefined;
4439
}
4540

46-
/**
47-
* Synthetic responses (`new Response(...)`) have no URL and nothing to tag,
48-
* so their body reads are passed through untouched.
49-
*/
50-
function responseUrl(response: Response): string | undefined {
51-
try {
52-
return response.url || undefined;
53-
} catch {
54-
return undefined;
41+
function looksLikeWasmResponse(response: Response): boolean {
42+
const contentType = response.headers.get('content-type');
43+
if (contentType?.includes('application/wasm')) {
44+
return true;
5545
}
46+
47+
const { url } = response;
48+
return Boolean(url && /\.wasm(?:\?|#|$)/i.test(url));
5649
}
5750

5851
/**
5952
* Runs inside the caller's `arrayBuffer()` / `bytes()` promise chain, so it must never throw:
6053
* a failure here would reject a body read that has nothing to do with wasm.
6154
*/
62-
function tagResponseSource(source: unknown, url: string): void {
55+
function tagResponseSource(response: Response, source: unknown): void {
6356
try {
6457
const buffer = toArrayBuffer(source);
65-
if (buffer) {
66-
wasmSourceUrls.set(buffer, url);
58+
if (buffer && response.url && looksLikeWasmResponse(response)) {
59+
wasmSourceUrls.set(buffer, response.url);
6760
}
6861
} catch {
6962
// see above
@@ -83,12 +76,8 @@ export function patchWasmResponseBodyReaders(): void {
8376
fill(Response.prototype, 'arrayBuffer', (original: (this: Response) => Promise<ArrayBuffer>) => {
8477
return function arrayBuffer(this: Response): Promise<ArrayBuffer> {
8578
const bufferPromise: Promise<ArrayBuffer> = original.call(this);
86-
const url = responseUrl(this);
87-
if (!url) {
88-
return bufferPromise;
89-
}
9079
return bufferPromise.then(buffer => {
91-
tagResponseSource(buffer, url);
80+
tagResponseSource(this, buffer);
9281
return buffer;
9382
});
9483
};
@@ -97,12 +86,8 @@ export function patchWasmResponseBodyReaders(): void {
9786
fill(Response.prototype, 'bytes', (original: (this: Response) => Promise<Uint8Array>) => {
9887
return function bytes(this: Response): Promise<Uint8Array> {
9988
const bytesPromise: Promise<Uint8Array> = original.call(this);
100-
const url = responseUrl(this);
101-
if (!url) {
102-
return bytesPromise;
103-
}
10489
return bytesPromise.then(bytes => {
105-
tagResponseSource(bytes, url);
90+
tagResponseSource(this, bytes);
10691
return bytes;
10792
});
10893
};

0 commit comments

Comments
 (0)