Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(wasm): patch non-streaming load paths#23767
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base:develop
Are you sure you want to change the base?
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
5ae341899a2a6023275388bfb52c7799bee3211543File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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', | ||
| }, | ||
| ]); | ||
| }, | ||
| ); |
| 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; | ||
| } |
| 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, | ||
| @@ -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; | ||
| } | ||
andreiborza marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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; | ||
| } | ||
| 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']); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.