diff --git a/ipc-runtime/ts/src/wasm/backend.ts b/ipc-runtime/ts/src/wasm/backend.ts index 9c7031cf6c6..c5d0de7ff9b 100644 --- a/ipc-runtime/ts/src/wasm/backend.ts +++ b/ipc-runtime/ts/src/wasm/backend.ts @@ -343,7 +343,12 @@ export class WasmFfiBackend implements IpcClientAsync { module, options: { threads: opts.threads, - memory: opts.memory, + memory: { + ...opts.memory, + // The worker cannot tell an iPad from a Mac: its navigator has no maxTouchPoints. + maximum: + opts.memory?.maximum ?? bound.platform.maximumMemoryPages?.(), + }, env: opts.env, entry: opts.entry, }, diff --git a/ipc-runtime/ts/src/wasm/browser/platform.ts b/ipc-runtime/ts/src/wasm/browser/platform.ts index c79f04eb034..3d76c815945 100644 --- a/ipc-runtime/ts/src/wasm/browser/platform.ts +++ b/ipc-runtime/ts/src/wasm/browser/platform.ts @@ -1,4 +1,9 @@ -import type { WasmPlatform, WorkerHandle, WorkerSide } from "../platform.js"; +import { + type WasmPlatform, + type WorkerHandle, + type WorkerSide, + isIos, +} from "../platform.js"; /** The parent's handle on a browser `Worker`. */ export function browserWorkerHandle(worker: Worker): WorkerHandle { @@ -26,10 +31,7 @@ export const browserPlatform: WasmPlatform = { true, // iOS browsers kill a page well before it reaches the 4 GiB a wasm memory may declare; asking // for a 1 GiB maximum keeps the reservation within what they grant. - maximumMemoryPages: () => - typeof navigator !== "undefined" && /iPad|iPhone/.test(navigator.userAgent) - ? 2 ** 14 - : 2 ** 16, + maximumMemoryPages: () => (isIos() ? 2 ** 14 : 2 ** 16), }; /** The worker's side of a browser `Worker` channel. */ diff --git a/ipc-runtime/ts/src/wasm/entry.ts b/ipc-runtime/ts/src/wasm/entry.ts index 53758ab4e72..2679e2a75c6 100644 --- a/ipc-runtime/ts/src/wasm/entry.ts +++ b/ipc-runtime/ts/src/wasm/entry.ts @@ -10,6 +10,7 @@ import { export type { WasmModuleSource } from "./module_source.js"; export type { WasmPlatform, WorkerHandle, WorkerSide } from "./platform.js"; +export { isIos } from "./platform.js"; export type { WasmFfiBackendOptions, WasmFfiBinding, WasmFfiOptions }; export { MAX_THREADS, diff --git a/ipc-runtime/ts/src/wasm/platform.ts b/ipc-runtime/ts/src/wasm/platform.ts index 590e3aa2f2d..4f90cfacdb7 100644 --- a/ipc-runtime/ts/src/wasm/platform.ts +++ b/ipc-runtime/ts/src/wasm/platform.ts @@ -16,10 +16,29 @@ export interface WasmPlatform extends ModuleLoadPlatform { hardwareConcurrency(): number; /** Threads need a shared memory; browsers only allow one under COOP/COEP. */ sharedMemoryAvailable(): boolean; - /** Upper bound on linear memory (64 KiB pages) to ask for when the caller sets none. */ + /** + * Upper bound on linear memory (64 KiB pages) to ask for when the caller sets none. The backend + * reads it on the thread that creates it: in a worker, an iPad reads as a Mac (see `isIos`). + */ maximumMemoryPages?(): number; } +/** + * Whether this runs on an iPhone or an iPad. iPadOS Safari and an iPhone that requests the desktop + * website send a Mac user agent, and only their touch points tell them from a Mac. A worker has no + * `maxTouchPoints`, so there those two devices read as a Mac. + */ +export function isIos(): boolean { + if (typeof navigator === "undefined") { + return false; + } + const { userAgent, maxTouchPoints } = navigator; + return ( + /iPad|iPhone/.test(userAgent) || + (/Macintosh/.test(userAgent) && maxTouchPoints > 1) + ); +} + /** The worker's view of its parent (node `parentPort` or a browser worker global). */ export interface WorkerSide { onMessage(handler: (message: any) => void): void; diff --git a/ipc-runtime/ts/src/wasm_platform.test.ts b/ipc-runtime/ts/src/wasm_platform.test.ts new file mode 100644 index 00000000000..aa60b77fa4d --- /dev/null +++ b/ipc-runtime/ts/src/wasm_platform.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { WasmFfiBackend } from "./wasm/backend.js"; +import { browserPlatform } from "./wasm/browser/platform.js"; +import type { WorkerHandle } from "./wasm/platform.js"; + +const IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)"; +const IPAD = "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X)"; +const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"; + +const realNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator")!; +afterEach(() => Object.defineProperty(globalThis, "navigator", realNavigator)); + +for (const [device, userAgent, maxTouchPoints, pages] of [ + ["an iPhone", IPHONE, 5, 2 ** 14], + ["an iPad", IPAD, 5, 2 ** 14], + ["a Mac user agent with touch points", MAC, 5, 2 ** 14], + ["a Mac", MAC, 0, 2 ** 16], + ["a worker (no touch points) on an iPad or a Mac", MAC, undefined, 2 ** 16], +] as const) { + test(`${device} gets a maximum of ${pages} memory pages`, () => { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent, maxTouchPoints }, + configurable: true, + }); + assert.equal(browserPlatform.maximumMemoryPages!(), pages); + }); +} + +async function mainWorkerMaximum(callerMaximum?: number): Promise { + let init: any; + const handlers: ((message: unknown) => void)[] = []; + const worker: WorkerHandle = { + postMessage: (message) => { + init = message; + queueMicrotask(() => handlers.forEach((h) => h({ type: "ready" }))); + }, + onMessage: (handler) => handlers.push(handler), + onError: () => {}, + terminate: async () => {}, + unref: () => {}, + }; + await WasmFfiBackend.create( + { + module: new Uint8Array([0x00, 0x61, 0x73, 0x6d, 1, 0, 0, 0]), + memory: { maximum: callerMaximum }, + }, + { + platform: { ...browserPlatform, maximumMemoryPages: () => 1234 }, + createMainWorker: () => worker, + createThreadWorker: () => worker, + }, + ); + return init.options.memory.maximum; +} + +test("the main worker gets the caller's memory maximum, else that of the thread that creates it", async () => { + assert.equal(await mainWorkerMaximum(99), 99); + assert.equal(await mainWorkerMaximum(), 1234); +});