From eb13e83fca4313ad567f928e4793486865b5e17c Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 10 Jul 2026 17:46:28 +0200 Subject: [PATCH 01/17] feat: hasWasmSupport util --- package.json | 7 +++++-- src/index.ts | 1 + src/wasm-info.spec.ts | 24 ++++++++++++++++++++++++ src/wasm-info.ts | 10 ++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/wasm-info.spec.ts create mode 100644 src/wasm-info.ts diff --git a/package.json b/package.json index 16dbedd..80771d9 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,11 @@ "module": "dist/esm/index.js", "types": "dist/types/index.d.ts", "exports": { - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js" + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + } }, "scripts": { "build": "run-s clean compile", diff --git a/src/index.ts b/src/index.ts index ac1bd5d..bfee768 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './browser-info'; export * from './cpu-info'; export * from './system-info'; +export * from './wasm-info'; export * from './web-capabilities'; diff --git a/src/wasm-info.spec.ts b/src/wasm-info.spec.ts new file mode 100644 index 0000000..751a147 --- /dev/null +++ b/src/wasm-info.spec.ts @@ -0,0 +1,24 @@ +import { hasWasmSupport } from './wasm-info'; + +describe('hasWasmSupport', () => { + const originalWebAssembly = globalThis.WebAssembly; + + afterEach(() => { + // Restore the WebAssembly global mutated by individual cases. + (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; + }); + + it('should return true when the WebAssembly runtime is available', () => { + expect.assertions(1); + + expect(hasWasmSupport()).toBe(true); + }); + + it('should return false when the WebAssembly runtime is hard-disabled', () => { + expect.assertions(1); + + delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; + + expect(hasWasmSupport()).toBe(false); + }); +}); diff --git a/src/wasm-info.ts b/src/wasm-info.ts new file mode 100644 index 0000000..600c068 --- /dev/null +++ b/src/wasm-info.ts @@ -0,0 +1,10 @@ +/** + * Checks whether the WebAssembly runtime is available, without throwing when it is not. + * + * Returns false only when WebAssembly is hard-disabled, such as Chromium jitless mode where the + * global is removed entirely. + * + * @returns True if the WebAssembly runtime is present, false if it is hard-disabled. + */ +export const hasWasmSupport = (): boolean => + typeof WebAssembly === 'object' && typeof WebAssembly.validate === 'function'; From 91a22d7230aae3de84fb582446c880b25cd6b659 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 10 Jul 2026 17:58:31 +0200 Subject: [PATCH 02/17] chore: simplify exports --- package.json | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 80771d9..fbfffbc 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,9 @@ "module": "dist/esm/index.js", "types": "dist/types/index.d.ts", "exports": { - ".": { - "types": "./dist/types/index.d.ts", - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js" - } + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" }, "scripts": { "build": "run-s clean compile", From 791fd12973fb9e6870ce8784cc47ea87a0f02503 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 10 Jul 2026 18:13:55 +0200 Subject: [PATCH 03/17] refactor: update logic --- src/index.ts | 1 - src/wasm-info.spec.ts | 24 ------------------------ src/wasm-info.ts | 10 ---------- src/web-capabilities.spec.ts | 32 ++++++++++++++++++++++++++++++++ src/web-capabilities.ts | 17 +++++++++++++++++ 5 files changed, 49 insertions(+), 35 deletions(-) delete mode 100644 src/wasm-info.spec.ts delete mode 100644 src/wasm-info.ts diff --git a/src/index.ts b/src/index.ts index bfee768..ac1bd5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ export * from './browser-info'; export * from './cpu-info'; export * from './system-info'; -export * from './wasm-info'; export * from './web-capabilities'; diff --git a/src/wasm-info.spec.ts b/src/wasm-info.spec.ts deleted file mode 100644 index 751a147..0000000 --- a/src/wasm-info.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { hasWasmSupport } from './wasm-info'; - -describe('hasWasmSupport', () => { - const originalWebAssembly = globalThis.WebAssembly; - - afterEach(() => { - // Restore the WebAssembly global mutated by individual cases. - (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; - }); - - it('should return true when the WebAssembly runtime is available', () => { - expect.assertions(1); - - expect(hasWasmSupport()).toBe(true); - }); - - it('should return false when the WebAssembly runtime is hard-disabled', () => { - expect.assertions(1); - - delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; - - expect(hasWasmSupport()).toBe(false); - }); -}); diff --git a/src/wasm-info.ts b/src/wasm-info.ts deleted file mode 100644 index 600c068..0000000 --- a/src/wasm-info.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Checks whether the WebAssembly runtime is available, without throwing when it is not. - * - * Returns false only when WebAssembly is hard-disabled, such as Chromium jitless mode where the - * global is removed entirely. - * - * @returns True if the WebAssembly runtime is present, false if it is hard-disabled. - */ -export const hasWasmSupport = (): boolean => - typeof WebAssembly === 'object' && typeof WebAssembly.validate === 'function'; diff --git a/src/web-capabilities.spec.ts b/src/web-capabilities.spec.ts index 9e5b8b2..3aa7de1 100644 --- a/src/web-capabilities.spec.ts +++ b/src/web-capabilities.spec.ts @@ -41,6 +41,17 @@ describe('WebCapabilities', () => { expect(WebCapabilities.isCapableOfReceiving1080pVideo()).toBe(CapabilityState.CAPABLE); expect(WebCapabilities.isCapableOfSending1080pVideo()).toBe(CapabilityState.CAPABLE); }); + it('should return NOT_CAPABLE for background noise removal when WebAssembly is hard-disabled', () => { + expect.assertions(1); + const originalWebAssembly = globalThis.WebAssembly; + jest.spyOn(CpuInfo, 'getNumLogicalCores').mockReturnValue(8); + delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; + + expect(WebCapabilities.isCapableOfBackgroundNoiseRemoval()).toBe(CapabilityState.NOT_CAPABLE); + + (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; + }); + describe('supportsEncodedStreamTransforms', () => { afterEach(() => { // Clean up window modifications @@ -249,6 +260,27 @@ describe('WebCapabilities', () => { }); }); + describe('supportsWasm', () => { + const originalWebAssembly = globalThis.WebAssembly; + + afterEach(() => { + // Restore the WebAssembly global mutated by individual cases. + (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; + }); + + it('should return CAPABLE when the WebAssembly runtime is available', () => { + expect.assertions(1); + expect(WebCapabilities.supportsWasm()).toBe(CapabilityState.CAPABLE); + }); + + it('should return NOT_CAPABLE when the WebAssembly runtime is hard-disabled', () => { + expect.assertions(1); + delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; + + expect(WebCapabilities.supportsWasm()).toBe(CapabilityState.NOT_CAPABLE); + }); + }); + describe('supportsEncodingCodec', () => { let isChromeSpy: jest.SpyInstance; let isEdgeSpy: jest.SpyInstance; diff --git a/src/web-capabilities.ts b/src/web-capabilities.ts index 9d4fa54..8836f66 100644 --- a/src/web-capabilities.ts +++ b/src/web-capabilities.ts @@ -23,6 +23,11 @@ export class WebCapabilities { * @returns A {@link CapabilityState}. */ static isCapableOfBackgroundNoiseRemoval(): CapabilityState { + // Background noise removal runs as a WebAssembly module, so it cannot work at all when the + // runtime is hard-disabled (such as Chromium jitless mode). + if (WebCapabilities.supportsWasm() === CapabilityState.NOT_CAPABLE) { + return CapabilityState.NOT_CAPABLE; + } const numCores = CpuInfo.getNumLogicalCores(); if (numCores === undefined) { return CapabilityState.UNKNOWN; @@ -112,6 +117,18 @@ export class WebCapabilities { : CapabilityState.NOT_CAPABLE; } + /** + * Checks whether the browser supports the WebAssembly runtime. Some environments hard-disable it, + * such as Chromium jitless mode, where the WebAssembly global is removed entirely. + * + * @returns A {@link CapabilityState}. + */ + static supportsWasm(): CapabilityState { + return typeof WebAssembly === 'object' && typeof WebAssembly.validate === 'function' + ? CapabilityState.CAPABLE + : CapabilityState.NOT_CAPABLE; + } + /** * Checks whether the browser supports RTCPeerConnection. This is needed, * because some users install browser extensions that remove RTCPeerConnection. From be8d02a096939a7839784f10b3776693ede8bec4 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 10 Jul 2026 21:00:14 +0200 Subject: [PATCH 04/17] refactor: update logic for isCapableOfVirtualBackground --- src/web-capabilities.spec.ts | 11 +++++++++++ src/web-capabilities.ts | 9 +++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/web-capabilities.spec.ts b/src/web-capabilities.spec.ts index 3aa7de1..a605481 100644 --- a/src/web-capabilities.spec.ts +++ b/src/web-capabilities.spec.ts @@ -52,6 +52,17 @@ describe('WebCapabilities', () => { (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; }); + it('should return NOT_CAPABLE for virtual background when WebAssembly is hard-disabled', () => { + expect.assertions(1); + const originalWebAssembly = globalThis.WebAssembly; + jest.spyOn(CpuInfo, 'getNumLogicalCores').mockReturnValue(8); + delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; + + expect(WebCapabilities.isCapableOfVirtualBackground()).toBe(CapabilityState.NOT_CAPABLE); + + (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; + }); + describe('supportsEncodedStreamTransforms', () => { afterEach(() => { // Clean up window modifications diff --git a/src/web-capabilities.ts b/src/web-capabilities.ts index 8836f66..a486abf 100644 --- a/src/web-capabilities.ts +++ b/src/web-capabilities.ts @@ -44,6 +44,11 @@ export class WebCapabilities { * @returns A {@link CapabilityState}. */ static isCapableOfVirtualBackground(): CapabilityState { + // Virtual background runs as a WebAssembly module, so it cannot work at all when the runtime + // is hard-disabled (such as Chromium jitless mode). + if (WebCapabilities.supportsWasm() === CapabilityState.NOT_CAPABLE) { + return CapabilityState.NOT_CAPABLE; + } const numCores = CpuInfo.getNumLogicalCores(); if (numCores === undefined) { return CapabilityState.UNKNOWN; @@ -118,8 +123,8 @@ export class WebCapabilities { } /** - * Checks whether the browser supports the WebAssembly runtime. Some environments hard-disable it, - * such as Chromium jitless mode, where the WebAssembly global is removed entirely. + * Checks whether the browser supports the WebAssembly runtime, which some environments + * hard-disable (such as Chromium jitless mode). * * @returns A {@link CapabilityState}. */ From 7fa2dc32b2cfdb225966b35f70c037b31444ce17 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Wed, 15 Jul 2026 12:34:31 +0200 Subject: [PATCH 05/17] feat: add new wasm logic for probing --- cspell.json | 5 + jest.config.js | 4 + jest.raw-transform.js | 13 +++ package.json | 1 + rollup.config.js | 12 +- src/index.ts | 1 + src/wasm-runtime-probe.spec.ts | 176 ++++++++++++++++++++++++++++ src/wasm-runtime-probe.ts | 177 +++++++++++++++++++++++++++++ src/wasm-runtime-probe.worker.d.ts | 7 ++ src/wasm-runtime-probe.worker.js | 117 +++++++++++++++++++ src/web-capabilities.spec.ts | 28 +++++ src/web-capabilities.ts | 10 ++ yarn.lock | 19 ++++ 13 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 jest.raw-transform.js create mode 100644 src/wasm-runtime-probe.spec.ts create mode 100644 src/wasm-runtime-probe.ts create mode 100644 src/wasm-runtime-probe.worker.d.ts create mode 100644 src/wasm-runtime-probe.worker.js diff --git a/cspell.json b/cspell.json index 629754d..be61343 100644 --- a/cspell.json +++ b/cspell.json @@ -8,6 +8,7 @@ "automock", "bitauth", "bitjson", + "BNR", "Bowser", "cimg", "circleci", @@ -33,11 +34,14 @@ "libauth", "mindmeld", "mkdir", + "MULT", "multistream", "ndarray", "Onnx", "onnxruntime", + "preconfigured", "prettierignore", + "retuned", "rohit", "sandboxed", "SSDK", @@ -45,6 +49,7 @@ "trackingid", "transcoding", "transpiled", + "tunables", "typedoc", "Unregisters", "untracked", diff --git a/jest.config.js b/jest.config.js index 485333d..9a04782 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,4 +3,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', rootDir: './src', + transform: { + '^.+\\.tsx?$': 'ts-jest', + '\\.worker\\.js$': '/../jest.raw-transform.js', + }, }; diff --git a/jest.raw-transform.js b/jest.raw-transform.js new file mode 100644 index 0000000..91a8cda --- /dev/null +++ b/jest.raw-transform.js @@ -0,0 +1,13 @@ +module.exports = { + /** + * Turns a *.worker.js file into a string module, matching how rollup-plugin-string + * inlines it at build time. Since tests swap in a fake Worker, the worker file's + * contents are never run — we only ever need it as a string, not as runnable code. + * + * @param sourceText - The raw worker file contents. + * @returns The transformed module source for Jest. + */ + process(sourceText) { + return { code: `module.exports = ${JSON.stringify(sourceText)};` }; + }, +}; diff --git a/package.json b/package.json index fbfffbc..1b686b9 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "rollup": "^2.63.0", "rollup-plugin-dts": "^4.1.0", "rollup-plugin-execute": "^1.1.1", + "rollup-plugin-string": "^3.0.0", "rollup-plugin-typescript2": "^0.31.1", "semantic-release": "^19.0.2", "ts-jest": "^27.1.2", diff --git a/rollup.config.js b/rollup.config.js index d210631..8da594b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,8 +2,13 @@ import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import dts from 'rollup-plugin-dts'; import execute from 'rollup-plugin-execute'; +import { string } from 'rollup-plugin-string'; import typescript from 'rollup-plugin-typescript2'; +// Bundle *.worker.js as a string so the probe can start it from a Blob URL +// with no separate file to load. +const workerString = string({ include: '**/*.worker.js' }); + export default [ { input: 'src/index.ts', @@ -18,6 +23,7 @@ export default [ }, ], plugins: [ + workerString, typescript({ useTsconfigDeclarationDir: true }), resolve({ browser: true, extensions: ['.js', '.ts'] }), commonjs(), @@ -32,7 +38,11 @@ export default [ format: 'es', file: 'dist/types.d.ts', }, - plugins: [dts(), execute(['rm -f dist/types/*', 'mv dist/types.d.ts dist/types/index.d.ts'])], + plugins: [ + workerString, + dts(), + execute(['rm -f dist/types/*', 'mv dist/types.d.ts dist/types/index.d.ts']), + ], watch: true, }, ]; diff --git a/src/index.ts b/src/index.ts index ac1bd5d..9a43256 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './wasm-runtime-probe'; export * from './browser-info'; export * from './cpu-info'; export * from './system-info'; diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts new file mode 100644 index 0000000..3e68e7a --- /dev/null +++ b/src/wasm-runtime-probe.spec.ts @@ -0,0 +1,176 @@ +import { WasmRuntimeProbe, WasmJitStatus } from './wasm-runtime-probe'; +import { CapabilityState } from './web-capabilities'; + +interface FakeReply { + ok: boolean; + wasmMs?: number; + jsMs?: number; +} + +// Shared state that controls how the mock Worker behaves in the current test. +let workerReply: FakeReply | undefined; +let workerConstructCount = 0; + +/** + * Fake Worker for jsdom, which has no real one. On postMessage it replies immediately with whatever + * {@link workerReply} the test set, or stays silent so we can test the timeout path. + */ +class MockWorker { + onmessage: ((event: { data: FakeReply }) => void) | null = null; + + onerror: (() => void) | null = null; + + /** + * Counts how many workers were created, so the caching test can check it. + */ + constructor() { + workerConstructCount += 1; + } + + /** + * Sends the configured reply back to the probe, or nothing if none is set. + */ + postMessage(): void { + if (workerReply && this.onmessage) { + this.onmessage({ data: workerReply }); + } + } + + /** + * Does nothing; just matches the real Worker API. + */ + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-empty-function + terminate(): void {} +} + +describe('WasmRuntimeProbe', () => { + const originalWebAssembly = globalThis.WebAssembly; + + beforeEach(() => { + // Clear the per-page cache so each test starts fresh (private, reached via a cast). + (WasmRuntimeProbe as unknown as { cachedResult?: unknown }).cachedResult = undefined; + workerReply = undefined; + workerConstructCount = 0; + }); + + afterEach(() => { + (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; + }); + + it('should return DISABLED when WebAssembly is hard-disabled', async () => { + expect.assertions(5); + delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmJitStatus.DISABLED); + expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); + expect(result.ratio).toBeNull(); + expect(result.wasmMs).toBeNull(); + expect(result.jsMs).toBeNull(); + }); + + it('should return UNKNOWN when Web Workers are not available', async () => { + expect.assertions(2); + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmJitStatus.UNKNOWN); + expect(result.capability).toBe(CapabilityState.UNKNOWN); + }); + + describe('worker benchmark', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'Worker', { + writable: true, + configurable: true, + value: MockWorker, + }); + Object.defineProperty(URL, 'createObjectURL', { + writable: true, + configurable: true, + value: jest.fn(() => 'blob:mock'), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + writable: true, + configurable: true, + value: jest.fn(), + }); + }); + + afterEach(() => { + delete (globalThis as { Worker?: unknown }).Worker; + delete (URL as { createObjectURL?: unknown }).createObjectURL; + delete (URL as { revokeObjectURL?: unknown }).revokeObjectURL; + }); + + it('should return SLOW when the wasm/js ratio is below the threshold', async () => { + expect.assertions(5); + workerReply = { ok: true, wasmMs: 25, jsMs: 100 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmJitStatus.SLOW); + expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); + expect(result.ratio).toBe(0.25); + expect(result.wasmMs).toBe(25); + expect(result.jsMs).toBe(100); + }); + + it('should return OK when the wasm/js ratio is at or above the threshold', async () => { + expect.assertions(5); + workerReply = { ok: true, wasmMs: 110, jsMs: 100 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmJitStatus.OK); + expect(result.capability).toBe(CapabilityState.CAPABLE); + expect(result.ratio).toBe(1.1); + expect(result.wasmMs).toBe(110); + expect(result.jsMs).toBe(100); + }); + + it('should return UNKNOWN when the worker reports a failure', async () => { + expect.assertions(1); + workerReply = { ok: false }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmJitStatus.UNKNOWN); + }); + + it('should return UNKNOWN when jsMs is not a positive number', async () => { + expect.assertions(1); + workerReply = { ok: true, wasmMs: 10, jsMs: 0 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmJitStatus.UNKNOWN); + }); + + it('should return UNKNOWN when the worker does not reply before the timeout', async () => { + expect.assertions(1); + jest.useFakeTimers(); + workerReply = undefined; // never replies + + const promise = WasmRuntimeProbe.check(); + jest.advanceTimersByTime(3000); + const result = await promise; + + expect(result.status).toBe(WasmJitStatus.UNKNOWN); + jest.useRealTimers(); + }); + + it('should cache the result so repeated calls run the benchmark only once', async () => { + expect.assertions(2); + workerReply = { ok: true, wasmMs: 110, jsMs: 100 }; + + const first = WasmRuntimeProbe.check(); + const second = WasmRuntimeProbe.check(); + + expect(first).toBe(second); + await first; + expect(workerConstructCount).toBe(1); + }); + }); +}); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts new file mode 100644 index 0000000..2edc2ea --- /dev/null +++ b/src/wasm-runtime-probe.ts @@ -0,0 +1,177 @@ +import { CapabilityState, WebCapabilities } from './web-capabilities'; +import WORKER_SRC from './wasm-runtime-probe.worker'; + +/** Possible results of the WASM runtime probe. */ +export enum WasmJitStatus { + OK = 'ok', + SLOW = 'slow', + DISABLED = 'disabled', + UNKNOWN = 'unknown', +} + +/** + * Result of the WASM runtime probe. Used to decide whether to allow real-time + * WASM effects (BNR, VBG), which run poorly when the browser runs WASM through a + * slow interpreter. + */ +export interface WasmRuntimeResult { + status: WasmJitStatus; + capability: CapabilityState; + ratio: number | null; // wasmMs / jsMs, kept raw so the cutoff can be tuned later + wasmMs: number | null; + jsMs: number | null; +} + +// Calibrated cutoff for the wasm/js ratio. +const SLOW_RATIO_THRESHOLD = 0.6; +const WORKER_TIMEOUT_MS = 3000; + +/** + * Maps a probe status to a CAPABLE/NOT_CAPABLE verdict. + * + * @param status - The probe {@link WasmJitStatus}. + * @returns The corresponding {@link CapabilityState}. + */ +const statusToCapability = (status: WasmJitStatus): CapabilityState => { + switch (status) { + case WasmJitStatus.OK: + return CapabilityState.CAPABLE; + case WasmJitStatus.SLOW: + case WasmJitStatus.DISABLED: + return CapabilityState.NOT_CAPABLE; + default: + return CapabilityState.UNKNOWN; + } +}; + +interface WorkerReply { + ok: boolean; + wasmMs?: number; + jsMs?: number; +} + +/** + * Checks whether this browser runs WebAssembly at full (JIT) speed or through a + * slow interpreter, by timing the same loop in WASM vs JS. This catches the case + * where WASM is present but too slow for real-time effects (e.g. Edge with JIT + * turned off). The quick "disabled" check is instant; the timed benchmark runs + * off the main thread. The result is cached, so it runs at most once per page. + */ +export class WasmRuntimeProbe { + private static cachedResult?: Promise; + + /** + * Runs the probe (cached per page) and resolves with the classified result. + * + * @returns A promise that resolves with the {@link WasmRuntimeResult}. + */ + static check(): Promise { + if (!this.cachedResult) { + this.cachedResult = this.run(); + } + return this.cachedResult; + } + + /** + * Builds a {@link WasmRuntimeResult} from a status and optional raw measurements. + * + * @param status - The classified {@link WasmJitStatus}. + * @param extra - Optional raw measurements to include. + * @param extra.ratio - The wasmMs / jsMs ratio. + * @param extra.wasmMs - The measured WASM time in milliseconds. + * @param extra.jsMs - The measured JS time in milliseconds. + * @returns The assembled {@link WasmRuntimeResult}. + */ + private static buildResult( + status: WasmJitStatus, + extra?: { ratio?: number; wasmMs?: number; jsMs?: number } + ): WasmRuntimeResult { + return { + status, + capability: statusToCapability(status), + ratio: extra?.ratio ?? null, + wasmMs: extra?.wasmMs ?? null, + jsMs: extra?.jsMs ?? null, + }; + } + + /** + * Runs the checks in order: the instant "disabled" check, then the timed Worker benchmark. + * + * @returns A promise that resolves with the {@link WasmRuntimeResult}. + */ + private static async run(): Promise { + if (WebCapabilities.supportsWasm() === CapabilityState.NOT_CAPABLE) { + return this.buildResult(WasmJitStatus.DISABLED); + } + + // Can't run the benchmark without a Web Worker and a Blob URL. + if ( + WebCapabilities.supportsWorker() === CapabilityState.NOT_CAPABLE || + typeof URL === 'undefined' || + !URL.createObjectURL + ) { + return this.buildResult(WasmJitStatus.UNKNOWN); + } + + const started = this.startWorker(); + if (!started) { + return this.buildResult(WasmJitStatus.UNKNOWN); + } + + const { worker, url } = started; + try { + // eslint-disable-next-line jsdoc/require-jsdoc + const msg = await new Promise((resolve) => { + const timer = setTimeout(() => resolve({ ok: false }), WORKER_TIMEOUT_MS); + // eslint-disable-next-line jsdoc/require-jsdoc + worker.onmessage = (e: MessageEvent) => { + clearTimeout(timer); + resolve(e.data); + }; + // eslint-disable-next-line jsdoc/require-jsdoc + worker.onerror = () => { + clearTimeout(timer); + resolve({ ok: false }); + }; + worker.postMessage('start'); + }); + + if ( + !msg.ok || + typeof msg.jsMs !== 'number' || + msg.jsMs <= 0 || + typeof msg.wasmMs !== 'number' + ) { + return this.buildResult(WasmJitStatus.UNKNOWN); + } + + const ratio = Number((msg.wasmMs / msg.jsMs).toFixed(2)); + const status = ratio < SLOW_RATIO_THRESHOLD ? WasmJitStatus.SLOW : WasmJitStatus.OK; + return this.buildResult(status, { + ratio, + wasmMs: Number(msg.wasmMs.toFixed(2)), + jsMs: Number(msg.jsMs.toFixed(2)), + }); + } finally { + worker.terminate(); + URL.revokeObjectURL(url); + } + } + + /** + * Starts the benchmark worker from the inline source (via a Blob URL). + * + * @returns The worker and its Blob URL, or undefined if creation fails. + */ + private static startWorker(): { worker: Worker; url: string } | undefined { + let url: string | undefined; + try { + url = URL.createObjectURL(new Blob([WORKER_SRC], { type: 'text/javascript' })); + return { worker: new Worker(url), url }; + } catch { + if (url) URL.revokeObjectURL(url); + return undefined; + } + } +} diff --git a/src/wasm-runtime-probe.worker.d.ts b/src/wasm-runtime-probe.worker.d.ts new file mode 100644 index 0000000..6bbab45 --- /dev/null +++ b/src/wasm-runtime-probe.worker.d.ts @@ -0,0 +1,7 @@ +/** + * Tells TypeScript that importing `wasm-runtime-probe.worker.js` gives a string. + * The build (rollup-plugin-string) and the tests (jest raw transform) both turn + * that file into this default string export. + */ +declare const workerSource: string; +export default workerSource; diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js new file mode 100644 index 0000000..d24ba9c --- /dev/null +++ b/src/wasm-runtime-probe.worker.js @@ -0,0 +1,117 @@ +/* + * wasm-runtime-probe.worker.js — measures how long the same loop takes in WASM vs JS, off the main thread. + * + * Inlined as a string at build time and started from a Blob URL by wasm-runtime-probe.ts; + * kept as a real file so it stays readable. + * + * Protocol: main thread posts 'start'; replies { ok: true, wasmMs, jsMs } or { ok: false }. + */ +self.onmessage = function onProbeStart() { + try { + // Standard LCG constants (Numerical Recipes): acc = acc * MULT + INC is a cheap + // arithmetic loop the JIT can't optimize away, so it's a fair CPU benchmark. The + // WASM and JS loops share them so both do identical work; changing them + // invalidates the calibrated threshold. + var LCG_MULT = 1664525; // multiplier + var LCG_INC = 1013904223; // increment + var ITERATIONS = 5000000; + var SAMPLE_RUNS = 7; // keep the median of this many runs + + // Build a tiny WASM module in memory that exports bench(n) — nothing to fetch. + // Bytes use LEB128: encodeU32 for lengths/counts, encodeI32 for signed values. + var encodeU32 = function encodeU32(value) { + var out = []; + do { + var byte = value & 0x7f; + value >>>= 7; + if (value) byte |= 0x80; + out.push(byte); + } while (value); + return out; + }; + + var encodeI32 = function encodeI32(value) { + var out = []; + var more = true; + while (more) { + var byte = value & 0x7f; + value >>= 7; + if ((value === 0 && !(byte & 0x40)) || (value === -1 && byte & 0x40)) { + more = false; + } else { + byte |= 0x80; + } + out.push(byte); + } + return out; + }; + + // A section is one labelled block of the file: [id, length, ...bytes]. + var section = function section(id, bytes) { + return [id].concat(encodeU32(bytes.length)).concat(bytes); + }; + + var I32 = 0x7f; // WASM's code for the 32-bit integer type. + + var buildWasmLoopModule = function buildWasmLoopModule() { + var typeSec = section(1, encodeU32(1).concat([0x60, 0x01, I32, 0x01, I32])); // bench's type: takes one i32, returns one i32 + var funcSec = section(3, encodeU32(1).concat([0x00])); // function 0 uses signature 0 + var name = 'bench'.split('').map(function toCharCode(c) { + return c.charCodeAt(0); + }); + var exportSec = section( + 7, + encodeU32(1).concat(encodeU32(name.length)).concat(name).concat([0x00, 0x00]) + ); // export the function as "bench" + // Function body — 2 locals (i, acc), then the loop: + // acc = acc * LCG_MULT + LCG_INC; i += 1; if (i < n) loop; return acc + var body = encodeU32(1) + .concat(encodeU32(2)) + .concat([I32, 0x03, 0x40, 0x20, 0x02, 0x41]) + .concat(encodeI32(LCG_MULT)) + .concat([0x6c, 0x41]) + .concat(encodeI32(LCG_INC)) + .concat([ + 0x6a, 0x21, 0x02, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00, + 0x0b, 0x20, 0x02, 0x0b, + ]); + var codeSec = section(10, encodeU32(1).concat(encodeU32(body.length)).concat(body)); + // "\0asm" + version 1 + the four sections + return new Uint8Array( + [0, 0x61, 0x73, 0x6d, 1, 0, 0, 0].concat(typeSec, funcSec, exportSec, codeSec) + ); + }; + + // Median of several runs, so one slow run (e.g. a background hiccup) is ignored. + var medianRuntimeMs = function medianRuntimeMs(loopFn) { + loopFn(ITERATIONS); // warm-up run (not timed) + var samples = []; + for (var k = 0; k < SAMPLE_RUNS; k++) { + var start = performance.now(); + loopFn(ITERATIONS); + samples.push(performance.now() - start); + } + samples.sort(function ascending(a, b) { + return a - b; + }); + return samples[Math.floor(samples.length / 2)]; + }; + + var runWasmLoop = new WebAssembly.Instance( + new WebAssembly.Module(buildWasmLoopModule()) + ).exports.bench; + var runJsLoop = function runJsLoop(n) { + var acc = 0; + for (var k = 0; k < n; k++) { + acc = (Math.imul(acc, LCG_MULT) + LCG_INC) | 0; + } + return acc; + }; + + var wasmMs = medianRuntimeMs(runWasmLoop); + var jsMs = medianRuntimeMs(runJsLoop); + self.postMessage({ ok: true, wasmMs: wasmMs, jsMs: jsMs }); + } catch (err) { + self.postMessage({ ok: false }); + } +}; diff --git a/src/web-capabilities.spec.ts b/src/web-capabilities.spec.ts index a605481..fe027a3 100644 --- a/src/web-capabilities.spec.ts +++ b/src/web-capabilities.spec.ts @@ -292,6 +292,34 @@ describe('WebCapabilities', () => { }); }); + describe('supportsWorker', () => { + const originalWorker = (globalThis as { Worker?: unknown }).Worker; + + /** + * Minimal stand-in for the Worker constructor, which jsdom does not provide. + */ + class MockWorker {} + + afterEach(() => { + // Restore the Worker global mutated by individual cases. + (globalThis as { Worker?: unknown }).Worker = originalWorker; + }); + + it('should return CAPABLE when Web Workers are available', () => { + expect.assertions(1); + (globalThis as { Worker?: unknown }).Worker = MockWorker; + + expect(WebCapabilities.supportsWorker()).toBe(CapabilityState.CAPABLE); + }); + + it('should return NOT_CAPABLE when Web Workers are not available', () => { + expect.assertions(1); + delete (globalThis as { Worker?: unknown }).Worker; + + expect(WebCapabilities.supportsWorker()).toBe(CapabilityState.NOT_CAPABLE); + }); + }); + describe('supportsEncodingCodec', () => { let isChromeSpy: jest.SpyInstance; let isEdgeSpy: jest.SpyInstance; diff --git a/src/web-capabilities.ts b/src/web-capabilities.ts index a486abf..5a0d593 100644 --- a/src/web-capabilities.ts +++ b/src/web-capabilities.ts @@ -134,6 +134,16 @@ export class WebCapabilities { : CapabilityState.NOT_CAPABLE; } + /** + * Checks whether the browser supports Web Workers, which run scripts on a + * background thread separate from the main UI thread. + * + * @returns A {@link CapabilityState}. + */ + static supportsWorker(): CapabilityState { + return typeof Worker === 'function' ? CapabilityState.CAPABLE : CapabilityState.NOT_CAPABLE; + } + /** * Checks whether the browser supports RTCPeerConnection. This is needed, * because some users install browser extensions that remove RTCPeerConnection. diff --git a/yarn.lock b/yarn.lock index 1a045c2..4e2a3ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3281,6 +3281,11 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" + integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== + estree-walker@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" @@ -6462,6 +6467,13 @@ rollup-plugin-execute@^1.1.1: resolved "https://registry.yarnpkg.com/rollup-plugin-execute/-/rollup-plugin-execute-1.1.1.tgz#ee7bcb293e48bc599232b66b66473763e3cb8965" integrity sha512-isCNR/VrwlEfWJMwsnmt5TBRod8dW1IjVRxcXCBrxDmVTeA1IXjzeLSS3inFBmRD7KDPlo38KSb2mh5v5BoWgA== +rollup-plugin-string@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-string/-/rollup-plugin-string-3.0.0.tgz#fed2d6301fae1e59eb610957df757ef13fada3f0" + integrity sha512-vqyzgn9QefAgeKi+Y4A7jETeIAU1zQmS6VotH6bzm/zmUQEnYkpIGRaOBPY41oiWYV4JyBoGAaBjYMYuv+6wVw== + dependencies: + rollup-pluginutils "^2.4.1" + rollup-plugin-typescript2@^0.31.1: version "0.31.2" resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.31.2.tgz#463aa713a7e2bf85b92860094b9f7fb274c5a4d8" @@ -6474,6 +6486,13 @@ rollup-plugin-typescript2@^0.31.1: resolve "^1.20.0" tslib "^2.3.1" +rollup-pluginutils@^2.4.1: + version "2.8.2" + resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" + integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== + dependencies: + estree-walker "^0.6.1" + rollup@^2.63.0: version "2.79.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" From 4a2c8021b7432aac983c605c056ebc7471cb7abf Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Wed, 15 Jul 2026 17:54:37 +0200 Subject: [PATCH 06/17] chore: rename wasm types --- src/wasm-runtime-probe.spec.ts | 16 ++++++++-------- src/wasm-runtime-probe.ts | 28 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index 3e68e7a..2f1aabc 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -1,4 +1,4 @@ -import { WasmRuntimeProbe, WasmJitStatus } from './wasm-runtime-probe'; +import { WasmRuntimeProbe, WasmRuntimeStatus } from './wasm-runtime-probe'; import { CapabilityState } from './web-capabilities'; interface FakeReply { @@ -63,7 +63,7 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmJitStatus.DISABLED); + expect(result.status).toBe(WasmRuntimeStatus.DISABLED); expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); expect(result.ratio).toBeNull(); expect(result.wasmMs).toBeNull(); @@ -75,7 +75,7 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmJitStatus.UNKNOWN); + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); expect(result.capability).toBe(CapabilityState.UNKNOWN); }); @@ -110,7 +110,7 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmJitStatus.SLOW); + expect(result.status).toBe(WasmRuntimeStatus.SLOW); expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); expect(result.ratio).toBe(0.25); expect(result.wasmMs).toBe(25); @@ -123,7 +123,7 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmJitStatus.OK); + expect(result.status).toBe(WasmRuntimeStatus.OK); expect(result.capability).toBe(CapabilityState.CAPABLE); expect(result.ratio).toBe(1.1); expect(result.wasmMs).toBe(110); @@ -136,7 +136,7 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmJitStatus.UNKNOWN); + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); }); it('should return UNKNOWN when jsMs is not a positive number', async () => { @@ -145,7 +145,7 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmJitStatus.UNKNOWN); + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); }); it('should return UNKNOWN when the worker does not reply before the timeout', async () => { @@ -157,7 +157,7 @@ describe('WasmRuntimeProbe', () => { jest.advanceTimersByTime(3000); const result = await promise; - expect(result.status).toBe(WasmJitStatus.UNKNOWN); + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); jest.useRealTimers(); }); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index 2edc2ea..4bc3b5a 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -2,7 +2,7 @@ import { CapabilityState, WebCapabilities } from './web-capabilities'; import WORKER_SRC from './wasm-runtime-probe.worker'; /** Possible results of the WASM runtime probe. */ -export enum WasmJitStatus { +export enum WasmRuntimeStatus { OK = 'ok', SLOW = 'slow', DISABLED = 'disabled', @@ -15,7 +15,7 @@ export enum WasmJitStatus { * slow interpreter. */ export interface WasmRuntimeResult { - status: WasmJitStatus; + status: WasmRuntimeStatus; capability: CapabilityState; ratio: number | null; // wasmMs / jsMs, kept raw so the cutoff can be tuned later wasmMs: number | null; @@ -29,15 +29,15 @@ const WORKER_TIMEOUT_MS = 3000; /** * Maps a probe status to a CAPABLE/NOT_CAPABLE verdict. * - * @param status - The probe {@link WasmJitStatus}. + * @param status - The probe {@link WasmRuntimeStatus}. * @returns The corresponding {@link CapabilityState}. */ -const statusToCapability = (status: WasmJitStatus): CapabilityState => { +const statusToCapability = (status: WasmRuntimeStatus): CapabilityState => { switch (status) { - case WasmJitStatus.OK: + case WasmRuntimeStatus.OK: return CapabilityState.CAPABLE; - case WasmJitStatus.SLOW: - case WasmJitStatus.DISABLED: + case WasmRuntimeStatus.SLOW: + case WasmRuntimeStatus.DISABLED: return CapabilityState.NOT_CAPABLE; default: return CapabilityState.UNKNOWN; @@ -75,7 +75,7 @@ export class WasmRuntimeProbe { /** * Builds a {@link WasmRuntimeResult} from a status and optional raw measurements. * - * @param status - The classified {@link WasmJitStatus}. + * @param status - The classified {@link WasmRuntimeStatus}. * @param extra - Optional raw measurements to include. * @param extra.ratio - The wasmMs / jsMs ratio. * @param extra.wasmMs - The measured WASM time in milliseconds. @@ -83,7 +83,7 @@ export class WasmRuntimeProbe { * @returns The assembled {@link WasmRuntimeResult}. */ private static buildResult( - status: WasmJitStatus, + status: WasmRuntimeStatus, extra?: { ratio?: number; wasmMs?: number; jsMs?: number } ): WasmRuntimeResult { return { @@ -102,7 +102,7 @@ export class WasmRuntimeProbe { */ private static async run(): Promise { if (WebCapabilities.supportsWasm() === CapabilityState.NOT_CAPABLE) { - return this.buildResult(WasmJitStatus.DISABLED); + return this.buildResult(WasmRuntimeStatus.DISABLED); } // Can't run the benchmark without a Web Worker and a Blob URL. @@ -111,12 +111,12 @@ export class WasmRuntimeProbe { typeof URL === 'undefined' || !URL.createObjectURL ) { - return this.buildResult(WasmJitStatus.UNKNOWN); + return this.buildResult(WasmRuntimeStatus.UNKNOWN); } const started = this.startWorker(); if (!started) { - return this.buildResult(WasmJitStatus.UNKNOWN); + return this.buildResult(WasmRuntimeStatus.UNKNOWN); } const { worker, url } = started; @@ -143,11 +143,11 @@ export class WasmRuntimeProbe { msg.jsMs <= 0 || typeof msg.wasmMs !== 'number' ) { - return this.buildResult(WasmJitStatus.UNKNOWN); + return this.buildResult(WasmRuntimeStatus.UNKNOWN); } const ratio = Number((msg.wasmMs / msg.jsMs).toFixed(2)); - const status = ratio < SLOW_RATIO_THRESHOLD ? WasmJitStatus.SLOW : WasmJitStatus.OK; + const status = ratio < SLOW_RATIO_THRESHOLD ? WasmRuntimeStatus.SLOW : WasmRuntimeStatus.OK; return this.buildResult(status, { ratio, wasmMs: Number(msg.wasmMs.toFixed(2)), From 8bd15981f05f0485b6c7ab13592807291ab6eb8e Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Wed, 22 Jul 2026 11:39:40 +0200 Subject: [PATCH 07/17] chore: update public api comment --- src/wasm-runtime-probe.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index 4bc3b5a..bbbe4ed 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -63,6 +63,11 @@ export class WasmRuntimeProbe { /** * Runs the probe (cached per page) and resolves with the classified result. * + * Times the same loop in WASM and JS off the main thread and compares them as a + * ratio (wasmMs / jsMs), which normalizes for the user's CPU. When the engine + * isn't running at full JIT speed the ratio drops below a calibrated threshold, + * and the probe reports {@link WasmRuntimeStatus.SLOW}. + * * @returns A promise that resolves with the {@link WasmRuntimeResult}. */ static check(): Promise { From acbb7e415f16f1ba9564bcc5c8b601a1e35d002e Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 6 Aug 2026 12:15:20 +0200 Subject: [PATCH 08/17] feat: new probe flow for WasmRuntimeResult --- cspell.json | 2 + src/wasm-runtime-probe.spec.ts | 112 ++++++++++++---- src/wasm-runtime-probe.ts | 199 ++++++++++++++++++++++------ src/wasm-runtime-probe.worker.js | 221 ++++++++++++++++++++----------- 4 files changed, 390 insertions(+), 144 deletions(-) diff --git a/cspell.json b/cspell.json index be61343..89ef530 100644 --- a/cspell.json +++ b/cspell.json @@ -23,6 +23,7 @@ "dependabot", "eamodio", "editorconfig", + "embedder", "esbenp", "esnext", "execa", @@ -30,6 +31,7 @@ "globby", "gohri", "inferencing", + "interp", "KHTML", "libauth", "mindmeld", diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index 2f1aabc..692ac1a 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -3,10 +3,38 @@ import { CapabilityState } from './web-capabilities'; interface FakeReply { ok: boolean; - wasmMs?: number; - jsMs?: number; + ops?: number; + addMedianMs?: number; + divMedianMs?: number; + sqrtMedianMs?: number; + addMinMs?: number; + divMinMs?: number; + sqrtMinMs?: number; + checkAdd?: number; + checkDiv?: number; + checkSqrt?: number; } +const OPS = 16_000_000; + +// Realistic measured signatures (see wasm-runtime-probe calibration matrix). +// JIT ON (Mac Chrome): add 6.5ms, div 35.6, sqrt 80.6 -> divRatio 5.5, addNs 0.41. +const FAST_REPLY: FakeReply = { + ok: true, + ops: OPS, + addMedianMs: 6.5, + divMedianMs: 35.6, + sqrtMedianMs: 80.6, +}; +// JIT OFF (Edge, JIT disabled): add 32.1, div 60.6, sqrt 111.5 -> divRatio 1.9, addNs 2.0. +const SLOW_REPLY: FakeReply = { + ok: true, + ops: OPS, + addMedianMs: 32.1, + divMedianMs: 60.6, + sqrtMedianMs: 111.5, +}; + // Shared state that controls how the mock Worker behaves in the current test. let workerReply: FakeReply | undefined; let workerConstructCount = 0; @@ -65,9 +93,9 @@ describe('WasmRuntimeProbe', () => { expect(result.status).toBe(WasmRuntimeStatus.DISABLED); expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.ratio).toBeNull(); - expect(result.wasmMs).toBeNull(); - expect(result.jsMs).toBeNull(); + expect(result.divRatio).toBeNull(); + expect(result.addNsPerOp).toBeNull(); + expect(result.divMedianMs).toBeNull(); }); it('should return UNKNOWN when Web Workers are not available', async () => { @@ -104,30 +132,59 @@ describe('WasmRuntimeProbe', () => { delete (URL as { revokeObjectURL?: unknown }).revokeObjectURL; }); - it('should return SLOW when the wasm/js ratio is below the threshold', async () => { - expect.assertions(5); - workerReply = { ok: true, wasmMs: 25, jsMs: 100 }; + it('should return OK when the op-cost ratios show native (JIT) speed', async () => { + expect.assertions(4); + workerReply = FAST_REPLY; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.OK); + expect(result.capability).toBe(CapabilityState.CAPABLE); + expect(result.divRatio).toBeCloseTo(5.48, 1); + expect(result.addNsPerOp).toBeCloseTo(0.406, 2); + }); + + it('should return SLOW when the div/add ratio collapses (interpreter)', async () => { + expect.assertions(4); + workerReply = SLOW_REPLY; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.SLOW); expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.ratio).toBe(0.25); - expect(result.wasmMs).toBe(25); - expect(result.jsMs).toBe(100); + expect(result.divRatio).toBeCloseTo(1.888, 1); + expect(result.addNsPerOp).toBeGreaterThan(1.2); }); - it('should return OK when the wasm/js ratio is at or above the threshold', async () => { - expect.assertions(5); - workerReply = { ok: true, wasmMs: 110, jsMs: 100 }; + it('should return UNCERTAIN when a fast ratio contradicts an interpreter-slow add', async () => { + expect.assertions(2); + // divRatio 5 (looks native) but addNs 2.5 (interpreter-slow) -> contradiction. + workerReply = { ok: true, ops: OPS, addMedianMs: 40, divMedianMs: 200, sqrtMedianMs: 480 }; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.OK); - expect(result.capability).toBe(CapabilityState.CAPABLE); - expect(result.ratio).toBe(1.1); - expect(result.wasmMs).toBe(110); - expect(result.jsMs).toBe(100); + expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); + expect(result.capability).toBe(CapabilityState.UNKNOWN); + }); + + it('should return UNCERTAIN when the ratios fall between the fast and slow bars', async () => { + expect.assertions(1); + // divRatio 3.5 (between 3 and 4), sqrtRatio 7 (< 8), addNs 0.625 (< floor). + workerReply = { ok: true, ops: OPS, addMedianMs: 10, divMedianMs: 35, sqrtMedianMs: 70 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); + }); + + it('should return UNKNOWN when the div kernel is too small to have really run', async () => { + expect.assertions(1); + // div median below the MIN_DIV_MEDIAN_MS floor -> nothing measurable executed. + workerReply = { ok: true, ops: OPS, addMedianMs: 1, divMedianMs: 2, sqrtMedianMs: 5 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); }); it('should return UNKNOWN when the worker reports a failure', async () => { @@ -139,9 +196,18 @@ describe('WasmRuntimeProbe', () => { expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); }); - it('should return UNKNOWN when jsMs is not a positive number', async () => { + it('should return UNKNOWN when a measurement field is missing', async () => { + expect.assertions(1); + workerReply = { ok: true, ops: OPS, addMedianMs: 6.5 }; // no div/sqrt + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + }); + + it('should return UNKNOWN when addMedianMs is not positive', async () => { expect.assertions(1); - workerReply = { ok: true, wasmMs: 10, jsMs: 0 }; + workerReply = { ok: true, ops: OPS, addMedianMs: 0, divMedianMs: 60, sqrtMedianMs: 110 }; const result = await WasmRuntimeProbe.check(); @@ -154,7 +220,7 @@ describe('WasmRuntimeProbe', () => { workerReply = undefined; // never replies const promise = WasmRuntimeProbe.check(); - jest.advanceTimersByTime(3000); + jest.advanceTimersByTime(8000); const result = await promise; expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); @@ -163,7 +229,7 @@ describe('WasmRuntimeProbe', () => { it('should cache the result so repeated calls run the benchmark only once', async () => { expect.assertions(2); - workerReply = { ok: true, wasmMs: 110, jsMs: 100 }; + workerReply = FAST_REPLY; const first = WasmRuntimeProbe.check(); const second = WasmRuntimeProbe.check(); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index bbbe4ed..32ed54d 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -3,9 +3,15 @@ import WORKER_SRC from './wasm-runtime-probe.worker'; /** Possible results of the WASM runtime probe. */ export enum WasmRuntimeStatus { + /** Engine runs WASM at full JIT (native) speed. */ OK = 'ok', + /** Engine interprets WASM (e.g. Edge with JIT disabled by policy) — too slow for real-time effects. */ SLOW = 'slow', + /** WASM is missing or refuses to compile. */ DISABLED = 'disabled', + /** Signals conflicted (a throttled-but-healthy JIT, or a slow-divider interpreter) — the probe won't guess. */ + UNCERTAIN = 'uncertain', + /** The probe could not run or measure (no Worker, background tab, timeout, error). */ UNKNOWN = 'unknown', } @@ -13,22 +19,54 @@ export enum WasmRuntimeStatus { * Result of the WASM runtime probe. Used to decide whether to allow real-time * WASM effects (BNR, VBG), which run poorly when the browser runs WASM through a * slow interpreter. + * + * The probe is a pure SENSOR: it emits a status plus the raw, hardware-independent + * metrics so callers can re-threshold per feature without re-running it. */ export interface WasmRuntimeResult { status: WasmRuntimeStatus; capability: CapabilityState; - ratio: number | null; // wasmMs / jsMs, kept raw so the cutoff can be tuned later - wasmMs: number | null; - jsMs: number | null; + /** Div/add op-cost ratio — the primary discriminator (JIT high, interpreter ~2). */ + divRatio: number | null; + /** Sqrt/add op-cost ratio — a second, independent-unit signal (fast-divider rescue). */ + sqrtRatio: number | null; + /** Absolute cost of the cheap `add` op in ns/op (hardware-DEPENDENT; interpreter tell). */ + addNsPerOp: number | null; + /** Median time (ms) of the cheap `add` kernel. */ + addMedianMs: number | null; + /** Median time (ms) of the costly integer `div` kernel. */ + divMedianMs: number | null; + /** Median time (ms) of the costly FP `sqrt` kernel. */ + sqrtMedianMs: number | null; } -// Calibrated cutoff for the wasm/js ratio. -const SLOW_RATIO_THRESHOLD = 0.6; -const WORKER_TIMEOUT_MS = 3000; +// --- Calibrated thresholds (Intel Mac + Windows, 4 engines, JIT on/off, throttled) --- +// div/add is the PRIMARY discriminator (JIT 5.4-33 vs interpreter 1.8-2.4). +const DIV_FAST_RATIO = 4.0; // div/add >= this => native code +const DIV_SLOW_RATIO = 3.0; // div/add <= this => interpreter +// sqrt/add is a SECONDARY rescue for a fast-divider CPU (e.g. Apple Silicon). It +// runs ~2x higher than div on BOTH sides (JIT 12-20 vs interp 3.4-4.5), so it +// needs its OWN, higher bar — reusing div's 4.0 here caused a false 'fast'. +const SQRT_FAST_RATIO = 8.0; // sqrt/add >= this => native code (div may have sagged) +// Absolute add cost floor (ns/op). JIT 0.06-0.48 vs interpreter 2.0-2.5. This +// signal is hardware-DEPENDENT (scales with clock), so it only ever ADDS a slow +// vote or flags a contradiction — it never overrides a fast ratio. +const INTERP_ADD_NS_FLOOR = 1.2; +// The integer div kernel is always multi-cycle; if its median is below this the +// loop didn't really run (timer floor / hostile embedder) — treat as unmeasured. +const MIN_DIV_MEDIAN_MS = 8; + +// The op-cost probe runs ~16M ops x 5 trials; a heavily throttled interpreter +// measured ~3.4s, so allow generous headroom. +const WORKER_TIMEOUT_MS = 8000; /** * Maps a probe status to a CAPABLE/NOT_CAPABLE verdict. * + * Only a confident SLOW/DISABLED blocks WASM effects. UNCERTAIN maps to UNKNOWN + * so the feature keeps its own default (enable) rather than false-blocking a + * real user whose signals merely conflicted. + * * @param status - The probe {@link WasmRuntimeStatus}. * @returns The corresponding {@link CapabilityState}. */ @@ -46,16 +84,25 @@ const statusToCapability = (status: WasmRuntimeStatus): CapabilityState => { interface WorkerReply { ok: boolean; - wasmMs?: number; - jsMs?: number; + ops?: number; + addMedianMs?: number; + divMedianMs?: number; + sqrtMedianMs?: number; + addMinMs?: number; + divMinMs?: number; + sqrtMinMs?: number; + checkAdd?: number; + checkDiv?: number; + checkSqrt?: number; } /** * Checks whether this browser runs WebAssembly at full (JIT) speed or through a - * slow interpreter, by timing the same loop in WASM vs JS. This catches the case - * where WASM is present but too slow for real-time effects (e.g. Edge with JIT - * turned off). The quick "disabled" check is instant; the timed benchmark runs - * off the main thread. The result is cached, so it runs at most once per page. + * slow interpreter, by comparing the cost of cheap vs expensive WASM ops. This + * catches the case where WASM is present but too slow for real-time effects + * (e.g. Edge with JIT turned off). The quick "disabled" check is instant; the + * timed benchmark runs off the main thread. The result is cached, so it runs at + * most once per page. */ export class WasmRuntimeProbe { private static cachedResult?: Promise; @@ -63,10 +110,11 @@ export class WasmRuntimeProbe { /** * Runs the probe (cached per page) and resolves with the classified result. * - * Times the same loop in WASM and JS off the main thread and compares them as a - * ratio (wasmMs / jsMs), which normalizes for the user's CPU. When the engine - * isn't running at full JIT speed the ratio drops below a calibrated threshold, - * and the probe reports {@link WasmRuntimeStatus.SLOW}. + * Times three dependent-chain WASM kernels (add / div / sqrt) off the main + * thread and compares them as ratios (div/add, sqrt/add). A ratio cancels out + * raw CPU speed, so it describes the engine, not the machine: under a slow + * interpreter both ratios collapse to ~2 and the probe reports + * {@link WasmRuntimeStatus.SLOW}. * * @returns A promise that resolves with the {@link WasmRuntimeResult}. */ @@ -78,25 +126,38 @@ export class WasmRuntimeProbe { } /** - * Builds a {@link WasmRuntimeResult} from a status and optional raw measurements. + * Builds a {@link WasmRuntimeResult} from a status and optional raw metrics. * * @param status - The classified {@link WasmRuntimeStatus}. - * @param extra - Optional raw measurements to include. - * @param extra.ratio - The wasmMs / jsMs ratio. - * @param extra.wasmMs - The measured WASM time in milliseconds. - * @param extra.jsMs - The measured JS time in milliseconds. + * @param metrics - Optional raw op-cost metrics to include. + * @param metrics.divRatio - The div/add op-cost ratio. + * @param metrics.sqrtRatio - The sqrt/add op-cost ratio. + * @param metrics.addNsPerOp - The absolute cost of `add` in ns/op. + * @param metrics.addMedianMs - Median time of the `add` kernel. + * @param metrics.divMedianMs - Median time of the `div` kernel. + * @param metrics.sqrtMedianMs - Median time of the `sqrt` kernel. * @returns The assembled {@link WasmRuntimeResult}. */ private static buildResult( status: WasmRuntimeStatus, - extra?: { ratio?: number; wasmMs?: number; jsMs?: number } + metrics?: { + divRatio?: number; + sqrtRatio?: number; + addNsPerOp?: number; + addMedianMs?: number; + divMedianMs?: number; + sqrtMedianMs?: number; + } ): WasmRuntimeResult { return { status, capability: statusToCapability(status), - ratio: extra?.ratio ?? null, - wasmMs: extra?.wasmMs ?? null, - jsMs: extra?.jsMs ?? null, + divRatio: metrics?.divRatio ?? null, + sqrtRatio: metrics?.sqrtRatio ?? null, + addNsPerOp: metrics?.addNsPerOp ?? null, + addMedianMs: metrics?.addMedianMs ?? null, + divMedianMs: metrics?.divMedianMs ?? null, + sqrtMedianMs: metrics?.sqrtMedianMs ?? null, }; } @@ -142,28 +203,84 @@ export class WasmRuntimeProbe { worker.postMessage('start'); }); - if ( - !msg.ok || - typeof msg.jsMs !== 'number' || - msg.jsMs <= 0 || - typeof msg.wasmMs !== 'number' - ) { - return this.buildResult(WasmRuntimeStatus.UNKNOWN); - } - - const ratio = Number((msg.wasmMs / msg.jsMs).toFixed(2)); - const status = ratio < SLOW_RATIO_THRESHOLD ? WasmRuntimeStatus.SLOW : WasmRuntimeStatus.OK; - return this.buildResult(status, { - ratio, - wasmMs: Number(msg.wasmMs.toFixed(2)), - jsMs: Number(msg.jsMs.toFixed(2)), - }); + return this.classify(msg); } finally { worker.terminate(); URL.revokeObjectURL(url); } } + /** + * Turns the worker's raw measurements into a status + metrics. + * + * Div/add is the primary discriminator; sqrt/add is an independent-unit rescue + * for a fast-divider CPU; the absolute add cost is a one-way interpreter tell. + * When a fast ratio and an interpreter-slow add disagree we return UNCERTAIN + * rather than guess. + * + * @param msg - The {@link WorkerReply} from the benchmark worker. + * @returns The classified {@link WasmRuntimeResult}. + */ + private static classify(msg: WorkerReply): WasmRuntimeResult { + if ( + !msg.ok || + typeof msg.ops !== 'number' || + msg.ops <= 0 || + typeof msg.addMedianMs !== 'number' || + msg.addMedianMs <= 0 || + typeof msg.divMedianMs !== 'number' || + typeof msg.sqrtMedianMs !== 'number' + ) { + return this.buildResult(WasmRuntimeStatus.UNKNOWN); + } + + const { addMedianMs, divMedianMs, sqrtMedianMs } = msg; + /** + * Rounds a metric to 3 decimals for the report. + * + * @param v - The value to round. + * @returns The value rounded to 3 decimal places. + */ + const round = (v: number): number => Number(v.toFixed(3)); + + const divRatio = divMedianMs / addMedianMs; + const sqrtRatio = sqrtMedianMs / addMedianMs; + const addNsPerOp = (addMedianMs * 1e6) / msg.ops; + + // Ratios are hardware-INDEPENDENT; addNsPerOp is hardware-dependent. + const fastSignal = divRatio >= DIV_FAST_RATIO || sqrtRatio >= SQRT_FAST_RATIO; + const slowSignalRatio = divRatio <= DIV_SLOW_RATIO; + const interpAbs = addNsPerOp > INTERP_ADD_NS_FLOOR; + // A background-throttled tab can starve the worker; div is always multi-cycle, + // so a tiny div median means nothing measurable really executed. + const workRan = divMedianMs >= MIN_DIV_MEDIAN_MS; + const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; + + let status: WasmRuntimeStatus; + if (hidden || !workRan) { + status = WasmRuntimeStatus.UNKNOWN; + } else if (fastSignal && interpAbs) { + // Contradiction: a ratio looks native, but `add` is absolutely interpreter-slow. + // Happens on a throttled JIT machine OR a slow-divider interpreter — don't guess. + status = WasmRuntimeStatus.UNCERTAIN; + } else if (fastSignal) { + status = WasmRuntimeStatus.OK; + } else if (slowSignalRatio || interpAbs) { + status = WasmRuntimeStatus.SLOW; + } else { + status = WasmRuntimeStatus.UNCERTAIN; + } + + return this.buildResult(status, { + divRatio: round(divRatio), + sqrtRatio: round(sqrtRatio), + addNsPerOp: round(addNsPerOp), + addMedianMs: round(addMedianMs), + divMedianMs: round(divMedianMs), + sqrtMedianMs: round(sqrtMedianMs), + }); + } + /** * Starts the benchmark worker from the inline source (via a Blob URL). * diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js index d24ba9c..cb934a0 100644 --- a/src/wasm-runtime-probe.worker.js +++ b/src/wasm-runtime-probe.worker.js @@ -1,24 +1,44 @@ /* - * wasm-runtime-probe.worker.js — measures how long the same loop takes in WASM vs JS, off the main thread. + * wasm-runtime-probe.worker.js — hardware-power-INDEPENDENT WASM JIT probe. * - * Inlined as a string at build time and started from a Blob URL by wasm-runtime-probe.ts; - * kept as a real file so it stays readable. + * Inlined as a string at build time and started from a Blob URL by + * wasm-runtime-probe.ts; kept as a real file so it stays readable. * - * Protocol: main thread posts 'start'; replies { ok: true, wasmMs, jsMs } or { ok: false }. + * WHAT IT MEASURES + * ---------------- + * It times three dependent-chain WASM kernels on the SAME cpu, unrolled so the + * inner op dominates loop overhead. Each is latency-bound (every op depends on + * the previous accumulator), which is the only form an interpreter cannot hide: + * + * add : acc = acc + tmp (cheap — integer ALU) + * div : acc = acc / tmp (costly — INTEGER divider unit) + * sqrt : facc = sqrt(facc + tmp) (costly — FP SQRT unit, a different unit) + * + * The MAIN THREAD then compares them as ratios (div/add, sqrt/add). A ratio + * cancels out raw CPU speed, so it describes the ENGINE, not the machine: + * JIT ON -> real hardware op-costs show through -> ratios HIGH (div ~5-30x) + * JIT OFF -> per-op interpreter dispatch overhead swamps the cheap add, so + * both gaps collapse -> ratios LOW (~2x) + * Two different execution units (integer divider vs FP sqrt) mean a single + * hardware quirk (e.g. a very fast divider) can't fool both signals. + * + * This worker only MEASURES. It returns raw medians/mins so the main thread can + * classify and re-threshold per feature. Changing the kernels or op counts + * invalidates the calibrated thresholds in wasm-runtime-probe.ts. + * + * Protocol: main thread posts 'start'; replies + * { ok: true, ops, addMedianMs, divMedianMs, sqrtMedianMs, + * addMinMs, divMinMs, sqrtMinMs, checkAdd, checkDiv, checkSqrt } + * or { ok: false }. */ self.onmessage = function onProbeStart() { try { - // Standard LCG constants (Numerical Recipes): acc = acc * MULT + INC is a cheap - // arithmetic loop the JIT can't optimize away, so it's a fair CPU benchmark. The - // WASM and JS loops share them so both do identical work; changing them - // invalidates the calibrated threshold. - var LCG_MULT = 1664525; // multiplier - var LCG_INC = 1013904223; // increment - var ITERATIONS = 5000000; - var SAMPLE_RUNS = 7; // keep the median of this many runs + var UNROLL = 16; + var LOOPS = 1000000; + var OPS = LOOPS * UNROLL; // ~16M effective inner ops per timed run + var TRIALS = 5; - // Build a tiny WASM module in memory that exports bench(n) — nothing to fetch. - // Bytes use LEB128: encodeU32 for lengths/counts, encodeI32 for signed values. + // --- LEB128 + section helpers (build a tiny module in memory, nothing to fetch) --- var encodeU32 = function encodeU32(value) { var out = []; do { @@ -30,87 +50,128 @@ self.onmessage = function onProbeStart() { return out; }; - var encodeI32 = function encodeI32(value) { - var out = []; - var more = true; - while (more) { - var byte = value & 0x7f; - value >>= 7; - if ((value === 0 && !(byte & 0x40)) || (value === -1 && byte & 0x40)) { - more = false; - } else { - byte |= 0x80; - } - out.push(byte); - } - return out; - }; - // A section is one labelled block of the file: [id, length, ...bytes]. var section = function section(id, bytes) { return [id].concat(encodeU32(bytes.length)).concat(bytes); }; - var I32 = 0x7f; // WASM's code for the 32-bit integer type. - - var buildWasmLoopModule = function buildWasmLoopModule() { - var typeSec = section(1, encodeU32(1).concat([0x60, 0x01, I32, 0x01, I32])); // bench's type: takes one i32, returns one i32 - var funcSec = section(3, encodeU32(1).concat([0x00])); // function 0 uses signature 0 - var name = 'bench'.split('').map(function toCharCode(c) { + var toCharCodes = function toCharCodes(str) { + return str.split('').map(function charCode(c) { return c.charCodeAt(0); }); - var exportSec = section( - 7, - encodeU32(1).concat(encodeU32(name.length)).concat(name).concat([0x00, 0x00]) - ); // export the function as "bench" - // Function body — 2 locals (i, acc), then the loop: - // acc = acc * LCG_MULT + LCG_INC; i += 1; if (i < n) loop; return acc - var body = encodeU32(1) - .concat(encodeU32(2)) - .concat([I32, 0x03, 0x40, 0x20, 0x02, 0x41]) - .concat(encodeI32(LCG_MULT)) - .concat([0x6c, 0x41]) - .concat(encodeI32(LCG_INC)) - .concat([ - 0x6a, 0x21, 0x02, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00, - 0x0b, 0x20, 0x02, 0x0b, - ]); - var codeSec = section(10, encodeU32(1).concat(encodeU32(body.length)).concat(body)); - // "\0asm" + version 1 + the four sections - return new Uint8Array( - [0, 0x61, 0x73, 0x6d, 1, 0, 0, 0].concat(typeSec, funcSec, exportSec, codeSec) - ); }; - // Median of several runs, so one slow run (e.g. a background hiccup) is ignored. - var medianRuntimeMs = function medianRuntimeMs(loopFn) { - loopFn(ITERATIONS); // warm-up run (not timed) - var samples = []; - for (var k = 0; k < SAMPLE_RUNS; k++) { - var start = performance.now(); - loopFn(ITERATIONS); - samples.push(performance.now() - start); + // One export entry: name, then 0x00 (function kind) and the function index. + var exportEntry = function exportEntry(name, funcIndex) { + var chars = toCharCodes(name); + return encodeU32(chars.length).concat(chars).concat([0x00, funcIndex]); + }; + + var I32 = 0x7f; // WASM's code for the 32-bit integer type. + var F64 = 0x7c; // WASM's code for the 64-bit float type. + + // Two signatures: T0 (i32)->i32 for add/div, T1 (i32)->f64 for sqrt. + var typeSec = section( + 1, + encodeU32(2) + .concat([0x60, 0x01, I32, 0x01, I32]) + .concat([0x60, 0x01, I32, 0x01, F64]) + ); + var funcSec = section(3, encodeU32(3).concat([0x00, 0x00, 0x01])); // add:T0 div:T0 sqrt:T1 + var exportSec = section( + 7, + encodeU32(3) + .concat(exportEntry('add', 0)) + .concat(exportEntry('div', 1)) + .concat(exportEntry('sqrt', 2)) + ); + + // --- integer kernel (add/div): identical scaffold, ONE opcode differs --- + // locals (beyond param0 = n): i(1), acc(2), tmp(3), all i32. + var buildIntBody = function buildIntBody(opcode) { + var body = [1, 3, I32].concat([0x03, 0x40]); // 3 i32 locals; loop (void) + body = body.concat([0x20, 0x01, 0x41, 0x01, 0x72, 0x21, 0x03]); // tmp = (i | 1) + for (var k = 0; k < UNROLL; k++) { + body = body.concat([0x20, 0x02, 0x20, 0x03, opcode, 0x21, 0x02]); // acc = acc OP tmp } - samples.sort(function ascending(a, b) { - return a - b; - }); - return samples[Math.floor(samples.length / 2)]; + // i += 1; if (i < n) continue loop + body = body.concat([0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00]); + body = body.concat([0x0b, 0x20, 0x02, 0x0b]); // end loop; return acc; end func + return encodeU32(body.length).concat(body); }; + var addCode = buildIntBody(0x6a); // i32.add + var divCode = buildIntBody(0x6e); // i32.div_u (dependent latency chain) - var runWasmLoop = new WebAssembly.Instance( - new WebAssembly.Module(buildWasmLoopModule()) - ).exports.bench; - var runJsLoop = function runJsLoop(n) { - var acc = 0; - for (var k = 0; k < n; k++) { - acc = (Math.imul(acc, LCG_MULT) + LCG_INC) | 0; + // --- FP sqrt kernel: dependent chain on the FP sqrt unit --- + // locals (beyond param0 = n): i(1) i32, facc(2) f64. + var buildSqrtBody = function buildSqrtBody() { + var body = [2, 1, I32, 1, F64].concat([0x03, 0x40]); // locals i(i32), facc(f64); loop (void) + for (var k = 0; k < UNROLL; k++) { + // facc = sqrt(facc + f64(i | 1)) + body = body.concat([0x20, 0x02, 0x20, 0x01, 0x41, 0x01, 0x72, 0xb8, 0xa0, 0x9f, 0x21, 0x02]); } - return acc; + body = body.concat([0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00]); + body = body.concat([0x0b, 0x20, 0x02, 0x0b]); // end loop; return facc; end func + return encodeU32(body.length).concat(body); + }; + var sqrtCode = buildSqrtBody(); + + var codeSec = section(10, encodeU32(3).concat(addCode).concat(divCode).concat(sqrtCode)); + var bytes = new Uint8Array( + [0, 0x61, 0x73, 0x6d, 1, 0, 0, 0].concat(typeSec, funcSec, exportSec, codeSec) + ); + var exports = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; + + var stats = function stats(samples) { + var sorted = samples.slice().sort(function ascending(a, b) { + return a - b; + }); + return { median: sorted[Math.floor(sorted.length / 2)], min: sorted[0] }; }; - var wasmMs = medianRuntimeMs(runWasmLoop); - var jsMs = medianRuntimeMs(runJsLoop); - self.postMessage({ ok: true, wasmMs: wasmMs, jsMs: jsMs }); + // Warm up / tier up all three so the JIT (if on) has compiled before timing. + exports.add(200000); + exports.div(200000); + exports.sqrt(200000); + exports.add(200000); + exports.div(200000); + exports.sqrt(200000); + + // Interleaved trials: contention in any round hits all three equally, so the + // per-kernel median stays comparable and the ratios survive a load spike. + var addMs = []; + var divMs = []; + var sqrtMs = []; + for (var t = 0; t < TRIALS; t++) { + var a0 = performance.now(); + exports.add(LOOPS); + addMs.push(performance.now() - a0); + var d0 = performance.now(); + exports.div(LOOPS); + divMs.push(performance.now() - d0); + var s0 = performance.now(); + exports.sqrt(LOOPS); + sqrtMs.push(performance.now() - s0); + } + + var addStat = stats(addMs); + var divStat = stats(divMs); + var sqrtStat = stats(sqrtMs); + + self.postMessage({ + ok: true, + ops: OPS, + addMedianMs: addStat.median, + divMedianMs: divStat.median, + sqrtMedianMs: sqrtStat.median, + addMinMs: addStat.min, + divMinMs: divStat.min, + sqrtMinMs: sqrtStat.min, + // Cheap correctness canaries (the kernels actually ran and returned a value). + checkAdd: exports.add(3), + checkDiv: exports.div(3), + checkSqrt: exports.sqrt(3), + }); } catch (err) { self.postMessage({ ok: false }); } From 4269b89302eee50e7f17efe5d96b3bb95fb06dcc Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 15:52:14 +0200 Subject: [PATCH 09/17] feat: update wasm runtime probe --- src/wasm-runtime-probe.spec.ts | 6 ------ src/wasm-runtime-probe.ts | 6 ------ src/wasm-runtime-probe.worker.js | 31 ++++++++++++------------------- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index 692ac1a..e3c5c0d 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -7,12 +7,6 @@ interface FakeReply { addMedianMs?: number; divMedianMs?: number; sqrtMedianMs?: number; - addMinMs?: number; - divMinMs?: number; - sqrtMinMs?: number; - checkAdd?: number; - checkDiv?: number; - checkSqrt?: number; } const OPS = 16_000_000; diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index 32ed54d..7a716fd 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -88,12 +88,6 @@ interface WorkerReply { addMedianMs?: number; divMedianMs?: number; sqrtMedianMs?: number; - addMinMs?: number; - divMinMs?: number; - sqrtMinMs?: number; - checkAdd?: number; - checkDiv?: number; - checkSqrt?: number; } /** diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js index cb934a0..2d8c5a2 100644 --- a/src/wasm-runtime-probe.worker.js +++ b/src/wasm-runtime-probe.worker.js @@ -22,13 +22,16 @@ * Two different execution units (integer divider vs FP sqrt) mean a single * hardware quirk (e.g. a very fast divider) can't fool both signals. * - * This worker only MEASURES. It returns raw medians/mins so the main thread can + * The direction reads backwards on purpose: a HIGH ratio is healthy, a LOW one + * (~2) is the interpreter. Don't invert it — see EDGE-BNR-CASE-CONTEXT.md + * §3.3/§4.1 for the measurements behind this. + * + * This worker only MEASURES. It returns raw medians so the main thread can * classify and re-threshold per feature. Changing the kernels or op counts * invalidates the calibrated thresholds in wasm-runtime-probe.ts. * * Protocol: main thread posts 'start'; replies - * { ok: true, ops, addMedianMs, divMedianMs, sqrtMedianMs, - * addMinMs, divMinMs, sqrtMinMs, checkAdd, checkDiv, checkSqrt } + * { ok: true, ops, addMedianMs, divMedianMs, sqrtMedianMs } * or { ok: false }. */ self.onmessage = function onProbeStart() { @@ -122,11 +125,12 @@ self.onmessage = function onProbeStart() { ); var exports = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; - var stats = function stats(samples) { + // Median, not mean: one scheduler hiccup in a trial must not move the result. + var median = function median(samples) { var sorted = samples.slice().sort(function ascending(a, b) { return a - b; }); - return { median: sorted[Math.floor(sorted.length / 2)], min: sorted[0] }; + return sorted[Math.floor(sorted.length / 2)]; }; // Warm up / tier up all three so the JIT (if on) has compiled before timing. @@ -154,23 +158,12 @@ self.onmessage = function onProbeStart() { sqrtMs.push(performance.now() - s0); } - var addStat = stats(addMs); - var divStat = stats(divMs); - var sqrtStat = stats(sqrtMs); - self.postMessage({ ok: true, ops: OPS, - addMedianMs: addStat.median, - divMedianMs: divStat.median, - sqrtMedianMs: sqrtStat.median, - addMinMs: addStat.min, - divMinMs: divStat.min, - sqrtMinMs: sqrtStat.min, - // Cheap correctness canaries (the kernels actually ran and returned a value). - checkAdd: exports.add(3), - checkDiv: exports.div(3), - checkSqrt: exports.sqrt(3), + addMedianMs: median(addMs), + divMedianMs: median(divMs), + sqrtMedianMs: median(sqrtMs), }); } catch (err) { self.postMessage({ ok: false }); From d14545ced6126133abbabdab6da6da70b88c858e Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 15:55:32 +0200 Subject: [PATCH 10/17] chore: update comments --- src/wasm-runtime-probe.spec.ts | 29 ++----- src/wasm-runtime-probe.ts | 143 +++++++++++-------------------- src/wasm-runtime-probe.worker.js | 78 +++++------------ 3 files changed, 81 insertions(+), 169 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index e3c5c0d..633ebb9 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -11,8 +11,8 @@ interface FakeReply { const OPS = 16_000_000; -// Realistic measured signatures (see wasm-runtime-probe calibration matrix). -// JIT ON (Mac Chrome): add 6.5ms, div 35.6, sqrt 80.6 -> divRatio 5.5, addNs 0.41. +// Lab-shaped worker replies (calibration matrix). +// JIT on (Mac Chrome): add 6.5, div 35.6, sqrt 80.6. const FAST_REPLY: FakeReply = { ok: true, ops: OPS, @@ -20,7 +20,7 @@ const FAST_REPLY: FakeReply = { divMedianMs: 35.6, sqrtMedianMs: 80.6, }; -// JIT OFF (Edge, JIT disabled): add 32.1, div 60.6, sqrt 111.5 -> divRatio 1.9, addNs 2.0. +// JIT off (Edge): add 32.1, div 60.6, sqrt 111.5. const SLOW_REPLY: FakeReply = { ok: true, ops: OPS, @@ -29,38 +29,28 @@ const SLOW_REPLY: FakeReply = { sqrtMedianMs: 111.5, }; -// Shared state that controls how the mock Worker behaves in the current test. let workerReply: FakeReply | undefined; let workerConstructCount = 0; -/** - * Fake Worker for jsdom, which has no real one. On postMessage it replies immediately with whatever - * {@link workerReply} the test set, or stays silent so we can test the timeout path. - */ +/** Stand-in Worker for jsdom; uses {@link workerReply}. */ class MockWorker { onmessage: ((event: { data: FakeReply }) => void) | null = null; onerror: (() => void) | null = null; - /** - * Counts how many workers were created, so the caching test can check it. - */ + /** Tracks how many workers tests constructed. */ constructor() { workerConstructCount += 1; } - /** - * Sends the configured reply back to the probe, or nothing if none is set. - */ + /** Posts {@link workerReply} to {@link MockWorker.onmessage} when configured. */ postMessage(): void { if (workerReply && this.onmessage) { this.onmessage({ data: workerReply }); } } - /** - * Does nothing; just matches the real Worker API. - */ + /** No-op for API parity. */ // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-empty-function terminate(): void {} } @@ -69,7 +59,6 @@ describe('WasmRuntimeProbe', () => { const originalWebAssembly = globalThis.WebAssembly; beforeEach(() => { - // Clear the per-page cache so each test starts fresh (private, reached via a cast). (WasmRuntimeProbe as unknown as { cachedResult?: unknown }).cachedResult = undefined; workerReply = undefined; workerConstructCount = 0; @@ -192,7 +181,7 @@ describe('WasmRuntimeProbe', () => { it('should return UNKNOWN when a measurement field is missing', async () => { expect.assertions(1); - workerReply = { ok: true, ops: OPS, addMedianMs: 6.5 }; // no div/sqrt + workerReply = { ok: true, ops: OPS, addMedianMs: 6.5 }; // missing div/sqrt const result = await WasmRuntimeProbe.check(); @@ -211,7 +200,7 @@ describe('WasmRuntimeProbe', () => { it('should return UNKNOWN when the worker does not reply before the timeout', async () => { expect.assertions(1); jest.useFakeTimers(); - workerReply = undefined; // never replies + workerReply = undefined; const promise = WasmRuntimeProbe.check(); jest.advanceTimersByTime(8000); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index 7a716fd..52926c5 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -1,71 +1,52 @@ import { CapabilityState, WebCapabilities } from './web-capabilities'; import WORKER_SRC from './wasm-runtime-probe.worker'; -/** Possible results of the WASM runtime probe. */ +/** Outcome of {@link WasmRuntimeProbe.check}. */ export enum WasmRuntimeStatus { - /** Engine runs WASM at full JIT (native) speed. */ + /** WASM runs at full JIT speed. */ OK = 'ok', - /** Engine interprets WASM (e.g. Edge with JIT disabled by policy) — too slow for real-time effects. */ + /** WASM runs through a slow interpreter (too slow for real-time effects). */ SLOW = 'slow', - /** WASM is missing or refuses to compile. */ + /** WASM missing or will not compile. */ DISABLED = 'disabled', - /** Signals conflicted (a throttled-but-healthy JIT, or a slow-divider interpreter) — the probe won't guess. */ + /** Measurements disagree; do not treat as a confident slow or fast. */ UNCERTAIN = 'uncertain', - /** The probe could not run or measure (no Worker, background tab, timeout, error). */ + /** Probe could not run (no Worker, timeout, background tab, bad sample). */ UNKNOWN = 'unknown', } /** - * Result of the WASM runtime probe. Used to decide whether to allow real-time - * WASM effects (BNR, VBG), which run poorly when the browser runs WASM through a - * slow interpreter. - * - * The probe is a pure SENSOR: it emits a status plus the raw, hardware-independent - * metrics so callers can re-threshold per feature without re-running it. + * Probe result for real-time WASM effects (BNR, VBG). Includes raw metrics so + * callers can change thresholds without re-running the benchmark. */ export interface WasmRuntimeResult { status: WasmRuntimeStatus; capability: CapabilityState; - /** Div/add op-cost ratio — the primary discriminator (JIT high, interpreter ~2). */ + /** Divide median divided by add median (main JIT vs interpreter signal). */ divRatio: number | null; - /** Sqrt/add op-cost ratio — a second, independent-unit signal (fast-divider rescue). */ + /** Sqrt median divided by add median (second unit, helps some CPUs). */ sqrtRatio: number | null; - /** Absolute cost of the cheap `add` op in ns/op (hardware-DEPENDENT; interpreter tell). */ + /** Nanoseconds per add op; scales with clock, used as a slow hint only. */ addNsPerOp: number | null; - /** Median time (ms) of the cheap `add` kernel. */ addMedianMs: number | null; - /** Median time (ms) of the costly integer `div` kernel. */ divMedianMs: number | null; - /** Median time (ms) of the costly FP `sqrt` kernel. */ sqrtMedianMs: number | null; } -// --- Calibrated thresholds (Intel Mac + Windows, 4 engines, JIT on/off, throttled) --- -// div/add is the PRIMARY discriminator (JIT 5.4-33 vs interpreter 1.8-2.4). -const DIV_FAST_RATIO = 4.0; // div/add >= this => native code -const DIV_SLOW_RATIO = 3.0; // div/add <= this => interpreter -// sqrt/add is a SECONDARY rescue for a fast-divider CPU (e.g. Apple Silicon). It -// runs ~2x higher than div on BOTH sides (JIT 12-20 vs interp 3.4-4.5), so it -// needs its OWN, higher bar — reusing div's 4.0 here caused a false 'fast'. -const SQRT_FAST_RATIO = 8.0; // sqrt/add >= this => native code (div may have sagged) -// Absolute add cost floor (ns/op). JIT 0.06-0.48 vs interpreter 2.0-2.5. This -// signal is hardware-DEPENDENT (scales with clock), so it only ever ADDS a slow -// vote or flags a contradiction — it never overrides a fast ratio. +// Lab calibration: Intel Mac + Windows, four engines, JIT on/off, throttled runs. +const DIV_FAST_RATIO = 4.0; +const DIV_SLOW_RATIO = 3.0; +// Sqrt/add runs higher than div/add on both JIT and interpreter; needs its own bar. +const SQRT_FAST_RATIO = 8.0; +// Interpreter adds are absolutely slower; never overrides a fast ratio alone. const INTERP_ADD_NS_FLOOR = 1.2; -// The integer div kernel is always multi-cycle; if its median is below this the -// loop didn't really run (timer floor / hostile embedder) — treat as unmeasured. +// Div is multi-cycle; below this median the timed loop likely did not really run. const MIN_DIV_MEDIAN_MS = 8; - -// The op-cost probe runs ~16M ops x 5 trials; a heavily throttled interpreter -// measured ~3.4s, so allow generous headroom. +// ~16M ops x 5 trials; throttled interpreter needed ~3.4s in lab. const WORKER_TIMEOUT_MS = 8000; /** - * Maps a probe status to a CAPABLE/NOT_CAPABLE verdict. - * - * Only a confident SLOW/DISABLED blocks WASM effects. UNCERTAIN maps to UNKNOWN - * so the feature keeps its own default (enable) rather than false-blocking a - * real user whose signals merely conflicted. + * Maps probe status to capability. UNCERTAIN stays UNKNOWN so we do not false-block. * * @param status - The probe {@link WasmRuntimeStatus}. * @returns The corresponding {@link CapabilityState}. @@ -91,23 +72,16 @@ interface WorkerReply { } /** - * Checks whether this browser runs WebAssembly at full (JIT) speed or through a - * slow interpreter, by comparing the cost of cheap vs expensive WASM ops. This - * catches the case where WASM is present but too slow for real-time effects - * (e.g. Edge with JIT turned off). The quick "disabled" check is instant; the - * timed benchmark runs off the main thread. The result is cached, so it runs at - * most once per page. + * Detects whether WASM runs at JIT speed or through a slow interpreter (for example + * Edge with JIT disabled by policy). Uses a quick disabled check, then a Worker + * benchmark. Result is cached for the page lifetime. */ export class WasmRuntimeProbe { private static cachedResult?: Promise; /** - * Runs the probe (cached per page) and resolves with the classified result. - * - * Times three dependent-chain WASM kernels (add / div / sqrt) off the main - * thread and compares them as ratios (div/add, sqrt/add). A ratio cancels out - * raw CPU speed, so it describes the engine, not the machine: under a slow - * interpreter both ratios collapse to ~2 and the probe reports + * Runs the probe once per page (cached). Compares WASM op-cost ratios from a Worker; + * under a slow interpreter both div/add and sqrt/add collapse near ~2 and status is * {@link WasmRuntimeStatus.SLOW}. * * @returns A promise that resolves with the {@link WasmRuntimeResult}. @@ -120,17 +94,17 @@ export class WasmRuntimeProbe { } /** - * Builds a {@link WasmRuntimeResult} from a status and optional raw metrics. + * Assembles a {@link WasmRuntimeResult} from status and optional worker metrics. * - * @param status - The classified {@link WasmRuntimeStatus}. - * @param metrics - Optional raw op-cost metrics to include. - * @param metrics.divRatio - The div/add op-cost ratio. - * @param metrics.sqrtRatio - The sqrt/add op-cost ratio. - * @param metrics.addNsPerOp - The absolute cost of `add` in ns/op. - * @param metrics.addMedianMs - Median time of the `add` kernel. - * @param metrics.divMedianMs - Median time of the `div` kernel. - * @param metrics.sqrtMedianMs - Median time of the `sqrt` kernel. - * @returns The assembled {@link WasmRuntimeResult}. + * @param status - Classified status. + * @param metrics - Optional raw timings from the worker. + * @param metrics.divRatio - Divide median divided by add median. + * @param metrics.sqrtRatio - Sqrt median divided by add median. + * @param metrics.addNsPerOp - Nanoseconds per add op. + * @param metrics.addMedianMs - Median add kernel time in ms. + * @param metrics.divMedianMs - Median div kernel time in ms. + * @param metrics.sqrtMedianMs - Median sqrt kernel time in ms. + * @returns Assembled {@link WasmRuntimeResult}. */ private static buildResult( status: WasmRuntimeStatus, @@ -156,16 +130,15 @@ export class WasmRuntimeProbe { } /** - * Runs the checks in order: the instant "disabled" check, then the timed Worker benchmark. + * Disabled check, then Worker benchmark with timeout and cleanup. * - * @returns A promise that resolves with the {@link WasmRuntimeResult}. + * @returns Classified probe result. */ private static async run(): Promise { if (WebCapabilities.supportsWasm() === CapabilityState.NOT_CAPABLE) { return this.buildResult(WasmRuntimeStatus.DISABLED); } - // Can't run the benchmark without a Web Worker and a Blob URL. if ( WebCapabilities.supportsWorker() === CapabilityState.NOT_CAPABLE || typeof URL === 'undefined' || @@ -205,15 +178,11 @@ export class WasmRuntimeProbe { } /** - * Turns the worker's raw measurements into a status + metrics. - * - * Div/add is the primary discriminator; sqrt/add is an independent-unit rescue - * for a fast-divider CPU; the absolute add cost is a one-way interpreter tell. - * When a fast ratio and an interpreter-slow add disagree we return UNCERTAIN - * rather than guess. + * Classifies worker medians. Fast div or sqrt ratio wins unless absolute add cost + * contradicts it ({@link WasmRuntimeStatus.UNCERTAIN}). * - * @param msg - The {@link WorkerReply} from the benchmark worker. - * @returns The classified {@link WasmRuntimeResult}. + * @param msg - Raw worker reply. + * @returns Classified result with rounded metrics. */ private static classify(msg: WorkerReply): WasmRuntimeResult { if ( @@ -229,24 +198,16 @@ export class WasmRuntimeProbe { } const { addMedianMs, divMedianMs, sqrtMedianMs } = msg; - /** - * Rounds a metric to 3 decimals for the report. - * - * @param v - The value to round. - * @returns The value rounded to 3 decimal places. - */ - const round = (v: number): number => Number(v.toFixed(3)); + // eslint-disable-next-line jsdoc/require-jsdoc + const round3 = (v: number): number => Number(v.toFixed(3)); const divRatio = divMedianMs / addMedianMs; const sqrtRatio = sqrtMedianMs / addMedianMs; const addNsPerOp = (addMedianMs * 1e6) / msg.ops; - // Ratios are hardware-INDEPENDENT; addNsPerOp is hardware-dependent. const fastSignal = divRatio >= DIV_FAST_RATIO || sqrtRatio >= SQRT_FAST_RATIO; const slowSignalRatio = divRatio <= DIV_SLOW_RATIO; const interpAbs = addNsPerOp > INTERP_ADD_NS_FLOOR; - // A background-throttled tab can starve the worker; div is always multi-cycle, - // so a tiny div median means nothing measurable really executed. const workRan = divMedianMs >= MIN_DIV_MEDIAN_MS; const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; @@ -254,8 +215,6 @@ export class WasmRuntimeProbe { if (hidden || !workRan) { status = WasmRuntimeStatus.UNKNOWN; } else if (fastSignal && interpAbs) { - // Contradiction: a ratio looks native, but `add` is absolutely interpreter-slow. - // Happens on a throttled JIT machine OR a slow-divider interpreter — don't guess. status = WasmRuntimeStatus.UNCERTAIN; } else if (fastSignal) { status = WasmRuntimeStatus.OK; @@ -266,19 +225,19 @@ export class WasmRuntimeProbe { } return this.buildResult(status, { - divRatio: round(divRatio), - sqrtRatio: round(sqrtRatio), - addNsPerOp: round(addNsPerOp), - addMedianMs: round(addMedianMs), - divMedianMs: round(divMedianMs), - sqrtMedianMs: round(sqrtMedianMs), + divRatio: round3(divRatio), + sqrtRatio: round3(sqrtRatio), + addNsPerOp: round3(addNsPerOp), + addMedianMs: round3(addMedianMs), + divMedianMs: round3(divMedianMs), + sqrtMedianMs: round3(sqrtMedianMs), }); } /** - * Starts the benchmark worker from the inline source (via a Blob URL). + * Creates a Worker from the inlined benchmark source. * - * @returns The worker and its Blob URL, or undefined if creation fails. + * @returns Worker plus Blob URL, or undefined if creation fails. */ private static startWorker(): { worker: Worker; url: string } | undefined { let url: string | undefined; diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js index 2d8c5a2..9330db8 100644 --- a/src/wasm-runtime-probe.worker.js +++ b/src/wasm-runtime-probe.worker.js @@ -1,47 +1,21 @@ /* - * wasm-runtime-probe.worker.js — hardware-power-INDEPENDENT WASM JIT probe. + * WASM runtime benchmark worker. Inlined into wasm-runtime-probe.ts via Blob URL. * - * Inlined as a string at build time and started from a Blob URL by - * wasm-runtime-probe.ts; kept as a real file so it stays readable. + * Times dependent-chain add / div / sqrt kernels; main thread uses div/add and + * sqrt/add ratios so CPU clock mostly cancels out. * - * WHAT IT MEASURES - * ---------------- - * It times three dependent-chain WASM kernels on the SAME cpu, unrolled so the - * inner op dominates loop overhead. Each is latency-bound (every op depends on - * the previous accumulator), which is the only form an interpreter cannot hide: + * Counterintuitive: HIGH ratio means JIT OK, LOW (~2) means interpreted WASM. + * Thresholds are in wasm-runtime-probe.ts — do not invert ratios when classifying. * - * add : acc = acc + tmp (cheap — integer ALU) - * div : acc = acc / tmp (costly — INTEGER divider unit) - * sqrt : facc = sqrt(facc + tmp) (costly — FP SQRT unit, a different unit) - * - * The MAIN THREAD then compares them as ratios (div/add, sqrt/add). A ratio - * cancels out raw CPU speed, so it describes the ENGINE, not the machine: - * JIT ON -> real hardware op-costs show through -> ratios HIGH (div ~5-30x) - * JIT OFF -> per-op interpreter dispatch overhead swamps the cheap add, so - * both gaps collapse -> ratios LOW (~2x) - * Two different execution units (integer divider vs FP sqrt) mean a single - * hardware quirk (e.g. a very fast divider) can't fool both signals. - * - * The direction reads backwards on purpose: a HIGH ratio is healthy, a LOW one - * (~2) is the interpreter. Don't invert it — see EDGE-BNR-CASE-CONTEXT.md - * §3.3/§4.1 for the measurements behind this. - * - * This worker only MEASURES. It returns raw medians so the main thread can - * classify and re-threshold per feature. Changing the kernels or op counts - * invalidates the calibrated thresholds in wasm-runtime-probe.ts. - * - * Protocol: main thread posts 'start'; replies - * { ok: true, ops, addMedianMs, divMedianMs, sqrtMedianMs } - * or { ok: false }. + * postMessage('start') -> { ok, ops, addMedianMs, divMedianMs, sqrtMedianMs } or { ok: false }. */ self.onmessage = function onProbeStart() { try { var UNROLL = 16; var LOOPS = 1000000; - var OPS = LOOPS * UNROLL; // ~16M effective inner ops per timed run + var OPS = LOOPS * UNROLL; var TRIALS = 5; - // --- LEB128 + section helpers (build a tiny module in memory, nothing to fetch) --- var encodeU32 = function encodeU32(value) { var out = []; do { @@ -53,7 +27,6 @@ self.onmessage = function onProbeStart() { return out; }; - // A section is one labelled block of the file: [id, length, ...bytes]. var section = function section(id, bytes) { return [id].concat(encodeU32(bytes.length)).concat(bytes); }; @@ -64,23 +37,21 @@ self.onmessage = function onProbeStart() { }); }; - // One export entry: name, then 0x00 (function kind) and the function index. var exportEntry = function exportEntry(name, funcIndex) { var chars = toCharCodes(name); return encodeU32(chars.length).concat(chars).concat([0x00, funcIndex]); }; - var I32 = 0x7f; // WASM's code for the 32-bit integer type. - var F64 = 0x7c; // WASM's code for the 64-bit float type. + var I32 = 0x7f; + var F64 = 0x7c; - // Two signatures: T0 (i32)->i32 for add/div, T1 (i32)->f64 for sqrt. var typeSec = section( 1, encodeU32(2) .concat([0x60, 0x01, I32, 0x01, I32]) .concat([0x60, 0x01, I32, 0x01, F64]) ); - var funcSec = section(3, encodeU32(3).concat([0x00, 0x00, 0x01])); // add:T0 div:T0 sqrt:T1 + var funcSec = section(3, encodeU32(3).concat([0x00, 0x00, 0x01])); var exportSec = section( 7, encodeU32(3) @@ -89,32 +60,26 @@ self.onmessage = function onProbeStart() { .concat(exportEntry('sqrt', 2)) ); - // --- integer kernel (add/div): identical scaffold, ONE opcode differs --- - // locals (beyond param0 = n): i(1), acc(2), tmp(3), all i32. var buildIntBody = function buildIntBody(opcode) { - var body = [1, 3, I32].concat([0x03, 0x40]); // 3 i32 locals; loop (void) - body = body.concat([0x20, 0x01, 0x41, 0x01, 0x72, 0x21, 0x03]); // tmp = (i | 1) + var body = [1, 3, I32].concat([0x03, 0x40]); + body = body.concat([0x20, 0x01, 0x41, 0x01, 0x72, 0x21, 0x03]); for (var k = 0; k < UNROLL; k++) { - body = body.concat([0x20, 0x02, 0x20, 0x03, opcode, 0x21, 0x02]); // acc = acc OP tmp + body = body.concat([0x20, 0x02, 0x20, 0x03, opcode, 0x21, 0x02]); } - // i += 1; if (i < n) continue loop body = body.concat([0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00]); - body = body.concat([0x0b, 0x20, 0x02, 0x0b]); // end loop; return acc; end func + body = body.concat([0x0b, 0x20, 0x02, 0x0b]); return encodeU32(body.length).concat(body); }; - var addCode = buildIntBody(0x6a); // i32.add - var divCode = buildIntBody(0x6e); // i32.div_u (dependent latency chain) + var addCode = buildIntBody(0x6a); + var divCode = buildIntBody(0x6e); - // --- FP sqrt kernel: dependent chain on the FP sqrt unit --- - // locals (beyond param0 = n): i(1) i32, facc(2) f64. var buildSqrtBody = function buildSqrtBody() { - var body = [2, 1, I32, 1, F64].concat([0x03, 0x40]); // locals i(i32), facc(f64); loop (void) + var body = [2, 1, I32, 1, F64].concat([0x03, 0x40]); for (var k = 0; k < UNROLL; k++) { - // facc = sqrt(facc + f64(i | 1)) body = body.concat([0x20, 0x02, 0x20, 0x01, 0x41, 0x01, 0x72, 0xb8, 0xa0, 0x9f, 0x21, 0x02]); } body = body.concat([0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00]); - body = body.concat([0x0b, 0x20, 0x02, 0x0b]); // end loop; return facc; end func + body = body.concat([0x0b, 0x20, 0x02, 0x0b]); return encodeU32(body.length).concat(body); }; var sqrtCode = buildSqrtBody(); @@ -125,7 +90,7 @@ self.onmessage = function onProbeStart() { ); var exports = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; - // Median, not mean: one scheduler hiccup in a trial must not move the result. + // Median dampens one bad scheduler slice in a trial. var median = function median(samples) { var sorted = samples.slice().sort(function ascending(a, b) { return a - b; @@ -133,7 +98,7 @@ self.onmessage = function onProbeStart() { return sorted[Math.floor(sorted.length / 2)]; }; - // Warm up / tier up all three so the JIT (if on) has compiled before timing. + // Tier-up before timing so JIT-on engines are measured compiled, not cold. exports.add(200000); exports.div(200000); exports.sqrt(200000); @@ -141,8 +106,7 @@ self.onmessage = function onProbeStart() { exports.div(200000); exports.sqrt(200000); - // Interleaved trials: contention in any round hits all three equally, so the - // per-kernel median stays comparable and the ratios survive a load spike. + // Round-robin trials so load spikes affect all three kernels equally. var addMs = []; var divMs = []; var sqrtMs = []; From 9d9a79c80d0182074855a54ea05b7d357e5fa556 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 16:25:44 +0200 Subject: [PATCH 11/17] chore: update with wasm error codes data --- src/wasm-runtime-probe.spec.ts | 60 +++++--- src/wasm-runtime-probe.ts | 242 +++++++++++++++++++++++++------ src/wasm-runtime-probe.worker.js | 10 +- 3 files changed, 245 insertions(+), 67 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index 633ebb9..b063e4e 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -1,4 +1,9 @@ -import { WasmRuntimeProbe, WasmRuntimeStatus } from './wasm-runtime-probe'; +import { + WasmRuntimeProbe, + WasmRuntimeStatus, + WasmRuntimeUncertainReason, + WasmRuntimeUnknownReason, +} from './wasm-runtime-probe'; import { CapabilityState } from './web-capabilities'; interface FakeReply { @@ -11,8 +16,7 @@ interface FakeReply { const OPS = 16_000_000; -// Lab-shaped worker replies (calibration matrix). -// JIT on (Mac Chrome): add 6.5, div 35.6, sqrt 80.6. +// Calibration fixtures for classify() (fast JIT-shaped vs slow interpreter-shaped medians). const FAST_REPLY: FakeReply = { ok: true, ops: OPS, @@ -20,7 +24,7 @@ const FAST_REPLY: FakeReply = { divMedianMs: 35.6, sqrtMedianMs: 80.6, }; -// JIT off (Edge): add 32.1, div 60.6, sqrt 111.5. +// Slow interpreter-shaped medians for the same op count. const SLOW_REPLY: FakeReply = { ok: true, ops: OPS, @@ -69,25 +73,29 @@ describe('WasmRuntimeProbe', () => { }); it('should return DISABLED when WebAssembly is hard-disabled', async () => { - expect.assertions(5); + expect.assertions(8); delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.DISABLED); expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); + expect(result.unknownReason).toBeNull(); + expect(result.uncertainReason).toBeNull(); + expect(result.uncertainDetail).toBeNull(); expect(result.divRatio).toBeNull(); expect(result.addNsPerOp).toBeNull(); expect(result.divMedianMs).toBeNull(); }); it('should return UNKNOWN when Web Workers are not available', async () => { - expect.assertions(2); + expect.assertions(3); const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); expect(result.capability).toBe(CapabilityState.UNKNOWN); + expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_UNAVAILABLE); }); describe('worker benchmark', () => { @@ -116,19 +124,21 @@ describe('WasmRuntimeProbe', () => { }); it('should return OK when the op-cost ratios show native (JIT) speed', async () => { - expect.assertions(4); + expect.assertions(6); workerReply = FAST_REPLY; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.OK); expect(result.capability).toBe(CapabilityState.CAPABLE); + expect(result.unknownReason).toBeNull(); + expect(result.uncertainReason).toBeNull(); expect(result.divRatio).toBeCloseTo(5.48, 1); expect(result.addNsPerOp).toBeCloseTo(0.406, 2); }); it('should return SLOW when the div/add ratio collapses (interpreter)', async () => { - expect.assertions(4); + expect.assertions(5); workerReply = SLOW_REPLY; const result = await WasmRuntimeProbe.check(); @@ -137,68 +147,79 @@ describe('WasmRuntimeProbe', () => { expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); expect(result.divRatio).toBeCloseTo(1.888, 1); expect(result.addNsPerOp).toBeGreaterThan(1.2); + expect(result.uncertainReason).toBeNull(); }); it('should return UNCERTAIN when a fast ratio contradicts an interpreter-slow add', async () => { - expect.assertions(2); - // divRatio 5 (looks native) but addNs 2.5 (interpreter-slow) -> contradiction. + expect.assertions(5); + // Fast div ratio but interpreter-slow addNs. Expect UNCERTAIN. workerReply = { ok: true, ops: OPS, addMedianMs: 40, divMedianMs: 200, sqrtMedianMs: 480 }; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); expect(result.capability).toBe(CapabilityState.UNKNOWN); + expect(result.uncertainReason).toBe(WasmRuntimeUncertainReason.CONFLICTING_SIGNALS); + expect(result.uncertainDetail).toContain( + 'Different parts of the measurement do not point to the same outcome.' + ); + expect(result.uncertainDetail).toMatch(/divRatio=5, sqrtRatio=12, addNsPerOp=2\.5/); }); it('should return UNCERTAIN when the ratios fall between the fast and slow bars', async () => { - expect.assertions(1); - // divRatio 3.5 (between 3 and 4), sqrtRatio 7 (< 8), addNs 0.625 (< floor). + expect.assertions(3); + // Ratios between fast and slow thresholds. workerReply = { ok: true, ops: OPS, addMedianMs: 10, divMedianMs: 35, sqrtMedianMs: 70 }; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); + expect(result.uncertainReason).toBe(WasmRuntimeUncertainReason.RATIOS_INCONCLUSIVE); + expect(result.uncertainDetail).toContain('addMedianMs=10ms'); }); it('should return UNKNOWN when the div kernel is too small to have really run', async () => { - expect.assertions(1); - // div median below the MIN_DIV_MEDIAN_MS floor -> nothing measurable executed. + expect.assertions(2); workerReply = { ok: true, ops: OPS, addMedianMs: 1, divMedianMs: 2, sqrtMedianMs: 5 }; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL); }); it('should return UNKNOWN when the worker reports a failure', async () => { - expect.assertions(1); + expect.assertions(2); workerReply = { ok: false }; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED); }); it('should return UNKNOWN when a measurement field is missing', async () => { - expect.assertions(1); - workerReply = { ok: true, ops: OPS, addMedianMs: 6.5 }; // missing div/sqrt + expect.assertions(2); + workerReply = { ok: true, ops: OPS, addMedianMs: 6.5 }; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.INVALID_MEASUREMENT); }); it('should return UNKNOWN when addMedianMs is not positive', async () => { - expect.assertions(1); + expect.assertions(2); workerReply = { ok: true, ops: OPS, addMedianMs: 0, divMedianMs: 60, sqrtMedianMs: 110 }; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.INVALID_MEASUREMENT); }); it('should return UNKNOWN when the worker does not reply before the timeout', async () => { - expect.assertions(1); + expect.assertions(2); jest.useFakeTimers(); workerReply = undefined; @@ -207,6 +228,7 @@ describe('WasmRuntimeProbe', () => { const result = await promise; expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_TIMEOUT); jest.useRealTimers(); }); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index 52926c5..6ae6308 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -9,44 +9,122 @@ export enum WasmRuntimeStatus { SLOW = 'slow', /** WASM missing or will not compile. */ DISABLED = 'disabled', - /** Measurements disagree; do not treat as a confident slow or fast. */ + /** Different parts of the measurement do not point to the same thing. See {@link WasmRuntimeResult.uncertainReason}. */ UNCERTAIN = 'uncertain', - /** Probe could not run (no Worker, timeout, background tab, bad sample). */ + /** Probe could not produce a trustworthy measurement. See {@link WasmRuntimeResult.unknownReason}. */ UNKNOWN = 'unknown', } /** - * Probe result for real-time WASM effects (BNR, VBG). Includes raw metrics so - * callers can change thresholds without re-running the benchmark. + * Why {@link WasmRuntimeStatus.UNKNOWN} was returned. Set only when status is unknown. + * Null for ok, slow, disabled, and uncertain. + */ +export enum WasmRuntimeUnknownReason { + /** No Web Worker or Blob URL support in this environment. */ + WORKER_UNAVAILABLE = 'worker_unavailable', + /** Worker or Blob URL creation threw. */ + WORKER_START_FAILED = 'worker_start_failed', + /** Benchmark did not finish before the configured worker timeout. */ + WORKER_TIMEOUT = 'worker_timeout', + /** Worker onerror or ok false from the benchmark script. */ + WORKER_BENCHMARK_FAILED = 'worker_benchmark_failed', + /** Reply missing fields or non-positive timings. */ + INVALID_MEASUREMENT = 'invalid_measurement', + /** Page/tab was hidden so timers are not trustworthy. */ + BACKGROUND_TAB = 'background_tab', + /** Div kernel median too small. The timed loop probably did not run. */ + TIMING_SAMPLE_TOO_SMALL = 'timing_sample_too_small', +} + +/** + * Why {@link WasmRuntimeStatus.UNCERTAIN} was returned. Set only when status is uncertain. + * Null for ok, slow, disabled, and unknown. + */ +export enum WasmRuntimeUncertainReason { + /** Op-cost ratios look fast but absolute add cost looks interpreter-slow. */ + CONFLICTING_SIGNALS = 'conflicting_signals', + /** Ratios sit between the fast and slow calibration bars with no clear slow add cost. */ + RATIOS_INCONCLUSIVE = 'ratios_inconclusive', +} + +/** Log/telemetry prefix when {@link WasmRuntimeStatus.UNCERTAIN}. */ +const UNCERTAIN_DETAIL_PREFIX = + 'Different parts of the measurement do not point to the same outcome.'; + +/** + * Builds the uncertain detail string for logs and telemetry. + * + * @param metrics - Rounded classification metrics. + * @param metrics.divRatio - Divide median divided by add median. + * @param metrics.sqrtRatio - Sqrt median divided by add median. + * @param metrics.addNsPerOp - Nanoseconds per add op. + * @param metrics.addMedianMs - Median add kernel time in ms. + * @param metrics.divMedianMs - Median div kernel time in ms. + * @param metrics.sqrtMedianMs - Median sqrt kernel time in ms. + * @returns Detail string with {@link UNCERTAIN_DETAIL_PREFIX} and metric values. + */ +const formatUncertainDetail = (metrics: { + divRatio: number; + sqrtRatio: number; + addNsPerOp: number; + addMedianMs: number; + divMedianMs: number; + sqrtMedianMs: number; +}): string => + `${UNCERTAIN_DETAIL_PREFIX} divRatio=${metrics.divRatio}, sqrtRatio=${metrics.sqrtRatio}, addNsPerOp=${metrics.addNsPerOp}, addMedianMs=${metrics.addMedianMs}ms, divMedianMs=${metrics.divMedianMs}ms, sqrtMedianMs=${metrics.sqrtMedianMs}ms`; + +/** + * Result of the WASM runtime probe. Used to decide whether to allow real-time + * WASM effects (BNR, VBG), which run poorly when the browser runs WASM through a + * slow interpreter. */ export interface WasmRuntimeResult { status: WasmRuntimeStatus; capability: CapabilityState; - /** Divide median divided by add median (main JIT vs interpreter signal). */ + /** + * When status is {@link WasmRuntimeStatus.UNKNOWN}, which guard or measurement + * problem applied. Otherwise null. + */ + unknownReason: WasmRuntimeUnknownReason | null; + /** + * When status is {@link WasmRuntimeStatus.UNCERTAIN}, which disagreement pattern + * applied. Otherwise null. + */ + uncertainReason: WasmRuntimeUncertainReason | null; + /** + * When status is {@link WasmRuntimeStatus.UNCERTAIN}, generic explanation plus + * main metrics for logs and telemetry. Otherwise null. + */ + uncertainDetail: string | null; + /** Divide time divided by add time. High means JIT. Low near 2 means interpreter. */ divRatio: number | null; - /** Sqrt median divided by add median (second unit, helps some CPUs). */ + /** + * Sqrt time divided by add time. Uses the FP sqrt unit, not the integer divider, + * so a chip with a fast divider still has a second signal. + */ sqrtRatio: number | null; - /** Nanoseconds per add op; scales with clock, used as a slow hint only. */ + /** Nanoseconds per add op. Scales with CPU clock. Slow hint only, not used alone for OK. */ addNsPerOp: number | null; addMedianMs: number | null; divMedianMs: number | null; sqrtMedianMs: number | null; } -// Lab calibration: Intel Mac + Windows, four engines, JIT on/off, throttled runs. +// Classification thresholds. Calibration constants — retune when re-benchmarking. const DIV_FAST_RATIO = 4.0; const DIV_SLOW_RATIO = 3.0; -// Sqrt/add runs higher than div/add on both JIT and interpreter; needs its own bar. +// Sqrt/add bar is higher than div/add on both JIT and interpreter engines. const SQRT_FAST_RATIO = 8.0; -// Interpreter adds are absolutely slower; never overrides a fast ratio alone. +// Absolute add cost can flag interpreter speed. It never overrides a fast ratio alone. const INTERP_ADD_NS_FLOOR = 1.2; -// Div is multi-cycle; below this median the timed loop likely did not really run. +// Integer div is slow. A tiny div median means the benchmark barely ran. const MIN_DIV_MEDIAN_MS = 8; -// ~16M ops x 5 trials; throttled interpreter needed ~3.4s in lab. +// ~16M inner ops, 5 trials. Timeout sized for the slowest expected interpreter run. const WORKER_TIMEOUT_MS = 8000; /** - * Maps probe status to capability. UNCERTAIN stays UNKNOWN so we do not false-block. + * Maps probe status to capability. Uncertain stays unknown capability so we do not + * false-block effects. * * @param status - The probe {@link WasmRuntimeStatus}. * @returns The corresponding {@link CapabilityState}. @@ -71,18 +149,22 @@ interface WorkerReply { sqrtMedianMs?: number; } +type WorkerBenchOutcome = + | { type: 'reply'; data: WorkerReply } + | { type: 'fail'; reason: WasmRuntimeUnknownReason }; + /** - * Detects whether WASM runs at JIT speed or through a slow interpreter (for example - * Edge with JIT disabled by policy). Uses a quick disabled check, then a Worker - * benchmark. Result is cached for the page lifetime. + * Tells whether WASM is fast enough for real-time effects (for example BNR on Edge + * when JIT is off). Runs a quick WASM disabled check, then a Worker benchmark. + * Cached once per page load. */ export class WasmRuntimeProbe { private static cachedResult?: Promise; /** - * Runs the probe once per page (cached). Compares WASM op-cost ratios from a Worker; - * under a slow interpreter both div/add and sqrt/add collapse near ~2 and status is - * {@link WasmRuntimeStatus.SLOW}. + * Runs the probe once per page (cached). The Worker times add, div, and sqrt in + * WASM and the main thread compares ratios. Slow interpreter engines collapse both + * ratios near 2 and status becomes {@link WasmRuntimeStatus.SLOW}. * * @returns A promise that resolves with the {@link WasmRuntimeResult}. */ @@ -94,7 +176,8 @@ export class WasmRuntimeProbe { } /** - * Assembles a {@link WasmRuntimeResult} from status and optional worker metrics. + * Builds a {@link WasmRuntimeResult} from status, optional metrics, and optional + * unknown reason. * * @param status - Classified status. * @param metrics - Optional raw timings from the worker. @@ -104,6 +187,8 @@ export class WasmRuntimeProbe { * @param metrics.addMedianMs - Median add kernel time in ms. * @param metrics.divMedianMs - Median div kernel time in ms. * @param metrics.sqrtMedianMs - Median sqrt kernel time in ms. + * @param unknownReason - Set when status is {@link WasmRuntimeStatus.UNKNOWN}. + * @param uncertainReason - Set when status is {@link WasmRuntimeStatus.UNCERTAIN}. * @returns Assembled {@link WasmRuntimeResult}. */ private static buildResult( @@ -115,11 +200,42 @@ export class WasmRuntimeProbe { addMedianMs?: number; divMedianMs?: number; sqrtMedianMs?: number; - } + }, + unknownReason?: WasmRuntimeUnknownReason, + uncertainReason?: WasmRuntimeUncertainReason ): WasmRuntimeResult { + const hasFullMetrics = + metrics?.divRatio !== undefined && + metrics.sqrtRatio !== undefined && + metrics.addNsPerOp !== undefined && + metrics.addMedianMs !== undefined && + metrics.divMedianMs !== undefined && + metrics.sqrtMedianMs !== undefined; + + const uncertainDetail = + status === WasmRuntimeStatus.UNCERTAIN && hasFullMetrics + ? formatUncertainDetail({ + divRatio: metrics.divRatio as number, + sqrtRatio: metrics.sqrtRatio as number, + addNsPerOp: metrics.addNsPerOp as number, + addMedianMs: metrics.addMedianMs as number, + divMedianMs: metrics.divMedianMs as number, + sqrtMedianMs: metrics.sqrtMedianMs as number, + }) + : null; + return { status, capability: statusToCapability(status), + unknownReason: + status === WasmRuntimeStatus.UNKNOWN + ? unknownReason ?? WasmRuntimeUnknownReason.INVALID_MEASUREMENT + : null, + uncertainReason: + status === WasmRuntimeStatus.UNCERTAIN + ? uncertainReason ?? WasmRuntimeUncertainReason.RATIOS_INCONCLUSIVE + : null, + uncertainDetail, divRatio: metrics?.divRatio ?? null, sqrtRatio: metrics?.sqrtRatio ?? null, addNsPerOp: metrics?.addNsPerOp ?? null, @@ -130,7 +246,7 @@ export class WasmRuntimeProbe { } /** - * Disabled check, then Worker benchmark with timeout and cleanup. + * WASM support check, then Worker benchmark with timeout and cleanup. * * @returns Classified probe result. */ @@ -144,33 +260,47 @@ export class WasmRuntimeProbe { typeof URL === 'undefined' || !URL.createObjectURL ) { - return this.buildResult(WasmRuntimeStatus.UNKNOWN); + return this.buildResult( + WasmRuntimeStatus.UNKNOWN, + undefined, + WasmRuntimeUnknownReason.WORKER_UNAVAILABLE + ); } const started = this.startWorker(); if (!started) { - return this.buildResult(WasmRuntimeStatus.UNKNOWN); + return this.buildResult( + WasmRuntimeStatus.UNKNOWN, + undefined, + WasmRuntimeUnknownReason.WORKER_START_FAILED + ); } const { worker, url } = started; try { - // eslint-disable-next-line jsdoc/require-jsdoc - const msg = await new Promise((resolve) => { - const timer = setTimeout(() => resolve({ ok: false }), WORKER_TIMEOUT_MS); + const outcome = await new Promise((resolve) => { + const timer = setTimeout( + () => resolve({ type: 'fail', reason: WasmRuntimeUnknownReason.WORKER_TIMEOUT }), + WORKER_TIMEOUT_MS + ); // eslint-disable-next-line jsdoc/require-jsdoc worker.onmessage = (e: MessageEvent) => { clearTimeout(timer); - resolve(e.data); + resolve({ type: 'reply', data: e.data }); }; // eslint-disable-next-line jsdoc/require-jsdoc worker.onerror = () => { clearTimeout(timer); - resolve({ ok: false }); + resolve({ type: 'fail', reason: WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED }); }; worker.postMessage('start'); }); - return this.classify(msg); + if (outcome.type === 'fail') { + return this.buildResult(WasmRuntimeStatus.UNKNOWN, undefined, outcome.reason); + } + + return this.classify(outcome.data); } finally { worker.terminate(); URL.revokeObjectURL(url); @@ -178,15 +308,22 @@ export class WasmRuntimeProbe { } /** - * Classifies worker medians. Fast div or sqrt ratio wins unless absolute add cost - * contradicts it ({@link WasmRuntimeStatus.UNCERTAIN}). + * Turns worker medians into status and metrics. High div or sqrt ratio means fast + * WASM unless absolute add cost disagrees ({@link WasmRuntimeStatus.UNCERTAIN}). * * @param msg - Raw worker reply. * @returns Classified result with rounded metrics. */ private static classify(msg: WorkerReply): WasmRuntimeResult { + if (!msg.ok) { + return this.buildResult( + WasmRuntimeStatus.UNKNOWN, + undefined, + WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED + ); + } + if ( - !msg.ok || typeof msg.ops !== 'number' || msg.ops <= 0 || typeof msg.addMedianMs !== 'number' || @@ -194,7 +331,11 @@ export class WasmRuntimeProbe { typeof msg.divMedianMs !== 'number' || typeof msg.sqrtMedianMs !== 'number' ) { - return this.buildResult(WasmRuntimeStatus.UNKNOWN); + return this.buildResult( + WasmRuntimeStatus.UNKNOWN, + undefined, + WasmRuntimeUnknownReason.INVALID_MEASUREMENT + ); } const { addMedianMs, divMedianMs, sqrtMedianMs } = msg; @@ -212,26 +353,41 @@ export class WasmRuntimeProbe { const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; let status: WasmRuntimeStatus; - if (hidden || !workRan) { + let unknownReason: WasmRuntimeUnknownReason | undefined; + let uncertainReason: WasmRuntimeUncertainReason | undefined; + if (hidden) { + status = WasmRuntimeStatus.UNKNOWN; + unknownReason = WasmRuntimeUnknownReason.BACKGROUND_TAB; + } else if (!workRan) { status = WasmRuntimeStatus.UNKNOWN; + unknownReason = WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL; } else if (fastSignal && interpAbs) { + // Different parts of the measurement do not point to the same thing (ratios vs add cost). status = WasmRuntimeStatus.UNCERTAIN; + uncertainReason = WasmRuntimeUncertainReason.CONFLICTING_SIGNALS; } else if (fastSignal) { status = WasmRuntimeStatus.OK; } else if (slowSignalRatio || interpAbs) { status = WasmRuntimeStatus.SLOW; } else { + // Different parts of the measurement do not point to the same thing (ratios in gray zone). status = WasmRuntimeStatus.UNCERTAIN; + uncertainReason = WasmRuntimeUncertainReason.RATIOS_INCONCLUSIVE; } - return this.buildResult(status, { - divRatio: round3(divRatio), - sqrtRatio: round3(sqrtRatio), - addNsPerOp: round3(addNsPerOp), - addMedianMs: round3(addMedianMs), - divMedianMs: round3(divMedianMs), - sqrtMedianMs: round3(sqrtMedianMs), - }); + return this.buildResult( + status, + { + divRatio: round3(divRatio), + sqrtRatio: round3(sqrtRatio), + addNsPerOp: round3(addNsPerOp), + addMedianMs: round3(addMedianMs), + divMedianMs: round3(divMedianMs), + sqrtMedianMs: round3(sqrtMedianMs), + }, + unknownReason, + uncertainReason + ); } /** diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js index 9330db8..c027b1f 100644 --- a/src/wasm-runtime-probe.worker.js +++ b/src/wasm-runtime-probe.worker.js @@ -1,13 +1,13 @@ /* * WASM runtime benchmark worker. Inlined into wasm-runtime-probe.ts via Blob URL. * - * Times dependent-chain add / div / sqrt kernels; main thread uses div/add and - * sqrt/add ratios so CPU clock mostly cancels out. + * Times dependent-chain add, div, and sqrt kernels. The main thread compares + * div/add and sqrt/add ratios so CPU clock speed mostly cancels out. * - * Counterintuitive: HIGH ratio means JIT OK, LOW (~2) means interpreted WASM. - * Thresholds are in wasm-runtime-probe.ts — do not invert ratios when classifying. + * Counterintuitive: HIGH ratio means JIT OK. LOW near 2 means interpreted WASM. + * Thresholds live in wasm-runtime-probe.ts. Do not invert ratios when classifying. * - * postMessage('start') -> { ok, ops, addMedianMs, divMedianMs, sqrtMedianMs } or { ok: false }. + * postMessage('start') returns medians or { ok: false }. */ self.onmessage = function onProbeStart() { try { From f147ef8282a8bc703c5d2b72a504b3311b0e5c9d Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 16:41:08 +0200 Subject: [PATCH 12/17] chore: update comments for file --- src/wasm-runtime-probe.spec.ts | 6 +- src/wasm-runtime-probe.ts | 104 +++++++++++++++------------------ 2 files changed, 49 insertions(+), 61 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index b063e4e..2b7fae7 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -159,9 +159,9 @@ describe('WasmRuntimeProbe', () => { expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); expect(result.capability).toBe(CapabilityState.UNKNOWN); - expect(result.uncertainReason).toBe(WasmRuntimeUncertainReason.CONFLICTING_SIGNALS); + expect(result.uncertainReason).toBe(WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD); expect(result.uncertainDetail).toContain( - 'Different parts of the measurement do not point to the same outcome.' + 'Measurements do not clearly classify WASM as fast or slow.' ); expect(result.uncertainDetail).toMatch(/divRatio=5, sqrtRatio=12, addNsPerOp=2\.5/); }); @@ -174,7 +174,7 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); - expect(result.uncertainReason).toBe(WasmRuntimeUncertainReason.RATIOS_INCONCLUSIVE); + expect(result.uncertainReason).toBe(WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS); expect(result.uncertainDetail).toContain('addMedianMs=10ms'); }); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index 6ae6308..154b844 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -9,16 +9,13 @@ export enum WasmRuntimeStatus { SLOW = 'slow', /** WASM missing or will not compile. */ DISABLED = 'disabled', - /** Different parts of the measurement do not point to the same thing. See {@link WasmRuntimeResult.uncertainReason}. */ + /** Measurements do not clearly classify WASM as fast or slow. See {@link WasmRuntimeResult.uncertainReason}. */ UNCERTAIN = 'uncertain', - /** Probe could not produce a trustworthy measurement. See {@link WasmRuntimeResult.unknownReason}. */ + /** Probe could not complete or validate a measurement. See {@link WasmRuntimeResult.unknownReason}. */ UNKNOWN = 'unknown', } -/** - * Why {@link WasmRuntimeStatus.UNKNOWN} was returned. Set only when status is unknown. - * Null for ok, slow, disabled, and uncertain. - */ +/** Why {@link WasmRuntimeStatus.UNKNOWN} was returned. Set only when status is unknown. */ export enum WasmRuntimeUnknownReason { /** No Web Worker or Blob URL support in this environment. */ WORKER_UNAVAILABLE = 'worker_unavailable', @@ -28,40 +25,36 @@ export enum WasmRuntimeUnknownReason { WORKER_TIMEOUT = 'worker_timeout', /** Worker onerror or ok false from the benchmark script. */ WORKER_BENCHMARK_FAILED = 'worker_benchmark_failed', - /** Reply missing fields or non-positive timings. */ + /** Worker response is incomplete or contains an invalid timing. */ INVALID_MEASUREMENT = 'invalid_measurement', - /** Page/tab was hidden so timers are not trustworthy. */ + /** Page was hidden during the benchmark, which can distort its timing. */ BACKGROUND_TAB = 'background_tab', - /** Div kernel median too small. The timed loop probably did not run. */ + /** Divide benchmark finished too quickly to classify the runtime. */ TIMING_SAMPLE_TOO_SMALL = 'timing_sample_too_small', } -/** - * Why {@link WasmRuntimeStatus.UNCERTAIN} was returned. Set only when status is uncertain. - * Null for ok, slow, disabled, and unknown. - */ +/** Why {@link WasmRuntimeStatus.UNCERTAIN} was returned. Set only when status is uncertain. */ export enum WasmRuntimeUncertainReason { - /** Op-cost ratios look fast but absolute add cost looks interpreter-slow. */ - CONFLICTING_SIGNALS = 'conflicting_signals', - /** Ratios sit between the fast and slow calibration bars with no clear slow add cost. */ - RATIOS_INCONCLUSIVE = 'ratios_inconclusive', + /** Divide or sqrt ratio indicates fast WASM, but add cost indicates slow WASM. */ + FAST_RATIO_SLOW_ADD = 'fast_ratio_slow_add', + /** Ratios are neither clearly fast nor clearly slow. */ + RATIOS_BETWEEN_THRESHOLDS = 'ratios_between_thresholds', } -/** Log/telemetry prefix when {@link WasmRuntimeStatus.UNCERTAIN}. */ -const UNCERTAIN_DETAIL_PREFIX = - 'Different parts of the measurement do not point to the same outcome.'; +/** Human-readable explanation included with uncertain measurements. */ +const UNCERTAIN_DETAIL_PREFIX = 'Measurements do not clearly classify WASM as fast or slow.'; /** - * Builds the uncertain detail string for logs and telemetry. + * Formats an uncertain result for logs and telemetry. * - * @param metrics - Rounded classification metrics. - * @param metrics.divRatio - Divide median divided by add median. - * @param metrics.sqrtRatio - Sqrt median divided by add median. - * @param metrics.addNsPerOp - Nanoseconds per add op. - * @param metrics.addMedianMs - Median add kernel time in ms. - * @param metrics.divMedianMs - Median div kernel time in ms. - * @param metrics.sqrtMedianMs - Median sqrt kernel time in ms. - * @returns Detail string with {@link UNCERTAIN_DETAIL_PREFIX} and metric values. + * @param metrics - Measurements used to classify the WASM runtime. + * @param metrics.divRatio - Divide time relative to add time. + * @param metrics.sqrtRatio - Square root time relative to add time. + * @param metrics.addNsPerOp - Time for one add operation in nanoseconds. + * @param metrics.addMedianMs - Typical add benchmark time in milliseconds. + * @param metrics.divMedianMs - Typical divide benchmark time in milliseconds. + * @param metrics.sqrtMedianMs - Typical square root benchmark time in milliseconds. + * @returns A generic explanation followed by the measurement values. */ const formatUncertainDetail = (metrics: { divRatio: number; @@ -79,47 +72,45 @@ const formatUncertainDetail = (metrics: { * slow interpreter. */ export interface WasmRuntimeResult { + /** Probe classification. */ status: WasmRuntimeStatus; + /** Capability derived from the probe classification. */ capability: CapabilityState; - /** - * When status is {@link WasmRuntimeStatus.UNKNOWN}, which guard or measurement - * problem applied. Otherwise null. - */ + /** Reason the probe could not complete or validate a measurement. */ unknownReason: WasmRuntimeUnknownReason | null; - /** - * When status is {@link WasmRuntimeStatus.UNCERTAIN}, which disagreement pattern - * applied. Otherwise null. - */ + /** Reason the measurements did not clearly indicate fast or slow WASM. */ uncertainReason: WasmRuntimeUncertainReason | null; - /** - * When status is {@link WasmRuntimeStatus.UNCERTAIN}, generic explanation plus - * main metrics for logs and telemetry. Otherwise null. - */ + /** Human-readable uncertainty explanation with measurements for logs and telemetry. */ uncertainDetail: string | null; - /** Divide time divided by add time. High means JIT. Low near 2 means interpreter. */ + /** Divide time relative to add time. High suggests JIT. Low near 2 suggests an interpreter. */ divRatio: number | null; /** - * Sqrt time divided by add time. Uses the FP sqrt unit, not the integer divider, - * so a chip with a fast divider still has a second signal. + * Square root time relative to add time. Uses a different CPU execution unit than + * divide, providing an independent signal. */ sqrtRatio: number | null; - /** Nanoseconds per add op. Scales with CPU clock. Slow hint only, not used alone for OK. */ + /** Time for one add operation in nanoseconds. Used as a slow signal, but not to mark OK. */ addNsPerOp: number | null; + /** Typical add benchmark time in milliseconds. */ addMedianMs: number | null; + /** Typical divide benchmark time in milliseconds. */ divMedianMs: number | null; + /** Typical square root benchmark time in milliseconds. */ sqrtMedianMs: number | null; } -// Classification thresholds. Calibration constants — retune when re-benchmarking. +// Calibration values used to classify the benchmark measurements. +/** Divide ratio at or above this value indicates fast WASM. */ const DIV_FAST_RATIO = 4.0; +/** Divide ratio at or below this value indicates slow WASM. */ const DIV_SLOW_RATIO = 3.0; -// Sqrt/add bar is higher than div/add on both JIT and interpreter engines. +/** Square root ratio at or above this value provides another fast WASM signal. */ const SQRT_FAST_RATIO = 8.0; -// Absolute add cost can flag interpreter speed. It never overrides a fast ratio alone. +/** Add cost above this value indicates slow WASM unless a fast ratio disagrees. */ const INTERP_ADD_NS_FLOOR = 1.2; -// Integer div is slow. A tiny div median means the benchmark barely ran. +/** Divide samples below this duration are too short to classify. */ const MIN_DIV_MEDIAN_MS = 8; -// ~16M inner ops, 5 trials. Timeout sized for the slowest expected interpreter run. +/** Maximum time allowed for the worker benchmark to finish. */ const WORKER_TIMEOUT_MS = 8000; /** @@ -231,10 +222,7 @@ export class WasmRuntimeProbe { status === WasmRuntimeStatus.UNKNOWN ? unknownReason ?? WasmRuntimeUnknownReason.INVALID_MEASUREMENT : null, - uncertainReason: - status === WasmRuntimeStatus.UNCERTAIN - ? uncertainReason ?? WasmRuntimeUncertainReason.RATIOS_INCONCLUSIVE - : null, + uncertainReason: status === WasmRuntimeStatus.UNCERTAIN ? uncertainReason ?? null : null, uncertainDetail, divRatio: metrics?.divRatio ?? null, sqrtRatio: metrics?.sqrtRatio ?? null, @@ -362,17 +350,17 @@ export class WasmRuntimeProbe { status = WasmRuntimeStatus.UNKNOWN; unknownReason = WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL; } else if (fastSignal && interpAbs) { - // Different parts of the measurement do not point to the same thing (ratios vs add cost). + // Ratios indicate fast WASM while absolute add cost indicates slow WASM. status = WasmRuntimeStatus.UNCERTAIN; - uncertainReason = WasmRuntimeUncertainReason.CONFLICTING_SIGNALS; + uncertainReason = WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD; } else if (fastSignal) { status = WasmRuntimeStatus.OK; } else if (slowSignalRatio || interpAbs) { status = WasmRuntimeStatus.SLOW; } else { - // Different parts of the measurement do not point to the same thing (ratios in gray zone). + // Ratios fall between the calibrated fast and slow ranges. status = WasmRuntimeStatus.UNCERTAIN; - uncertainReason = WasmRuntimeUncertainReason.RATIOS_INCONCLUSIVE; + uncertainReason = WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS; } return this.buildResult( From b0576d3eb5f87384d082fe6d118cb0bc35f1e5b4 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 16:51:02 +0200 Subject: [PATCH 13/17] chore: update namings --- src/wasm-runtime-probe.spec.ts | 84 +++++++++++----- src/wasm-runtime-probe.ts | 171 ++++++++++++++++----------------- 2 files changed, 141 insertions(+), 114 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index 2b7fae7..abae763 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -6,7 +6,7 @@ import { } from './wasm-runtime-probe'; import { CapabilityState } from './web-capabilities'; -interface FakeReply { +interface FakeWorkerResponse { ok: boolean; ops?: number; addMedianMs?: number; @@ -16,16 +16,14 @@ interface FakeReply { const OPS = 16_000_000; -// Calibration fixtures for classify() (fast JIT-shaped vs slow interpreter-shaped medians). -const FAST_REPLY: FakeReply = { +const FAST_RESPONSE: FakeWorkerResponse = { ok: true, ops: OPS, addMedianMs: 6.5, divMedianMs: 35.6, sqrtMedianMs: 80.6, }; -// Slow interpreter-shaped medians for the same op count. -const SLOW_REPLY: FakeReply = { +const SLOW_RESPONSE: FakeWorkerResponse = { ok: true, ops: OPS, addMedianMs: 32.1, @@ -33,12 +31,13 @@ const SLOW_REPLY: FakeReply = { sqrtMedianMs: 111.5, }; -let workerReply: FakeReply | undefined; +let workerResponse: FakeWorkerResponse | undefined; +let workerRuntimeError = false; let workerConstructCount = 0; -/** Stand-in Worker for jsdom; uses {@link workerReply}. */ +/** Stand-in Worker for jsdom. */ class MockWorker { - onmessage: ((event: { data: FakeReply }) => void) | null = null; + onmessage: ((event: { data: FakeWorkerResponse }) => void) | null = null; onerror: (() => void) | null = null; @@ -47,10 +46,12 @@ class MockWorker { workerConstructCount += 1; } - /** Posts {@link workerReply} to {@link MockWorker.onmessage} when configured. */ + /** Sends the configured response or runtime error. */ postMessage(): void { - if (workerReply && this.onmessage) { - this.onmessage({ data: workerReply }); + if (workerRuntimeError && this.onerror) { + this.onerror(); + } else if (workerResponse && this.onmessage) { + this.onmessage({ data: workerResponse }); } } @@ -64,7 +65,8 @@ describe('WasmRuntimeProbe', () => { beforeEach(() => { (WasmRuntimeProbe as unknown as { cachedResult?: unknown }).cachedResult = undefined; - workerReply = undefined; + workerResponse = undefined; + workerRuntimeError = false; workerConstructCount = 0; }); @@ -125,7 +127,7 @@ describe('WasmRuntimeProbe', () => { it('should return OK when the op-cost ratios show native (JIT) speed', async () => { expect.assertions(6); - workerReply = FAST_REPLY; + workerResponse = FAST_RESPONSE; const result = await WasmRuntimeProbe.check(); @@ -139,7 +141,7 @@ describe('WasmRuntimeProbe', () => { it('should return SLOW when the div/add ratio collapses (interpreter)', async () => { expect.assertions(5); - workerReply = SLOW_REPLY; + workerResponse = SLOW_RESPONSE; const result = await WasmRuntimeProbe.check(); @@ -152,8 +154,13 @@ describe('WasmRuntimeProbe', () => { it('should return UNCERTAIN when a fast ratio contradicts an interpreter-slow add', async () => { expect.assertions(5); - // Fast div ratio but interpreter-slow addNs. Expect UNCERTAIN. - workerReply = { ok: true, ops: OPS, addMedianMs: 40, divMedianMs: 200, sqrtMedianMs: 480 }; + workerResponse = { + ok: true, + ops: OPS, + addMedianMs: 40, + divMedianMs: 200, + sqrtMedianMs: 480, + }; const result = await WasmRuntimeProbe.check(); @@ -168,8 +175,13 @@ describe('WasmRuntimeProbe', () => { it('should return UNCERTAIN when the ratios fall between the fast and slow bars', async () => { expect.assertions(3); - // Ratios between fast and slow thresholds. - workerReply = { ok: true, ops: OPS, addMedianMs: 10, divMedianMs: 35, sqrtMedianMs: 70 }; + workerResponse = { + ok: true, + ops: OPS, + addMedianMs: 10, + divMedianMs: 35, + sqrtMedianMs: 70, + }; const result = await WasmRuntimeProbe.check(); @@ -180,7 +192,13 @@ describe('WasmRuntimeProbe', () => { it('should return UNKNOWN when the div kernel is too small to have really run', async () => { expect.assertions(2); - workerReply = { ok: true, ops: OPS, addMedianMs: 1, divMedianMs: 2, sqrtMedianMs: 5 }; + workerResponse = { + ok: true, + ops: OPS, + addMedianMs: 1, + divMedianMs: 2, + sqrtMedianMs: 5, + }; const result = await WasmRuntimeProbe.check(); @@ -190,7 +208,7 @@ describe('WasmRuntimeProbe', () => { it('should return UNKNOWN when the worker reports a failure', async () => { expect.assertions(2); - workerReply = { ok: false }; + workerResponse = { ok: false }; const result = await WasmRuntimeProbe.check(); @@ -198,9 +216,19 @@ describe('WasmRuntimeProbe', () => { expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED); }); + it('should return UNKNOWN when the worker raises a runtime error', async () => { + expect.assertions(2); + workerRuntimeError = true; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR); + }); + it('should return UNKNOWN when a measurement field is missing', async () => { expect.assertions(2); - workerReply = { ok: true, ops: OPS, addMedianMs: 6.5 }; + workerResponse = { ok: true, ops: OPS, addMedianMs: 6.5 }; const result = await WasmRuntimeProbe.check(); @@ -210,7 +238,13 @@ describe('WasmRuntimeProbe', () => { it('should return UNKNOWN when addMedianMs is not positive', async () => { expect.assertions(2); - workerReply = { ok: true, ops: OPS, addMedianMs: 0, divMedianMs: 60, sqrtMedianMs: 110 }; + workerResponse = { + ok: true, + ops: OPS, + addMedianMs: 0, + divMedianMs: 60, + sqrtMedianMs: 110, + }; const result = await WasmRuntimeProbe.check(); @@ -221,10 +255,10 @@ describe('WasmRuntimeProbe', () => { it('should return UNKNOWN when the worker does not reply before the timeout', async () => { expect.assertions(2); jest.useFakeTimers(); - workerReply = undefined; + workerResponse = undefined; const promise = WasmRuntimeProbe.check(); - jest.advanceTimersByTime(8000); + jest.advanceTimersByTime(5000); const result = await promise; expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); @@ -234,7 +268,7 @@ describe('WasmRuntimeProbe', () => { it('should cache the result so repeated calls run the benchmark only once', async () => { expect.assertions(2); - workerReply = FAST_REPLY; + workerResponse = FAST_RESPONSE; const first = WasmRuntimeProbe.check(); const second = WasmRuntimeProbe.check(); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index 154b844..4ea8db0 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -9,13 +9,13 @@ export enum WasmRuntimeStatus { SLOW = 'slow', /** WASM missing or will not compile. */ DISABLED = 'disabled', - /** Measurements do not clearly classify WASM as fast or slow. See {@link WasmRuntimeResult.uncertainReason}. */ + /** Measurements do not clearly classify WASM as fast or slow. */ UNCERTAIN = 'uncertain', - /** Probe could not complete or validate a measurement. See {@link WasmRuntimeResult.unknownReason}. */ + /** Probe could not complete or validate a measurement. */ UNKNOWN = 'unknown', } -/** Why {@link WasmRuntimeStatus.UNKNOWN} was returned. Set only when status is unknown. */ +/** Reasons a probe returns {@link WasmRuntimeStatus.UNKNOWN}. */ export enum WasmRuntimeUnknownReason { /** No Web Worker or Blob URL support in this environment. */ WORKER_UNAVAILABLE = 'worker_unavailable', @@ -23,7 +23,9 @@ export enum WasmRuntimeUnknownReason { WORKER_START_FAILED = 'worker_start_failed', /** Benchmark did not finish before the configured worker timeout. */ WORKER_TIMEOUT = 'worker_timeout', - /** Worker onerror or ok false from the benchmark script. */ + /** Worker raised an error before returning a response. */ + WORKER_RUNTIME_ERROR = 'worker_runtime_error', + /** Worker completed but reported that the benchmark failed. */ WORKER_BENCHMARK_FAILED = 'worker_benchmark_failed', /** Worker response is incomplete or contains an invalid timing. */ INVALID_MEASUREMENT = 'invalid_measurement', @@ -33,7 +35,7 @@ export enum WasmRuntimeUnknownReason { TIMING_SAMPLE_TOO_SMALL = 'timing_sample_too_small', } -/** Why {@link WasmRuntimeStatus.UNCERTAIN} was returned. Set only when status is uncertain. */ +/** Reasons a probe returns {@link WasmRuntimeStatus.UNCERTAIN}. */ export enum WasmRuntimeUncertainReason { /** Divide or sqrt ratio indicates fast WASM, but add cost indicates slow WASM. */ FAST_RATIO_SLOW_ADD = 'fast_ratio_slow_add', @@ -41,30 +43,26 @@ export enum WasmRuntimeUncertainReason { RATIOS_BETWEEN_THRESHOLDS = 'ratios_between_thresholds', } -/** Human-readable explanation included with uncertain measurements. */ +interface WasmRuntimeMeasurements { + divRatio: number; + sqrtRatio: number; + addNsPerOp: number; + addMedianMs: number; + divMedianMs: number; + sqrtMedianMs: number; +} + +/** Keeps the human-readable uncertainty message consistent in logs and telemetry. */ const UNCERTAIN_DETAIL_PREFIX = 'Measurements do not clearly classify WASM as fast or slow.'; /** * Formats an uncertain result for logs and telemetry. * - * @param metrics - Measurements used to classify the WASM runtime. - * @param metrics.divRatio - Divide time relative to add time. - * @param metrics.sqrtRatio - Square root time relative to add time. - * @param metrics.addNsPerOp - Time for one add operation in nanoseconds. - * @param metrics.addMedianMs - Typical add benchmark time in milliseconds. - * @param metrics.divMedianMs - Typical divide benchmark time in milliseconds. - * @param metrics.sqrtMedianMs - Typical square root benchmark time in milliseconds. + * @param measurements - Values used to classify the WASM runtime. * @returns A generic explanation followed by the measurement values. */ -const formatUncertainDetail = (metrics: { - divRatio: number; - sqrtRatio: number; - addNsPerOp: number; - addMedianMs: number; - divMedianMs: number; - sqrtMedianMs: number; -}): string => - `${UNCERTAIN_DETAIL_PREFIX} divRatio=${metrics.divRatio}, sqrtRatio=${metrics.sqrtRatio}, addNsPerOp=${metrics.addNsPerOp}, addMedianMs=${metrics.addMedianMs}ms, divMedianMs=${metrics.divMedianMs}ms, sqrtMedianMs=${metrics.sqrtMedianMs}ms`; +const formatUncertainDetail = (measurements: WasmRuntimeMeasurements): string => + `${UNCERTAIN_DETAIL_PREFIX} divRatio=${measurements.divRatio}, sqrtRatio=${measurements.sqrtRatio}, addNsPerOp=${measurements.addNsPerOp}, addMedianMs=${measurements.addMedianMs}ms, divMedianMs=${measurements.divMedianMs}ms, sqrtMedianMs=${measurements.sqrtMedianMs}ms`; /** * Result of the WASM runtime probe. Used to decide whether to allow real-time @@ -72,13 +70,13 @@ const formatUncertainDetail = (metrics: { * slow interpreter. */ export interface WasmRuntimeResult { - /** Probe classification. */ + /** See {@link WasmRuntimeStatus}. */ status: WasmRuntimeStatus; - /** Capability derived from the probe classification. */ + /** Product capability derived from {@link status}. */ capability: CapabilityState; - /** Reason the probe could not complete or validate a measurement. */ + /** See {@link WasmRuntimeUnknownReason}. */ unknownReason: WasmRuntimeUnknownReason | null; - /** Reason the measurements did not clearly indicate fast or slow WASM. */ + /** See {@link WasmRuntimeUncertainReason}. */ uncertainReason: WasmRuntimeUncertainReason | null; /** Human-readable uncertainty explanation with measurements for logs and telemetry. */ uncertainDetail: string | null; @@ -99,7 +97,6 @@ export interface WasmRuntimeResult { sqrtMedianMs: number | null; } -// Calibration values used to classify the benchmark measurements. /** Divide ratio at or above this value indicates fast WASM. */ const DIV_FAST_RATIO = 4.0; /** Divide ratio at or below this value indicates slow WASM. */ @@ -111,7 +108,7 @@ const INTERP_ADD_NS_FLOOR = 1.2; /** Divide samples below this duration are too short to classify. */ const MIN_DIV_MEDIAN_MS = 8; /** Maximum time allowed for the worker benchmark to finish. */ -const WORKER_TIMEOUT_MS = 8000; +const WORKER_TIMEOUT_MS = 5000; /** * Maps probe status to capability. Uncertain stays unknown capability so we do not @@ -132,7 +129,7 @@ const statusToCapability = (status: WasmRuntimeStatus): CapabilityState => { } }; -interface WorkerReply { +interface WorkerResponse { ok: boolean; ops?: number; addMedianMs?: number; @@ -140,9 +137,14 @@ interface WorkerReply { sqrtMedianMs?: number; } -type WorkerBenchOutcome = - | { type: 'reply'; data: WorkerReply } - | { type: 'fail'; reason: WasmRuntimeUnknownReason }; +type WorkerResponseOutcome = + | { type: 'response'; response: WorkerResponse } + | { + type: 'no_response'; + reason: + | WasmRuntimeUnknownReason.WORKER_TIMEOUT + | WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR; + }; /** * Tells whether WASM is fast enough for real-time effects (for example BNR on Edge @@ -167,51 +169,37 @@ export class WasmRuntimeProbe { } /** - * Builds a {@link WasmRuntimeResult} from status, optional metrics, and optional - * unknown reason. + * Keeps status-specific reason and detail fields consistent. * * @param status - Classified status. - * @param metrics - Optional raw timings from the worker. - * @param metrics.divRatio - Divide median divided by add median. - * @param metrics.sqrtRatio - Sqrt median divided by add median. - * @param metrics.addNsPerOp - Nanoseconds per add op. - * @param metrics.addMedianMs - Median add kernel time in ms. - * @param metrics.divMedianMs - Median div kernel time in ms. - * @param metrics.sqrtMedianMs - Median sqrt kernel time in ms. - * @param unknownReason - Set when status is {@link WasmRuntimeStatus.UNKNOWN}. - * @param uncertainReason - Set when status is {@link WasmRuntimeStatus.UNCERTAIN}. + * @param measurements - Available benchmark measurements. + * @param unknownReason - Optional unknown result detail. + * @param uncertainReason - Optional uncertain result detail. * @returns Assembled {@link WasmRuntimeResult}. */ private static buildResult( status: WasmRuntimeStatus, - metrics?: { - divRatio?: number; - sqrtRatio?: number; - addNsPerOp?: number; - addMedianMs?: number; - divMedianMs?: number; - sqrtMedianMs?: number; - }, + measurements?: Partial, unknownReason?: WasmRuntimeUnknownReason, uncertainReason?: WasmRuntimeUncertainReason ): WasmRuntimeResult { const hasFullMetrics = - metrics?.divRatio !== undefined && - metrics.sqrtRatio !== undefined && - metrics.addNsPerOp !== undefined && - metrics.addMedianMs !== undefined && - metrics.divMedianMs !== undefined && - metrics.sqrtMedianMs !== undefined; + measurements?.divRatio !== undefined && + measurements.sqrtRatio !== undefined && + measurements.addNsPerOp !== undefined && + measurements.addMedianMs !== undefined && + measurements.divMedianMs !== undefined && + measurements.sqrtMedianMs !== undefined; const uncertainDetail = status === WasmRuntimeStatus.UNCERTAIN && hasFullMetrics ? formatUncertainDetail({ - divRatio: metrics.divRatio as number, - sqrtRatio: metrics.sqrtRatio as number, - addNsPerOp: metrics.addNsPerOp as number, - addMedianMs: metrics.addMedianMs as number, - divMedianMs: metrics.divMedianMs as number, - sqrtMedianMs: metrics.sqrtMedianMs as number, + divRatio: measurements.divRatio as number, + sqrtRatio: measurements.sqrtRatio as number, + addNsPerOp: measurements.addNsPerOp as number, + addMedianMs: measurements.addMedianMs as number, + divMedianMs: measurements.divMedianMs as number, + sqrtMedianMs: measurements.sqrtMedianMs as number, }) : null; @@ -224,12 +212,12 @@ export class WasmRuntimeProbe { : null, uncertainReason: status === WasmRuntimeStatus.UNCERTAIN ? uncertainReason ?? null : null, uncertainDetail, - divRatio: metrics?.divRatio ?? null, - sqrtRatio: metrics?.sqrtRatio ?? null, - addNsPerOp: metrics?.addNsPerOp ?? null, - addMedianMs: metrics?.addMedianMs ?? null, - divMedianMs: metrics?.divMedianMs ?? null, - sqrtMedianMs: metrics?.sqrtMedianMs ?? null, + divRatio: measurements?.divRatio ?? null, + sqrtRatio: measurements?.sqrtRatio ?? null, + addNsPerOp: measurements?.addNsPerOp ?? null, + addMedianMs: measurements?.addMedianMs ?? null, + divMedianMs: measurements?.divMedianMs ?? null, + sqrtMedianMs: measurements?.sqrtMedianMs ?? null, }; } @@ -266,29 +254,36 @@ export class WasmRuntimeProbe { const { worker, url } = started; try { - const outcome = await new Promise((resolve) => { + const outcome = await new Promise((resolve) => { const timer = setTimeout( - () => resolve({ type: 'fail', reason: WasmRuntimeUnknownReason.WORKER_TIMEOUT }), + () => + resolve({ + type: 'no_response', + reason: WasmRuntimeUnknownReason.WORKER_TIMEOUT, + }), WORKER_TIMEOUT_MS ); // eslint-disable-next-line jsdoc/require-jsdoc - worker.onmessage = (e: MessageEvent) => { + worker.onmessage = (event: MessageEvent) => { clearTimeout(timer); - resolve({ type: 'reply', data: e.data }); + resolve({ type: 'response', response: event.data }); }; // eslint-disable-next-line jsdoc/require-jsdoc worker.onerror = () => { clearTimeout(timer); - resolve({ type: 'fail', reason: WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED }); + resolve({ + type: 'no_response', + reason: WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR, + }); }; worker.postMessage('start'); }); - if (outcome.type === 'fail') { + if (outcome.type === 'no_response') { return this.buildResult(WasmRuntimeStatus.UNKNOWN, undefined, outcome.reason); } - return this.classify(outcome.data); + return this.classify(outcome.response); } finally { worker.terminate(); URL.revokeObjectURL(url); @@ -299,11 +294,11 @@ export class WasmRuntimeProbe { * Turns worker medians into status and metrics. High div or sqrt ratio means fast * WASM unless absolute add cost disagrees ({@link WasmRuntimeStatus.UNCERTAIN}). * - * @param msg - Raw worker reply. + * @param response - Raw worker response. * @returns Classified result with rounded metrics. */ - private static classify(msg: WorkerReply): WasmRuntimeResult { - if (!msg.ok) { + private static classify(response: WorkerResponse): WasmRuntimeResult { + if (!response.ok) { return this.buildResult( WasmRuntimeStatus.UNKNOWN, undefined, @@ -312,12 +307,12 @@ export class WasmRuntimeProbe { } if ( - typeof msg.ops !== 'number' || - msg.ops <= 0 || - typeof msg.addMedianMs !== 'number' || - msg.addMedianMs <= 0 || - typeof msg.divMedianMs !== 'number' || - typeof msg.sqrtMedianMs !== 'number' + typeof response.ops !== 'number' || + response.ops <= 0 || + typeof response.addMedianMs !== 'number' || + response.addMedianMs <= 0 || + typeof response.divMedianMs !== 'number' || + typeof response.sqrtMedianMs !== 'number' ) { return this.buildResult( WasmRuntimeStatus.UNKNOWN, @@ -326,13 +321,13 @@ export class WasmRuntimeProbe { ); } - const { addMedianMs, divMedianMs, sqrtMedianMs } = msg; + const { addMedianMs, divMedianMs, sqrtMedianMs } = response; // eslint-disable-next-line jsdoc/require-jsdoc const round3 = (v: number): number => Number(v.toFixed(3)); const divRatio = divMedianMs / addMedianMs; const sqrtRatio = sqrtMedianMs / addMedianMs; - const addNsPerOp = (addMedianMs * 1e6) / msg.ops; + const addNsPerOp = (addMedianMs * 1e6) / response.ops; const fastSignal = divRatio >= DIV_FAST_RATIO || sqrtRatio >= SQRT_FAST_RATIO; const slowSignalRatio = divRatio <= DIV_SLOW_RATIO; @@ -350,7 +345,6 @@ export class WasmRuntimeProbe { status = WasmRuntimeStatus.UNKNOWN; unknownReason = WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL; } else if (fastSignal && interpAbs) { - // Ratios indicate fast WASM while absolute add cost indicates slow WASM. status = WasmRuntimeStatus.UNCERTAIN; uncertainReason = WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD; } else if (fastSignal) { @@ -358,7 +352,6 @@ export class WasmRuntimeProbe { } else if (slowSignalRatio || interpAbs) { status = WasmRuntimeStatus.SLOW; } else { - // Ratios fall between the calibrated fast and slow ranges. status = WasmRuntimeStatus.UNCERTAIN; uncertainReason = WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS; } From 35fae7767ff6e300b1bb4b9b670c51fa50e0c18a Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 17:19:44 +0200 Subject: [PATCH 14/17] chore: update namings, comments, tests --- src/wasm-runtime-probe.spec.ts | 88 ++++++++------- src/wasm-runtime-probe.ts | 181 ++++++++++--------------------- src/wasm-runtime-probe.worker.js | 11 +- 3 files changed, 116 insertions(+), 164 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index abae763..0021156 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -33,6 +33,7 @@ const SLOW_RESPONSE: FakeWorkerResponse = { let workerResponse: FakeWorkerResponse | undefined; let workerRuntimeError = false; +let workerConstructionError = false; let workerConstructCount = 0; /** Stand-in Worker for jsdom. */ @@ -44,6 +45,9 @@ class MockWorker { /** Tracks how many workers tests constructed. */ constructor() { workerConstructCount += 1; + if (workerConstructionError) { + throw new Error('Worker construction failed'); + } } /** Sends the configured response or runtime error. */ @@ -67,6 +71,7 @@ describe('WasmRuntimeProbe', () => { (WasmRuntimeProbe as unknown as { cachedResult?: unknown }).cachedResult = undefined; workerResponse = undefined; workerRuntimeError = false; + workerConstructionError = false; workerConstructCount = 0; }); @@ -75,19 +80,15 @@ describe('WasmRuntimeProbe', () => { }); it('should return DISABLED when WebAssembly is hard-disabled', async () => { - expect.assertions(8); + expect.assertions(4); delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.DISABLED); expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.unknownReason).toBeNull(); - expect(result.uncertainReason).toBeNull(); - expect(result.uncertainDetail).toBeNull(); - expect(result.divRatio).toBeNull(); - expect(result.addNsPerOp).toBeNull(); - expect(result.divMedianMs).toBeNull(); + expect(result.reason).toBeNull(); + expect(result.measurements).toBeNull(); }); it('should return UNKNOWN when Web Workers are not available', async () => { @@ -97,7 +98,7 @@ describe('WasmRuntimeProbe', () => { expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); expect(result.capability).toBe(CapabilityState.UNKNOWN); - expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_UNAVAILABLE); + expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_UNAVAILABLE); }); describe('worker benchmark', () => { @@ -125,18 +126,28 @@ describe('WasmRuntimeProbe', () => { delete (URL as { revokeObjectURL?: unknown }).revokeObjectURL; }); + it('should revoke the Blob URL when Worker construction fails', async () => { + expect.assertions(3); + workerConstructionError = true; + + const result = await WasmRuntimeProbe.check(); + + expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_START_FAILED); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock'); + }); + it('should return OK when the op-cost ratios show native (JIT) speed', async () => { - expect.assertions(6); + expect.assertions(5); workerResponse = FAST_RESPONSE; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.OK); expect(result.capability).toBe(CapabilityState.CAPABLE); - expect(result.unknownReason).toBeNull(); - expect(result.uncertainReason).toBeNull(); - expect(result.divRatio).toBeCloseTo(5.48, 1); - expect(result.addNsPerOp).toBeCloseTo(0.406, 2); + expect(result.reason).toBeNull(); + expect(result.measurements?.divRatio).toBeCloseTo(5.48, 1); + expect(result.measurements?.addNsPerOp).toBeCloseTo(0.406, 2); }); it('should return SLOW when the div/add ratio collapses (interpreter)', async () => { @@ -147,13 +158,13 @@ describe('WasmRuntimeProbe', () => { expect(result.status).toBe(WasmRuntimeStatus.SLOW); expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.divRatio).toBeCloseTo(1.888, 1); - expect(result.addNsPerOp).toBeGreaterThan(1.2); - expect(result.uncertainReason).toBeNull(); + expect(result.measurements?.divRatio).toBeCloseTo(1.888, 1); + expect(result.measurements?.addNsPerOp).toBeGreaterThan(1.2); + expect(result.reason).toBeNull(); }); it('should return UNCERTAIN when a fast ratio contradicts an interpreter-slow add', async () => { - expect.assertions(5); + expect.assertions(4); workerResponse = { ok: true, ops: OPS, @@ -166,15 +177,16 @@ describe('WasmRuntimeProbe', () => { expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); expect(result.capability).toBe(CapabilityState.UNKNOWN); - expect(result.uncertainReason).toBe(WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD); - expect(result.uncertainDetail).toContain( - 'Measurements do not clearly classify WASM as fast or slow.' - ); - expect(result.uncertainDetail).toMatch(/divRatio=5, sqrtRatio=12, addNsPerOp=2\.5/); + expect(result.reason).toBe(WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD); + expect(result.measurements).toMatchObject({ + divRatio: 5, + sqrtRatio: 12, + addNsPerOp: 2.5, + }); }); it('should return UNCERTAIN when the ratios fall between the fast and slow bars', async () => { - expect.assertions(3); + expect.assertions(2); workerResponse = { ok: true, ops: OPS, @@ -186,12 +198,11 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); - expect(result.uncertainReason).toBe(WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS); - expect(result.uncertainDetail).toContain('addMedianMs=10ms'); + expect(result.reason).toBe(WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS); }); it('should return UNKNOWN when the div kernel is too small to have really run', async () => { - expect.assertions(2); + expect.assertions(3); workerResponse = { ok: true, ops: OPS, @@ -203,7 +214,8 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL); + expect(result.reason).toBe(WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL); + expect(result.measurements?.divMedianMs).toBe(2); }); it('should return UNKNOWN when the worker reports a failure', async () => { @@ -213,7 +225,7 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED); + expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED); }); it('should return UNKNOWN when the worker raises a runtime error', async () => { @@ -223,37 +235,38 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR); + expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR); }); it('should return UNKNOWN when a measurement field is missing', async () => { - expect.assertions(2); + expect.assertions(3); workerResponse = { ok: true, ops: OPS, addMedianMs: 6.5 }; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.INVALID_MEASUREMENT); + expect(result.reason).toBe(WasmRuntimeUnknownReason.INVALID_MEASUREMENT); + expect(result.measurements).toBeNull(); }); - it('should return UNKNOWN when addMedianMs is not positive', async () => { + it('should return UNKNOWN when a measurement is not positive', async () => { expect.assertions(2); workerResponse = { ok: true, ops: OPS, - addMedianMs: 0, - divMedianMs: 60, + addMedianMs: 32, + divMedianMs: -1, sqrtMedianMs: 110, }; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.INVALID_MEASUREMENT); + expect(result.reason).toBe(WasmRuntimeUnknownReason.INVALID_MEASUREMENT); }); it('should return UNKNOWN when the worker does not reply before the timeout', async () => { - expect.assertions(2); + expect.assertions(3); jest.useFakeTimers(); workerResponse = undefined; @@ -262,7 +275,8 @@ describe('WasmRuntimeProbe', () => { const result = await promise; expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.unknownReason).toBe(WasmRuntimeUnknownReason.WORKER_TIMEOUT); + expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_TIMEOUT); + expect(result.measurements).toBeNull(); jest.useRealTimers(); }); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index 4ea8db0..f4164a2 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -17,17 +17,11 @@ export enum WasmRuntimeStatus { /** Reasons a probe returns {@link WasmRuntimeStatus.UNKNOWN}. */ export enum WasmRuntimeUnknownReason { - /** No Web Worker or Blob URL support in this environment. */ WORKER_UNAVAILABLE = 'worker_unavailable', - /** Worker or Blob URL creation threw. */ WORKER_START_FAILED = 'worker_start_failed', - /** Benchmark did not finish before the configured worker timeout. */ WORKER_TIMEOUT = 'worker_timeout', - /** Worker raised an error before returning a response. */ WORKER_RUNTIME_ERROR = 'worker_runtime_error', - /** Worker completed but reported that the benchmark failed. */ WORKER_BENCHMARK_FAILED = 'worker_benchmark_failed', - /** Worker response is incomplete or contains an invalid timing. */ INVALID_MEASUREMENT = 'invalid_measurement', /** Page was hidden during the benchmark, which can distort its timing. */ BACKGROUND_TAB = 'background_tab', @@ -43,27 +37,25 @@ export enum WasmRuntimeUncertainReason { RATIOS_BETWEEN_THRESHOLDS = 'ratios_between_thresholds', } -interface WasmRuntimeMeasurements { +/** Additional context for an unknown or uncertain result. */ +export type WasmRuntimeReason = WasmRuntimeUnknownReason | WasmRuntimeUncertainReason; + +/** Measurements produced by a completed benchmark. */ +export interface WasmRuntimeMeasurements { + /** Divide time relative to add time. */ divRatio: number; + /** Square root time relative to add time. */ sqrtRatio: number; + /** Time for one add operation in nanoseconds. */ addNsPerOp: number; + /** Typical add benchmark time in milliseconds. */ addMedianMs: number; + /** Typical divide benchmark time in milliseconds. */ divMedianMs: number; + /** Typical square root benchmark time in milliseconds. */ sqrtMedianMs: number; } -/** Keeps the human-readable uncertainty message consistent in logs and telemetry. */ -const UNCERTAIN_DETAIL_PREFIX = 'Measurements do not clearly classify WASM as fast or slow.'; - -/** - * Formats an uncertain result for logs and telemetry. - * - * @param measurements - Values used to classify the WASM runtime. - * @returns A generic explanation followed by the measurement values. - */ -const formatUncertainDetail = (measurements: WasmRuntimeMeasurements): string => - `${UNCERTAIN_DETAIL_PREFIX} divRatio=${measurements.divRatio}, sqrtRatio=${measurements.sqrtRatio}, addNsPerOp=${measurements.addNsPerOp}, addMedianMs=${measurements.addMedianMs}ms, divMedianMs=${measurements.divMedianMs}ms, sqrtMedianMs=${measurements.sqrtMedianMs}ms`; - /** * Result of the WASM runtime probe. Used to decide whether to allow real-time * WASM effects (BNR, VBG), which run poorly when the browser runs WASM through a @@ -74,27 +66,10 @@ export interface WasmRuntimeResult { status: WasmRuntimeStatus; /** Product capability derived from {@link status}. */ capability: CapabilityState; - /** See {@link WasmRuntimeUnknownReason}. */ - unknownReason: WasmRuntimeUnknownReason | null; - /** See {@link WasmRuntimeUncertainReason}. */ - uncertainReason: WasmRuntimeUncertainReason | null; - /** Human-readable uncertainty explanation with measurements for logs and telemetry. */ - uncertainDetail: string | null; - /** Divide time relative to add time. High suggests JIT. Low near 2 suggests an interpreter. */ - divRatio: number | null; - /** - * Square root time relative to add time. Uses a different CPU execution unit than - * divide, providing an independent signal. - */ - sqrtRatio: number | null; - /** Time for one add operation in nanoseconds. Used as a slow signal, but not to mark OK. */ - addNsPerOp: number | null; - /** Typical add benchmark time in milliseconds. */ - addMedianMs: number | null; - /** Typical divide benchmark time in milliseconds. */ - divMedianMs: number | null; - /** Typical square root benchmark time in milliseconds. */ - sqrtMedianMs: number | null; + /** Additional context for an unknown or uncertain status. */ + reason: WasmRuntimeReason | null; + /** Benchmark measurements when useful data was produced. */ + measurements: WasmRuntimeMeasurements | null; } /** Divide ratio at or above this value indicates fast WASM. */ @@ -137,6 +112,17 @@ interface WorkerResponse { sqrtMedianMs?: number; } +/** + * Checks that a worker response contains complete, usable measurements. + * + * @param response - Worker response to validate. + * @returns Whether every measurement is finite and positive. + */ +const hasValidMeasurements = (response: WorkerResponse): response is Required => + [response.ops, response.addMedianMs, response.divMedianMs, response.sqrtMedianMs].every( + (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 + ); + type WorkerResponseOutcome = | { type: 'response'; response: WorkerResponse } | { @@ -169,55 +155,23 @@ export class WasmRuntimeProbe { } /** - * Keeps status-specific reason and detail fields consistent. + * Derives capability while keeping result construction in one place. * * @param status - Classified status. - * @param measurements - Available benchmark measurements. - * @param unknownReason - Optional unknown result detail. - * @param uncertainReason - Optional uncertain result detail. + * @param reason - Additional result context. + * @param measurements - Useful benchmark measurements. * @returns Assembled {@link WasmRuntimeResult}. */ private static buildResult( status: WasmRuntimeStatus, - measurements?: Partial, - unknownReason?: WasmRuntimeUnknownReason, - uncertainReason?: WasmRuntimeUncertainReason + reason: WasmRuntimeReason | null = null, + measurements: WasmRuntimeMeasurements | null = null ): WasmRuntimeResult { - const hasFullMetrics = - measurements?.divRatio !== undefined && - measurements.sqrtRatio !== undefined && - measurements.addNsPerOp !== undefined && - measurements.addMedianMs !== undefined && - measurements.divMedianMs !== undefined && - measurements.sqrtMedianMs !== undefined; - - const uncertainDetail = - status === WasmRuntimeStatus.UNCERTAIN && hasFullMetrics - ? formatUncertainDetail({ - divRatio: measurements.divRatio as number, - sqrtRatio: measurements.sqrtRatio as number, - addNsPerOp: measurements.addNsPerOp as number, - addMedianMs: measurements.addMedianMs as number, - divMedianMs: measurements.divMedianMs as number, - sqrtMedianMs: measurements.sqrtMedianMs as number, - }) - : null; - return { status, capability: statusToCapability(status), - unknownReason: - status === WasmRuntimeStatus.UNKNOWN - ? unknownReason ?? WasmRuntimeUnknownReason.INVALID_MEASUREMENT - : null, - uncertainReason: status === WasmRuntimeStatus.UNCERTAIN ? uncertainReason ?? null : null, - uncertainDetail, - divRatio: measurements?.divRatio ?? null, - sqrtRatio: measurements?.sqrtRatio ?? null, - addNsPerOp: measurements?.addNsPerOp ?? null, - addMedianMs: measurements?.addMedianMs ?? null, - divMedianMs: measurements?.divMedianMs ?? null, - sqrtMedianMs: measurements?.sqrtMedianMs ?? null, + reason, + measurements, }; } @@ -238,7 +192,6 @@ export class WasmRuntimeProbe { ) { return this.buildResult( WasmRuntimeStatus.UNKNOWN, - undefined, WasmRuntimeUnknownReason.WORKER_UNAVAILABLE ); } @@ -247,7 +200,6 @@ export class WasmRuntimeProbe { if (!started) { return this.buildResult( WasmRuntimeStatus.UNKNOWN, - undefined, WasmRuntimeUnknownReason.WORKER_START_FAILED ); } @@ -280,7 +232,7 @@ export class WasmRuntimeProbe { }); if (outcome.type === 'no_response') { - return this.buildResult(WasmRuntimeStatus.UNKNOWN, undefined, outcome.reason); + return this.buildResult(WasmRuntimeStatus.UNKNOWN, outcome.reason); } return this.classify(outcome.response); @@ -301,74 +253,59 @@ export class WasmRuntimeProbe { if (!response.ok) { return this.buildResult( WasmRuntimeStatus.UNKNOWN, - undefined, WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED ); } - if ( - typeof response.ops !== 'number' || - response.ops <= 0 || - typeof response.addMedianMs !== 'number' || - response.addMedianMs <= 0 || - typeof response.divMedianMs !== 'number' || - typeof response.sqrtMedianMs !== 'number' - ) { + if (!hasValidMeasurements(response)) { return this.buildResult( WasmRuntimeStatus.UNKNOWN, - undefined, WasmRuntimeUnknownReason.INVALID_MEASUREMENT ); } const { addMedianMs, divMedianMs, sqrtMedianMs } = response; // eslint-disable-next-line jsdoc/require-jsdoc - const round3 = (v: number): number => Number(v.toFixed(3)); + const roundToThreeDecimals = (value: number): number => Number(value.toFixed(3)); const divRatio = divMedianMs / addMedianMs; const sqrtRatio = sqrtMedianMs / addMedianMs; const addNsPerOp = (addMedianMs * 1e6) / response.ops; - const fastSignal = divRatio >= DIV_FAST_RATIO || sqrtRatio >= SQRT_FAST_RATIO; - const slowSignalRatio = divRatio <= DIV_SLOW_RATIO; - const interpAbs = addNsPerOp > INTERP_ADD_NS_FLOOR; - const workRan = divMedianMs >= MIN_DIV_MEDIAN_MS; - const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; + const hasFastRatio = divRatio >= DIV_FAST_RATIO || sqrtRatio >= SQRT_FAST_RATIO; + const hasSlowDivideRatio = divRatio <= DIV_SLOW_RATIO; + const hasSlowAddCost = addNsPerOp > INTERP_ADD_NS_FLOOR; + const hasSufficientDivideTiming = divMedianMs >= MIN_DIV_MEDIAN_MS; + const isPageHidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; let status: WasmRuntimeStatus; - let unknownReason: WasmRuntimeUnknownReason | undefined; - let uncertainReason: WasmRuntimeUncertainReason | undefined; - if (hidden) { + let reason: WasmRuntimeReason | null = null; + if (isPageHidden) { status = WasmRuntimeStatus.UNKNOWN; - unknownReason = WasmRuntimeUnknownReason.BACKGROUND_TAB; - } else if (!workRan) { + reason = WasmRuntimeUnknownReason.BACKGROUND_TAB; + } else if (!hasSufficientDivideTiming) { status = WasmRuntimeStatus.UNKNOWN; - unknownReason = WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL; - } else if (fastSignal && interpAbs) { + reason = WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL; + } else if (hasFastRatio && hasSlowAddCost) { status = WasmRuntimeStatus.UNCERTAIN; - uncertainReason = WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD; - } else if (fastSignal) { + reason = WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD; + } else if (hasFastRatio) { status = WasmRuntimeStatus.OK; - } else if (slowSignalRatio || interpAbs) { + } else if (hasSlowDivideRatio || hasSlowAddCost) { status = WasmRuntimeStatus.SLOW; } else { status = WasmRuntimeStatus.UNCERTAIN; - uncertainReason = WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS; + reason = WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS; } - return this.buildResult( - status, - { - divRatio: round3(divRatio), - sqrtRatio: round3(sqrtRatio), - addNsPerOp: round3(addNsPerOp), - addMedianMs: round3(addMedianMs), - divMedianMs: round3(divMedianMs), - sqrtMedianMs: round3(sqrtMedianMs), - }, - unknownReason, - uncertainReason - ); + return this.buildResult(status, reason, { + divRatio: roundToThreeDecimals(divRatio), + sqrtRatio: roundToThreeDecimals(sqrtRatio), + addNsPerOp: roundToThreeDecimals(addNsPerOp), + addMedianMs: roundToThreeDecimals(addMedianMs), + divMedianMs: roundToThreeDecimals(divMedianMs), + sqrtMedianMs: roundToThreeDecimals(sqrtMedianMs), + }); } /** diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js index c027b1f..9e74354 100644 --- a/src/wasm-runtime-probe.worker.js +++ b/src/wasm-runtime-probe.worker.js @@ -1,8 +1,9 @@ /* * WASM runtime benchmark worker. Inlined into wasm-runtime-probe.ts via Blob URL. * - * Times dependent-chain add, div, and sqrt kernels. The main thread compares - * div/add and sqrt/add ratios so CPU clock speed mostly cancels out. + * Times add, divide, and square root WASM loops. Each operation uses the previous + * result so the browser cannot run several operations at once. The main thread + * compares divide/add and square root/add ratios so CPU speed mostly cancels out. * * Counterintuitive: HIGH ratio means JIT OK. LOW near 2 means interpreted WASM. * Thresholds live in wasm-runtime-probe.ts. Do not invert ratios when classifying. @@ -90,7 +91,7 @@ self.onmessage = function onProbeStart() { ); var exports = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; - // Median dampens one bad scheduler slice in a trial. + // Median reduces the effect of one unusually slow trial. var median = function median(samples) { var sorted = samples.slice().sort(function ascending(a, b) { return a - b; @@ -98,7 +99,7 @@ self.onmessage = function onProbeStart() { return sorted[Math.floor(sorted.length / 2)]; }; - // Tier-up before timing so JIT-on engines are measured compiled, not cold. + // Run each function before timing so the browser can compile it. exports.add(200000); exports.div(200000); exports.sqrt(200000); @@ -106,7 +107,7 @@ self.onmessage = function onProbeStart() { exports.div(200000); exports.sqrt(200000); - // Round-robin trials so load spikes affect all three kernels equally. + // Alternate operations so temporary system load affects them similarly. var addMs = []; var divMs = []; var sqrtMs = []; From efe17dd450c65e95c07f75b2e7775755b5515269 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 18:16:50 +0200 Subject: [PATCH 15/17] test: update test cases --- src/wasm-runtime-probe.spec.ts | 138 +++++++++++++++---------------- src/wasm-runtime-probe.ts | 23 +++--- src/wasm-runtime-probe.worker.js | 28 ++++--- 3 files changed, 98 insertions(+), 91 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index 0021156..4c297e8 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -6,7 +6,7 @@ import { } from './wasm-runtime-probe'; import { CapabilityState } from './web-capabilities'; -interface FakeWorkerResponse { +interface MockWorkerResponse { ok: boolean; ops?: number; addMedianMs?: number; @@ -14,52 +14,52 @@ interface FakeWorkerResponse { sqrtMedianMs?: number; } -const OPS = 16_000_000; +const TOTAL_OPERATIONS = 16_000_000; -const FAST_RESPONSE: FakeWorkerResponse = { +const FAST_BENCHMARK_RESPONSE: MockWorkerResponse = { ok: true, - ops: OPS, + ops: TOTAL_OPERATIONS, addMedianMs: 6.5, divMedianMs: 35.6, sqrtMedianMs: 80.6, }; -const SLOW_RESPONSE: FakeWorkerResponse = { +const SLOW_BENCHMARK_RESPONSE: MockWorkerResponse = { ok: true, - ops: OPS, + ops: TOTAL_OPERATIONS, addMedianMs: 32.1, divMedianMs: 60.6, sqrtMedianMs: 111.5, }; -let workerResponse: FakeWorkerResponse | undefined; -let workerRuntimeError = false; -let workerConstructionError = false; -let workerConstructCount = 0; +let workerResponse: MockWorkerResponse | undefined; +let shouldRaiseWorkerRuntimeError = false; +let shouldFailWorkerConstruction = false; +let workerConstructionCount = 0; -/** Stand-in Worker for jsdom. */ +/** Mock Worker controlled by test state. */ class MockWorker { - onmessage: ((event: { data: FakeWorkerResponse }) => void) | null = null; + onmessage: ((event: { data: MockWorkerResponse }) => void) | null = null; onerror: (() => void) | null = null; - /** Tracks how many workers tests constructed. */ + /** Creates a worker or simulates a construction failure. */ constructor() { - workerConstructCount += 1; - if (workerConstructionError) { + workerConstructionCount += 1; + if (shouldFailWorkerConstruction) { throw new Error('Worker construction failed'); } } - /** Sends the configured response or runtime error. */ + /** Delivers the configured worker outcome. */ postMessage(): void { - if (workerRuntimeError && this.onerror) { + if (shouldRaiseWorkerRuntimeError && this.onerror) { this.onerror(); } else if (workerResponse && this.onmessage) { this.onmessage({ data: workerResponse }); } } - /** No-op for API parity. */ + /** Matches the Worker API without test cleanup. */ // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-empty-function terminate(): void {} } @@ -70,16 +70,16 @@ describe('WasmRuntimeProbe', () => { beforeEach(() => { (WasmRuntimeProbe as unknown as { cachedResult?: unknown }).cachedResult = undefined; workerResponse = undefined; - workerRuntimeError = false; - workerConstructionError = false; - workerConstructCount = 0; + shouldRaiseWorkerRuntimeError = false; + shouldFailWorkerConstruction = false; + workerConstructionCount = 0; }); afterEach(() => { (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; }); - it('should return DISABLED when WebAssembly is hard-disabled', async () => { + it('should return DISABLED when WebAssembly is unavailable', async () => { expect.assertions(4); delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; @@ -91,7 +91,7 @@ describe('WasmRuntimeProbe', () => { expect(result.measurements).toBeNull(); }); - it('should return UNKNOWN when Web Workers are not available', async () => { + it('should return UNKNOWN when Web Workers are unavailable', async () => { expect.assertions(3); const result = await WasmRuntimeProbe.check(); @@ -128,7 +128,7 @@ describe('WasmRuntimeProbe', () => { it('should revoke the Blob URL when Worker construction fails', async () => { expect.assertions(3); - workerConstructionError = true; + shouldFailWorkerConstruction = true; const result = await WasmRuntimeProbe.check(); @@ -137,37 +137,49 @@ describe('WasmRuntimeProbe', () => { expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock'); }); - it('should return OK when the op-cost ratios show native (JIT) speed', async () => { - expect.assertions(5); - workerResponse = FAST_RESPONSE; + it('should return OK when measurements indicate fast WASM', async () => { + expect.assertions(4); + workerResponse = FAST_BENCHMARK_RESPONSE; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.OK); expect(result.capability).toBe(CapabilityState.CAPABLE); expect(result.reason).toBeNull(); - expect(result.measurements?.divRatio).toBeCloseTo(5.48, 1); - expect(result.measurements?.addNsPerOp).toBeCloseTo(0.406, 2); + expect(result.measurements).toMatchObject({ + divRatio: 5.477, + sqrtRatio: 12.4, + addNsPerOp: 0.406, + addMedianMs: 6.5, + divMedianMs: 35.6, + sqrtMedianMs: 80.6, + }); }); - it('should return SLOW when the div/add ratio collapses (interpreter)', async () => { - expect.assertions(5); - workerResponse = SLOW_RESPONSE; + it('should return SLOW when measurements indicate slow WASM', async () => { + expect.assertions(4); + workerResponse = SLOW_BENCHMARK_RESPONSE; const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.SLOW); expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.measurements?.divRatio).toBeCloseTo(1.888, 1); - expect(result.measurements?.addNsPerOp).toBeGreaterThan(1.2); + expect(result.measurements).toMatchObject({ + divRatio: 1.888, + sqrtRatio: 3.474, + addNsPerOp: 2.006, + addMedianMs: 32.1, + divMedianMs: 60.6, + sqrtMedianMs: 111.5, + }); expect(result.reason).toBeNull(); }); - it('should return UNCERTAIN when a fast ratio contradicts an interpreter-slow add', async () => { + it('should return UNCERTAIN when fast ratios conflict with slow add timing', async () => { expect.assertions(4); workerResponse = { ok: true, - ops: OPS, + ops: TOTAL_OPERATIONS, addMedianMs: 40, divMedianMs: 200, sqrtMedianMs: 480, @@ -185,11 +197,11 @@ describe('WasmRuntimeProbe', () => { }); }); - it('should return UNCERTAIN when the ratios fall between the fast and slow bars', async () => { + it('should return UNCERTAIN when ratios are between thresholds', async () => { expect.assertions(2); workerResponse = { ok: true, - ops: OPS, + ops: TOTAL_OPERATIONS, addMedianMs: 10, divMedianMs: 35, sqrtMedianMs: 70, @@ -201,11 +213,11 @@ describe('WasmRuntimeProbe', () => { expect(result.reason).toBe(WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS); }); - it('should return UNKNOWN when the div kernel is too small to have really run', async () => { + it('should return UNKNOWN when divide timing is too short', async () => { expect.assertions(3); workerResponse = { ok: true, - ops: OPS, + ops: TOTAL_OPERATIONS, addMedianMs: 1, divMedianMs: 2, sqrtMedianMs: 5, @@ -214,11 +226,11 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL); - expect(result.measurements?.divMedianMs).toBe(2); + expect(result.reason).toBe(WasmRuntimeUnknownReason.DIV_TIMING_TOO_SHORT); + expect(result.measurements).toMatchObject({ divMedianMs: 2 }); }); - it('should return UNKNOWN when the worker reports a failure', async () => { + it('should return UNKNOWN when the worker reports benchmark failure', async () => { expect.assertions(2); workerResponse = { ok: false }; @@ -230,7 +242,7 @@ describe('WasmRuntimeProbe', () => { it('should return UNKNOWN when the worker raises a runtime error', async () => { expect.assertions(2); - workerRuntimeError = true; + shouldRaiseWorkerRuntimeError = true; const result = await WasmRuntimeProbe.check(); @@ -238,34 +250,20 @@ describe('WasmRuntimeProbe', () => { expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR); }); - it('should return UNKNOWN when a measurement field is missing', async () => { - expect.assertions(3); - workerResponse = { ok: true, ops: OPS, addMedianMs: 6.5 }; - - const result = await WasmRuntimeProbe.check(); - - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.INVALID_MEASUREMENT); - expect(result.measurements).toBeNull(); - }); - - it('should return UNKNOWN when a measurement is not positive', async () => { - expect.assertions(2); - workerResponse = { - ok: true, - ops: OPS, - addMedianMs: 32, - divMedianMs: -1, - sqrtMedianMs: 110, - }; + it('should reject an incomplete worker response', async () => { + expect.assertions(1); + workerResponse = { ok: true, ops: TOTAL_OPERATIONS, addMedianMs: 6.5 }; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.INVALID_MEASUREMENT); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.INVALID_MEASUREMENT, + measurements: null, + }); }); - it('should return UNKNOWN when the worker does not reply before the timeout', async () => { + it('should return UNKNOWN when the worker times out', async () => { expect.assertions(3); jest.useFakeTimers(); workerResponse = undefined; @@ -280,16 +278,16 @@ describe('WasmRuntimeProbe', () => { jest.useRealTimers(); }); - it('should cache the result so repeated calls run the benchmark only once', async () => { + it('should run the benchmark once for repeated checks', async () => { expect.assertions(2); - workerResponse = FAST_RESPONSE; + workerResponse = FAST_BENCHMARK_RESPONSE; const first = WasmRuntimeProbe.check(); const second = WasmRuntimeProbe.check(); expect(first).toBe(second); await first; - expect(workerConstructCount).toBe(1); + expect(workerConstructionCount).toBe(1); }); }); }); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index f4164a2..bacbdc3 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -26,7 +26,7 @@ export enum WasmRuntimeUnknownReason { /** Page was hidden during the benchmark, which can distort its timing. */ BACKGROUND_TAB = 'background_tab', /** Divide benchmark finished too quickly to classify the runtime. */ - TIMING_SAMPLE_TOO_SMALL = 'timing_sample_too_small', + DIV_TIMING_TOO_SHORT = 'div_timing_too_short', } /** Reasons a probe returns {@link WasmRuntimeStatus.UNCERTAIN}. */ @@ -78,8 +78,8 @@ const DIV_FAST_RATIO = 4.0; const DIV_SLOW_RATIO = 3.0; /** Square root ratio at or above this value provides another fast WASM signal. */ const SQRT_FAST_RATIO = 8.0; -/** Add cost above this value indicates slow WASM unless a fast ratio disagrees. */ -const INTERP_ADD_NS_FLOOR = 1.2; +/** Add time per operation above this value indicates slow WASM unless a fast ratio disagrees. */ +const SLOW_ADD_NS_PER_OP_THRESHOLD = 1.2; /** Divide samples below this duration are too short to classify. */ const MIN_DIV_MEDIAN_MS = 8; /** Maximum time allowed for the worker benchmark to finish. */ @@ -270,12 +270,13 @@ export class WasmRuntimeProbe { const divRatio = divMedianMs / addMedianMs; const sqrtRatio = sqrtMedianMs / addMedianMs; - const addNsPerOp = (addMedianMs * 1e6) / response.ops; + const addNsPerOp = (addMedianMs * 1_000_000) / response.ops; + // CPUs can handle divide and square root differently, so either fast ratio is enough. const hasFastRatio = divRatio >= DIV_FAST_RATIO || sqrtRatio >= SQRT_FAST_RATIO; - const hasSlowDivideRatio = divRatio <= DIV_SLOW_RATIO; - const hasSlowAddCost = addNsPerOp > INTERP_ADD_NS_FLOOR; - const hasSufficientDivideTiming = divMedianMs >= MIN_DIV_MEDIAN_MS; + const hasSlowDivRatio = divRatio <= DIV_SLOW_RATIO; + const isAddTimingSlow = addNsPerOp > SLOW_ADD_NS_PER_OP_THRESHOLD; + const hasSufficientDivTiming = divMedianMs >= MIN_DIV_MEDIAN_MS; const isPageHidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; let status: WasmRuntimeStatus; @@ -283,15 +284,15 @@ export class WasmRuntimeProbe { if (isPageHidden) { status = WasmRuntimeStatus.UNKNOWN; reason = WasmRuntimeUnknownReason.BACKGROUND_TAB; - } else if (!hasSufficientDivideTiming) { + } else if (!hasSufficientDivTiming) { status = WasmRuntimeStatus.UNKNOWN; - reason = WasmRuntimeUnknownReason.TIMING_SAMPLE_TOO_SMALL; - } else if (hasFastRatio && hasSlowAddCost) { + reason = WasmRuntimeUnknownReason.DIV_TIMING_TOO_SHORT; + } else if (hasFastRatio && isAddTimingSlow) { status = WasmRuntimeStatus.UNCERTAIN; reason = WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD; } else if (hasFastRatio) { status = WasmRuntimeStatus.OK; - } else if (hasSlowDivideRatio || hasSlowAddCost) { + } else if (hasSlowDivRatio || isAddTimingSlow) { status = WasmRuntimeStatus.SLOW; } else { status = WasmRuntimeStatus.UNCERTAIN; diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js index 9e74354..7aa53b7 100644 --- a/src/wasm-runtime-probe.worker.js +++ b/src/wasm-runtime-probe.worker.js @@ -12,11 +12,14 @@ */ self.onmessage = function onProbeStart() { try { - var UNROLL = 16; + // Each function runs 1 million loops with 16 operations per loop. + var OPERATIONS_PER_LOOP = 16; var LOOPS = 1000000; - var OPS = LOOPS * UNROLL; + var OPS = LOOPS * OPERATIONS_PER_LOOP; var TRIALS = 5; + var WARMUP_LOOPS = 200000; + // Build the WASM binary in memory so the probe does not need a separate file. var encodeU32 = function encodeU32(value) { var out = []; do { @@ -46,6 +49,7 @@ self.onmessage = function onProbeStart() { var I32 = 0x7f; var F64 = 0x7c; + // Define three exported functions. Add and divide return i32, while square root returns f64. var typeSec = section( 1, encodeU32(2) @@ -61,22 +65,25 @@ self.onmessage = function onProbeStart() { .concat(exportEntry('sqrt', 2)) ); + // Build an integer loop where each result becomes the input to the next operation. var buildIntBody = function buildIntBody(opcode) { var body = [1, 3, I32].concat([0x03, 0x40]); body = body.concat([0x20, 0x01, 0x41, 0x01, 0x72, 0x21, 0x03]); - for (var k = 0; k < UNROLL; k++) { + for (var k = 0; k < OPERATIONS_PER_LOOP; k++) { body = body.concat([0x20, 0x02, 0x20, 0x03, opcode, 0x21, 0x02]); } body = body.concat([0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00]); body = body.concat([0x0b, 0x20, 0x02, 0x0b]); return encodeU32(body.length).concat(body); }; + // These WASM opcodes select i32.add and unsigned i32.div. var addCode = buildIntBody(0x6a); var divCode = buildIntBody(0x6e); + // Build the same dependency pattern for floating-point square root. var buildSqrtBody = function buildSqrtBody() { var body = [2, 1, I32, 1, F64].concat([0x03, 0x40]); - for (var k = 0; k < UNROLL; k++) { + for (var k = 0; k < OPERATIONS_PER_LOOP; k++) { body = body.concat([0x20, 0x02, 0x20, 0x01, 0x41, 0x01, 0x72, 0xb8, 0xa0, 0x9f, 0x21, 0x02]); } body = body.concat([0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00]); @@ -85,6 +92,7 @@ self.onmessage = function onProbeStart() { }; var sqrtCode = buildSqrtBody(); + // Assemble and compile the module once before any timed trials. var codeSec = section(10, encodeU32(3).concat(addCode).concat(divCode).concat(sqrtCode)); var bytes = new Uint8Array( [0, 0x61, 0x73, 0x6d, 1, 0, 0, 0].concat(typeSec, funcSec, exportSec, codeSec) @@ -100,12 +108,12 @@ self.onmessage = function onProbeStart() { }; // Run each function before timing so the browser can compile it. - exports.add(200000); - exports.div(200000); - exports.sqrt(200000); - exports.add(200000); - exports.div(200000); - exports.sqrt(200000); + exports.add(WARMUP_LOOPS); + exports.div(WARMUP_LOOPS); + exports.sqrt(WARMUP_LOOPS); + exports.add(WARMUP_LOOPS); + exports.div(WARMUP_LOOPS); + exports.sqrt(WARMUP_LOOPS); // Alternate operations so temporary system load affects them similarly. var addMs = []; From 263d26a6668440571d992b4323ac94c02a6ea2ba Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 18:18:34 +0200 Subject: [PATCH 16/17] test: update test toMatchObject --- src/wasm-runtime-probe.spec.ts | 145 +++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 59 deletions(-) diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index 4c297e8..248284f 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -80,25 +80,30 @@ describe('WasmRuntimeProbe', () => { }); it('should return DISABLED when WebAssembly is unavailable', async () => { - expect.assertions(4); + expect.assertions(1); delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.DISABLED); - expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.reason).toBeNull(); - expect(result.measurements).toBeNull(); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.DISABLED, + capability: CapabilityState.NOT_CAPABLE, + reason: null, + measurements: null, + }); }); it('should return UNKNOWN when Web Workers are unavailable', async () => { - expect.assertions(3); + expect.assertions(1); const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.capability).toBe(CapabilityState.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_UNAVAILABLE); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + capability: CapabilityState.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_UNAVAILABLE, + measurements: null, + }); }); describe('worker benchmark', () => { @@ -127,56 +132,63 @@ describe('WasmRuntimeProbe', () => { }); it('should revoke the Blob URL when Worker construction fails', async () => { - expect.assertions(3); + expect.assertions(2); shouldFailWorkerConstruction = true; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_START_FAILED); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_START_FAILED, + measurements: null, + }); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock'); }); it('should return OK when measurements indicate fast WASM', async () => { - expect.assertions(4); + expect.assertions(1); workerResponse = FAST_BENCHMARK_RESPONSE; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.OK); - expect(result.capability).toBe(CapabilityState.CAPABLE); - expect(result.reason).toBeNull(); - expect(result.measurements).toMatchObject({ - divRatio: 5.477, - sqrtRatio: 12.4, - addNsPerOp: 0.406, - addMedianMs: 6.5, - divMedianMs: 35.6, - sqrtMedianMs: 80.6, + expect(result).toMatchObject({ + status: WasmRuntimeStatus.OK, + capability: CapabilityState.CAPABLE, + reason: null, + measurements: { + divRatio: 5.477, + sqrtRatio: 12.4, + addNsPerOp: 0.406, + addMedianMs: 6.5, + divMedianMs: 35.6, + sqrtMedianMs: 80.6, + }, }); }); it('should return SLOW when measurements indicate slow WASM', async () => { - expect.assertions(4); + expect.assertions(1); workerResponse = SLOW_BENCHMARK_RESPONSE; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.SLOW); - expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.measurements).toMatchObject({ - divRatio: 1.888, - sqrtRatio: 3.474, - addNsPerOp: 2.006, - addMedianMs: 32.1, - divMedianMs: 60.6, - sqrtMedianMs: 111.5, + expect(result).toMatchObject({ + status: WasmRuntimeStatus.SLOW, + capability: CapabilityState.NOT_CAPABLE, + reason: null, + measurements: { + divRatio: 1.888, + sqrtRatio: 3.474, + addNsPerOp: 2.006, + addMedianMs: 32.1, + divMedianMs: 60.6, + sqrtMedianMs: 111.5, + }, }); - expect(result.reason).toBeNull(); }); it('should return UNCERTAIN when fast ratios conflict with slow add timing', async () => { - expect.assertions(4); + expect.assertions(1); workerResponse = { ok: true, ops: TOTAL_OPERATIONS, @@ -187,18 +199,20 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); - expect(result.capability).toBe(CapabilityState.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD); - expect(result.measurements).toMatchObject({ - divRatio: 5, - sqrtRatio: 12, - addNsPerOp: 2.5, + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNCERTAIN, + capability: CapabilityState.UNKNOWN, + reason: WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD, + measurements: { + divRatio: 5, + sqrtRatio: 12, + addNsPerOp: 2.5, + }, }); }); it('should return UNCERTAIN when ratios are between thresholds', async () => { - expect.assertions(2); + expect.assertions(1); workerResponse = { ok: true, ops: TOTAL_OPERATIONS, @@ -209,12 +223,15 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNCERTAIN); - expect(result.reason).toBe(WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNCERTAIN, + capability: CapabilityState.UNKNOWN, + reason: WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS, + }); }); it('should return UNKNOWN when divide timing is too short', async () => { - expect.assertions(3); + expect.assertions(1); workerResponse = { ok: true, ops: TOTAL_OPERATIONS, @@ -225,29 +242,37 @@ describe('WasmRuntimeProbe', () => { const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.DIV_TIMING_TOO_SHORT); - expect(result.measurements).toMatchObject({ divMedianMs: 2 }); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.DIV_TIMING_TOO_SHORT, + measurements: { divMedianMs: 2 }, + }); }); it('should return UNKNOWN when the worker reports benchmark failure', async () => { - expect.assertions(2); + expect.assertions(1); workerResponse = { ok: false }; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED, + measurements: null, + }); }); it('should return UNKNOWN when the worker raises a runtime error', async () => { - expect.assertions(2); + expect.assertions(1); shouldRaiseWorkerRuntimeError = true; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR, + measurements: null, + }); }); it('should reject an incomplete worker response', async () => { @@ -264,7 +289,7 @@ describe('WasmRuntimeProbe', () => { }); it('should return UNKNOWN when the worker times out', async () => { - expect.assertions(3); + expect.assertions(1); jest.useFakeTimers(); workerResponse = undefined; @@ -272,9 +297,11 @@ describe('WasmRuntimeProbe', () => { jest.advanceTimersByTime(5000); const result = await promise; - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.reason).toBe(WasmRuntimeUnknownReason.WORKER_TIMEOUT); - expect(result.measurements).toBeNull(); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_TIMEOUT, + measurements: null, + }); jest.useRealTimers(); }); From a1339a72d0ee9eed8824d6994a3eb5616370f89a Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 7 Aug 2026 18:41:33 +0200 Subject: [PATCH 17/17] chore: update cspell --- cspell.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/cspell.json b/cspell.json index 89ef530..be61343 100644 --- a/cspell.json +++ b/cspell.json @@ -23,7 +23,6 @@ "dependabot", "eamodio", "editorconfig", - "embedder", "esbenp", "esnext", "execa", @@ -31,7 +30,6 @@ "globby", "gohri", "inferencing", - "interp", "KHTML", "libauth", "mindmeld",