Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 24 additions & 25 deletions e2e/engine-isolation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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);
});
4 changes: 2 additions & 2 deletions help/concepts/how-simulations-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion help/faq/known-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions help/faq/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 5 additions & 9 deletions help/getting-started/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions help/guide/multithreading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Binary file removed help/public/screenshots/safari-unsupported.webp
Binary file not shown.
17 changes: 0 additions & 17 deletions help/scripts/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file not shown.
25 changes: 4 additions & 21 deletions src/components/Simulation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/engine/RunController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 9 additions & 2 deletions src/engine/RunController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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 }),
);
Expand Down Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions src/examples.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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));
}

Expand Down Expand Up @@ -127,7 +134,9 @@ async function loadEngine(): Promise<Engine> {
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,
Expand Down
3 changes: 3 additions & 0 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
Loading
Loading