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
70 changes: 11 additions & 59 deletions packages/wasm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
setupOnce() {
patchWebAssembly();
patchWebAssembly(registerModule);
},
processEvent(event: Event): Event {
let hasAtLeastOneWasmFrameWithImage = false;
Expand All @@ -50,8 +50,8 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => {
event.exception.values.forEach(exception => {
if (exception.stacktrace?.frames) {
hasAtLeastOneWasmFrameWithImage =
hasAtLeastOneWasmFrameWithImage ||
patchFrames(exception.stacktrace.frames, options.applicationKey, existingImagesCount);
patchFrames(exception.stacktrace.frames, options.applicationKey, existingImagesCount) ||
hasAtLeastOneWasmFrameWithImage;
}
});
}
Expand Down Expand Up @@ -158,62 +158,14 @@ function getWorkerImage(url: string): number {
* - `self`: The worker's global scope (self).
*/
export function registerWebWorkerWasm({ self }: RegisterWebWorkerWasmOptions): void {
patchWebAssemblyWithForwarding(self);
}
patchWebAssembly((module, url) => {
const image = registerModule(module, url);

/**
* Patches the WebAssembly object in the worker scope and forwards
* registered modules to the parent thread.
*/
function patchWebAssemblyWithForwarding(workerSelf: MinimalDedicatedWorkerGlobalScope): void {
if ('instantiateStreaming' in WebAssembly) {
const origInstantiateStreaming = WebAssembly.instantiateStreaming;
WebAssembly.instantiateStreaming = function instantiateStreaming(
response: Response | PromiseLike<Response>,
importObject: WebAssembly.Imports,
): Promise<WebAssembly.Module> {
return Promise.resolve(response).then(response => {
return origInstantiateStreaming(response, importObject).then(rv => {
if (response.url) {
registerModuleAndForward(rv.module, response.url, workerSelf);
}
return rv;
});
});
} as typeof WebAssembly.instantiateStreaming;
}

if ('compileStreaming' in WebAssembly) {
const origCompileStreaming = WebAssembly.compileStreaming;
WebAssembly.compileStreaming = function compileStreaming(
source: Response | Promise<Response>,
): Promise<WebAssembly.Module> {
return Promise.resolve(source).then(response => {
return origCompileStreaming(response).then(module => {
if (response.url) {
registerModuleAndForward(module, response.url, workerSelf);
}
return module;
});
if (image) {
self.postMessage({
_sentryMessage: true,
_sentryWasmImages: [image],
});
} as typeof WebAssembly.compileStreaming;
}
}

/**
* Registers a WASM module and forwards its debug image to the parent thread.
*/
function registerModuleAndForward(
module: WebAssembly.Module,
url: string,
workerSelf: MinimalDedicatedWorkerGlobalScope,
): void {
const image = registerModule(module, url);

if (image) {
workerSelf.postMessage({
_sentryMessage: true,
_sentryWasmImages: [image],
});
}
}
});
}
47 changes: 33 additions & 14 deletions packages/wasm/src/patchWebAssembly.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,58 @@
import { registerModule } from './registry';
export type RegisterModuleCallback = (module: WebAssembly.Module, url: string) => void;

/**
* Patches the web assembly runtime.
* Patches the WebAssembly streaming APIs so that every compiled module gets
* registered as a debug image under the URL of the response it was compiled
* from.
*
* @param registerModule callback invoked for every successfully compiled module
*/
export function patchWebAssembly(): void {
export function patchWebAssembly(registerModule: RegisterModuleCallback): void {
if ('instantiateStreaming' in WebAssembly) {
const origInstantiateStreaming = WebAssembly.instantiateStreaming;
const origInstantiateStreaming = WebAssembly.instantiateStreaming as (
response: unknown,
...rest: unknown[]
) => Promise<WebAssembly.WebAssemblyInstantiatedSource>;
WebAssembly.instantiateStreaming = function instantiateStreaming(
response: Response | PromiseLike<Response>,
importObject: WebAssembly.Imports,
): Promise<WebAssembly.Module> {
...rest: unknown[]
): Promise<WebAssembly.WebAssemblyInstantiatedSource> {
return Promise.resolve(response).then(response => {
return origInstantiateStreaming(response, importObject).then(rv => {
return origInstantiateStreaming(response, ...rest).then(rv => {
if (response.url) {
registerModule(rv.module, response.url);
registerSafely(registerModule, rv.module, response.url);
}
return rv;
});
});
} as typeof WebAssembly.instantiateStreaming;
};
}

if ('compileStreaming' in WebAssembly) {
const origCompileStreaming = WebAssembly.compileStreaming;
const origCompileStreaming = WebAssembly.compileStreaming as (
source: unknown,
...rest: unknown[]
) => Promise<WebAssembly.Module>;
WebAssembly.compileStreaming = function compileStreaming(
source: Response | Promise<Response>,
source: Response | PromiseLike<Response>,
...rest: unknown[]
): Promise<WebAssembly.Module> {
return Promise.resolve(source).then(response => {
return origCompileStreaming(response).then(module => {
return origCompileStreaming(response, ...rest).then(module => {
if (response.url) {
registerModule(module, response.url);
registerSafely(registerModule, module, response.url);
}
return module;
});
});
} as typeof WebAssembly.compileStreaming;
};
}
}

function registerSafely(registerModule: RegisterModuleCallback, module: WebAssembly.Module, url: string): void {
try {
registerModule(module, url);
} catch {
// a registration failure must never break the user's WebAssembly call
}
}
72 changes: 72 additions & 0 deletions packages/wasm/test/patchWebAssembly.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { patchWebAssembly } from '../src/patchWebAssembly';

const RESPONSE = { url: 'http://localhost:8001/main.wasm' } as Response;
const MODULE = {} as WebAssembly.Module;

describe('patchWebAssembly()', () => {
const originalInstantiateStreaming = WebAssembly.instantiateStreaming;
const originalCompileStreaming = WebAssembly.compileStreaming;

afterEach(() => {
WebAssembly.instantiateStreaming = originalInstantiateStreaming;
WebAssembly.compileStreaming = originalCompileStreaming;
});

it('forwards every argument to instantiateStreaming and registers the module', async () => {
const orig = vi.fn().mockResolvedValue({ module: MODULE, instance: {} });
WebAssembly.instantiateStreaming = orig as unknown as typeof WebAssembly.instantiateStreaming;
const registered: Array<[WebAssembly.Module, string]> = [];

patchWebAssembly((module, url) => registered.push([module, url]));

const importObject = { env: {} };
const compileOptions = { builtins: ['js-string'] };
await (WebAssembly.instantiateStreaming as unknown as (...args: unknown[]) => Promise<unknown>)(
RESPONSE,
importObject,
compileOptions,
);

expect(orig).toHaveBeenCalledWith(RESPONSE, importObject, compileOptions);
expect(registered).toEqual([[MODULE, RESPONSE.url]]);
});

it('forwards every argument to compileStreaming and registers the module', async () => {
const orig = vi.fn().mockResolvedValue(MODULE);
WebAssembly.compileStreaming = orig as unknown as typeof WebAssembly.compileStreaming;
const registered: Array<[WebAssembly.Module, string]> = [];

patchWebAssembly((module, url) => registered.push([module, url]));

const compileOptions = { builtins: ['js-string'] };
await (WebAssembly.compileStreaming as unknown as (...args: unknown[]) => Promise<unknown>)(
RESPONSE,
compileOptions,
);

expect(orig).toHaveBeenCalledWith(RESPONSE, compileOptions);
expect(registered).toEqual([[MODULE, RESPONSE.url]]);
});

it('does not register modules of responses without a url', async () => {
WebAssembly.compileStreaming = vi.fn().mockResolvedValue(MODULE) as unknown as typeof WebAssembly.compileStreaming;
const registered: string[] = [];

patchWebAssembly((_module, url) => registered.push(url));

await WebAssembly.compileStreaming({ url: '' } as Response);

expect(registered).toEqual([]);
});

it('resolves the original result even if registration throws', async () => {
WebAssembly.compileStreaming = vi.fn().mockResolvedValue(MODULE) as unknown as typeof WebAssembly.compileStreaming;

patchWebAssembly(() => {
throw new Error('registration failed');
});

await expect(WebAssembly.compileStreaming(RESPONSE)).resolves.toBe(MODULE);
});
});
38 changes: 38 additions & 0 deletions packages/wasm/test/processEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Event } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { wasmIntegration } from '../src/index';
import { IMAGES } from '../src/registry';

const WASM_FILENAME = 'http://localhost:8001/main.wasm:wasm-function[10]:0x1234';

function exceptionValue(): NonNullable<NonNullable<Event['exception']>['values']>[number] {
return { stacktrace: { frames: [{ filename: WASM_FILENAME, function: 'run', in_app: true }] } };
}

describe('processEvent()', () => {
afterEach(() => {
IMAGES.length = 0;
});

it('patches frames of all exception values, not only the first matching one', () => {
IMAGES.push({
type: 'wasm',
code_id: 'abc123',
code_file: 'http://localhost:8001/main.wasm',
debug_file: null,
debug_id: 'abc12300000000000000000000000000',
});

const integration = wasmIntegration();
const event = integration.processEvent?.(
{ exception: { values: [exceptionValue(), exceptionValue()] } },
{},
{} as never,
) as Event;

const frames = event.exception?.values?.map(value => value.stacktrace?.frames?.[0]);
expect(frames?.[0]?.addr_mode).toBe('rel:0');
expect(frames?.[1]?.addr_mode).toBe('rel:0');
expect(event.debug_meta?.images).toHaveLength(1);
});
});
Loading