Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import * as Sentry from '@sentry/browser';
import { registerWebWorkerWasm } from '@sentry/wasm';
import { wasmIntegration } from '@sentry/wasm';

window.Sentry = Sentry;

Sentry.init({
traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
});

// `registerWebWorkerWasm` installs the same patches a worker would, and reports
// every registered module to the scope it is given. Collecting them here is the
// only way to observe registration from the page, since main-thread images stay
// module-internal until a frame matches one.
window.registeredImages = [];
registerWebWorkerWasm({
self: {
postMessage: message => window.registeredImages.push(...(message._sentryWasmImages || [])),
integrations: [wasmIntegration()],
beforeSend: event => {
window.events.push(event);
return null;
},
});
window.events = [];
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
window.loadWasmFromBuffer = async () => {
const response = await fetch('https://localhost:5887/simple.wasm');
const buffer = await response.arrayBuffer();
window.getEvent = async () => {
function crash() {
throw new Error('whoops');
}

await WebAssembly.instantiate(new Uint8Array(buffer), {
const response = await fetch('https://localhost:5887/named.wasm');
const buffer = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(new Uint8Array(buffer), {
env: {
external_func: () => {},
external_func: crash,
},
});

return window.registeredImages;
try {
instance.exports.internal_func();
} catch (err) {
Sentry.captureException(err);
return window.events.pop();
}
};
Original file line number Diff line number Diff line change
@@ -1,49 +1,68 @@
import type { Page, Route } from '@playwright/test';
import { expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { sentryTest } from '../../../utils/fixtures';
import { shouldSkipWASMTests } from '../../../utils/wasmHelpers';

async function serveWasmFixture(page: Page): Promise<void> {
// `page.route` resolves with a `Disposable` as of Playwright 1.63, so it can't be returned directly.
await page.route('**/simple.wasm', (route: Route) => {
const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'simple.wasm'));

return route.fulfill({
status: 200,
body: wasmModule,
headers: {
'Content-Type': 'application/wasm',
},
});
});
}

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

const url = await getLocalTestUrl({ testDir: __dirname });
await serveWasmFixture(page);

await page.route('**/named.wasm', route => {
const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'named.wasm'));

return route.fulfill({
status: 200,
body: wasmModule,
headers: {
'Content-Type': 'application/wasm',
},
});
});

await page.goto(url);

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

expect(images).toEqual([
{
type: 'wasm',
code_file: 'https://localhost:5887/simple.wasm',
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
debug_file: null,
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
},
]);
expect(event.exception.values[0].stacktrace.frames).toEqual(
expect.arrayContaining([
expect.objectContaining({
filename: 'https://localhost:5887/named.wasm',
function: 'namedmodule.internal_func',
in_app: true,
instruction_addr: '0x8c',
addr_mode: 'rel:0',
platform: 'native',
}),
expect.objectContaining({
filename: expect.stringMatching(/subject\.bundle\.js$/),
function: 'crash',
in_app: true,
}),
]),
);

expect(event.debug_meta).toMatchObject({
images: [
{
code_file: 'https://localhost:5887/named.wasm',
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
debug_file: null,
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
type: 'wasm',
},
],
});
},
);
Binary file not shown.
30 changes: 23 additions & 7 deletions packages/wasm/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { DebugImage, Event, IntegrationFn, StackFrame } from '@sentry/core';
import type { Event, IntegrationFn, StackFrame } from '@sentry/core';
import { defineIntegration, GLOBAL_OBJ } from '@sentry/core';
import { uniqueImageForSyntheticFilename } from './matchSyntheticWasmFilename';
import { patchWebAssembly } from './patchWebAssembly';
import { getImage, getImages, registerModule } from './registry';
import { getImage, getImages, registerModule, toProtocolDebugImage, type RegisteredWasmImage } from './registry';

const INTEGRATION_NAME = 'Wasm';

Expand Down Expand Up @@ -32,7 +33,7 @@ interface WasmIntegrationOptions {

// Access WINDOW with proper typing for _sentryWasmImages
const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryWasmImages?: Array<DebugImage>;
_sentryWasmImages?: Array<RegisteredWasmImage>;
};

