README.md (19334B)
Haskell WASM probes and browser embedding
These opt-in targets compile the production Runtime.Tree and Runtime.Value
modules unchanged for native GHC and upstream GHC WASM. They are not a port of
the native CLI. The command-line probes measure reduction and the site's pinned
portable compiler; the browser target adds an isolated package/compiler session
with the site's codecs, inspection/export and five-action IO protocol.
nix build .#haskellWasmProbe --out-link /tmp/tricu-haskell-wasm-probe
nix shell --inputs-from . nixpkgs#python3 -c python3 \
ext/haskell-wasm/measure.py /tmp/tricu-haskell-wasm-probe /tmp/haskell-wasm.json
The root flake pins ghc-wasm-meta independently of native nixpkgs. The output
contains versions.txt, native and WASM probes, Hello World, and the matching
Wasmtime executable. Building checks Hello World, reduction rules, lazy discard,
non-demanding application construction, rejection of an out-of-range byte, and
small completely observed workloads on both execution targets. All builds and
runs go through Nix; no Cabal invocation is needed for these boot-library-only
modules (the build records the Cabal-install version without building with it).
The measurement script runs five fresh processes per host/workload, alternating host order, with a 60-second timeout, 512 MiB Haskell heap limit and 8 MiB Haskell stack limit. These are not limits on total engine memory or native machine stack. It records every sample, medians, binary SHA256s, raw and gzip sizes, versions, and platform. Wasmtime compilation caching is disabled. External process time therefore includes engine compilation; it is not warm browser-call latency.
Workloads are deliberately narrow:
codec: construct and fully force a canonical byte-list input, apply tree identity, then decode and compare every byte against the original input. Inputs change between rounds. This is a runtime-codec test, not a tricu codec program or JavaScript transfer benchmark.deep: build and force nested constant-selection applications.distribution: build and force nested distribution-based identity applications.
Construction, WHNF reduction and complete observation/validation are timed separately. GC during each phase is included in that phase. Harness-only anti-sharing options and an opaque application wrapper prevent accidental hoisting of repeated applications; the production reducer compiles normally.
Native GHC and WASM GHC currently differ in version and word size. Ratios describe these pinned configurations, not isolated backend overhead. Small times and concurrently loaded machines require caution; use repeated independent runs before making deployment decisions.
Site compiler workloads
nix shell --inputs-from . nixpkgs#python3 -c python3 \
ext/haskell-wasm/measure-compiler.py /tmp/tricu-haskell-wasm-probe \
/tmp/haskell-wasm-compiler.json --samples 5
This runs the site's existing compiler-conformance.json cases on native Haskell,
Wasmtime, Node/V8 WASM, and the current TS evaluator under the same Node version.
It covers identity, curried pair construction, dependent definitions, six
sequential definitions, local recursion/pattern matching, and two diagnostic
cases. The IO case is excluded: no interaction-tree driver is included here.
Every successful job compares all compiled definitions, including their full tree encodings, with existing goldens, then applies the selected program and checks its complete result. Failures must produce the expected diagnostic. Both export-name availability (without forcing programs) and complete-result observation are timed. Timing runs are outside Nix builds, never threshold gates. Each sample starts a fresh process. Wasmtime caching is disabled; Node/V8 may perform tiered compilation during execution. This is not a warm-session benchmark.
prepare-compiler.mjs authenticates the original site compiler bundle against
compiler.json and the goldens, validates it with the site's bundle decoder, then
emits a compact indexed DAG and the site's contract environment. Haskell loads
this trusted benchmark transport, preserving sharing. This avoids bringing
native crypto/vector/package dependencies into the initial evaluator experiment.
It does not establish that the Haskell CLI's bundle loader has been ported. The
transport is not an interchange format or a supported public API.
Loading and input construction are outside compiler timings. TS authenticates and decodes the original bundle during loading; Haskell loads the prepared DAG. Consequently loading and total process times are not like-for-like loader benchmarks. Reports retain the original compiler pin, golden-file and prepared-DAG hashes, binary hashes/sizes, tool versions, all samples and medians.
The build checks all seven cases on native Haskell, Wasmtime and Node/V8, plus a TS oracle smoke. Negative controls reject a wrong expected result, a self-referencing DAG, a truncated DAG, corrupt compiler bytes during preparation, and missing WASI filesystem permissions. The Node wrapper propagates WASI's exit status; otherwise a failed Haskell process can appear successful to its supervisor.
The runner gives each process 120 seconds and each Haskell runtime a 512 MiB
heap/8 MiB Haskell-stack budget. TS uses the site's existing graph limits. RTS
allocated_bytes, max_live_bytes, and max_mem_in_use_bytes include loading
and validation, not just compilation. They are not total process RSS or WASM
linear-memory size. Node's TS maximum RSS is separately labelled in raw samples.
Browser loading, persistent-instance ownership, JS tree transfer, cancellation,
and process-wide memory limits are not measured by this command-line probe.
See the separate browser reactor below.
Persistent browser reactor
The separate opt-in target provides an isolated static test page and Worker, not a selectable backend in the published demo UI:
nix build .#haskellWasmBrowser --out-link /tmp/tricu-haskell-wasm-browser
nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c node \
ext/haskell-wasm/check-browser.mjs /tmp/tricu-haskell-wasm-browser \
/tmp/haskell-wasm-browser.json
nix shell --inputs-from . nixpkgs#nodejs nixpkgs#firefox -c node \
ext/haskell-wasm/check-firefox.mjs /tmp/tricu-haskell-wasm-browser \
/tmp/haskell-wasm-firefox.json
Both runners use the same page/Worker assertions and unchanged reactor. Chromium uses CDP; stock Firefox reports completion to a tokenized localhost endpoint, without Playwright patches or WebDriver. Each runner uses a temporary localhost server and fresh browser profile, removes both on exit, and writes the browser version, compiler provenance, reactor hash and size, every timing sample, initialization phases, memory observations, and assertion results. It runs the same five successful compiler cases five times in one persistent Worker, comparing export names, all compiled trees, and application results. Both diagnostic cases must match exactly. Timing includes bulk Haskell response encoding; round-trip timings also include Worker messaging and main-thread UTF-8 decoding. Do not compare these measurements directly with fresh-process Node timings without accounting for the different boundaries.
Additional controls cover prepared-input hashes, valid Unicode source against an independent scalar-tree golden, lone-surrogate/oversized source, malformed ternary input, invalidation of prior definitions after a failed compile, single-flight request rejection, 30 compile/run/reset cycles, inaccessible exports after reset, a main-thread heartbeat during divergent reduction, deadline-triggered termination, rejection of further requests to the disposed Worker, and successful execution in a replacement Worker.
browser-parity.mjs additionally checks all eight curated authenticated packages
against 117 existing Haskell result/terminal goldens, including four Conversation
IO cases, then compiles all eight sources using the empty/practical environments.
It checks all seven output codecs, invalid bytes/scalars/records, authentication
and profile rejection (failed loads preserve exports; failed compile replacements
invalidate them), a 40-level exponentially
shared tree, bounded preview, immutable and runtime DAG export, and refusal to
export truncated snapshots or oversized expanded trees. IO controls cover sentinel
version, unsupported actions, action/output limits, stale replies, GC while
awaiting input, invalid-line retry, explicit cancellation and subsequent recovery.
Separate divergent-payload tests ensure bounded preview and abort traces do not
force excluded program subtrees. Both browser reports include these assertions.
Boundary and ownership
Reactor.hs links the unchanged runtime into a WASI Preview 1 reactor. The pinned
upstream browser_wasi_shim supplies the required imports. The loader calls WASI
_initialize once and then the C initializer, which calls
hs_init_with_rtsopts. This synchronous C-export path needs no generated JSFFI
loader and exercises neither asynchronous JSFFI nor Haskell parallelism.
Each Worker owns one runtime, an optional retained compiler/environment and at
most one current definition set. Source is UTF-8 copied into Haskell-owned bytes before
the JS allocation is freed. A successful compilation atomically replaces the
definitions after its names response is materialized. Starting a replacement
clears the preceding public interface and expression scope, including on failure.
Native modules retain a separate local/import expression scope. Imports supply
lexical bindings; reexports select dependency exports for the public interface. Header parsing and
name qualification invoke authenticated source_module trees, with bindings
hydrated from bundles verified against both transport and manifest pins. The
compiler-exports.tsv root table is authenticated with the prepared compiler kit. Program trees stay lazy until inspected or applied. dump and
observe produce a bounded bulk UTF-8 response. Haskell copies that response into
a JS-requested WASM buffer and clears its retained reference. JS clones the buffer,
frees it, and transfers the resulting ArrayBuffer to the parent.
The UI receives serialized values and runtime statistics.
Legacy observe/dump ternary output expands sharing and is retained for
benchmark comparisons only. preview/snapshot use local IDs, stable-name
identity with collision checks, and explicit aliases. Labels are scoped to each
snapshot. Preview registers child references and observes nodes within its budget. A complete snapshot is converted to the site's immutable DAG export
input, rejecting partial graphs and cycles. Stored package export uses the
original validated bundle records. The framed binary wire serves the private
embedding protocol.
The JS wrapper refreshes linear-memory views after calls that might grow memory.
reset discards current definitions and collects while retaining the compiler;
dispose terminates the Worker and invalidates the entire runtime. Both client
and Worker enforce single-flight requests. There is no default compile/run
timer: request(message, null) (the default) and Infinity disable the timer;
a positive finite millisecond value supplies an explicit deadline. Tests use
finite deadlines to verify termination. A deadline, fatal WASM trap or RTS
asynchronous/resource exception discards the instance instead of resuming it.
Expected input/compiler errors are recoverable. Termination loses unsaved
definitions; callers must initialize a new Worker to resume work.
Wire/observation limits are separate from user execution budgets. They include
16,384 source scalars, 16 compiled exports (package tables permit 4,096), 16
arguments, and 16 MiB framed input/response bytes. Typed inputs use the site's
literal validator, including its nesting/item/text bounds. Legacy ternary input
is capped at 50,000 digits per argument. Preview permits 1–256 emitted nodes;
complete snapshots permit up to 100,000, and refuse partial export. Those limits
do not bound the reduction needed to expose any one node: a divergent reduction
still needs explicit Worker termination. Shared export retains the site's
32 MiB expanded / 128 MiB DAG file limits. The RTS receives a 256 MiB heap and
8 MiB Haskell-stack budget; the linker caps linear memory at 512 MiB. These are
not a browser-process memory cap. Reported linearBytes includes WASM linear
memory only, excluding JS buffers, compiled machine code and browser overhead.
Reset/GC can reuse memory but WebAssembly memory does not shrink. The plateau
assertion covers this finite workload, not arbitrary programs.
The tracked checks have passed on Chromium 148 and Firefox 150.0.2 on Linux. This does not establish support for earlier releases, other operating systems, or Safari. Fresh-Worker startup samples use localhost and may benefit from browser caches; they are not Internet cold-download measurements. Browser timer precision differs: a reported zero for a short transfer/observation is below the clock's resolution, not proof of zero cost. The site adapter has separate real-page integration tests; this isolated suite does not exercise that UI. Restoration of definitions after termination is not supported. Prepared compiler DAG/environment bytes are authenticated against hashes embedded in the built Worker assets; their original compiler provenance is verified during Nix preparation. Ordinary package/environment bytes instead pass the site's SHA-256 and bundle decoder inside the Worker before conversion to the checked internal DAG. The Haskell core does not implement a second bundle parser.
Worker operations
browser-client.mjs builds to client.mjs; its CompilerWorker loads the adjacent
worker.mjs. Both paths must come from the Nix browser output. Successful replies
carry UTF-8 text, milliseconds, roundtripMs and actual linearBytes. Except
for the legacy text operations and compilation names, structured results are JSON
in text. These are private adapter operations, not yet the published site's UI
protocol:
| Operation | Inputs / result |
|---|---|
init |
Default loads the build-pinned compiler kit; compiler:false starts a package-only session without downloading the kit. Ready identifies compilerHash (or null). |
load |
bytes: ArrayBuffer, lowercase whole-bundle hash; returns export names, atomically replaces definitions. |
compiler-init |
Lazily authenticate/load the prepared compiler kit into an existing package session; returns compilerHash without replacing definitions. |
expression |
Private adapter request: one wrapped expression (name, source), mode, output. Compiles against the retained expression scope (package exports, or draft locals and explicit imports) without changing it; returns a value view or starts the ordinary IO event protocol. Requires an initialized compiler and idle IO. |
compile |
source, optional standalone environment or site profileName/same-origin base; returns newline-separated public names and scopeNames. Native headers resolve manifest imports. A failed replacement leaves no previous definitions. |
evaluate |
name, typed arguments, output; returns the site's ValueView. |
preview / snapshot |
name, typed arguments, optional limit; returns {root,nodes,aliases,truncated}. |
export |
name, format (ternary, tricu, triangle, dag); returns the shared exporter's TreeFile. Stored bundles are exported without evaluation; compiled definitions require a complete snapshot. |
io-start |
name, typed arguments; starts the resumable action machine. |
io-resume |
token, optional line value; consumes exactly the current event token. |
io-view / io-snapshot |
Decode/inspect the completed IO result without moving a continuation into JS. |
io-cancel |
Drop the pending/completed interaction, retaining definitions and compiler. |
reset |
Drop definitions and IO state, collect, retain compiler. |
collect |
GC while retaining definitions, compiler and any IO continuation/result. |
stats |
Actual WASM linearMemoryBytes. |
Compilation environment semantics are explicit: omitted means the retained
contract environment; null means empty; {bytes,hash,names} means exactly those
named exports from an authenticated environment bundle. Missing names, duplicate
names and wrong hashes are errors. The shared site host owns profile
selection and checks its compiler pin against ready's compilerHash.
Resumable browser IO
BrowserRuntime.hs handles only pure, bind, abortIO, putStr, and getLine.
The native IODriver (filesystem, networking, threads, etc.) is not pulled in.
The machine returns before, read, write, after, or done events with
monotonically increasing session-local tokens. JS awaits the before/step hook
before acknowledging it; a later event requests the actual read/write effect.
after includes step/name/value trace fields without forcing arbitrary values.
On done, the retained result is available through io-view/io-snapshot.
No Haskell FFI call remains blocked while the user is entering a line.
An adapter maps these events to the shared IO host's before/after/write/read/
running callbacks. Page request IDs are independent of internal resume tokens.
It must cancel the interaction on terminal driver errors; an invalid line or
stale token leaves the pending state intact so a caller may retry. io-cancel
also permits cancelling a paused interaction without destroying compiled code.
Loading/compiling while an interaction is pending is rejected; reset or Worker
disposal also cancels it. There are no automatic effects or default terminal
fallbacks. Bounds match the site: 128 actions, 256-scalar single-line input with
no CR/LF, 4,096-scalar decoded strings and 16,384 total output scalars.
Upstream references:
- https://ghc.gitlab.haskell.org/ghc/doc/users_guide/wasm.html
- https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta
- Pinned mirror: https://github.com/haskell-wasm/ghc-wasm-meta/tree/8079eb96bbc63823d99ad8acaff0765532625474
The upstream README at this revision recommends Cabal-install 3.14 or earlier because of a reported 3.16 regression. The selected flavour targets WASI Preview 1; browser validation does not imply native WASI Preview 2 or component-model support.
The editor's tricu, triangle, dag and ternary result interpretations use
view to return a bounded complete snapshot (up to 100,000 nodes) through the
private protocol. The site adapter then resolves snapshot aliases and formats the
actual pure/IO result with the shared tree serializer. Serialization requires a
complete, acyclic snapshot.
The site adapter resolves local modules from page-owned bytes and verifies their
manifest pins. module-snapshot observes the declared public trees as one shared
list for portable export. Its 100,000-node budget includes the list spine.
Observation can diverge; Worker termination cancels it. See the demo
site's local module workflow.