Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
@@ -0,0 +1,20 @@
import * as Sentry from '@sentry/browser';
import { registerWebWorkerWasm } 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 || [])),
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
window.loadWasmFromBuffer = async () => {
const response = await fetch('https://localhost:5887/simple.wasm');
const buffer = await response.arrayBuffer();

await WebAssembly.instantiate(new Uint8Array(buffer), {
env: {
external_func: () => {},
},
});

return window.registeredImages;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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';

function serveWasmFixture(page: Page): Promise<void> {
return 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',
},
});
});
}

sentryTest(
'registers a module loaded via fetch, arrayBuffer and instantiate under its response url',
async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipWASMTests(browserName)) {
sentryTest.skip();
}

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

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

expect(images).toEqual([
{
type: 'wasm',
code_file: 'https://localhost:5887/simple.wasm',
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
debug_file: null,
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
},
]);
},
);
103 changes: 103 additions & 0 deletions packages/wasm/src/patchWasmResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { addNonEnumerableProperty, fill } from '@sentry/core';

/**
* Streaming wasm registration (`instantiateStreaming` / `compileStreaming`) reads the module URL
* from `Response.url`. Non-streaming paths (`WebAssembly.instantiate` / `compile` with bytes) only
* receive a buffer — no URL — so registration would otherwise be skipped.
*
* This module patches `Response.prototype.arrayBuffer` and `bytes` so that when wasm is fetched
* and then loaded from bytes, we can map the resulting `ArrayBuffer` back to the fetch URL via
* `getWasmSourceUrl()` and register the module in `patchNonStreamingWebAssembly`.
*/
const wasmSourceUrls = new WeakMap<ArrayBuffer, string>();

const PATCHED_SYMBOL = Symbol.for('__sentryWasmPatched');

type MaybePatched = { [PATCHED_SYMBOL]?: boolean };

/**
* Resolves a wasm source buffer back to its fetch URL, when known.
*/
export function getWasmSourceUrl(source: BufferSource): string | undefined {
const buffer = toArrayBuffer(source);
if (!buffer) {
return undefined;
}

return wasmSourceUrls.get(buffer);
}

function toArrayBuffer(source: BufferSource): ArrayBuffer | undefined {
if (source instanceof ArrayBuffer) {
return source;
}

if (ArrayBuffer.isView(source)) {
const { buffer } = source;
return buffer instanceof ArrayBuffer ? buffer : undefined;
}

return undefined;
}

function looksLikeWasmResponse(response: Response): boolean {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/wasm')) {
return true;
}

