diff --git a/e2e/engine-isolation.spec.ts b/e2e/engine-isolation.spec.ts index 0fa5044b..cb22accd 100644 --- a/e2e/engine-isolation.spec.ts +++ b/e2e/engine-isolation.spec.ts @@ -5,7 +5,7 @@ test("supported browsers isolate a fresh visit and run the engine", async ({ page, browserName, }) => { - test.skip(browserName === "webkit", "Safari is explicitly unsupported"); + test.skip(browserName === "webkit", "Safari cannot be cross-origin isolated"); await gotoApp(page); await expect .poll(() => page.evaluate(() => window.crossOriginIsolated)) @@ -22,44 +22,43 @@ test("supported browsers isolate a fresh visit and run the engine", async ({ expect(await page.evaluate(() => window.crossOriginIsolated)).toBe(true); }); -test("unavailable isolation shows an error instead of an endless loading chip", async ({ +test("without isolation the serial engine loads and runs", async ({ browser, browserName, }) => { - test.skip( - browserName === "webkit", - "Safari has a dedicated unsupported message", - ); + // Blocking service workers keeps the page from being cross-origin + // isolated, the situation Safari is always in (no COEP credentialless). + test.skip(browserName === "webkit", "covered by the Safari test below"); const context = await browser.newContext({ serviceWorkers: "block" }); const page = await context.newPage(); + const engineRequests: string[] = []; + page.on("request", (request) => { + if (request.url().includes("lammps-atomify")) + engineRequests.push(request.url()); + }); await page.goto("http://localhost:5200/atomify/"); - await expect(page.getByTestId("engine-error-chip")).toContainText( - "requires cross-origin isolation", - ); - await expect(page.getByTestId("engine-loading-chip")).toHaveCount(0); + expect(await page.evaluate(() => window.crossOriginIsolated)).toBe(false); + await waitForEngine(page); + await expect(page.getByTestId("engine-error-chip")).toHaveCount(0); + await page.getByTestId("nav-examples").click(); + await page.getByTestId("library-quick-2D-lj-fluid").click(); + await waitRunCompleted(page); + expect( + engineRequests.some((url) => url.includes("lammps-atomify-serial")), + ).toBe(true); await context.close(); }); -test("Safari shows an unsupported message without downloading the engine", async ({ +test("Safari runs simulations on the serial engine", async ({ page, browserName, }) => { test.skip(browserName !== "webkit", "Safari-specific behavior"); - const engineRequests: string[] = []; - page.on("request", (request) => { - if (request.url().includes("lammps-atomify")) - engineRequests.push(request.url()); - }); await gotoApp(page); - await expect(page.getByTestId("engine-error-chip")).toContainText( - "Safari is not supported", - ); - await expect(page.getByTestId("engine-error-chip")).toContainText( - "Chrome or Firefox on a desktop computer", - ); - await expect(page.getByTestId("engine-loading-chip")).toHaveCount(0); + await waitForEngine(page); + await expect(page.getByTestId("engine-error-chip")).toHaveCount(0); await page.getByTestId("nav-examples").click(); await expect(page.getByTestId("library-example-2D-lj-fluid")).toBeVisible(); - await expect(page.getByTestId("library-quick-2D-lj-fluid")).toBeDisabled(); - expect(engineRequests).toEqual([]); + await page.getByTestId("library-quick-2D-lj-fluid").click(); + await waitRunCompleted(page); }); diff --git a/help/concepts/how-simulations-run.md b/help/concepts/how-simulations-run.md index fd83b33f..ff088f6c 100644 --- a/help/concepts/how-simulations-run.md +++ b/help/concepts/how-simulations-run.md @@ -8,7 +8,7 @@ The first time you open Atomify, the browser downloads the engine (tens of megab ## Cross-origin isolation -The engine uses several threads that share memory (`SharedArrayBuffer`). Browsers only allow that on pages that are **cross-origin isolated**. GitHub Pages can't send the headers that turn this on, so Atomify installs a small service worker that adds them, which is why the page reloads once on your first visit. If isolation fails, the engine reports **The simulation engine requires cross-origin isolation.** See [Troubleshooting](/faq/troubleshooting). +The engine uses several threads that share memory (`SharedArrayBuffer`). Browsers only allow that on pages that are **cross-origin isolated**. GitHub Pages can't send the headers that turn this on, so Atomify installs a small service worker that adds them, which is why the page reloads once on your first visit. Where isolation isn't available (always in Safari, which lacks the header mode Atomify uses), Atomify loads a single-threaded build of the same engine instead, with the same LAMMPS packages but without KOKKOS. See [Troubleshooting](/faq/troubleshooting). ## What happens when you click run @@ -25,7 +25,7 @@ After a run completes, its atoms stay loaded in the engine until the next run st ## Multithreading -The engine is a KOKKOS build. Scripts that say `suffix kk` use 4 threads; others run on one. See [Multithreading](/guide/multithreading). +The engine is a KOKKOS build. Scripts that say `suffix kk` use 4 threads; others run on one. In Safari everything runs on one thread. See [Multithreading](/guide/multithreading). ## If the engine crashes diff --git a/help/faq/known-limitations.md b/help/faq/known-limitations.md index 25b39f36..63050235 100644 --- a/help/faq/known-limitations.md +++ b/help/faq/known-limitations.md @@ -4,7 +4,7 @@ Things Atomify doesn't do (yet), so you can plan around them. ## Browsers and devices -- **No Safari, iPhone or iPad.** Desktop Chrome, Firefox, Edge and other Chromium browsers only. +- **Safari, iPhone and iPad run single-threaded.** [Multithreading](/guide/multithreading) needs desktop Chrome, Firefox, Edge or another Chromium browser. - **Projects live in one browser.** No sync between browsers or computers; use download and import. ## Simulations diff --git a/help/faq/troubleshooting.md b/help/faq/troubleshooting.md index 3df25150..9969e281 100644 --- a/help/faq/troubleshooting.md +++ b/help/faq/troubleshooting.md @@ -4,13 +4,11 @@ **"Engine loading…" never finishes.** The engine is a large download. On a slow connection give it a minute. If the percentage stops moving, reload the page. -**"Safari is not supported for simulations."** Use Chrome, Firefox or Edge on a desktop computer. See [Browser requirements](/getting-started/requirements). - -**"The simulation engine requires cross-origin isolation."** The helper that enables isolation didn't take effect. +**The Multithreading switch says "Not available in this browser".** The page isn't cross-origin isolated, so Atomify loaded its single-threaded engine. That's always the case in Safari. In Chrome, Firefox or Edge it means the helper that enables isolation didn't take effect: 1. Reload the page. 2. Open [andeplane.github.io/atomify/?sw-reset](https://andeplane.github.io/atomify/?sw-reset). This removes Atomify's service worker and cached files and reloads. Your projects are kept. -3. Make sure you're not in a mode that blocks service workers (some privacy extensions or strict privacy settings do). Try a normal window or another browser. +3. Make sure you're not in a mode that blocks service workers (some privacy extensions or strict privacy settings do). Try a normal window. **"Failed to load LAMMPS" or "Engine unavailable: …".** Reload. If it keeps happening, try `?sw-reset` as above, then [report it](/faq/getting-help) with the message. diff --git a/help/getting-started/requirements.md b/help/getting-started/requirements.md index 914d9042..cd37f60f 100644 --- a/help/getting-started/requirements.md +++ b/help/getting-started/requirements.md @@ -2,19 +2,15 @@ ## Supported browsers -Use **Chrome**, **Firefox** or another Chromium-based browser (**Edge**, **Opera**, **Brave**) on a **desktop or laptop computer**. +Use **Chrome**, **Firefox** or another Chromium-based browser (**Edge**, **Opera**, **Brave**) on a **desktop or laptop computer** for the best performance. -::: danger Safari and iPhone/iPad are not supported -Safari, and every browser on iPhone and iPad (they all use Safari's engine), cannot run the simulation engine. You can still browse the app, but running shows: - -> Safari is not supported for simulations. Use Chrome or Firefox on a desktop computer. -> ::: - -![The engine status in the sidebar on an unsupported browser](/screenshots/safari-unsupported.webp) +::: tip Safari runs single-threaded +Safari runs simulations, but on one thread only: it can't turn on the cross-origin isolation that [Multithreading](/guide/multithreading) needs, so Atomify loads a single-threaded engine there. Scripts with `suffix kk` still run, just without the speedup, and the **Multithreading** switch is unavailable. iPhone and iPad browsers all use Safari's engine and behave the same way, but they have far less memory, so keep systems small. +::: ## The first visit reloads once -The first time you open Atomify, the page reloads itself once. This is expected. Atomify installs a small helper (a service worker) that turns on _cross-origin isolation_, a browser security mode the multithreaded engine needs (it uses `SharedArrayBuffer`). After that first reload you will not see it again. +The first time you open Atomify, the page reloads itself once. This is expected. Atomify installs a small helper (a service worker) that turns on _cross-origin isolation_, a browser security mode the multithreaded engine needs (it uses `SharedArrayBuffer`). After that first reload you will not see it again. (Safari can't use it, see above.) ## Waiting for the engine diff --git a/help/guide/multithreading.md b/help/guide/multithreading.md index 305ecc0b..1484d8ff 100644 --- a/help/guide/multithreading.md +++ b/help/guide/multithreading.md @@ -25,6 +25,10 @@ Atomify then adds `suffix kk` to the start of the script it gives LAMMPS for thi Without `suffix kk`, Atomify runs the regular single-threaded styles. +## Safari + +Safari can't run the multithreaded engine (see [Browser requirements](/getting-started/requirements)), so Atomify uses a single-threaded build there. The **Multithreading** switch reads "Not available in this browser", and scripts with `suffix kk` run on one thread. + ## Related - [The LAMMPS engine](/reference/lammps-engine) diff --git a/help/public/screenshots/safari-unsupported.webp b/help/public/screenshots/safari-unsupported.webp deleted file mode 100644 index 81970ad7..00000000 Binary files a/help/public/screenshots/safari-unsupported.webp and /dev/null differ diff --git a/help/scripts/screenshots.spec.ts b/help/scripts/screenshots.spec.ts index b0b8a5da..80685c4b 100644 --- a/help/scripts/screenshots.spec.ts +++ b/help/scripts/screenshots.spec.ts @@ -96,23 +96,6 @@ test("capture Help screenshots", async ({ browser }) => { await page.context().close(); }); - await test.step("unsupported browser (Safari)", async () => { - const page = await newPage(browser, { - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", - }); - await gotoApp(page); - const chip = page.getByTestId("engine-error-chip"); - await expect(chip).toBeVisible({ timeout: 60_000 }); - await shot( - page, - "safari-unsupported", - [chip, page.getByTestId("sidebar-settings")], - { pad: 10, padTop: 2 }, - ); - await page.context().close(); - }); - await test.step("home for a new user", async () => { const page = await newPage(browser); await gotoApp(page); diff --git a/package-lock.json b/package-lock.json index 762e9cc3..c6d39e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "dygraphs": "^2.1.0", "easy-peasy": "^6.1.1", "fflate": "^0.8.3", - "lammps.js": "1.5.1", + "lammps.js": "1.6.1", "localforage": "^1.10.0", "mixpanel-browser": "^2.75.0", "monaco-editor": "^0.55.1", @@ -4421,9 +4421,9 @@ } }, "node_modules/lammps.js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/lammps.js/-/lammps.js-1.5.1.tgz", - "integrity": "sha512-kQKM1yA8ga9lAPd5cAuXHO3onVHiPc8w7+8ud9OLmvzgZXf6LQx/shFDrb7ILTFvmYkJP1y9SqGCHF1gZSdj8A==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/lammps.js/-/lammps.js-1.6.1.tgz", + "integrity": "sha512-YbW97iu+nOlnEd9ZwlHKcwJOiKIEaEPHA5IJ6kmZJSmjcUtivPfMTbj9F36EaU8vA20YdLVTvHo4xMTCfp9swA==", "license": "MIT" }, "node_modules/lerp": { diff --git a/package.json b/package.json index ad31242e..cbbda32d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dygraphs": "^2.1.0", "easy-peasy": "^6.1.1", "fflate": "^0.8.3", - "lammps.js": "1.5.1", + "lammps.js": "1.6.1", "localforage": "^1.10.0", "mixpanel-browser": "^2.75.0", "monaco-editor": "^0.55.1", diff --git a/pypi/lammps_js-1.5.1-py3-none-any.whl b/pypi/lammps_js-1.6.1-py3-none-any.whl similarity index 82% rename from pypi/lammps_js-1.5.1-py3-none-any.whl rename to pypi/lammps_js-1.6.1-py3-none-any.whl index f125a1fa..f0e4a645 100644 Binary files a/pypi/lammps_js-1.5.1-py3-none-any.whl and b/pypi/lammps_js-1.6.1-py3-none-any.whl differ diff --git a/src/components/Simulation.tsx b/src/components/Simulation.tsx index faf4e679..fd311c98 100644 --- a/src/components/Simulation.tsx +++ b/src/components/Simulation.tsx @@ -165,26 +165,6 @@ const SimulationComponent = () => { } loadStartedRef.current = true; setEngineError(undefined); - // Safari and other WebKit browsers (including iOS browsers) cannot run - // our credentialless / SharedArrayBuffer engine configuration. - const isWebKit = - /AppleWebKit/i.test(navigator.userAgent) && - !/Chrome\/|Chromium\/|Edg\/|OPR\/|Android/i.test(navigator.userAgent); - if (isWebKit) { - setEngineError( - "Safari is not supported for simulations. Use Chrome or Firefox on a desktop computer.", - ); - return; - } - if ( - !window.crossOriginIsolated || - typeof SharedArrayBuffer === "undefined" - ) { - setEngineError( - "The simulation engine requires cross-origin isolation. Reload the page; if this persists, check browser settings or try another browser.", - ); - return; - } setStatus({ title: "Downloading LAMMPS ...", text: "", @@ -194,7 +174,10 @@ const SimulationComponent = () => { // The atomify wasm build is a multithreaded KOKKOS/pthreads + ASYNCIFY // module. A pthreads module cannot initialize on the browser main thread // (its thread-pool handshake blocks on Atomics.wait, forbidden off a - // Worker), so it runs inside a Web Worker. The proxy owns that worker and + // Worker), so it runs inside a Web Worker. Where the page is not + // cross-origin isolated (Safari has no COEP credentialless, so it never + // is), SharedArrayBuffer is missing and the proxy picks the serial + // (non-KOKKOS) build instead; see wasm/engineVariant.ts. The proxy owns that worker and // is the app's LammpsWeb: the modifier/render pipeline reads the typed // arrays of its latest streamed frame, and engine-FS writes are forwarded // to the worker as commands. The run pipeline (RunController) owns the diff --git a/src/engine/RunController.test.ts b/src/engine/RunController.test.ts index cee634d6..a568989d 100644 --- a/src/engine/RunController.test.ts +++ b/src/engine/RunController.test.ts @@ -154,6 +154,18 @@ describe("prepareEngineInputs", () => { ).toBe("pair_style hybrid lj/cut 2.5\n"); }); + it("runs scripts as written on the serial (non-KOKKOS) engine", () => { + const prepared = prepareEngineInputs(inputs, "in.lmp", true, false)!; + // No opt-in injected and no serial-style rewriting: the engine starts + // without `-sf kk`, so there is nothing to undo. + expect(prepared.script).toBe(inputs[0].content); + expect(prepared.runScript).not.toContain("suffix"); + expect(prepared.runScript).toMatch(/^atom_style full\n/); + expect( + prepared.files.find((file) => file.fileName === "include.lmp")?.content, + ).toBe("pair_style hybrid lj/cut 2.5\n"); + }); + it("returns null when the input script is missing or not text", () => { expect(prepareEngineInputs(inputs, "in.other", false)).toBeNull(); expect(prepareEngineInputs(inputs, "restart.bin", false)).toBeNull(); diff --git a/src/engine/RunController.ts b/src/engine/RunController.ts index 867a794b..37a59202 100644 --- a/src/engine/RunController.ts +++ b/src/engine/RunController.ts @@ -175,6 +175,11 @@ export function prepareEngineInputs( inputs: RunInputFile[], inputScript: string, useKokkos: boolean, + /** + * False for the serial (non-KOKKOS) engine: it starts without `-sf kk`, so + * scripts need no serial-style rewriting and no opt-in is injected. + */ + kokkosEngine = true, ): { files: RunInputFile[]; /** The input script as the user wrote it (plus an injected opt-in). */ @@ -186,11 +191,12 @@ export function prepareEngineInputs( if (!main || typeof main.content !== "string") { return null; } - const script = useKokkos ? injectKokkosOptIn(main.content) : main.content; + const script = + useKokkos && kokkosEngine ? injectKokkosOptIn(main.content) : main.content; const kokkos = scriptOptsIntoKokkos(script); const engineText = (content: string, isMainScript: boolean) => prepareGroupInspection( - kokkos + kokkos || !kokkosEngine ? content : prepareScriptForSerialStyles(content, { isMainScript }), ); @@ -447,6 +453,7 @@ export class RunController { inputs, plan.meta.inputScript, plan.useKokkos, + engine.multithreaded !== false, ); if (!prepared) { // Guard before Simulation.Start/time_event fire: an aborted run must diff --git a/src/examples.smoke.ts b/src/examples.smoke.ts index 66f9ac44..baafb7fe 100644 --- a/src/examples.smoke.ts +++ b/src/examples.smoke.ts @@ -20,12 +20,16 @@ * when the run is cut too short (micelle at 200). * EXAMPLES_FILTER only run examples whose id contains this string * EXAMPLES_DEBUG echo LAMMPS output live and keep all of it in errors + * EXAMPLES_ENGINE "serial" runs the atomify-serial build (no KOKKOS, + * the engine Safari gets) the way the app does there: + * plain startup args and no KOKKOS preprocessing */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import createModule from "lammps.js/wasm-atomify"; +import createThreadedModule from "lammps.js/wasm-atomify"; +import createSerialModule from "lammps.js/wasm-atomify-serial"; import type { LAMMPSWeb, LammpsModule } from "lammps.js"; import { LammpsAdapter } from "./wasm/lammpsAdapter"; @@ -57,6 +61,8 @@ const EXAMPLES_DIR = join(process.cwd(), "public", "examples"); const MAX_SECONDS = Number(process.env.EXAMPLES_MAX_SECONDS ?? 120); const MAX_RUN = Number(process.env.EXAMPLES_MAX_RUN ?? 1000); const FILTER = process.env.EXAMPLES_FILTER ?? ""; +const SERIAL = process.env.EXAMPLES_ENGINE === "serial"; +const createModule = SERIAL ? createSerialModule : createThreadedModule; // Beyond the budget the Stop path needs a synced step to act on; a script // stuck in one long command never reaches one, so give up on it eventually. const HARD_TIMEOUT_MS = (MAX_SECONDS + 90) * 1000; @@ -82,9 +88,10 @@ function prepareFile( isMainScript: boolean, kokkosOptIn: boolean, ): string { - const styled = kokkosOptIn - ? content - : prepareScriptForSerialStyles(content, { isMainScript }); + const styled = + kokkosOptIn || SERIAL + ? content + : prepareScriptForSerialStyles(content, { isMainScript }); return prepareGroupInspection(capRunLengths(styled)); } @@ -127,7 +134,9 @@ async function loadEngine(): Promise { printErr: printLine, })) as unknown as LammpsModule; const native = new module.LAMMPSWeb(); - const adapter = new LammpsAdapter(module, native); + const adapter = new LammpsAdapter(module, native, { + multithreaded: !SERIAL, + }); adapter.start(); const engine: Engine = { module, diff --git a/src/setupTests.ts b/src/setupTests.ts index d356b613..29600df6 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -28,6 +28,9 @@ Object.defineProperty(window, "matchMedia", { vi.mock("lammps.js/wasm-atomify", () => ({ default: () => new Promise(() => {}), })); +vi.mock("lammps.js/wasm-atomify-serial", () => ({ + default: () => new Promise(() => {}), +})); // Mock Monaco Editor loader to prevent it from trying to load in test environment vi.mock("@monaco-editor/react", () => ({ diff --git a/src/shell/modals/NewRunModal.tsx b/src/shell/modals/NewRunModal.tsx index 6a51ab18..5248388f 100644 --- a/src/shell/modals/NewRunModal.tsx +++ b/src/shell/modals/NewRunModal.tsx @@ -125,6 +125,10 @@ const NewRunModal = ({ }, [active?.runs]); const threads = defaultThreadCount(); + // The serial engine (Safari, or any page without cross-origin isolation) + // has no KOKKOS, so the toggle is off and locked there. + const multithreadingAvailable = lammps?.multithreaded !== false; + const useKokkos = kokkos && multithreadingAvailable; const plan = useMemo( () => buildRunPlan(variables, drafts, nextRunNumber), [variables, drafts, nextRunNumber], @@ -132,10 +136,10 @@ const NewRunModal = ({ const command = useMemo( () => buildCommandPreview(inputScript, variables, drafts, { - enabled: kokkos, + enabled: useKokkos, threads, }), - [inputScript, variables, drafts, kokkos, threads], + [inputScript, variables, drafts, useKokkos, threads], ); if (!active) { @@ -157,7 +161,7 @@ const NewRunModal = ({ plan.runs.map((vars) => ({ inputScript, vars, - useKokkos: kokkos, + useKokkos, threads, })), ); @@ -354,14 +358,19 @@ const NewRunModal = ({ marginTop: 2, }} > - Runs LAMMPS with KOKKOS acceleration (×{threads} threads) + {multithreadingAvailable + ? `Runs LAMMPS with KOKKOS acceleration (×${threads} threads)` + : "Not available in this browser. Simulations run on a single thread."} - setKokkos((value) => !value)} - data-testid="kokkos-toggle" - /> +
+ setKokkos((value) => !value)} + disabled={!multithreadingAvailable} + data-testid="kokkos-toggle" + /> +
{/* Deviation from the design mock: no per-run thread picker. The engine's Kokkos thread pool is fixed at engine load diff --git a/src/types.ts b/src/types.ts index d9337d29..37af6689 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,12 @@ export type LammpsWeb = { skipped: { path: string; size: number }[]; }>; getMemoryUsage: () => number; + /** + * False when the engine is the serial (non-KOKKOS) build, used where the + * page cannot be cross-origin isolated (Safari). Scripts then run as + * written, without KOKKOS preprocessing. Undefined means multithreaded. + */ + readonly multithreaded?: boolean; getExceptionMessage: (address: number) => string; // Engine-FS writes, applied in order before any later runFile. Nothing is diff --git a/src/wasm/LammpsWorkerProxy.test.ts b/src/wasm/LammpsWorkerProxy.test.ts index b09a679a..c533ac79 100644 --- a/src/wasm/LammpsWorkerProxy.test.ts +++ b/src/wasm/LammpsWorkerProxy.test.ts @@ -160,13 +160,23 @@ const runFileId = () => describe("LammpsWorkerProxy protocol handshake", () => { it("resolves load() for a worker of this build and records its engine version", async () => { const load = proxy.load(); - expect(worker.posted).toEqual([{ type: "load" }]); + // jsdom is not cross-origin isolated, so the proxy picks the serial build. + expect(worker.posted).toEqual([{ type: "load", variant: "serial" }]); + expect(proxy.multithreaded).toBe(false); worker.emit(readyEvent()); await expect(load).resolves.toBeUndefined(); expect(proxy.engineVersion).toBe(LAMMPS_JS_VERSION); expect(proxy.crashed).toBe(false); }); + it("asks the worker for the threaded build when constructed for it", () => { + proxy = new LammpsWorkerProxy("threaded"); + worker = FakeWorker.instances[1]; + void proxy.load(); + expect(proxy.multithreaded).toBe(true); + expect(worker.posted).toContainEqual({ type: "load", variant: "threaded" }); + }); + it.each([ { name: "protocol", diff --git a/src/wasm/LammpsWorkerProxy.ts b/src/wasm/LammpsWorkerProxy.ts index ab7ea982..a54ad5fe 100644 --- a/src/wasm/LammpsWorkerProxy.ts +++ b/src/wasm/LammpsWorkerProxy.ts @@ -2,6 +2,7 @@ import { GROUP_LABEL_PREFIX } from "../utils/lammpsGroups"; import { LammpsWeb, LMPModifier, ModifierType } from "../types"; import type { ModifierCategory } from "lammps.js"; import { emptyEngineFrame, type EngineFrame } from "./engineFrame"; +import { pickEngineVariant, type EngineVariant } from "./engineVariant"; import { LAMMPS_JS_VERSION, WORKER_PROTOCOL_VERSION, @@ -26,7 +27,8 @@ export interface WorkdirSnapshot { /** * Main-thread facade over the LAMMPS Web Worker. * - * The worker owns the real (KOKKOS/pthreads) wasm module — it cannot run on the + * The worker owns the real wasm module (KOKKOS/pthreads, or the serial build + * where SharedArrayBuffer is unavailable) — the threaded one cannot run on the * main thread. This proxy never touches wasm: it keeps the latest * {@link EngineFrame} the worker streamed (typed arrays whose buffers were * transferred, so they are read in place) and exposes it through the @@ -88,7 +90,11 @@ export class LammpsWorkerProxy implements LammpsWeb { private errorListener?: (message: string) => void; private crashListener?: (message: string) => void; - constructor() { + /** The wasm build the worker loads (see engineVariant.ts). */ + readonly variant: EngineVariant; + + constructor(variant: EngineVariant = pickEngineVariant()) { + this.variant = variant; this.worker = new Worker(new URL("./lammps.worker.ts", import.meta.url), { type: "module", }); @@ -149,6 +155,14 @@ export class LammpsWorkerProxy implements LammpsWeb { return this.crashError !== undefined; } + /** + * False for the serial build: no KOKKOS, so scripts run as written and the + * Multithreading toggle is unavailable. + */ + get multithreaded(): boolean { + return this.variant === "threaded"; + } + /** lammps.js version of the loaded worker; undefined until it is ready. */ get engineVersion(): string | undefined { return this.workerEngineVersion; @@ -254,7 +268,7 @@ export class LammpsWorkerProxy implements LammpsWeb { // `error` event, or by handleCrash. this.resolveLoad = resolve; this.rejectLoad = reject; - this.send({ type: "load" }); + this.send({ type: "load", variant: this.variant }); }); } return this.readyPromise; diff --git a/src/wasm/engineVariant.test.ts b/src/wasm/engineVariant.test.ts new file mode 100644 index 00000000..ec3402f4 --- /dev/null +++ b/src/wasm/engineVariant.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { pickEngineVariant } from "./engineVariant"; + +describe("pickEngineVariant", () => { + it("picks the threaded build in a cross-origin isolated context", () => { + expect( + pickEngineVariant({ + crossOriginIsolated: true, + SharedArrayBuffer, + }), + ).toBe("threaded"); + }); + + it("falls back to the serial build without isolation (Safari)", () => { + expect( + pickEngineVariant({ crossOriginIsolated: false, SharedArrayBuffer }), + ).toBe("serial"); + expect(pickEngineVariant({ crossOriginIsolated: true })).toBe("serial"); + expect(pickEngineVariant({})).toBe("serial"); + }); +}); diff --git a/src/wasm/engineVariant.ts b/src/wasm/engineVariant.ts new file mode 100644 index 00000000..1442a6cd --- /dev/null +++ b/src/wasm/engineVariant.ts @@ -0,0 +1,26 @@ +/** + * Which lammps.js wasm build the engine worker loads. + * + * - "threaded": the atomify build (lammps.js/wasm-atomify), a multithreaded + * KOKKOS/pthreads module. It needs SharedArrayBuffer, so the page must be + * cross-origin isolated (COOP/COEP, see index.html). + * - "serial": the atomify-serial build (lammps.js/wasm-atomify-serial), the + * same package set without KOKKOS or pthreads. It needs no + * SharedArrayBuffer, so it runs where isolation is unavailable: Safari, + * which does not support the COEP credentialless policy Atomify uses, or a + * browser that blocks the isolation service worker. + */ +export type EngineVariant = "threaded" | "serial"; + +/** Pick the build the current context can run: threaded when isolated. */ +export function pickEngineVariant( + scope: { + crossOriginIsolated?: boolean; + SharedArrayBuffer?: unknown; + } = globalThis, +): EngineVariant { + return scope.crossOriginIsolated === true && + typeof scope.SharedArrayBuffer === "function" + ? "threaded" + : "serial"; +} diff --git a/src/wasm/lammps-atomify.d.ts b/src/wasm/lammps-atomify.d.ts index 9b1a8176..ba6be4c1 100644 --- a/src/wasm/lammps-atomify.d.ts +++ b/src/wasm/lammps-atomify.d.ts @@ -7,3 +7,11 @@ declare module "lammps.js/wasm-atomify" { const createModule: (options?: ModuleOptions) => Promise; export default createModule; } + +// The serial (non-KOKKOS) sibling, "./wasm-atomify-serial", for pages that +// cannot be cross-origin isolated (see engineVariant.ts). +declare module "lammps.js/wasm-atomify-serial" { + import type { LammpsModule, ModuleOptions } from "lammps.js"; + const createModule: (options?: ModuleOptions) => Promise; + export default createModule; +} diff --git a/src/wasm/lammps.worker.ts b/src/wasm/lammps.worker.ts index adb934d4..73508337 100644 --- a/src/wasm/lammps.worker.ts +++ b/src/wasm/lammps.worker.ts @@ -8,8 +8,9 @@ import { /** * LAMMPS Web Worker entry. * - * The atomify wasm build is a multithreaded KOKKOS/pthreads + ASYNCIFY module. - * A pthreads module cannot initialize on the browser main thread (its + * The threaded atomify wasm build is a multithreaded KOKKOS/pthreads + ASYNCIFY + * module (the serial build, for contexts without SharedArrayBuffer, runs here + * too so both share one code path). A pthreads module cannot initialize on the browser main thread (its * thread-pool handshake blocks on Atomics.wait, forbidden off a Worker), so the * module is created and driven entirely here, inside a Worker, where pthreads * and SharedArrayBuffer work. This worker owns the wasm module and the @@ -26,6 +27,7 @@ import { type WorkerCommand, type WorkerEvent, } from "./workerMessages"; +import type { EngineVariant } from "./engineVariant"; import type { LAMMPSWeb as NativeLammps, LammpsModule, @@ -262,7 +264,31 @@ function streamStep(force = false) { frames.offer(frame, force); } -async function load() { +/** + * Fetch a variant's emscripten glue (embedded wasm) once and import + * createModule from a blob URL of it. The `?url` imports are spelled out per + * variant so Vite can resolve each as a static asset; `@vite-ignore` keeps it + * from bundling/splitting the giant module itself. + */ +async function fetchModule(variant: EngineVariant) { + const moduleUrl: string = ( + variant === "threaded" + ? ((await import("lammps.js/wasm-atomify?url")) as { default: string }) + : ((await import("lammps.js/wasm-atomify-serial?url")) as { + default: string; + }) + ).default; + const blob = await (await fetch(moduleUrl)).blob(); + const blobUrl = URL.createObjectURL(blob); + const createModule = ( + (await import(/* @vite-ignore */ blobUrl)) as { + default: typeof import("lammps.js/wasm-atomify").default; + } + ).default; + return { blob, createModule }; +} + +async function load(variant: EngineVariant) { if (module) { postReady(); return; @@ -285,25 +311,16 @@ async function load() { post({ type: "printed", text }); }; - // Fetch the atomify emscripten glue (embedded wasm, ~50 MB) once and keep it - // as a Blob. Load createModule from that same blob URL (?url + @vite-ignore - // keeps Vite from bundling/splitting the giant asset). Passing the Blob as - // `mainScriptUrlOrBlob` is essential for this KOKKOS/pthreads build: without + // Fetch the emscripten glue (embedded wasm, ~50 MB) once and keep it as a + // Blob (see fetchModule). Passing the Blob as `mainScriptUrlOrBlob` is + // essential for the threaded KOKKOS/pthreads build: without // it, emscripten spawns each of the 8 pthread workers via // `new Worker(new URL("lammps-atomify.js", import.meta.url))`, and 8 // concurrent 50 MB fetches overflow Chromium's disk cache // (net::ERR_CACHE_WRITE_FAILURE), aborting the module. With the Blob, every // pthread instantiates from the in-memory blob URL — no per-worker fetch. - const moduleUrl: string = ( - (await import("lammps.js/wasm-atomify?url")) as { default: string } - ).default; - const blob = await (await fetch(moduleUrl)).blob(); - const blobUrl = URL.createObjectURL(blob); - const createModule = ( - (await import(/* @vite-ignore */ blobUrl)) as { - default: typeof import("lammps.js/wasm-atomify").default; - } - ).default; + // The serial build spawns no pthreads and ignores it. + const { blob, createModule } = await fetchModule(variant); module = (await createModule({ print: printLine, @@ -311,9 +328,11 @@ async function load() { mainScriptUrlOrBlob: blob, })) as LammpsModule; native = new module.LAMMPSWeb(); - adapter = new LammpsAdapter(module, native); - // Start the Kokkos runtime once, here in the worker where the pthread - // handshake can block. Startup args are constant for the module's life; + adapter = new LammpsAdapter(module, native, { + multithreaded: variant === "threaded", + }); + // Start the Kokkos runtime (threaded build) once, here in the worker where + // the pthread handshake can block. Startup args are constant for the module's life; // per-simulation acceleration is decided by the script (see utils/kokkos.ts). adapter.start(); adapter.setStepListener(streamStep); @@ -325,7 +344,7 @@ ctx.onmessage = async (ev: MessageEvent) => { try { switch (cmd.type) { case "load": - await load(); + await load(cmd.variant); break; case "writeFile": if (module) { diff --git a/src/wasm/lammpsAdapter.ts b/src/wasm/lammpsAdapter.ts index 39308401..b86ff7fe 100644 --- a/src/wasm/lammpsAdapter.ts +++ b/src/wasm/lammpsAdapter.ts @@ -129,9 +129,18 @@ export class LammpsAdapter { private runFinishResolve: (() => void) | null = null; private runFinished = true; - constructor(module: LammpsModule, native: NativeLammps) { + // False for the serial (non-KOKKOS) build, which starts without Kokkos + // arguments (see engineVariant.ts). + private readonly multithreaded: boolean; + + constructor( + module: LammpsModule, + native: NativeLammps, + options: { multithreaded?: boolean } = {}, + ) { this.module = module; this.native = native; + this.multithreaded = options.multithreaded ?? true; this.installStepCallback(); } @@ -260,15 +269,13 @@ export class LammpsAdapter { // scripts (a `suffix kk` line) run fully accelerated as-is, and all other // scripts are preprocessed with `suffix off` + a kk-wrapped atom_style so // their styles run serially (measured: same speed as a true serial build). - // See src/utils/kokkos.ts. - this.native.startWithArgs([ - "-k", - "on", - "t", - String(this.kokkosThreads), - "-sf", - "kk", - ]); + // See src/utils/kokkos.ts. The serial build has no KOKKOS package, so it + // starts plain (scripts are not preprocessed for it either). + this.native.startWithArgs( + this.multithreaded + ? ["-k", "on", "t", String(this.kokkosThreads), "-sf", "kk"] + : [], + ); return true; } diff --git a/src/wasm/workerMessages.ts b/src/wasm/workerMessages.ts index 733c2215..5a05ffae 100644 --- a/src/wasm/workerMessages.ts +++ b/src/wasm/workerMessages.ts @@ -2,10 +2,12 @@ * Typed message protocol between the main thread (LammpsWorkerProxy) and the * LAMMPS Web Worker (lammps.worker.ts). * - * The atomify wasm build is a KOKKOS/pthreads + ASYNCIFY emscripten module. It - * CANNOT initialize on the browser main thread (its thread-pool handshake needs - * Atomics.wait blocking, which is forbidden off a Worker), so the module lives - * entirely inside the worker. omovi's WebGL objects live only on the main + * The threaded atomify wasm build is a KOKKOS/pthreads + ASYNCIFY emscripten + * module. It CANNOT initialize on the browser main thread (its thread-pool + * handshake needs Atomics.wait blocking, which is forbidden off a Worker), so + * the module lives entirely inside the worker. The serial build (no + * SharedArrayBuffer, for Safari) runs in the same worker, so both share one + * code path. omovi's WebGL objects live only on the main * thread, so the worker never touches them — it streams plain typed arrays, * copied out of the wasm heap inside the (asyncify-safe) step callback, and the * main thread renders them. @@ -13,6 +15,7 @@ import type { ModifierCategory } from "lammps.js"; import type { EngineFrame } from "./engineFrame"; +import type { EngineVariant } from "./engineVariant"; import { dependencies } from "../../package.json"; /** @@ -21,7 +24,7 @@ import { dependencies } from "../../package.json"; * (e.g. a stale worker script cached from a different deploy). Bump it on * any incompatible change to WorkerCommand / WorkerEvent. */ -export const WORKER_PROTOCOL_VERSION = 2; +export const WORKER_PROTOCOL_VERSION = 3; /** * The lammps.js release the app is built against (pinned exactly in @@ -33,7 +36,8 @@ export const LAMMPS_JS_VERSION: string = dependencies["lammps.js"]; /** Commands sent from the main thread to the worker. */ export type WorkerCommand = - | { type: "load" } + // Load the given wasm build (see engineVariant.ts) and start LAMMPS. + | { type: "load"; variant: EngineVariant } // Worker-FS writes. Commands are delivered in order, so a file written // before runFile is in place when the run starts. mkdir is mkdir -p; // writeFile and chdir create missing directories too. diff --git a/vite.config.ts b/vite.config.ts index c0791add..270d432b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,7 +5,8 @@ import react from "@vitejs/plugin-react"; import basicSsl from "@vitejs/plugin-basic-ssl"; /** - * Dev-only: serve the ~43 MB lammps-atomify.js emscripten module RAW. + * Dev-only: serve the ~43 MB lammps-atomify.js emscripten module (and its + * serial sibling lammps-atomify-serial.js) RAW. * The worker loads it via `?url` + fetch-as-blob (see lammps.worker.ts) — * it is never bundled — but Vite's dev transform would otherwise rewrite it * and append an inline sourcemap, inflating the download to ~460 MB (which @@ -21,12 +22,23 @@ function serveLammpsModuleRaw(): Plugin { const require = createRequire(import.meta.url); // Resolve via the package's exports map (the raw dist path is not an // exported subpath). - const filePath = require.resolve("lammps.js/wasm-atomify"); + const modules = [ + { + name: "lammps-atomify-serial.js", + path: require.resolve("lammps.js/wasm-atomify-serial"), + }, + { + name: "lammps-atomify.js", + path: require.resolve("lammps.js/wasm-atomify"), + }, + ]; server.middlewares.use((req, res, next) => { const url = req.url ?? ""; - if (!url.includes("lammps-atomify.js") || url.includes("?")) { + const match = modules.find((module) => url.includes(module.name)); + if (!match || url.includes("?")) { return next(); } + const filePath = match.path; res.setHeader("Content-Type", "text/javascript"); res.setHeader("Content-Length", statSync(filePath).size); createReadStream(filePath).pipe(res);