const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => {
Expand All @@ -58,8 +59,8 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => {

if (hasAtLeastOneWasmFrameWithImage) {
event.debug_meta = event.debug_meta || {};
const mainThreadImages = getImages();
const workerImages = WINDOW._sentryWasmImages || [];
const mainThreadImages = getImages().map(toProtocolDebugImage);
const workerImages = (WINDOW._sentryWasmImages || []).map(toProtocolDebugImage);
event.debug_meta.images = [...(event.debug_meta.images || []), ...mainThreadImages, ...workerImages];
}

Expand Down Expand Up @@ -109,9 +110,11 @@ export function patchFrames(
match = frame.filename.match(PARSER_REGEX) as null | [string, string, string];
}

// `<url>` is the fetch URL, or `wasm://wasm/<name>-<hash>` when Chrome
// compiled the module from bytes.
if (match) {
const index = getImage(match[1]);
const workerImageIndex = getWorkerImage(match[1]);
let index = getImage(match[1]);
let workerImageIndex = getWorkerImage(match[1]);
frame.instruction_addr = match[2];
frame.filename = match[1];
frame.platform = 'native';
Expand All @@ -123,6 +126,19 @@ export function patchFrames(
};
}

// Exact `code_file` miss: `match[1]` is `wasm://wasm/…`, not the registered http URL.
if (index < 0 && workerImageIndex < 0) {
const unique = uniqueImageForSyntheticFilename(match[1], getImages(), WINDOW._sentryWasmImages || []);
if (unique) {
frame.filename = unique.codeFile;
if (unique.worker) {
workerImageIndex = unique.index;
} else {
index = unique.index;
}
}
}

if (index >= 0) {
frame.addr_mode = `rel:${existingImagesOffset + index}`;
hasAtLeastOneWasmFrameWithImage = true;
Expand Down
112 changes: 112 additions & 0 deletions packages/wasm/src/matchSyntheticWasmFilename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { RegisteredWasmImage } from './registry';

/**
* Maps Chrome `wasm://wasm/<name>-<hash>` frames to a registered `code_file`.
*
* V8 builds the label from the wasm `name` section, so an image with a parsed
* `moduleName` matches on that name only. Images without one are guessed from
* the fetch URL basename (including wasm-bindgen `_bg.wasm` → `.wasm`). Hits
* are accepted only when every candidate shares one `debug_id`.
*
* Hash-only `wasm://wasm/<hash>` labels carry no name and are not mapped
* (see #23781).
*
* Fetch-URL frames (`http://…/file.wasm:wasm-function[…]`) still use exact
* `code_file` lookup in `patchFrames`, not this matcher.
*/

export type SyntheticWasmImageHit = {
index: number;
worker: boolean;
codeFile: string;
};

type Hit = SyntheticWasmImageHit & { debugId: string };

/** Last path segment of a registered wasm URL (`http://…/demo_bg.wasm` → `demo_bg.wasm`). */
export function fileBasename(url: string): string | undefined {
try {
return new URL(url).pathname.split('/').pop() || undefined;
} catch {
return url.split('/').pop();
}
}

/**
* Module name from Chrome's label: `wasm://wasm/demo.wasm-000197f6` → `demo.wasm`.
*
* V8 appends the `-<hash>` suffix only after a module name. A label without
* it is hash-only and yields `undefined`, so a hex-looking module name such
* as `ed25519` is still returned.
*/
export function syntheticModuleName(filename: string): string | undefined {
const body = filename.match(/^wasm:\/\/wasm\/(.+)$/i)?.[1];
if (!body) {
return undefined;
}
const name = body.replace(/-[0-9a-fA-F]{6,16}$/, '');
return name === body ? undefined : name;
}

/**
* Fetch filename plus known packaging aliases.
*
* wasm-bindgen writes `foo_bg.wasm` next to `foo.js` but the stack label is often
* `foo.wasm`. Used only for images without a parsed name section.
*/
export function namesForRegisteredWasm(codeFile: string): string[] {
const basename = fileBasename(codeFile);
if (!basename) {
return [];
}

const names = [basename];
const withoutBindgenBg = basename.replace(/_bg\.wasm$/i, '.wasm');
if (withoutBindgenBg !== basename) {
names.push(withoutBindgenBg);
}
return names;
}

function imageMatchesSyntheticName(image: RegisteredWasmImage, syntheticName: string): boolean {
if (image.moduleName) {
return image.moduleName === syntheticName;
}
return namesForRegisteredWasm(image.code_file).includes(syntheticName);
}

/**
* Multiple URLs may register the same binary. Only use a hit when every candidate
* shares one `debug_id`. Different binaries with the same name stay unmatched.
*/
export function uniqueHitByDebugId<T extends { debugId: string }>(hits: T[]): T | undefined {
const debugIds = new Set(hits.map(hit => hit.debugId));
return debugIds.size === 1 ? hits[0] : undefined;
}

export function uniqueImageForSyntheticFilename(
filename: string,
pageImages: ReadonlyArray<RegisteredWasmImage>,
workerImages: ReadonlyArray<RegisteredWasmImage>,
): SyntheticWasmImageHit | undefined {
const name = syntheticModuleName(filename);
if (!name) {
return undefined;
}

const hits: Hit[] = [];
const consider = (images: ReadonlyArray<RegisteredWasmImage>, worker: boolean): void => {
images.forEach((image, index) => {
if (imageMatchesSyntheticName(image, name)) {
hits.push({ index, worker, codeFile: image.code_file, debugId: image.debug_id });
}
});
};
consider(pageImages, false);
consider(workerImages, true);
const hit = uniqueHitByDebugId(hits);
if (!hit) {
return undefined;
}
return { index: hit.index, worker: hit.worker, codeFile: hit.codeFile };
}
Loading
Loading