const { url } = response;
return Boolean(url && /\.wasm(?:\?|#|$)/i.test(url));
}

function tagResponseBuffer(response: Response, buffer: ArrayBuffer): void {
if (looksLikeWasmResponse(response) && response.url) {
wasmSourceUrls.set(buffer, response.url);
}
}

/**
* Patches Response body readers so wasm bytes remember their fetch URL.
*/
export function patchWasmResponseBodyReaders(): void {
if (typeof Response === 'undefined') {
return;
}

const responseProto = Response.prototype as MaybePatched;
if (responseProto[PATCHED_SYMBOL]) {
return;
}

fill(Response.prototype, 'arrayBuffer', (original: (this: Response) => Promise<ArrayBuffer>) => {
return function arrayBuffer(this: Response): Promise<ArrayBuffer> {
const bufferPromise: Promise<ArrayBuffer> = original.call(this);
return bufferPromise.then((buffer: ArrayBuffer) => {
tagResponseBuffer(this, buffer);
return buffer;
});
};
});

fill(Response.prototype, 'bytes', (original: (this: Response) => Promise<Uint8Array>) => {
return function bytes(this: Response): Promise<Uint8Array> {
const bytesPromise: Promise<Uint8Array> = original.call(this);
return bytesPromise.then((bytes: Uint8Array) => {
const { buffer } = bytes;
if (buffer instanceof ArrayBuffer) {
tagResponseBuffer(this, buffer);
}
Comment thread
andreiborza marked this conversation as resolved.
Outdated
return bytes;
});
};
});

addNonEnumerableProperty(responseProto, PATCHED_SYMBOL, true);
}

/** @internal */
export function _resetResponsePatchForTests(): void {
if (typeof Response !== 'undefined') {
addNonEnumerableProperty(Response.prototype, PATCHED_SYMBOL, false);
}
}
67 changes: 66 additions & 1 deletion packages/wasm/src/patchWebAssembly.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { getWasmSourceUrl, patchWasmResponseBodyReaders } from './patchWasmResponse';

export type RegisterModuleCallback = (module: WebAssembly.Module, url: string) => void;

let nonStreamingPatched = false;

/**
* 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(registerModule: RegisterModuleCallback): void {
export function patchStreamingWebAssembly(registerModule: RegisterModuleCallback): void {
if ('instantiateStreaming' in WebAssembly) {
const origInstantiateStreaming = WebAssembly.instantiateStreaming as (
response: unknown,
Expand Down Expand Up @@ -56,3 +60,64 @@ function registerSafely(registerModule: RegisterModuleCallback, module: WebAssem
// a registration failure must never break the user's WebAssembly call
}
}

function registerFromBufferSource(
registerModule: RegisterModuleCallback,
module: WebAssembly.Module,
source: BufferSource,
): void {
const url = getWasmSourceUrl(source);
if (url) {
registerSafely(registerModule, module, url);
}
}

/**
* Patches the non-streaming web assembly runtime.
*/
function patchNonStreamingWebAssembly(registerModule: RegisterModuleCallback): void {
if (nonStreamingPatched) {
return;
}
Comment thread
andreiborza marked this conversation as resolved.
Comment thread
andreiborza marked this conversation as resolved.

nonStreamingPatched = true;

// Double-cast, because the overloaded native signature (buffer vs. module
// first argument) cannot be widened to a pass-through shape in one step.
const origInstantiate = WebAssembly.instantiate as unknown as (
source: unknown,
...rest: unknown[]
) => Promise<WebAssembly.WebAssemblyInstantiatedSource>;
WebAssembly.instantiate = function instantiate(source: BufferSource | WebAssembly.Module, ...rest: unknown[]) {
if (source instanceof WebAssembly.Module) {
return origInstantiate(source, ...rest);
}

return origInstantiate(source, ...rest).then(result => {
registerFromBufferSource(registerModule, result.module, source);
return result;
});
} as typeof WebAssembly.instantiate;

const origCompile = WebAssembly.compile as (source: unknown, ...rest: unknown[]) => Promise<WebAssembly.Module>;
WebAssembly.compile = function compile(source: BufferSource, ...rest: unknown[]): Promise<WebAssembly.Module> {
return origCompile(source, ...rest).then(module => {
registerFromBufferSource(registerModule, module, source);
return module;
});
};
}

/**
* Patches the web assembly runtime.
*/
export function patchWebAssembly(registerModule: RegisterModuleCallback): void {
patchWasmResponseBodyReaders();
patchNonStreamingWebAssembly(registerModule);
patchStreamingWebAssembly(registerModule);
}

/** @internal */
export function _resetNonStreamingPatchForTests(): void {
nonStreamingPatched = false;
}
21 changes: 21 additions & 0 deletions packages/wasm/test/frozenResponse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest';
import { patchWebAssembly } from '../src/patchWebAssembly';

// Kept in its own file because freezing `Response.prototype` cannot be undone
// and would leak into every other test sharing the environment.
describe('patchWebAssembly() with a frozen Response.prototype', () => {
it('does not throw and still installs the streaming patch', async () => {
Object.freeze(Response.prototype);

const module = {} as WebAssembly.Module;
WebAssembly.compileStreaming = vi.fn().mockResolvedValue(module) as unknown as typeof WebAssembly.compileStreaming;

const registered: string[] = [];

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

await WebAssembly.compileStreaming({ url: 'http://localhost:8001/main.wasm' } as Response);

expect(registered).toEqual(['http://localhost:8001/main.wasm']);
});
});
Loading
Loading