Skip to content
Draft
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
Expand Up @@ -20,13 +20,16 @@ function serveWasmFixture(page: Page): Promise<void> {
}

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

// Modules at or below V8's content-hashing cutoff are registered under a
// placeholder name, since the name the engine picks cannot be predicted.
const SMALL_IMAGE_MATCHER = { ...IMAGE_MATCHER, code_file: 'wasm://wasm/unknown' };

const FRAME_MATCHER = {
function: 'internal_func',
in_app: true,
Expand All @@ -35,6 +38,66 @@ const FRAME_MATCHER = {
platform: 'native',
};

sentryTest(
'falls back to the single buffer module below the content-hash cutoff',
async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipWASMTests(browserName) || browserName === 'firefox') {
sentryTest.skip();
}

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

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

expect(event.exception.values[0].stacktrace.frames).toEqual(
expect.arrayContaining([
expect.objectContaining({
...FRAME_MATCHER,
filename: expect.stringMatching(/^wasm:\/\/wasm\/[0-9a-f]{8}$/),
}),
]),
);

expect(event.debug_meta).toMatchObject({ images: [SMALL_IMAGE_MATCHER] });
},
);

sentryTest(
'captured exception should include modified frames and debug_meta for non-streaming instantiation @firefox',
async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipWASMTests(browserName) || browserName !== 'firefox') {
sentryTest.skip();
}

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

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

// Firefox derives the script name from the compile call site, so the
// frame matches through the single-buffer-module fallback.
expect(event.exception.values[0].stacktrace.frames).toEqual(
expect.arrayContaining([
expect.objectContaining({
...FRAME_MATCHER,
filename: expect.stringContaining('> WebAssembly.instantiate'),
}),
]),
);

expect(event.debug_meta).toMatchObject({ images: [SMALL_IMAGE_MATCHER] });
},
);

sentryTest(
'exactly matches the length-derived synthetic name for modules above the content-hash cutoff',
async ({ getLocalTestUrl, page, browserName }) => {
Expand Down Expand Up @@ -70,3 +133,33 @@ sentryTest(
});
},
);

sentryTest(
'falls back to the single buffer module for call-site-derived names above the content-hash cutoff @firefox',
async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipWASMTests(browserName) || browserName !== 'firefox') {
sentryTest.skip();
}

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

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

expect(event.exception.values[0].stacktrace.frames).toEqual(
expect.arrayContaining([
expect.objectContaining({
...FRAME_MATCHER,
filename: expect.stringContaining('> WebAssembly.instantiate'),
}),
]),
);

