Skip to content

Commit 59dee53

Browse files
committed
fix(wasm): match wasm:// frames on the name section and cover it end to end
- Match on the parsed name-section module name only; guess from the fetch basename only for images without one - Treat a label as hash-only when no `-<hash>` suffix was stripped, so hex-looking module names such as `ed25519` still map - Drop the unreachable bare `wasm://` branch in patchFrames; Chrome always keeps `:wasm-function[N]:0xADDR` in the filename - Skip tagging body reads of responses without a URL - Feed unit tests the real Chrome frame shape and add a browser test that loads a module from fetched bytes and asserts the frame maps to its image
1 parent bafae46 commit 59dee53

10 files changed

Lines changed: 160 additions & 139 deletions

File tree

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
import * as Sentry from '@sentry/browser';
2-
import { registerWebWorkerWasm } from '@sentry/wasm';
2+
import { wasmIntegration } from '@sentry/wasm';
33

44
window.Sentry = Sentry;
55

66
Sentry.init({
77
traceLifecycle: 'static',
88
dsn: 'https://public@dsn.ingest.sentry.io/1337',
9-
});
10-
11-
// `registerWebWorkerWasm` installs the same patches a worker would, and reports
12-
// every registered module to the scope it is given. Collecting them here is the
13-
// only way to observe registration from the page, since main-thread images stay
14-
// module-internal until a frame matches one.
15-
window.registeredImages = [];
16-
registerWebWorkerWasm({
17-
self: {
18-
postMessage: message => window.registeredImages.push(...(message._sentryWasmImages || [])),
9+
integrations: [wasmIntegration()],
10+
beforeSend: event => {
11+
window.events.push(event);
12+
return null;
1913
},
2014
});
15+
window.events = [];
Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
window.loadWasmFromBuffer = async () => {
2-
const response = await fetch('https://localhost:5887/simple.wasm');
3-
const buffer = await response.arrayBuffer();
1+
window.getEvent = async () => {
2+
function crash() {
3+
throw new Error('whoops');
4+
}
45

5-
await WebAssembly.instantiate(new Uint8Array(buffer), {
6+
const response = await fetch('https://localhost:5887/named.wasm');
7+
const buffer = await response.arrayBuffer();
8+
const { instance } = await WebAssembly.instantiate(new Uint8Array(buffer), {
69
env: {
7-
external_func: () => {},
10+
external_func: crash,
811
},
912
});
1013

11-
return window.registeredImages;
14+
try {
15+
instance.exports.internal_func();
16+
} catch (err) {
17+
Sentry.captureException(err);
18+
return window.events.pop();
19+
}
1220
};
Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,68 @@
1-
import type { Page, Route } from '@playwright/test';
21
import { expect } from '@playwright/test';
32
import fs from 'fs';
43
import path from 'path';
54
import { sentryTest } from '../../../utils/fixtures';
65
import { shouldSkipWASMTests } from '../../../utils/wasmHelpers';
76

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-
7+
// `named.wasm` is `../simple.wasm` plus a module-name subsection (`namedmodule`)
8+
// in its `name` section. Chrome labels bytes-compiled modules with that name
9+
// as `wasm://wasm/namedmodule-<hash>`, not with the fetch URL.
2210
sentryTest(
23-
'registers a module loaded via fetch, arrayBuffer and instantiate under its response url',
11+
'maps frames of a module compiled from fetched bytes to its debug image',
2412
async ({ getLocalTestUrl, page, browserName }) => {
2513
if (shouldSkipWASMTests(browserName)) {
2614
sentryTest.skip();
2715
}
2816

2917
const url = await getLocalTestUrl({ testDir: __dirname });
30-
await serveWasmFixture(page);
18+
19+
await page.route('**/named.wasm', route => {
20+
const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'named.wasm'));
21+
22+
return route.fulfill({
23+
status: 200,
24+
body: wasmModule,
25+
headers: {
26+
'Content-Type': 'application/wasm',
27+
},
28+
});
29+
});
30+
3131
await page.goto(url);
3232

33-
const images = await page.evaluate(async () => {
33+
const event = await page.evaluate(async () => {
3434
// @ts-expect-error this function exists
35-
return window.loadWasmFromBuffer();
35+
return window.getEvent();
3636
});
3737

38-
expect(images).toEqual([
39-
{
40-
type: 'wasm',
41-
code_file: 'https://localhost:5887/simple.wasm',
42-
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
43-
debug_file: null,
44-
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
45-
},
46-
]);
38+
expect(event.exception.values[0].stacktrace.frames).toEqual(
39+
expect.arrayContaining([
40+
expect.objectContaining({
41+
filename: 'https://localhost:5887/named.wasm',
42+
function: 'namedmodule.internal_func',
43+
in_app: true,
44+
instruction_addr: '0x8c',
45+
addr_mode: 'rel:0',
46+
platform: 'native',
47+
}),
48+
expect.objectContaining({
49+
filename: expect.stringMatching(/subject\.bundle\.js$/),
50+
function: 'crash',
51+
in_app: true,
52+
}),
53+
]),
54+
);
55+
56+
expect(event.debug_meta).toMatchObject({
57+
images: [
58+
{
59+
code_file: 'https://localhost:5887/named.wasm',
60+
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
61+
debug_file: null,
62+
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
63+
type: 'wasm',
64+
},
65+
],
66+
});
4767
},
4868
);
4.09 KB
Binary file not shown.

packages/wasm/src/index.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ export function patchFrames(
110110
match = frame.filename.match(PARSER_REGEX) as null | [string, string, string];
111111
}
112112

113-
// `<url>:wasm-function[N]:0xADDR` — address is still in filename (JS parser did not split it).
114-
// `<url>` is usually the fetch URL (`http://…/app.wasm`); Chrome may instead use `wasm://wasm/<file>-<hash>`.
113+
// `<url>` is the fetch URL, or `wasm://wasm/<name>-<hash>` when Chrome
114+
// compiled the module from bytes.
115115
if (match) {
116116
let index = getImage(match[1]);
117117
let workerImageIndex = getWorkerImage(match[1]);
@@ -147,23 +147,6 @@ export function patchFrames(
147147
frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`;
148148
hasAtLeastOneWasmFrameWithImage = true;
149149
}
150-
} else {
151-
// Bare `wasm://wasm/<file>-<hash>` — JS parser already set `instruction_addr`.
152-
const unique = uniqueImageForSyntheticFilename(frame.filename, getImages(), WINDOW._sentryWasmImages || []);
153-
if (unique && frame.instruction_addr) {
154-
frame.filename = unique.codeFile;
155-
frame.platform = 'native';
156-
if (applicationKey) {
157-
frame.module_metadata = {
158-
...frame.module_metadata,
159-
[`${BUNDLER_PLUGIN_APP_KEY_PREFIX}${applicationKey}`]: true,
160-
};
161-
}
162-
frame.addr_mode = unique.worker
163-
? `rel:${existingImagesOffset + getImages().length + unique.index}`
164-
: `rel:${existingImagesOffset + unique.index}`;
165-
hasAtLeastOneWasmFrameWithImage = true;
166-
}
167150
}
168151
});
169152

packages/wasm/src/matchSyntheticWasmFilename.ts

Lines changed: 23 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import type { DebugImage } from '@sentry/core';
21
import type { RegisteredWasmImage } from './registry';
32

43
/**
54
* Maps Chrome `wasm://wasm/<name>-<hash>` frames to a registered `code_file`.
65
*
7-
* Prefer the wasm `name` section (`moduleName`). If that section is missing or
8-
* does not match the stack label, guess from the fetch URL basename (including
9-
* wasm-bindgen `_bg.wasm` → `.wasm`). Hits are accepted only when every
10-
* candidate shares one `debug_id`.
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`.
1110
*
12-
* Hash-only `wasm://wasm/<id>` is not mapped (see #23781).
11+
* Hash-only `wasm://wasm/<hash>` labels carry no name and are not mapped
12+
* (see #23781).
1313
*
1414
* Fetch-URL frames (`http://…/file.wasm:wasm-function[…]`) still use exact
1515
* `code_file` lookup in `patchFrames`, not this matcher.
@@ -33,22 +33,26 @@ export function fileBasename(url: string): string | undefined {
3333
}
3434

3535
/**
36-
* Chrome's module label without the `wasm://wasm/` prefix or trailing isolate hash.
37-
* `wasm://wasm/demo.wasm-000197f6` → `demo.wasm`. Hash-only `wasm://wasm/0bee4c4e` → `0bee4c4e`.
36+
* Module name from Chrome's label: `wasm://wasm/demo.wasm-000197f6` → `demo.wasm`.
37+
*
38+
* V8 appends the `-<hash>` suffix only after a module name. A label without
39+
* it is hash-only and yields `undefined`, so a hex-looking module name such
40+
* as `ed25519` is still returned.
3841
*/
3942
export function syntheticModuleName(filename: string): string | undefined {
4043
const body = filename.match(/^wasm:\/\/wasm\/(.+)$/i)?.[1];
4144
if (!body) {
4245
return undefined;
4346
}
44-
return body.replace(/-[0-9a-fA-F]{6,16}$/, '');
47+
const name = body.replace(/-[0-9a-fA-F]{6,16}$/, '');
48+
return name === body ? undefined : name;
4549
}
4650

4751
/**
4852
* Fetch filename plus known packaging aliases.
4953
*
5054
* wasm-bindgen writes `foo_bg.wasm` next to `foo.js` but the stack label is often
51-
* `foo.wasm`. Used when the name section is missing or does not match.
55+
* `foo.wasm`. Used only for images without a parsed name section.
5256
*/
5357
export function namesForRegisteredWasm(codeFile: string): string[] {
5458
const basename = fileBasename(codeFile);
@@ -64,20 +68,11 @@ export function namesForRegisteredWasm(codeFile: string): string[] {
6468
return names;
6569
}
6670

67-
export function registeredWasmMatchesSyntheticName(codeFile: string, syntheticName: string): boolean {
68-
return namesForRegisteredWasm(codeFile).includes(syntheticName);
69-
}
70-
71-
function wasmNameSectionName(image: DebugImage): string | undefined {
72-
const moduleName = (image as RegisteredWasmImage).moduleName;
73-
return typeof moduleName === 'string' && moduleName.length > 0 ? moduleName : undefined;
74-
}
75-
76-
export function imageMatchesSyntheticName(image: DebugImage, syntheticName: string): boolean {
77-
if (wasmNameSectionName(image) === syntheticName) {
78-
return true;
71+
function imageMatchesSyntheticName(image: RegisteredWasmImage, syntheticName: string): boolean {
72+
if (image.moduleName) {
73+
return image.moduleName === syntheticName;
7974
}
80-
return typeof image.code_file === 'string' && registeredWasmMatchesSyntheticName(image.code_file, syntheticName);
75+
return namesForRegisteredWasm(image.code_file).includes(syntheticName);
8176
}
8277

8378
/**
@@ -89,28 +84,20 @@ export function uniqueHitByDebugId<T extends { debugId: string }>(hits: T[]): T
8984
return debugIds.size === 1 ? hits[0] : undefined;
9085
}
9186

92-
/**
93-
* Chrome's isolate hash is not a debug_id and is not on `WebAssembly.Module`.
94-
* `wasm://wasm/<hex>` with no module name must not pick an image (see #23781).
95-
*/
96-
function isHashOnlySyntheticName(name: string): boolean {
97-
return /^[0-9a-fA-F]{6,16}$/.test(name);
98-
}
99-
10087
export function uniqueImageForSyntheticFilename(
10188
filename: string,
102-
pageImages: ReadonlyArray<DebugImage>,
103-
workerImages: ReadonlyArray<DebugImage>,
89+
pageImages: ReadonlyArray<RegisteredWasmImage>,
90+
workerImages: ReadonlyArray<RegisteredWasmImage>,
10491
): SyntheticWasmImageHit | undefined {
10592
const name = syntheticModuleName(filename);
106-
if (!name || isHashOnlySyntheticName(name)) {
93+
if (!name) {
10794
return undefined;
10895
}
10996

11097
const hits: Hit[] = [];
111-
const consider = (images: ReadonlyArray<DebugImage>, worker: boolean): void => {
98+
const consider = (images: ReadonlyArray<RegisteredWasmImage>, worker: boolean): void => {
11299
images.forEach((image, index) => {
113-
if (image.type === 'wasm' && typeof image.code_file === 'string' && imageMatchesSyntheticName(image, name)) {
100+
if (imageMatchesSyntheticName(image, name)) {
114101
hits.push({ index, worker, codeFile: image.code_file, debugId: image.debug_id });
115102
}
116103
});

packages/wasm/src/patchWasmResponse.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,27 @@ function toArrayBuffer(source: unknown): ArrayBuffer | undefined {
4343
return undefined;
4444
}
4545

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;
55+
}
56+
}
57+
4658
/**
4759
* Runs inside the caller's `arrayBuffer()` / `bytes()` promise chain, so it must never throw:
4860
* a failure here would reject a body read that has nothing to do with wasm.
4961
*/
50-
function tagResponseSource(response: Response, source: unknown): void {
62+
function tagResponseSource(source: unknown, url: string): void {
5163
try {
5264
const buffer = toArrayBuffer(source);
53-
if (buffer && response.url) {
54-
wasmSourceUrls.set(buffer, response.url);
65+
if (buffer) {
66+
wasmSourceUrls.set(buffer, url);
5567
}
5668
} catch {
5769
// see above
@@ -71,8 +83,12 @@ export function patchWasmResponseBodyReaders(): void {
7183
fill(Response.prototype, 'arrayBuffer', (original: (this: Response) => Promise<ArrayBuffer>) => {
7284
return function arrayBuffer(this: Response): Promise<ArrayBuffer> {
7385
const bufferPromise: Promise<ArrayBuffer> = original.call(this);
86+
const url = responseUrl(this);
87+
if (!url) {
88+
return bufferPromise;
89+
}
7490
return bufferPromise.then(buffer => {
75-
tagResponseSource(this, buffer);
91+
tagResponseSource(buffer, url);
7692
return buffer;
7793
});
7894
};
@@ -81,8 +97,12 @@ export function patchWasmResponseBodyReaders(): void {
8197
fill(Response.prototype, 'bytes', (original: (this: Response) => Promise<Uint8Array>) => {
8298
return function bytes(this: Response): Promise<Uint8Array> {
8399
const bytesPromise: Promise<Uint8Array> = original.call(this);
100+
const url = responseUrl(this);
101+
if (!url) {
102+
return bytesPromise;
103+
}
84104
return bytesPromise.then(bytes => {
85-
tagResponseSource(this, bytes);
105+
tagResponseSource(bytes, url);
86106
return bytes;
87107
});
88108
};

0 commit comments

Comments
 (0)