Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/wasm-threads-plan.md

Real threads in the wasm — the plan, and the facts it rests on

nornir:class=design · written 2026-09-19 on loki, after the GPU lanes landed.

RULING 2026-09-28 (owner): a GPU always exists — this plan is PARKED

Read this before any line below. The owner ruled on 2026-09-28:

So the threads bundle (facet-wrapped-web-threads) and the COOP/COEP front are PARKED. They are un-parked by a MEASUREMENT, not by a design: a browser run that shows a CPU-side stage on the main thread that matters at 100k nodes (the browser_bench harness splits the mount into js_parse_ms, js_stringify_ms, check_ms, first_frame_ms, settle_ms and per-frame app_ui_ms_mean, so the stage has a name when it shows up). The measurements that decide it are in .nornir/facett-benchmarks.md ("GPU lanes at 100k", lane B, 2026-09-29). Everything below is the design as it stood on 2026-09-19; it is kept, not deleted, because it is what gets built if that measurement ever comes.

Why this is not one change

The 2026-09-18 note (gpu-prep-in-the-browser.md) chose WebGPU as the browser's parallelism because real threads need four things at once, in four places:

# what where state
1 a fan-out that can run on Web Workers znippy-zoomies/gatling std::thread::scope only (gatling_forkjoin.rs:94,169,248,352,464); on wasm32-unknown-unknown std::thread::spawn is Err(Unsupported) even with atomics, so scope would PANIC, not degrade — today it never spawns because available_parallelism() is Err → 1 worker → inline
2 a wasm built with shared memory facet-wrapped, facett-demo nightly -Zbuild-std=std,panic_abort + -C target-feature=+atomics,+bulk-memory,+mutable-globals; nightly + rust-src + rust-std-wasm32 are installed on loki; wasm-bindgen 0.2.126 supports shared-memory init(module, memory)
3 a loader that spawns N workers over ONE memory and only when allowed facet-wrapped loader / index.html crossOriginIsolated must be true; otherwise SharedArrayBuffer does not exist and the single-thread bundle must load instead
4 a host that sends Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp on the TOP page AND the component iframe Streamlit (tornado, no header config), gh-pages neither sends them today; Streamlit needs a header-adding front (proxy or a tornado hook), pages needs _headers support the host may not have

The design, in order of what can be proven first

  1. gatling wasm-threads feature. #[cfg(all(target_arch = "wasm32", feature = "wasm-threads"))] use wasm_thread as thread; and every std::thread::scope / available_parallelism goes through that alias. wasm_thread spawns a Web Worker per thread over the shared memory and answers available_parallelism from navigator.hardwareConcurrency. Without the feature nothing changes. Open question to measure, not assume: joining on the MAIN thread. Atomics.wait (and the wasm memory.atomic.wait32 it lowers to) traps on the main thread; wasm_thread's join must spin there. gatling's fan-outs are millisecond-scale (layout steps, scanlines, extrusion) so a spin-join is acceptable if it is a spin; if it parks, the first fan-out hangs the page. A five-line probe page decides this before any port.
  2. A second wasm row, facet-wrapped-web-threads, declared in facett-build-thing beside facet-wrapped-web: same crate, the atomics flags, nightly, a distinct dist-threads/ — so the single-thread bundle stays byte-identical and keeps shipping. The build-thing/toml_parity pair holds it.
  3. Loader. Publish facet_state().threads (1 today). If crossOriginIsolated && typeof SharedArrayBuffer !== "undefined" and the threads bundle is present, load it and spawn min(hardwareConcurrency, 8) workers; else load the plain bundle. The reason is recorded in state (threads_reason: "not-isolated" | "no-bundle" | "ok"), the same honesty rule as layout_fallback.
  4. Host. For the Streamlit demo: a header-adding front in the container (the image is ours: xtask/src/containerfiles/streamlit.Containerfile). Caddy or a 40-line tornado wrapper that re-serves Streamlit with the two headers on every response, websockets included. For gh-pages: check whether Codeberg Pages honours a _headers file; if not, the pages demo stays single-thread and says so.
  5. Proof. The headful robot (py/tests/robot_streamlit.py --headful) asserts threads > 1 when the page is isolated, and threads == 1 with threads_reason set when it is not. Measure one fan-out (graph layout under the GPU threshold, or the SDF scanline raster) single- vs multi-thread in the same browser; the note's rule stands — MEASURE before ranking.

Measured 2026-09-19 (read, not run): wasm_thread refuses the main thread

wasm_thread 0.3.3/src/wasm32/scoped.rs:93 — scope() PANICS unless is_web_worker_thread(): "scope is not allowed on the main thread", because a blocking join lowers to Atomics.wait, which traps on the main thread. Its own mutexes spin (utils.rs:78, SpinLockMutex) for the same reason. So step 1 above cannot be a plain alias: gatling's scope runs on eframe's MAIN thread (the paint path), and wasm_thread::scope there is a panic, not a fan-out.

Two designs remain, and the choice is the decision to make before code:

The first is the one to build: it changes one crate, keeps the loader, and its failure mode (a spin that never ends) is observable as a stuck threads_reason.

What was tried today