const expectedUrl = `wasm://wasm/${(byteLength * 4 + 2).toString(16).padStart(8, '0')}`;
expect(event.debug_meta).toMatchObject({ images: [{ ...IMAGE_MATCHER, code_file: expectedUrl }] });
},
);
38 changes: 38 additions & 0 deletions packages/wasm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ export function patchFrames(
const mainThreadImagesCount = getImages().length;
frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`;
hasAtLeastOneWasmFrameWithImage = true;
} else if (isEngineNamedWasmFilename(match[1])) {
// The engine names modules that were compiled from raw bytes itself:
// V8 hashes the content of modules below its hashing cutoff, Firefox
// derives the name from the compile call site. Neither can be
// predicted at registration time. If exactly one distinct module was
// registered from raw bytes, the frame can only belong to it.
const fallbackIndex = getSingleBufferImageIndex();
if (fallbackIndex >= 0) {
frame.addr_mode = `rel:${existingImagesOffset + fallbackIndex}`;
hasAtLeastOneWasmFrameWithImage = true;
}
}
}
});
Expand All @@ -158,6 +169,33 @@ function getWorkerImage(url: string): number {
return getWorkerImages().findIndex(image => image.type === 'wasm' && image.code_file === url);
}

function isEngineNamedWasmFilename(filename: string): boolean {
return filename.startsWith('wasm://') || filename.includes('> WebAssembly.');
}

/**
* Returns the index (across main-thread and worker images) of the only
* distinct image that was registered from raw bytes, or -1 if there is none
* or more than one. The same module registered on several threads counts
* once, since the images share their build id.
*/
function getSingleBufferImageIndex(): number {
const mainImages = getImages();
const workerImages = getWorkerImages();
let index = -1;
const buildIds = new Set<string>();
const collect = (image: WasmDebugImage, imageIndex: number): void => {
const buildId = image.code_id;
if (image._fromBuffer && buildId && !buildIds.has(buildId)) {
buildIds.add(buildId);
index = imageIndex;
}
};
mainImages.forEach((image, i) => collect(image, i));
workerImages.forEach((image, i) => collect(image, mainImages.length + i));
return buildIds.size === 1 ? index : -1;
}

/**
* Use this function to register WASM support in a web worker.
*
Expand Down
83 changes: 82 additions & 1 deletion packages/wasm/test/nonstreaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface BuildWasmOptions {
moduleName?: string;
padding?: number;
padSeed?: number;
buildIdSeed?: number;
}

const WASM_HEADER = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
Expand All @@ -48,13 +49,14 @@ const CODE_SECTION = [0x0a, 0x05, 0x01, 0x03, 0x00, 0x00, 0x0b]; // body: unreac
// optionally with build_id / name sections and a padding custom section.
function buildWasm({
buildId = true,
buildIdSeed = 0,
moduleName,
padding = 0,
padSeed = 0,
}: BuildWasmOptions = {}): Uint8Array<ArrayBuffer> {
const bytes = [...WASM_HEADER, ...TYPE_SECTION, ...FUNCTION_SECTION, ...EXPORT_SECTION, ...CODE_SECTION];
if (buildId) {
bytes.push(...customSection('build_id', BUILD_ID_BYTES));
bytes.push(...customSection('build_id', [...BUILD_ID_BYTES.slice(0, 15), BUILD_ID_BYTES[15]! + buildIdSeed]));
}
if (moduleName) {
const nameBytes = [...moduleName].map(c => c.charCodeAt(0));
Expand Down Expand Up @@ -248,6 +250,85 @@ describe('registerWebWorkerWasm()', () => {
});
});

describe('single-buffer-image fallback', () => {
// The patched Module constructor registers the module as a buffer image.
function registerBufferImage(bytes: Uint8Array<ArrayBuffer>): void {
new WebAssembly.Module(bytes);
}

it('matches call-site-derived names when exactly one buffer image exists', () => {
registerBufferImage(buildWasm({ padding: 17000 }));

const frames = [
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
];

expect(patchFrames(frames)).toBe(true);
expect(frames[0]?.addr_mode).toBe('rel:0');
expect(frames[0]?.platform).toBe('native');
});

it('does not fall back when the filename is a regular url', () => {
registerBufferImage(buildWasm({ padding: 17000 }));

const frames = [frameForFilename('http://localhost:8001/other.wasm:wasm-function[0]:0x1e')];

expect(patchFrames(frames)).toBe(false);
expect(frames[0]?.addr_mode).toBeUndefined();
});

it('does not fall back when multiple buffer images exist', () => {
registerBufferImage(buildWasm({ padding: 17000 }));
registerBufferImage(buildWasm({ padding: 18000, buildIdSeed: 1 }));

const frames = [
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
];

expect(patchFrames(frames)).toBe(false);
});

it('does not fall back for images registered from streaming urls', () => {
const module = new WebAssembly.Module(buildWasm({ padding: 17000 }));
IMAGES.length = 0; // drop the auto-registered buffer image
registerModule(module, 'http://localhost:8001/main.wasm');

const frames = [
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
];

expect(patchFrames(frames)).toBe(false);
});

it('matches unpredicted wasm:// names, which is how modules below the hashing cutoff are found', () => {
registerBufferImage(buildWasm({ padding: 100 }));

const frames = [frameForFilename('wasm://wasm/ffffffff:wasm-function[0]:0x1e')];

expect(patchFrames(frames)).toBe(true);
expect(frames[0]?.addr_mode).toBe('rel:0');
});

it('falls back when the same module is registered on the main thread and in a worker', () => {
const bytes = buildWasm({ padding: 17000 });
registerBufferImage(bytes);
(GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages = [
{ ...(getImages()[0] as WasmDebugImage) },
];

try {
const frames = [
frameForFilename('http://localhost:8001/app.js line 12 > WebAssembly.instantiate:wasm-function[0]:0x1e'),
];

expect(patchFrames(frames)).toBe(true);
expect(frames[0]?.addr_mode).toBe('rel:0');
} finally {
delete (GLOBAL_OBJ as { _sentryWasmImages?: WasmDebugImage[] })._sentryWasmImages;
}
});
});

describe('processEvent', () => {
it('strips internal fields from attached debug images', () => {
const bytes = buildWasm({ padding: 17000 });
Expand Down
Loading