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 @@ -71,6 +71,37 @@ sentryTest(
},
);

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: [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 @@ -106,3 +137,32 @@ 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 } = 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'),
}),
]),
);

expect(event.debug_meta).toMatchObject({ images: [IMAGE_MATCHER] });
},
);
43 changes: 43 additions & 0 deletions packages/wasm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ export function patchFrames(
const mainThreadImagesCount = getImages().length;
frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`;
hasAtLeastOneWasmFrameWithImage = true;
} else if (isCallSiteDerivedWasmFilename(match[1])) {
// Firefox derives script names for buffer-compiled modules from the
// compile call site, which cannot be predicted at registration time.
// If exactly one distinct module was registered from raw bytes, the
// frame can only belong to that module. Unmatched `wasm://` names are
// deliberately NOT handled here: those come from modules that were
// compiled before the SDK was initialized, and attributing them to an
// unrelated image would mis-symbolicate.
const fallbackIndex = getSingleBufferImageIndex();
if (fallbackIndex >= 0) {
frame.addr_mode = `rel:${existingImagesOffset + fallbackIndex}`;
hasAtLeastOneWasmFrameWithImage = true;
}
}
}
});
Expand All @@ -158,6 +171,36 @@ function getWorkerImage(url: string): number {
return getWorkerImages().findIndex(image => imageMatchesUrl(image, url));
}

function isCallSiteDerivedWasmFilename(filename: string): boolean {
return 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 (content-derived) `code_file`.
*/
function getSingleBufferImageIndex(): number {
const mainImages = getImages();
const workerImages = getWorkerImages();
let index = -1;
const codeFiles = new Set<string>();
mainImages.forEach((image, i) => {
if (image._matchUrls && !codeFiles.has(image.code_file)) {
codeFiles.add(image.code_file);
index = i;
}
});
workerImages.forEach((image, i) => {
if (image._matchUrls && !codeFiles.has(image.code_file)) {
codeFiles.add(image.code_file);
index = mainImages.length + i;
}
});
return codeFiles.size === 1 ? index : -1;
}

/**
* Use this function to register WASM support in a web worker.
*
Expand Down
79 changes: 79 additions & 0 deletions packages/wasm/test/nonstreaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,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 unpredicted synthetic 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 }));

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('does not fall back for unmatched wasm:// names of modules compiled before SDK init', () => {
registerBufferImage(buildWasm({ padding: 17000 }));

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

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

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 match urls from attached debug images', () => {
const bytes = buildWasm({ padding: 17000 });
Expand Down
Loading