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
@@ -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',
},
]);
},
);
100 changes: 100 additions & 0 deletions packages/wasm/src/patchWasmResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { 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>();

let responseReadersPatched = false;

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

return wasmSourceUrls.get(buffer);
}

function toArrayBuffer(source: unknown): 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));
}

/**
* Runs inside the caller's `arrayBuffer()` / `bytes()` promise chain, so it must never throw:
* a failure here would reject a body read that has nothing to do with wasm.
*/
function tagResponseSource(response: Response, source: unknown): void {
try {
const buffer = toArrayBuffer(source);
if (buffer && response.url && looksLikeWasmResponse(response)) {
wasmSourceUrls.set(buffer, response.url);
}
} catch {
// see above
}
}

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

responseReadersPatched = true;

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 => {
tagResponseSource(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 => {
tagResponseSource(this, bytes);
return bytes;
});
};
});
}

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

/**
* Registers a module compiled from bytes under the URL those bytes were fetched from, when known.
* Runs inside the caller's promise chain, so nothing in here may throw.
*/
function registerFromBufferSource(
registerModule: RegisterModuleCallback,
compiled: WebAssembly.Module | WebAssembly.WebAssemblyInstantiatedSource | WebAssembly.Instance,
source: unknown,
): void {
try {
// `instantiate(module)` resolves to a bare Instance, which carries nothing new to register
const module =
compiled instanceof WebAssembly.Module ? compiled : 'module' in compiled ? compiled.module : undefined;
const url = getWasmSourceUrl(source);
if (module && url) {
registerModule(module, url);
}
} catch {
// a registration failure must never break the user's WebAssembly call
}
}

/**
* 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.Instance>;
WebAssembly.instantiate = function instantiate(source: unknown, ...rest: unknown[]) {
return origInstantiate(source, ...rest).then(result => {
registerFromBufferSource(registerModule, result, 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.
*
* Every patch is guarded on its own: a missing or frozen global must neither throw out of
* `Sentry.init()` / `registerWebWorkerWasm()` nor keep the remaining patches from installing.
*/
export function patchWebAssembly(registerModule: RegisterModuleCallback): void {
tryPatch(() => patchWasmResponseBodyReaders());
tryPatch(() => patchNonStreamingWebAssembly(registerModule));
tryPatch(() => patchStreamingWebAssembly(registerModule));
}

function tryPatch(patch: () => void): void {
try {
patch();
} catch {
// see patchWebAssembly()
}
}

/** @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