tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit b32ab7b9d770258f5fd1c716dc1e380745bda2a7
parent 07a4a522913ccf40b933c1b2efff5724802c43c0
Author: James Eversole <james@eversole.co>
Date:   Sun, 18 Oct 2026 15:34:29 -0500

Add selectable browser runtimes with Haskell as the default

Integrate the frozen Haskell WASM checkpoint 5b49f7cc17203627954bb0cdecad5be9e35dcb6e and a separate Zig session adapter behind shared authenticated package, compiler, IO and export boundaries. Preserve explicit backend selection, hard Worker cancellation and honest runtime diagnostics.

Remove execution-limit controls, the Edit button and compilation warning. Retain unapplied-source styling. Add both-browser site parity, retained-session/security controls and matched production-backend timing/memory benchmarks. Label choices Haskell, Zig and JavaScript; document Haskell's reference-evaluator role and SIMD compatibility requirement.

Validation: nix flake check (all 9 current-system checks), 93 site unit tests, generated-JS parity, tricu-wasm-tests and tricu-zig-tests, Haskell default integration in Chromium/Firefox, and Zig site parity including 117 full golden applications and eight source demos.

Diffstat:
Mext/demo-site/README.md | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mext/demo-site/dist/app.js | 168+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mext/demo-site/dist/compiler-worker.js | 20++++++++++----------
Mext/demo-site/dist/compiler.js | 1-
Mext/demo-site/dist/execution.js | 35+++++------------------------------
Aext/demo-site/dist/haskell-session.js | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/dist/io.js | 9+++++++++
Dext/demo-site/dist/limits-ui.js | 95-------------------------------------------------------------------------------
Aext/demo-site/dist/published-bundle.js | 21+++++++++++++++++++++
Aext/demo-site/dist/runtime-backends.js | 15+++++++++++++++
Aext/demo-site/dist/runtime-session.js | 10++++++++++
Aext/demo-site/dist/wasm-worker.js | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/dist/worker.js | 45++++++++++++---------------------------------
Mext/demo-site/index.html | 17++++-------------
Mext/demo-site/release.nix | 9++++++++-
Mext/demo-site/src/app.ts | 132+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mext/demo-site/src/compiler-protocol.ts | 10+++++-----
Mext/demo-site/src/compiler-worker.ts | 18+++++++++---------
Mext/demo-site/src/compiler.ts | 1-
Mext/demo-site/src/execution.ts | 37++++++-------------------------------
Aext/demo-site/src/haskell-session.ts | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/src/io.ts | 8++++++++
Dext/demo-site/src/limits-ui.ts | 65-----------------------------------------------------------------
Mext/demo-site/src/protocol.ts | 9++++-----
Aext/demo-site/src/published-bundle.ts | 17+++++++++++++++++
Aext/demo-site/src/runtime-backends.ts | 17+++++++++++++++++
Aext/demo-site/src/runtime-session.ts | 24++++++++++++++++++++++++
Aext/demo-site/src/wasm-worker.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/src/worker.ts | 38+++++++++++---------------------------
Mext/demo-site/style.css | 25++-----------------------
Aext/demo-site/test/backend-benchmark.mjs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/backend-browser.mjs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/backend-firefox.mjs | 30++++++++++++++++++++++++++++++
Aext/demo-site/test/backend-probe.mjs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/backend-resume-probe.mjs | 33+++++++++++++++++++++++++++++++++
Aext/demo-site/test/backend-server.mjs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/benchmark-backends.mjs | 27+++++++++++++++++++++++++++
Aext/demo-site/test/browser-memory.mjs | 29+++++++++++++++++++++++++++++
Mext/demo-site/test/browser.mjs | 169+++++++++++++++++++++++++-------------------------------------------------------
Aext/demo-site/test/budgets.mjs | 3+++
Mext/demo-site/test/compiler-conformance.mjs | 3++-
Mext/demo-site/test/contracts-conformance.mjs | 3++-
Mext/demo-site/test/demo-cost.mjs | 3++-
Mext/demo-site/test/draft-session.test.mjs | 3++-
Mext/demo-site/test/execution.test.mjs | 41++++++++++-------------------------------
Mext/demo-site/test/graph.test.mjs | 4++--
Mext/demo-site/test/packages.test.mjs | 19++++++-------------
Mext/demo-site/test/release.py | 15++++++++++++++-
Aext/demo-site/test/resume.test.mjs | 9+++++++++
Aext/demo-site/test/runtime-backends.test.mjs | 32++++++++++++++++++++++++++++++++
Mext/demo-site/test/site.test.mjs | 48+++++++++++++++++++++++++++++-------------------
Aext/demo-site/test/summarize-backends.mjs | 29+++++++++++++++++++++++++++++
Aext/demo-site/test/zig-negative-fixtures.mjs | 23+++++++++++++++++++++++
Aext/demo-site/test/zig-session-probe.mjs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/BrowserRuntime.hs | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/CompilerProbe.hs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/Probe.hs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/README.md | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/Reactor.hs | 266+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/browser-bridge.mjs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/browser-client.mjs | 38++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/browser-page.mjs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/browser-parity.mjs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/browser-worker.mjs | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/browser.nix | 40++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/build.nix | 33+++++++++++++++++++++++++++++++++
Aext/haskell-wasm/check-browser.mjs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/check-compiler.py | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/check-firefox.mjs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/measure-compiler.py | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/measure.py | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/node-wasi.mjs | 8++++++++
Aext/haskell-wasm/prepare-compiler.mjs | 45+++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/prepare-parity.mjs | 46++++++++++++++++++++++++++++++++++++++++++++++
Aext/haskell-wasm/reactor-init.c | 8++++++++
Aext/haskell-wasm/ts-compiler.mjs | 42++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/README.md | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/backend-check.mjs | 37+++++++++++++++++++++++++++++++++++++
Aext/wasm/backends.mjs | 29+++++++++++++++++++++++++++++
Aext/wasm/backends/js.mjs | 14++++++++++++++
Aext/wasm/backends/zig-wasm.mjs | 15+++++++++++++++
Aext/wasm/browser-page.mjs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/browser.mjs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/check.mjs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/compare.mjs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/preview-compare.mjs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/runtime.mjs | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/site-session.mjs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/wasm/tree-session.mjs | 34++++++++++++++++++++++++++++++++++
Aext/wasm/worker.mjs | 38++++++++++++++++++++++++++++++++++++++
Mext/zig/README.md | 6++++++
Mext/zig/build.zig | 22++++++++++++++++++++++
Mext/zig/src/c_abi.zig | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aext/zig/src/preview.zig | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/zig/src/reduce.zig | 44+++++++++++++++++++++++++++-----------------
Mext/zig/src/reduce_test.zig | 1+
Mext/zig/src/source_text.zig | 47++++++++++++++++++++++++++++++++++++++++-------
Mflake.lock | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mflake.nix | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
99 files changed, 5376 insertions(+), 799 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -11,6 +11,56 @@ The three panels share one page: definitions, and compiles editable demos or scratch programs in one workspace. - **Export Definition** writes a stored tree without running it. +## Runtime selection + +The **Runtime** selector offers **Haskell** (default), **Zig**, and +**JavaScript**. Haskell is the reference evaluator; the Haskell and Zig browser +backends run as WASM. Selection is explicit and page-local; failures never silently switch +backends. Changing runtime discards the old runtime/checkpoint, preserves source +text, and requires compilation again for a draft. Published selections reload on +the chosen runtime. Switching is disabled while work is active; Stop first. + +The release packages the pinned Haskell reactor, loader, WASI shim and authenticated +compiler kit under `runtime/haskell/`. Package-only runs do not download the +compiler kit. Draft compilation checks the runtime's original compiler-bundle +identity against `compiler.json`, then resolves the selected empty, practical or +default-contract environment. Missing assets or mismatched pins are errors. +Zig packages a freestanding reactor and private adapter under `runtime/zig/`. +It has no WASI imports. Its compiler session fetches the exact authenticated +compiler bundle selected by `compiler.json`, rather than loading Haskell's +prepared kit. Both use the same environment/profile pins and names. + +All runtimes use shared bundle authentication, literal validation, immutable +stored-definition export and the same page IO protocol. `runtime-session.ts` +defines the coarse adapter boundary: load, compile, run with async IO callbacks, +diagnostics and disposal. Private pointers, IO tokens and heap checkpoints do not +cross backend types. `wasm-worker.ts` wraps the selected session; +`haskell-session.ts` maps the built Haskell client's protocol, including its +nested Worker and asynchronous resume tokens. Host cleanup always terminates the +owning Worker tree; there is no default compile/run timer. Zig's private +`ext/wasm/site-session.mjs` owns its handles, serializes operations, and retains +IO continuations across awaited host callbacks. Its exclusive async scope frees +all transient handles on success or failure; compiled definitions remain rooted. +Zig's site demands, bulk Unicode decoding and bounded previews explicitly select +unbounded work, not a large substitute fuel value. The native ABI's finite-fuel +operations remain available for native callers and negative-control tests. + +WASM diagnostics report actual linear memory, not fabricated JS graph +counters or total browser RSS. Haskell's fixed RTS limits are 256 MiB heap and 8 MiB +Haskell stack; both reactors cap linear memory at 512 MiB. Zig also has an 8 MiB +WASM stack. Zig's strict scalar decoder rejects redundant high-zero numeral +representations that the permissive JS text decoder tolerates; valid Unicode +scalar strings agree. These are implementation +constraints, not UI settings or a promise about total process memory. Exhaustion +or a fatal trap discards the session and requires explicit reinitialization. + +The Haskell binary requires WebAssembly SIMD, supported starting with Chrome 91, +Firefox 89 and Safari/iOS 16.4. These are feature-support floors, not tested +minimum browser versions: integration is validated in Chromium 148 and Firefox +150; Safari/iOS remain unvalidated. Older clients can explicitly select JS or +Zig if Haskell initialization fails; there is no silent fallback. Haskell uses +a bundled WASI shim and a nested module Worker, not browser-native WASI support. + ## Programs and controls All curated and arbitrary packages take the same verified bundle/heap execution @@ -206,7 +256,7 @@ stacked source-first layout: Program and bundle details, full-width **Source and editing**, then **Try this program** with definition, inputs, Run and results. Both source and Try start expanded and can be collapsed independently. Interaction trace is a child of Try, not a separate top-level section. Input help/raw JSON, result interpretation, IO mode and -resource settings live under **Advanced**. Tree results have a separate inspection +execution mode and stepping controls live under **Advanced**. Tree results have a separate inspection disclosure instead of dominating the result area. Pure programs do not show an empty IO terminal. Curated input labels describe the published argument order; arbitrary bundles and local drafts retain generic positional labels. @@ -217,30 +267,33 @@ packages instead use **Package details / Compiled package (no editable source)** and explain that running/exporting remains available but editable source is not included. Reference source without a supported compiler environment is labelled **Source (read-only) / Read-only source**, with editing and local compilation -explicitly unavailable. Published source is read-only until **Edit source** is -clicked. Opening the editor alone keeps **Run published**: the authenticated -bundle is executed, not recompiled. Changing text switches to **Local draft**. +explicitly unavailable. Supported source is directly editable without an Edit +button. Until the text changes, **Run published** executes the authenticated +bundle, not a recompilation. Changing text switches to **Local draft**. The first run requires an explicit **Compile draft**; compilation does not run its result. After success, **Run draft** always runs the last successful compiled version, even after further source edits. A persistent note identifies unapplied edits. A failed compilation invalidates that version: it never falls back to an earlier draft or the published executable. Loading another program also clears -it. **Restore published source** reinstates the original bundle and read-only -source. There is no combined compile-and-run button. - -Run errors (including result interpretation errors), Stop, Reset, limits changes, -and pane changes do not require recompilation. Run remains enabled during an -application; clicking it cancels that attempt and starts a fresh one. The footer -also provides Stop and a link to the Run controls. +it. **Restore published source** reinstates the original bundle and editable +source. The editor and authorship label use the modified color while text differs +from the current runnable compilation (or has not been compiled). Successful +compilation or restoring the published source clears that color; subsequent +unapplied edits restore it. There is no combined compile-and-run button. + +Ordinary run errors (including result interpretation errors) retain usable +compiled definitions. The JS backend restores its checkpoint for each attempt; +Run can replace a running JS attempt. Both WASM backends keep definitions in a live +Worker, so Run is disabled while that Worker is busy. Hard Stop, Reset during +active work, a fatal runtime failure or leaving the pane during a WASM run +loses those definitions; the UI requires Compile again and keeps the source. +Reset while idle and unapplied source edits retain the live WASM definitions. +The footer also provides Stop and a link to the Run controls. **Compile draft** starts directly when clicked; there is no confirmation popup. -The inline notice identifies the JavaScript Tree Calculus reducer, notes the time -and memory cost, links native Haskell tools for larger builds, and directs users -to **Advanced** for optional memory, work and time limits. It makes no numerical -browser/native performance claim. An elapsed indicator and Stop remain available; -source is retained after cancellation or failure. No optional execution limits -are enabled by default. Compilation speed depends on the source and pinned -compiler; the notice does not prescribe an expected duration. +There is no slow-compilation warning. An elapsed indicator and Stop remain +available; source is retained after cancellation or failure. There are no +configurable execution limits. **Download .arboricx**, beside the primary **Load package** action in both Playground and Export, returns the original, bounded, hash-verified bundle @@ -356,7 +409,7 @@ including deeply nested trees. Stop, reset, replacement, and pane changes retain the existing Worker termination boundary; export allows 120 active seconds. Writers flush bounded text-fragment batches so expanded output does not retain one array entry per logical node. Loading retains its independent 60-second deadline. -These output limits remain in force with optional execution limits off. +These output limits remain in force independently of program execution. ## Compile source @@ -405,16 +458,14 @@ programs retain the default contract environment described above. Compiler input is limited to 16,384 Unicode scalars. Diagnostic labels are limited to 512 scalars, and displayed definitions to 16 names of 128 scalars each. Input size alone does not bound compilation work or memory. Long compilation -is permitted. With optional limits off (the default), compilation has no -application ceiling, but still has engine/OS limits. Enabling optional limits -can stop a large live graph at its configured memory budget. +is permitted. Compilation has no application node, work, frame or elapsed-time +ceiling, but still has engine/OS limits. Stop terminates the Worker. -One compilation supports repeated applications through a page-local checkpoint +For JavaScript, one compilation supports repeated applications through a page-local checkpoint of the collected lazy graph and definition handles. Checkpointing neither forces code nor expands shared trees. Every run uses a new Worker and its own graph copy, -so an error, partial reduction, IO pause, work limit or hard Worker termination -cannot damage the saved definitions. Each run receives the current execution -settings and a fresh work allowance. Compilation and successful-run work are +so an error, partial reduction, IO pause or hard Worker termination cannot damage +the saved definitions. Compilation and successful-run work are reported cumulatively; failed/terminated work is not included in that total. The checkpoint remains in memory only, adding a graph-sized retained copy plus @@ -426,7 +477,14 @@ the checkpoint, and page reload discards it. Selection links never fetch the compiler or automatically execute source. Ordinary examples still need no compiler download. -## Sessions and execution limits +Each WASM backend instead retains its lazy definition set inside its live runtime Worker. +It does not export a JS checkpoint or eagerly normalize programs to simulate one. +Successful runs and recoverable result-decoding/IO errors keep that set for reuse; +IO continuations/results are cancelled after each finished attempt. Fatal errors +and hard Worker termination discard it. The site never substitutes a previously +compiled or published program after a failed compilation. + +## Sessions and cancellation The shared pane opens on **Ecosystem**. **Playground** and **Export Definition** are explicit @@ -446,71 +504,48 @@ Compiler downloading/execution for drafts starts with Compile. Explicitly select the indexed compiler package also downloads/verifies its library, without compiling or executing a program. -Both runners start with **Run without optional limits** checked: application -node, evaluator-frame, work and active-time execution ceilings are off. Under -**Advanced → Optional limits**, uncheck it to enable the editable budgets. -Memory budgets count live tree nodes, not megabytes or total browser memory. -Garbage collection and Stop remain available with or without limits. Choices are -not persisted across page reloads; reload -use the limits-off default. Download, format, literal, IO and export checks are -independent and remain enabled. - -The **Advanced** editors configure live nodes, work units, evaluator frames and -active seconds. Zero work or seconds means no corresponding cutoff; node and -frame settings remain positive and finite. Values are validated again at the -Worker boundary even when limits are off. **Apply limits** starts a fresh session; -invalid edits preserve the current heap. **Restore defaults** restores limits off -and the predefined budget values. Running without limits disables these editors -but remembers the last applied values for switching back. Links and package -metadata never set execution budgets. - -Optional compiler budget defaults: 6m live nodes, no work cutoff, 100k frames, time unlimited. Package -defaults: 250k live nodes, 20m work, 50k frames, 10 active seconds. Each compile/run -gets its own active deadline; input/step/idle pauses exclude time. Long deadlines -use chunks that fit browser timers. Packages retain an independent 60-second -loading deadline in all profiles. All package profiles enable tracing GC and exclusive owned IO roots; thresholds -adapt to small node budgets. Resource-setting changes discard/reload the heap while preserving source and -selected inputs. Download/schema/literal/IO/output/handle guards remain. Stop and -leaving the pane end running jobs; browser memory restrictions apply. - -Loading has a separate 60-second deadline covering Worker startup, transfers, -verification and heap construction, even when optional execution limits are off. -Timeouts identify package loading, draft compilation, program execution, -definition export or bundle download; failures retain their underlying diagnostic -with the operation named. Catalogue-loading failures are also distinguished. -Manual Stop and pane-change cancellation remain cancellation messages rather than -being reported as operation failures. The Playground's **Export this definition** +Compilation and program execution have no configurable node, evaluator-frame, +work or active-time ceilings. The former optional-limit editors, settings and +Worker request fields have been removed, rather than retained as hidden defaults. +Garbage collection remains enabled. Stop and leaving the pane terminate running +Workers; actual browser/OS memory restrictions still apply. A divergent program +can run until cancelled, and a large live result can exhaust browser memory. + +Package loading retains a 60-second deadline covering Worker startup, transfer, +verification and heap construction. Downloads and bounded definition exports +also retain their independent deadlines; these are transport/format safeguards, +not program execution budgets. Download, schema, literal, IO, preview and output +bounds remain enabled. Native tools remain preferable for large builds. + +Failures retain their underlying diagnostic with the operation named. Timeouts +identify loading, download or definition export; compilation and runs do not +have elapsed-time cutoffs. Manual Stop and pane-change cancellation remain +cancellation messages rather than operation failures. The Playground's **Export this definition** link follows Interaction trace at the bottom of Try this program. ## Ordinary package resource and safety boundaries -Download and format checks remain enabled when optional execution limits are off. +Download and format checks are independent of program execution. Every bundle must match its whole-file SHA-256. A bundle selected for both compiler and ordinary-package roles must satisfy the smaller download cap. - Ordinary package downloads: 16 MiB (draft-compilation compiler profile: 2 MiB); bundles: 100,000 physical indexed nodes, 4,096 exports/roots. -- Graph: 250,000 live nodes (including thunks), 20,000,000 work units, - 50,000 evaluation frames when optional limits are enabled, editable under Advanced. - These application execution guards are off by default. Loading/observers/codecs consume the session budget. +- Graph: tracing GC, but no application node, work or frame ceiling. Physical + browser memory remains finite; source/input bounds do not bound evaluation cost. - Inputs: at most 16 arguments; list nesting 16 /4,096 items; argument JSON 16,000 characters; ternary literal 4,096 nodes; text 4,096 scalars. Natural inputs are bounded decimal strings; decoded naturals are capped at 4,096 bits. - Result previews: up to 64 distinct constructors; list view: 32 entries with 8-node previews. Strings/bytes have 4,096-element limits. - IO: 128 actions; 256 scalars per line, no CR/LF; 16,384 output scalars. -- Main-page watchdog: 10 seconds active time per load; run deadlines use applied - active seconds (optional preset 10), with zero/disabled meaning unlimited run time. - Runs have no active-time cutoff in the default guards-off profile. User step - and input pauses do not consume it. Timers can be delayed by browser throttling - or suspension; this is not a hard real-time guarantee. Stop/reset/replacement - terminates the Worker and does not execute cleanup/finalizers. - -Optional limits bound graph growth and program work, not total browser memory. -Output bounds apply independently. Historical evidence: a full shipping-demo -recompilation with the earlier bounded profile hit its 6m-node budget after about -seven minutes in the Node validation host (about 1.2 GiB sampled RSS); smaller -prebaked-environment drafts passed in Chromium. That is historical validation evidence, not a timing forecast or acceptance -result for unbounded compilation. +- Main-page deadlines: 60 seconds for package loading/download and 120 seconds + for bounded definition export. Program execution and compilation run until + completion, error or cancellation. Stop/reset/replacement terminates the Worker + without executing cleanup/finalizers. Browser timer throttling means loading + and export deadlines are not hard real-time guarantees. + +Output bounds apply independently of execution. No application setting promises +to bound total browser memory; JS objects, parser buffers and GC add overhead. Published demos remain available without compilation. JS objects, decoded bundle records, hash buffers, queues, and GC have overhead. A 32-node DAG denoting 4,294,967,295 logical nodes loads/returns cheaply and is @@ -672,7 +707,7 @@ validation evidence, and complete scratch retention. The site/dependency outputs must match; do not activate a site without its corresponding registry dependencies. These steps prepare and validate local release assets, not a public deployment. -## Runtime architecture +## Runtime architecture (JavaScript backend) - `src/graph.ts`: Worker-local heap of immutable constructors, lazy application thunks, and memoized forwarding references. Explicit continuation frames force @@ -696,7 +731,7 @@ These steps prepare and validate local release assets, not a public deployment. - `src/protocol.ts`, `worker.ts`, `app.ts`: typed discriminated Worker messages, branded node/hash/request identities, one load, and one run per ordinary Worker. Main page owns watchdog/termination; inputs use request IDs. `compiler.ts` and - `compiler-{protocol,worker,app}.ts` add the separately bounded compiler session + `compiler-{protocol,worker}.ts` add the compiler session and its retained definition handles. All package strings, source, diagnostics, and results are rendered as text, never HTML. @@ -708,7 +743,7 @@ Rerunning verifies/loads a fresh package and resets the heap. Within a run, IO resumption never serializes/rebuilds that heap. Metadata loading does not force exports. `Graph.apply` returns a handle; it does not normalize or serialize it. -### Graph collection and roots +### JavaScript graph collection and roots The compiler enables iterative, non-forcing mark/sweep at reducer safe points, initially at 250k live cells with a survival-adjusted threshold. Constructor, @@ -731,17 +766,17 @@ handles, allowing compiler/input/decoder garbage to go. Compiler IO explicitly owns its graph: `runIO(..., true)` traces current action + saved continuations between steps, and may invalidate unrelated caller handles. Its ordinary API keeps this off by default. No collector runs concurrently during a user pause. -All package profiles enable tracing (initial threshold 100k) and own their IO graph, +Package sessions enable tracing (initial threshold 100k) and own their IO graph, retaining application/continuation roots only. Compiler tracing starts at 250k; compiler IO also lists all saved definitions as additional retained roots. Stats separate live cells (`nodes`), cumulative cell allocations (`allocations`), -peak live cells, collections, reclaimed cells, and collector work. Finite limits/fuel -apply to collection too; the compiler with optional limits off instead relies -on manual Stop/Worker termination and actual browser limits. No budget reset. -These are graph-cell metrics, not total JavaScript object/heap byte measurements. -A genuinely large live result can still exhaust the live-cell cap; constructor -batches can hit it before another reducer safe point. +peak live cells, collections, reclaimed cells, and collector work. Both browser +runners use unbounded application budgets and rely on Stop/Worker termination +and actual browser limits. Finite graph budgets remain available to reference +tests, not as runtime settings. These are graph-cell metrics, not total +JavaScript object/heap byte measurements. Large live results can exhaust memory; +constructor batches can allocate before another reducer safe point. ## Application preparation @@ -764,6 +799,18 @@ No npm installation. Tooling comes from the repository's locked nixpkgs: ./ext/demo-site/build.sh --check # fresh temporary compile; exact shipped-JS parity nix shell --inputs-from . nixpkgs#nodejs -c python3 ext/demo-site/test/run-bounded.py \ --seconds 60 -- node --max-old-space-size=512 --test --test-concurrency=1 ext/demo-site/test/*.test.mjs +# Actual Runtime-selector integration on the packaged site (Chromium + Firefox) +nix build .#arboricxSite --no-link +SITE=$(nix path-info .#arboricxSite) +ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ + node ext/demo-site/test/backend-browser.mjs "$SITE/runtime/haskell" chromium +ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpkgs#nodejs nixpkgs#firefox -c \ + node ext/demo-site/test/backend-firefox.mjs "$SITE/runtime/haskell" firefox +# Same real-page suite for Zig, plus full independent golden/embedding checks: +TEST_BACKEND=zig-wasm ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ + node ext/demo-site/test/backend-browser.mjs "$SITE/runtime/haskell" chromium +TEST_BACKEND=zig-wasm ARBORICX_SITE_ROOT="$SITE" nix shell --inputs-from . nixpkgs#nodejs nixpkgs#firefox -c \ + node ext/demo-site/test/backend-firefox.mjs "$SITE/runtime/haskell" firefox # Heavy compiler comparisons: explicit opt-in, not included in *.test.mjs nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 ext/demo-site/test/compiler-conformance.mjs nix shell --inputs-from . nixpkgs#nodejs -c node --max-old-space-size=768 ext/demo-site/test/contracts-conformance.mjs @@ -783,9 +830,87 @@ and generated output are covered by the explicit parity check. The graph, package and browser checks are **opt-in**, not added to the fast host gate. Chromium CDP tests require Node with global WebSocket support and use a temporary profile plus `--no-sandbox` for container compatibility; only run them against -this trusted local fixture site. Non-Chromium and real mobile performance are +this trusted local fixture site. Safari and real mobile performance are not yet validated. Browser tests exercise a local registry, not production. +The backend-selector suites serve the actual packaged page at a nested URL, +with a local authenticated registry and packaged runtime assets. They check +published programs, IO, stored export, default/empty/practical compilation, +repeated retained runs, output-error recovery, missing-runtime and compiler-pin +failure without fallback, Stop/recompile (including non-returning reduction), +stepping, stale/wrong-kind/invalid-line resume rejection and backend-switch +isolation. Invalid responses do not consume a pending request ID. Both WASM +backends have passed in Chromium 148 and Firefox 150 on Linux. They are not broader +claims about other engines, mobile devices or Internet download latency. The +Haskell embedding's independent 117-application parity suite remains in +`ext/haskell-wasm`; it is distinct from these real-page integration checks. +The Zig extension of the page suite also checks all 117 complete published +Haskell result/terminal goldens, all eight source/environment profiles, seven +codecs and negative controls, physical sharing, nonforcing divergent preview and +abort payloads, IO limits and GC across input waits, compile/host-error recovery, +and 50 retained runs with stable owned-handle counts and linear-memory plateau. +Complete bounded observation is an explicit test operation, never a checkpoint +or the implementation of ordinary Run. These are site-capability parity claims, +not a claim that the two private embedding protocols expose identical operations. + +### Matched production-backend benchmark + +Use a built site, not the prototype Worker. The harness alternates backend order +across three fresh browser profiles per backend/engine, loads all eight source +demos as published packages, then compiles each in a fresh production Worker. +Each compilation is followed by 21 retained applications; the first is recorded +separately from the 20 warm applications. Complete selected-codec results and IO +terminal text are checked against independent Haskell captures. Only the first +captured input per demo is timed; the broader parity suite remains separate. + +```sh +nix build .#arboricxSite --no-link +SITE=$(nix path-info .#arboricxSite) +nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium nixpkgs#firefox -c \ + node ext/demo-site/test/benchmark-backends.mjs "$SITE" /tmp/tricu-backend-timing +nix shell --inputs-from . nixpkgs#nodejs -c \ + node ext/demo-site/test/summarize-backends.mjs /tmp/tricu-backend-timing/report.json +# Separate memory pass: idle/live holds permit PSS/private-page sampling. +# Its timings are intentionally not used as performance measurements. +BENCHMARK_ROUNDS=1 BENCHMARK_PSS=1 nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium nixpkgs#firefox -c \ + node ext/demo-site/test/benchmark-backends.mjs "$SITE" /tmp/tricu-backend-memory +``` + +Reports include platform/browser identity, runtime hashes, phase timings, actual +linear-memory size, and sampled browser-process-tree RSS. RSS sums double-count +shared pages; they are not private memory. The separate memory pass samples +`smaps_rollup` PSS (shared pages apportioned) and private pages during baseline, +retained-session holds and final idle. Measurements require readable Linux +`/proc`; PID-namespace translation is handled explicitly. Sampling is not a hard +peak-memory guarantee. PSS can also depend on other processes sharing libraries. +No forced GC or runtime policy changes are used. Browser startup/background work +and code caching can affect the baseline, so absolute RSS is not attributed to +the evaluator alone. + +A matched desktop run on Ryzen AI MAX+ 395, Linux, Chromium 148 and Firefox 150 +used Zig reactor `11fb47689911066d0691419a5bd8d039ebbaf8f8524566b8e7fbfb06f3b327bb` +and Haskell reactor `ddefcf48270c5be630ce606bc7f96788388ef794f0b3712a827b8c6701af1575`. +Median first-package readiness was Zig/Haskell 25/47 ms in Chromium and 22/40 ms +in Firefox. Shipping-demo compilation including initialization and environment +loading was 1122/1864 ms in Chromium and 1193/1293 ms in Firefox; warm compiled +applications were 44/2.8 ms and 47/2 ms respectively. These production-adapter +results are not the earlier isolated reducer/compiler microbenchmarks: Zig was +not universally faster, particularly for repeated compiled applications. + +Across the timed workloads Zig package sessions used 8.75–16.63 MiB linear memory +and compiled sessions 76.56–76.69 MiB; Haskell used 9.06–9.13 MiB and 12.13–23.88 MiB +respectively. Neither grew between the first and twenty-first retained application. +A separate one-profile-per-backend/engine memory pass observed Zig's retained +compiled sessions approximately 23–32 MiB higher in browser-tree PSS than Haskell +in Chromium and 13–47 MiB higher in Firefox, comparing corresponding demo holds. +These are bounded desktop observations, not mobile capacity guarantees or memory +thresholds. Larger programs and different inputs can cost more. + +All HTTP is localhost, uncompressed and `no-store`; subsequent Workers may benefit +from engine code caches. These figures do not measure Internet cold-download +latency, mobile battery use or isolated reducer throughput. The benchmark does +not alter the site's default backend or impose execution deadlines on users. + The bounded runner uses a Linux per-process writable-data limit, an aggregate process-group RSS watchdog and a whole-job deadline. The watchdog is sampled, not a cgroup guarantee. Run Node tests serially. Binary artifact checks compare @@ -809,7 +934,7 @@ network publication. Long full-source draft comparisons are explicitly opt-in (optional final argument selects a single demo). They use independent Haskell result captures, keep the -normal compiler memory guard, and can fail at that guard on large examples: +finite reference-harness memory guard, and can fail at that guard on large examples: ```sh nix shell --inputs-from . nixpkgs#nodejs -c python3 ext/demo-site/test/run-bounded.py \ @@ -854,7 +979,7 @@ decoder behavior without executing the compiler. The independent Haskell corpus covers small normal-tree applications, discarded divergence, partial/multiple applications, demo policies, canonical codecs, and IO. Chromium exercises streamed registry bytes, malformed/hash/oversize controls, -shared DAGs, budgets, modes, literals, repeated runs, source-free export, Unicode +shared DAGs, cancellation, modes, literals, repeated runs, source-free export, Unicode IO, input/step pauses, reset/replacement, safe text rendering, selection links, Worker termination, and narrow layouts. diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -1,10 +1,10 @@ +import { backend, backends, graphStats, runtimeStats } from './runtime-backends.js'; import { demoInputLabels } from './demo-inputs.js'; import { argumentEditor } from './arguments-ui.js'; import { createTreeExport } from './export-app.js'; import { activePane } from './tabs.js'; -import { readSettings } from './limits-ui.js'; import { ActiveClock } from './clock.js'; -import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds, operationFailure, operationTimeout } from './execution.js'; +import { PACKAGE_LOAD_MS, EXPORT_ACTIVE_MS, operationFailure, operationTimeout } from './execution.js'; import { sourceText } from './compiler.js'; import { packageIndex, packageReleases } from './packages.js'; import { bundleHash, fetchBytes } from './bundle.js'; @@ -18,8 +18,11 @@ function el(id) { } const sourceEditor = el('source-editor'), compileButton = el('compile'); const restoreButton = el('restore-source'), downloadButton = el('download-bundle'); -let editingSource = false; let sourceAvailable = false; +const backendPicker = el('runtime-backend'); +let selectedBackend = backend(backendPicker.value); +const persistentDefinitions = () => backends[selectedBackend].persistentDefinitions; +const workerURL = (compiler = false) => new URL(compiler ? backends[selectedBackend].compiler : backends[selectedBackend].worker, import.meta.url); let draft = false, scratch = false, compiled = false, busy = false, originalSource; let compiledDraft, compiledSource; let totalWork = 0; @@ -35,7 +38,6 @@ const argsInput = el('arguments'), resultCodec = el('output-type'); const argumentsUI = argumentEditor(argsInput); const mode = el('mode'), steps = el('step-mode'); const runButton = el('run'), stopButton = el('stop'), nextButton = el('next'); -let settings = readSettings('package'); let operation; const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout')); const shareButton = el('share'); @@ -61,15 +63,16 @@ let preferredExport; const traceRows = new Map(); function pauseClock() { deadline.pause(); } function clock() { deadline.start(); } -function runClock() { operation = 'running'; deadline.reset(activeMilliseconds(settings)); clock(); } +function runClock() { operation = 'running'; deadline.reset(Infinity); clock(); } function controls(working) { busy = working; + backendPicker.disabled = busy; programPicker.disabled = busy || entries.length === 0; versionPicker.disabled = busy; hashInput.disabled = busy; el('load-release').disabled = busy || !programPackage(programPicker.value); el('load').disabled = busy; - runButton.disabled = draft ? !compiled : busy || !loaded; + runButton.disabled = draft ? !compiled || (busy && persistentDefinitions()) : busy || !loaded; runButton.textContent = draft ? 'Run draft' : 'Run published'; el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource); el('draft-run-note').textContent = compiled @@ -84,9 +87,7 @@ function controls(working) { compileButton.hidden = !draft; restoreButton.hidden = !draft; sourceEditor.disabled = busy; - sourceEditor.readOnly = !editingSource && !draft; - el('edit-source').disabled = busy; - el('edit-source').hidden = originalSource === undefined || editingSource || draft; + sourceEditor.readOnly = originalSource === undefined && !draft; const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); el('source-summary').textContent = editableSource ? 'Source and editing' : sourceAvailable ? 'Source (read-only)' : 'Package details'; el('source-title').textContent = editableSource ? 'Read and edit source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; @@ -97,14 +98,10 @@ function controls(working) { restoreButton.disabled = busy || originalSource === undefined || !draft; downloadButton.disabled = busy || draft || downloadHash() === undefined; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; - el('authorship').classList.toggle('draft', draft); - sourceEditor.classList.toggle('draft', draft); - el('compiler-settings').hidden = !draft; - el('compiler-resource-choice').hidden = !draft; - el('package-resource-choice').hidden = draft; - el('compiler-disclosure').hidden = !draft; + const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource); + el('authorship').classList.toggle('modified', sourceModified); + sourceEditor.classList.toggle('modified', sourceModified); el('export-draft-note').hidden = !draft; - el('package-settings').hidden = draft; for (const button of document.querySelectorAll('[data-compiler-example]')) button.disabled = busy; stopButton.disabled = !busy && !(draft && (worker || compiled)); @@ -148,10 +145,6 @@ function clearOutput() { lineInput.value = ''; } function finish(message, error = false, reason = 'failure') { - if (error && /Graph allocation limit|evaluation stack limit|Graph stack limit/u.test(message)) - message = `Optional memory or evaluation-depth limit reached. Adjust limits under Advanced to continue. Your source is unchanged. ${message}`; - else if (error && /Graph fuel limit/u.test(message)) - message = `Optional work limit reached. Adjust limits under Advanced to continue. Your source is unchanged. ${message}`; if (error && reason === 'failure') message = operationFailure(operation, message); if (!draft && !loaded) @@ -191,10 +184,9 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { return; dispose(); operation = 'loading'; - settings = readSettings('package'); - deadline.reset(PACKAGE_ACTIVE_MS); + deadline.reset(PACKAGE_LOAD_MS); automaticRun = runAfterLoad; - const current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); + const current = new Worker(workerURL(), { type: 'module' }); worker = current; let currentVerified = false; controls(true); @@ -313,7 +305,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(result.value, null, 2); - finish(`Returned ${result.type}: tree graph · ${stats.nodes.toLocaleString()} heap nodes · ${stats.work.toLocaleString()} work units`); + finish(`Returned ${result.type}: tree graph · ${runtimeStats(stats)}`); } else finish(`Returned ${result.type}: ${JSON.stringify(result.value)}`); @@ -332,7 +324,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { loaded = false; finish(event.message || 'Worker failed.', true); } }; - send({ type: 'load', ...selection, ...settings }); + send({ type: 'load', ...selection }); } function selectExample(entry) { const name = examplePackage(entry); @@ -343,7 +335,6 @@ function selectExample(entry) { loadBundle(latest); } function select(entry, release) { - editingSource = false; sourceAvailable = false; draft = false; scratch = false; @@ -368,7 +359,6 @@ function select(entry, release) { } function invalidateProgram() { el('bundle-download-status').textContent = ''; - editingSource = false; sourceAvailable = false; draft = false; scratch = false; @@ -470,10 +460,10 @@ inputForm.onsubmit = event => { }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => finish(draft && compiled ? 'Stopped. Last compiled draft retained.' : 'Stopped. Session discarded.', true, 'cancelled'); +stopButton.onclick = () => finish(draft && compiledDraft ? 'Stopped. Last compiled draft retained.' : draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); el('stop-compile').onclick = stopButton.onclick; el('try-program-link').onclick = () => { el('try-program').open = true; }; -el('reset').onclick = () => { dispose(true); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; +el('reset').onclick = () => { keepDraft(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) return; @@ -608,13 +598,13 @@ downloadButton.onclick = () => { // It never changes source, definitions, arguments or the Run/Export target. dispose(); operation = 'downloading'; - const current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); + const current = new Worker(workerURL(), { type: 'module' }); worker = current; controls(true); el('status').textContent = 'Downloading bundle'; el('bundle-download-status').textContent = 'Verifying published bundle…'; el('bundle-download-status').classList.remove('error'); - deadline.reset(PACKAGE_ACTIVE_MS); + deadline.reset(PACKAGE_LOAD_MS); clock(); current.onmessage = ({ data }) => { if (worker !== current) @@ -651,20 +641,13 @@ downloadButton.onclick = () => { }; current.onerror = event => { if (worker === current) finish(event.message || 'Worker error', true); }; - current.postMessage({ type: 'load', hash, uncapped: true }); -}; -el('edit-source').onclick = () => { - editingSource = true; - el('source-section').open = true; - controls(false); - sourceEditor.focus(); + current.postMessage({ type: 'load', hash }); }; mode.onchange = () => { el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; }; sourceEditor.oninput = () => { - editingSource = true; el('source-section').open = true; preferredName = exportsInput.value || preferredName; - dispose(true); + keepDraft(); clearOutput(); clearTreeExport(); if (!compiled) @@ -684,7 +667,6 @@ restoreButton.onclick = () => { dispose(); sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); - editingSource = false; controls(busy); }; compileButton.onclick = () => compileDraft(); @@ -697,10 +679,9 @@ function compileDraft() { clearTreeExport(); try { sourceText(sourceEditor.value); - settings = readSettings('compiler'); const current = draftWorker(); controls(true); - deadline.reset(activeMilliseconds(settings)); + deadline.reset(Infinity); clock(); el('status').textContent = 'Compiling'; el('compiler-stats').textContent = 'Loading pinned compiler and demo environment…'; @@ -708,7 +689,7 @@ function compileDraft() { const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; }; activity(); compilerTimer = setInterval(activity, 1000); - current.postMessage({ type: 'compile', source: sourceEditor.value, ...settings, + current.postMessage({ type: 'compile', source: sourceEditor.value, ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); } catch (error) { @@ -716,7 +697,7 @@ function compileDraft() { } } function draftWorker() { - const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' }); + const current = new Worker(workerURL(true), { type: 'module' }); worker = current; current.onmessage = ({ data }) => { if (worker !== current) @@ -729,7 +710,7 @@ function draftWorker() { compiledDraft = data.draft; compiledSource = sourceEditor.value; compiled = true; - totalWork = data.stats.work; + totalWork = graphStats(data.stats) ? data.stats.work : 0; exportsInput.replaceChildren(); for (const name of data.names) { const option = document.createElement('option'); @@ -739,9 +720,12 @@ function draftWorker() { } if (data.names.includes(preferredName)) exportsInput.value = preferredName; - dispose(true); + if (persistentDefinitions()) + idleDraft(); + else + dispose(true); el('status').textContent = 'Compiled'; - compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'optional limits off' : settings.limits.fuel === 0 ? 'memory/depth limits on; work unlimited' : 'optional limits on'} · ${data.stats.work.toLocaleString()} work units`; + compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${graphStats(data.stats) ? `${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${data.stats.work.toLocaleString()} work units` : runtimeStats(data.stats)}`; el('compiler-stats').textContent = compilationStats; break; case 'state': @@ -769,8 +753,12 @@ function draftWorker() { el('terminal').textContent += data.text; break; case 'done': - totalWork += data.stats.work; - dispose(true); + if (graphStats(data.stats)) + totalWork += data.stats.work; + if (persistentDefinitions()) + idleDraft(); + else + dispose(true); el('status').textContent = 'Finished'; if (data.result.type === 'tree' || data.result.type === 'list') { el('result-inspection').hidden = false; @@ -780,10 +768,17 @@ function draftWorker() { } else el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; - el('compiler-stats').textContent = `${compilationStats} · ${totalWork.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`; + el('compiler-stats').textContent = `${compilationStats} · ${graphStats(data.stats) ? `${totalWork.toLocaleString()} cumulative work` : runtimeStats(data.stats)}`; break; case 'error': - finish(data.message, true); + if (persistentDefinitions() && operation === 'running' && !data.fatal) { + idleDraft(); + el('status').textContent = 'Stopped'; + el('result').textContent = operationFailure('running', data.message); + el('result').className = 'error'; + } + else + finish(data.message, true); break; } }; @@ -793,28 +788,59 @@ function draftWorker() { } }; return current; } +// Persistent backends retain definitions only in their live Worker. JS may +// restore its backend-private checkpoint; no checkpoint crosses backend types. +function idleDraft() { + stopCompilerActivity(); + pauseClock(); + operation = undefined; + pending = undefined; + inputForm.hidden = true; + nextButton.disabled = true; + controls(false); +} +function keepDraft() { + if (persistentDefinitions() && worker && compiled && !busy) + idleDraft(); + else + dispose(true); +} function runDraft() { - if (!compiledDraft) + if (!compiled) return; - dispose(true); - operation = 'running'; try { const args = argumentsUI.read(), output = outputType(resultCodec.value); - settings = readSettings('compiler'); + if (!persistentDefinitions()) + dispose(true); + else if (!worker || busy) + return; + operation = 'running'; clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; - const current = draftWorker(); - current.postMessage({ type: 'restore', draft: compiledDraft, ...settings }); + const current = persistentDefinitions() ? worker : draftWorker(); + if (!persistentDefinitions()) + current.postMessage({ type: 'restore', draft: compiledDraft }); current.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); } catch (error) { - el('result').textContent = operationFailure(operation, error instanceof Error ? error.message : String(error)); + el('result').textContent = operationFailure('running', error instanceof Error ? error.message : String(error)); el('result').className = 'error'; - dispose(true); + keepDraft(); } } +backendPicker.onchange = () => { + selectedBackend = backend(backendPicker.value); + dispose(); + clearOutput(); + if (draft) { + controls(false); + el('status').textContent = 'Backend changed · compile draft to run'; + } + else if (selection) + newWorker(false, true); +}; const examples = { identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, @@ -828,9 +854,8 @@ for (const button of document.querySelectorAll('[data-compiler-example]')) if (!example) return; chooseProgram(''); - editingSource = true; el('source-section').open = true; - dispose(true); + keepDraft(); clearOutput(); clearTreeExport(); scratch = true; @@ -962,22 +987,3 @@ window.addEventListener('pane-change', () => { newWorker(false); } }); -window.addEventListener('limits-change', event => { - const runner = event.detail; - if (runner === 'compiler') { - if (draft) { - dispose(true); - clearOutput(); - el('status').textContent = compiled ? 'Compiled' : 'Ready to compile'; - } - return; - } - if (runner !== 'package' || draft) - return; - preferredExport = exportsInput.value || preferredExport; - dispose(); - loaded = false; - clearOutput(); - if (selection && packagePane()) - newWorker(false, true); -}); diff --git a/ext/demo-site/dist/compiler-worker.js b/ext/demo-site/dist/compiler-worker.js @@ -1,5 +1,6 @@ +import { resumeValue } from './io.js'; import { environmentProfile } from './packages.js'; -import { executionSettings, executionBudget, collectionPolicy } from './execution.js'; +import { BROWSER_BUDGET, collectionPolicy } from './execution.js'; import { contractEnvironment } from './contracts.js'; import { Graph } from './graph.js'; import { COMPILER_BUNDLE_LIMIT, fetchBundle, fetchBytes, loadBundle } from './bundle.js'; @@ -20,11 +21,11 @@ function request(kind) { const id = ++requestId; return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); } -async function compile(source, settings, profileName) { +async function compile(source, profileName) { phase = 'compiling'; running(); const start = performance.now(); - const budget = executionBudget(settings); + const budget = BROWSER_BUDGET; graph = new Graph(budget, collectionPolicy(budget)); const input = sourceLiteral(graph, source); // Validate before downloading a compiler. const bytes = await fetchBytes(new URL('compiler.json', base), 4096); @@ -63,7 +64,6 @@ async function compile(source, settings, profileName) { async function run(message) { if (!graph || !exports) throw new Error('Compile a unit first'); - graph.beginOperation(); // Each application gets its own allowance, separate from compilation. phase = 'running'; running(); const fn = exports.get(message.export); @@ -92,12 +92,12 @@ scope.onmessage = ({ data }) => { if (message['type'] === 'resume') { if (!pending || message['id'] !== pending.id) return; + const value = resumeValue(pending.kind, message['value']); + if (value === undefined) + return; const waiter = pending; pending = undefined; - if (waiter.kind === 'input' && typeof message['value'] !== 'string') - waiter.reject(new Error('Invalid input response')); - else - waiter.resolve(typeof message['value'] === 'string' ? message['value'] : ''); + waiter.resolve(value); return; } let operation; @@ -105,7 +105,7 @@ scope.onmessage = ({ data }) => { if (message['type'] === 'restore' && phase === 'empty') { // Only the owning page supplies this trusted in-memory checkpoint. const restored = data; - const budget = executionBudget(executionSettings(restored.limits, 'compiler', restored.uncapped ?? false)); + const budget = BROWSER_BUDGET; graph = Graph.restore(restored.draft.graph, budget, collectionPolicy(budget)); exports = new Map(restored.draft.exports); phase = 'ready'; @@ -114,7 +114,7 @@ scope.onmessage = ({ data }) => { else if (message['type'] === 'compile' && phase === 'empty') { if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) throw new Error('Invalid demo environment'); - operation = compile(message['source'], executionSettings(message['limits'], 'compiler', message['uncapped'] === undefined ? false : message['uncapped']), message['environment']); + operation = compile(message['source'], message['environment']); } else if (message['type'] === 'run' && phase === 'ready') { if (typeof message['export'] !== 'string' || message['export'].length > 128) diff --git a/ext/demo-site/dist/compiler.js b/ext/demo-site/dist/compiler.js @@ -9,7 +9,6 @@ export function compilerDescriptor(raw) { throw new Error('Invalid stable compiler descriptor'); return { hash: bundleHash(descriptor['hash']) }; } -export const COMPILER_BUDGET = Object.freeze({ nodes: 6_000_000, fuel: Infinity, stack: 100_000 }); export const COMPILER_COLLECTION = Object.freeze({ threshold: 250_000 }); export const SOURCE_LIMIT = 16_384; export function sourceText(source) { diff --git a/ext/demo-site/dist/execution.js b/ext/demo-site/dist/execution.js @@ -1,36 +1,11 @@ -import { DEFAULT_BUDGET } from './graph.js'; -import { COMPILER_BUDGET } from './compiler.js'; -export const PACKAGE_ACTIVE_MS = 60_000; +// Execution has no application node/work/frame ceilings. Browser memory remains +// finite; Stop terminates the owning Worker. GC thresholds are not budgets. +export const BROWSER_BUDGET = Object.freeze({ nodes: Infinity, fuel: Infinity, stack: Infinity }); +export const PACKAGE_LOAD_MS = 60_000; +// Export is bounded transport/rendering, not program execution. export const EXPORT_ACTIVE_MS = 120_000; export const EXECUTION_COLLECTION = Object.freeze({ threshold: 250_000 }); export const PACKAGE_COLLECTION = Object.freeze({ threshold: 100_000 }); -export const PACKAGE_BUDGET = Object.freeze({ ...DEFAULT_BUDGET, fuel: 20_000_000 }); -export function defaultLimits(runner) { - return { ...(runner === 'package' ? PACKAGE_BUDGET : COMPILER_BUDGET), fuel: runner === 'package' ? PACKAGE_BUDGET.fuel : 0, seconds: runner === 'package' ? 10 : 0 }; -} -export function executionSettings(raw, runner, uncapped = false) { - if (typeof uncapped !== 'boolean') - throw new Error('Invalid disable-limits selection'); - const values = raw === undefined ? defaultLimits(runner) : raw; - if (!values || typeof values !== 'object' || Array.isArray(values)) - throw new Error('Invalid execution limits'); - const input = values; - for (const key of ['nodes', 'fuel', 'stack', 'seconds']) { - const value = input[key]; - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < (key === 'seconds' || key === 'fuel' ? 0 : 1) - || (key === 'seconds' && !Number.isSafeInteger(value * 1000))) - throw new Error(`${key}: enter a ${key === 'seconds' || key === 'fuel' ? 'nonnegative' : 'positive'} whole number`); - } - return { limits: { nodes: input['nodes'], fuel: input['fuel'], - stack: input['stack'], seconds: input['seconds'] }, uncapped }; -} -export function executionBudget(settings) { - const { nodes, fuel, stack } = settings.limits; - return settings.uncapped ? { nodes: Infinity, fuel: Infinity, stack: Infinity } : { nodes, fuel: fuel === 0 ? Infinity : fuel, stack }; -} -export function activeMilliseconds(settings) { - return settings.uncapped || settings.limits.seconds === 0 ? Infinity : settings.limits.seconds * 1000; -} export function collectionPolicy(budget, threshold = EXECUTION_COLLECTION.threshold) { return budget.nodes < 2 ? undefined : { threshold: Math.min(threshold, budget.nodes) }; } diff --git a/ext/demo-site/dist/haskell-session.js b/ext/demo-site/dist/haskell-session.js @@ -0,0 +1,91 @@ +// Backend-private protocol mapping. No WASM pointer, Haskell heap checkpoint or +// fabricated graph statistic crosses into the shared site protocol. +import { compilerDescriptor } from './compiler.js'; +import { fetchBytes } from './bundle.js'; +import { environmentProfile } from './packages.js'; +export class HaskellSession { + client; + linearMemoryBytes = 0; + constructor(client) { + this.client = client; + } + static async create(compiler) { + const module = await import(new URL('../runtime/haskell/client.mjs', import.meta.url).href); + const session = new HaskellSession(new module.CompilerWorker()); + try { + const ready = await session.request({ type: 'init', compiler }); + if (compiler) { + const bytes = await fetchBytes(new URL('../compiler.json', import.meta.url), 4096); + const expected = compilerDescriptor(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))).hash; + if (ready.compilerHash !== expected) + throw new Error('Runtime compiler pin mismatch'); + } + session.compilerHash = ready.compilerHash ?? null; + return session; + } + catch (error) { + session.dispose(); + throw error; + } + } + compilerHash = null; + get disposed() { return this.client.disposed; } + async request(message) { + const reply = await this.client.request(message, null); + this.linearMemoryBytes = reply.linearBytes; + return reply; + } + async json(message) { return JSON.parse((await this.request(message)).text); } + async load(bytes, hash) { await this.request({ type: 'load', bytes: new Uint8Array(bytes).buffer, hash }); } + async compile(source, profileName) { + let environment; + if (profileName !== undefined) { + const bytes = await fetchBytes(new URL('../environments.json', import.meta.url), 32768); + const selected = environmentProfile(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)), profileName); + if (selected.compiler !== this.compilerHash) + throw new Error('Demo environment/compiler pin mismatch'); + environment = selected.profile ? { + bytes: new Uint8Array(await fetchBytes(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`, import.meta.url))).buffer, + hash: selected.profile.hash, names: selected.profile.names, + } : null; + } + const reply = await this.request({ type: 'compile', source, ...(environment === undefined ? {} : { environment }) }); + return reply.text.trimEnd().split('\n').filter(Boolean); + } + async run(name, args, output, mode, host) { + if (mode === 'pure') + return this.json({ type: 'evaluate', name, arguments: args, output }); + try { + let event = await this.json({ type: 'io-start', name, arguments: args }); + while (event.kind !== 'done') { + let value = ''; + switch (event.kind) { + case 'before': + await host.before({ step: event.step, name: event.name, detail: event.detail, depth: event.depth }); + break; + case 'read': + value = await host.read(); + break; + case 'write': + host.write(event.text); + break; + case 'after': + host.after({ step: event.step, name: event.name, value: event.value }); + break; + default: throw new Error('Unknown runtime IO event'); + } + host.running(); + event = await this.json({ type: 'io-resume', token: event.token, value }); + } + return await this.json({ type: 'io-view', output }); + } + finally { + // Release result/continuations even when decoding or a host hook fails. + // A poisoned instance cannot be recovered by cancelling the interaction. + if (!this.disposed) + await this.request({ type: 'io-cancel' }); + } + } + stats() { return { backend: 'haskell-wasm', linearMemoryBytes: this.linearMemoryBytes }; } + dispose() { this.client.dispose(); } +} diff --git a/ext/demo-site/dist/io.js b/ext/demo-site/dist/io.js @@ -52,3 +52,12 @@ export async function runIO(graph, handle, host, collectActions = false, retaine } throw new Error('IO action limit exceeded'); } +// Ignore malformed/wrong-kind/stale resumes without consuming the waiting token. +// The page validates interactively; the Worker must also retain a retryable wait. +export function resumeValue(kind, value) { + if (kind === 'step') + return value === undefined ? '' : undefined; + if (typeof value !== 'string' || value.length > 512 || /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) + return undefined; + return value; +} diff --git a/ext/demo-site/dist/limits-ui.js b/ext/demo-site/dist/limits-ui.js @@ -1,95 +0,0 @@ -import { defaultLimits, executionSettings } from './execution.js'; -const committed = new Map(); -export function readSettings(runner) { - const settings = committed.get(runner); - if (!settings) - throw new Error('Execution settings are not ready'); - return { limits: { ...settings.limits }, uncapped: settings.uncapped }; -} -for (const runner of ['package', 'compiler']) { - const checkbox = document.getElementById(`${runner === 'package' ? '' : 'compiler-'}uncapped`); - const summary = document.getElementById(`${runner}-limits-summary`); - const container = document.getElementById(`${runner}-limits-defaults`); - const defaults = defaultLimits(runner), fields = new Map(); - const form = document.createElement('form'); - form.noValidate = true; - form.className = 'limits-editor'; - for (const [key, label] of [['nodes', 'Memory budget (live nodes)'], ['fuel', 'Work units (0 = no work limit)'], ['stack', 'Evaluation depth (frames)'], ['seconds', 'Active seconds (0 = unlimited)']]) { - const wrapper = document.createElement('div'), caption = document.createElement('label'), input = document.createElement('input'); - const hint = document.createElement('span'); - hint.className = 'hint'; - input.id = `${runner}-limit-${key}`; - input.type = 'number'; - input.min = key === 'seconds' || key === 'fuel' ? '0' : '1'; - input.step = '1'; - input.inputMode = 'numeric'; - input.required = true; - input.value = String(defaults[key]); - caption.htmlFor = input.id; - caption.textContent = label; - hint.textContent = `Default: ${defaults[key].toLocaleString()}`; - wrapper.append(caption, input, hint); - form.append(wrapper); - fields.set(key, input); - } - const actions = document.createElement('div'); - actions.className = 'controls'; - const apply = document.createElement('button'), restore = document.createElement('button'); - apply.type = 'submit'; - apply.textContent = 'Apply limits'; - restore.type = 'button'; - restore.textContent = 'Restore defaults'; - const error = document.createElement('p'); - error.className = 'limits-error'; - error.setAttribute('role', 'status'); - actions.append(apply, restore); - form.append(actions, error); - container.replaceWith(form); - committed.set(runner, executionSettings(defaults, runner, true)); - function update() { - const settings = readSettings(runner); - const customized = Object.entries(defaults).some(([key, value]) => settings.limits[key] !== value); - summary.textContent = `Optional limits${settings.uncapped ? ' (off)' : customized ? ' (custom)' : ' (on)'}`; - for (const input of fields.values()) - input.disabled = settings.uncapped; - apply.disabled = settings.uncapped; - document.getElementById(`${runner}-resource-mode`).textContent = settings.uncapped ? 'Off' : 'On'; - } - function publish(settings) { - committed.set(runner, settings); - for (const [key, input] of fields) - input.value = String(settings.limits[key]); - error.textContent = ''; - update(); - window.dispatchEvent(new CustomEvent('limits-change', { detail: runner })); - } - form.oninput = () => { error.textContent = ''; summary.textContent = 'Advanced: unapplied resource settings'; }; - form.onsubmit = event => { - event.preventDefault(); - try { - const values = {}; - for (const [key, field] of fields) { - if (!field.value.trim()) - throw new Error(`${key}: enter a whole number`); - values[key] = Number(field.value); - } - publish(executionSettings(values, runner, checkbox.checked)); - } - catch (cause) { - error.textContent = cause instanceof Error ? cause.message : String(cause); - } - }; - checkbox.onchange = () => { - const settings = readSettings(runner); - settings.uncapped = checkbox.checked; - // Bypass uses the last applied finite values, not a partial/invalid draft. - publish(settings); - }; - restore.onclick = () => { - for (const [key, input] of fields) - input.value = String(defaults[key]); - checkbox.checked = true; - publish(executionSettings(defaults, runner, true)); - }; - update(); -} diff --git a/ext/demo-site/dist/published-bundle.js b/ext/demo-site/dist/published-bundle.js @@ -0,0 +1,21 @@ +import { decodeBundle, fetchBytes, sha256 } from './bundle.js'; +// Common authenticated package/source boundary, independent of the evaluator. +export async function publishedBundle(hash, localId) { + const base = new URL('../', import.meta.url); + let source = null; + if (localId !== undefined) { + const catalogue = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await fetchBytes(new URL('catalogue.json', base), 64000))); + if (!Array.isArray(catalogue)) + throw new Error('Invalid catalogue'); + const entry = catalogue.find((item) => !!item && typeof item === 'object' && item['id'] === localId); + if (!entry || entry['hash'] !== hash) + throw new Error('Invalid catalogue pin'); + if (typeof entry['source'] !== 'string' || entry['source'].length > 32000 || await sha256(new TextEncoder().encode(entry['source'])) !== entry['sourceSha256']) + throw new Error('Catalogue source integrity failure'); + source = entry['source']; + } + const bytes = await fetchBytes(new URL(`/_arboricx/bundle/hash/${hash}`, base)); + if (await sha256(bytes) !== hash) + throw new Error('Bundle SHA-256 mismatch'); + return { hash, bytes, bundle: decodeBundle(bytes), source }; +} diff --git a/ext/demo-site/dist/runtime-backends.js b/ext/demo-site/dist/runtime-backends.js @@ -0,0 +1,15 @@ +export const backends = { + js: { label: 'JavaScript', persistentDefinitions: false, worker: './worker.js', compiler: './compiler-worker.js' }, + 'zig-wasm': { label: 'Zig', persistentDefinitions: true, worker: './wasm-worker.js?backend=zig-wasm', compiler: './wasm-worker.js?backend=zig-wasm' }, + 'haskell-wasm': { label: 'Haskell', persistentDefinitions: true, worker: './wasm-worker.js?backend=haskell-wasm', compiler: './wasm-worker.js?backend=haskell-wasm' }, +}; +export function backend(value) { + if (!Object.hasOwn(backends, value)) + throw new Error('Unknown runtime backend'); + return value; +} +export function graphStats(stats) { return !('backend' in stats); } +export function runtimeStats(stats) { + return graphStats(stats) ? `${stats.nodes.toLocaleString()} heap nodes · ${stats.work.toLocaleString()} work units` + : `${(stats.linearMemoryBytes / 1048576).toFixed(1)} MiB WASM linear memory`; +} diff --git a/ext/demo-site/dist/runtime-session.js b/ext/demo-site/dist/runtime-session.js @@ -0,0 +1,10 @@ +import { HaskellSession } from './haskell-session.js'; +export async function createRuntimeSession(name, compiler) { + if (name === 'zig-wasm') { + const module = await import(new URL('../runtime/zig/site-session.mjs', import.meta.url).href); + return module.create({ base: new URL('../', import.meta.url), compiler }); + } + if (name !== 'haskell-wasm') + throw new Error('Unsupported runtime backend'); + return HaskellSession.create(compiler); +} diff --git a/ext/demo-site/dist/wasm-worker.js b/ext/demo-site/dist/wasm-worker.js @@ -0,0 +1,98 @@ +import { resumeValue } from './io.js'; +import { createRuntimeSession } from './runtime-session.js'; +const selectedBackend = new URL(globalThis.location.href).searchParams.get('backend'); +import { publishedBundle } from './published-bundle.js'; +import { bundleHash } from './bundle.js'; +import { literals, outputType } from './codecs.js'; +import { sourceText } from './compiler.js'; +import { exportTree, treeFormat } from './tree-export.js'; +const scope = globalThis; +const send = (m) => scope.postMessage(m); +const running = () => send({ type: 'state', state: 'running' }); +let session, stored; +let busy = false, requestId = 0; +let pending; +function request(kind) { + if (pending) + throw new Error('Driver already waiting'); + const id = ++requestId; + return new Promise((resolve, reject) => { pending = { id, resolve, reject, kind }; send({ type: 'state', state: kind, id }); }); +} +scope.onmessage = ({ data }) => { + if (!data || typeof data !== 'object' || Array.isArray(data)) + return; + const m = data; + if (m['type'] === 'resume') { + if (!pending || m['id'] !== pending.id) + return; + const value = resumeValue(pending.kind, m['value']); + if (value === undefined) + return; + const waiter = pending; + pending = undefined; + waiter.resolve(value); + return; + } + if (busy) + return; + busy = true; + void (async () => { + if (m['type'] === 'load') { + if (session || stored) + throw new Error('Already initialized'); + const localId = m['localId']; + if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) + throw new Error('Invalid catalogue ID'); + running(); + stored = await publishedBundle(bundleHash(m['hash']), localId); + session = await createRuntimeSession(selectedBackend, false); + await session.load(stored.bytes, stored.hash); + send({ type: 'loaded', hash: stored.hash, exports: stored.bundle.exports, nodes: stored.bundle.nodes.length, metadata: stored.bundle.metadata, source: stored.source }); + } + else if (m['type'] === 'compile') { + if (session) + throw new Error('Already initialized'); + const source = sourceText(m['source']), profile = m['environment']; + if (profile !== undefined && (typeof profile !== 'string' || profile.length > 128)) + throw new Error('Invalid demo environment'); + running(); + const start = performance.now(); + session = await createRuntimeSession(selectedBackend, true); + const names = await session.compile(source, profile); + send({ type: 'compiled', names, stats: session.stats(), milliseconds: performance.now() - start }); + } + else if (m['type'] === 'run') { + if (!session) + throw new Error('Load or compile first'); + if (typeof m['export'] !== 'string' || m['export'].length > 512) + throw new Error('Invalid export'); + if (m['mode'] !== 'pure' && m['mode'] !== 'io') + throw new Error('Unsupported execution mode'); + const args = literals(m['arguments']), output = outputType(m['output']); + running(); + const result = await session.run(m['export'], args, output, m['mode'], { + async before(event) { send({ type: 'trace', phase: 'before', event }); if (m['step'] === true) + await request('step'); running(); }, + after(event) { send({ type: 'trace', phase: 'after', event }); }, write(text) { send({ type: 'output', text }); }, + read() { return request('input'); }, running, + }); + send({ type: 'done', result, stats: session.stats() }); + } + else if (m['type'] === 'export' && stored) { + try { + if (typeof m['export'] !== 'string') + throw new Error('Invalid export'); + const format = treeFormat(m['format']); + send({ type: 'exported', hash: stored.hash, export: m['export'], format, file: exportTree(stored.bundle, m['export'], format) }); + } + catch (e) { + send({ type: 'export-error', message: String(e) }); + } + } + else if (m['type'] === 'download' && stored) + send({ type: 'downloaded', hash: stored.hash, bytes: stored.bytes }); + else + throw new Error('Unsupported backend operation'); + })().catch((e) => send({ type: 'error', message: e instanceof Error ? e.message : String(e), fatal: session?.disposed ?? true })) + .finally(() => { busy = false; }); +}; diff --git a/ext/demo-site/dist/worker.js b/ext/demo-site/dist/worker.js @@ -1,18 +1,18 @@ +import { resumeValue } from './io.js'; +import { publishedBundle } from './published-bundle.js'; import { exportTree, treeFormat } from './tree-export.js'; -import { executionSettings, executionBudget, collectionPolicy, PACKAGE_COLLECTION } from './execution.js'; +import { BROWSER_BUDGET, collectionPolicy, PACKAGE_COLLECTION } from './execution.js'; import { Graph } from './graph.js'; -import { bundleHash, decodeBundle, fetchBytes, loadBundle, sha256 } from './bundle.js'; +import { bundleHash, loadBundle } from './bundle.js'; import { literals, makeLiteral, outputType, view } from './codecs.js'; import { runIO } from './io.js'; const scope = globalThis; -const base = new URL('../', import.meta.url); const send = (message) => scope.postMessage(message); const running = () => send({ type: 'state', state: 'running' }); let phase = 'empty'; let graph, exports; let stored; let requestId = 0; -let settings = executionSettings(undefined, 'package'); let pending; function request(kind) { if (pending) @@ -23,30 +23,10 @@ function request(kind) { async function load(hash, localId) { phase = 'loading'; running(); - const url = new URL(`/_arboricx/bundle/hash/${hash}`, base); - let source = null; - if (localId !== undefined) { - const bytes = await fetchBytes(new URL('catalogue.json', base), 64_000); - const catalogue = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); - if (!Array.isArray(catalogue)) - throw new Error('Invalid catalogue'); - const raw = catalogue.find((item) => !!item && typeof item === 'object' && item['id'] === localId); - if (!raw || typeof raw !== 'object') - throw new Error('Unknown curated package'); - const entry = raw; - if (entry['hash'] !== hash) - throw new Error('Invalid catalogue pin'); - if (typeof entry['source'] !== 'string' || entry['source'].length > 32_000 || - await sha256(new TextEncoder().encode(entry['source'])) !== entry['sourceSha256']) - throw new Error('Catalogue source integrity failure'); - source = entry['source']; - } - const bytes = await fetchBytes(url); - if (await sha256(bytes) !== hash) - throw new Error('Bundle SHA-256 mismatch'); - const bundle = decodeBundle(bytes); - stored = { hash, bundle, bytes }; - const budget = executionBudget(settings); + const loaded = await publishedBundle(hash, localId); + const { bundle, source } = loaded; + stored = loaded; + const budget = BROWSER_BUDGET; graph = new Graph(budget, collectionPolicy(budget, PACKAGE_COLLECTION.threshold)); exports = loadBundle(graph, bundle); phase = 'ready'; @@ -83,12 +63,12 @@ scope.onmessage = ({ data }) => { if (message['type'] === 'resume') { if (!pending || message['id'] !== pending.id) return; + const value = resumeValue(pending.kind, message['value']); + if (value === undefined) + return; const waiter = pending; pending = undefined; - if (waiter.kind === 'input' && typeof message['value'] !== 'string') - waiter.reject(new Error('Invalid input response')); - else - waiter.resolve(typeof message['value'] === 'string' ? message['value'] : ''); + waiter.resolve(value); return; } if (message['type'] === 'download' && phase === 'ready' && stored) { @@ -111,7 +91,6 @@ scope.onmessage = ({ data }) => { let operation; try { if (message['type'] === 'load' && phase === 'empty') { - settings = executionSettings(message['limits'], 'package', message['uncapped'] === undefined ? false : message['uncapped']); const localId = message['localId']; if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) throw new Error('Invalid catalogue ID'); diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -33,6 +33,7 @@ <div data-package-slot><div id="package-selection"> + <div class="program-choice"><label for="runtime-backend">Runtime</label><select id="runtime-backend"><option value="haskell-wasm" selected>Haskell</option><option value="zig-wasm">Zig</option><option value="js">JavaScript</option></select></div> <div class="program-choice"><label for="program-picker">Program</label><select id="program-picker" disabled><option value="">Loading programs…</option></select></div> <div id="package-toolbar" class="package-toolbar" hidden> <div id="program-release" class="release-choice" hidden><label for="package-version">Package version</label><div class="input-row"><select id="package-version" aria-label="Exact package version"></select><button id="load-release" type="button" class="primary">Load package</button></div></div> @@ -45,12 +46,11 @@ </div></div> <div class="program-workspace"> <details id="source-section" class="source-disclosure" open><summary id="source-summary">Package details</summary> - <section class="source-panel compiler-source" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Package information</h3><span id="source-label">tricu programs</span><button id="edit-source" type="button" disabled>Edit source</button></div> + <section class="source-panel compiler-source" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Package information</h3><span id="source-label">tricu programs</span></div> <p id="source-availability" class="hint" hidden></p> - <p id="compiler-disclosure" class="hint" hidden>Compilation uses a JavaScript Tree Calculus reducer and can take time or extra memory. Use the <a href="https://git.eversole.co/James/tricu/file/docs/build-and-run.md.html">native Haskell tools</a> for larger builds. Set optional limits under Advanced; Stop cancels compilation and keeps your source.</p> <p id="authorship" role="status">Published package</p> <label class="sr-only" for="source-editor">Editable demo source (up to 16,384 characters)</label> - <textarea id="source-editor" rows="16" maxlength="32768" spellcheck="false" readonly hidden></textarea> + <textarea id="source-editor" rows="16" maxlength="32768" spellcheck="false" hidden></textarea> <pre id="source-notes"><code id="source">Loading…</code></pre> <div class="controls"><button id="compile" type="button" class="primary" disabled hidden>Compile draft</button><button id="stop-compile" type="button" disabled hidden>Stop</button><a id="try-program-link" href="#program-actions">Try this program ↓</a><button id="restore-source" type="button" disabled>Restore published source</button></div> <p id="compile-activity" class="hint" role="status" hidden></p> @@ -79,16 +79,7 @@ </details> <div class="result-codec"><div><label for="output-type">Result interpretation</label><select id="output-type"><option value="tree">Tree graph</option><option value="string">String</option><option value="number">Natural number</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="list">List of trees</option><option value="record">Scalar record</option></select></div></div> <div><label for="mode">Execution mode</label><select id="mode"><option value="pure">Pure value</option><option value="io">Text / line IO</option></select></div><div class="controls"><button type="button" id="reset">Reset</button><button type="button" id="share" disabled>Copy published selection link</button></div> <div class="step-controls"><label><input type="checkbox" id="step-mode"> Pause before each IO action</label><button type="button" id="next" disabled>Next action →</button></div> - <details id="package-settings" class="execution-limits"><summary id="package-limits-summary">Optional resource limits</summary><div class="limits-content"><div id="package-resource-choice" class="resource-choice"> - <p>Optional limits: <strong id="package-resource-mode">Off</strong></p> - <label><input id="uncapped" type="checkbox" checked aria-describedby="package-resource-warning"> Run without optional limits</label> - <p id="package-resource-warning" class="hint">Uncheck to set limits below. Limits can stop a job before it uses more resources; Stop remains available either way.</p> - </div><h3>Session settings</h3><div id="package-limits-defaults"></div><p class="hint">Memory is measured in live tree nodes, not megabytes. Garbage collection stays active with or without these limits. Bundle validation and IO format checks always apply.</p><p class="hint">Package input: 16 MiB bundle and 100,000 stored nodes; loading allows 60 seconds, including transfer and processing. IO: up to 128 actions, 256 characters per line and 16,384 output characters. Tree size counts every occurrence, including shared branches.</p></div></details> - <details id="compiler-settings" class="execution-limits" hidden><summary id="compiler-limits-summary">Optional resource limits</summary><div class="limits-content"><div id="compiler-resource-choice" class="resource-choice" hidden> - <p>Optional limits: <strong id="compiler-resource-mode">Off</strong></p> - <label><input id="compiler-uncapped" type="checkbox" checked aria-describedby="compiler-resource-warning"> Run without optional limits</label> - <p id="compiler-resource-warning" class="hint">Uncheck to set limits below. Limits can stop a job before it uses more resources; Stop remains available either way.</p> - </div><h3>Session settings</h3><div id="compiler-limits-defaults"></div><p class="hint">Memory is measured in live tree nodes, not megabytes. Garbage collection stays active with or without these limits. Bundle validation and IO format checks always apply.</p><p class="hint">Source: 16,384 characters and up to 16 definitions. IO uses the same text limits as the playground. Compile once, then run definitions with new arguments. Compilation and each run have separate work and active-time allowances. Enabled limits apply to live nodes and evaluation frames; statistics remain cumulative.</p></div></details> + <p class="hint">Stop cancels execution. Bundle validation, bounded previews and text output checks remain active. Browser memory is finite; large programs can fail even without an application execution limit.</p> </details> <details class="trace-panel"><summary>Interaction trace</summary><section aria-labelledby="trace-title"><div class="panel-heading"><h3 id="trace-title">Interaction tree / execution trace</h3><span>action → response → next action</span></div><p id="trace-empty" class="hint">Choose Text / line IO for an interactive program. Each action, response and continuation appears here.</p><ol id="trace"></ol></section></details> <p class="hint"><a href="#export-definition" data-open-pane="export-definition">Export this definition →</a></p> diff --git a/ext/demo-site/release.nix b/ext/demo-site/release.nix @@ -1,4 +1,4 @@ -{ pkgs }: +{ pkgs, haskellRuntime, zigRuntime }: let siteSource = pkgs.lib.cleanSourceWith { src = ./.; @@ -34,6 +34,13 @@ let mkdir -p $out cp index.html style.css catalogue.json compiler.json packages.json environments.json $out/ cp -r "$TMPDIR/dist" $out/dist + mkdir -p $out/runtime/zig + cp ${zigRuntime}/lib/arboricx.wasm $out/runtime/zig/ + cp ${../wasm/runtime.mjs} $out/runtime/zig/runtime.mjs + cp ${../wasm/site-session.mjs} $out/runtime/zig/site-session.mjs + mkdir -p $out/runtime/haskell + cp ${haskellRuntime}/{client.mjs,worker.mjs,bridge.mjs,pins.mjs,reactor.wasm} $out/runtime/haskell/ + cp -r ${haskellRuntime}/{shim,data,site} $out/runtime/haskell/ # Keep the dependency pack in this output's Nix closure, not its webroot. mkdir -p $out/nix-support echo ${dependencies} > $out/nix-support/site-dependencies diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -1,10 +1,10 @@ +import { backend, backends, graphStats, runtimeStats, type Backend } from './runtime-backends.js'; import { demoInputLabels } from './demo-inputs.js'; import { argumentEditor } from './arguments-ui.js'; import { createTreeExport } from './export-app.js'; import { activePane } from './tabs.js'; -import { readSettings } from './limits-ui.js'; import { ActiveClock } from './clock.js'; -import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds, operationFailure, operationTimeout, type Operation } from './execution.js'; +import { PACKAGE_LOAD_MS, EXPORT_ACTIVE_MS, operationFailure, operationTimeout, type Operation } from './execution.js'; import { sourceText } from './compiler.js'; import { packageIndex, packageReleases, type Release } from './packages.js'; import type { CompiledDraft, CompilerMessage } from './compiler-protocol.js'; @@ -17,8 +17,11 @@ function el<T extends HTMLElement = HTMLElement>(id: string): T { } const sourceEditor = el<HTMLTextAreaElement>('source-editor'), compileButton = el<HTMLButtonElement>('compile'); const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = el<HTMLButtonElement>('download-bundle'); -let editingSource = false; let sourceAvailable = false; +const backendPicker = el<HTMLSelectElement>('runtime-backend'); +let selectedBackend: Backend = backend(backendPicker.value); +const persistentDefinitions = () => backends[selectedBackend].persistentDefinitions; +const workerURL = (compiler = false) => new URL(compiler ? backends[selectedBackend].compiler : backends[selectedBackend].worker, import.meta.url); let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined; let compiledDraft: CompiledDraft | undefined, compiledSource: string | undefined; let totalWork = 0; @@ -34,7 +37,6 @@ const argsInput = el<HTMLTextAreaElement>('arguments'), resultCodec = el<HTMLSel const argumentsUI = argumentEditor(argsInput); const mode = el<HTMLSelectElement>('mode'), steps = el<HTMLInputElement>('step-mode'); const runButton = el<HTMLButtonElement>('run'), stopButton = el<HTMLButtonElement>('stop'), nextButton = el<HTMLButtonElement>('next'); -let settings = readSettings('package'); let operation: Operation | undefined; const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout')); const shareButton = el<HTMLButtonElement>('share'); @@ -56,14 +58,15 @@ let preferredExport: string | undefined; const traceRows = new Map<number, HTMLLIElement>(); function pauseClock(): void { deadline.pause(); } function clock(): void { deadline.start(); } -function runClock(): void { operation = 'running'; deadline.reset(activeMilliseconds(settings)); clock(); } +function runClock(): void { operation = 'running'; deadline.reset(Infinity); clock(); } function controls(working: boolean): void { busy = working; + backendPicker.disabled = busy; programPicker.disabled = busy || entries.length === 0; versionPicker.disabled = busy; hashInput.disabled = busy; el<HTMLButtonElement>('load-release').disabled = busy || !programPackage(programPicker.value); el<HTMLButtonElement>('load').disabled = busy; - runButton.disabled = draft ? !compiled : busy || !loaded; + runButton.disabled = draft ? !compiled || (busy && persistentDefinitions()) : busy || !loaded; runButton.textContent = draft ? 'Run draft' : 'Run published'; el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource); el('draft-run-note').textContent = compiled @@ -75,9 +78,7 @@ function controls(working: boolean): void { runButton.classList.toggle('primary', !draft || compiled); compileButton.classList.toggle('primary', !compiled); compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource); compileButton.hidden = !draft; restoreButton.hidden = !draft; - sourceEditor.disabled = busy; sourceEditor.readOnly = !editingSource && !draft; - el<HTMLButtonElement>('edit-source').disabled = busy; - el('edit-source').hidden = originalSource === undefined || editingSource || draft; + sourceEditor.disabled = busy; sourceEditor.readOnly = originalSource === undefined && !draft; const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); el('source-summary').textContent = editableSource ? 'Source and editing' : sourceAvailable ? 'Source (read-only)' : 'Package details'; el('source-title').textContent = editableSource ? 'Read and edit source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; @@ -88,14 +89,10 @@ function controls(working: boolean): void { restoreButton.disabled = busy || originalSource === undefined || !draft; downloadButton.disabled = busy || draft || downloadHash() === undefined; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; - el('authorship').classList.toggle('draft', draft); - sourceEditor.classList.toggle('draft', draft); - el('compiler-settings').hidden = !draft; - el('compiler-resource-choice').hidden = !draft; - el('package-resource-choice').hidden = draft; - el('compiler-disclosure').hidden = !draft; + const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource); + el('authorship').classList.toggle('modified', sourceModified); + sourceEditor.classList.toggle('modified', sourceModified); el('export-draft-note').hidden = !draft; - el('package-settings').hidden = draft; for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy; stopButton.disabled = !busy && !(draft && (worker || compiled)); el<HTMLButtonElement>('stop-compile').disabled = stopButton.disabled; @@ -118,8 +115,6 @@ function clearOutput(): void { el('trace').replaceChildren(); traceRows.clear(); el('trace-empty').hidden = false; lineInput.value = ''; } function finish(message: string, error = false, reason: 'failure' | 'timeout' | 'cancelled' = 'failure'): void { - if (error && /Graph allocation limit|evaluation stack limit|Graph stack limit/u.test(message)) message = `Optional memory or evaluation-depth limit reached. Adjust limits under Advanced to continue. Your source is unchanged. ${message}`; - else if (error && /Graph fuel limit/u.test(message)) message = `Optional work limit reached. Adjust limits under Advanced to continue. Your source is unchanged. ${message}`; if (error && reason === 'failure') message = operationFailure(operation, message); if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`; if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) treeExport.fail(message); @@ -139,8 +134,8 @@ function startExport(message: Extract<ClientMessage, { type: 'export' }>, send: } function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoad?: Extract<ClientMessage, { type: 'export' }>): void { if (!selection) return; - dispose(); operation = 'loading'; settings = readSettings('package'); deadline.reset(PACKAGE_ACTIVE_MS); automaticRun = runAfterLoad; - const current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); worker = current; + dispose(); operation = 'loading'; deadline.reset(PACKAGE_LOAD_MS); automaticRun = runAfterLoad; + const current = new Worker(workerURL(), { type: 'module' }); worker = current; let currentVerified = false; controls(true); el('status').textContent = 'Loading package'; el('package-info').textContent = 'Verifying package…'; clock(); const send = (message: ClientMessage): void => current.postMessage(message); @@ -203,7 +198,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa const { result, stats } = data; if (result.type === 'tree' || result.type === 'list') { el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(result.value, null, 2); - finish(`Returned ${result.type}: tree graph · ${stats.nodes.toLocaleString()} heap nodes · ${stats.work.toLocaleString()} work units`); + finish(`Returned ${result.type}: tree graph · ${runtimeStats(stats)}`); } else finish(`Returned ${result.type}: ${JSON.stringify(result.value)}`); break; } @@ -211,7 +206,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa } }; current.onerror = event => { if (worker === current) { event.preventDefault(); if (!currentVerified) loaded = false; finish(event.message || 'Worker failed.', true); } }; - send({ type: 'load', ...selection, ...settings }); + send({ type: 'load', ...selection }); } function selectExample(entry: CatalogueEntry): void { const name = examplePackage(entry); @@ -220,7 +215,7 @@ function selectExample(entry: CatalogueEntry): void { else loadBundle(latest); } function select(entry: CatalogueEntry, release?: Release): void { - editingSource = false; sourceAvailable = false; + sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; el<HTMLDetailsElement>('source-section').open = true; @@ -235,7 +230,7 @@ function select(entry: CatalogueEntry, release?: Release): void { } function invalidateProgram(): void { el('bundle-download-status').textContent = ''; - editingSource = false; sourceAvailable = false; + sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined; exportsInput.replaceChildren(); treeExport.selection(exportsInput); @@ -282,10 +277,10 @@ inputForm.onsubmit = event => { catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; } }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => finish(draft && compiled ? 'Stopped. Last compiled draft retained.' : 'Stopped. Session discarded.', true, 'cancelled'); +stopButton.onclick = () => finish(draft && compiledDraft ? 'Stopped. Last compiled draft retained.' : draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); el<HTMLButtonElement>('stop-compile').onclick = stopButton.onclick; el('try-program-link').onclick = () => { el<HTMLDetailsElement>('try-program').open = true; }; -el<HTMLButtonElement>('reset').onclick = () => { dispose(true); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; +el<HTMLButtonElement>('reset').onclick = () => { keepDraft(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; shareButton.onclick = async () => { if (draft || !selection) return; const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: exportsInput.value }); @@ -370,10 +365,10 @@ downloadButton.onclick = () => { // Download has its own verified target, not the loaded executable selection. // It never changes source, definitions, arguments or the Run/Export target. dispose(); operation = 'downloading'; - const current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); worker = current; + const current = new Worker(workerURL(), { type: 'module' }); worker = current; controls(true); el('status').textContent = 'Downloading bundle'; el('bundle-download-status').textContent = 'Verifying published bundle…'; el('bundle-download-status').classList.remove('error'); - deadline.reset(PACKAGE_ACTIVE_MS); clock(); + deadline.reset(PACKAGE_LOAD_MS); clock(); current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => { if (worker !== current) return; if (data.type === 'loaded') { @@ -390,16 +385,13 @@ downloadButton.onclick = () => { } else if (data.type === 'error') finish(data.message, true); }; current.onerror = event => { if (worker === current) finish(event.message || 'Worker error', true); }; - current.postMessage({ type: 'load', hash, uncapped: true }); -}; -el<HTMLButtonElement>('edit-source').onclick = () => { - editingSource = true; el<HTMLDetailsElement>('source-section').open = true; controls(false); sourceEditor.focus(); + current.postMessage({ type: 'load', hash }); }; mode.onchange = () => { el('terminal').hidden = mode.value !== 'io' && !el('terminal').textContent; }; sourceEditor.oninput = () => { - editingSource = true; el<HTMLDetailsElement>('source-section').open = true; + el<HTMLDetailsElement>('source-section').open = true; preferredName = exportsInput.value || preferredName; - dispose(true); clearOutput(); clearTreeExport(); + keepDraft(); clearOutput(); clearTreeExport(); if (!compiled) exportsInput.replaceChildren(); draft = compiled || scratch || sourceEditor.value !== originalSource; controls(false); @@ -408,7 +400,7 @@ sourceEditor.oninput = () => { }; restoreButton.onclick = () => { if (originalSource === undefined) return; - dispose(); sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); editingSource = false; controls(busy); + dispose(); sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); controls(busy); }; compileButton.onclick = () => compileDraft(); function compileDraft(): void { @@ -416,29 +408,28 @@ function compileDraft(): void { dispose(true); operation = 'compiling'; clearOutput(); clearTreeExport(); try { sourceText(sourceEditor.value); - settings = readSettings('compiler'); const current = draftWorker(); - controls(true); deadline.reset(activeMilliseconds(settings)); clock(); el('status').textContent = 'Compiling'; + controls(true); deadline.reset(Infinity); clock(); el('status').textContent = 'Compiling'; el('compiler-stats').textContent = 'Loading pinned compiler and demo environment…'; const started = performance.now(); const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; }; activity(); compilerTimer = setInterval(activity, 1000); - current.postMessage({ type: 'compile', source: sourceEditor.value, ...settings, + current.postMessage({ type: 'compile', source: sourceEditor.value, ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } } function draftWorker(): Worker { - const current = new Worker(new URL('./compiler-worker.js', import.meta.url), { type: 'module' }); worker = current; + const current = new Worker(workerURL(true), { type: 'module' }); worker = current; current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => { if (worker !== current) return; switch (data.type) { case 'compiled': - stopCompilerActivity(); pauseClock(); operation = undefined; compiledDraft = data.draft; compiledSource = sourceEditor.value; compiled = true; totalWork = data.stats.work; + stopCompilerActivity(); pauseClock(); operation = undefined; compiledDraft = data.draft; compiledSource = sourceEditor.value; compiled = true; totalWork = graphStats(data.stats) ? data.stats.work : 0; exportsInput.replaceChildren(); for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; exportsInput.append(option); } if (data.names.includes(preferredName)) exportsInput.value = preferredName; - dispose(true); el('status').textContent = 'Compiled'; - compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${settings.uncapped ? 'optional limits off' : settings.limits.fuel === 0 ? 'memory/depth limits on; work unlimited' : 'optional limits on'} · ${data.stats.work.toLocaleString()} work units`; + if (persistentDefinitions()) idleDraft(); else dispose(true); el('status').textContent = 'Compiled'; + compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${graphStats(data.stats) ? `${data.stats.allocations.toLocaleString()} allocated / ${data.stats.peakNodes.toLocaleString()} peak live · ${data.stats.work.toLocaleString()} work units` : runtimeStats(data.stats)}`; el('compiler-stats').textContent = compilationStats; break; case 'state': @@ -450,30 +441,53 @@ function draftWorker(): Worker { } case 'output': el('terminal').textContent += data.text; break; case 'done': - totalWork += data.stats.work; - dispose(true); el('status').textContent = 'Finished'; + if (graphStats(data.stats)) totalWork += data.stats.work; + if (persistentDefinitions()) idleDraft(); else dispose(true); el('status').textContent = 'Finished'; if (data.result.type === 'tree' || data.result.type === 'list') { el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; } else el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; - el('compiler-stats').textContent = `${compilationStats} · ${totalWork.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`; + el('compiler-stats').textContent = `${compilationStats} · ${graphStats(data.stats) ? `${totalWork.toLocaleString()} cumulative work` : runtimeStats(data.stats)}`; + break; + case 'error': + if (persistentDefinitions() && operation === 'running' && !data.fatal) { + idleDraft(); el('status').textContent = 'Stopped'; + el('result').textContent = operationFailure('running', data.message); el('result').className = 'error'; + } else finish(data.message, true); break; - case 'error': finish(data.message, true); break; } }; current.onerror = event => { if (worker === current) { event.preventDefault(); finish(event.message || 'Compiler Worker failed.', true); } }; return current; } +// Persistent backends retain definitions only in their live Worker. JS may +// restore its backend-private checkpoint; no checkpoint crosses backend types. +function idleDraft(): void { + stopCompilerActivity(); pauseClock(); operation = undefined; pending = undefined; + inputForm.hidden = true; nextButton.disabled = true; controls(false); +} +function keepDraft(): void { + if (persistentDefinitions() && worker && compiled && !busy) idleDraft(); + else dispose(true); +} function runDraft(): void { - if (!compiledDraft) return; - dispose(true); operation = 'running'; + if (!compiled) return; try { const args = argumentsUI.read(), output = outputType(resultCodec.value); - settings = readSettings('compiler'); - clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; - const current = draftWorker(); - current.postMessage({ type: 'restore', draft: compiledDraft, ...settings }); + if (!persistentDefinitions()) dispose(true); + else if (!worker || busy) return; + operation = 'running'; clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; + const current = persistentDefinitions() ? worker! : draftWorker(); + if (!persistentDefinitions()) current.postMessage({ type: 'restore', draft: compiledDraft }); current.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); - } catch (error) { el('result').textContent = operationFailure(operation, error instanceof Error ? error.message : String(error)); el('result').className = 'error'; dispose(true); } + } catch (error) { + el('result').textContent = operationFailure('running', error instanceof Error ? error.message : String(error)); el('result').className = 'error'; + keepDraft(); + } } +backendPicker.onchange = () => { + selectedBackend = backend(backendPicker.value); dispose(); clearOutput(); + if (draft) { controls(false); el('status').textContent = 'Backend changed · compile draft to run'; } + else if (selection) newWorker(false, true); +}; const examples = { identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, @@ -484,8 +498,8 @@ const examples = { for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.onclick = () => { const example = examples[button.dataset['compilerExample'] as keyof typeof examples]; if (!example) return; chooseProgram(''); - editingSource = true; el<HTMLDetailsElement>('source-section').open = true; - dispose(true); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; + el<HTMLDetailsElement>('source-section').open = true; + keepDraft(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; if (!compiled) exportsInput.replaceChildren(); preferredName = example.name; sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source; @@ -553,11 +567,3 @@ window.addEventListener('pane-change', () => { else if (!draft && entries.length && !loaded && selection) newWorker(false); } }); -window.addEventListener('limits-change', event => { - const runner = (event as CustomEvent<string>).detail; - if (runner === 'compiler') { if (draft) { dispose(true); clearOutput(); el('status').textContent = compiled ? 'Compiled' : 'Ready to compile'; } return; } - if (runner !== 'package' || draft) return; - preferredExport = exportsInput.value || preferredExport; - dispose(); loaded = false; clearOutput(); - if (selection && packagePane()) newWorker(false, true); -}); diff --git a/ext/demo-site/src/compiler-protocol.ts b/ext/demo-site/src/compiler-protocol.ts @@ -1,11 +1,11 @@ -import type { ExecutionLimits } from './execution.js'; -import type { GraphCheckpoint, NodeId, GraphStats } from './graph.js'; +import type { RuntimeStats } from './runtime-backends.js'; +import type { GraphCheckpoint, NodeId } from './graph.js'; import type { OutputType } from './codecs.js'; import type { RequestId, WorkerMessage } from './protocol.js'; export interface CompiledDraft { graph: GraphCheckpoint; exports: Array<[string, NodeId]>; } -export type CompilerClient = { type: 'compile'; source: unknown; environment?: string; uncapped?: boolean; limits?: ExecutionLimits } - | { type: 'restore'; draft: CompiledDraft; uncapped?: boolean; limits?: ExecutionLimits } +export type CompilerClient = { type: 'compile'; source: unknown; environment?: string } + | { type: 'restore'; draft: CompiledDraft } | { type: 'run'; export: string; arguments: unknown; output: OutputType; mode: 'pure' | 'io'; step: boolean } | { type: 'resume'; id: RequestId; value?: string }; export type CompilerMessage = Exclude<WorkerMessage, { type: 'loaded' }> - | { type: 'compiled'; names: string[]; draft: CompiledDraft; stats: GraphStats; milliseconds: number }; + | { type: 'compiled'; names: string[]; draft?: CompiledDraft; stats: RuntimeStats; milliseconds: number }; diff --git a/ext/demo-site/src/compiler-worker.ts b/ext/demo-site/src/compiler-worker.ts @@ -1,5 +1,6 @@ +import { resumeValue } from './io.js'; import { environmentProfile } from './packages.js'; -import { executionSettings, executionBudget, collectionPolicy, type ExecutionSettings } from './execution.js'; +import { BROWSER_BUDGET, collectionPolicy } from './execution.js'; import { contractEnvironment } from './contracts.js'; import { Graph, type NodeId } from './graph.js'; import { COMPILER_BUNDLE_LIMIT, fetchBundle, fetchBytes, loadBundle } from './bundle.js'; @@ -21,9 +22,9 @@ function request(kind: 'step' | 'input'): Promise<string> { const id = ++requestId as RequestId; return new Promise((resolve, reject) => { pending = { id, kind, resolve, reject }; send({ type: 'state', state: kind, id }); }); } -async function compile(source: unknown, settings: ExecutionSettings, profileName?: string): Promise<void> { +async function compile(source: unknown, profileName?: string): Promise<void> { phase = 'compiling'; running(); const start = performance.now(); - const budget = executionBudget(settings); + const budget = BROWSER_BUDGET; graph = new Graph(budget, collectionPolicy(budget)); const input = sourceLiteral(graph, source); // Validate before downloading a compiler. const bytes = await fetchBytes(new URL('compiler.json', base), 4096); @@ -56,7 +57,6 @@ async function compile(source: unknown, settings: ExecutionSettings, profileName } async function run(message: Extract<CompilerClient, { type: 'run' }>): Promise<void> { if (!graph || !exports) throw new Error('Compile a unit first'); - graph.beginOperation(); // Each application gets its own allowance, separate from compilation. phase = 'running'; running(); const fn = exports.get(message.export); if (fn === undefined) throw new Error('Unknown compiled definition'); @@ -77,9 +77,9 @@ scope.onmessage = ({ data }) => { const message = data as Record<string, unknown>; if (message['type'] === 'resume') { if (!pending || message['id'] !== pending.id) return; - const waiter = pending; pending = undefined; - if (waiter.kind === 'input' && typeof message['value'] !== 'string') waiter.reject(new Error('Invalid input response')); - else waiter.resolve(typeof message['value'] === 'string' ? message['value'] : ''); + const value = resumeValue(pending.kind, message['value']); + if (value === undefined) return; + const waiter = pending; pending = undefined; waiter.resolve(value); return; } let operation: Promise<void>; @@ -87,12 +87,12 @@ scope.onmessage = ({ data }) => { if (message['type'] === 'restore' && phase === 'empty') { // Only the owning page supplies this trusted in-memory checkpoint. const restored = data as Extract<CompilerClient, { type: 'restore' }>; - const budget = executionBudget(executionSettings(restored.limits, 'compiler', restored.uncapped ?? false)); + const budget = BROWSER_BUDGET; graph = Graph.restore(restored.draft.graph, budget, collectionPolicy(budget)); exports = new Map(restored.draft.exports); phase = 'ready'; return; } else if (message['type'] === 'compile' && phase === 'empty') { if (message['environment'] !== undefined && (typeof message['environment'] !== 'string' || message['environment'].length > 128)) throw new Error('Invalid demo environment'); - operation = compile(message['source'], executionSettings(message['limits'], 'compiler', message['uncapped'] === undefined ? false : message['uncapped']), message['environment'] as string | undefined); + operation = compile(message['source'], message['environment'] as string | undefined); } else if (message['type'] === 'run' && phase === 'ready') { if (typeof message['export'] !== 'string' || message['export'].length > 128) throw new Error('Invalid compiled selection'); operation = run({ type: 'run', export: message['export'], arguments: message['arguments'], diff --git a/ext/demo-site/src/compiler.ts b/ext/demo-site/src/compiler.ts @@ -8,7 +8,6 @@ export function compilerDescriptor(raw: unknown): { hash: BundleHash } { if (descriptor['schema'] !== 1 || descriptor['backend'] !== 'stable' || descriptor['export'] !== COMPILER_EXPORT) throw new Error('Invalid stable compiler descriptor'); return { hash: bundleHash(descriptor['hash']) }; } -export const COMPILER_BUDGET = Object.freeze({ nodes: 6_000_000, fuel: Infinity, stack: 100_000 }); export const COMPILER_COLLECTION = Object.freeze({ threshold: 250_000 }); export const SOURCE_LIMIT = 16_384; export function sourceText(source: unknown): string { diff --git a/ext/demo-site/src/execution.ts b/ext/demo-site/src/execution.ts @@ -1,37 +1,12 @@ -import { DEFAULT_BUDGET, type Budget, type CollectionPolicy } from './graph.js'; -import { COMPILER_BUDGET } from './compiler.js'; -export const PACKAGE_ACTIVE_MS = 60_000; +import type { Budget, CollectionPolicy } from './graph.js'; +// Execution has no application node/work/frame ceilings. Browser memory remains +// finite; Stop terminates the owning Worker. GC thresholds are not budgets. +export const BROWSER_BUDGET: Readonly<Budget> = Object.freeze({ nodes: Infinity, fuel: Infinity, stack: Infinity }); +export const PACKAGE_LOAD_MS = 60_000; +// Export is bounded transport/rendering, not program execution. export const EXPORT_ACTIVE_MS = 120_000; export const EXECUTION_COLLECTION = Object.freeze({ threshold: 250_000 }); export const PACKAGE_COLLECTION = Object.freeze({ threshold: 100_000 }); -export const PACKAGE_BUDGET: Readonly<Budget> = Object.freeze({ ...DEFAULT_BUDGET, fuel: 20_000_000 }); -export type Runner = 'package' | 'compiler'; -export interface ExecutionLimits extends Budget { seconds: number; } -export interface ExecutionSettings { limits: ExecutionLimits; uncapped: boolean; } -export function defaultLimits(runner: Runner): ExecutionLimits { - return { ...(runner === 'package' ? PACKAGE_BUDGET : COMPILER_BUDGET), fuel: runner === 'package' ? PACKAGE_BUDGET.fuel : 0, seconds: runner === 'package' ? 10 : 0 }; -} -export function executionSettings(raw: unknown, runner: Runner, uncapped: unknown = false): ExecutionSettings { - if (typeof uncapped !== 'boolean') throw new Error('Invalid disable-limits selection'); - const values: unknown = raw === undefined ? defaultLimits(runner) : raw; - if (!values || typeof values !== 'object' || Array.isArray(values)) throw new Error('Invalid execution limits'); - const input = values as Record<string, unknown>; - for (const key of ['nodes', 'fuel', 'stack', 'seconds']) { - const value = input[key]; - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < (key === 'seconds' || key === 'fuel' ? 0 : 1) - || (key === 'seconds' && !Number.isSafeInteger(value * 1000))) - throw new Error(`${key}: enter a ${key === 'seconds' || key === 'fuel' ? 'nonnegative' : 'positive'} whole number`); - } - return { limits: { nodes: input['nodes'] as number, fuel: input['fuel'] as number, - stack: input['stack'] as number, seconds: input['seconds'] as number }, uncapped }; -} -export function executionBudget(settings: ExecutionSettings): Readonly<Budget> { - const { nodes, fuel, stack } = settings.limits; - return settings.uncapped ? { nodes: Infinity, fuel: Infinity, stack: Infinity } : { nodes, fuel: fuel === 0 ? Infinity : fuel, stack }; -} -export function activeMilliseconds(settings: ExecutionSettings): number { - return settings.uncapped || settings.limits.seconds === 0 ? Infinity : settings.limits.seconds * 1000; -} export function collectionPolicy(budget: Readonly<Budget>, threshold: number = EXECUTION_COLLECTION.threshold): CollectionPolicy | undefined { return budget.nodes < 2 ? undefined : { threshold: Math.min(threshold, budget.nodes) }; } diff --git a/ext/demo-site/src/haskell-session.ts b/ext/demo-site/src/haskell-session.ts @@ -0,0 +1,81 @@ +// Backend-private protocol mapping. No WASM pointer, Haskell heap checkpoint or +// fabricated graph statistic crosses into the shared site protocol. +import { compilerDescriptor } from './compiler.js'; +import { fetchBytes, type BundleHash } from './bundle.js'; +import { environmentProfile } from './packages.js'; +import type { Literal, OutputType, ValueView } from './codecs.js'; +import type { IOHost } from './io.js'; +import type { RuntimeStats } from './runtime-backends.js'; +interface Reply { text: string; linearBytes: number; compilerHash?: string | null; } +interface Client { + disposed: boolean; + request(message: object, timeout?: number | null): Promise<Reply>; + dispose(reason?: string): void; +} +export class HaskellSession { + private linearMemoryBytes = 0; + private constructor(private readonly client: Client) {} + static async create(compiler: boolean): Promise<HaskellSession> { + const module = await import(new URL('../runtime/haskell/client.mjs', import.meta.url).href) as { CompilerWorker: new () => Client }; + const session = new HaskellSession(new module.CompilerWorker()); + try { + const ready = await session.request({ type: 'init', compiler }); + if (compiler) { + const bytes = await fetchBytes(new URL('../compiler.json', import.meta.url), 4096); + const expected = compilerDescriptor(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown).hash; + if (ready.compilerHash !== expected) throw new Error('Runtime compiler pin mismatch'); + } + session.compilerHash = ready.compilerHash ?? null; + return session; + } catch (error) { session.dispose(); throw error; } + } + private compilerHash: string | null = null; + get disposed(): boolean { return this.client.disposed; } + private async request(message: object): Promise<Reply> { + const reply = await this.client.request(message, null); + this.linearMemoryBytes = reply.linearBytes; + return reply; + } + private async json<T>(message: object): Promise<T> { return JSON.parse((await this.request(message)).text) as T; } + async load(bytes: Uint8Array, hash: BundleHash): Promise<void> { await this.request({ type: 'load', bytes: new Uint8Array(bytes).buffer, hash }); } + async compile(source: string, profileName?: string): Promise<string[]> { + let environment: { bytes: ArrayBuffer; hash: BundleHash; names: string[] } | null | undefined; + if (profileName !== undefined) { + const bytes = await fetchBytes(new URL('../environments.json', import.meta.url), 32768); + const selected = environmentProfile(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown, profileName); + if (selected.compiler !== this.compilerHash) throw new Error('Demo environment/compiler pin mismatch'); + environment = selected.profile ? { + bytes: new Uint8Array(await fetchBytes(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`, import.meta.url))).buffer, + hash: selected.profile.hash, names: selected.profile.names, + } : null; + } + const reply = await this.request({ type: 'compile', source, ...(environment === undefined ? {} : { environment }) }); + return reply.text.trimEnd().split('\n').filter(Boolean); + } + async run(name: string, args: Literal[], output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView> { + if (mode === 'pure') return this.json<ValueView>({ type: 'evaluate', name, arguments: args, output }); + type Event = { kind: string; token: number; step: number; name: string; detail: string; depth: number; value: string; text: string }; + try { + let event = await this.json<Event>({ type: 'io-start', name, arguments: args }); + while (event.kind !== 'done') { + let value = ''; + switch (event.kind) { + case 'before': await host.before({ step: event.step, name: event.name, detail: event.detail, depth: event.depth }); break; + case 'read': value = await host.read(); break; + case 'write': host.write(event.text); break; + case 'after': host.after({ step: event.step, name: event.name, value: event.value }); break; + default: throw new Error('Unknown runtime IO event'); + } + host.running(); + event = await this.json<Event>({ type: 'io-resume', token: event.token, value }); + } + return await this.json<ValueView>({ type: 'io-view', output }); + } finally { + // Release result/continuations even when decoding or a host hook fails. + // A poisoned instance cannot be recovered by cancelling the interaction. + if (!this.disposed) await this.request({ type: 'io-cancel' }); + } + } + stats(): RuntimeStats { return { backend: 'haskell-wasm', linearMemoryBytes: this.linearMemoryBytes }; } + dispose(): void { this.client.dispose(); } +} diff --git a/ext/demo-site/src/io.ts b/ext/demo-site/src/io.ts @@ -46,3 +46,11 @@ export async function runIO(graph: Graph, handle: NodeId, host: IOHost, collectA } throw new Error('IO action limit exceeded'); } + +// Ignore malformed/wrong-kind/stale resumes without consuming the waiting token. +// The page validates interactively; the Worker must also retain a retryable wait. +export function resumeValue(kind: 'input' | 'step', value: unknown): string | undefined { + if (kind === 'step') return value === undefined ? '' : undefined; + if (typeof value !== 'string' || value.length > 512 || /[\r\n\uD800-\uDFFF]/u.test(value) || Array.from(value).length > 256) return undefined; + return value; +} diff --git a/ext/demo-site/src/limits-ui.ts b/ext/demo-site/src/limits-ui.ts @@ -1,65 +0,0 @@ -import { defaultLimits, executionSettings, type ExecutionSettings, type Runner } from './execution.js'; -const committed = new Map<Runner, ExecutionSettings>(); -export function readSettings(runner: Runner): ExecutionSettings { - const settings = committed.get(runner); - if (!settings) throw new Error('Execution settings are not ready'); - return { limits: { ...settings.limits }, uncapped: settings.uncapped }; -} -for (const runner of ['package', 'compiler'] as const) { - const checkbox = document.getElementById(`${runner === 'package' ? '' : 'compiler-'}uncapped`) as HTMLInputElement; - const summary = document.getElementById(`${runner}-limits-summary`)!; - const container = document.getElementById(`${runner}-limits-defaults`)!; - const defaults = defaultLimits(runner), fields = new Map<string, HTMLInputElement>(); - const form = document.createElement('form'); form.noValidate = true; form.className = 'limits-editor'; - for (const [key, label] of [['nodes', 'Memory budget (live nodes)'], ['fuel', 'Work units (0 = no work limit)'], ['stack', 'Evaluation depth (frames)'], ['seconds', 'Active seconds (0 = unlimited)']] as const) { - const wrapper = document.createElement('div'), caption = document.createElement('label'), input = document.createElement('input'); - const hint = document.createElement('span'); hint.className = 'hint'; - input.id = `${runner}-limit-${key}`; input.type = 'number'; input.min = key === 'seconds' || key === 'fuel' ? '0' : '1'; input.step = '1'; - input.inputMode = 'numeric'; input.required = true; input.value = String(defaults[key]); - caption.htmlFor = input.id; caption.textContent = label; - hint.textContent = `Default: ${defaults[key].toLocaleString()}`; - wrapper.append(caption, input, hint); form.append(wrapper); fields.set(key, input); - } - const actions = document.createElement('div'); actions.className = 'controls'; - const apply = document.createElement('button'), restore = document.createElement('button'); - apply.type = 'submit'; apply.textContent = 'Apply limits'; restore.type = 'button'; restore.textContent = 'Restore defaults'; - const error = document.createElement('p'); error.className = 'limits-error'; error.setAttribute('role', 'status'); - actions.append(apply, restore); form.append(actions, error); container.replaceWith(form); - committed.set(runner, executionSettings(defaults, runner, true)); - function update(): void { - const settings = readSettings(runner); - const customized = Object.entries(defaults).some(([key, value]) => settings.limits[key as keyof typeof defaults] !== value); - summary.textContent = `Optional limits${settings.uncapped ? ' (off)' : customized ? ' (custom)' : ' (on)'}`; - for (const input of fields.values()) input.disabled = settings.uncapped; - apply.disabled = settings.uncapped; - document.getElementById(`${runner}-resource-mode`)!.textContent = settings.uncapped ? 'Off' : 'On'; - } - function publish(settings: ExecutionSettings): void { - committed.set(runner, settings); - for (const [key, input] of fields) input.value = String(settings.limits[key as keyof typeof settings.limits]); - error.textContent = ''; update(); - window.dispatchEvent(new CustomEvent('limits-change', { detail: runner })); - } - form.oninput = () => { error.textContent = ''; summary.textContent = 'Advanced: unapplied resource settings'; }; - form.onsubmit = event => { - event.preventDefault(); - try { - const values: Record<string, number> = {}; - for (const [key, field] of fields) { - if (!field.value.trim()) throw new Error(`${key}: enter a whole number`); - values[key] = Number(field.value); - } - publish(executionSettings(values, runner, checkbox.checked)); - } catch (cause) { error.textContent = cause instanceof Error ? cause.message : String(cause); } - }; - checkbox.onchange = () => { - const settings = readSettings(runner); settings.uncapped = checkbox.checked; - // Bypass uses the last applied finite values, not a partial/invalid draft. - publish(settings); - }; - restore.onclick = () => { - for (const [key, input] of fields) input.value = String(defaults[key as keyof typeof defaults]); - checkbox.checked = true; publish(executionSettings(defaults, runner, true)); - }; - update(); -} diff --git a/ext/demo-site/src/protocol.ts b/ext/demo-site/src/protocol.ts @@ -1,6 +1,5 @@ import type { TreeFormat, TreeFile } from './tree-export.js'; -import type { ExecutionLimits } from './execution.js'; -import type { GraphStats } from './graph.js'; +import type { RuntimeStats } from './runtime-backends.js'; import type { BundleHash, ExportInfo } from './bundle.js'; import type { OutputType, ValueView } from './codecs.js'; import type { Trace } from './io.js'; @@ -11,7 +10,7 @@ export interface CatalogueEntry { environment?: string; presets?: Record<string, { arguments: unknown[]; output: OutputType; mode: 'pure' | 'io' }>; } -export type ClientMessage = { type: 'load'; hash: BundleHash; localId?: string; uncapped?: boolean; limits?: ExecutionLimits } +export type ClientMessage = { type: 'load'; hash: BundleHash; localId?: string } | { type: 'run'; export: string; arguments: unknown; output: OutputType; mode: 'pure' | 'io'; step: boolean } | { type: 'download' } | { type: 'export'; export: string; format: TreeFormat } @@ -22,8 +21,8 @@ export type WorkerMessage = { type: 'state'; state: 'running' } | { type: 'trace'; phase: 'before'; event: Trace } | { type: 'trace'; phase: 'after'; event: { step: number; name: string; value: string } } | { type: 'output'; text: string } - | { type: 'done'; result: ValueView; stats: GraphStats } + | { type: 'done'; result: ValueView; stats: RuntimeStats } | { type: 'exported'; hash: BundleHash; export: string; format: TreeFormat; file: TreeFile } | { type: 'downloaded'; hash: BundleHash; bytes: Uint8Array } | { type: 'export-error'; message: string } - | { type: 'error'; message: string }; + | { type: 'error'; message: string; fatal?: boolean }; diff --git a/ext/demo-site/src/published-bundle.ts b/ext/demo-site/src/published-bundle.ts @@ -0,0 +1,17 @@ +import { decodeBundle, fetchBytes, sha256, type BundleHash } from './bundle.js'; +// Common authenticated package/source boundary, independent of the evaluator. +export async function publishedBundle(hash: BundleHash, localId?: string) { + const base = new URL('../', import.meta.url); + let source: string | null = null; + if (localId !== undefined) { + const catalogue: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await fetchBytes(new URL('catalogue.json', base), 64000))); + if (!Array.isArray(catalogue)) throw new Error('Invalid catalogue'); + const entry = catalogue.find((item: unknown) => !!item && typeof item === 'object' && (item as Record<string, unknown>)['id'] === localId) as Record<string, unknown> | undefined; + if (!entry || entry['hash'] !== hash) throw new Error('Invalid catalogue pin'); + if (typeof entry['source'] !== 'string' || entry['source'].length > 32000 || await sha256(new TextEncoder().encode(entry['source'])) !== entry['sourceSha256']) throw new Error('Catalogue source integrity failure'); + source = entry['source']; + } + const bytes = await fetchBytes(new URL(`/_arboricx/bundle/hash/${hash}`, base)); + if (await sha256(bytes) !== hash) throw new Error('Bundle SHA-256 mismatch'); + return { hash, bytes, bundle: decodeBundle(bytes), source }; +} diff --git a/ext/demo-site/src/runtime-backends.ts b/ext/demo-site/src/runtime-backends.ts @@ -0,0 +1,17 @@ +import type { GraphStats } from './graph.js'; +export type Backend = 'js' | 'haskell-wasm' | 'zig-wasm'; +export type RuntimeStats = GraphStats | { backend: string; linearMemoryBytes: number }; +export const backends = { + js: { label: 'JavaScript', persistentDefinitions: false, worker: './worker.js', compiler: './compiler-worker.js' }, + 'zig-wasm': { label: 'Zig', persistentDefinitions: true, worker: './wasm-worker.js?backend=zig-wasm', compiler: './wasm-worker.js?backend=zig-wasm' }, + 'haskell-wasm': { label: 'Haskell', persistentDefinitions: true, worker: './wasm-worker.js?backend=haskell-wasm', compiler: './wasm-worker.js?backend=haskell-wasm' }, +} as const; +export function backend(value: string): Backend { + if (!Object.hasOwn(backends, value)) throw new Error('Unknown runtime backend'); + return value as Backend; +} +export function graphStats(stats: RuntimeStats): stats is GraphStats { return !('backend' in stats); } +export function runtimeStats(stats: RuntimeStats): string { + return graphStats(stats) ? `${stats.nodes.toLocaleString()} heap nodes · ${stats.work.toLocaleString()} work units` + : `${(stats.linearMemoryBytes / 1048576).toFixed(1)} MiB WASM linear memory`; +} diff --git a/ext/demo-site/src/runtime-session.ts b/ext/demo-site/src/runtime-session.ts @@ -0,0 +1,24 @@ +import { HaskellSession } from './haskell-session.js'; +import type { BundleHash } from './bundle.js'; +import type { Literal, OutputType, ValueView } from './codecs.js'; +import type { IOHost } from './io.js'; +import type { RuntimeStats } from './runtime-backends.js'; +// Coarse backend boundary. The page/driver never sees pointers, evaluator nodes, +// internal IO tokens, or runtime-specific checkpoints. Implementations own their +// private protocol; unsupported backends are never replaced by another. +export interface RuntimeSession { + readonly disposed: boolean; + load(bytes: Uint8Array, hash: BundleHash): Promise<void>; + compile(source: string, profileName?: string): Promise<string[]>; + run(name: string, args: Literal[], output: OutputType, mode: 'pure' | 'io', host: IOHost): Promise<ValueView>; + stats(): RuntimeStats; + dispose(): void; +} +export async function createRuntimeSession(name: string | null, compiler: boolean): Promise<RuntimeSession> { + if (name === 'zig-wasm') { + const module = await import(new URL('../runtime/zig/site-session.mjs', import.meta.url).href) as { create(options: { base: URL; compiler: boolean }): Promise<RuntimeSession> }; + return module.create({ base: new URL('../', import.meta.url), compiler }); + } + if (name !== 'haskell-wasm') throw new Error('Unsupported runtime backend'); + return HaskellSession.create(compiler); +} diff --git a/ext/demo-site/src/wasm-worker.ts b/ext/demo-site/src/wasm-worker.ts @@ -0,0 +1,72 @@ +import { resumeValue } from './io.js'; +import { createRuntimeSession, type RuntimeSession } from './runtime-session.js'; +const selectedBackend = new URL(globalThis.location.href).searchParams.get('backend'); +import { publishedBundle } from './published-bundle.js'; +import { bundleHash } from './bundle.js'; +import { literals, outputType } from './codecs.js'; +import { sourceText } from './compiler.js'; +import { exportTree, treeFormat } from './tree-export.js'; +import type { CompilerMessage } from './compiler-protocol.js'; +import type { WorkerMessage, RequestId } from './protocol.js'; +const scope = globalThis as unknown as { postMessage(m: CompilerMessage | WorkerMessage): void; onmessage: ((e: MessageEvent<unknown>) => void) | null }; +const send = (m: CompilerMessage | WorkerMessage) => scope.postMessage(m); +const running = () => send({ type: 'state', state: 'running' }); +let session: RuntimeSession | undefined, stored: Awaited<ReturnType<typeof publishedBundle>> | undefined; +let busy = false, requestId = 0; +let pending: { id: RequestId; resolve(value: string): void; reject(error: Error): void; kind: 'input' | 'step' } | undefined; +function request(kind: 'input' | 'step'): Promise<string> { + if (pending) throw new Error('Driver already waiting'); + const id = ++requestId as RequestId; + return new Promise((resolve, reject) => { pending = { id, resolve, reject, kind }; send({ type: 'state', state: kind, id }); }); +} +scope.onmessage = ({ data }) => { + if (!data || typeof data !== 'object' || Array.isArray(data)) return; + const m = data as Record<string, unknown>; + if (m['type'] === 'resume') { + if (!pending || m['id'] !== pending.id) return; + const value = resumeValue(pending.kind, m['value']); + if (value === undefined) return; + const waiter = pending; pending = undefined; waiter.resolve(value); + return; + } + if (busy) return; + busy = true; + void (async () => { + if (m['type'] === 'load') { + if (session || stored) throw new Error('Already initialized'); + const localId = m['localId']; + if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) throw new Error('Invalid catalogue ID'); + running(); stored = await publishedBundle(bundleHash(m['hash']), localId); + session = await createRuntimeSession(selectedBackend, false); + await session.load(stored.bytes, stored.hash); + send({ type: 'loaded', hash: stored.hash, exports: stored.bundle.exports, nodes: stored.bundle.nodes.length, metadata: stored.bundle.metadata, source: stored.source }); + } else if (m['type'] === 'compile') { + if (session) throw new Error('Already initialized'); + const source = sourceText(m['source']), profile = m['environment']; + if (profile !== undefined && (typeof profile !== 'string' || profile.length > 128)) throw new Error('Invalid demo environment'); + running(); const start = performance.now(); + session = await createRuntimeSession(selectedBackend, true); + const names = await session.compile(source, profile); + send({ type: 'compiled', names, stats: session.stats(), milliseconds: performance.now() - start }); + } else if (m['type'] === 'run') { + if (!session) throw new Error('Load or compile first'); + if (typeof m['export'] !== 'string' || m['export'].length > 512) throw new Error('Invalid export'); + if (m['mode'] !== 'pure' && m['mode'] !== 'io') throw new Error('Unsupported execution mode'); + const args = literals(m['arguments']), output = outputType(m['output']); running(); + const result = await session.run(m['export'], args, output, m['mode'], { + async before(event) { send({ type: 'trace', phase: 'before', event }); if (m['step'] === true) await request('step'); running(); }, + after(event) { send({ type: 'trace', phase: 'after', event }); }, write(text) { send({ type: 'output', text }); }, + read() { return request('input'); }, running, + }); + send({ type: 'done', result, stats: session.stats() }); + } else if (m['type'] === 'export' && stored) { + try { + if (typeof m['export'] !== 'string') throw new Error('Invalid export'); + const format = treeFormat(m['format']); + send({ type: 'exported', hash: stored.hash, export: m['export'], format, file: exportTree(stored.bundle, m['export'], format) }); + } catch (e) { send({ type: 'export-error', message: String(e) }); } + } else if (m['type'] === 'download' && stored) send({ type: 'downloaded', hash: stored.hash, bytes: stored.bytes }); + else throw new Error('Unsupported backend operation'); + })().catch((e: unknown) => send({ type: 'error', message: e instanceof Error ? e.message : String(e), fatal: session?.disposed ?? true })) + .finally(() => { busy = false; }); +}; diff --git a/ext/demo-site/src/worker.ts b/ext/demo-site/src/worker.ts @@ -1,19 +1,19 @@ +import { resumeValue } from './io.js'; +import { publishedBundle } from './published-bundle.js'; import { exportTree, treeFormat } from './tree-export.js'; -import { executionSettings, executionBudget, collectionPolicy, PACKAGE_COLLECTION, type ExecutionSettings } from './execution.js'; +import { BROWSER_BUDGET, collectionPolicy, PACKAGE_COLLECTION } from './execution.js'; import { Graph } from './graph.js'; -import { bundleHash, decodeBundle, fetchBytes, loadBundle, sha256, type Bundle, type BundleHash } from './bundle.js'; +import { bundleHash, loadBundle, type Bundle, type BundleHash } from './bundle.js'; import { literals, makeLiteral, outputType, view } from './codecs.js'; import { runIO } from './io.js'; import type { ClientMessage, WorkerMessage, RequestId } from './protocol.js'; const scope = globalThis as unknown as { postMessage(message: WorkerMessage): void; onmessage: ((event: MessageEvent<unknown>) => void) | null }; -const base = new URL('../', import.meta.url); const send = (message: WorkerMessage): void => scope.postMessage(message); const running = (): void => send({ type: 'state', state: 'running' }); let phase: 'empty' | 'loading' | 'ready' | 'running' | 'done' = 'empty'; let graph: Graph | undefined, exports: Map<string, import('./graph.js').NodeId> | undefined; let stored: { hash: BundleHash; bundle: Bundle; bytes: Uint8Array } | undefined; let requestId = 0; -let settings: ExecutionSettings = executionSettings(undefined, 'package'); let pending: { id: RequestId; kind: 'step' | 'input'; resolve(value: string): void; reject(error: Error): void } | undefined; function request(kind: 'step' | 'input'): Promise<string> { if (pending) throw new Error('Driver already waiting'); @@ -22,25 +22,10 @@ function request(kind: 'step' | 'input'): Promise<string> { } async function load(hash: BundleHash, localId: string | undefined): Promise<void> { phase = 'loading'; running(); - const url = new URL(`/_arboricx/bundle/hash/${hash}`, base); - let source: string | null = null; - if (localId !== undefined) { - const bytes = await fetchBytes(new URL('catalogue.json', base), 64_000); - const catalogue: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); - if (!Array.isArray(catalogue)) throw new Error('Invalid catalogue'); - const raw: unknown = catalogue.find((item: unknown) => !!item && typeof item === 'object' && (item as Record<string, unknown>)['id'] === localId); - if (!raw || typeof raw !== 'object') throw new Error('Unknown curated package'); - const entry = raw as Record<string, unknown>; - if (entry['hash'] !== hash) throw new Error('Invalid catalogue pin'); - if (typeof entry['source'] !== 'string' || entry['source'].length > 32_000 || - await sha256(new TextEncoder().encode(entry['source'])) !== entry['sourceSha256']) throw new Error('Catalogue source integrity failure'); - source = entry['source']; - } - const bytes = await fetchBytes(url); - if (await sha256(bytes) !== hash) throw new Error('Bundle SHA-256 mismatch'); - const bundle = decodeBundle(bytes); - stored = { hash, bundle, bytes }; - const budget = executionBudget(settings); + const loaded = await publishedBundle(hash, localId); + const { bundle, source } = loaded; + stored = loaded; + const budget = BROWSER_BUDGET; graph = new Graph(budget, collectionPolicy(budget, PACKAGE_COLLECTION.threshold)); exports = loadBundle(graph, bundle); phase = 'ready'; send({ type: 'loaded', hash, exports: bundle.exports, nodes: bundle.nodes.length, metadata: bundle.metadata, source }); @@ -67,9 +52,9 @@ scope.onmessage = ({ data }) => { const message = data as Record<string, unknown>; if (message['type'] === 'resume') { if (!pending || message['id'] !== pending.id) return; - const waiter = pending; pending = undefined; - if (waiter.kind === 'input' && typeof message['value'] !== 'string') waiter.reject(new Error('Invalid input response')); - else waiter.resolve(typeof message['value'] === 'string' ? message['value'] : ''); + const value = resumeValue(pending.kind, message['value']); + if (value === undefined) return; + const waiter = pending; pending = undefined; waiter.resolve(value); return; } if (message['type'] === 'download' && phase === 'ready' && stored) { @@ -87,7 +72,6 @@ scope.onmessage = ({ data }) => { let operation: Promise<void>; try { if (message['type'] === 'load' && phase === 'empty') { - settings = executionSettings(message['limits'], 'package', message['uncapped'] === undefined ? false : message['uncapped']); const localId = message['localId']; if (localId !== undefined && (typeof localId !== 'string' || localId.length > 128)) throw new Error('Invalid catalogue ID'); operation = load(bundleHash(message['hash']), localId); diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -119,13 +119,6 @@ details pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:11px; } .workspace-panel { display:none; min-width:0; padding:28px; } .workspace-panel.is-active { display:block; } .workspace-panel.explanation { padding-top:28px; padding-bottom:28px; } -.execution-limits { border:1px solid var(--line); border-radius:6px; background:var(--paper); margin:18px 0; } -.execution-limits summary { padding:12px 16px; font-weight:600; } -.limits-content { padding:4px 16px 12px; } -.limits-content dl { display:grid; grid-template-columns:1fr 1fr; gap:6px 20px; max-width:500px; } -.limits-content dt { color:var(--muted); } -.limits-content dd { margin:0; font:12px ui-monospace,monospace; } -.limits-content label { display:flex; align-items:center; gap:8px; margin-top:16px; } @media(max-width:740px) { .workspace-panel { padding:20px 14px; } .workspace-panel.explanation { padding:20px 14px; } .workspace-tabs { gap:0; padding:7px; } .workspace-tabs button { padding:10px 5px; font-size:13px; } } /* Horizontal tree illustrations and centered CTA for handheld/tablet layouts. */ @@ -138,8 +131,6 @@ details pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:11px; } @media (max-width:740px) { textarea,#bundle-hash { font-size:16px; } .panel-heading { flex-wrap:wrap; } - .limits-content dl { grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:8px 12px; } - .limits-content dd { overflow-wrap:anywhere; } } @media (max-width:540px) { header { flex-wrap:wrap; gap:8px 16px; padding-top:20px; padding-bottom:20px; } @@ -147,12 +138,6 @@ details pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:11px; } nav a { display:inline-flex; align-items:center; min-height:44px; white-space:nowrap; } } -.limits-editor { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px 20px; margin-top:12px; } -.limits-editor label,.limits-editor .hint { display:block; margin:0 0 5px; } -.limits-editor .hint { margin-top:5px; } -.limits-editor .controls,.limits-editor .limits-error { grid-column:1 / -1; } -.limits-error { color:#9b3422; margin:0; } -@media(max-width:430px) { .limits-editor { grid-template-columns:1fr; } } .tree-export .execution-panel { padding-bottom:8px; } .tree-export .source-panel { display:flex; flex-direction:column; } @@ -169,9 +154,9 @@ details pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:11px; } } #source-editor { flex:1; width:100%; margin:0; min-height:320px; resize:vertical; border:0; border-radius:0; background:transparent; color:inherit; font:13px/1.6 ui-monospace,monospace; padding:20px; } -#source-editor.draft { background:#273c32; box-shadow:inset 4px 0 #e5b96b; } +#source-editor.modified { background:#273c32; box-shadow:inset 4px 0 #e5b96b; } #authorship { margin:0; padding:10px 20px; color:#c6dacb; font-size:13px; } -#authorship.draft { color:#ffda98; } +#authorship.modified { color:#ffda98; } button.primary.draft { background:#8b581b; } .source-panel > .controls,.scratch-examples,.compiler-metrics { margin:10px 20px; } .scratch-examples,.compiler-metrics { color:#c6dacb; } @@ -181,10 +166,7 @@ button.primary.draft { background:#8b581b; } [hidden] { display:none !important; } @media(max-width:740px) { #source-editor { font-size:16px; height:320px; min-height:240px; max-height:360px; } } -#compiler-disclosure,#compile-activity { margin:10px 20px; color:#e2d5af; } .source-panel details > summary { color:#c6dacb; } -.resource-choice { border:1px solid var(--line); border-radius:8px; padding:12px 16px; margin:12px 0; } -.resource-choice p { margin:4px 0 8px; } /* A single primary path, with secondary controls disclosed on demand. */ .workspace-tabs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); } @@ -207,7 +189,6 @@ button.primary.draft { background:#8b581b; } .argument-help { margin:12px 0; } .argument-help textarea { margin-top:10px; } .run-tools { margin-top:16px; margin-bottom:18px; border-top:1px solid var(--line); padding-top:12px; } -.run-tools .execution-limits { margin:12px 0; } .trace-panel > summary { padding:14px 20px; } @media(max-width:540px) { .workspace-tabs { grid-template-columns:repeat(3,minmax(0,1fr)); } @@ -233,9 +214,7 @@ button.primary.draft { background:#8b581b; } .source-disclosure .source-panel { border-radius:0; } #source-editor { flex:none; height:480px; min-height:320px; max-height:none; font-size:14px; } #source-editor[readonly] { cursor:text; } -#edit-source { margin-left:auto; } .compiler-source .controls a { color:#c6dacb; align-self:center; } -.compiler-source #compiler-disclosure a { color:inherit; } @media(max-width:740px) { #argument-fields { grid-template-columns:minmax(0,1fr); } #source-editor { font-size:16px; height:320px; min-height:240px; max-height:360px; } diff --git a/ext/demo-site/test/backend-benchmark.mjs b/ext/demo-site/test/backend-benchmark.mjs @@ -0,0 +1,64 @@ +// Matched production Worker benchmark. No evaluator instrumentation or GC tuning. +export async function run(control,backend,memoryOnly=false){ + const base=new URL('/nested/site/',location.href); + const [fixtures,catalogue,{Graph},{view}]=await Promise.all([ + fetch('/zig-fixtures.json').then(r=>r.json()),fetch(new URL('catalogue.json',base)).then(r=>r.json()), + import(new URL('dist/graph.js',base)),import(new URL('dist/codecs.js',base)), + ]); + const assert=(ok,message)=>{if(!ok)throw Error(message);}; + const mark=async name=>{await fetch(control+'/phase',{method:'POST',body:JSON.stringify({name})});}; + const pause=ms=>new Promise(r=>setTimeout(r,ms)); + const report={schema:1,backend,memoryOnly,userAgent:navigator.userAgent,packages:[],compilations:[],checks:['production Worker replies checked against independent Haskell goldens'],scope:'Fresh profile; localhost uncompressed/no-store HTTP; first package includes cold runtime startup; later Workers may benefit from engine module caches. Compilation includes initialization/authentication/environment loading. Runs include full selected-codec observation and Worker messaging. No user think-time or forced GC.'}; + function client(){ + const worker=new Worker(new URL('dist/wasm-worker.js?backend='+backend,base),{type:'module'}); + let pending; + worker.onerror=e=>{pending?.reject(Error(e.message));pending=undefined;}; + worker.onmessage=({data:m})=>{ + if(!pending)return; + if(m.type==='error'){pending.reject(Error(m.message));pending=undefined;return;} + if(m.type==='output')pending.terminal+=m.text; + if(m.type==='state'&&m.state==='input')worker.postMessage({type:'resume',id:m.id,value:pending.input}); + if(m.type===pending.expected){pending.resolve({message:m,terminal:pending.terminal,ms:performance.now()-pending.start});pending=undefined;} + }; + return {request(message,expected,input=''){assert(!pending,'Overlapping benchmark request');return new Promise((resolve,reject)=>{pending={resolve,reject,expected,input,terminal:'',start:performance.now()};worker.postMessage(message);});},dispose(){worker.terminate();}}; + } + const cases=Object.entries(fixtures.demos).map(([name,demo])=>{ + const entry=catalogue.find(e=>e.hash===demo.hash),c=demo.haskellCases[0];assert(entry,'Missing fixture catalogue entry'); + const output=entry.presets?.[c.export]?.output??entry.output; + const graph=new Graph(),expected=view(graph,graph.fromTernary(c.result),output); + assert(output!=='tree'&&output!=='list','Benchmark requires complete scalar/record observation'); + return {name,demo,entry,c,output,expected}; + }); + await mark('baseline');await pause(memoryOnly?3000:700); + for(const {name,demo,entry,c,output,expected} of cases){ + await mark('package:'+name);const start=performance.now(),w=client(); + try{ + await w.request({type:'load',hash:demo.hash,localId:name},'loaded');const readyMs=performance.now()-start; + const result=await w.request({type:'run',export:c.export,arguments:c.arguments.map(value=>({type:'tree',value})),output,mode:demo.mode},'done',c.input??''); + assert(JSON.stringify(result.message.result)===JSON.stringify(expected),'Published result '+name); + assert(result.terminal===c.terminal,'Published terminal '+name); + report.packages.push({name,readyMs,runMs:result.ms,linearBytes:result.message.stats.linearMemoryBytes}); + if(memoryOnly){await mark('live-package:'+name);await pause(1500);} + }finally{w.dispose();} + await mark('package-disposed:'+name);await pause(120); + } + for(const {name,demo,entry,c,output,expected} of cases){ + await mark('compile:'+name);const start=performance.now(),w=client(); + try{ + const compiled=await w.request({type:'compile',source:demo.source,environment:entry.environment},'compiled'); + const row={name,readyMs:performance.now()-start,workerCompileMs:compiled.message.milliseconds,compileLinearBytes:compiled.message.stats.linearMemoryBytes,runs:[]}; + await mark('runs:'+name); + for(let i=0;i<21;i++){ + const r=await w.request({type:'run',export:c.export,arguments:c.arguments.map(value=>({type:'tree',value})),output,mode:demo.mode},'done',c.input??''); + assert(JSON.stringify(r.message.result)===JSON.stringify(expected),'Compiled result '+name); + assert(r.terminal===c.terminal,'Compiled terminal '+name); + row.runs.push({ms:r.ms,linearBytes:r.message.stats.linearMemoryBytes}); + } + report.compilations.push(row); + if(memoryOnly){await mark('live-compiled:'+name);await pause(1500);} + }finally{w.dispose();} + await mark('compile-disposed:'+name);await pause(150); + } + await mark('final-idle');await pause(1500);await mark('finished'); + return report; +} diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import {writeFile} from 'node:fs/promises'; +import {sampleBrowser} from './browser-memory.mjs'; +import {spawn} from 'node:child_process'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {resolve} from 'node:path'; +import {startSiteServer} from './backend-server.mjs'; +const {server,url,control,marks}=await startSiteServer(process.argv[2]); +const profile = await mkdtemp(resolve(tmpdir(), 'tricu-demo-chromium-')); +let browser, socket, stopMemory; +let next = 0; const pending = new Map(); +try { + browser = spawn(process.argv[3] || 'chromium', ['--headless', '--no-sandbox', '--disable-dev-shm-usage', + '--js-flags=--max-old-space-size=768', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] }); + if(process.env.TEST_BENCHMARK)stopMemory=sampleBrowser(browser.pid,marks); + const debuggerURL = await new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => reject(new Error('Chromium startup timed out: ' + stderr)), 15_000); + browser.on('error', error => { clearTimeout(timer); reject(error); }); + browser.on('exit', code => { clearTimeout(timer); reject(new Error('Chromium exited: ' + code + '\n' + stderr)); }); + browser.stderr.on('data', chunk => { + stderr += chunk; + const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/); + if (match) { clearTimeout(timer); resolve(match[1]); } + }); + }); + const endpoint = new URL(debuggerURL); + const tab = await (await fetch(`http://${endpoint.host}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); + socket = new WebSocket(tab.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); + const exceptions = []; + socket.onmessage = ({ data }) => { + const event = JSON.parse(data); + if (event.id) { + const waiter = pending.get(event.id); pending.delete(event.id); + if (!waiter) return; + clearTimeout(waiter.timer); + if (event.error) waiter.reject(new Error(JSON.stringify(event.error))); + else waiter.resolve(event.result); + } else if (event.method === 'Runtime.exceptionThrown') exceptions.push(event.params); + }; + const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++next; + const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, 180_000); + pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); + await send('Runtime.enable'); await send('Page.enable'); + const evaluate = async expression => { + const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + for(let i=0;!await evaluate(`location.href===${JSON.stringify(url)} && document.readyState==='complete'`);i++){if(i>400)throw Error('Page did not load');await new Promise(r=>setTimeout(r,25));} + const report=await evaluate(`import(${JSON.stringify(process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); + assert.deepEqual(exceptions,[]); + if(stopMemory){report.browserMemory=await stopMemory(marks);stopMemory=undefined;} + if(process.env.BENCHMARK_REPORT)await writeFile(process.env.BENCHMARK_REPORT,JSON.stringify(report,null,2)+'\n'); + if(report.parity)console.log(JSON.stringify(report.parity)); + console.log('PASS actual site Chromium: '+report.checks.join(' / ')); +} finally { + if(stopMemory)await stopMemory(marks); + for (const waiter of pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Browser test closed')); } + pending.clear(); socket?.close(); + if (browser && browser.exitCode === null) { + const exited = new Promise(resolve => browser.once('exit', resolve)); + browser.kill('SIGKILL'); await exited; + } + await new Promise(resolve => server.close(resolve)); + await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); +} diff --git a/ext/demo-site/test/backend-firefox.mjs b/ext/demo-site/test/backend-firefox.mjs @@ -0,0 +1,30 @@ +import {writeFile} from 'node:fs/promises'; +import {sampleBrowser} from './browser-memory.mjs'; +import {spawn} from 'node:child_process'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {resolve} from 'node:path'; +import {startSiteServer} from './backend-server.mjs'; +const {server,url,completion,marks}=await startSiteServer(process.argv[2],true); +const profile=await mkdtemp(resolve(tmpdir(),'tricu-site-firefox-')); +let browser,timer,stopMemory; +try { + browser=spawn(process.argv[3]||'firefox',['--headless','--no-remote','--new-instance','--profile',profile,url],{stdio:['ignore','ignore','pipe'],detached:true}); + if(process.env.TEST_BENCHMARK)stopMemory=sampleBrowser(browser.pid,marks); + let log='';browser.stderr.on('data',b=>{log=(log+b).slice(-16000);}); + const failure=new Promise((_,reject)=>{ + browser.on('error',reject);browser.on('exit',code=>reject(Error('Firefox exited '+code+'\n'+log))); + timer=setTimeout(()=>reject(Error('Site backend test timed out\n'+log)),180000); + }); + const report=await Promise.race([completion,failure]); + if(stopMemory){report.browserMemory=await stopMemory(marks);stopMemory=undefined;} + if(process.env.BENCHMARK_REPORT)await writeFile(process.env.BENCHMARK_REPORT,JSON.stringify(report,null,2)+'\n'); + if(report.parity)console.log(JSON.stringify(report.parity)); + console.log('PASS actual site '+report.userAgent+': '+report.checks.join(' / ')); +} finally { + if(stopMemory)await stopMemory(marks); + clearTimeout(timer); + if(browser?.pid){const exited=browser.exitCode===null?new Promise(r=>browser.once('exit',r)):Promise.resolve();try{process.kill(-browser.pid,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}await exited;} + server.closeAllConnections();await new Promise(r=>server.close(r)); + await rm(profile,{recursive:true,force:true,maxRetries:5,retryDelay:100}); +} diff --git a/ext/demo-site/test/backend-probe.mjs b/ext/demo-site/test/backend-probe.mjs @@ -0,0 +1,101 @@ +// Shared real-page assertions for Chromium and stock Firefox. Test-only module. +const assert={ + equal(a,b,message='Values differ'){if(!Object.is(a,b))throw Error(message+': '+JSON.stringify({actual:a,expected:b}));}, + match(value,pattern){if(!pattern.test(value))throw Error('Missing '+pattern+' in '+value);}, + doesNotMatch(value,pattern){if(pattern.test(value))throw Error('Unexpected '+pattern+' in '+value);}, +}; +export async function run(control, selectedBackend='haskell-wasm') { + assert.equal([...document.getElementById('runtime-backend').options].some(x=>x.value===selectedBackend),true,'Selected backend must be offered by the real page'); + const selectBackend=async()=>{const el=document.getElementById('runtime-backend');el.value=selectedBackend;el.dispatchEvent(new Event('change'));}; + const compilerHash=(await (await fetch(new URL('./compiler.json',location.href))).json()).hash; + const evaluate=async expression=>(0,eval)(expression); + const requestLog=async()=> (await fetch(control+'/requests')).json(); + const fault=async values=>{const r=await fetch(control+'/fault',{method:'POST',body:JSON.stringify(values)});if(!r.ok)throw Error('Fault control failed');}; + const wait=async(expression,seconds=30)=>{ + const until=Date.now()+seconds*1000; + while(Date.now()<until){if(await evaluate(expression))return;await new Promise(r=>setTimeout(r,25));} + throw Error('Timed out '+expression+'\n'+await evaluate("document.getElementById('result').textContent")); + }; + const status=s=>`document.getElementById('status').textContent===${JSON.stringify(s)}`; + const click=async id=>evaluate(`document.getElementById(${JSON.stringify(id)}).click()`); + const select=async id=>{await evaluate(`document.getElementById('program-picker').value='demo:${id}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready'));}; + await wait("typeof document.getElementById('tab-playground')?.onclick==='function'"); + assert.equal(document.getElementById('runtime-backend').value,'haskell-wasm','Fresh page defaults to Haskell'); + await click('tab-playground');await wait(status('Ready')); + await click('run');await wait(status('Finished')); + assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); + assert.equal((await requestLog()).some(x=>x.endsWith('/runtime/haskell/reactor.wasm')),true,'Default actually loads Haskell'); + await fault({runtime:true}); + await selectBackend();await wait(status('Stopped')); + assert.match(await evaluate("document.getElementById('result').textContent"),/WASM fetch failed|HTTP|fetch/i); + assert.equal(await evaluate("document.getElementById('run').disabled"),true,'runtime failure must not fall back to JS'); + await fault({runtime:false});await select('intro'); + assert.equal((await requestLog()).some(x=>x.endsWith('/data/compiler.dag')||x.endsWith('/hash/'+compilerHash)),false,'packages do not load a compiler kit'); + await click('run');await wait(status('Finished')); + assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); + // The same real page can reinitialize each supported published package. + for(const name of ['rgb','microscope','shipping','events','registration','migration']) { + await select(name);await click('run');await wait(status('Finished')); + assert.match(await evaluate("document.getElementById('result').textContent"),/^Returned /); + } + await select('greet');await click('run');await wait(status('Waiting for input')); + await evaluate("document.getElementById('line-input').value='λ雪🙂';document.getElementById('input-form').requestSubmit()");await wait(status('Finished')); + assert.match(await evaluate("document.getElementById('terminal').textContent"),/λ雪🙂/); + // Stored export remains backend-independent and does not run the program. + await click('tab-export-definition'); + await evaluate("document.getElementById('tree-format').value='dag';document.getElementById('tree-format').dispatchEvent(new Event('change'))"); + await click('export-tree');await wait("!document.getElementById('copy-tree').disabled"); + assert.match(await evaluate("document.getElementById('tree-preview').textContent"),/tree/); + await click('tab-playground');await select('intro'); + await evaluate("document.getElementById('source-editor').value=document.getElementById('source-editor').value.replace('first 42 7','first 43 7');document.getElementById('source-editor').dispatchEvent(new Event('input'))"); + await click('compile');await wait(status('Compiled')); + assert.equal((await requestLog()).some(x=>x.endsWith('/data/compiler.dag')||x.endsWith('/hash/'+compilerHash)),true); + assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/WASM linear memory/); + assert.doesNotMatch(await evaluate("document.getElementById('compiler-stats').textContent"),/work units|heap nodes/); + assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); + for(let i=0;i<3;i++){await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"');} + await evaluate("document.getElementById('output-type').value='string'");await click('run');await wait(status('Stopped')); + assert.equal(await evaluate("document.getElementById('run').disabled"),false,'recoverable run errors preserve definitions'); + await evaluate("document.getElementById('output-type').value='number';document.getElementById('source-editor').value+='\\n-- unapplied';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); + assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),true); + await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"'); + await click('restore-source');await wait(status('Ready')); + // A practical draft exercises selected environment bytes/pin/name resolution. + await select('shipping');await evaluate("document.getElementById('source-editor').value+='\\n-- adapter test';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); + await click('compile');await wait(status('Compiled'));await click('run');await wait(status('Finished')); + // Scratch compilation selects the runtime's authenticated default environment. + await evaluate("document.querySelector('[data-compiler-example=contracts]').click()");await click('compile');await wait(status('Compiled')); + await click('run');await wait(status('Finished'));assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); + await fault({compilerPin:true}); + await evaluate("document.getElementById('source-editor').value+='\\n-- pin control';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); + await click('compile');await wait(status('Stopped')); + assert.match(await evaluate("document.getElementById('result').textContent"),/Runtime compiler pin mismatch|HTTP|fetch/i); + assert.equal(await evaluate("document.getElementById('run').disabled"),true); + await fault({compilerPin:false}); + await evaluate("document.querySelector('[data-compiler-example=input]').click()");await click('compile');await wait(status('Compiled')); + await click('run');await wait(status('Waiting for input'));await click('stop');await wait(status('Stopped')); + assert.equal(await evaluate("document.getElementById('run').disabled"),true,'hard Stop loses persistent WASM definitions'); + assert.match(await evaluate("document.getElementById('result').textContent"),/compile the draft again/); + await click('compile');await wait(status('Compiled')); + await evaluate("document.getElementById('step-mode').checked=true");await click('run');await wait(status('Paused before action')); + await click('next');await wait(status('Waiting for input')); + await evaluate("document.getElementById('line-input').value='restart🙂';document.getElementById('input-form').requestSubmit()");await wait(status('Finished')); + assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "restart🙂"'); + // An actual non-returning reduction must remain stoppable from the page. + await evaluate("document.getElementById('source-editor').value='id = (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); + await click('compile');await wait(status('Compiled')); + await evaluate("document.getElementById('mode').value='pure';document.getElementById('output-type').value='tree';document.getElementById('arguments').value=JSON.stringify([{type:'tree',value:'212110021100'},{type:'tree',value:'212110021100'}])"); + await click('run');await wait(status('Running')); + await new Promise(r=>setTimeout(r,150));assert.equal(await evaluate("document.getElementById('status').textContent"),'Running'); + await click('stop');await wait(status('Stopped'));assert.equal(await evaluate("document.getElementById('run').disabled"),true); + // Backend switching preserves text but never carries heaps/checkpoints across. + const source=await evaluate("document.getElementById('source-editor').value"); + await evaluate("document.getElementById('runtime-backend').value='js';document.getElementById('runtime-backend').dispatchEvent(new Event('change'))"); + assert.equal(await evaluate("document.getElementById('run').disabled"),true); + assert.equal(await evaluate("document.getElementById('source-editor').value"),source); + await click('compile');await wait(status('Compiled')); + assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/work units/); + await(await import('/backend-resume-probe.mjs')).probeResume(selectedBackend); + const parity=selectedBackend==='zig-wasm'?await(await import('/zig-session-probe.mjs')).run():null; + return {parity,userAgent:navigator.userAgent,checks:['Haskell default startup/execution','package/IO/export','default/empty/practical compilation','persistent definitions','recoverable errors','artifact/pin failure without fallback','hard Stop/recompile including CPU-bound work','IO stepping/stale and malformed resume rejection','backend-switch isolation']}; +} diff --git a/ext/demo-site/test/backend-resume-probe.mjs b/ext/demo-site/test/backend-resume-probe.mjs @@ -0,0 +1,33 @@ +// Exercise actual page-facing request IDs, independently of private runtime IO. +export async function probeResume(backend){ + const worker=new Worker(new URL('dist/wasm-worker.js?backend='+backend,location.href),{type:'module'}),events=[]; + worker.onmessage=e=>events.push(e.data); + const assert=(ok,message)=>{if(!ok)throw Error(message);}; + const sleep=()=>new Promise(r=>setTimeout(r,40)); + const wait=async(predicate,start=0)=>{ + const deadline=performance.now()+10000; + while(performance.now()<deadline){const messages=events.slice(start);const failure=messages.find(x=>x.type==='error');if(failure)throw Error(failure.message);const found=messages.find(predicate);if(found)return found;await sleep();} + throw Error('Resume probe timed out'); + }; + try{ + worker.postMessage({type:'compile',source:'main = t "tricuIO" (t 1 (t 11 t))'}); + await wait(x=>x.type==='compiled'); + const run={type:'run',export:'main',arguments:[],output:'string',mode:'io',step:true}; + worker.postMessage(run);const step=await wait(x=>x.type==='state'&&x.state==='step'); + worker.postMessage({type:'resume',id:step.id,value:'wrong-kind'}); + worker.postMessage({type:'resume',id:step.id+999});await sleep(); + assert(!events.some(x=>x.type==='error'||x.type==='done'||x.state==='input'),'Malformed/stale step consumed pending wait'); + worker.postMessage({type:'resume',id:step.id});const input=await wait(x=>x.type==='state'&&x.state==='input'); + for(const value of [undefined,'bad\nline','\ud800','a'.repeat(257)])worker.postMessage({type:'resume',id:input.id,value}); + worker.postMessage({type:'resume',id:step.id,value:'stale'});await sleep(); + assert(!events.some(x=>x.type==='error'||x.type==='done'),'Invalid line consumed pending wait'); + worker.postMessage({type:'resume',id:input.id,value:'λ雪🙂'}); + assert((await wait(x=>x.type==='done')).result.value==='λ雪🙂','Resume value mismatch'); + const mark=events.length;worker.postMessage({...run,step:false}); + const next=await wait(x=>x.type==='state'&&x.state==='input',mark);assert(next.id>input.id,'Request IDs reused'); + worker.postMessage({type:'resume',id:input.id,value:'stale'});await sleep(); + assert(!events.slice(mark).some(x=>x.type==='done'),'Previous run token accepted'); + worker.postMessage({type:'resume',id:next.id,value:'fresh'}); + assert((await wait(x=>x.type==='done',mark)).result.value==='fresh','Fresh run failed'); + }finally{worker.terminate();} +} diff --git a/ext/demo-site/test/backend-server.mjs b/ext/demo-site/test/backend-server.mjs @@ -0,0 +1,57 @@ +// Loopback-only test origin with bounded, token-authenticated fault/report routes. +import {createServer} from 'node:http'; +import {readFile} from 'node:fs/promises'; +import {resolve,extname,sep} from 'node:path'; +import {randomBytes} from 'node:crypto'; +import {fixtures} from './zig-negative-fixtures.mjs'; +export async function startSiteServer(directory,autoRun=false) { + const root=resolve(process.env.ARBORICX_SITE_ROOT||'ext/demo-site'),runtime=resolve(directory),registry=resolve('ext/demo-site/bundles'); + const control='/test-'+randomBytes(24).toString('hex'),requests=[],marks=[]; + let failRuntime=false,compilerMismatch=false,deliver,fail; + const completion=new Promise((resolve,reject)=>{deliver=resolve;fail=reject;});completion.catch(()=>{}); + const server=createServer(async(req,res)=>{ + try { + const path=new URL(req.url,'http://localhost').pathname;requests.push(path); + res.setHeader('Cache-Control','no-store'); + if(path===control+'/requests'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(requests));return;} + if(path===control+'/fault'||path===control+'/report'||path===control+'/phase') { + if(req.method!=='POST')throw Error('method'); + let text='';for await(const chunk of req){text+=chunk;if(text.length>262144)throw Error('control size');} + const data=JSON.parse(text); + if(path.endsWith('/phase'))marks.push({name:data.name,at:performance.now()}); + else if(path.endsWith('/fault')){failRuntime=data.runtime??failRuntime;compilerMismatch=data.compilerPin??compilerMismatch;} + else {data.ok?deliver(data.report):fail(Error(data.error));} + res.end('ok');return; + } + if(path==='/zig-negative.json'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(fixtures()));return;} + if(path==='/zig-fixtures.json'){res.setHeader('Content-Type','application/json');res.end(await readFile(new URL('../demos.json',import.meta.url)));return;} + if(path==='/zig-session-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./zig-session-probe.mjs',import.meta.url)));return;} + if(path==='/backend-resume-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-resume-probe.mjs',import.meta.url)));return;} + if(path==='/backend-benchmark.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-benchmark.mjs',import.meta.url)));return;} + if(path==='/backend-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-probe.mjs',import.meta.url)));return;} + if(failRuntime&&(path.endsWith('/reactor.wasm')||path.endsWith('/arboricx.wasm')))throw Error('Injected artifact failure'); + if(compilerMismatch&&path==='/nested/site/compiler.json') { + const descriptor=JSON.parse(await readFile(resolve(root,'compiler.json'))); + res.setHeader('Content-Type','application/json');res.end(JSON.stringify({...descriptor,hash:'0'.repeat(64)}));return; + } + let file; + if(path.startsWith('/_arboricx/bundle/hash/')) { + const hash=path.split('/').at(-1);if(!/^[a-f0-9]{64}$/.test(hash))throw Error('hash');file=resolve(registry,hash+'.arboricx'); + } else { + const prefix='/nested/site/';if(!path.startsWith(prefix))throw Error('mount'); + const relative=path.slice(prefix.length),base=relative.startsWith('runtime/haskell/')?runtime:root; + file=resolve(base,relative.startsWith('runtime/haskell/')?relative.slice('runtime/haskell/'.length):relative); + if(!file.startsWith(base+sep))throw Error('path'); + } + res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.mjs':'text/javascript','.wasm':'application/wasm','.json':'application/json','.css':'text/css'})[extname(file)]??'application/octet-stream'); + let bytes=await readFile(file); + if(autoRun&&path==='/nested/site/index.html')bytes=Buffer.from(bytes.toString()+`<script type="module"> + try{const m=await import(${JSON.stringify(process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} + catch(e){await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:false,error:String(e.stack||e)})});} + </script>`); + res.end(bytes); + }catch{res.writeHead(404);res.end();} + }); + await new Promise(r=>server.listen(0,'127.0.0.1',r)); + return {server,url:`http://127.0.0.1:${server.address().port}/nested/site/index.html`,control,completion,marks}; +} diff --git a/ext/demo-site/test/benchmark-backends.mjs b/ext/demo-site/test/benchmark-backends.mjs @@ -0,0 +1,27 @@ +// Run through a Nix tooling shell with Node, Chromium and Firefox available. +import {spawn} from 'node:child_process'; +import {mkdir,readFile,writeFile} from 'node:fs/promises'; +import {resolve} from 'node:path'; +import {createHash} from 'node:crypto'; +import {cpus,release} from 'node:os'; +const [site,output]=process.argv.slice(2); +if(!site||!output)throw Error('Usage: benchmark-backends.mjs PACKAGED_SITE OUTPUT_DIRECTORY'); +await mkdir(output,{recursive:true}); +const reports=[],rounds=Number(process.env.BENCHMARK_ROUNDS??3); +if(!Number.isInteger(rounds)||rounds<1||rounds>10)throw Error('BENCHMARK_ROUNDS must be 1–10'); +for(const browser of ['chromium','firefox'])for(let round=0;round<rounds;round++)for(const backend of round%2?['haskell-wasm','zig-wasm']:['zig-wasm','haskell-wasm']){ + const file=resolve(output,`${browser}-${backend}-${round}.json`); + console.log('Benchmark',browser,backend,round); + const child=spawn(process.execPath,[`ext/demo-site/test/backend-${browser==='chromium'?'browser':'firefox'}.mjs`,resolve(site,'runtime/haskell'),browser],{ + env:{...process.env,ARBORICX_SITE_ROOT:resolve(site),TEST_BACKEND:backend,TEST_BENCHMARK:'1',BENCHMARK_REPORT:file},stdio:'inherit', + }); + const code=await new Promise((r,j)=>{child.on('error',j);child.on('exit',r);});if(code!==0)throw Error('Benchmark failed'); + reports.push({browser,round,...JSON.parse(await readFile(file,'utf8'))}); +} +const artifacts={}; +for(const [backend,name] of [['zig','arboricx'],['haskell','reactor']]){ + const bytes=await readFile(resolve(site,`runtime/${backend}/${name}.wasm`));artifacts[backend]={bytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')}; +} +const fixtures={}; +for(const name of ['demos.json','catalogue.json','compiler.json','environments.json']){const bytes=await readFile(resolve(name==='demos.json'?'ext/demo-site':site,name));fixtures[name]=createHash('sha256').update(bytes).digest('hex');} +await writeFile(resolve(output,'report.json'),JSON.stringify({rounds,memoryOnly:!!process.env.BENCHMARK_PSS,fixtures,site:resolve(site),platform:release(),cpu:cpus()[0]?.model,node:process.version,artifacts,reports},null,2)+'\n'); diff --git a/ext/demo-site/test/browser-memory.mjs b/ext/demo-site/test/browser-memory.mjs @@ -0,0 +1,29 @@ +// Linux process-tree RSS sampling. Shared pages are double-counted; not PSS, +// private memory or a hard peak. No forced GC and no evaluator instrumentation. +import {readdir,readFile} from 'node:fs/promises'; +export function sampleBrowser(root,marks=[]){ + const samples=[];let active=true,task; + async function sample(){ + const rows=[]; + const fields=text=>Object.fromEntries(text.split('\n').filter(x=>x.includes(':')).map(x=>{const at=x.indexOf(':');return [x.slice(0,at),x.slice(at+1).trim()];})); + const own=fields(await readFile('/proc/self/status','utf8')); + for(const pid of (await readdir('/proc')).filter(x=>/^\d+$/.test(x))){ + try{const f=fields(await readFile('/proc/'+pid+'/status','utf8'));rows.push({pid:Number(f.Pid),parent:Number(f.PPid),local:Number(f.NSpid?.split(/\s+/).at(-1)??f.Pid),rss:Number(f.VmRSS?.split(/\s+/)[0]??0)*1024});}catch{} + } + // /proc may be mounted from an outer PID namespace. Locate our direct child + // using both the host parent PID and the child's namespace-local PID. + const hostRoot=rows.find(r=>r.parent===Number(own.Pid)&&r.local===root)?.pid; + const family=new Set(hostRoot===undefined?[]:[hostRoot]);let changed=true; + while(changed){changed=false;for(const r of rows)if(family.has(r.parent)&&!family.has(r.pid)){family.add(r.pid);changed=true;}} + const found=rows.filter(r=>family.has(r.pid));const current=marks.at(-1); + let pssBytes=null,privateBytes=null; + if(process.env.BENCHMARK_PSS&&current&&(current.name.startsWith('live-')||['baseline','final-idle'].includes(current.name))){ + let pss=0,priv=0,complete=found.length>0; + for(const r of found){try{const f=fields(await readFile('/proc/'+r.pid+'/smaps_rollup','utf8'));pss+=Number(f.Pss.split(/\s+/)[0])*1024;priv+=(Number(f.Private_Clean.split(/\s+/)[0])+Number(f.Private_Dirty.split(/\s+/)[0]))*1024;}catch{complete=false;}} + if(complete){pssBytes=pss;privateBytes=priv;} + } + samples.push({at:performance.now(),phase:current?.name,rssBytes:found.length?found.reduce((s,r)=>s+r.rss,0):null,processes:found.length,pssBytes,privateBytes}); + } + task=(async()=>{while(active){await sample();await new Promise(r=>setTimeout(r,100));}})(); + return async marks=>{active=false;await task;if(!samples.some(x=>x.rssBytes>0))throw Error('Browser RSS unavailable');return {method:'Linux /proc descendant RSS sum, ~100ms sampling (shared pages double-counted). Optional PSS/private pages sampled only during explicit idle/live memory holds, not timing samples.',samples,phases:marks.map((m,i)=>{const values=samples.filter(x=>x.at>=m.at&&x.at<(marks[i+1]?.at??Infinity)).map(x=>x.rssBytes).filter(x=>x!==null);return {...m,maxRssBytes:values.length?Math.max(...values):null,minRssBytes:values.length?Math.min(...values):null};})};}; +} diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -// Opt-in Chromium CDP integration, dependency-free. Uses a fresh temporary profile. +// Opt-in JS-reference Chromium integration, dependency-free. Uses a fresh profile. +// The backend-selector suites separately exercise the unchanged Haskell default. // node ext/demo-site/test/browser.mjs [chromium-path] [optional-screenshot.png] import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; @@ -81,7 +82,12 @@ const server = createServer(async (request, response) => { const file = path.endsWith(sep) || path === root ? resolve(path, 'index.html') : path; const types = { '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.wasm': 'application/wasm' }; response.setHeader('Content-Type', types[extname(file)] || 'text/plain'); - response.end(await readFile(file)); + let content=await readFile(file); + // Explicitly select the reference backend before deferred app initialization. + // This suite tests JS checkpoints/counters, not the production default. + if(extname(file)==='.html')content=Buffer.from(content.toString().replace('</body>', + '<script>document.getElementById("runtime-backend").value="js";</script></body>')); + response.end(content); } catch { response.writeHead(404); response.end('Not found'); } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); @@ -187,8 +193,6 @@ try { for(const id of ['tab-export-definition','tab-how','tab-playground']) {await click(id);heights.push(await assertPaneFits());} assert.ok(new Set(heights.map(Math.round)).size>1,'height changes between tabs'); assert.equal(await evaluate("document.querySelectorAll('[role=tab]').length"),3); - assert.deepEqual(await evaluate("['nodes','fuel','stack','seconds'].map(k=>document.getElementById('package-limit-'+k).value)"),['250000','20000000','50000','10']); - assert.deepEqual(await evaluate("['nodes','fuel','stack','seconds'].map(k=>document.getElementById('compiler-limit-'+k).value)"),['6000000','0','100000','0']); assert.equal(await evaluate("document.getElementById('program-picker').value"),'demo:intro'); assert.deepEqual(await evaluate("[...document.querySelectorAll('#program-picker optgroup')].map(g=>g.label)"),['Examples','Published packages']); assert.equal(await evaluate("document.querySelector('#program-picker option[value=hash]').textContent"),'Load by bundle hash…'); @@ -245,7 +249,8 @@ try { assert.equal(await evaluate("document.getElementById('run').disabled"),true); await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); console.log('PASS unified Program selector / visible hash option / mutually exclusive inputs / pending selections shared with Export / invalid hash fails closed'); - console.log('PASS Ecosystem default / deferred package load / ARIA keyboard tabs / dynamic active height / editable default limits'); + assert.equal(await evaluate("document.querySelectorAll('.limits-editor,#uncapped,#compiler-uncapped').length"),0); + console.log('PASS Ecosystem default / deferred package load / ARIA keyboard tabs / dynamic active height / no resource editors'); assert.equal(requests.some(path=>path.endsWith('/compiler.json')),false); assert.equal(await evaluate("document.getElementById('compile').disabled"),true); assert.equal(await evaluate("document.getElementById('export').value"),'answer'); @@ -259,9 +264,6 @@ try { if(expected.endsWith('tree graph'))assert.ok(resultText.startsWith(expected)); else assert.equal(resultText,expected); } - assert.equal(await evaluate("document.getElementById('uncapped').checked"),true); - assert.equal(await evaluate("document.getElementById('compiler-uncapped').checked"),true); - assert.equal(await evaluate("document.getElementById('uncapped').closest('details').open"),false); await evaluate("document.getElementById('export').value='identity';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('argument-0').value='\"field input\"';document.getElementById('argument-0').dispatchEvent(new Event('input'))"); await click('run');await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "field input"'); @@ -322,7 +324,7 @@ try { assert.ok(Math.abs(widths.source-widths.execution)<4,'source uses the full program width'); return layout.height; }; assert.equal(await evaluate("document.getElementById('source-section').open"),true); - assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),true); + assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),false); assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Source and editing'); assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Read and edit source'); assert.equal(await evaluate("document.getElementById('source-availability').hidden"),true); @@ -346,7 +348,7 @@ try { await evaluate("document.getElementById('program-picker').value='demo:parser';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Source (read-only)'); assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Read-only source'); - assert.equal(await evaluate("document.getElementById('edit-source').hidden"),true); + assert.equal(await evaluate("document.getElementById('edit-source')"),null); assert.equal(await evaluate("document.getElementById('compile').hidden"),true); assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true); assert.match(await evaluate("document.getElementById('source').textContent"),/parserSepBy1/); @@ -386,7 +388,7 @@ try { await evaluate("document.getElementById('program-picker').value='package:tricu-compiler';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.5';document.getElementById('package-version').dispatchEvent(new Event('change'))"); await click('load-release');await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Package details'); - assert.equal(await evaluate("document.getElementById('edit-source').hidden"),true); + assert.equal(await evaluate("document.getElementById('edit-source')"),null); assert.equal(await evaluate("document.getElementById('export').options.length"),1191); assert.match(await evaluate("document.getElementById('package-info').textContent"),new RegExp(compilerDescriptor.hash)); const indexedCompilerRequests=requests.filter(p=>p.endsWith('/dist/compiler-worker.js')).length; @@ -411,7 +413,7 @@ try { assert.equal(await evaluate("document.getElementById('export').value"),'root'); }else assert.match(await evaluate("document.getElementById('result').textContent"),/download limit/); } - console.log('PASS 16 MiB package boundary / over-limit rejection with optional execution guards off'); + console.log('PASS 16 MiB package boundary / over-limit rejection without execution settings'); console.log('PASS lexer positions / pure arithmetic / indexed compiler exact pin and DAG export / compiler hash loading / native builder identity'); for(const [demo,exports] of [['shipping',['v1','v2']],['events',['vendorA','vendorB']],['registration',['main']],['migration',['v1','v2']]]){ await evaluate(`document.getElementById('program-picker').value='demo:${demo}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); @@ -447,7 +449,7 @@ try { if(data.type==='done'){clearTimeout(timer);w.terminate();resolve({compilation,result:data.result,uncapped:data.stats.fuel===Infinity});} if(data.type==='error'){clearTimeout(timer);w.terminate();reject(Error(data.message));} }; - w.postMessage({type:'compile',source:'id = (x : x)',uncapped:true,limits:{nodes:2,fuel:1,stack:1,seconds:1}}); + w.postMessage({type:'compile',source:'id = (x : x)'}); })`); assert.deepEqual(compilerGate.compilation.names,['id']);assert.equal(compilerGate.result.value,'42');assert.equal(compilerGate.uncapped,true); assert.ok(compilerGate.compilation.stats.collections>0); @@ -460,10 +462,8 @@ try { await click('run');await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned bool: true'); console.log('PASS curated microscope / multiple exports / repeated runs'); - await evaluate("document.getElementById('uncapped').checked=true;document.getElementById('uncapped').dispatchEvent(new Event('change'))"); - await wait(status('Ready'));await click('run');await wait(status('Finished')); + await click('run');await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned bool: true'); - assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Optional limits (off)'); const unlimitedPackage=await evaluate(`new Promise((resolve,reject)=>{ const w=new Worker(new URL('./dist/worker.js',location.href),{type:'module'}); w.onmessage=({data})=>{ @@ -471,38 +471,10 @@ try { if(data.type==='done'){w.terminate();resolve({result:data.result,fuel:data.stats.fuel===Infinity,collections:data.stats.collections});} if(data.type==='error'){w.terminate();reject(Error(data.message));} }; - w.postMessage({type:'load',hash:${JSON.stringify(published.id)},uncapped:true,limits:{nodes:2,fuel:1,stack:1,seconds:1}}); + w.postMessage({type:'load',hash:${JSON.stringify(published.id)}}); })`); assert.equal(unlimitedPackage.result.value,'42');assert.equal(unlimitedPackage.fuel,true);assert.ok(unlimitedPackage.collections>0); - await evaluate("document.getElementById('uncapped').checked=false;document.getElementById('uncapped').dispatchEvent(new Event('change'))");await wait(status('Ready')); - console.log('PASS package limits toggle / actual unlimited Worker / GC / restore defaults'); - const customWorker=async (limits,compiler=false)=>evaluate(`new Promise(resolve=>{ - const w=new Worker(new URL('./dist/'+${JSON.stringify(compiler?'compiler-worker.js':'worker.js')},location.href),{type:'module'}); - w.onmessage=({data})=>{ - if(data.type==='loaded')w.postMessage({type:'run',export:'id',arguments:[{type:'number',value:'42'}],output:'number',mode:'pure',step:false}); - if(data.type==='error'||data.type==='done'||data.type==='compiled'){w.terminate();resolve(data);} - }; - w.postMessage(${JSON.stringify(compiler?{type:'compile',source:'id = (x : x)',limits}:{type:'load',hash:published.id,limits})}); - })`); - const custom=await customWorker({nodes:300000,fuel:3000000,stack:60000,seconds:3}); - assert.equal(custom.result.value,'42');assert.equal(custom.stats.fuel,3000000-custom.stats.work); - for(const limits of [{nodes:1,fuel:10000,stack:50,seconds:1},{nodes:10000,fuel:1,stack:50,seconds:1}]) { - const error=await customWorker(limits);assert.equal(error.type,'error');assert.match(error.message,/allocation|fuel/); - } - const invalid=await customWorker({nodes:-1,fuel:100,stack:50,seconds:1});assert.match(invalid.message,/nodes/); - const customCompiler=await customWorker({nodes:500000,fuel:90000000,stack:200000,seconds:0},true); - assert.deepEqual(customCompiler.names,['id']);assert.equal(customCompiler.stats.fuel,90000000-customCompiler.stats.work); - const smallCompiler=await customWorker({nodes:100,fuel:10000,stack:50,seconds:0},true); - assert.equal(smallCompiler.type,'error');assert.match(smallCompiler.message,/allocation/); - // Draft/invalid edits preserve the loaded heap until a valid Apply. - await evaluate("document.getElementById('package-limit-nodes').value='0';document.getElementById('package-limit-nodes').dispatchEvent(new Event('input'));document.querySelector('#playground .limits-editor').requestSubmit()"); - assert.equal(await evaluate("document.getElementById('status').textContent"),'Ready'); - assert.match(await evaluate("document.querySelector('#playground .limits-error').textContent"),/nodes/); - await evaluate("document.getElementById('package-limit-nodes').value='300000';document.querySelector('#playground .limits-editor').requestSubmit()");await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('package-limits-summary').textContent"),'Optional limits (custom)'); - await evaluate("document.querySelector('#playground .limits-editor button[type=button]').click()");await wait(status('Ready')); - assert.equal(await evaluate("document.getElementById('package-limit-nodes').value"),'250000'); - console.log('PASS custom Worker budgets / finite fuel / small GC thresholds / malformed settings / Apply validation / restore'); + console.log('PASS package Worker has no application ceilings / GC remains active'); @@ -520,14 +492,12 @@ try { await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('terminal').textContent"), 'What is your name?\nHello, λ雪🙂!\n'); console.log('PASS packaged greeting / Unicode / continuation handles'); - await evaluate("document.getElementById('uncapped').checked=true;document.getElementById('uncapped').dispatchEvent(new Event('change'))");await wait(status('Ready')); await click('run');await wait(status('Waiting for input')); await evaluate("document.getElementById('line-input').value='λ雪🙂';document.getElementById('input-form').requestSubmit()");await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('terminal').textContent"),'What is your name?\nHello, λ雪🙂!\n'); await click('run');await wait(status('Waiting for input'));await click('tab-how'); assert.equal(await evaluate("document.getElementById('input-form').hidden"),true); await click('tab-playground'); - await evaluate("document.getElementById('uncapped').checked=false;document.getElementById('uncapped').dispatchEvent(new Event('change'))");await wait(status('Ready')); console.log('PASS unlimited package owned-IO continuations / Unicode / switching cancels pending input'); @@ -619,9 +589,12 @@ try { await evaluate(`document.getElementById('export').value='f';document.getElementById('arguments').value='[{"type":"tree","value":"212110021100"}]';document.getElementById('output-type').value='bool'`); await click('run');await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned bool: false'); - await evaluate("document.getElementById('export').value='q'");await click('run');await wait(status('Stopped')); - assert.match(await evaluate("document.getElementById('result').textContent"),/limit exceeded/); - console.log('PASS exponential DAG / lazy discarded divergence / actual divergent program budget'); + await evaluate("document.getElementById('export').value='q'");await click('run');await wait(status('Running')); + await new Promise(resolve=>setTimeout(resolve,100)); + assert.equal(await evaluate("document.getElementById('stop').disabled"),false); + await click('stop');await wait(status('Stopped')); + assert.match(await evaluate("document.getElementById('result').textContent"),/Stopped/); + console.log('PASS exponential DAG / lazy discarded divergence / hard Stop of actual divergent program'); for(const [hash,expected] of [['f'.repeat(64),/SHA-256 mismatch/],['a'.repeat(64),/download limit/],[published.invalid,/reserved/]]){ await evaluate(`document.getElementById('program-picker').value='hash';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('bundle-hash').value=${JSON.stringify(hash)};document.getElementById('program-hash').requestSubmit()`); @@ -640,7 +613,7 @@ try { assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true,'library release is not a curated editable demo'); assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Package details'); assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Compiled package (no editable source)'); - assert.equal(await evaluate("document.getElementById('edit-source').hidden"),true); + assert.equal(await evaluate("document.getElementById('edit-source')"),null); assert.equal(await evaluate("document.getElementById('source-availability').hidden"),false); assert.match(await evaluate("document.getElementById('source-availability').textContent"),/Editable source is not included/); await evaluate("document.getElementById('export').value='showNumber';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]';document.getElementById('output-type').value='string';document.getElementById('mode').value='pure'"); @@ -700,9 +673,10 @@ try { await evaluate("document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); console.log('PASS prominent shared download toolbar / all demo versions visible / selected version and direct hash download without Load / no implicit execution'); await evaluate("URL.createObjectURL=window.__createURL;document.removeEventListener('click',window.__bundleClick)"); - await click('edit-source'); + assert.equal(await evaluate("document.getElementById('edit-source')"),null); + assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),false); - assert.equal(await evaluate("document.getElementById('run').textContent"),'Run published','opening the editor alone does not replace the executable'); + assert.equal(await evaluate("document.getElementById('run').textContent"),'Run published','directly editable source does not replace the executable'); const beforeDraftEdit=requests.filter(p=>p.endsWith('/compiler.json')).length; await evaluate("document.getElementById('source-editor').value=document.getElementById('source-editor').value.replace('first 42 7','first 43 7');document.getElementById('source-editor').dispatchEvent(new Event('input'))"); assert.match(await evaluate("document.getElementById('authorship').textContent"),/Local draft/); @@ -714,14 +688,17 @@ try { assert.equal(await evaluate("document.getElementById('compile').disabled"),false); await evaluate("document.getElementById('run').onclick()"); assert.equal(requests.filter(p=>p.endsWith('/compiler.json')).length,beforeDraftEdit,'Run cannot compile an uncompiled draft'); - assert.match(await evaluate("document.getElementById('compiler-disclosure').textContent"),/limits under Advanced/); + assert.equal(await evaluate("document.getElementById('compiler-disclosure')"),null); + assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),true); await click('compile');await wait(status('Compiled')+' || '+status('Stopped'),60); + assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); await click('run');await wait(status('Finished')+' || '+status('Stopped'),60); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "43"'); console.log('PASS curated intro edit with explicit empty environment',await evaluate("document.getElementById('compiler-stats').textContent")); // Edits keep the last successful definitions runnable until a compilation // fails. Failed compilation must never fall back to the earlier 43 or published 42. await evaluate("document.getElementById('source-editor').value='answer = (';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); + assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),true); assert.equal(await evaluate("document.getElementById('export').disabled"),false); assert.equal(await evaluate("document.getElementById('run').disabled"),false); await click('run');await wait(status('Finished')); @@ -732,6 +709,7 @@ try { await click('restore-source');await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('source-editor').value"),publishedSource); assert.match(await evaluate("document.getElementById('authorship').textContent"),/Published/); + assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); await click('run');await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); // All practical demos use a prebaked support package, not browser imports. @@ -739,6 +717,7 @@ try { await evaluate(`document.getElementById('program-picker').value='demo:${demo}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); await evaluate("document.getElementById('source-editor').value='answer = stringField \"status\" \"edited\"';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('arguments').value='[]';document.getElementById('output-type').value='list'"); await click('compile');await wait(status('Compiled')+' || '+status('Stopped'),60); + assert.equal(await evaluate("document.getElementById('source-editor').classList.contains('modified')"),false); await click('run');await wait(status('Finished')+' || '+status('Stopped'),60); const result=await evaluate("document.getElementById('result').textContent"); assert.match(result,/^Returned list:/,demo+': '+result); @@ -753,7 +732,6 @@ try { await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); // Compiler UI: checkpoint transport preserves lazy DAGs, never flat source. const compilerStatus=text=>`document.getElementById('status').textContent===${JSON.stringify(text)}`; - await evaluate("document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); await click('compile');await wait(compilerStatus('Compiled')); assert.equal(await evaluate("document.getElementById('export').value"),'id'); // Bad arguments leave the costly compiled heap available for correction. @@ -776,22 +754,12 @@ try { await click('run');await wait(compilerStatus('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); } - // A failed bounded run and subsequent limit change also retain the checkpoint. - await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'));document.getElementById('compiler-limit-fuel').value='1';document.querySelector('#compiler-settings .limits-editor').requestSubmit()"); - await click('run');await wait(compilerStatus('Stopped')); - assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await evaluate("document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); - await click('run');await wait(compilerStatus('Finished')); - assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - - // Even termination by the main-page deadline must leave runnable definitions. - await evaluate(`document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'));document.getElementById('compiler-limit-fuel').value='0';document.getElementById('compiler-limit-seconds').value='1';document.querySelector('#compiler-settings .limits-editor').requestSubmit(); - globalThis.savedDraftPost=Worker.prototype.postMessage; + // Hard Stop during a stalled run must retain runnable definitions. + await evaluate(`globalThis.savedDraftPost=Worker.prototype.postMessage; Worker.prototype.postMessage=function(message,...args){if(message.type!=='run')return globalThis.savedDraftPost.call(this,message,...args);}`); - await click('run');await wait(compilerStatus('Stopped'),5); - assert.match(await evaluate("document.getElementById('result').textContent"),/^Program execution stopped: active time limit exceeded\.$/); + await click('run');await wait(compilerStatus('Running'));await click('stop'); assert.equal(await evaluate("document.getElementById('run').disabled"),false); - await evaluate("Worker.prototype.postMessage=globalThis.savedDraftPost;delete globalThis.savedDraftPost;document.getElementById('compiler-limit-seconds').value='0';document.querySelector('#compiler-settings .limits-editor').requestSubmit();document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); + await evaluate("Worker.prototype.postMessage=globalThis.savedDraftPost;delete globalThis.savedDraftPost"); for(const value of ['7','99']){ await evaluate(`document.getElementById('arguments').value='[{"type":"number","value":"${value}"}]'`); await click('run');await wait(compilerStatus('Finished')); @@ -800,16 +768,13 @@ try { } assert.equal(requests.filter(path=>path.endsWith('/compiler.json')).length,compiledRequests); await evaluate("document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]'"); - assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/optional limits off/); - await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); - await evaluate("document.getElementById('compiler-limit-fuel').value='0';document.querySelector('#compiler-settings .limits-editor').requestSubmit()"); - console.log('PASS uncapped compiler UI / corrected arguments / repeated values without recompile / profile disposal'); + assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/work units/); + console.log('PASS compiler without resource settings / corrected arguments / repeated values without recompile / Worker disposal'); assert.equal(await evaluate("document.getElementById('source-editor').maxLength"),32768); - await evaluate("document.getElementById('source-editor').value='--'+ 'x'.repeat(300)+'\\nid = (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'));document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); + await evaluate("document.getElementById('source-editor').value='--'+ 'x'.repeat(300)+'\\nid = (x : x)';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); await click('compile');await wait(compilerStatus('Compiled'),60); await click('run');await wait(compilerStatus('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); console.log('PASS source beyond former 256 limit / no main-thread graph encoding cap'); for(const example of ['pair','definitions']){ @@ -826,20 +791,16 @@ try { await evaluate("document.querySelector('[data-compiler-example=contracts]').click()"); await click('compile');await wait(compilerStatus('Compiled'));await click('run');await wait(compilerStatus('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); - await evaluate("document.getElementById('compiler-limit-fuel').value='80000000';document.querySelector('#compiler-settings .limits-editor').requestSubmit()"); - await click('compile');await wait(compilerStatus('Compiled')); let previousTotal=0; for(const value of ['12','13']){ await evaluate(`document.getElementById('arguments').value='[{"type":"number","value":"${value}"}]'`); await click('run');await wait(compilerStatus('Finished')); const stats=await evaluate("document.getElementById('compiler-stats').textContent"); const total=Number(stats.match(/([0-9,]+) cumulative work/)[1].replaceAll(',','')); - const remaining=Number(stats.match(/([0-9,]+) run work remaining/)[1].replaceAll(',','')); assert.ok(total>previousTotal);previousTotal=total; - assert.ok(remaining>79_000_000&&remaining<=80_000_000,'run has its own work budget, excluding compilation and prior runs'); assert.equal(await evaluate("document.getElementById('result').textContent"),`Returned number: "${value}"`); } - console.log('PASS separate compile/run work allowances / cumulative counters'); + console.log('PASS cumulative compile/run work counters without ceilings'); await evaluate("document.getElementById('arguments').value='[{\"type\":\"tree\",\"value\":\"10\"}]';document.getElementById('output-type').value='tree'"); await click('run');await wait(compilerStatus('Finished')); assert.match(await evaluate("document.getElementById('graph-result').textContent"),/fork/); @@ -918,7 +879,7 @@ try { assert.equal(await evaluate("document.getElementById('status').textContent"),'Stopped'); await click('reset'); assert.equal(await evaluate("document.getElementById('compile').disabled"),false); - console.log('PASS compiler syntax/name diagnostics / descriptor and pin integrity / source cap / replacement / stop/reset/profile toggle'); + console.log('PASS compiler syntax/name diagnostics / descriptor and pin integrity / source cap / replacement / stop/reset recovery'); // Stall real operation messages and advance the clock only at its scheduled // 60-second boundary, avoiding a minute of test time per load/download case. @@ -950,9 +911,9 @@ try { assert.match(await evaluate("document.getElementById('result').textContent"),/^Bundle download timed out\./); assert.equal(await evaluate('globalThis.stalledWorkerTerminated'),true); await send('Page.removeScriptToEvaluateOnNewDocument',{identifier:loadTimeoutScript.identifier}); - console.log('PASS loading deadlines in Playground/Export with guards off / download deadline / phase-specific messages / worker termination'); + console.log('PASS loading deadlines in Playground/Export / download deadline / phase-specific messages / worker termination'); - // A genuinely CPU-bound Worker gets a finite active timeout. No production hook. + // A genuinely CPU-bound Worker stays running until Stop or pane cancellation. No production hook. const watchdogScript = await send('Page.addScriptToEvaluateOnNewDocument', { source: `const NativeWorker = globalThis.Worker; globalThis.Worker = class extends NativeWorker { @@ -963,18 +924,6 @@ try { terminate() { this.spin?.terminate(); super.terminate(); globalThis.testWorkerTerminated = true; } };` }); await navigate('Page.reload'); await openPlayground(); - await evaluate("document.getElementById('uncapped').checked=false;document.getElementById('uncapped').dispatchEvent(new Event('change'))");await wait(status('Ready')); - await click('run'); await wait(status('Stopped'), 13); - assert.match(await evaluate("document.getElementById('result').textContent"), /Program execution stopped: active time limit exceeded/); - assert.equal(await evaluate('globalThis.testWorkerTerminated'), true); - console.log('PASS hard main-page watchdog / termination of CPU-bound Worker'); - await evaluate("document.getElementById('package-limit-seconds').value='1';document.querySelector('#playground .limits-editor').requestSubmit()");await wait(status('Ready')); - await click('run');await wait(status('Stopped'),3); - assert.match(await evaluate("document.getElementById('result').textContent"),/Program execution stopped: active time limit exceeded/); - await evaluate("document.querySelector('#playground .limits-editor button[type=button]').click()");await wait(status('Ready')); - console.log('PASS editable package active-second deadline'); - - await evaluate("document.getElementById('uncapped').checked=true;document.getElementById('uncapped').dispatchEvent(new Event('change'))");await wait(status('Ready')); await click('run');await new Promise(resolve=>setTimeout(resolve,11_000)); assert.equal(await evaluate("document.getElementById('status').textContent"),'Running'); await click('tab-how');assert.equal(await evaluate("document.getElementById('status').textContent"),'Stopped'); @@ -995,7 +944,7 @@ try { await new Promise(resolve=>setTimeout(resolve,500)); assert.match(await evaluate("document.getElementById('tree-provenance').textContent"),/Stopped/); assert.equal(await evaluate("document.getElementById('tree-preview').hidden"),true); - console.log('PASS export watchdog even with execution uncapped / visible errors / Stop / pane cancellation'); + console.log('PASS export watchdog independent of execution / visible errors / Stop / pane cancellation'); // A CPU-bound compiler remains running past the former 30-second deadline; @@ -1007,7 +956,6 @@ try { };`}); await navigate('Page.reload');await openPlayground(); await evaluate("document.querySelector('[data-compiler-example=identity]').click()"); - await evaluate("document.getElementById('compiler-uncapped').checked=true;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'))"); await click('compile'); await new Promise(resolve=>setTimeout(resolve,31_000)); assert.equal(await evaluate("document.getElementById('status').textContent"),'Compiling'); @@ -1020,14 +968,6 @@ try { assert.equal(await evaluate("document.getElementById('status').textContent"),'Stopped'); assert.equal(await evaluate('globalThis.compilerWorkerTerminated'),true); console.log('PASS leaving compiler pane terminates active compilation'); - await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'));document.getElementById('compiler-limit-seconds').value='1';document.querySelector('#compiler-settings .limits-editor').requestSubmit()"); - await click('compile');await wait(compilerStatus('Stopped'),3); - assert.match(await evaluate("document.getElementById('result').textContent"),/Draft compilation stopped: active time limit exceeded/); - await evaluate("document.querySelector('#compiler-settings .limits-editor button[type=button]').click()"); - assert.equal(await evaluate("document.getElementById('compiler-limit-seconds').value"),'0'); - console.log('PASS editable compiler active-second deadline / restore unlimited default'); - - await send('Page.removeScriptToEvaluateOnNewDocument',{identifier:compilerFaultScript.identifier}); // Reload a real Worker page, check narrow layout and optionally save a screenshot. @@ -1108,17 +1048,12 @@ try { assert.ok(tabRects.every(r=>Math.abs(r.width-tabRects[0].width)<1)); console.log('PASS responsive 320-1440px / Leaf midpoint / centered mobile CTA / header targets / legible inputs / unchanged desktop arrangement'); await send('Emulation.setDeviceMetricsOverride',{width:320,height:844,deviceScaleFactor:1,mobile:true}); - for(const id of ['tab-playground']) { - await click(id);await evaluate("document.querySelector('#playground .run-tools').open=true");const before=await paneHeight(); - await evaluate("document.querySelector('.workspace-panel.is-active .execution-limits').open=true"); - assert.ok((await paneHeight())>before);await assertPaneFits(); - assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true); - await evaluate("document.querySelector('.workspace-panel.is-active .execution-limits').open=false"); - assert.ok((await paneHeight())<before+1); - } + await click('tab-playground'); + await evaluate("document.querySelector('#playground .run-tools').open=true");await assertPaneFits(); + assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true); + assert.equal(await evaluate("document.querySelectorAll('.limits-editor,.execution-limits').length"),0); await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true});await click('tab-how'); - console.log('PASS limit editors expand/shrink active pane / narrow-screen fields'); - + console.log('PASS narrow-screen Advanced without resource editors'); assert.equal(requests.some(path=>path.startsWith(prefix+'bundles/')),false,'no static bundle fallback'); for(const entry of [...catalogue,compilerDescriptor])assert.ok(requests.includes('/_arboricx/bundle/hash/'+entry.hash),'dependency fetched from registry'); @@ -1175,7 +1110,7 @@ try { assert.equal(await evaluate("document.querySelector('.draft-recovery,#recover-draft,#forget-draft,#download-draft-source')"),null); await evaluate("document.querySelector('[data-compiler-example=identity]').click();document.getElementById('source-editor').value='id = (x : x)\\n-- page-local draft';document.getElementById('source-editor').dispatchEvent(new Event('input'))"); await click('compile');await wait(compilerStatus('Compiled')); - assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/optional limits off/); + assert.match(await evaluate("document.getElementById('compiler-stats').textContent"),/work units/); const liveSource=await evaluate("document.getElementById('source-editor').value"); await click('run');await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned number: "42"'); diff --git a/ext/demo-site/test/budgets.mjs b/ext/demo-site/test/budgets.mjs @@ -0,0 +1,3 @@ +// Finite reference-test/cost-screen budgets, not browser execution defaults. +export const COMPILER_BUDGET = Object.freeze({nodes:6_000_000,fuel:Infinity,stack:100_000}); +export const PACKAGE_BUDGET = Object.freeze({nodes:250_000,fuel:20_000_000,stack:50_000}); diff --git a/ext/demo-site/test/compiler-conformance.mjs b/ext/demo-site/test/compiler-conformance.mjs @@ -1,3 +1,4 @@ +import {COMPILER_BUDGET} from './budgets.mjs'; // Opt-in expensive compiler execution, deliberately not a *.test.mjs/default gate. import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; @@ -5,7 +6,7 @@ import {createHash} from 'node:crypto'; import {contractEnvironment} from '../dist/contracts.js'; import {Graph} from '../dist/graph.js'; import {COMPILER_BUNDLE_LIMIT,decodeBundle,loadBundle} from '../dist/bundle.js'; -import {COMPILER_EXPORT,COMPILER_BUDGET,COMPILER_COLLECTION,sourceLiteral,compiledDefinitions} from '../dist/compiler.js'; +import {COMPILER_EXPORT,COMPILER_COLLECTION,sourceLiteral,compiledDefinitions} from '../dist/compiler.js'; import {runIO} from '../dist/io.js'; const vectors=JSON.parse(await readFile(new URL('./compiler-conformance.json',import.meta.url))); const descriptor=JSON.parse(await readFile(new URL('../compiler.json',import.meta.url))); diff --git a/ext/demo-site/test/contracts-conformance.mjs b/ext/demo-site/test/contracts-conformance.mjs @@ -1,3 +1,4 @@ +import {COMPILER_BUDGET} from './budgets.mjs'; // Opt-in source compilation against independent Haskell contract goldens. import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; @@ -5,7 +6,7 @@ import {createHash} from 'node:crypto'; import {Graph} from '../dist/graph.js'; import {COMPILER_BUNDLE_LIMIT,decodeBundle,loadBundle} from '../dist/bundle.js'; import {contractEnvironment,CONTRACT_BINDINGS} from '../dist/contracts.js'; -import {COMPILER_EXPORT,COMPILER_BUDGET,COMPILER_COLLECTION,sourceLiteral,compiledDefinitions} from '../dist/compiler.js'; +import {COMPILER_EXPORT,COMPILER_COLLECTION,sourceLiteral,compiledDefinitions} from '../dist/compiler.js'; const goldens=JSON.parse(await readFile(new URL('./contracts-conformance.json',import.meta.url))); const bytes=await readFile(new URL('../bundles/'+goldens.compilerBundleSha256+'.arboricx',import.meta.url)); const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); diff --git a/ext/demo-site/test/demo-cost.mjs b/ext/demo-site/test/demo-cost.mjs @@ -7,7 +7,8 @@ import {fileURLToPath} from 'node:url'; import {createHash} from 'node:crypto'; import {Graph} from '../dist/graph.js'; import {decodeBundle,loadBundle} from '../dist/bundle.js'; -import {PACKAGE_BUDGET,PACKAGE_COLLECTION} from '../dist/execution.js'; +import {PACKAGE_BUDGET} from './budgets.mjs'; +import {PACKAGE_COLLECTION} from '../dist/execution.js'; import {runIO} from '../dist/io.js'; const current=fileURLToPath(new URL('../',import.meta.url)); if(!process.argv[2])throw Error('Select the retained reference site directory'); diff --git a/ext/demo-site/test/draft-session.test.mjs b/ext/demo-site/test/draft-session.test.mjs @@ -17,8 +17,9 @@ test('browser code has no draft persistence or retired storage module',async()=> test('source remains editable without autosave or recovery controls',async()=>{ const html=await readFile(new URL('../index.html',import.meta.url),'utf8'); assert.doesNotMatch(html,/draft-recovery|draft-save-status|recover-draft|forget-draft|download-draft-source|Draft recovery/); - for(const id of ['source-editor','edit-source','compile','stop-compile','restore-source']) + for(const id of ['source-editor','compile','stop-compile','restore-source']) assert.match(html,new RegExp(`id="${id}"`)); + assert.doesNotMatch(html,/id="edit-source"|id="compiler-disclosure"/); const readme=await readFile(new URL('../README.md',import.meta.url),'utf8'); assert.match(readme,/Drafts exist only in the open page/); assert.match(readme,/page reload\s+starts fresh/); diff --git a/ext/demo-site/test/execution.test.mjs b/ext/demo-site/test/execution.test.mjs @@ -1,38 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {Graph} from '../dist/graph.js'; -import {executionSettings,executionBudget,activeMilliseconds,collectionPolicy,defaultLimits,operationFailure,operationTimeout,PACKAGE_ACTIVE_MS} from '../dist/execution.js'; +import {BROWSER_BUDGET,collectionPolicy,operationFailure,operationTimeout,PACKAGE_LOAD_MS} from '../dist/execution.js'; import {ActiveClock} from '../dist/clock.js'; -test('editable limits preserve defaults, validated snapshots and adaptive GC',()=>{ - for(const runner of ['package','compiler']){ - const settings=executionSettings(undefined,runner); - assert.deepEqual(settings.limits,defaultLimits(runner)); - assert.deepEqual(Object.keys(executionBudget(settings)),['nodes','fuel','stack']); - assert.doesNotThrow(()=>new Graph(executionBudget(settings),collectionPolicy(executionBudget(settings)))); - assert.equal(activeMilliseconds(settings),runner==='package'?10000:Infinity); - } - const values={nodes:100,fuel:1000,stack:20,seconds:3}; - const settings=executionSettings(values,'package');values.fuel=1; - assert.equal(settings.limits.fuel,1000);assert.equal(activeMilliseconds(settings),3000); - assert.deepEqual(collectionPolicy(executionBudget(settings)),{threshold:100}); +test('browser execution has no configurable ceilings; GC is independent',()=>{ + assert.deepEqual(BROWSER_BUDGET,{nodes:Infinity,fuel:Infinity,stack:Infinity}); + const g=new Graph(BROWSER_BUDGET,collectionPolicy(BROWSER_BUDGET)); + assert.equal(g.stats.fuel,Infinity); + assert.deepEqual(collectionPolicy(BROWSER_BUDGET),{threshold:250000}); + // Finite evaluator budgets remain available to bounded reference tests. + assert.deepEqual(collectionPolicy({nodes:100}),{threshold:100}); assert.equal(collectionPolicy({nodes:1}),undefined); - const unlimited=executionSettings(settings.limits,'compiler',true); - assert.equal(activeMilliseconds(unlimited),Infinity);assert.equal(executionBudget(unlimited).fuel,Infinity); - assert.equal(executionBudget(unlimited).nodes,Infinity);assert.equal(executionBudget(unlimited).stack,Infinity); - assert.equal(executionBudget(executionSettings({...settings.limits,fuel:0},'compiler')).fuel,Infinity); - assert.equal(activeMilliseconds(executionSettings({...settings.limits,seconds:0},'package')),Infinity); -}); - -test('malformed custom limits cannot become unlimited or fall back silently',()=>{ - const defaults=defaultLimits('package'); - for(const key of ['nodes','fuel','stack'])for(const value of [...(key==='fuel'?[]:[0]),-1,1.5,NaN,Infinity,'500',true,Number.MAX_SAFE_INTEGER+1]) - assert.throws(()=>executionSettings({...defaults,[key]:value},'package'),new RegExp(key)); - for(const seconds of [-1,0.5,Infinity,Number.MAX_SAFE_INTEGER]) - assert.throws(()=>executionSettings({...defaults,seconds},'compiler'),/seconds/); - for(const raw of [null,[],{},'limits'])assert.throws(()=>executionSettings(raw,'package')); - for(const flag of [null,1,'true',{}])assert.throws(()=>executionSettings(defaults,'package',flag),/disable/); - assert.throws(()=>executionSettings({...defaults,nodes:Infinity},'package',true),/nodes/); }); function fakeClock(){ @@ -49,7 +28,7 @@ test('active deadline excludes pauses, restarts per operation and supports off', f.clock.reset(Infinity);f.clock.start();assert.equal(f.pending.size,0); assert.throws(()=>f.clock.reset(0),/deadline/); }); -test('large editable deadlines use signed-32-bit-safe timer chunks',()=>{ +test('large infrastructure deadlines use signed-32-bit-safe timer chunks',()=>{ const f=fakeClock();f.clock.reset(5000000000);f.clock.start(); assert.equal([...f.pending.values()][0].delay,2147483647); f.fire();assert.equal(f.expired,0);f.fire();assert.equal(f.expired,0); @@ -76,7 +55,7 @@ test('operation work resets preserve cumulative counters, handles and memory cap }); test('operation failures and deadlines identify the actual phase, not generic execution',()=>{ - assert.equal(PACKAGE_ACTIVE_MS,60000); + assert.equal(PACKAGE_LOAD_MS,60000); const labels={catalogue:'Program catalogue loading',loading:'Package loading',compiling:'Draft compilation',running:'Program execution',exporting:'Definition export',downloading:'Bundle download'}; for(const [operation,label] of Object.entries(labels)){ assert.equal(operationFailure(operation,'SHA-256 mismatch'),`${label} failed: SHA-256 mismatch`); diff --git a/ext/demo-site/test/graph.test.mjs b/ext/demo-site/test/graph.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; -import {executionSettings,executionBudget,collectionPolicy,PACKAGE_COLLECTION} from '../dist/execution.js'; +import {BROWSER_BUDGET,collectionPolicy,PACKAGE_COLLECTION} from '../dist/execution.js'; import { Graph } from '../dist/graph.js'; import { natural, readNatural, text, readText, line, literals, makeLiteral, view } from '../dist/codecs.js'; import { runIO } from '../dist/io.js'; @@ -73,7 +73,7 @@ for (const [name, demo] of Object.entries(data.demos)) { test(`${name}: packaged stable exports match ${demo.haskellCases.length} Haskell goldens`, async () => { const bundle=decodeBundle(await readFile(new URL(`../bundles/${demo.hash}.arboricx`,import.meta.url))); for (const golden of demo.haskellCases) { - const budget=executionBudget(executionSettings(undefined,'package')); + const budget=BROWSER_BUDGET; const g = new Graph(budget,collectionPolicy(budget,PACKAGE_COLLECTION.threshold)), h = host(golden.input); const exports=loadBundle(g,bundle); const applied=g.apply(exports.get(golden.export),golden.arguments.map(x=>g.fromTernary(x))); diff --git a/ext/demo-site/test/packages.test.mjs b/ext/demo-site/test/packages.test.mjs @@ -5,7 +5,7 @@ import {createHash} from 'node:crypto'; import {packageIndex,packageReleases,environmentProfile} from '../dist/packages.js'; import {decodeBundle,loadBundle} from '../dist/bundle.js'; import {Graph} from '../dist/graph.js'; -import {executionSettings,executionBudget} from '../dist/execution.js'; +import {BROWSER_BUDGET} from '../dist/execution.js'; const read=name=>readFile(new URL('../'+name,import.meta.url)); test('named versions and demo environments are validated and pinned',async()=>{ @@ -37,18 +37,11 @@ test('index and environment boundaries reject malformed paths, pins, duplicates assert.throws(()=>environmentProfile(descriptor,'toString'),/Unknown/); for(const names of [['bad name'],['x','x'],Array(129).fill('x'),['../x']])assert.throws(()=>environmentProfile({...descriptor,profiles:{test:{hash:r.hash,names}}},'test')); }); -test('Standard retains memory guards; explicit browser limits bypass execution guards but retain GC',()=>{ - for(const raw of [undefined,{nodes:10,fuel:0,stack:20,seconds:0}]){ - const settings=executionSettings(raw,'compiler'),budget=executionBudget(settings); - assert.equal(budget.fuel,Infinity);assert.ok(Number.isFinite(budget.nodes));assert.ok(Number.isFinite(budget.stack)); - const g=new Graph({...budget,nodes:2});g.stem(g.leaf); - assert.throws(()=>g.stem(g.leaf),/allocation/); - const browser=executionBudget(executionSettings(raw,'compiler',true)); - assert.deepEqual(browser,{nodes:Infinity,fuel:Infinity,stack:Infinity}); - const open=new Graph(browser,{threshold:2});let root=open.leaf; - for(let i=0;i<100;i++){root=open.stem(root);open.collect([root]);} - assert.ok(open.stats.nodes>10);assert.ok(open.stats.collections>0); - } +test('browser execution has no application ceilings and retains GC',()=>{ + assert.deepEqual(BROWSER_BUDGET,{nodes:Infinity,fuel:Infinity,stack:Infinity}); + const open=new Graph(BROWSER_BUDGET,{threshold:2});let root=open.leaf; + for(let i=0;i<100;i++){root=open.stem(root);open.collect([root]);} + assert.ok(open.stats.nodes>10);assert.ok(open.stats.collections>0); }); test('published native libraries have authenticated complete interfaces and no fabricated demo source',async()=>{ diff --git a/ext/demo-site/test/release.py b/ext/demo-site/test/release.py @@ -33,9 +33,22 @@ def main(): assert not list(args.site.rglob('*.arboricx')) assert not (args.site / 'bundles').exists() assert all('path' not in entry for entry in [*catalogue, compiler]) - for script in ['worker.js', 'compiler-worker.js']: + # Package authentication/transport is shared by both Worker drivers now. + for script in ['worker.js', 'wasm-worker.js']: + source = (args.site / 'dist' / script).read_text() + assert './published-bundle.js' in source and 'bundles/' not in source + for script in ['published-bundle.js', 'compiler-worker.js', 'haskell-session.js']: source = (args.site / 'dist' / script).read_text() assert '/_arboricx/bundle/hash/' in source and 'bundles/' not in source + for backend, binary, scripts in [ + ('haskell', 'reactor.wasm', ['client.mjs', 'worker.mjs', 'bridge.mjs', 'pins.mjs']), + ('zig', 'arboricx.wasm', ['runtime.mjs', 'site-session.mjs']), + ]: + runtime = args.site / 'runtime' / backend + assert (runtime / binary).read_bytes()[:8] == b'\x00asm\x01\x00\x00\x00' + assert all((runtime / name).is_file() for name in scripts) + for name in ['shim/index.js', 'data/compiler.dag', 'data/environment.tsv', 'site/bundle.js']: + assert (args.site / 'runtime/haskell' / name).is_file() spec = importlib.util.spec_from_file_location('seed', args.seeder) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) diff --git a/ext/demo-site/test/resume.test.mjs b/ext/demo-site/test/resume.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {resumeValue} from '../dist/io.js'; +test('resume kinds and bounded Unicode lines are checked without consuming a retry',()=>{ + assert.equal(resumeValue('step',undefined),''); + for(const v of ['',false,7,null])assert.equal(resumeValue('step',v),undefined); + for(const v of [undefined,null,7,'\n','\r','\ud800','\udfff','a'.repeat(257),'🙂'.repeat(257)])assert.equal(resumeValue('input',v),undefined); + for(const v of ['','λ雪🙂','🙂'.repeat(256)])assert.equal(resumeValue('input',v),v); +}); diff --git a/ext/demo-site/test/runtime-backends.test.mjs b/ext/demo-site/test/runtime-backends.test.mjs @@ -0,0 +1,32 @@ +import {readFile} from 'node:fs/promises'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {backend,backends,runtimeStats} from '../dist/runtime-backends.js'; +import {createRuntimeSession} from '../dist/runtime-session.js'; + +test('runtime selection is explicit, fixed and fails without fallback',async()=>{ + assert.deepEqual(Object.keys(backends),['js','zig-wasm','haskell-wasm']); + assert.equal(backend('haskell-wasm'),'haskell-wasm'); + assert.deepEqual(Object.values(backends).map(x=>x.label),['JavaScript','Zig','Haskell']); + for(const name of ['toString','constructor','unknown'])assert.throws(()=>backend(name),/Unknown/); + for(const name of [null,'js','unknown'])await assert.rejects(createRuntimeSession(name,false),/Unsupported/); + assert.equal(backends.js.persistentDefinitions,false); + assert.equal(backends['haskell-wasm'].persistentDefinitions,true); + assert.equal(backends['zig-wasm'].persistentDefinitions,true); +}); +test('runtime diagnostics do not invent graph counters for WASM',()=>{ + const value=runtimeStats({backend:'haskell-wasm',linearMemoryBytes:12*1048576}); + assert.equal(value,'12.0 MiB WASM linear memory'); + assert.doesNotMatch(value,/work|heap nodes|allocated/); +}); + +test('the real page explicitly defaults to Haskell and retains alternative runtimes',async()=>{ + const html=await readFile(new URL('../index.html',import.meta.url),'utf8'); + const selector=html.match(/<select id="runtime-backend">([\s\S]*?)<\/select>/)[1]; + assert.match(selector,/^<option value="haskell-wasm" selected>Haskell<\/option>/); + assert.match(selector,/>Zig<\/option>/); + assert.match(selector,/>JavaScript<\/option>/); + assert.doesNotMatch(selector.replace(/<[^>]*>/g,''),/WASM|reference/i); + assert.equal((selector.match(/\bselected\b/g)||[]).length,1); + for(const name of ['js','zig-wasm'])assert.match(selector,new RegExp('value="'+name+'"')); +}); diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -23,24 +23,22 @@ test('site copy uses plain quotes and focuses on the Tree Calculus ecosystem',as test('demo-site build and reproduction paths follow the directory move',async()=>{ for(const file of ['README.md','build.sh','prepare.py','test/browser.mjs']){ const text=await readFile(new URL(file,here),'utf8'); - assert.doesNotMatch(text,/ext\/wasm/); + // ext/wasm now also contains the supported Zig adapter. Reject stale site + // build/fixture paths, not legitimate runtime implementation references. + assert.doesNotMatch(text,/ext\/wasm\/(?:build\.sh|prepare\.py|index\.html|test\/browser\.mjs)/); assert.match(text,/ext\/demo-site/); } }); -test('shared pane has accessible tabs and execution settings in both runners',async()=>{ +test('shared pane has accessible tabs without execution-limit controls',async()=>{ const html=await readFile(new URL('index.html',here),'utf8'); assert.equal((html.match(/role="tab"/g)||[]).length,3); assert.equal((html.match(/role="tabpanel"/g)||[]).length,3); assert.match(html,/id="tab-how"[^>]*aria-selected="true"/); - for(const id of ['package','compiler']) - assert.match(html,new RegExp(`id="${id}-limits-defaults"`)); - for(const id of ['uncapped','compiler-uncapped']) - assert.match(html,new RegExp(`id="${id}" type="checkbox"`)); - const {executionSettings,executionBudget,PACKAGE_ACTIVE_MS,EXPORT_ACTIVE_MS}=await import('../dist/execution.js'); - const budget=executionBudget(executionSettings(undefined,'compiler',true)); - assert.equal(budget.fuel,Infinity);assert.equal(budget.nodes,Infinity);assert.equal(budget.stack,Infinity); - assert.equal(PACKAGE_ACTIVE_MS,60000);assert.equal(EXPORT_ACTIVE_MS,120000); + assert.doesNotMatch(html,/limits-defaults|id="uncapped"|id="compiler-uncapped"|resource-mode|execution-limits/); + const {BROWSER_BUDGET,PACKAGE_LOAD_MS,EXPORT_ACTIVE_MS}=await import('../dist/execution.js'); + assert.deepEqual(BROWSER_BUDGET,{fuel:Infinity,nodes:Infinity,stack:Infinity}); + assert.equal(PACKAGE_LOAD_MS,60000);assert.equal(EXPORT_ACTIVE_MS,120000); }); test('header keeps Source; footer attribution matches README authors and resources',async()=>{ @@ -62,7 +60,7 @@ test('compiler copy trims helper disclosure and entry-point instruction',async() const html=await readFile(new URL('../index.html',import.meta.url),'utf8'); assert.doesNotMatch(html,/<summary>Included contract helpers<\/summary>|Select main to run your entry point/); assert.match(html,/Use \[\] for its arguments/); - assert.match(html,/Compilation and each run have separate work and active-time allowances/); + assert.match(html,/id="stop-compile"/); }); @@ -101,16 +99,16 @@ test('hero pronunciation, ecosystem order, and desktop shape alignment',async()= assert.match(css,/@media \(min-width:950px\)\s*\{\s*\.shapes\s*\{[^}]*top:70px/); }); -test('unified authoring has inline compilation notice and retains published identity',async()=>{ +test('unified authoring is directly editable without a slow-compilation warning',async()=>{ const html=await readFile(new URL('index.html',here),'utf8'); assert.doesNotMatch(html,/id="tab-compiler"/); - for(const id of ['source-editor','authorship','restore-source','compiler-disclosure','download-bundle','program-picker','package-version'])assert.match(html,new RegExp(`id="${id}"`)); + for(const id of ['source-editor','authorship','restore-source','download-bundle','program-picker','package-version'])assert.match(html,new RegExp(`id="${id}"`)); assert.doesNotMatch(html,/<dialog|compile-consent|can take minutes|Standard|tab crash/); - assert.match(html,/Compilation uses a JavaScript Tree Calculus reducer/); - assert.match(html,/limits under Advanced/); + assert.doesNotMatch(html,/compiler-disclosure|Compilation uses a JavaScript Tree Calculus reducer|can take time or extra memory/); + assert.doesNotMatch(html,/limits under Advanced/); }); -test('primary workspace is compact and guards-off defaults stay inside Advanced',async()=>{ +test('primary workspace is compact and Advanced has no resource editors',async()=>{ const html=await readFile(new URL('index.html',here),'utf8'); assert.match(html,/<select id="program-picker"/); assert.doesNotMatch(html,/role="group" aria-label="Curated packages"/); @@ -121,7 +119,7 @@ test('primary workspace is compact and guards-off defaults stay inside Advanced' assert.match(html,/Bundle hash \(SHA-256\)/); assert.match(html,/>Load bundle<\/button>/); const advanced=html.slice(html.indexOf('<details class="run-tools">'),html.indexOf('<details class="trace-panel"')); - for(const id of ['uncapped','compiler-uncapped'])assert.match(advanced,new RegExp(`id="${id}" type="checkbox" checked`)); + assert.doesNotMatch(advanced,/uncapped|resource-mode|limits-editor/); assert.match(advanced,/id="mode"/); for(const id of ['argument-fields','add-argument','raw-arguments'])assert.match(html,new RegExp(`id="${id}"`)); assert.match(html,/One literal per field/); @@ -140,8 +138,9 @@ test('source-first workspace has full-width source and explicit compilation',asy assert.ok(html.indexOf('class="trace-panel"')<html.indexOf('id="export-definition"')); assert.ok(html.indexOf('class="trace-panel"')<html.indexOf('>Export this definition →</a>')); assert.match(html,/<details id="source-section"/); - assert.match(html,/id="source-editor"[^>]*readonly/); - assert.match(html,/id="edit-source"/); + assert.doesNotMatch(html,/id="source-editor"[^>]*readonly/); + assert.doesNotMatch(html,/id="edit-source"/); + assert.match(app,/sourceEditor.classList.toggle\('modified', sourceModified\)/); assert.match(html,/>Compile draft<\/button>/); assert.doesNotMatch(app,/Compile & run|compileDraft\(true\)/); assert.match(app,/draft \? !compiled : !loaded/); @@ -171,3 +170,14 @@ test('bundle download stays beside primary Load controls, never inside Advanced' assert.match(css,/\.release-choice select \{ width:9rem;/); assert.match(css,/@media\(max-width:740px\)[\s\S]*#download-bundle \{ width:100%;/); }); + +test('production workers cannot inherit finite Graph defaults or request budgets',async()=>{ + for(const file of ['src/worker.ts','src/compiler-worker.ts']) { + const source=await readFile(new URL(file,here),'utf8'); + assert.match(source,/const budget = BROWSER_BUDGET/); + assert.doesNotMatch(source,/executionSettings|executionBudget|\['limits'\]|\['uncapped'\]/); + } + for(const file of ['src/protocol.ts','src/compiler-protocol.ts','src/app.ts']) + assert.doesNotMatch(await readFile(new URL(file,here),'utf8'),/ExecutionLimits|readSettings|limits-change|uncapped|activeMilliseconds/); + await assert.rejects(access(new URL('dist/limits-ui.js',here))); +}); diff --git a/ext/demo-site/test/summarize-backends.mjs b/ext/demo-site/test/summarize-backends.mjs @@ -0,0 +1,29 @@ +import {readFile} from 'node:fs/promises'; +const input=JSON.parse(await readFile(process.argv[2],'utf8')); +const median=xs=>{xs=[...xs].sort((a,b)=>a-b);const n=xs.length;return n%2?xs[(n-1)/2]:(xs[n/2-1]+xs[n/2])/2;}; +const mib=n=>Math.round(n/1048576*100)/100,ms=n=>Math.round(n*100)/100; +const summary={memoryOnly:input.memoryOnly??false,browsers:{}}; +for(const browser of new Set(input.reports.map(r=>r.browser))){ + const result=summary.browsers[browser]={}; + for(const backend of ['zig-wasm','haskell-wasm']){ + const reports=input.reports.filter(r=>r.browser===browser&&r.backend===backend); + if(!reports.length)continue; + const samples=reports.flatMap(r=>r.browserMemory.samples); + const phases=[...new Set(samples.map(s=>s.phase).filter(Boolean))]; + result[backend]={ + freshProfilePackageReadyMs:ms(median(reports.map(r=>r.packages[0].readyMs))), + packageLinearMiB:[Math.min(...reports.flatMap(r=>r.packages.map(p=>p.linearBytes))),Math.max(...reports.flatMap(r=>r.packages.map(p=>p.linearBytes)))].map(mib), + compiledLinearMiB:[Math.min(...reports.flatMap(r=>r.compilations.map(c=>c.compileLinearBytes))),Math.max(...reports.flatMap(r=>r.compilations.flatMap(c=>c.runs.map(x=>x.linearBytes))))].map(mib), + maximumRetainedRunGrowthMiB:mib(Math.max(...reports.flatMap(r=>r.compilations.map(c=>c.runs.at(-1).linearBytes-c.runs[0].linearBytes)))), + demos:reports[0].compilations.map(({name})=>{ + const cs=reports.map(r=>r.compilations.find(c=>c.name===name)); + return {name,compileReadyMs:ms(median(cs.map(c=>c.readyMs))),firstRunMs:ms(median(cs.map(c=>c.runs[0].ms))),warmRunMs:ms(median(cs.flatMap(c=>c.runs.slice(1).map(x=>x.ms)))),publishedRunMs:ms(median(reports.map(r=>r.packages.find(p=>p.name===name).runMs)))}; + }), + heldMemory:phases.flatMap(phase=>{ + const vs=samples.filter(s=>s.phase===phase&&s.pssBytes!==null&&s.pssBytes!==undefined);if(!vs.length)return []; + return [{phase,pssMiB:mib(median(vs.map(x=>x.pssBytes))),privateMiB:mib(median(vs.map(x=>x.privateBytes)))}]; + }), + }; + } +} +console.log(JSON.stringify(summary,null,2)); diff --git a/ext/demo-site/test/zig-negative-fixtures.mjs b/ext/demo-site/test/zig-negative-fixtures.mjs @@ -0,0 +1,23 @@ +// Constructor-only security fixtures, independent of the compiler/evaluator. +import {makeBundle} from './bundle-fixture.mjs'; +import {createHash} from 'node:crypto'; +export function fixtures(){ + const result={}; + const fixture=(name,build)=>{ + const nodes=[],memo=new Map(); + const node=(...children)=>{const key=children.join(',');if(memo.has(key))return memo.get(key);const id=nodes.length;nodes.push(children);memo.set(key,id);return id;}; + const leaf=node(),fork=(a,b)=>node(a,b),num=n=>n===0?leaf:fork(n%2?node(leaf):leaf,num(Math.floor(n/2))); + const list=xs=>xs.reduceRight((tail,x)=>fork(x,tail),leaf),text=s=>list(Array.from(s,c=>num(c.codePointAt(0)))); + const action=(tag,payload)=>fork(num(tag),payload),constant=t=>fork(leaf,t); + const io=(value,version=1)=>fork(text('tricuIO'),fork(num(version),value)); + const root=build({leaf,fork,num,text,action,constant,io});const bytes=makeBundle(nodes,[['root',root]]); + result[name]={bytes:[...bytes],hash:createHash('sha256').update(bytes).digest('hex')}; + }; + fixture('version',x=>x.io(x.action(0,x.leaf),2)); + fixture('unsupported',x=>x.io(x.action(99,x.leaf))); + fixture('abort',x=>{let t=x.leaf;for(let i=0;i<40;i++)t=x.fork(t,t);return x.io(x.action(2,t));}); + fixture('output',x=>{let a=x.action(0,x.leaf);const write=x.action(10,x.text('x'.repeat(4096)));for(let i=0;i<5;i++)a=x.action(1,x.fork(write,x.constant(a)));return x.io(a);}); + fixture('actions',x=>{let a=x.action(0,x.leaf);for(let i=0;i<65;i++)a=x.action(1,x.fork(x.action(0,x.leaf),x.constant(a)));return x.io(a);}); + fixture('sharing',x=>{let t=x.leaf;for(let i=0;i<40;i++)t=x.fork(t,t);return t;}); + return result; +} diff --git a/ext/demo-site/test/zig-session-probe.mjs b/ext/demo-site/test/zig-session-probe.mjs @@ -0,0 +1,86 @@ +// Independent complete Haskell captures, exercised through Zig's coarse adapter. +const assert=(ok,message)=>{if(!ok)throw Error(message);}; +const equal=(a,b,label)=>assert(JSON.stringify(a)===JSON.stringify(b),label+': '+JSON.stringify(a).slice(0,160)); +async function rejects(fn,pattern){try{await fn();}catch(e){assert(pattern.test(e.message),'Wrong rejection: '+e.message);return;}throw Error('Expected '+pattern);} +export async function run() { + const base=new URL('/nested/site/',location.href); + const {create}=await import(new URL('runtime/zig/site-session.mjs',base)); + const fixtures=await(await fetch('/zig-fixtures.json')).json(); + const catalogue=await(await fetch(new URL('catalogue.json',base))).json(); + const report={packages:0,applications:0,compiledDemos:0,checks:[],memory:[]}; + const host=input=>({before:async()=>{},after:()=>{},read:async()=>input,write:()=>{},running:()=>{}}); + const bytes=async hash=>new Uint8Array(await(await fetch('/_arboricx/bundle/hash/'+hash)).arrayBuffer()); + const s=await create({base,compiler:false}); + try { + for(const [name,demo] of Object.entries(fixtures.demos)) { + await s.load(await bytes(demo.hash),demo.hash);report.packages++; + for(const c of demo.haskellCases){ + let terminal='',before; + const hooks=host(c.input); + hooks.before=async e=>{assert(!before,'Missing after');before=e;}; + hooks.after=e=>{assert(e.step===before?.step&&e.name===before.name,'Trace ordering/name');before=undefined;}; + hooks.read=async()=>{s.collect();return c.input;};hooks.write=text=>{terminal+=text;}; + equal(await s.inspect(c.export,c.arguments.map(value=>({type:'tree',value})),demo.mode,hooks),c.result,'Full golden '+name); + equal(terminal,c.terminal,'Terminal '+name);report.applications++; + } + } + const negatives=await(await fetch('/zig-negative.json')).json(); + for(const [name,pattern] of [['version',/version/],['unsupported',/Unsupported/],['abort',/aborted/],['output',/output limit/],['actions',/action limit/]]){ + const f=negatives[name];await s.load(new Uint8Array(f.bytes),f.hash); + await rejects(()=>s.run('root',[],'tree','io',host()),pattern); + } + const shared=negatives.sharing;await s.load(new Uint8Array(shared.bytes),shared.hash); + const preview=(await s.run('root',[],'tree','pure',host())).value; + assert(!preview.truncated&&preview.nodes.length===41,'Physical sharing expanded'); + assert(preview.nodes.filter(n=>n.kind==='fork').every(n=>n.children[0]===n.children[1]),'Lost child aliases'); + const intro=fixtures.demos.intro;await s.load(await bytes(intro.hash),intro.hash); + const invoke=(arg,type)=>s.run('identity',[arg],type,'pure',host()); + for(const vector of fixtures.codecVectors){const type=vector.kind,value=type==='number'?String(vector.value):vector.value;equal((await invoke({type,value},type)).value,value,'Scalar codec');} + for(const value of [true,false])equal((await invoke({type:'bool',value},'bool')).value,value,'Boolean'); + equal((await invoke({type:'bytes',value:[0,1,127,128,255]},'bytes')).value,[0,1,127,128,255],'Bytes'); + await rejects(()=>invoke({type:'number',value:'256'},'bool'),/Boolean/); + await rejects(()=>invoke({type:'list',value:[{type:'number',value:'256'}]},'bytes'),/byte/); + await rejects(()=>invoke({type:'list',value:[{type:'number',value:'55296'}]},'string'),/status 5/); + await rejects(()=>invoke({type:'string',value:'\ud800'},'string'),/surrogate/); + const record={type:'list',value:[{type:'list',value:[{type:'string',value:'__proto__'},{type:'number',value:'0'},{type:'string',value:'safe'}]}]}; + const decoded=(await invoke(record,'record')).value;assert(Object.hasOwn(decoded,'__proto__')&&decoded.__proto__==='safe','Prototype-safe record'); + await rejects(()=>invoke({type:'list',value:[record.value[0],record.value[0]]},'record'),/unique/); + equal((await invoke({type:'list',value:[{type:'number',value:'42'}]},'list')).value.length,1,'List'); + const original=await bytes(intro.hash); + await rejects(()=>s.load(original,'0'.repeat(64)),/SHA-256 mismatch/); + const mutable=original.slice(),loading=s.load(mutable,intro.hash);mutable.fill(0);await loading; + equal((await invoke({type:'number',value:'42'},'number')).value,'42','Load must authenticate an immutable snapshot'); + const baseline=s.diagnostics().owned; + for(let i=0;i<50;i++){ + equal((await invoke({type:'string',value:'λ雪🙂'},'string')).value,'λ雪🙂','Retained run'); + s.collect();equal(s.diagnostics().owned,baseline,'Temporary owned-handle leak'); + report.memory.push(s.diagnostics().linearMemoryBytes); + } + assert(Math.max(...report.memory.slice(25))<=Math.max(...report.memory.slice(0,25))+65536,'Memory failed to plateau'); + report.checks.push('117 complete published goldens; IO GC/trace ordering/limits; seven codecs/negative controls; physical sharing; ownership and memory plateau'); + const greet=fixtures.demos.greet;await s.load(await bytes(greet.hash),greet.hash); + let entered;const paused=new Promise(resolve=>{entered=resolve;}); + const running=s.run(greet.haskellCases[0].export,[],'string','io',{...host(''),before:()=>{entered();return new Promise(()=>{});}}); + running.catch(()=>{});await paused;s.dispose();await rejects(()=>running,/Disposed/);assert(s.disposed,'Disposed session reused'); + }finally{s.dispose();} + const c=await create({base,compiler:true}); + try { + for(const [name,demo] of Object.entries(fixtures.demos)){ + const entry=catalogue.find(x=>x.hash===demo.hash);assert(entry,'Catalogue fixture pin'); + await c.compile(demo.source,entry.environment); + const first=demo.haskellCases[0]; + equal(await c.inspect(first.export,first.arguments.map(value=>({type:'tree',value})),demo.mode,host(first.input)),first.result,'Compiled '+name);report.compiledDemos++; + } + const intro=fixtures.demos.intro;await c.compile(intro.source,'empty'); + await rejects(()=>c.compile('name = unavailableName','empty'),/Compiler diagnostic/); + equal((await c.run('answer',[],'number','pure',host())).value,'42','Compile error preserves definitions'); + const greet=fixtures.demos.greet;await c.compile(greet.source,'empty'); + await rejects(()=>c.run(greet.haskellCases[0].export,[],'string','io',{...host('x'),before:async()=>{throw Error('host failed');}}),/host failed/); + const first=greet.haskellCases[0];equal(await c.inspect(first.export,[],greet.mode,host(first.input)),first.result,'Host-error cleanup preserves definitions'); + await c.compile('q x = x x\nlazy = t (q q) t\naborted = t \"tricuIO\" (t 1 (t 2 (q q)))','empty'); + const partial=await c.preview('lazy',1);assert(partial.nodes.length===1&&partial.truncated,'Preview forced excluded divergent child'); + await rejects(()=>c.run('aborted',[],'tree','io',host()),/aborted/); + report.checks.push('eight complete source demos/environment profiles; compile/host-error retention; nonforcing divergent preview/abort'); + }finally{c.dispose();} + return report; +} diff --git a/ext/haskell-wasm/BrowserRuntime.hs b/ext/haskell-wasm/BrowserRuntime.hs @@ -0,0 +1,235 @@ +{-# LANGUAGE BangPatterns #-} +-- Browser embedding primitives, not an alternate evaluator. All application is +-- Runtime.Tree.apply; observation budgets bound transport, not reduction fuel. +module BrowserRuntime where +import Runtime.Tree +import Runtime.Value +import Control.Exception (evaluate) +import Control.Monad (unless, forM_, foldM) +import Data.Array (Array, listArray, (!), elems) +import Data.Bits ((.|.), shiftL) +import qualified Data.ByteString as BS +import qualified Data.Text as Text +import qualified Data.Text.Encoding as Text +import qualified Data.IntMap.Strict as IM +import qualified Data.IntSet as IS +import Data.IORef +import Data.Char (ord) +import Data.List (intercalate) +import System.Mem.StableName +import Data.Word (Word32) +import Numeric (showHex) +import Text.Read (readMaybe) + +-- JSON output only. Inputs use length-prefixed byte fields, avoiding a second +-- implementation of the site's JSON/literal validator inside the RTS. +data J = S String | N Integer | B Bool | A [J] | O [(String,J)] +json :: J -> String +json (S s)='"':concatMap escape s++"\"" where + escape '"'="\\\""; escape '\\'="\\\\" + escape c | ord c<32 = let h=showHex (ord c) "" in "\\u"++replicate (4-length h) '0'++h + | otherwise=[c] +json (N n)=show n +json (B b)=if b then "true" else "false" +json (A xs)="["++intercalate "," (map json xs)++"]" +json (O xs)="{"++intercalate "," [json(S k)++":"++json v | (k,v)<-xs]++"}" +number :: Int -> J +number=N . toInteger + +word :: BS.ByteString -> Int -> Int +word b at=fromIntegral (foldr (.|.) 0 [fromIntegral(BS.index b (at+j)) `shiftL` (8*j) | j<-[0..3]] :: Word32) +fields :: BS.ByteString -> IO [BS.ByteString] +fields b=go 0 0 where + go at count + | at==BS.length b=pure [] + | count>=16384 || BS.length b-at<4=fail "invalid field framing" + | otherwise=do + let n=word b at + unless(n>=0 && n<=BS.length b-at-4)(fail "truncated field") + rest<-go (at+4+n) (count+1) + pure(BS.take n (BS.drop (at+4) b):rest) +utf8 :: BS.ByteString -> IO String +utf8=either (fail.show) (pure.Text.unpack) . Text.decodeUtf8' +index :: Int -> BS.ByteString -> IO Int +index upper b=do + s<-utf8 b + case readMaybe s of Just n | n>=0 && n<upper -> pure n; _->fail "invalid index" + +loadDAG :: BS.ByteString -> IO (Array Int T,Int) +loadDAG b=do + unless(BS.length b>=8)(fail "truncated DAG") + let n=word b 0;root=word b 4 + unless(n>0 && n<=1000000 && root>=0 && root<n && BS.length b==8+9*n)(fail "invalid DAG header") + forM_ [0..n-1] $ \i->do + let at=8+9*i;tag=BS.index b at;l=word b (at+1);r=word b(at+5);prior x=x>=0&&x<i + unless(case tag of 0->l==0&&r==0;1->prior l&&r==0;2->prior l&&prior r;_->False)(fail "invalid DAG record") + let trees=listArray (0,n-1) [case BS.index b(8+9*i) of + 0->Leaf;1->Stem(trees!word b(9+9*i));_->Fork(trees!word b(9+9*i))(trees!word b(13+9*i)) | i<-[0..n-1]] + forM_ (elems trees) $ \t->evaluate(case t of Leaf->();Stem x->x `seq` ();Fork x y->x `seq` y `seq` ()) + pure(trees,n) + +boundedList :: Int -> T -> IO [T] +boundedList limit=go limit where + go n tree=case tree of + Leaf->pure [] + Fork a rest | n>0 ->(a:) <$> go(n-1) rest + | otherwise->fail "List decode limit exceeded" + _->fail "Invalid canonical list" +natural :: T -> IO Integer +natural=go 0 1 0 where + go !bits !place !acc tree + | bits>4096=fail "Natural number decode limit exceeded" + | otherwise=case tree of + Leaf->pure acc + Fork bit rest->case bit of + Leaf->go(bits+1)(2*place)acc rest + Stem Leaf->go(bits+1)(2*place)(acc+place)rest + _->fail "Invalid number bit" + _->fail "Invalid canonical number" +text :: Int -> T -> IO String +text limit tree=boundedList limit tree >>= mapM (\t->natural t >>= either fail pure . toChar) +boolean :: T -> IO Bool +boolean Leaf=pure False +boolean (Stem Leaf)=pure True +boolean _=fail "Invalid canonical Boolean" +pair :: T -> IO (T,T) +pair (Fork a b)=pure(a,b) +pair _=fail "Expected pair" + +-- Local IDs label a snapshot only. Register children WITHOUT evaluating them. +-- Stable-name collisions are resolved by equality; aliases record thunk updates. +-- Hitting the budget never forces the next pending child. +preview :: Int -> T -> IO J +preview limit root=do + unless(limit>=1 && limit<=100000)(fail "Invalid snapshot budget") + memo<-newIORef IM.empty + next<-newIORef 0 + done<-newIORef IS.empty + let register t=do + sn<-makeStableName t + seen<-readIORef memo + case lookup sn (IM.findWithDefault [] (hashStableName sn) seen) of + Just ident->pure ident + Nothing->do + ident<-readIORef next;modifyIORef' next (+1) + modifyIORef' memo (IM.insertWith (++) (hashStableName sn) [(sn,ident)]) + pure ident + go [] _ rows aliases=pure(rows,aliases,False) + go _ 0 rows aliases=pure(rows,aliases,True) + go ((ident,input):pending) budget rows aliases=do + tree<-evaluate input + sn<-makeStableName tree + seen<-readIORef memo + case lookup sn (IM.findWithDefault [] (hashStableName sn) seen) of + Just other | other/=ident ->go pending budget rows ((ident,other):aliases) + _->do + modifyIORef' memo (IM.insertWith (++) (hashStableName sn) [(sn,ident)]) + visited<-readIORef done + if IS.member ident visited then go pending budget rows aliases else do + modifyIORef' done (IS.insert ident) + let (kind,children)=case tree of Leaf->("leaf",[]);Stem a->("stem",[a]);Fork a b->("fork",[a,b]) + ids<-mapM register children + go (reverse(zip ids children)++pending) (budget-1) ((ident,kind,ids):rows) aliases + rid<-register root + (rows,aliases,truncated)<-go [(rid,root)] limit [] [] + pure(O [("root",number rid),("nodes",A [O[("id",number i),("kind",S k),("children",A(map number cs))] | (i,k,cs)<-reverse rows]), + ("aliases",A[O[("from",number a),("to",number b)] | (a,b)<-reverse aliases]),("truncated",B truncated)]) + +view :: String -> T -> IO J +view kind tree=do + value<-case kind of + "tree"->preview 64 tree + "string"->S <$> text 4096 tree + "number"->S . show <$> natural tree + "bool"->B <$> boolean tree + "bytes"->do + xs<-boundedList 4096 tree + A <$> mapM (\x->do n<-natural x;unless(n<=255)(fail "Invalid byte");pure(N n)) xs + "list"->boundedList 32 tree >>= fmap A . mapM (preview 8) + "record"->do + rows<-boundedList 32 tree + (_,_,values)<-foldM record ([],0,[]) rows + pure(O(reverse values)) + _->fail "Unsupported output codec" + pure(O[("type",S kind),("value",value)]) + where + record (names,size,values) row=do + xs<-boundedList 3 row + (key,tag,t)<-case xs of [a,b,c]->pure(a,b,c);_->fail "Record field must be [name tag value]" + name<-text 128 key + unless(not(null name)&&name `notElem` names)(fail "Record field names must be nonempty and unique") + code<-natural tag + (value,len)<-case code of + 0->do s<-text 4096 t;pure(S s,length s) + 1->do n<-natural t;unless(n<=9007199254740991)(fail "Record number exceeds safe integer range");pure(N n,length(show n)) + 2->do b<-boolean t;pure(B b,if b then 4 else 5) + _->fail "Unsupported record field tag" + let total=size+length name+len + unless(total<=16384)(fail "Record output limit exceeded") + pure(name:names,total,(name,value):values) + +-- Explicit action machine. Events contain no tree handles; the RTS owns all +-- continuations while the browser is waiting. Each resume consumes a fresh token. +data Machine = Machine T [T] Int Int +data Phase = Before Machine Integer T | ReadLine Machine | Wrote Machine | After Machine String | Done T + +beginIO :: T -> IO Phase +beginIO root=do + (sentinel,body)<-pair root + s<-text 7 sentinel + unless(s=="tricuIO")(fail "Missing tricuIO sentinel") + (version,action)<-pair body + v<-natural version + unless(v==1)(fail "Unsupported tricuIO version") + advance(Machine action [] 0 0) +advance :: Machine -> IO Phase +advance machine@(Machine action _ step _)=do + unless(step<128)(fail "IO action limit exceeded") + (tag,payload)<-pair action + code<-natural tag + unless(code `elem` [0,1,2,10,11])(fail ("Unsupported browser IO action "++show code)) + pure(Before machine code payload) +finishValue :: String -> Machine -> T -> Phase +finishValue _ (Machine _ [] _ _) value=Done value +finishValue name (Machine _ (f:fs) step output) value=After (Machine (apply f value) fs step output) name + +-- An after event precedes completion too, so stepping never skips an action. +data Pending = Pending Phase | Complete T Int String +resumeIO :: Pending -> String -> IO Pending +resumeIO (Complete value _ _) _=pure(Pending(Done value)) +resumeIO (Pending phase) value=case phase of + Before (Machine action frames step output) code payload->do + let m=Machine action frames (step+1) output + result v=case frames of []->Complete v (step+1) (actionName code);_->Pending(finishValue (actionName code) m v) + case code of + 0->pure(result payload) + 1->do (left,k)<-pair payload;pure(Pending(After (Machine left (k:frames) (step+1) output) "bind")) + 2->fail "Execution aborted (diagnostic payload not forced)." + 10->do + s<-text 4096 payload + unless(output+length s<=16384)(fail "Terminal output limit exceeded") + pure(Pending(Wrote(Machine action frames (step+1) (output+length s)))) + 11->pure(Pending(ReadLine m)) + _->fail "Unsupported IO action" + ReadLine m@(Machine _ frames step _)->do + unless(length value<=256 && all (`notElem` "\r\n") value)(fail "Input must be one bounded line") + let t=ofString value + pure(if null frames then Complete t step "getLine" else Pending(finishValue "getLine" m t)) + Wrote m@(Machine _ frames step _)->pure(if null frames then Complete Leaf step "putStr" else Pending(finishValue "putStr" m Leaf)) + After m _->Pending <$> advance m + Done _->fail "IO already finished" +actionName :: Integer -> String +actionName code=case code of 0->"pure";1->"bind";2->"abortIO";10->"putStr";11->"getLine";_->"unknown" +event :: Integer -> Pending -> IO J +event token pending=do + payload<-case pending of + Complete _ step name->pure[("kind",S "after"),("step",number step),("name",S name),("value",S "retained value")] + Pending phase->case phase of + Before (Machine _ frames step _) code t->do + detail<-if code==10 then text 4096 t else pure "retained value" + pure[("kind",S "before"),("step",number(step+1)),("name",S(actionName code)),("detail",S detail),("depth",number(length frames))] + ReadLine _->pure[("kind",S "read")] + Wrote (Machine action _ _ _)->do (_,t)<-pair action;s<-text 4096 t;pure[("kind",S "write"),("text",S s)] + After (Machine _ _ step _) name->pure[("kind",S "after"),("step",number step),("name",S name),("value",S "retained value")] + Done _->pure[("kind",S "done")] + pure(O(("token",N token):payload)) diff --git a/ext/haskell-wasm/CompilerProbe.hs b/ext/haskell-wasm/CompilerProbe.hs @@ -0,0 +1,144 @@ +{-# LANGUAGE BangPatterns #-} +{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-} +module CompilerProbe where +import Runtime.Tree +import Runtime.Value +import Control.Exception (evaluate) +import Control.Monad (forM_, unless) +import Data.Array (Array, listArray, (!), elems) +import Data.Bits ((.|.), shiftL) +import qualified Data.ByteString as BS +import qualified Data.Text as Text +import qualified Data.Text.Encoding as Text +import Data.Word (Word32) +import GHC.Clock (getMonotonicTimeNSec) +import GHC.Stats +import System.Environment (getArgs) +import System.Exit (die) +import System.Mem (performGC) +import Text.Printf (printf) + +now :: IO Double +now = (/1e9) . fromIntegral <$> getMonotonicTimeNSec +utf8 :: FilePath -> IO String +utf8 path = do + b <- BS.readFile path + either (die . show) (pure . Text.unpack) (Text.decodeUtf8' b) +word :: BS.ByteString -> Int -> Int +word b at = fromIntegral (foldr (.|.) 0 [fromIntegral (BS.index b (at+j)) `shiftL` (8*j) | j<-[0..3]] :: Word32) +-- Bounds and backward references are checked before constructing the lazy array. +load :: FilePath -> IO (T,T) +load dir = do + b <- BS.readFile (dir++"/compiler.dag") + unless (BS.length b >= 8) (die "truncated DAG") + let n=word b 0; root=word b 4 + unless (n>0 && n<=1000000 && root>=0 && root<n && BS.length b==8+9*n) (die "invalid DAG header") + forM_ [0..n-1] $ \i -> do + let at=8+9*i; tag=BS.index b at; l=word b (at+1); r=word b (at+5) + prior x=x>=0 && x<i + unless (case tag of 0->l==0 && r==0; 1->prior l && r==0; 2->prior l && prior r; _->False) + (die "invalid DAG record") + let trees :: Array Int T + trees=listArray (0,n-1) [case BS.index b (8+9*i) of + 0->Leaf; 1->Stem (trees ! word b (9+9*i)); _->Fork (trees ! word b (9+9*i)) (trees ! word b (13+9*i)) | i<-[0..n-1]] + -- Force every physical node and child pointer, never expand shared subtrees. + forM_ (elems trees) $ \t -> evaluate (case t of Leaf->(); Stem x->x `seq` (); Fork x y->x `seq` y `seq` ()) + bindings <- lines <$> utf8 (dir++"/environment.tsv") + env <- mapM (\line -> case words line of + [name,idx] | let i=read idx, i>=0 && i<n -> do + let key=ofString name + _<-evaluate (force key) + pure (Fork key (trees ! read idx)) + _ -> die "invalid environment binding") bindings + let environment=ofList env + _<-evaluate (forceSpine environment) + pure (trees!root, environment) + +force :: T -> () +force Leaf=() +force (Stem x)=force x +force (Fork x y)=force x `seq` force y +forceSpine :: T -> () +forceSpine Leaf=() +forceSpine (Fork x xs)=x `seq` forceSpine xs +forceSpine _=error "invalid list" + +pair :: T -> (T,T) +pair (Fork a b)=(a,b) +pair _=error "expected pair" +text :: T -> String +text=either error id . toString +nat :: T -> Integer +nat=either error id . toNumber +ternary :: T -> String +ternary t=go t [] where + go Leaf rest='0':rest + go (Stem x) rest='1':go x rest + go (Fork x y) rest='2':go x (go y rest) +fromTernary :: String -> T +fromTernary s=case parse s of (t,"")->t; _->error "trailing ternary" +parse :: String -> (T,String) +parse ('0':xs)=(Leaf,xs) +parse ('1':xs)=let (a,rest)=parse xs in (Stem a,rest) +parse ('2':xs)=let (a,rest)=parse xs; (b,end)=parse rest in (Fork a b,end) +parse _=error "invalid ternary" + +diagnostic :: T -> String +diagnostic payload = + let (d,_)=pair payload; (code,tail1)=pair d; (expected,fields)=pair tail1 + (_,locations)=pair fields; (location,_)=pair locations + whereAt=case location of Leaf->""; _->let (line,rest)=pair location; (column,_)=pair rest + in " at "++show (nat line)++":"++show (nat column) + c=nat code + label | c==2 = ": expected token kind "++show (nat expected) + | c==20 = ": unresolved name "++show (text expected) + | c>=30 = ": expected "++show (text expected) + | otherwise = "" + in "Compiler diagnostic "++show c++whereAt++label + +definitions :: T -> Either String [(String,T)] +definitions result=case pair result of + (Leaf,payload)->Left (diagnostic payload) + (Stem Leaf,payload)->let (ds,_)=pair payload in Right + [(text name,program) | binding<-either error id (toList ds), let (name,program)=pair binding] + _->error "invalid compiler tag" + +{-# OPAQUE compile #-} +compile :: T -> T -> T -> T +compile compiler env source=apply (apply compiler env) source + +main :: IO () +main = do + args <- getArgs + (dir,caseDir)<-case args of [d,c]->pure(d,c); _->die "usage: compiler-probe DATA CASE" + start<-now + (compiler,env)<-load dir + source<-ofString <$> utf8 (caseDir++"/source") + _<-evaluate (force source) + expected<-lines <$> utf8 (caseDir++"/expected") + run<-lines <$> utf8 (caseDir++"/run") + loaded<-now + result<-evaluate (compile compiler env source) + whnf<-now + let defs=definitions result + -- Force export names/spine, not program trees: the site's selector boundary. + _<-evaluate (case defs of Left msg->length msg; Right ds->sum [length name | (name,_)<-ds]) + named<-now + valid<-evaluate $ case (expected,defs) of + (["error",message],Left actual)->message==actual + ("ok":rows,Right ds)->rows==[name++"\t"++ternary program | (name,program)<-ds] + _->False + unless valid (die "compiler golden mismatch") + complete<-now + case (defs,run) of + (Right ds,name:wanted:arguments)->case lookup name ds of + Just program->unless (ternary (foldl apply program (map fromTernary arguments))==wanted) (die "compiled program result mismatch") + Nothing->die "missing compiled export" + (Left _,[])->pure () + _->die "invalid run specification" + executed<-now + performGC + stats<-getRTSStats + printf "{\"load_s\":%.9f,\"whnf_s\":%.9f,\"names_s\":%.9f,\"complete_s\":%.9f,\"run_s\":%.9f,\"allocated_bytes\":%d,\"max_live_bytes\":%d,\"max_mem_in_use_bytes\":%d}\n" + (loaded-start) (whnf-loaded) (named-whnf) (complete-named) (executed-complete) + (allocated_bytes stats) (max_live_bytes stats) (max_mem_in_use_bytes stats) diff --git a/ext/haskell-wasm/Probe.hs b/ext/haskell-wasm/Probe.hs @@ -0,0 +1,78 @@ +{-# LANGUAGE BangPatterns #-} +{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-} +-- Only the harness disables sharing optimizations, not the production runtime. +module Main where +import Runtime.Tree +import Runtime.Value +import qualified Data.ByteString as BS +import Control.Exception (evaluate) +import Control.Monad (foldM, unless) +import GHC.Clock (getMonotonicTimeNSec) +import System.Environment (getArgs) +import System.Exit (die) +import Text.Printf (printf) + +now :: IO Double +now = (/1e9) . fromIntegral <$> getMonotonicTimeNSec + +data Suspended = Suspended T +{-# OPAQUE suspend #-} +suspend :: T -> T -> Suspended +suspend f x = Suspended (apply f x) + +force :: T -> () +force Leaf = () +force (Stem x) = force x +force (Fork x y) = force x `seq` force y + +check :: IO () +check = do + let a = Stem (Stem Leaf); b = Fork Leaf (Stem Leaf) + identity = Fork (Stem (Stem Leaf)) (Stem Leaf) + cases = [ (apply Leaf a, Stem a), (apply (Stem a) b, Fork a b) + , (apply (Fork Leaf a) (error "discard forced"), a) + , (apply identity b, b) + , (apply (Fork (Fork a b) Leaf) Leaf, a) + , (apply (Fork (Fork a b) Leaf) (Stem Leaf), apply b Leaf) + , (apply (Fork (Fork a b) identity) (Fork a b), apply (apply identity a) b)] + mapM_ (\(actual, expected) -> unless (actual == expected) (die "reduction mismatch")) cases + _ <- evaluate (suspend (error "construction forced function") Leaf) + unless (toBytes (Fork (ofNumber 256) Leaf) == Left "Byte value out of range: 256") + (die "invalid byte accepted") + putStrLn "checks passed" + +iteration :: String -> Int -> Int -> IO (Double, Double, Double) +iteration kind n roundNo = do + let bytes = BS.pack [fromIntegral (97 + (j+roundNo) `mod` 26) | j <- [0..n-1]] + _ <- evaluate (BS.length bytes) + start <- now + Suspended term <- if kind == "codec" then do + let input = ofBytes bytes + _ <- evaluate (force input) + evaluate (suspend (Fork (Stem (Stem Leaf)) (Stem Leaf)) input) + else foldM (\(Suspended previous) _ -> evaluate $ + if kind == "deep" then suspend (Fork Leaf previous) Leaf + else suspend (Fork (Stem (Stem Leaf)) (Stem Leaf)) previous) + (Suspended Leaf) [1..n] + built <- now + result <- evaluate term + reduced <- now + valid <- evaluate $ if kind == "codec" then toBytes result == Right bytes + else case result of Leaf -> True; _ -> False + unless valid (die "incorrect complete result") + observed <- now + pure (built-start, reduced-built, observed-reduced) + +main :: IO () +main = do + args <- getArgs + case args of + ["check"] -> check + [kind, size, count] | kind `elem` ["codec", "deep", "distribution"] -> do + let n = read size; rounds = read count + unless (n > 0 && rounds > 0) (die "positive sizes required") + (!b,!r,!o) <- foldM (\(!b,!r,!o) i -> do + (b',r',o') <- iteration kind n i + pure (b+b',r+r',o+o')) (0,0,0) [0..rounds-1] + printf "{\"kind\":\"%s\",\"n\":%d,\"rounds\":%d,\"build_s\":%.9f,\"reduce_s\":%.9f,\"observe_s\":%.9f}\n" kind n rounds b r o + _ -> die "usage: probe check | codec|deep|distribution SIZE ROUNDS" diff --git a/ext/haskell-wasm/README.md b/ext/haskell-wasm/README.md @@ -0,0 +1,276 @@ +# 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. + +```sh +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 + +```sh +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**: + +```sh +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 both prepared-input hashes, valid Unicode source +against an independent scalar-tree golden, lone-surrogate/oversized source, +malformed ternary input, preservation 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 without losing prior exports, 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; failure preserves the +preceding set. 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. +No per-node handles, native addresses, or Haskell heap pointers cross to the UI. +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; no label is a persistent +runtime handle. Preview registers child references without forcing excluded +children. 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 and never evaluates the stored program. +The embedding's framed binary wire is private, not a public interchange format. + +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](../demo-site/README.md#runtime-selection) 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. | +| `compile` | `source`, optional `environment`; returns newline-separated names, preserves old definitions on failure. | +| `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 `linearMemoryBytes` only; no fabricated node, fuel or RTS counts. | + +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 fail without fallback. 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. diff --git a/ext/haskell-wasm/Reactor.hs b/ext/haskell-wasm/Reactor.hs @@ -0,0 +1,266 @@ +{-# LANGUAGE ForeignFunctionInterface #-} +{-# LANGUAGE ScopedTypeVariables #-} +module Reactor where + +import qualified CompilerProbe as C +import qualified BrowserRuntime as R +import Data.Array (Array, (!)) +import Text.Read (readMaybe) +import Runtime.Tree +import Runtime.Value +import Control.Exception (SomeException, SomeAsyncException, catch, evaluate, fromException, displayException) +import Control.Monad (unless) +import Data.IORef +import Data.List (nub) +import qualified Data.ByteString as BS +import qualified Data.Text as Text +import qualified Data.Text.Encoding as Text +import Foreign.Ptr (Ptr, castPtr) +import Foreign.C.Types (CInt(..)) +import Foreign.Marshal.Utils (copyBytes) +import System.IO.Unsafe (unsafePerformIO) +import System.Mem (performGC) +import System.IO.Error (isUserError, ioeGetErrorString) + +{-# NOINLINE context #-} +context :: IORef (Maybe (T,T)) +context = unsafePerformIO (newIORef Nothing) +{-# NOINLINE current #-} +current :: IORef [(String,T)] +current = unsafePerformIO (newIORef []) +{-# NOINLINE response #-} +response :: IORef BS.ByteString +response = unsafePerformIO (newIORef BS.empty) + +-- One owned response at a time; no tree handles or Haskell heap addresses cross +-- the boundary. JS copies it out before another request. Inputs are copied in. +reply :: String -> IO () +reply s = do + let bounded=take (16777216+1) s + unless (length bounded<=16777216) (fail "response limit exceeded") + let bytes=Text.encodeUtf8 (Text.pack bounded) + unless (BS.length bytes<=16777216) (fail "response byte limit exceeded") + _<-evaluate (BS.length bytes) + writeIORef response bytes + +guardRequest :: IO () -> IO CInt +guardRequest action = (action >> pure 0) `catch` \(e::SomeException) -> do + let message=case fromException e of + Just ioError | isUserError ioError -> ioeGetErrorString ioError + _ -> displayException e + reply ("error\n"++take 2048 message) + pure (case (fromException e :: Maybe SomeAsyncException) of Just _->2;Nothing->1) + +input :: Ptr () -> CInt -> IO String +input ptr len = do + unless (len>=0 && len<=16777216) (fail "input byte limit exceeded") + bytes<-BS.packCStringLen (castPtr ptr,fromIntegral len) + either (fail . show) (pure . Text.unpack) (Text.decodeUtf8' bytes) + +initialize :: IO CInt +initialize = guardRequest $ do + existing<-readIORef context + case existing of Just _->fail "already initialized"; Nothing->pure () + loaded<-C.load "/data" + writeIORef context (Just loaded) + reply "ok\nready" + +compileSource :: Ptr () -> CInt -> IO CInt +compileSource ptr len = guardRequest $ do + source<-input ptr len + ctx<-readIORef context + (compiler,env)<-maybe (fail "not initialized") pure ctx + compileWith compiler env source + +compileWith :: T -> T -> String -> IO () +compileWith compiler env source = do + requireIdle + unless(not(null source)&&length source<=16384)(fail "source scalar limit exceeded") + let argument=ofString source + _<-evaluate(C.force argument) + defs<-case C.definitions(C.compile compiler env argument) of + Left message->fail message + Right values->pure values + let names=map fst(take 17 defs) + unless(not(null names)&&length names<=16&&all (\n->not(null n)&&length n<=128) names&&length(nub names)==length names) + (fail "invalid compiler export names") + reply("ok\n"++unlines names) + writeIORef current defs + writeIORef ioResult Nothing + +observe :: Ptr () -> CInt -> IO CInt +observe ptr len = guardRequest $ do + request<-input ptr len + (name,args)<-case lines request of []->fail "missing export"; n:as->pure(n,as) + unless (length args<=16 && all ((<=50000).length) args) (fail "argument limit exceeded") + defs<-readIORef current + term<-maybe (fail "missing export") pure (lookup name defs) + let result=foldl apply term (map C.fromTernary args) + reply ("ok\n"++C.ternary result) + +dump :: IO CInt +dump = guardRequest $ do + defs<-readIORef current + reply ("ok\n"++unlines [name++"\t"++C.ternary term | (name,term)<-defs]) + +reset :: IO CInt +reset = guardRequest $ do + writeIORef current [] + writeIORef interaction Nothing + writeIORef ioResult Nothing + writeIORef response BS.empty + performGC + reply "ok\nreset" + +collect :: IO CInt +collect = guardRequest (performGC >> reply "ok\ncollected") + +responseSize :: IO CInt +responseSize = fromIntegral . BS.length <$> readIORef response +responseCopy :: Ptr () -> CInt -> IO CInt +responseCopy ptr capacity = do + bytes<-readIORef response + if fromIntegral capacity<BS.length bytes then pure (-1) else do + BS.useAsCStringLen bytes $ \(src,len)->copyBytes ptr (castPtr src) len + writeIORef response BS.empty + pure (fromIntegral (BS.length bytes)) + + +{-# NOINLINE interaction #-} +interaction :: IORef (Maybe (Integer,R.Pending)) +interaction = unsafePerformIO (newIORef Nothing) +{-# NOINLINE nextToken #-} +nextToken :: IORef Integer +nextToken = unsafePerformIO (newIORef 0) +{-# NOINLINE ioResult #-} +ioResult :: IORef (Maybe T) +ioResult = unsafePerformIO (newIORef Nothing) + +requireIdle :: IO () +requireIdle = do + active<-readIORef interaction + case active of Nothing->pure ();Just _->fail "IO interaction is pending" + +emitEvent :: R.Pending -> IO () +emitEvent pending=do + token<-(+1) <$> readIORef nextToken + value<-R.event token pending + reply ("ok\n"++R.json value) + writeIORef nextToken token + case pending of + R.Pending (R.Done value)->do + writeIORef ioResult (Just value) + writeIORef interaction Nothing + _->writeIORef interaction (Just(token,pending)) + +select :: BS.ByteString -> BS.ByteString -> [BS.ByteString] -> IO T +select nameBytes dag roots=do + name<-R.utf8 nameBytes + defs<-readIORef current + worker<-maybe (fail "missing export") pure (lookup name defs) + unless(length roots<=16)(fail "argument limit exceeded") + (trees,n)<-R.loadDAG dag + indices<-mapM (R.index n) roots + pure(foldl apply worker [trees!i | i<-indices]) + +bindings :: Array Int T -> Int -> [BS.ByteString] -> IO [(String,T)] +bindings trees n entries=do + unless(length entries<=8192&&even(length entries))(fail "invalid binding table") + let pairs []=pure [] + pairs (nameBytes:rootBytes:rest)=do + name<-R.utf8 nameBytes + unless(not(null name)&&length name<=4096)(fail "invalid binding name") + root<-R.index n rootBytes + ((name,trees!root):) <$> pairs rest + pairs _=fail "invalid binding table" + defs<-pairs entries + unless(length(nub(map fst defs))==length defs)(fail "duplicate binding name") + pure defs + +request :: Ptr () -> CInt -> IO CInt +request ptr len = guardRequest $ do + unless(len>=0 && len<=16777216)(fail "input byte limit exceeded") + bytes<-BS.packCStringLen(castPtr ptr,fromIntegral len) + fs<-R.fields bytes + (op,arguments)<-case fs of + tag:rest->do name<-R.utf8 tag;pure(name,rest) + _->fail "missing operation" + case (op,arguments) of + ("compile",sourceBytes:dag:entries)->do + requireIdle + ctx<-readIORef context + (compiler,_)<-maybe (fail "compiler not initialized") pure ctx + source<-R.utf8 sourceBytes + (trees,n)<-R.loadDAG dag + defs<-bindings trees n entries + compileWith compiler (ofList [Fork (ofString name) tree | (name,tree)<-defs]) source + ("load",dag:entries)->do + requireIdle + (trees,n)<-R.loadDAG dag + unless(not(null entries)&&length entries<=8192&&even(length entries))(fail "invalid export table") + defs<-bindings trees n entries + reply("ok\n"++R.json(R.A(map (R.S . fst) defs))) + writeIORef current defs + writeIORef ioResult Nothing + ("evaluate",name:codec:dag:roots)->do + kind<-R.utf8 codec + term<-select name dag roots + value<-R.view kind term + reply("ok\n"++R.json value) + ("preview",name:limit:dag:roots)->do + size<-R.index 257 limit + term<-select name dag roots + value<-R.preview size term + reply("ok\n"++R.json value) + ("snapshot",name:limit:dag:roots)->do + size<-R.index 100001 limit + term<-select name dag roots + value<-R.preview size term + reply("ok\n"++R.json value) + ("io-start",name:dag:roots)->do + requireIdle + term<-select name dag roots + phase<-R.beginIO term + emitEvent(R.Pending phase) + writeIORef ioResult Nothing + ("io-resume",tokenBytes:valueBytes:[])->do + tokenText<-R.utf8 tokenBytes + supplied<-maybe (fail "invalid resume token") pure (readMaybe tokenText) + saved<-readIORef interaction + (token,pending)<-maybe (fail "no IO interaction") pure saved + unless(supplied==token)(fail "stale IO resume token") + value<-R.utf8 valueBytes + next<-R.resumeIO pending value + emitEvent next + ("io-cancel",[])->do + writeIORef interaction Nothing + writeIORef ioResult Nothing + reply("ok\n"++R.json(R.O[("kind",R.S "cancelled")])) + ("io-snapshot",[limit])->do + saved<-readIORef ioResult + result<-maybe (fail "no completed IO result") pure saved + size<-R.index 100001 limit + value<-R.preview size result + reply("ok\n"++R.json value) + ("io-view",[codec])->do + saved<-readIORef ioResult + result<-maybe (fail "no completed IO result") pure saved + kind<-R.utf8 codec + value<-R.view kind result + reply("ok\n"++R.json value) + _->fail "invalid operation fields" + +initializeEmpty :: IO CInt +initializeEmpty = guardRequest (reply "ok\nready") + +foreign export ccall "tricu_request" request :: Ptr () -> CInt -> IO CInt +foreign export ccall "tricu_initialize_empty" initializeEmpty :: IO CInt + +foreign export ccall "tricu_initialize" initialize :: IO CInt +foreign export ccall "tricu_compile" compileSource :: Ptr () -> CInt -> IO CInt +foreign export ccall "tricu_observe" observe :: Ptr () -> CInt -> IO CInt +foreign export ccall "tricu_dump" dump :: IO CInt +foreign export ccall "tricu_reset" reset :: IO CInt +foreign export ccall "tricu_collect" collect :: IO CInt +foreign export ccall "tricu_response_size" responseSize :: IO CInt +foreign export ccall "tricu_response_copy" responseCopy :: Ptr () -> CInt -> IO CInt diff --git a/ext/haskell-wasm/browser-bridge.mjs b/ext/haskell-wasm/browser-bridge.mjs @@ -0,0 +1,96 @@ +// Shared site's boundary validators/codecs; no JS evaluator executes programs. +import {decodeBundle,sha256,bundleHash} from './site/bundle.js'; +import {literals,makeLiteral,outputType} from './site/codecs.js'; +import {exportTree,treeFormat} from './site/tree-export.js'; +const utf8=new TextEncoder(); +export function frame(fields){ + const values=fields.map(x=>typeof x==='string'?utf8.encode(x):x); + const size=values.reduce((sum,x)=>sum+4+x.byteLength,0); + if(size>16777216)throw Error('Input byte limit exceeded'); + const bytes=new Uint8Array(size),view=new DataView(bytes.buffer);let at=0; + for(const value of values){view.setUint32(at,value.byteLength,true);at+=4;bytes.set(value,at);at+=value.byteLength;} + return bytes; +} +export function dagBytes(nodes){ + if(!nodes.length||nodes.length>1000000)throw Error('DAG node limit exceeded'); + const bytes=new Uint8Array(8+nodes.length*9),view=new DataView(bytes.buffer); + view.setUint32(0,nodes.length,true);view.setUint32(4,0,true); + for(const [i,node] of nodes.entries()){ + const at=8+i*9; + bytes[at]=node.kind==='leaf'?0:node.kind==='stem'?1:2; + view.setUint32(at+1,node.kind==='stem'?node.child:node.kind==='fork'?node.left:0,true); + view.setUint32(at+5,node.kind==='fork'?node.right:0,true); + } + return bytes; +} +export async function authenticatedBundle(buffer,hash){ + bundleHash(hash); + if(!(buffer instanceof ArrayBuffer)||buffer.byteLength>16777216)throw Error('Bundle byte limit exceeded'); + const bytes=new Uint8Array(buffer.slice(0)); + if(await sha256(bytes)!==hash)throw Error('Bundle SHA-256 mismatch'); + return decodeBundle(bytes); +} +export function loadFields(bundle){return ['load',dagBytes(bundle.nodes),...bundle.exports.flatMap(e=>[e.name,String(e.root)])];} +class DataGraph { + constructor(){this.nodes=[{kind:'leaf'}];this.leaf=0;} + add(node){if(this.nodes.length>=1000000)throw Error('Input DAG limit exceeded');this.nodes.push(node);return this.nodes.length-1;} + stem(child){return this.add({kind:'stem',child});} + fork(left,right){return this.add({kind:'fork',left,right});} + fromTernary(text){ + const pending=[];let root; + for(const c of text){ + if(root!==undefined)throw Error('Trailing ternary data'); + if(c==='1'||c==='2'){pending.push({arity:Number(c),children:[]});continue;} + if(c!=='0')throw Error('Invalid ternary digit'); + let value=this.leaf; + for(;;){ + if(!pending.length){root=value;break;} + const frame=pending.at(-1);frame.children.push(value); + if(frame.children.length<frame.arity)break; + pending.pop();value=frame.arity===1?this.stem(frame.children[0]):this.fork(...frame.children); + } + } + if(root===undefined||pending.length)throw Error('Truncated tree'); + return root; + } +} +export function application(name,args=[]){ + if(typeof name!=='string'||!name||name.length>4096)throw Error('Invalid export name'); + const graph=new DataGraph(),roots=literals(args).map(value=>makeLiteral(graph,value)); + return [name,dagBytes(graph.nodes),...roots.map(String)]; +} +export function evaluation(name,args,codec){const [n,...rest]=application(name,args);return ['evaluate',n,outputType(codec),...rest];} +export function snapshotFields(name,args,limit,full=false){ + if(!Number.isInteger(limit)||limit<1||limit>(full?100000:256))throw Error('Invalid snapshot budget'); + const [n,...rest]=application(name,args);return [full?'snapshot':'preview',n,String(limit),...rest]; +} +// Convert a COMPLETE runtime snapshot to the site's immutable DAG export input. +// IDs are local to this snapshot; no Haskell handle or address is retained. +export function snapshotBundle(snapshot,name){ + if(snapshot.truncated)throw Error('Snapshot limit exceeded; refusing partial export'); + const aliases=new Map(snapshot.aliases.map(a=>[a.from,a.to])),byId=new Map(snapshot.nodes.map(n=>[n.id,n])); + const resolve=id=>{const seen=new Set();while(aliases.has(id)){if(seen.has(id))throw Error('Snapshot alias cycle');seen.add(id);id=aliases.get(id);}return id;}; + const nodes=[],mapped=new Map(),active=new Set(),root=resolve(snapshot.root),stack=[[root,false]]; + while(stack.length){ + const [raw,leave]=stack.pop(),id=resolve(raw); + if(mapped.has(id))continue; + const node=byId.get(id);if(!node)throw Error('Incomplete snapshot'); + if(!leave){ + if(active.has(id))throw Error('Cyclic tree cannot be exported as a DAG'); + active.add(id);stack.push([id,true]);for(const child of [...node.children].reverse())stack.push([child,false]); + }else{ + active.delete(id);const cs=node.children.map(c=>mapped.get(resolve(c))); + if(cs.some(c=>c===undefined))throw Error('Invalid snapshot order'); + mapped.set(id,nodes.length);nodes.push(node.kind==='leaf'?{kind:'leaf'}:node.kind==='stem'?{kind:'stem',child:cs[0]}:{kind:'fork',left:cs[0],right:cs[1]}); + } + } + return {nodes,roots:[mapped.get(root)],exports:[{name,root:mapped.get(root),kind:'term',abi:'arboricx.abi.tree.v1'}],metadata:{}}; +} +export function exportedTree(bundle,name,format){return exportTree(bundle,name,treeFormat(format));} + +export function environmentFields(bundle,names){ + if(!Array.isArray(names)||names.length>4096||names.some(n=>typeof n!=='string'||!n||n.length>128)||new Set(names).size!==names.length)throw Error('Invalid environment bindings'); + const exports=new Map(bundle.exports.map(e=>[e.name,e.root])); + return [dagBytes(bundle.nodes),...names.flatMap(name=>{if(!exports.has(name))throw Error('Missing environment binding '+name);return [name,String(exports.get(name))];})]; +} +export function emptyEnvironment(){return [dagBytes([{kind:'leaf'}])];} diff --git a/ext/haskell-wasm/browser-client.mjs b/ext/haskell-wasm/browser-client.mjs @@ -0,0 +1,38 @@ +// Single-flight Worker ownership. Termination rejects the active request and +// discards the entire runtime; no attempt to interrupt/reuse a poisoned RTS. +export class CompilerWorker { + constructor(){ + this.worker=new Worker(new URL('./worker.mjs',import.meta.url),{type:'module'}); + this.next=0;this.pending=null;this.disposed=false; + this.worker.onmessage=({data})=>{ + const pending=this.pending;if(!pending||pending.id!==data.id)return; + clearTimeout(pending.timer);this.pending=null; + if(data.ok){ + data.text=new TextDecoder('utf-8',{fatal:true}).decode(data.bytes); + data.roundtripMs=performance.now()-pending.start; + pending.resolve(data); + }else{ + pending.reject(Error(data.error)); + if(data.fatal)this.dispose('Fatal runtime error'); + } + }; + this.worker.onerror=event=>{event.preventDefault();this.dispose(event.message||'Worker failed');}; + } + request(message,timeout=null){ + if(this.disposed)return Promise.reject(Error('Disposed Worker')); + if(this.pending)return Promise.reject(Error('Worker is busy')); + if(timeout!==null&&timeout!==Infinity&&(!Number.isFinite(timeout)||timeout<=0))return Promise.reject(Error('Invalid request deadline')); + const id=++this.next,start=performance.now(); + return new Promise((resolve,reject)=>{ + const timer=timeout===null||timeout===Infinity?undefined:setTimeout(()=>this.dispose('Worker deadline exceeded'),timeout); + this.pending={id,resolve,reject,timer,start}; + try{this.worker.postMessage({...message,id});} + catch(error){this.dispose('Request transport failed: '+error.message);} + }); + } + dispose(reason='Worker terminated'){ + if(this.disposed)return; + this.disposed=true;this.worker.terminate(); + if(this.pending){clearTimeout(this.pending.timer);this.pending.reject(Error(reason));this.pending=null;} + } +} diff --git a/ext/haskell-wasm/browser-page.mjs b/ext/haskell-wasm/browser-page.mjs @@ -0,0 +1,87 @@ +import {CompilerWorker} from './client.mjs'; +import {runParity} from './parity.mjs'; +const assert=(v,message)=>{if(!v)throw Error(message);}; +async function rejects(action,pattern){try{await action();}catch(e){assert(pattern.test(e.message),'Unexpected error: '+e.message);return;}throw Error('Expected rejection');} +window.runProbe=async()=>{ + const goldens=await (await fetch('./goldens.json')).json(); + const report={userAgent:navigator.userAgent,cold:[],jobs:[],memory:[],checks:[]}; + // Fresh workers; browser/network/module caches may be warm after the first. + for(let i=0;i<3;i++){ + const w=new CompilerWorker();try{report.cold.push(await w.request({type:'init'}));}finally{w.dispose();} + } + const corrupt=new CompilerWorker(); + try{await rejects(()=>corrupt.request({type:'init',corrupt:'compiler.dag'}),/SHA-256 mismatch/);} + finally{corrupt.dispose();} + const corruptEnv=new CompilerWorker(); + try{await rejects(()=>corruptEnv.request({type:'init',corrupt:'environment.tsv'}),/SHA-256 mismatch/);} + finally{corruptEnv.dispose();} + report.checks.push('corrupt prepared DAG/environment rejected'); + const w=new CompilerWorker(); + try{ + report.initialization=await w.request({type:'init'}); + for(let round=0;round<5;round++)for(const [id,c] of goldens.cases.entries()){ + if(c.mode==='io')continue; + if(c.diagnostic){ + let diagnostic; + try{await w.request({type:'compile',source:c.source});}catch(e){diagnostic=e.message;} + assert(diagnostic===c.diagnostic,'Compiler diagnostic mismatch: '+diagnostic); + continue; + } + const compile=await w.request({type:'compile',source:c.source}); + assert(JSON.stringify(compile.text.trim().split('\n'))===JSON.stringify(Object.keys(c.definitions)),'Export names mismatch'); + const dump=await w.request({type:'dump'}); + assert(dump.text===Object.entries(c.definitions).map(([n,t])=>n+'\t'+t+'\n').join(''),'Complete definitions mismatch'); + const run=await w.request({type:'observe',name:c.export,arguments:c.arguments}); + assert(run.text===c.result,'Compiled execution mismatch'); + report.jobs.push({round,id,compileMs:compile.milliseconds,dumpMs:dump.milliseconds,runMs:run.milliseconds, + roundtripMs:compile.roundtripMs+dump.roundtripMs+run.roundtripMs,linearBytes:run.linearBytes}); + } + const unicode=goldens.cases.find(c=>c.mode==='io'); + await w.request({type:'compile',source:'value = '+JSON.stringify(unicode.input)}); + assert((await w.request({type:'observe',name:'value',arguments:[]})).text===unicode.result,'Unicode scalar source/result mismatch'); + report.checks.push('Unicode source crosses UTF-8 boundary and matches independent scalar-tree golden'); + const identity=goldens.cases[0]; + const compiling=w.request({type:'compile',source:identity.source}); + await rejects(()=>w.request({type:'dump'}),/busy/); + await compiling; + await rejects(()=>w.request({type:'compile',source:'\ud800'}),/Unicode/); + await rejects(()=>w.request({type:'compile',source:'x'.repeat(16385)}),/limit/); + await rejects(()=>w.request({type:'compile',source:goldens.cases.find(c=>c.diagnostic).source}),/Compiler diagnostic/); + assert((await w.request({type:'observe',name:'id',arguments:identity.arguments})).text===identity.result,'Failure destroyed previous definitions'); + await rejects(()=>w.request({type:'observe',name:'id',arguments:['2']}),/invalid ternary/); + assert((await w.request({type:'observe',name:'id',arguments:identity.arguments})).text===identity.result,'Malformed argument poisoned runtime'); + report.checks.push('compiler/Unicode/size/argument errors recover with previous definitions intact'); + for(let round=0;round<30;round++){ + const c=goldens.cases[round%2===0?3:5]; + await w.request({type:'compile',source:c.source}); + const result=await w.request({type:'observe',name:c.export,arguments:c.arguments}); + assert(result.text===c.result,'Repeated execution mismatch'); + const reset=await w.request({type:'reset'});report.memory.push(reset.linearBytes); + } + await rejects(()=>w.request({type:'observe',name:'main',arguments:[]}),/missing export/); + assert(Math.max(...report.memory.slice(15))<=Math.max(...report.memory.slice(0,15))+2*65536,'Linear memory did not plateau'); + report.checks.push('30 compile/run/reset cycles; old exports inaccessible; linear memory plateau'); + await w.request({type:'compile',source:identity.source}); + let ticks=0;const heartbeat=setInterval(()=>ticks++,5); + const start=performance.now(); + try{ + // Known self-application diverger from the site's lazy conformance tests. + await rejects(()=>w.request({type:'observe',name:'id',arguments:['212110021100','212110021100']},75),/deadline/); + }finally{clearInterval(heartbeat);} + report.termination={milliseconds:performance.now()-start,mainThreadTicks:ticks}; + assert(ticks>0,'Main thread blocked'); + await rejects(()=>w.request({type:'dump'}),/Disposed/); + report.checks.push('non-returning reduction terminated; main thread responsive'); + }finally{w.dispose();} + const replacement=new CompilerWorker(); + try{ + await replacement.request({type:'init'}); + await replacement.request({type:'compile',source:goldens.cases[0].source}); + assert((await replacement.request({type:'observe',name:'id',arguments:goldens.cases[0].arguments})).text===goldens.cases[0].result,'Replacement Worker failed'); + report.checks.push('fresh Worker succeeds after cancellation'); + }finally{replacement.dispose();} + // Do not serialize transferred byte buffers into the report. + for(const x of [...report.cold,report.initialization])delete x.bytes; + report.parity=await runParity(); + return report; +}; diff --git a/ext/haskell-wasm/browser-parity.mjs b/ext/haskell-wasm/browser-parity.mjs @@ -0,0 +1,142 @@ +import {CompilerWorker} from './client.mjs'; +import {decodeBundle} from './site/bundle.js'; +import {snapshotBundle,exportedTree} from './bridge.mjs'; +const assert=(value,message)=>{if(!value)throw Error(message);}; +const equal=(a,b,message)=>assert(JSON.stringify(a)===JSON.stringify(b),message+': '+JSON.stringify(a).slice(0,200)); +async function rejects(action,pattern){try{await action();}catch(e){assert(pattern.test(e.message),'Unexpected rejection: '+e.message);return;}throw Error('Expected rejection '+pattern);} +export async function runParity(){ + const index=await(await fetch('./parity/index.json')).json(),demos=await(await fetch('./parity/demos.json')).json(); + const report={checks:[],packages:0,applications:0,ioRuns:0,compiledDemos:0}; + const w=new CompilerWorker(); + const request=async m=>JSON.parse((await w.request(m)).text); + const load=async name=>{ + const bytes=await(await fetch('./parity/'+name+'.arboricx')).arrayBuffer(); + const names=await request({type:'load',bytes,hash:index[name]}); + return {bytes,names}; + }; + async function runIO(name,args,input){ + let event=await request({type:'io-start',name,arguments:args}),terminal='',steps=0; + const trace=[];let awaitingAfter=null; + while(event.kind!=='done'){ + assert(++steps<600,'IO event loop failed to finish'); + trace.push(event.kind+':'+(event.name??'')); + if(event.kind==='before'){assert(awaitingAfter===null,'Missing after trace');awaitingAfter={step:event.step,name:event.name};} + if(event.kind==='after'){assert(awaitingAfter&&event.step===awaitingAfter.step&&event.name===awaitingAfter.name&&typeof event.value==='string','After trace contract');awaitingAfter=null;} + if(event.kind==='write')terminal+=event.text; + const old=event.token; + if(event.kind==='read'){ + await rejects(()=>request({type:'io-resume',token:old,value:'bad\nline'}),/one bounded line/); + await w.request({type:'collect'}); // Paused continuations survive a major GC. + } + event=await request({type:'io-resume',token:old,value:event.kind==='read'?(input??''):''}); + await rejects(()=>request({type:'io-resume',token:old,value:''}),/stale|no IO interaction/); + } + const snapshot=await request({type:'io-snapshot'}); + const tree=exportedTree(snapshotBundle(snapshot,'result'),'result','ternary').text; + report.ioRuns++; + return {terminal,tree,trace}; + } + try{ + const ready=await w.request({type:'init',compiler:false}); + assert(ready.compilerHash===null,'Package-only initialization claims a compiler'); + const stats=await w.request({type:'stats'}); + equal(JSON.parse(stats.text),{linearMemoryBytes:stats.linearBytes},'Stats must report only actual linear memory'); + await rejects(()=>w.request({type:'compile',source:'id x = x'}),/not initialized/); + const identity=await load('identity'),id=identity.names[0]; + await rejects(()=>request({type:'load',bytes:identity.bytes,hash:'0'.repeat(64)}),/SHA-256 mismatch/); + await rejects(()=>load('wrong-profile'),/manifest semantics/); + equal((await request({type:'evaluate',name:id,arguments:[{type:'number',value:'42'}],output:'number'})).value,'42','Failed load changed prior package'); + for(const vector of demos.codecVectors){ + const type=vector.kind,value=type==='number'?String(vector.value):vector.value; + const result=await request({type:'evaluate',name:id,arguments:[{type,value}],output:type}); + equal(result.value,value,'Scalar codec'); + const observed=await w.request({type:'observe',name:id,arguments:[vector.tree]}); + equal(observed.text,vector.tree,'Canonical scalar tree'); + } + for(const value of [true,false])equal((await request({type:'evaluate',name:id,arguments:[{type:'bool',value}],output:'bool'})).value,value,'Boolean codec'); + equal((await request({type:'evaluate',name:id,arguments:[{type:'bytes',value:[0,1,127,128,255]}],output:'bytes'})).value,[0,1,127,128,255],'Byte codec'); + await rejects(()=>request({type:'evaluate',name:id,arguments:[{type:'number',value:'256'}],output:'bool'}),/Boolean/); + await rejects(()=>request({type:'evaluate',name:id,arguments:[{type:'list',value:[{type:'number',value:'256'}]}],output:'bytes'}),/Invalid byte/); + await rejects(()=>request({type:'evaluate',name:id,arguments:[{type:'list',value:[{type:'number',value:'55296'}]}],output:'string'}),/Surrogate/); + const record={type:'list',value:[ + {type:'list',value:[{type:'string',value:'__proto__'},{type:'number',value:'0'},{type:'string',value:'safe'}]}, + {type:'list',value:[{type:'string',value:'n'},{type:'number',value:'1'},{type:'number',value:'42'}]}, + {type:'list',value:[{type:'string',value:'b'},{type:'number',value:'2'},{type:'bool',value:true}]} + ]}; + const decoded=(await request({type:'evaluate',name:id,arguments:[record],output:'record'})).value; + assert(Object.hasOwn(decoded,'__proto__')&&decoded.__proto__==='safe'&&decoded.n===42&&decoded.b===true,'Record codec / prototype key'); + await rejects(()=>request({type:'evaluate',name:id,arguments:[{type:'list',value:[record.value[0],record.value[0]]}],output:'record'}),/unique/); + const list=(await request({type:'evaluate',name:id,arguments:[{type:'list',value:[{type:'number',value:'42'},{type:'bool',value:true}]}],output:'list'})).value; + equal(list.length,2,'List codec');assert(list.every(v=>Array.isArray(v.nodes)),'List previews'); + report.checks.push('authenticated arbitrary bundle load; failed loads preserve previous exports; all seven site codecs and negative controls'); + await load('exponential'); + const preview=await request({type:'preview',name:'root',limit:8}); + assert(preview.truncated&&preview.nodes.length===8,'Bounded preview must truncate'); + const snapshot=await request({type:'snapshot',name:'root',limit:100}); + assert(!snapshot.truncated&&snapshot.nodes.length<=41,'Snapshot expanded sharing'); + const runtimeDag=exportedTree(snapshotBundle(snapshot,'root'),'root','dag'); + assert(runtimeDag.bytes<10000,'Runtime DAG export expanded sharing'); + const immutableDag=await request({type:'export',name:'root',format:'dag'}); + assert(immutableDag.bytes<10000,'Stored DAG export expanded sharing'); + await rejects(()=>request({type:'export',name:'root',format:'ternary'}),/Expanded tree/); + await rejects(()=>Promise.resolve(exportedTree(snapshotBundle(preview,'root'),'root','dag')),/partial export/); + report.checks.push('40-level exponential sharing preserved in runtime snapshot and immutable DAG export; bounded preview and expansion rejection'); + for(const [name,demo] of Object.entries(demos.demos)){ + await load(name);report.packages++; + for(const c of demo.haskellCases){ + const args=c.arguments.map(value=>({type:'tree',value})); + if(demo.mode==='io'){ + const actual=await runIO(c.export,args,c.input); + equal(actual.tree,c.result,'IO result '+name);equal(actual.terminal,c.terminal,'IO terminal '+name); + }else equal((await w.request({type:'observe',name:c.export,arguments:c.arguments})).text,c.result,'Published program '+name); + report.applications++; + } + const stored=await request({type:'export',name:demo.haskellCases[0].export,format:'dag'}); + assert(stored.extension==='dag'&&stored.bytes>0,'Stored definition export'); + } + report.checks.push('all curated published packages and Haskell result/terminal goldens'); + for(const [name,pattern] of [['bad-version',/version/],['unsupported-io',/Unsupported browser IO action/],['abort-io',/aborted/],['output-limit',/output limit/],['action-limit',/action limit/]]){ + await load(name); + await rejects(()=>runIO('root',[],''),pattern); + await request({type:'io-cancel'}); + } + await load('greet'); + let event=await request({type:'io-start',name:demos.demos.greet.haskellCases[0].export,arguments:[]}); + await rejects(()=>load('identity'),/pending/); + await request({type:'io-cancel'}); + await rejects(()=>request({type:'io-resume',token:event.token,value:''}),/no IO interaction/); + await load('identity'); + equal((await request({type:'evaluate',name:id,arguments:[{type:'string',value:'recovered'}],output:'string'})).value,'recovered','IO errors poisoned runtime'); + report.checks.push('IO envelope/version/action/output limits; abort without forcing huge payload; stale-token rejection; GC while awaiting input; explicit cancellation and recovery'); + }finally{w.dispose();} + // A compiler-capable session exercises all profiles without package loading + // replacing the retained compiler/environment. + const c=new CompilerWorker(); + try{ + const ready=await c.request({type:'init'},Infinity); + equal(ready.compilerHash,demos.compilerBundleSha256,'Ready compiler pin'); + const environments=await(await fetch('./parity/environments.json')).json(); + const profile=environments.profiles.practical; + const support=await(await fetch('./parity/environment-practical.arboricx')).arrayBuffer(); + for(const [name,demo] of Object.entries(demos.demos)){ + const environment=demo.supportSourceSha256?{bytes:support,hash:profile.hash,names:profile.names}:null; + await c.request({type:'compile',source:demo.source,environment},60000); + const first=demo.haskellCases[0]; + if(demo.mode!=='io')equal((await c.request({type:'observe',name:first.export,arguments:first.arguments})).text,first.result,'Compiled demo '+name); + const exported=JSON.parse((await c.request({type:'export',name:first.export,format:'dag'})).text); + assert(exported.bytes>0,'Compiled DAG export'); + report.compiledDemos++; + } + await rejects(()=>c.request({type:'compile',source:'x = t',environment:{bytes:support,hash:'0'.repeat(64),names:profile.names}}),/SHA-256 mismatch/); + await c.request({type:'compile',source:'q x = x x\nlazy = t (q q) t\naborted = t "tricuIO" (t 1 (t 2 (q q)))'},null); + const partial=JSON.parse((await c.request({type:'preview',name:'lazy',limit:1},2000)).text); + assert(partial.nodes.length===1&&partial.truncated,'Preview forced excluded divergent child'); + const before=JSON.parse((await c.request({type:'io-start',name:'aborted'},2000)).text); + assert(before.kind==='before'&&before.name==='abortIO','Abort before event'); + await rejects(()=>c.request({type:'io-resume',token:before.token,value:''},2000),/aborted/); + await c.request({type:'io-cancel'}); + report.checks.push('all curated sources compile with empty/practical environments; compiled graph-preserving export; environment authentication; ready compiler pin'); + report.checks.push('preview and abort trace do not force excluded divergent payloads; null/Infinity disable request timer'); + }finally{c.dispose();} + return report; +} diff --git a/ext/haskell-wasm/browser-worker.mjs b/ext/haskell-wasm/browser-worker.mjs @@ -0,0 +1,134 @@ +import {WASI,File,OpenFile,ConsoleStdout,PreopenDirectory} from './shim/index.js'; +import pins,{compilerHash} from './pins.mjs'; +import {frame,authenticatedBundle,loadFields,application,evaluation,snapshotFields,snapshotBundle,exportedTree,environmentFields,emptyEnvironment} from './bridge.mjs'; +let instance,booted=false,dead=false,stderr="",storedBundle=null,busy=false; +const encoder=new TextEncoder(),decoder=new TextDecoder('utf-8',{fatal:true}); +function scalarText(s,limit){ + if(typeof s!=='string')throw Error('Expected string'); + let count=0; + for(const c of s){const n=c.codePointAt(0);if(++count>limit||n>=0xd800&&n<=0xdfff)throw Error('Invalid Unicode or scalar limit');} + return s; +} +async function checkedFile(name,corrupt){ + const response=await fetch(new URL('data/'+name,import.meta.url)); + if(!response.ok)throw Error('Data fetch failed'); + const bytes=new Uint8Array(await response.arrayBuffer()); + // Test-only fault injection can corrupt, never replace the trusted pin. + if(corrupt&&bytes.length)bytes[0]^=1; + const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),b=>b.toString(16).padStart(2,'0')).join(''); + if(hash!==pins[name])throw Error('Prepared input SHA-256 mismatch'); + return new File(bytes,{readonly:true}); +} +function call(name,text){ + const e=instance.exports; + let ptr=0,status; + try{ + if(text===undefined)status=e[name](); + else{ + const bytes=typeof text==='string'?encoder.encode(text):text; + if(bytes.length>16777216)throw Error('Input byte limit'); + ptr=e.malloc(Math.max(bytes.length,1));if(!ptr)throw Error('Allocation failed'); + new Uint8Array(e.memory.buffer,ptr,bytes.length).set(bytes); + status=e[name](ptr,bytes.length); + } + }finally{if(ptr)e.free(ptr);} + const length=e.tricu_response_size(); + if(length<0||length>16777216)throw Error('Invalid response length'); + ptr=e.malloc(Math.max(length,1));if(!ptr)throw Error('Allocation failed'); + let output; + try{ + if(e.tricu_response_copy(ptr,length)!==length)throw Error('Response copy failed'); + // Refresh the view after every call: memory.grow detaches old views. + output=new Uint8Array(e.memory.buffer,ptr,length).slice(); + }finally{e.free(ptr);} + const decoded=decoder.decode(output); + if(status!==0){const error=Error(decoded.replace(/^error\n/,''));error.recoverable=status===1;throw error;} + if(!decoded.startsWith('ok\n'))throw Error('Invalid response tag'); + return output.subarray(3).slice().buffer; +} +onmessage=async({data:m})=>{ + if(busy){postMessage({id:m.id,ok:false,error:'Worker is busy',fatal:false});return;} + busy=true; + const begin=performance.now(); + try{ + if(dead)throw Error('Disposed runtime'); + let bytes,phases; + const encode=value=>encoder.encode(JSON.stringify(value)).buffer; + const operation=fields=>call('tricu_request',frame(fields)); + const safe=async action=>{try{return await action();}catch(e){e.recoverable=true;throw e;}}; + if(m.type==='init'){ + if(booted)throw Error('Already initialized');booted=true; + const files=m.compiler===false?[]:await Promise.all(Object.keys(pins).map(async name=>[name,await checkedFile(name,m.corrupt===name)])); + const fetched=performance.now(); + const response=await fetch(new URL('reactor.wasm',import.meta.url)); + if(!response.ok)throw Error('WASM fetch failed'); + const module=await WebAssembly.compileStreaming(response); + const compiled=performance.now(); + const wasi=new WASI([],[],[new OpenFile(new File([])),ConsoleStdout.lineBuffered(console.log),ConsoleStdout.lineBuffered(s=>{stderr=(stderr+s+"\n").slice(-4096);}),new PreopenDirectory('/data',files)]); + instance=await WebAssembly.instantiate(module,{wasi_snapshot_preview1:wasi.wasiImport}); + wasi.initialize(instance);instance.exports.tricu_rts_init(); + const initialized=performance.now(); + bytes=call(m.compiler===false?'tricu_initialize_empty':'tricu_initialize'); + phases={data_ms:fetched-begin,wasm_fetch_compile_ms:compiled-fetched,instantiate_rts_ms:initialized-compiled,load_ms:performance.now()-initialized}; + }else{ + if(!instance)throw Error('Not initialized'); + if(m.type==='compile'){ + let source; + try{source=scalarText(m.source,16384);if(!source.trim())throw Error('Enter a standalone source unit');}catch(e){e.recoverable=true;throw e;} + if(m.environment===undefined)bytes=call('tricu_compile',source); + else{ + let fields; + if(m.environment===null)fields=emptyEnvironment(); + else{ + const env=await safe(()=>authenticatedBundle(m.environment.bytes,m.environment.hash)); + fields=await safe(()=>environmentFields(env,m.environment.names)); + } + bytes=operation(['compile',source,...fields]); + } + storedBundle=null; + }else if(m.type==='load'){ + const bundle=await safe(()=>authenticatedBundle(m.bytes,m.hash)); + const fields=await safe(()=>loadFields(bundle)); + bytes=operation(fields);storedBundle=bundle; + }else if(m.type==='evaluate'){ + const fields=await safe(()=>evaluation(m.name,m.arguments,m.output)); + bytes=operation(fields); + }else if(m.type==='preview'||m.type==='snapshot'){ + const fields=await safe(()=>snapshotFields(m.name,m.arguments,m.limit??(m.type==='preview'?64:100000),m.type==='snapshot')); + bytes=operation(fields); + }else if(m.type==='export'){ + if(storedBundle)bytes=encode(await safe(()=>exportedTree(storedBundle,m.name,m.format))); + else{ + const fields=await safe(()=>snapshotFields(m.name,[],m.limit??100000,true)); + const snapshot=JSON.parse(decoder.decode(operation(fields))); + bytes=encode(await safe(()=>exportedTree(snapshotBundle(snapshot,m.name),m.name,m.format))); + } + }else if(m.type==='io-start'){ + const args=await safe(()=>application(m.name,m.arguments)); + bytes=operation(['io-start',...args]); + }else if(m.type==='io-resume'){ + await safe(()=>{if(!Number.isSafeInteger(m.token)||m.token<1)throw Error('Invalid IO token');scalarText(m.value??'',256);}); + bytes=operation(['io-resume',String(m.token),m.value??'']); + }else if(m.type==='stats'){ + bytes=encode({linearMemoryBytes:instance.exports.memory.buffer.byteLength}); + }else if(m.type==='io-cancel'){ + bytes=operation(['io-cancel']); + }else if(m.type==='io-snapshot'){ + bytes=operation(['io-snapshot',String(m.limit??100000)]); + }else if(m.type==='io-view'){ + bytes=operation(['io-view',String(m.output)]); + }else if(m.type==='observe'){ + // The private wire uses newline-separated names/ternary arguments. + if(typeof m.name!=='string'||!m.name||m.name.length>128||/[\r\n]/.test(m.name)||!Array.isArray(m.arguments)||m.arguments.length>16||m.arguments.some(x=>typeof x!=='string'||x.length>50000||!/^[012]+$/.test(x))){const e=Error('Invalid observation request');e.recoverable=true;throw e;} + bytes=call('tricu_observe',[m.name,...m.arguments].join('\n')); + }else if(['dump','reset','collect'].includes(m.type)){bytes=call('tricu_'+m.type);if(m.type==='reset')storedBundle=null;} + else{const e=Error('Unknown operation');e.recoverable=true;throw e;} + } + postMessage({id:m.id,ok:true,bytes,milliseconds:performance.now()-begin,linearBytes:instance.exports.memory.buffer.byteLength,phases,compilerHash:m.type==='init'?(m.compiler===false?null:compilerHash):undefined},[bytes]); + }catch(error){ + const fatal=m.type==='init'||!error.recoverable; + if(fatal)dead=true; + postMessage({id:m.id,ok:false,error:error.message+(stderr?"\n"+stderr:""),fatal}); + if(fatal)close(); + }finally{busy=false;} +}; diff --git a/ext/haskell-wasm/browser.nix b/ext/haskell-wasm/browser.nix @@ -0,0 +1,40 @@ +{ pkgs, toolchain, shimSource, probe }: +pkgs.runCommand "tricu-haskell-wasm-browser" { + nativeBuildInputs = [ toolchain pkgs.typescript pkgs.python3 ]; +} '' + export HOME=$TMPDIR + mkdir -p $out/shim objects + cp -r ${shimSource}/src shim-src + tsc --target es2022 --module es2022 --lib esnext,dom --skipLibCheck \ + --outDir $out/shim shim-src/*.ts + cp ${shimSource}/LICENSE-* $out/shim/ + cp ${./CompilerProbe.hs} CompilerProbe.hs + cp ${./BrowserRuntime.hs} BrowserRuntime.hs + wasm32-wasi-ghc -O2 -rtsopts -i${../../src} -i. -outputdir objects \ + ${./Reactor.hs} ${./reactor-init.c} -no-hs-main -optl-mexec-model=reactor \ + -optl-Wl,--max-memory=536870912 \ + -optl-Wl,--export=tricu_rts_init,--export=malloc,--export=free \ + -optl-Wl,--export=tricu_initialize,--export=tricu_compile,--export=tricu_observe \ + -optl-Wl,--export=tricu_request,--export=tricu_initialize_empty \ + -optl-Wl,--export=tricu_dump,--export=tricu_reset,--export=tricu_collect \ + -optl-Wl,--export=tricu_response_size,--export=tricu_response_copy \ + -o $out/reactor.wasm + cp -r ${../demo-site/dist} $out/site + cp ${./browser-bridge.mjs} $out/bridge.mjs + cp ${./browser-worker.mjs} $out/worker.mjs + cp ${./browser-client.mjs} $out/client.mjs + node ${./prepare-parity.mjs} ${../demo-site} $out/parity ${../../test/fixtures} + cp ${./browser-parity.mjs} $out/parity.mjs + cp ${./browser-page.mjs} $out/page.mjs + cp -r ${probe}/compiler-data $out/data + cp ${../demo-site/test/compiler-conformance.json} $out/goldens.json + python3 - "$out" <<'PY' +import hashlib,json,pathlib,sys +p=pathlib.Path(sys.argv[1]) +pins={name:hashlib.sha256((p/'data'/name).read_bytes()).hexdigest() for name in ['compiler.dag','environment.tsv']} +compiler=json.loads((p/'data/manifest.json').read_text())['compilerSha256'] +(p/'pins.mjs').write_text('export default '+json.dumps(pins)+';\nexport const compilerHash='+json.dumps(compiler)+';\n') +PY + echo '<!doctype html><meta charset="utf-8"><title>Haskell WASM probe</title><script type="module" src="page.mjs"></script>' > $out/index.html + cp ${probe}/versions.txt $out/versions.txt +'' diff --git a/ext/haskell-wasm/build.nix b/ext/haskell-wasm/build.nix @@ -0,0 +1,33 @@ +{ pkgs, toolchain }: +pkgs.runCommand "tricu-haskell-wasm-probe" { + nativeBuildInputs = [ toolchain pkgs.haskellPackages.ghc pkgs.nodejs pkgs.python3 ]; +} '' + mkdir -p $out/bin native wasm + export HOME=$TMPDIR XDG_CACHE_HOME=$TMPDIR/cache + ghc -O2 -rtsopts -i${../../src} -outputdir native ${./Probe.hs} -o $out/bin/probe-native + wasm32-wasi-ghc -O2 -rtsopts -i${../../src} -outputdir wasm ${./Probe.hs} -o $out/bin/probe.wasm + ln -s ${pkgs.nodejs}/bin/node $out/bin/node + ln -s ${../demo-site} $out/site + cp ${./node-wasi.mjs} $out/bin/node-wasi.mjs + cp ${./prepare-compiler.mjs} $out/bin/prepare-compiler.mjs + cp ${./ts-compiler.mjs} $out/bin/ts-compiler.mjs + mkdir -p compiler-native compiler-wasm $out/compiler-data + ghc -O2 -rtsopts -i${../../src} -outputdir compiler-native -main-is CompilerProbe ${./CompilerProbe.hs} -o $out/bin/compiler-native + wasm32-wasi-ghc -O2 -rtsopts -i${../../src} -outputdir compiler-wasm -main-is CompilerProbe ${./CompilerProbe.hs} -o $out/bin/compiler.wasm + node ${./prepare-compiler.mjs} ${../demo-site} $out/compiler-data + printf 'main = putStrLn "hello wasm"\n' > Hello.hs + wasm32-wasi-ghc Hello.hs -o $out/bin/hello.wasm + test "$(wasmtime $out/bin/hello.wasm)" = 'hello wasm' + $out/bin/probe-native check + wasmtime $out/bin/probe.wasm check + for kind in codec deep distribution; do + $out/bin/probe-native "$kind" 64 2 + wasmtime $out/bin/probe.wasm "$kind" 64 2 + done + { ghc --version; wasm32-wasi-ghc --version; cabal --version; + wasm32-wasi-clang --version; wasmtime --version; ${pkgs.nodejs}/bin/node --version; + wasm32-wasi-ghc-pkg list; + } > $out/versions.txt + ln -s ${toolchain}/bin/wasmtime $out/bin/wasmtime + python3 ${./check-compiler.py} $out +'' diff --git a/ext/haskell-wasm/check-browser.mjs b/ext/haskell-wasm/check-browser.mjs @@ -0,0 +1,68 @@ +// Opt-in dependency-free Chromium CDP check. Run via the pinned Nix shell. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createServer} from 'node:http'; +import {readFile,mkdtemp,rm,writeFile} from 'node:fs/promises'; +import {resolve,extname,sep} from 'node:path'; +import {tmpdir} from 'node:os'; +import {createHash} from 'node:crypto'; +import {gzipSync} from 'node:zlib'; +const [directory,output,chromium='chromium']=process.argv.slice(2),root=resolve(directory); +const server=createServer(async(req,res)=>{ + try{ + const pathname=decodeURIComponent(new URL(req.url,'http://localhost').pathname); + const file=resolve(root,'.'+(pathname==='/'?'/index.html':pathname)); + if(!file.startsWith(root+sep))throw Error('Outside root'); + res.setHeader('Cache-Control','no-store'); + res.setHeader('Content-Type',({'.html':'text/html','.mjs':'text/javascript','.js':'text/javascript','.json':'application/json','.wasm':'application/wasm'})[extname(file)]||'application/octet-stream'); + res.end(await readFile(file)); + }catch{res.writeHead(404);res.end();} +}); +await new Promise(r=>server.listen(0,'127.0.0.1',r)); +const profile=await mkdtemp(resolve(tmpdir(),'haskell-wasm-browser-')); +let browser,socket,sequence=0;const pending=new Map(),exceptions=[]; +try{ + browser=spawn(chromium,['--headless','--no-sandbox','--disable-dev-shm-usage','--remote-debugging-port=0',`--user-data-dir=${profile}`,'about:blank'],{stdio:['ignore','ignore','pipe']}); + const endpoint=await new Promise((resolve,reject)=>{ + let log='';const timer=setTimeout(()=>reject(Error('Browser startup timeout: '+log)),20000); + browser.on('error',e=>{clearTimeout(timer);reject(e);}); + browser.on('exit',code=>{clearTimeout(timer);reject(Error('Browser exited '+code+': '+log));}); + browser.stderr.on('data',b=>{log=(log+b).slice(-16000);const m=log.match(/DevTools listening on (ws:\/\/\S+)/);if(m){clearTimeout(timer);resolve(new URL(m[1]));}}); + }); + const tab=await (await fetch(`http://${endpoint.host}/json/new?${encodeURIComponent('http://127.0.0.1:'+server.address().port+'/')}`,{method:'PUT'})).json(); + socket=new WebSocket(tab.webSocketDebuggerUrl); + await new Promise((r,j)=>{socket.onopen=r;socket.onerror=j;}); + socket.onmessage=({data})=>{ + const m=JSON.parse(data); + if(m.id){const p=pending.get(m.id);if(!p)return;pending.delete(m.id);clearTimeout(p.timer);m.error?p.reject(Error(JSON.stringify(m.error))):p.resolve(m.result);} + else if(m.method==='Runtime.exceptionThrown')exceptions.push(m.params); + }; + const send=(method,params={})=>new Promise((resolve,reject)=>{ + const id=++sequence,timer=setTimeout(()=>{pending.delete(id);reject(Error('CDP timeout: '+method));},120000); + pending.set(id,{resolve,reject,timer});socket.send(JSON.stringify({id,method,params})); + }); + await send('Runtime.enable'); + for(let i=0;i<100;i++){ + const r=await send('Runtime.evaluate',{expression:'typeof window.runProbe'}); + if(r.result.value==='function')break; + await new Promise(r=>setTimeout(r,100)); + } + const result=await send('Runtime.evaluate',{expression:'window.runProbe()',awaitPromise:true,returnByValue:true}); + assert.ok(!result.exceptionDetails,JSON.stringify(result.exceptionDetails)); + assert.equal(exceptions.length,0,JSON.stringify(exceptions)); + const report=result.result.value; + report.package=root; + report.browser=await send('Browser.getVersion'); + report.versions=await readFile(resolve(root,'versions.txt'),'utf8'); + report.manifest=JSON.parse(await readFile(resolve(root,'data/manifest.json'))); + const bytes=await readFile(resolve(root,'reactor.wasm')); + report.artifact={bytes:bytes.length,gzipBytes:gzipSync(bytes).length,sha256:createHash('sha256').update(bytes).digest('hex')}; + await writeFile(output,JSON.stringify(report,null,2)+'\n'); + console.log(JSON.stringify({checks:report.checks,parity:report.parity,initialization:report.initialization,termination:report.termination,artifact:report.artifact,memory:report.memory},null,2)); +}finally{ + if(socket)socket.close(); + for(const p of pending.values())clearTimeout(p.timer); + if(browser&&browser.exitCode===null){const exited=new Promise(r=>browser.once('exit',r));browser.kill('SIGKILL');await exited;} + await new Promise(r=>server.close(r)); + await rm(profile,{recursive:true,force:true,maxRetries:5,retryDelay:100}); +} diff --git a/ext/haskell-wasm/check-compiler.py b/ext/haskell-wasm/check-compiler.py @@ -0,0 +1,64 @@ +"""Correctness/security controls for the opt-in prepared-input benchmark.""" +import json +import pathlib +import shutil +import struct +import subprocess +import sys +import tempfile + +root = pathlib.Path(sys.argv[1]) +data = root / 'compiler-data' +manifest = json.loads((data/'manifest.json').read_text()) + +def commands(directory, case): + return [ + [str(root/'bin/compiler-native'),str(directory),str(directory/str(case))], + [str(root/'bin/wasmtime'),'run','--dir',f'{directory}::/data',str(root/'bin/compiler.wasm'),'/data','/data/'+str(case)], + [str(root/'bin/node'),str(root/'bin/node-wasi.mjs'),str(root/'bin/compiler.wasm'),str(directory),str(case)], + ] + +def check(command, error=None, rts=True): + result = subprocess.run(command + (['+RTS','-T','-M512m','-K8m','-RTS'] if rts else []), + capture_output=True,text=True,timeout=120) + if error is None: + assert result.returncode == 0, result.stderr + if rts: + metrics=json.loads(result.stdout) + assert metrics['allocated_bytes']>0 + else: + assert result.returncode != 0, 'negative control unexpectedly succeeded' + assert error in result.stderr, result.stderr + +for case in manifest['cases']: + for command in commands(data,case['id']): + check(command) +# TS oracle smoke is not a timing gate. +check([str(root/'bin/node'),str(root/'bin/ts-compiler.mjs'),str(root/'site'),'0'],rts=False) +with tempfile.TemporaryDirectory() as tmp: + tmp=pathlib.Path(tmp) + bad=tmp/'bad'; shutil.copytree(data,bad) + (bad/'0/expected').write_text('ok\nid\t0\n') + for command in commands(bad,0): check(command,'compiler golden mismatch') + shutil.copyfile(data/'0/expected',bad/'0/expected') + b=bytearray((bad/'compiler.dag').read_bytes()) + b[8]=1; struct.pack_into('<I',b,9,0) # node zero referring to itself + (bad/'compiler.dag').write_bytes(b) + for command in commands(bad,0): check(command,'invalid DAG record') + (bad/'compiler.dag').write_bytes(b'bad') + for command in commands(bad,0): check(command,'truncated DAG') + site=tmp/'site'; site.mkdir() + original=root/'site' + for name in ['dist','bundles','test']: + (site/name).symlink_to((original/name).resolve(),target_is_directory=True) + descriptor=json.loads((original/'compiler.json').read_text()) + # Corrupt bytes under the correct pinned filename: authentication must fail. + (site/'bundles').unlink(); (site/'bundles').mkdir() + (site/'bundles'/(descriptor['hash']+'.arboricx')).write_bytes(b'corrupt') + (site/'compiler.json').write_text(json.dumps(descriptor)) + check([str(root/'bin/node'),str(root/'bin/prepare-compiler.mjs'),str(site),str(tmp/'prepared')], + 'AssertionError',rts=False) +# No preopen: cannot reach the compiler inputs. +check([str(root/'bin/wasmtime'),'run',str(root/'bin/compiler.wasm'),'/data','/data/0'], + 'does not exist') +print('compiler goldens and negative controls passed') diff --git a/ext/haskell-wasm/check-firefox.mjs b/ext/haskell-wasm/check-firefox.mjs @@ -0,0 +1,77 @@ +// Dependency-free stock Firefox check, using the same page/Worker assertions +// as Chromium. The localhost harness receives the report; no patched browser, +// extension, WebDriver or application changes are needed. +import assert from 'node:assert/strict'; +import {spawn,execFileSync} from 'node:child_process'; +import {createServer} from 'node:http'; +import {readFile,mkdtemp,rm,writeFile} from 'node:fs/promises'; +import {resolve,extname,sep} from 'node:path'; +import {tmpdir} from 'node:os'; +import {createHash,randomBytes} from 'node:crypto'; +import {gzipSync} from 'node:zlib'; +const [directory,output,firefox='firefox']=process.argv.slice(2),root=resolve(directory); +const token=randomBytes(24).toString('hex'); +let deliver,fail; +const completion=new Promise((resolve,reject)=>{deliver=resolve;fail=reject;}); +// Register a rejection handler before asynchronous startup steps. +completion.catch(()=>{}); +const server=createServer(async(req,res)=>{ + try{ + const pathname=decodeURIComponent(new URL(req.url,'http://localhost').pathname); + if(pathname==='/report/'+token&&req.method==='POST'){ + const chunks=[];let size=0; + for await(const chunk of req){size+=chunk.length;if(size>2*1024*1024)throw Error('Report too large');chunks.push(chunk);} + const result=JSON.parse(Buffer.concat(chunks).toString('utf8')); + res.writeHead(200);res.end('received'); + result.ok?deliver(result.report):fail(Error(result.error));return; + } + if(req.method!=='GET')throw Error('Unsupported method'); + res.setHeader('Cache-Control','no-store'); + if(pathname==='/'){ + res.setHeader('Content-Type','text/html'); + res.end(`<!doctype html><meta charset="utf-8"><title>Firefox Haskell WASM check</title><script type="module"> + let sent=false; + async function finish(result){if(sent)return;sent=true;await fetch('/report/${token}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(result)});} + addEventListener('error',e=>finish({ok:false,error:e.message})); + addEventListener('unhandledrejection',e=>finish({ok:false,error:String(e.reason?.stack||e.reason)})); + try{await import('./page.mjs');await finish({ok:true,report:await window.runProbe()});} + catch(e){await finish({ok:false,error:String(e.stack||e)});} + </script>`);return; + } + const file=resolve(root,'.'+pathname); + if(!file.startsWith(root+sep))throw Error('Outside root'); + res.setHeader('Content-Type',({'.html':'text/html','.mjs':'text/javascript','.js':'text/javascript','.json':'application/json','.wasm':'application/wasm'})[extname(file)]||'application/octet-stream'); + res.end(await readFile(file)); + }catch(e){res.writeHead(404);res.end();if(req.url.startsWith('/report/'))fail(e);} +}); +await new Promise(r=>server.listen(0,'127.0.0.1',r)); +const profile=await mkdtemp(resolve(tmpdir(),'haskell-wasm-firefox-')); +let browser,timer,log=''; +try{ + const version=execFileSync(firefox,['--version'],{encoding:'utf8',timeout:20000}).trim(); + browser=spawn(firefox,['--headless','--no-remote','--new-instance','--profile',profile,`http://127.0.0.1:${server.address().port}/`], + {stdio:['ignore','pipe','pipe'],detached:true}); + browser.stdout.on('data',b=>{log=(log+b).slice(-16000);}); + browser.stderr.on('data',b=>{log=(log+b).slice(-16000);}); + browser.on('error',fail); + browser.on('exit',code=>fail(Error('Firefox exited '+code+'\n'+log))); + timer=setTimeout(()=>fail(Error('Firefox test timeout\n'+log)),120000); + const report=await completion; + assert.match(report.userAgent,/Firefox\//); + report.package=root;report.browser={product:version}; + report.versions=await readFile(resolve(root,'versions.txt'),'utf8'); + report.manifest=JSON.parse(await readFile(resolve(root,'data/manifest.json'))); + const bytes=await readFile(resolve(root,'reactor.wasm')); + report.artifact={bytes:bytes.length,gzipBytes:gzipSync(bytes).length,sha256:createHash('sha256').update(bytes).digest('hex')}; + await writeFile(output,JSON.stringify(report,null,2)+'\n'); + console.log(JSON.stringify({browser:report.browser,checks:report.checks,parity:report.parity,initialization:report.initialization,termination:report.termination,artifact:report.artifact,memory:report.memory},null,2)); +}finally{ + clearTimeout(timer); + if(browser?.pid){ + const exited=browser.exitCode===null?new Promise(r=>browser.once('exit',r)):Promise.resolve(); + try{process.kill(-browser.pid,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;} + await exited; + } + server.closeAllConnections();await new Promise(r=>server.close(r)); + await rm(profile,{recursive:true,force:true,maxRetries:5,retryDelay:100}); +} diff --git a/ext/haskell-wasm/measure-compiler.py b/ext/haskell-wasm/measure-compiler.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Compare the site's pinned compiler on native Haskell and Wasmtime.""" +import argparse +import gzip +import hashlib +import json +import os +from pathlib import Path +import platform +import statistics +import subprocess +import tempfile +import time + +p = argparse.ArgumentParser() +p.add_argument('package', type=Path) +p.add_argument('output', type=Path) +p.add_argument('--samples', type=int, default=5) +a = p.parse_args() +assert a.samples > 0 +root = a.package.resolve() +data = root / 'compiler-data' +manifest = json.loads((data / 'manifest.json').read_text()) +assert hashlib.sha256((data / 'compiler.dag').read_bytes()).hexdigest() == manifest['dagSha256'] +report = dict(package=str(root), manifest=manifest, versions=(root/'versions.txt').read_text(), platform=platform.platform(), + samples=[], artifacts={}, limits={'timeout_s':120, 'heap':'512m', 'haskell_stack':'8m', 'ts_graph_nodes':6000000, 'ts_graph_stack':100000}, + scope='Trusted prepared DAG, not bundle authentication in WASM. Node/V8 command execution is not browser validation; no JS tree transfer/IO. TS loads/authenticates the original bundle; Haskell loads a prepared DAG, so loading times are not like-for-like. RTS memory is not total engine RSS. Names and full-normal-form boundaries both measured; goldens checked in every process. Native and WASM GHC versions differ.') +for name in ['compiler-native','compiler.wasm']: + b=(root/'bin'/name).read_bytes() + report['artifacts'][name]=dict(bytes=len(b), gzip_bytes=len(gzip.compress(b,mtime=0)),sha256=hashlib.sha256(b).hexdigest()) +with tempfile.TemporaryDirectory(prefix='tricu-haskell-compiler-') as tmp: + env=dict(os.environ,XDG_CACHE_HOME=tmp) + for sample in range(a.samples): + for case in manifest['cases']: + i=str(case['id']) + hosts={'native':[str(root/'bin/compiler-native'),str(data),str(data/i)], + 'wasmtime':[str(root/'bin/wasmtime'),'run','-C','cache=n','--dir',f'{data}::/data',str(root/'bin/compiler.wasm'),'/data','/data/'+i], + 'node-wasm':[str(root/'bin/node'),str(root/'bin/node-wasi.mjs'),str(root/'bin/compiler.wasm'),str(data),i], + 'node-ts':[str(root/'bin/node'),str(root/'bin/ts-compiler.mjs'),str(root/'site'),i]} + for host in list(hosts) if sample%2==0 else list(reversed(hosts)): + start=time.monotonic() + result=subprocess.run(hosts[host]+([] if host=='node-ts' else ['+RTS','-T','-M512m','-K8m','-RTS']),capture_output=True,text=True,env=env,timeout=120) + elapsed=time.monotonic()-start + row=dict(host=host,case=case['id'],sample=sample,process_s=elapsed,returncode=result.returncode) + if result.returncode: + row.update(stdout=result.stdout,stderr=result.stderr) + else: + row.update(json.loads(result.stdout)) + if host!='node-ts': + row['compile_names_s']=row['whnf_s']+row['names_s'] + row['compile_complete_s']=row['compile_names_s']+row['complete_s'] + report['samples'].append(row) + a.output.write_text(json.dumps(report,indent=2)+'\n') + print(host,i,sample, result.returncode, row.get('compile_complete_s'),flush=True) +report['medians']=[] +for case in manifest['cases']: + for host in ['native','wasmtime','node-wasm','node-ts']: + rows=[r for r in report['samples'] if r['case']==case['id'] and r['host']==host and r['returncode']==0] + if len(rows)==a.samples: + report['medians'].append(dict(case=case['id'],host=host,**{k:statistics.median(r[k] for r in rows) for k in + (['load_s','compile_names_s','compile_complete_s','run_s','process_s'] + ([] if host=='node-ts' else ['allocated_bytes','max_live_bytes','max_mem_in_use_bytes']))})) +a.output.write_text(json.dumps(report,indent=2)+'\n') +assert all(r['returncode']==0 for r in report['samples']), 'workload failures recorded in report' diff --git a/ext/haskell-wasm/measure.py b/ext/haskell-wasm/measure.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Fresh-process smoke measurements, not compiler or browser benchmarks.""" +import argparse +import gzip +import hashlib +import json +import os +from pathlib import Path +import platform +import statistics +import subprocess +import tempfile +import time + +p = argparse.ArgumentParser() +p.add_argument('package', type=Path) +p.add_argument('output', type=Path) +p.add_argument('--samples', type=int, default=5) +a = p.parse_args() +assert a.samples > 0 +root = a.package.resolve() +report = {'versions': (root / 'versions.txt').read_text(), + 'platform': platform.platform(), 'samples': [], 'artifacts': {}, + 'limits': {'timeout_s': 60, 'haskell_heap': '512m', 'haskell_stack': '8m'}, + 'scope': 'Synthetic reducer/codec smoke only; no bundles, compiler, browser, or JS transfer. Native and WASM GHC versions differ. External time includes process startup and engine compilation. No peak-memory measurement.'} +for name in ['probe-native', 'probe.wasm', 'hello.wasm']: + data = (root / 'bin' / name).read_bytes() + report['artifacts'][name] = {'bytes': len(data), 'gzip_bytes': len(gzip.compress(data, mtime=0)), + 'sha256': hashlib.sha256(data).hexdigest()} +with tempfile.TemporaryDirectory(prefix='tricu-haskell-wasm-') as tmp: + env = dict(os.environ, XDG_CACHE_HOME=tmp) + # Disable engine caching explicitly: compilation remains in external timings. + hosts = {'native': [str(root / 'bin/probe-native')], + 'wasmtime': [str(root / 'bin/wasmtime'), 'run', '-C', 'cache=n', str(root / 'bin/probe.wasm')]} + for sample in range(a.samples): + for kind, n, rounds in [('codec', 65536, 16), ('deep', 100000, 16), ('distribution', 100000, 16)]: + for host in (list(hosts) if sample % 2 == 0 else list(reversed(hosts))): + command = hosts[host] + [kind, str(n), str(rounds), '+RTS', '-M512m', '-K8m', '-RTS'] + start = time.monotonic() + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=60, check=True) + elapsed = time.monotonic() - start + report['samples'].append(dict(json.loads(result.stdout), host=host, sample=sample, process_s=elapsed)) +report['medians'] = [] +for kind in ['codec', 'deep', 'distribution']: + for host in hosts: + rows = [r for r in report['samples'] if r['kind'] == kind and r['host'] == host] + report['medians'].append(dict(kind=kind, host=host, **{ + key: statistics.median(r[key] for r in rows) for key in ['build_s', 'reduce_s', 'observe_s', 'process_s']}, + evaluation_observation_s=statistics.median(r['reduce_s']+r['observe_s'] for r in rows))) +a.output.write_text(json.dumps(report, indent=2) + '\n') +print(json.dumps(report['medians'], indent=2)) diff --git a/ext/haskell-wasm/node-wasi.mjs b/ext/haskell-wasm/node-wasi.mjs @@ -0,0 +1,8 @@ +// Node/V8 execution is not browser validation. Grant only the prepared data dir. +import {WASI} from 'node:wasi'; +import {readFile} from 'node:fs/promises'; +const [wasm,data,id,...rts]=process.argv.slice(2); +const wasi=new WASI({version:'preview1',args:[wasm,'/data','/data/'+id,...rts],preopens:{'/data':data}}); +const module=await WebAssembly.compile(await readFile(wasm)); +const instance=await WebAssembly.instantiate(module,{wasi_snapshot_preview1:wasi.wasiImport}); +process.exitCode=wasi.start(instance) ?? 0; diff --git a/ext/haskell-wasm/prepare-compiler.mjs b/ext/haskell-wasm/prepare-compiler.mjs @@ -0,0 +1,45 @@ +// Trusted benchmark transport only. Authenticate and validate the site's bundle +// before converting to a compact DAG; this is not a public bundle loader. +import {readFile, writeFile, mkdir} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import assert from 'node:assert/strict'; +import {pathToFileURL} from 'node:url'; +import path from 'node:path'; +const [site, output] = process.argv.slice(2); +const {decodeBundle, COMPILER_BUNDLE_LIMIT} = await import(pathToFileURL(path.resolve(site,'dist/bundle.js'))); +const {CONTRACT_BINDINGS} = await import(pathToFileURL(path.resolve(site,'dist/contracts.js'))); +const descriptor = JSON.parse(await readFile(path.join(site,'compiler.json'))); +const vectors = JSON.parse(await readFile(path.join(site,'test/compiler-conformance.json'))); +const bytes = await readFile(path.join(site,'bundles',descriptor.hash+'.arboricx')); +assert.equal(createHash('sha256').update(bytes).digest('hex'), descriptor.hash); +assert.equal(descriptor.hash,vectors.compilerBundleSha256); +assert.equal(descriptor.export,vectors.export); +const bundle = decodeBundle(bytes,COMPILER_BUNDLE_LIMIT); +const exports = new Map(bundle.exports.map(e=>[e.name,e.root])); +const root = exports.get(descriptor.export); assert.notEqual(root,undefined); +await mkdir(output,{recursive:true}); +const dag = Buffer.alloc(8+bundle.nodes.length*9); +dag.writeUInt32LE(bundle.nodes.length,0); dag.writeUInt32LE(root,4); +for (const [i,n] of bundle.nodes.entries()) { + const at=8+i*9; + dag[at]=n.kind==='leaf'?0:n.kind==='stem'?1:2; + dag.writeUInt32LE(n.kind==='stem'?n.child:n.kind==='fork'?n.left:0,at+1); + dag.writeUInt32LE(n.kind==='fork'?n.right:0,at+5); +} +await writeFile(path.join(output,'compiler.dag'),dag); +await writeFile(path.join(output,'environment.tsv'),Object.entries(CONTRACT_BINDINGS).map(([name,exported])=>{ + assert.ok(exports.has(exported)); return name+'\t'+exports.get(exported); +}).join('\n')+'\n'); +const cases=[]; +for (const [id,c] of vectors.cases.entries()) { + if(c.mode==='io')continue; // IO is a separate host-driver experiment. + const dir=path.join(output,String(id)); await mkdir(dir); + await writeFile(path.join(dir,'source'),c.source); + await writeFile(path.join(dir,'expected'),c.diagnostic ? 'error\n'+c.diagnostic+'\n' : + 'ok\n'+Object.entries(c.definitions).map(([name,tree])=>name+'\t'+tree).join('\n')+'\n'); + await writeFile(path.join(dir,'run'),c.diagnostic?'': [c.export,c.result,...c.arguments].join('\n')+'\n'); + cases.push({id,source:c.source,diagnostic:c.diagnostic??null,extended:c.extended??false}); +} +await writeFile(path.join(output,'manifest.json'),JSON.stringify({compilerSha256:descriptor.hash, + goldenSha256:createHash('sha256').update(await readFile(path.join(site,'test/compiler-conformance.json'))).digest('hex'), + dagSha256:createHash('sha256').update(dag).digest('hex'),cases},null,2)+'\n'); diff --git a/ext/haskell-wasm/prepare-parity.mjs b/ext/haskell-wasm/prepare-parity.mjs @@ -0,0 +1,46 @@ +import {readFile,writeFile,mkdir,copyFile} from 'node:fs/promises'; +import {pathToFileURL} from 'node:url'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +const [site,out,fixtures]=process.argv.slice(2); +const {makeBundle}=await import(pathToFileURL(path.join(site,'test/bundle-fixture.mjs'))); +await mkdir(out,{recursive:true}); +const index={}; +async function save(name,bytes){const hash=createHash('sha256').update(bytes).digest('hex');await writeFile(path.join(out,name+'.arboricx'),bytes);index[name]=hash;} +await save('identity',await readFile(path.join(fixtures,'id.arboricx'))); +const records=[[]];for(let i=1;i<=40;i++)records.push([i-1,i-1]); +await save('exponential',makeBundle(records)); +await save('wrong-profile',makeBundle([[]],[['root',0]],{evaluation:'wrong'})); +function fixture(name,build){ + const nodes=[],memo=new Map(); + const node=(...children)=>{const key=children.join(',');if(memo.has(key))return memo.get(key);const i=nodes.length;nodes.push(children);memo.set(key,i);return i;}; + const leaf=node(),stem=x=>node(x),fork=(a,b)=>node(a,b); + const num=n=>n===0?leaf:fork(n%2?stem(leaf):leaf,num(Math.floor(n/2))); + const list=xs=>xs.reduceRight((tail,x)=>fork(x,tail),leaf); + const text=s=>list(Array.from(s,c=>num(c.codePointAt(0)))); + const action=(tag,payload)=>fork(num(tag),payload); + const constant=t=>fork(leaf,t); + const result=build({leaf,stem,fork,num,list,text,action,constant}); + return save(name,makeBundle(nodes,[['root',result]])); +} +const io=(x,action,version=1)=>x.fork(x.text('tricuIO'),x.fork(x.num(version),action)); +await fixture('bad-version',x=>io(x,x.action(0,x.leaf),2)); +await fixture('unsupported-io',x=>io(x,x.action(99,x.leaf))); +await fixture('abort-io',x=>{let t=x.leaf;for(let i=0;i<40;i++)t=x.fork(t,t);return io(x,x.action(2,t));}); +await fixture('output-limit',x=>{ + let action=x.action(0,x.leaf);const output=x.action(10,x.text('x'.repeat(4096))); + for(let i=0;i<5;i++)action=x.action(1,x.fork(output,x.constant(action))); + return io(x,action); +}); +await fixture('action-limit',x=>{ + let action=x.action(0,x.leaf); + for(let i=0;i<65;i++)action=x.action(1,x.fork(x.action(0,x.leaf),x.constant(action))); + return io(x,action); +}); +const demos=JSON.parse(await readFile(path.join(site,'demos.json'))); +for(const [name,demo] of Object.entries(demos.demos))await save(name,await readFile(path.join(site,'bundles',demo.hash+'.arboricx'))); +await copyFile(path.join(site,'demos.json'),path.join(out,'demos.json')); +await copyFile(path.join(site,'environments.json'),path.join(out,'environments.json')); +const environments=JSON.parse(await readFile(path.join(site,'environments.json'))); +for(const [name,profile] of Object.entries(environments.profiles))if(profile)await save('environment-'+name,await readFile(path.join(site,'bundles',profile.hash+'.arboricx'))); +await writeFile(path.join(out,'index.json'),JSON.stringify(index,null,2)+'\n'); diff --git a/ext/haskell-wasm/reactor-init.c b/ext/haskell-wasm/reactor-init.c @@ -0,0 +1,8 @@ +#include <Rts.h> +/* Exactly once, after the WASI reactor's _initialize. */ +void tricu_rts_init(void) { + int argc = 6; + char *args[] = {"tricu-reactor", "+RTS", "-M256m", "-K8m", "-T", "-RTS", 0}; + char **argv = args; + hs_init_with_rtsopts(&argc, &argv); +} diff --git a/ext/haskell-wasm/ts-compiler.mjs b/ext/haskell-wasm/ts-compiler.mjs @@ -0,0 +1,42 @@ +// Matching compiler observation boundaries on the site's current TS evaluator. +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {pathToFileURL} from 'node:url'; +import path from 'node:path'; +const [site,id]=process.argv.slice(2); +const load=name=>import(pathToFileURL(path.resolve(site,'dist',name+'.js'))); +const {Graph}=await load('graph'); +const {decodeBundle,loadBundle,COMPILER_BUNDLE_LIMIT}=await load('bundle'); +const {contractEnvironment}=await load('contracts'); +const {COMPILER_EXPORT,COMPILER_COLLECTION,sourceLiteral,compiledDefinitions}=await load('compiler'); +// Fixed comparison-harness guard, not a site execution setting. +const COMPILER_BUDGET={nodes:6_000_000,fuel:Infinity,stack:100_000}; +const start=performance.now(); +const vectors=JSON.parse(await readFile(path.join(site,'test/compiler-conformance.json'))); +const descriptor=JSON.parse(await readFile(path.join(site,'compiler.json'))); +assert.equal(vectors.compilerBundleSha256,descriptor.hash); +const bytes=await readFile(path.join(site,'bundles',descriptor.hash+'.arboricx')); +assert.equal(createHash('sha256').update(bytes).digest('hex'),descriptor.hash); +const c=vectors.cases[Number(id)]; +assert.notEqual(c.mode,'io'); +const g=new Graph(COMPILER_BUDGET,COMPILER_COLLECTION); +const exports=loadBundle(g,decodeBundle(bytes,COMPILER_BUNDLE_LIMIT)); +const env=contractEnvironment(g,exports),source=sourceLiteral(g,c.source); +const loaded=performance.now(); +const result=g.apply(exports.get(COMPILER_EXPORT),[env,source]); +let defs,diagnostic; +try{defs=compiledDefinitions(g,result);}catch(e){diagnostic=e.message;} +const named=performance.now(); +if(c.diagnostic)assert.equal(diagnostic,c.diagnostic); +else{ + assert.equal(diagnostic,undefined); + assert.deepEqual([...defs.keys()],Object.keys(c.definitions)); + for(const [name,expected] of Object.entries(c.definitions))assert.equal(g.toTernary(defs.get(name),50000),expected); +} +const complete=performance.now(); +if(!c.diagnostic)assert.equal(g.toTernary(g.apply(defs.get(c.export),c.arguments.map(s=>g.fromTernary(s))),50000),c.result); +const executed=performance.now(); +console.log(JSON.stringify({load_s:(loaded-start)/1000,compile_names_s:(named-loaded)/1000, + compile_complete_s:(complete-loaded)/1000,run_s:(executed-complete)/1000, + graph:g.stats,node_max_rss_kib:process.resourceUsage().maxRSS})); diff --git a/ext/wasm/README.md b/ext/wasm/README.md @@ -0,0 +1,263 @@ +# Experimental Zig/WASM evaluator + +An isolated experiment, **not the demo site's deployed runtime**. It compiles the +existing Zig owned-handle ABI and reducer to freestanding wasm32 ReleaseSafe, +without WASI, libc, libuv, or JavaScript imports. Native IO returns `ARB_IO_ERROR`. +The Node/browser-compatible adapter implements bundle loading, application, +reduction, constructor observation, byte codecs and Unicode scalar text codecs. +Native ABI behavior is unchanged. + +## Reproduce + +From the repository root: + +```sh +nix build .#tricu-wasm .#tricu-wasm-tests .#tricu-wasm-comparison --no-link +REPORT=$(nix path-info .#tricu-wasm-comparison) +cat "$REPORT/report.json" +cat "$REPORT/preview-report.json" + +# Opt-in headless Chromium Worker validation and measurements +nix build .#tricu-wasm-browser --no-link +BROWSER=$(nix path-info .#tricu-wasm-browser) +cat "$BROWSER/report.json" +``` + +The WASM artifact is `$(nix path-info .#tricu-wasm)/lib/arboricx.wasm`. +Nix caches measurement outputs. These are correctness smokes with recorded timings, +not performance thresholds. Run without concurrent heavy workloads for useful +comparisons; the harnesses are not CPU-pinned. + +### Node checks and measurements + +Tests cover 300 independent Haskell conformance vectors, Haskell text codec +vectors, stale and foreign-context handles within an instance, GC roots, +malformed bundles, exhausted fuel and resume, lazy discard, byte roundtrips, +unsupported IO and detached memory views after growth. They also check malformed +UTF-8, invalid scalar trees, unpaired surrogates, exact scalar limits, allocation +ceiling failure and recovery, nested scope escape/failure cleanup, and 200 +retained-context GC cycles with stable owned-root and node counts. Bulk previews +also run all 300 conformance cases, shared/deep graph bounds, undemanded divergent +frontiers, failure output atomicity, repeated ownership checks and aggressive GC. +Native Debug/ReleaseSafe tests inject every allocation failure into the snapshot +builder. + +The comparison alternates JS/WASM order for three fresh-process samples per case. +It rebuilds the site's TypeScript and uses the site's pinned compiler bundle, not +a newly generated compiler. It verifies complete string results and four existing +compiler golden cases (all definition trees plus applied results). Each child +has a 120-second timeout and Node's 512 MiB old-space limit. Reports include raw +load, construction, evaluation/full-observation and total timings, peak process +RSS, linear memory size, fixture and WASM hashes, Node version and CPU. + +The `identity`/`append` cases use the same scalar-tree JS codecs on both backends. +Their `-bulk` variants use the WASM byte ABI for ASCII inputs and complete output +decoding; JS still uses the site codecs. Their `-text` variants use multibyte +Unicode text and the WASM scalar codecs. Generic per-node operations still expose +the cost of host crossings and retained root-table scans. The compiler cases use +the bulk scalar input codec on WASM. Node/V8 timings are not browser results. + +`preview-report.json` separately compares JS per-node observation, WASM per-node +observation, and WASM bulk snapshots of the same 128-node unary tree. Each fresh +process performs 100 inspections, with either zero or 2000 additional retained +host roots. Three samples alternate order. All iterations check the complete +shape; those checks are included in timing (string comparison for per-node output, +record comparisons for snapshots). This measures observation/transport plus its +check, not just reducer time. The fixture deliberately has no sharing, so the +batch path does not gain an unfair logical-expansion advantage. Root-table +pressure is synthetic, not a claim about typical browser sessions. + +### Browser Worker checks and measurements + +`browser.mjs` serves a loopback-only test origin and drives a fresh Chromium +profile through CDP. `worker.mjs` is an experimental test protocol, **not** the +site's IO/Worker protocol. Both JS and WASM paths check the supplied bundle digest +and use the site's strict bundle/DAG profile before instantiation/loading. The +pins and bytes come from the local harness, not an untrusted remote descriptor. + +Each backend runs in a module Worker. Three alternating-order samples use fresh +Workers, with four compiler cases per retained compiler session. `compileMs` +measures source encoding, compilation, decoding lazy definition handles and +replacement of previous exports. `observeRoundTripMs` separately measures full +independent definition/result checks, including message round trips. These phases +are different from the Node combined evaluation/observation timing. Reports record +Chromium's user agent, artifact hashes and graph/linear-memory diagnostics, **not +browser RSS or total memory**. Compilation benchmarks omit Worker initialization +and bundle loading, so they are not end-to-end page latency measurements. + +Controls check: + +- Hash mismatch and a malformed bundle with a matching hash are rejected. +- Unicode text survives Worker transport, including NUL and initial U+FEFF; + rejected surrogates do not prevent a subsequent valid operation. +- Repeated compilation/replacement/collection stabilizes owned roots and live + graph size after warm-up; retained compiled exports remain callable. +- Bulk snapshots of compiled definitions reconstruct the independent golden + trees across Worker messaging. `previewMs` records their capture time separately + from full logical inspection and message round trips. +- Existing compiler diagnostic goldens match, a failed compilation preserves + previous exports, and a subsequent compilation succeeds. +- Divergent reduction leaves the main-thread heartbeat responsive. Hard Worker + termination stops the job; a fresh Worker initializes and runs successfully. + +Each request has a main-thread 30-second deadline; the test process has a +240-second guard. Chromium is headless with `--no-sandbox` for Nix build-sandbox +compatibility, using only local fixtures. This is not a security test of a public +browser deployment. Firefox, WebKit, mobile devices, background-tab throttling, +large compiler jobs and the production IO protocol are not covered. + +## Backend session boundary + +The experimental Worker selects a backend explicitly on initialization: + +```js +worker.postMessage({ + type: 'init', backend: 'zig-wasm', bundle: bundleBytes, hash: bundleSha256, + name: exportName, artifacts: { wasm: wasmBytes } +}); +``` + +`backends.mjs` owns the trusted factory registry (`js`, `zig-wasm`) and common +bundle authentication/profile checks. Backend code is selected from that registry, +never from a caller-supplied script URL. `backends/js.mjs` and +`backends/zig-wasm.mjs` own runtime loading, text codecs, roots, collection and +cleanup. `worker.mjs` only dispatches session operations and returns cloneable +values. Unknown backends, missing artifacts and initialization failures are errors; +there is no automatic fallback. The ready reply identifies the selected backend. +Selection is immutable for that Worker; a second initialization is rejected. + +The current session operations are `runText`, `compile`, `inspect`, `preview`, +`collect`, `stats`, `dispose`, and a harness-only `runTree` probe. Operations may +return values or Promises. Compile retains definition handles privately and only +returns names; inspect/preview return copied values. Failed compilation preserves +the previous definitions within this experimental session. Dispose is idempotent; +other calls after disposal fail. Hard Stop still terminates the entire Worker, +without relying on graceful disposal running during a blocked evaluator. + +No handles, WASM pointers, runtime heap checkpoints or allocator conventions cross +this boundary. `stats` contains backend-specific diagnostics, not a portable +resource budget. The shared `tree-session.mjs` helper is an implementation detail +of the current adapters, not an interface another backend must implement. A new +backend may supply its own session implementation and artifact loader without +using the Zig C ABI or the JavaScript `Graph` API. Async-adapter, disposal, +authentication-before-initialization and no-fallback controls run in +`backend-check.mjs`; Chromium exercises both registered implementations. + +This boundary currently covers the experiment, not the deployed app's full IO, +package environment or retained-draft protocol. The site has a JS/Haskell/Zig selector and coarse session adapter (see +`ext/demo-site/src/runtime-session.ts`). `site-session.mjs` is Zig's private +production adapter; the older JS/Zig experiment remains an isolated harness. +No backend shares another runtime's memory, handles or checkpoints. + +## Ownership and retained sessions + +```js +import {WasmRuntime} from './runtime.mjs'; +const runtime = await WasmRuntime.create(wasmBytes); +try { + const identity = runtime.load(bundleBytes); // explicitly owned session root + const result = runtime.scope(() => { + const input = runtime.ofText('λ雪🙂'); + return runtime.toText(runtime.app(identity, input)); + }); // all temporary handles released, including on failure + console.log(result); + runtime.collect(); // identity remains rooted + runtime.release(identity); +} finally { + runtime.dispose(); +} +``` + +Handles are BigInts local to their WASM instance; never transfer them between +instances. Constructors borrow inputs and return owned handles. `observe` and +`pair` return owned child handles. `retain` produces an independent owned alias. +`apply` returns an owned handle even with zero arguments. `fromTernary` and +`toTernary` release internal temporaries as they traverse. + +`scope(callback)` tracks newly returned handles, including observed children. +It releases them on normal return or failure. `keep(handle)` marks a newly owned +handle for transfer to the parent scope (or session) **only on successful return**: + +```js +const saved = runtime.scope(({keep}) => keep(runtime.ofText('retained'))); +// ... later ... +runtime.release(saved); +``` + +Scopes are synchronous, not async lifetime managers. Never schedule asynchronous +work inside a scope. Borrowed outer handles are not released by a scope. Handles +returned without `keep` are stale after scope exit. Outside scopes, callers must +explicitly release handles or destroy the context. Direct raw ABI calls bypass +adapter ownership accounting and are for trusted tests only. + +Collection reclaims unreachable graph nodes but does not promise to shrink the +handle table, arena backing allocations, or WASM linear memory. Generic graph +codecs can create large root-table high-water marks even when eventually scoped; +use bulk codecs for text/bytes. `stats()` reports handle slots, owned roots, arena +node-array length, and `liveAfterCollect` (the most recent collector live count, +not a current count between collections). These are not byte limits. + +## Bounded bulk tree previews + +`runtime.preview(handle, limit = 64, fuel = 1_000_000n)` returns a copied snapshot: + +```js +{ root: 0, nodes: [{ id: 0, kind: 'fork', children: [1, 1] }, /* ... */], + truncated: false } +``` + +The node limit must be 1–256. One WASM call traverses constructors, preserving +physical sharing without logically expanding repeated subtrees. Records are +visited depth-first, left before right. Children outside the captured frontier +have labels but no records; `truncated` is true if any unvisited label remains. +Pending duplicates alone do not cause truncation. The frontier is not forced +merely to decide whether the snapshot is complete. + +Labels are **snapshot-local numbers**, not owned handles, arena node IDs, content +identities, or cross-snapshot keys. Snapshots own no runtime roots and remain +usable after collection or context destruction. This is a bounded view, not a +paginated cursor or full tree-export format; passing labels back to the evaluator +is invalid. Compiler logical-result checks still use the separate bounded +`toTernary` path. No deployed tree-view UI has been switched to these snapshots. + +Fuel is a u64 ceiling **per constructor demand**, not a total preview budget. +A node cap limits transport and visited graph size, not the cost of evaluating +each node. A divergent node within the captured region can exhaust fuel; a +frontier node outside the region is left undemanded. As with reduction, failure +may leave partial evaluation progress in the shared graph, but publishes no +partial output buffer or handles. Use a Worker deadline for whole-job limits. + +## Unicode and resource boundaries + +`ofText`/`toText` encode **lists of Unicode scalars**, not UTF-8 byte lists or UTF-16 +code units. They reuse Zig's strict source-text scalar codec. NUL, CRLF, initial +U+FEFF and noncharacters are preserved; unpaired surrogates, malformed UTF-8, +noncanonical numeral trees, surrogate values and values above U+10FFFF are +rejected. In particular, this strict decoder does not inherit the generic site's +`readNatural` tolerance for redundant high zero bits. No normalization is applied. +The default ceiling is 4096 scalars; callers can select a different nonnegative +u32 ceiling (the Worker compiler uses 16384). A ceiling is not a deadline or fuel +budget. `ofBytes`/`toBytes` remain byte-oriented and are not Unicode text codecs. + +Views are recreated after calls that may grow memory, and decoded buffers are +copied before being freed. WASM linear memory is capped at 512 MiB with an 8 MiB +stack. Neither the WASM cap nor Node's old-space limit bounds total process RSS. + +The raw adapter accepts trusted host buffers. The experimental Worker and the +site session separately authenticate bundles and validate their profiles. The +site session uses shared package/codecs/compiler helpers, keeps original stored +export in shared JS, and implements bounded browser IO with opaque trace labels. +It selects `WasmRuntime.create(bytes, {unbounded:true})`: ordinary demands, bulk +text observation and previews have no fuel ceiling. Explicit finite demands +remain supported by the raw harness. All execution remains cancellable by hard +Worker termination; input/output/preview bounds and real allocation failures +remain enforced. See the [site guide](../demo-site/README.md#runtime-selection) +for packaged release use and both-browser integration checks. + +`scopeAsync` is an exclusive outer operation for the serialized site driver, +not concurrent access to one WASM instance. Nested synchronous scopes transfer +kept temporary handles into that outer scope; it releases everything on success +or host/driver failure. Persistent definitions are owned outside run scopes. +Resource/trap failures poison the session and require replacement, not reuse. +The site reports actual linear memory only; raw diagnostic counts are used in +ownership/collection tests, not presented as JS work or total-process memory. diff --git a/ext/wasm/backend-check.mjs b/ext/wasm/backend-check.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {openSession,backendFactories} from './backends.mjs'; +const bundle=await readFile('test/fixtures/id.arboricx'); +const hash=createHash('sha256').update(bundle).digest('hex'); +const wasm=await readFile(process.argv[2]); +assert.deepEqual(Object.keys(backendFactories),['js','zig-wasm']); +for(const backend of ['toString','haskell-wasm','unknown']) + await assert.rejects(openSession({backend,bundle,hash}),/Unknown backend/); +for(const backend of Object.keys(backendFactories)) { + const session=await openSession({backend,bundle,hash,artifacts:{wasm}}); + assert.equal(await session.runText('λ雪🙂'),'λ雪🙂'); + assert.equal(await session.runTree('21100',['2100']),'2100'); + await session.dispose();await session.dispose(); + await assert.rejects(async()=>session.runText('stale'),/disposed/); +} +await assert.rejects(openSession({backend:'zig-wasm',bundle,hash}),/Missing zig-wasm artifact/); +// A future backend may have asynchronous operations and completely different +// internal ownership. No Graph/WASM exports/BigInt handles are required here. +let calls=0; +const factories={alternate:async config=>{ + calls++;assert.equal(config.name,'id'); + return {runText:async text=>text,stats:async()=>({backendMetric:7}),dispose:async()=>{}}; +}}; +await assert.rejects(openSession({backend:'alternate',bundle,hash:'0'.repeat(64)},factories),/hash mismatch/); +assert.equal(calls,0); +const alternate=await openSession({backend:'alternate',bundle,hash},factories); +assert.equal(await alternate.runText('async'),'async');assert.deepEqual(await alternate.stats(),{backendMetric:7}); +await alternate.dispose();assert.equal(calls,1); +let fallback=false; +await assert.rejects(openSession({backend:'broken',bundle,hash},{ + broken:async()=>{throw new Error('Selected backend failed');}, + js:async()=>{fallback=true;}, +}),/Selected backend failed/); +assert.equal(fallback,false); +console.log('Backend sessions: explicit selection, authentication, isolation/disposal, async adapters and no fallback passed'); diff --git a/ext/wasm/backends.mjs b/ext/wasm/backends.mjs @@ -0,0 +1,29 @@ +import {decodeBundle} from '../demo-site/dist/bundle.js'; +/** + * Experimental session boundary. Each method may return a value or Promise. + * Public values are cloneable data, never runtime handles or heap checkpoints. + * runText(string) -> string + * compile(source) -> string[] (retains definitions; failure preserves old ones) + * inspect(name, ternaryArguments) -> ternary result + * preview(name, limit) -> bounded snapshot + * runTree(ternaryProgram, ternaryArguments) -> ternary result (harness probe) + * collect(), stats() -> backend-specific diagnostics, dispose() -> idempotent + * New implementations need not use Graph, Koffi, the Zig ABI, or treeSession. + */ +export const backendFactories=Object.freeze({ + js: async config=>(await import('./backends/js.mjs')).create(config), + 'zig-wasm': async config=>(await import('./backends/zig-wasm.mjs')).create(config), +}); +const hash=async bytes=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),x=>x.toString(16).padStart(2,'0')).join(''); +// factories is a trusted host/test injection point, never a Worker message field. +export async function openSession({backend,bundle:input,hash:pin,name,artifacts},factories=backendFactories) { + if(typeof backend!=='string'||!Object.hasOwn(factories,backend))throw new Error('Unknown backend'); + const bytes=new Uint8Array(input); + if(await hash(bytes)!==pin)throw new Error('Bundle hash mismatch'); + const bundle=decodeBundle(bytes); + name ??= bundle.exports[0].name; + if(!bundle.exports.some(e=>e.name===name))throw new Error('Missing export'); + // Authentication and profile checks are shared. Artifact interpretation and + // ownership belong to the selected implementation. Never fall back on failure. + return factories[backend]({bytes,bundle,name,artifacts}); +} diff --git a/ext/wasm/backends/js.mjs b/ext/wasm/backends/js.mjs @@ -0,0 +1,14 @@ +import {Graph} from '../../demo-site/dist/graph.js'; +import {loadBundle} from '../../demo-site/dist/bundle.js'; +import {text,readText} from '../../demo-site/dist/codecs.js'; +import {treeSession} from '../tree-session.mjs'; +export function create({bundle,name}) { + // Finite harness caps, not site settings. This adapter is not yet deployed. + const graph=new Graph({nodes:6_000_000,fuel:Infinity,stack:100_000},{threshold:250_000}); + const root=loadBundle(graph,bundle).get(name); + return treeSession(graph,root,{ + scope:fn=>fn({keep:x=>x}),release(){}, + ofText:(value,limit)=>text(graph,value,limit),toText:value=>readText(graph,value), + collect:roots=>graph.collect(roots),stats:()=>graph.stats,dispose(){}, + }); +} diff --git a/ext/wasm/backends/zig-wasm.mjs b/ext/wasm/backends/zig-wasm.mjs @@ -0,0 +1,15 @@ +import {WasmRuntime} from '../runtime.mjs'; +import {treeSession} from '../tree-session.mjs'; +export async function create({bytes,name,artifacts}) { + if(!artifacts?.wasm)throw new Error('Missing zig-wasm artifact'); + const runtime=await WasmRuntime.create(artifacts.wasm); + try { + const root=runtime.load(bytes,name); + return treeSession(runtime,root,{ + scope:fn=>runtime.scope(fn),release:h=>runtime.release(h), + ofText:(value,limit)=>runtime.ofText(value,limit),toText:h=>runtime.toText(h), + collect:()=>runtime.collect(),stats:()=>({...runtime.stats(),memoryBytes:runtime.e.memory.buffer.byteLength}), + dispose:()=>runtime.dispose(), + }); + } catch(error) {runtime.dispose();throw error;} +} diff --git a/ext/wasm/browser-page.mjs b/ext/wasm/browser-page.mjs @@ -0,0 +1,129 @@ +// Driven by browser.mjs over CDP; every operation has a main-thread timeout. +const assert = (ok,message) => { if (!ok) throw new Error(message); }; +const bytes = async path => { const r=await fetch(path); if(!r.ok)throw new Error(path); return r.arrayBuffer(); }; +const digest = async b => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),x=>x.toString(16).padStart(2,'0')).join(''); +function client() { + const worker = new Worker(new URL('./worker.mjs',import.meta.url),{type:'module'}); + let id=0; const waiting=new Map(), unsolicited=[]; + const terminate = () => { worker.terminate(); for(const w of waiting.values()){clearTimeout(w.timer);w.reject(new Error('Terminated'));} waiting.clear(); }; + worker.onmessage=({data})=>{ + const w=waiting.get(data.id); + if(!w){unsolicited.push(data);return;} + waiting.delete(data.id);clearTimeout(w.timer); + if(data.type==='error')w.reject(new Error(data.message));else w.resolve(data); + }; + worker.onerror=e=>{for(const w of waiting.values()){clearTimeout(w.timer);w.reject(new Error(e.message));}waiting.clear();}; + return {terminate,unsolicited,send:m=>new Promise((resolve,reject)=>{ + const request=++id; + const timer=setTimeout(()=>{waiting.delete(request);terminate();reject(new Error('Worker deadline'));},30_000); + waiting.set(request,{resolve,reject,timer});worker.postMessage({...m,id:request}); + })}; +} +// Only used to validate small, complete snapshots against independent goldens. +function snapshotTree(snapshot) { + assert(!snapshot.truncated,'Unexpected truncated fixture preview'); + const nodes=new Map(snapshot.nodes.map(n=>[n.id,n])); + const aliases=new Map((snapshot.aliases??[]).map(a=>[a.from,a.to])); + const pending=[snapshot.root], chars=[]; + while(pending.length) { + assert(chars.length<10000,'Preview fixture expansion limit'); + let id=pending.pop(); + for(let i=0;aliases.has(id);i++){assert(i<256,'Alias cycle');id=aliases.get(id);} + const node=nodes.get(id);assert(node,'Missing preview node'); + chars.push(String(node.children.length));pending.push(...[...node.children].reverse()); + } + return chars.join(''); +} +export async function run() { + const wasm=await bytes('/arboricx.wasm'), identity=await bytes('/test/fixtures/id.arboricx'); + const goldens=await (await fetch('/ext/demo-site/test/compiler-conformance.json')).json(); + const compiler=await bytes(`/ext/demo-site/bundles/${goldens.compilerBundleSha256}.arboricx`); + const init=(host,bundle,hash,name)=>({type:'init',backend:host==='wasm'?'zig-wasm':'js',bundle,hash,name,artifacts:{wasm}}); + const idHash=await digest(identity), samples=[], controls=[]; + let ticks=0;const heartbeat=setInterval(()=>ticks++,10); + try { + for(const host of ['js','wasm']) { + const c=client(); + try { + // A rejected hash never constructs the evaluator. Retry initialization. + await c.send(init(host,identity,'0'.repeat(64))).then(()=>{throw new Error('Accepted wrong hash');},e=>assert(/hash mismatch/.test(e.message),'Wrong hash error: '+e.message)); + const malformed=new Uint8Array(identity.byteLength+1);malformed.set(new Uint8Array(identity)); + let invalidRejected=false; + try{await c.send(init(host,malformed.buffer,await digest(malformed)));}catch{invalidRejected=true;} + assert(invalidRejected,'Accepted malformed bundle with matching hash'); + const ready=await c.send(init(host,identity,idHash)); + assert(ready.backend===(host==='wasm'?'zig-wasm':'js'),'Wrong selected backend'); + await c.send(init(host==='wasm'?'js':'wasm',identity,idHash)).then( + ()=>{throw new Error('Switched live session backend');}, + e=>assert(/Already initialized/.test(e.message),'Wrong session-switch error')); + let surrogateRejected=false; + try{await c.send({type:'text',value:'\ud800'});}catch{surrogateRejected=true;} + assert(surrogateRejected,'Accepted unpaired surrogate'); + const unicode='\ufeff\0λ雪🙂\uffff\u{10ffff}'; + for(let i=0;i<10;i++)assert((await c.send({type:'text',value:unicode})).value===unicode,'Unicode mismatch'); + } finally {c.terminate();} + controls.push(`${host}: hash/profile rejection, invalid surrogate recovery and Unicode`); + } + // Alternate fresh Worker order. Capture compilation (lazy definition handles) + // separately from independent full definition/result validation. + for(let round=0;round<3;round++)for(const host of round%2?['wasm','js']:['js','wasm']) { + const c=client(); + try { + await c.send(init(host,compiler,goldens.compilerBundleSha256,goldens.export)); + let repeatStats; + for(let index=0;index<4;index++) { + const golden=goldens.cases[index]; + const compiled=await c.send({type:'compile',source:golden.source}); + assert(JSON.stringify([...compiled.names].sort())===JSON.stringify(Object.keys(golden.definitions).sort()),'Definition names mismatch'); + const observeStart=performance.now(); + for(const [name,value] of Object.entries(golden.definitions)) + assert((await c.send({type:'inspect',name,arguments:[]})).value===value,`Definition mismatch ${name}`); + assert((await c.send({type:'inspect',name:golden.export,arguments:golden.arguments})).value===golden.result,'Applied result mismatch'); + const observedMs=performance.now()-observeStart; + let previewMs=0; + for(const [name,value] of Object.entries(golden.definitions)) { + const preview=await c.send({type:'preview',name,limit:256}); + assert(snapshotTree(preview.value)===value,`Preview mismatch ${name}`); + previewMs+=preview.ms; + } + const {stats}=await c.send({type:'collect'}); + samples.push({host,round,case:index,compileMs:compiled.ms,observeRoundTripMs:observedMs,previewMs,stats}); + } + // Replace retained exports, then verify compiler-only/definition roots + // stabilize live counts after warm-up and explicit collection. + for(let i=0;i<8;i++) { + await c.send({type:'compile',source:goldens.cases[0].source}); + assert((await c.send({type:'inspect',name:'id',arguments:goldens.cases[0].arguments})).value===goldens.cases[0].result,'Retained compiler changed'); + const {stats}=await c.send({type:'collect'}); + if(host==='wasm' && repeatStats)assert(stats.owned===repeatStats.owned && stats.liveAfterCollect===repeatStats.liveAfterCollect,'Retained-session leak: '+JSON.stringify({stats,repeatStats})); + repeatStats=stats; + } + for(const bad of goldens.cases.filter(x=>x.diagnostic)) { + let diagnostic; + try {await c.send({type:'compile',source:bad.source});} catch(error) {diagnostic=error.message;} + assert(diagnostic?.includes(bad.diagnostic),'Compiler diagnostic mismatch: '+diagnostic); + assert((await c.send({type:'inspect',name:'id',arguments:goldens.cases[0].arguments})).value===goldens.cases[0].result,'Failed compilation destroyed retained exports'); + } + await c.send({type:'compile',source:goldens.cases[0].source}); + assert((await c.send({type:'inspect',name:'id',arguments:goldens.cases[0].arguments})).value===goldens.cases[0].result,'Compiler failed to recover'); + } finally {c.terminate();} + } + for(const host of ['js','wasm']) { + const c=client(); + try { + await c.send(init(host,identity,idHash)); + const before=ticks; + assert((await c.send({type:'diverge'})).type==='running','No divergence acknowledgment'); + await new Promise(r=>setTimeout(r,100)); + assert(ticks>before,'Main thread blocked by evaluator'); + c.terminate(); + await new Promise(r=>setTimeout(r,100)); + assert(c.unsolicited.length===0,'Divergent evaluator returned before Stop'); + } finally {c.terminate();} + // A separate fresh Worker must still initialize and execute after Stop. + const fresh=client();try{await fresh.send(init(host,identity,idHash));assert((await fresh.send({type:'text',value:'restart🙂'})).value==='restart🙂','Restart failed');}finally{fresh.terminate();} + controls.push(`${host}: hard termination, responsive main thread and fresh restart`); + } + return {schema:1,userAgent:navigator.userAgent,wasmSha256:await digest(wasm),compilerSha256:goldens.compilerBundleSha256,samples,controls}; + } finally {clearInterval(heartbeat);} +} diff --git a/ext/wasm/browser.mjs b/ext/wasm/browser.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createServer} from 'node:http'; +import {readFile,mkdtemp,rm,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {resolve,extname,sep} from 'node:path'; +const root=resolve('.'); +const server=createServer(async(req,res)=>{ + try { + const path=new URL(req.url,'http://localhost').pathname; + if(path==='/'){res.setHeader('Content-Type','text/html');res.end('<!doctype html><title>WASM experiment</title>');return;} + const file=path==='/arboricx.wasm'?process.argv[2]:resolve(root,'.'+path); + if(path!=='/arboricx.wasm'&&!file.startsWith(root+sep))throw new Error('outside root'); + res.setHeader('Content-Type',({'.mjs':'text/javascript','.js':'text/javascript','.wasm':'application/wasm','.json':'application/json'})[extname(file)]??'application/octet-stream'); + res.end(await readFile(file)); + } catch {res.writeHead(404);res.end();} +}); +await new Promise(r=>server.listen(0,'127.0.0.1',r)); +const url=`http://127.0.0.1:${server.address().port}/`; +const profile = await mkdtemp(resolve(tmpdir(), 'tricu-demo-chromium-')); +let browser, socket; +let next = 0; const pending = new Map(); +try { + browser = spawn(process.argv[3] || 'chromium', ['--headless', '--no-sandbox', '--disable-dev-shm-usage', + '--js-flags=--max-old-space-size=768', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] }); + const debuggerURL = await new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => reject(new Error('Chromium startup timed out: ' + stderr)), 15_000); + browser.on('error', error => { clearTimeout(timer); reject(error); }); + browser.on('exit', code => { clearTimeout(timer); reject(new Error('Chromium exited: ' + code + '\n' + stderr)); }); + browser.stderr.on('data', chunk => { + stderr += chunk; + const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/); + if (match) { clearTimeout(timer); resolve(match[1]); } + }); + }); + const endpoint = new URL(debuggerURL); + const tab = await (await fetch(`http://${endpoint.host}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); + socket = new WebSocket(tab.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); + const exceptions = []; + socket.onmessage = ({ data }) => { + const event = JSON.parse(data); + if (event.id) { + const waiter = pending.get(event.id); pending.delete(event.id); + if (!waiter) return; + clearTimeout(waiter.timer); + if (event.error) waiter.reject(new Error(JSON.stringify(event.error))); + else waiter.resolve(event.result); + } else if (event.method === 'Runtime.exceptionThrown') exceptions.push(event.params); + }; + const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++next; + const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, 180_000); + pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); + await send('Runtime.enable'); await send('Page.enable'); + const evaluate = async expression => { + const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + const report=await evaluate("import('/ext/wasm/browser-page.mjs').then(m=>m.run())"); + assert.equal(report.samples.length,24); + assert.deepEqual(exceptions,[]); + await writeFile(process.argv[4],JSON.stringify(report,null,2)+'\n'); + for(const index of [0,1,2,3]) { + const median=host=>report.samples.filter(s=>s.host===host&&s.case===index).map(s=>s.compileMs).sort((a,b)=>a-b)[1]; + console.log(`browser compiler-${index}: JS ${median('js').toFixed(1)} ms; WASM ${median('wasm').toFixed(1)} ms`); + } + console.log(report.controls.join('\n')); +} finally { + for (const waiter of pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Browser test closed')); } + pending.clear(); socket?.close(); + if (browser && browser.exitCode === null) { + const exited = new Promise(resolve => browser.once('exit', resolve)); + browser.kill('SIGKILL'); await exited; + } + await new Promise(resolve => server.close(resolve)); + await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); +} diff --git a/ext/wasm/check.mjs b/ext/wasm/check.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {WasmRuntime} from './runtime.mjs'; +const wasm = await readFile(process.argv[2]); +function snapshotTree(snapshot) { + assert.equal(snapshot.truncated,false); + const nodes=new Map(snapshot.nodes.map(n=>[n.id,n])),pending=[snapshot.root],chars=[]; + while(pending.length) { + assert.ok(chars.length<10000); + const node=nodes.get(pending.pop());assert.ok(node); + chars.push(String(node.children.length));pending.push(...[...node.children].reverse()); + } + return chars.join(''); +} +const vectors = JSON.parse(await readFile('ext/demo-site/test/conformance.json')); +for (const c of vectors.cases) { + const g = await WasmRuntime.create(wasm); + try { + const applied=g.apply(g.fromTernary(c.program),c.arguments.map(x=>g.fromTernary(x))); + assert.equal(snapshotTree(g.preview(applied,256)),c.result); + assert.equal(g.toTernary(applied),c.result); + } + finally { g.dispose(); } +} +const g = await WasmRuntime.create(wasm); +const other = new WasmRuntime({exports: g.e}); +try { + assert.throws(() => g.force(other.leaf), /status 1/); + const dead = g.stem(g.leaf); g.release(dead); + assert.throws(() => g.force(dead), /status 1/); + assert.throws(() => g.load(new Uint8Array([1,2,3])), /status 5/); + assert.throws(() => g.handle('arb_run_io', g.leaf, 0), /status 6/); + const bytes = Uint8Array.from([0,127,128,255]); + assert.deepEqual(g.toBytes(g.ofBytes(bytes)),bytes); + const id = g.load(await readFile('test/fixtures/id.arboricx')); + const arg = g.fromTernary('2100'), app = g.app(id,arg); + assert.throws(() => g.force(app,0n), /status 3/); + assert.equal(g.toTernary(app),'2100'); + g.release(id); g.release(arg); g.call('arb_collect'); + assert.equal(g.toTernary(app),'2100'); + const q = g.fromTernary('212110021100'), loop = g.app(q,q); + assert.equal(g.toTernary(g.app(g.fromTernary('200'),loop)), '0'); + assert.throws(() => g.force(loop,100n), /status 3/); + const before = g.e.memory.buffer; + g.e.memory.grow(1); + assert.notEqual(g.e.memory.buffer, before); + assert.equal(g.toTernary(app),'2100'); +} finally { g.dispose(); other.dispose(); } +assert.throws(() => g.force(0n), /Disposed/); +console.log('WASM: 300 independent conformance vectors, handles, GC, malformed bundle, fuel, laziness, memory growth passed'); + +const r = await WasmRuntime.create(wasm); +try { + const initial = r.stats().owned; + const vectors = JSON.parse(await readFile('ext/demo-site/demos.json')).codecVectors; + r.scope(() => { + for (const v of vectors.filter(v=>v.kind === 'string')) { + assert.equal(r.toTernary(r.ofText(v.value)),v.tree); + assert.equal(r.toText(r.fromTernary(v.tree)),v.value); + } + }); + assert.equal(r.e.arb_host_alloc(512*1024*1024),0); + assert.equal(r.toText(r.leaf),''); + assert.throws(() => r.scope(async () => {}), /synchronous/); + const unicode = '\ufeff\0a\r\néλ雪🙂\uffff\u{10ffff}'; + r.scope(() => { + const tree = r.ofText(unicode); + assert.equal(r.toText(tree), unicode); + assert.throws(() => r.toText(tree,2), /status 5/); + assert.throws(() => r.ofText('🙂',0), /limit/); + assert.throws(() => r.ofText('\ud800'), /surrogate/); + assert.throws(() => r.ofText('\udc00'), /surrogate/); + assert.equal(r.toText(r.ofText('',0),0),''); + const single = n => r.fork(r.handle('arb_of_number',n),r.leaf); + for (const n of [0xd800n,0xdfffn,0x110000n]) assert.throws(() => r.toText(single(n)), /status 5/); + const noncanonical = r.fork(r.leaf,r.leaf); + assert.throws(() => r.toText(r.fork(noncanonical,r.leaf)), /status 5/); + for (const bytes of [[0x80],[0xc0,0xaf],[0xed,0xa0,0x80],[0xf4,0x90,0x80,0x80]]) + assert.throws(() => r.buffer(Uint8Array.from(bytes),p=>r.handle('arb_host_of_text',p,bytes.length,10)), /status 5/); + }); + assert.equal(r.stats().owned,initial); + assert.throws(() => r.scope(() => { r.fromTernary('2100'); throw new Error('unwind'); }), /unwind/); + assert.throws(() => r.scope(({keep}) => { keep(r.ofText('temporary')); throw new Error('unwind'); }), /unwind/); + assert.throws(() => r.fromTernary('2100x'), /Invalid/); + assert.equal(r.stats().owned,initial); + const kept = r.scope(({keep}) => keep(r.scope(({keep}) => keep(r.ofText(unicode))))); + r.collect(); assert.equal(r.toText(kept),unicode); r.release(kept); + const id = r.load(await readFile('test/fixtures/id.arboricx')); + r.call('arb_set_gc_policy',32,1); + r.collect(); const baseline = r.stats(); + for (let i=0;i<200;i++) { + r.scope(() => { + const input = r.ofText(unicode), applied = r.app(id,input); + r.release(input); // application owns its graph, not the input handle + assert.equal(r.toText(applied),unicode); + assert.throws(() => r.toTernary(applied,2), /expansion/); + }); + r.collect(); + assert.equal(r.stats().owned,baseline.owned); + assert.equal(r.stats().nodes,baseline.nodes); + assert.ok(r.stats().slots < 100); + } + r.release(id); +} finally { r.dispose(); } +console.log('WASM: strict Unicode, nested scope escape/failure cleanup, and 200 retained-context GC cycles passed'); + +const previewRuntime = await WasmRuntime.create(wasm); +try { + const p = previewRuntime; + p.call('arb_set_gc_policy',1,1); + p.scope(() => { + let root = p.retain(p.leaf); + for(let i=0;i<31;i++) {const next=p.fork(root,root);p.release(root);root=next;} + const before=p.stats().owned; + for(let i=0;i<100;i++) { + const snapshot=p.preview(root,32); + assert.equal(snapshot.nodes.length,32);assert.equal(snapshot.truncated,false); + assert.equal(snapshot.root,0); + for(let j=0;j<31;j++)assert.deepEqual(snapshot.nodes[j],{id:j,kind:'fork',children:[j+1,j+1]}); + assert.deepEqual(snapshot.nodes[31],{id:31,kind:'leaf',children:[]}); + assert.equal(p.stats().owned,before); + } + const deep=p.fromTernary('1'.repeat(1000)+'0'); + const bounded=p.preview(deep,256); + assert.equal(bounded.nodes.length,256);assert.equal(bounded.truncated,true); + p.release(deep); + const short=p.preview(root,1); + assert.equal(short.nodes.length,1);assert.equal(short.truncated,true); + assert.deepEqual(short.nodes[0].children,[1,1]); + assert.throws(()=>p.preview(root,0),/budget/); + assert.throws(()=>p.preview(root,257),/budget/); + const dead=p.retain(root);p.release(dead); + assert.throws(()=>p.preview(dead),/status 1/); + const q=p.fromTernary('212110021100'),loop=p.app(q,q); + const lazy=p.stem(loop); + assert.equal(p.preview(lazy,1,0n).truncated,true); // frontier isn't forced + assert.throws(()=>p.preview(lazy,2,10n),/status 3/); + assert.equal(p.stats().owned,before+3); + // A failed demand leaves output fields untouched, and no snapshot buffer. + p.view().setBigUint64(p.scratch,0x123456789abcdef0n,true); + assert.equal(p.e.arb_host_preview(p.ctx,loop,2,10n,p.scratch,p.scratch+8),3); + assert.equal(p.view().getBigUint64(p.scratch,true),0x123456789abcdef0n); + assert.deepEqual(p.preview(p.leaf,1),{root:0,nodes:[{id:0,kind:'leaf',children:[]}],truncated:false}); + // Snapshot bytes own their labels; collection/reuse cannot mutate a result. + const saved=structuredClone(short);p.release(root);p.collect(); + assert.deepEqual(short,saved); + }); + assert.equal(p.stats().owned,1); +} finally {previewRuntime.dispose();} +console.log('WASM: bounded bulk preview sharing, exact limits, lazy frontier, fuel failure, GC and ownership passed'); + +const unbounded=await WasmRuntime.create(wasm,{unbounded:true}); +try { + const r=unbounded; + await r.scopeAsync(async()=>{ + const h=r.retain(r.leaf); + await assert.rejects(r.scopeAsync(async()=>{}),/exclusive/); + r.scope(({keep})=>keep(r.retain(h))); + await Promise.resolve();r.collect();assert.equal(r.toTernary(h),'0'); + }); + assert.equal(r.stats().owned,1); + await assert.rejects(r.scopeAsync(async()=>{r.retain(r.leaf);throw Error('host failure');}),/host failure/); + assert.equal(r.stats().owned,1); + r.scope(()=>{ + const delayed=r.app(r.leaf,r.leaf); + assert.throws(()=>r.force(delayed,0n),/status 3/); + assert.equal(r.toTernary(delayed),'10'); + assert.equal(r.preview(delayed).nodes.length,2); + }); + assert.equal(r.stats().owned,1); + assert.throws(()=>r.check(2),/status 2/);assert.equal(r.poisoned,true); +}finally{unbounded.dispose();} +console.log('WASM: explicit unbounded demands, finite negative control, exclusive async cleanup and fatal allocation classification passed'); diff --git a/ext/wasm/compare.mjs b/ext/wasm/compare.mjs @@ -0,0 +1,70 @@ +// Fresh-process paired smoke, not a browser performance claim. +import assert from 'node:assert/strict'; +import {readFile, writeFile} from 'node:fs/promises'; +import {spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {cpus} from 'node:os'; +import {WasmRuntime} from './runtime.mjs'; +import {Graph} from '../demo-site/dist/graph.js'; +import {decodeBundle,loadBundle} from '../demo-site/dist/bundle.js'; +import {text,readText} from '../demo-site/dist/codecs.js'; +import {compiledDefinitions} from '../demo-site/dist/compiler.js'; +const [wasmPath, output, host, job] = process.argv.slice(2); +const fixtures = JSON.parse(await readFile('ext/demo-site/test/compiler-conformance.json')); +const cases = ['identity','append','identity-bulk','append-bulk','identity-text','append-text', 'compiler-0','compiler-1','compiler-2','compiler-3']; +if (host) { + const start = performance.now(); + const g = host === 'wasm' ? await WasmRuntime.create(await readFile(wasmPath)) + : new Graph({nodes:6_000_000,fuel:Infinity,stack:100_000},{threshold:250_000}); + const compiler = job.startsWith('compiler-'); + const bulk = host === 'wasm' && job.endsWith('-bulk'); + const scalar = host === 'wasm' && job.endsWith('-text'); + const bytes = await readFile(compiler ? `ext/demo-site/bundles/${fixtures.compilerBundleSha256}.arboricx` + : `test/fixtures/${job.startsWith('identity') ? 'id' : 'append'}.arboricx`); + const name = compiler ? fixtures.export : undefined; + const root = host === 'wasm' ? g.load(bytes,name) : (() => { + const b = decodeBundle(bytes), exports = loadBundle(g,b); + return exports.get(name ?? b.exports[0].name); + })(); + const loaded = performance.now(); + let result, expected; + if (compiler) { + const c = fixtures.cases[Number(job.split('-')[1])]; + result = g.apply(root,[g.leaf,host==='wasm'?g.ofText(c.source,16384):text(g,c.source)]); expected = c; + } else { + const a = (job.endsWith('-text')?'λ雪🙂':'abc012').repeat(100), b = (job.endsWith('-text')?'é\0🦊':'XYZ789').repeat(100); + result = g.apply(root, (job.startsWith('identity') ? [a] : [a,b]).map(x => scalar ? g.ofText(x) : bulk ? g.ofBytes(new TextEncoder().encode(x)) : text(g,x))); + expected = job.startsWith('identity') ? a : a+b; + } + const constructed = performance.now(); + if (compiler) { + const defs = compiledDefinitions(g,result); + assert.deepEqual([...defs.keys()].sort(),Object.keys(expected.definitions).sort()); + for (const [name, tree] of Object.entries(expected.definitions)) assert.equal(g.toTernary(defs.get(name)),tree); + assert.equal(g.toTernary(g.apply(defs.get(expected.export),expected.arguments.map(x=>g.fromTernary(x)))),expected.result); + } else assert.equal(scalar ? g.toText(result) : bulk ? new TextDecoder('utf-8',{fatal:true}).decode(g.toBytes(result)) : readText(g,result),expected); + const end = performance.now(); + console.log(JSON.stringify({host,job,loadMs:loaded-start,constructMs:constructed-loaded, + evaluateObserveMs:end-constructed,totalMs:end-start,peakRssKiB:process.resourceUsage().maxRSS, + wasmMemoryBytes:host==='wasm'?g.e.memory.buffer.byteLength:null, + fixtureSha256:createHash('sha256').update(bytes).digest('hex')})); + g.dispose?.(); +} else { + const samples=[]; + for (const job of cases) for(let round=0;round<3;round++) { + for (const host of round%2 ? ['wasm','js'] : ['js','wasm']) { + const child=spawnSync(process.execPath,['--max-old-space-size=512',import.meta.filename,wasmPath,'-',host,job], + {encoding:'utf8',timeout:120_000,maxBuffer:1024*1024}); + if(child.error || child.status!==0) throw new Error(`${job}/${host}: ${child.error ?? child.stderr}`); + samples.push({...JSON.parse(child.stdout),round}); + } + } + const report={schema:1,node:process.version,cpu:cpus()[0]?.model,platform:process.platform, + wasmSha256:createHash('sha256').update(await readFile(wasmPath)).digest('hex'), + optimization:'ReleaseSafe',samples}; + await writeFile(output,JSON.stringify(report,null,2)+'\n'); + for (const job of cases) { + const median=h=>samples.filter(s=>s.job===job&&s.host===h).map(s=>s.evaluateObserveMs).sort((a,b)=>a-b)[1]; + console.log(`${job}: JS ${median('js').toFixed(1)} ms; WASM ${median('wasm').toFixed(1)} ms; ${(median('js')/median('wasm')).toFixed(2)}x`); + } +} diff --git a/ext/wasm/preview-compare.mjs b/ext/wasm/preview-compare.mjs @@ -0,0 +1,48 @@ +// Compare the same 128-node unary tree: no sharing/expansion advantage hidden +// in the baseline. Every iteration checks the complete shape in either format. +import assert from 'node:assert/strict'; +import {readFile,writeFile} from 'node:fs/promises'; +import {spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {cpus} from 'node:os'; +import {WasmRuntime} from './runtime.mjs'; +import {Graph} from '../demo-site/dist/graph.js'; +const [wasmPath,output,mode,pressureArg]=process.argv.slice(2); +const tree='1'.repeat(127)+'0',iterations=100; +if(mode) { + const pressure=Number(pressureArg); + const g=mode==='js'?new Graph({fuel:Infinity}):await WasmRuntime.create(await readFile(wasmPath)); + const root=g.fromTernary(tree); + // Extra owned roots simulate a retained host session. JS has no root-table + // scan in this path, but retain equivalent host references for comparison. + const retained=Array.from({length:pressure},()=>mode==='js'?g.leaf:g.retain(g.leaf)); + const before=mode==='js'?null:g.stats().owned; + const start=performance.now(); + for(let i=0;i<iterations;i++) { + if(mode==='wasm-batch') { + const p=g.preview(root,128); + assert.equal(p.truncated,false);assert.equal(p.nodes.length,128); + for(let j=0;j<128;j++)assert.deepEqual(p.nodes[j],{id:j,kind:j===127?'leaf':'stem',children:j===127?[]:[j+1]}); + } else assert.equal(g.toTernary(root),tree); + } + const ms=performance.now()-start; + if(mode!=='js')assert.equal(g.stats().owned,before); + assert.equal(retained.length,pressure); + console.log(JSON.stringify({mode,pressure,iterations,ms,perSnapshotMs:ms/iterations,peakRssKiB:process.resourceUsage().maxRSS})); + g.dispose?.(); +} else { + const samples=[]; + for(const pressure of [0,2000])for(let round=0;round<3;round++) { + for(const mode of round%2?['wasm-batch','wasm-node','js']:['js','wasm-node','wasm-batch']) { + const child=spawnSync(process.execPath,[import.meta.filename,wasmPath,'-',mode,String(pressure)],{encoding:'utf8',timeout:60_000}); + if(child.error||child.status!==0)throw new Error(`${mode}: ${child.error??child.stderr}`); + samples.push({...JSON.parse(child.stdout),round}); + } + } + await writeFile(output,JSON.stringify({schema:1,node:process.version,cpu:cpus()[0]?.model, + wasmSha256:createHash('sha256').update(await readFile(wasmPath)).digest('hex'),tree, samples},null,2)+'\n'); + for(const pressure of [0,2000]) { + const median=mode=>samples.filter(s=>s.mode===mode&&s.pressure===pressure).map(s=>s.perSnapshotMs).sort((a,b)=>a-b)[1]; + console.log(`128-node inspection (${pressure} extra roots): JS ${median('js').toFixed(3)} ms; WASM/node ${median('wasm-node').toFixed(3)} ms; WASM/batch ${median('wasm-batch').toFixed(3)} ms`); + } +} diff --git a/ext/wasm/runtime.mjs b/ext/wasm/runtime.mjs @@ -0,0 +1,206 @@ +// Experimental browser/Node adapter. Scope temporary handles; dispose after use. +// Handles returned to callers remain owned until release() or dispose(). +export class WasmRuntime { + static async create(bytes, {unbounded = false} = {}) { + const module = await WebAssembly.compile(bytes); + if (WebAssembly.Module.imports(module).length) throw new Error('Unexpected WASM imports'); + const runtime = new WasmRuntime(await WebAssembly.instantiate(module)); + runtime.unbounded = unbounded; + return runtime; + } + constructor(instance) { + this.e = instance.exports; + if (this.e.arb_abi_version() !== 2) throw new Error('ABI v2 required'); + this.ctx = this.e.arboricx_init(); + if (!this.ctx) throw new Error('WASM initialization failed'); + this.scratch = this.e.arb_host_alloc(16); + if (!this.scratch) { this.e.arboricx_free(this.ctx); throw new Error('Scratch allocation failed'); } + this.scopes = []; + this.owners = new Map(); + try { this.leaf = this.handle('arb_leaf'); } + catch (error) { this.dispose(); throw error; } + } + check(status) { + if ([1,2,7].includes(status)) this.poisoned = true; + if (status !== 0) throw new Error(`Arboricx status ${status}`); + } + call(name, ...args) { + if (!this.ctx) throw new Error('Disposed WASM runtime'); + try { this.check(this.e[name](this.ctx, ...args)); } + catch (error) { if (error instanceof WebAssembly.RuntimeError) this.poisoned = true; throw error; } + } + // Recreate views after every call: memory.grow detaches previous buffers. + view() { return new DataView(this.e.memory.buffer); } + handle(name, ...args) { + this.call(name, ...args, this.scratch); + return this.own(this.view().getBigUint64(this.scratch, true)); + } + own(handle) { + const scope = this.scopes.at(-1); + this.owners.set(handle, scope); + scope?.add(handle); + return handle; + } + // Synchronous scopes only. keep() transfers a newly owned handle to the parent; + // borrowed inputs are never implicitly released. Failure also unwinds roots. + scope(fn) { + if (fn.constructor.name === 'AsyncFunction') throw new Error('Scopes must be synchronous'); + const handles = new Set(), escaped = new Set(), parent = this.scopes.at(-1); + this.scopes.push(handles); + const keep = h => { + if (!handles.has(h)) throw new Error('keep requires a handle owned by this scope'); + escaped.add(h); return h; + }; + try { + const result = fn({keep}); + if (result && typeof result.then === 'function') throw new Error('Scopes must be synchronous'); + for (const h of escaped) { + if (!handles.has(h)) throw new Error('Escaped handle was released'); + } + for (const h of escaped) { handles.delete(h); parent?.add(h); this.owners.set(h,parent); } + return result; + } finally { + this.scopes.pop(); + for (const h of handles) this.release(h); + } + } + // Exclusive outer operation only. Session callers serialize operations; nested + // synchronous scopes work normally. No other async operation may enter. + async scopeAsync(fn) { + if (this.scopes.length) throw new Error('Async scope requires exclusive ownership'); + const handles = new Set(); this.scopes.push(handles); + try { return await fn(); } + finally { this.scopes.pop(); for (const h of handles) this.release(h); } + } + retain(x) { return this.handle('arb_retain', x); } + collect() { this.call('arb_collect'); } + stats() { + this.call('arb_host_stats', this.scratch, this.scratch + 4, this.scratch + 8, this.scratch + 12); + const v = this.view(); + return {slots:v.getUint32(this.scratch,true), owned:v.getUint32(this.scratch+4,true), nodes:v.getUint32(this.scratch+8,true), liveAfterCollect:v.getUint32(this.scratch+12,true)}; + } + buffer(bytes, fn) { + if (!this.ctx) throw new Error('Disposed WASM runtime'); + const p = this.e.arb_host_alloc(Math.max(1, bytes.length)); + if (!p) { this.poisoned = true; throw new Error('WASM buffer allocation failed'); } + try { new Uint8Array(this.e.memory.buffer, p, bytes.length).set(bytes); return fn(p); } + finally { this.e.arb_host_free(p, Math.max(1, bytes.length)); } + } + load(bytes, name) { + if (name !== undefined && (typeof name !== 'string' || name.includes('\0'))) throw new Error('Invalid export name'); + return this.buffer(bytes, p => name === undefined + ? this.handle('arb_load_bundle_default', p, bytes.length) + : this.buffer(new TextEncoder().encode(name + '\0'), n => this.handle('arb_load_bundle', p, bytes.length, n))); + } + ofBytes(bytes) { return this.buffer(bytes, p => this.handle('arb_of_bytes', p, bytes.length)); } + textLimit(limit) { + if (!Number.isSafeInteger(limit) || limit < 0 || limit > 0xffffffff) throw new Error('Invalid scalar limit'); + } + ofText(value, limit = 4096) { + this.textLimit(limit); + if (typeof value !== 'string' || value.length > limit * 2) throw new Error('Text limit/type'); + let count = 0; + for (const c of value) { + const scalar = c.codePointAt(0); + if (scalar >= 0xd800 && scalar <= 0xdfff) throw new Error('Unpaired Unicode surrogate'); + if (++count > limit) throw new Error('Text limit'); + } + const bytes = new TextEncoder().encode(value); + return this.buffer(bytes, p => this.handle('arb_host_of_text', p, bytes.length, limit)); + } + toText(x, limit = 4096) { + this.textLimit(limit); + // Preserve an initial U+FEFF; this is text, not a source-file BOM policy. + return new TextDecoder('utf-8', {fatal:true, ignoreBOM:true}).decode(this.output('arb_host_to_text', x, limit)); + } + // Labels are snapshot-local numbers, not owned handles or live node IDs. + preview(x, limit = 64, fuel = undefined) { + if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid preview budget'); + const unlimited = this.unbounded && fuel === undefined; + fuel ??= 1_000_000n; + if (typeof fuel !== 'bigint' || fuel < 0n || fuel > 0xffffffffffffffffn) throw new Error('Invalid preview fuel'); + const bytes = unlimited ? this.output('arb_host_preview_unbounded', x, limit) : this.output('arb_host_preview', x, limit, fuel); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (view.getUint32(0,true) !== 1) throw new Error('Unsupported preview format'); + const nodes = [], count = view.getUint32(8,true); + for (let i=0;i<count;i++) { + const at = 16+i*16, arity=view.getUint32(at+4,true); + nodes.push({id:view.getUint32(at,true),kind:['leaf','stem','fork'][arity], + children:Array.from({length:arity},(_,j)=>view.getUint32(at+8+4*j,true))}); + } + return {root:view.getUint32(4,true),nodes,truncated:!!view.getUint32(12,true)}; + } + toBytes(x) { return this.output('arb_to_bytes', x); } + output(name, ...args) { + this.call(name, ...args, this.scratch, this.scratch + 8); + const p = this.view().getUint32(this.scratch, true); + const n = this.view().getUint32(this.scratch + 8, true); + try { return new Uint8Array(this.e.memory.buffer, p, n).slice(); } + finally { this.e.arboricx_free_buf(this.ctx, p, n); } + } + stem(x) { return this.handle('arb_stem', x); } + fork(a, b) { return this.handle('arb_fork', a, b); } + app(f, x) { return this.handle('arb_app', f, x); } + apply(f, args) { + return this.scope(({keep}) => { + let result = this.retain(f); + for (const x of args) { const next = this.app(result, x); this.release(result); result = next; } + return keep(result); + }); + } + force(x, fuel = undefined) { + return this.unbounded && fuel === undefined ? this.handle('arb_host_reduce_unbounded', x) : this.handle('arb_reduce', x, fuel ?? 1_000_000_000n); + } + release(x) { this.call('arb_release', x); this.owners.get(x)?.delete(x); this.owners.delete(x); } + observe(x) { + const id = this.force(x); + try { + for (const kind of ['leaf', 'stem', 'fork']) { + this.call('arb_is_' + kind, id, this.scratch); + if (!this.view().getInt32(this.scratch, true)) continue; + if (kind === 'leaf') return {node: {kind}}; + if (kind === 'stem') return {node: {kind, child: this.handle('arb_get_stem_child', id)}}; + this.call('arb_get_fork_children', id, this.scratch, this.scratch + 8); + return {node: {kind, left: this.own(this.view().getBigUint64(this.scratch, true)), right: this.own(this.view().getBigUint64(this.scratch + 8, true))}}; + } + throw new Error('Expected constructor'); + } finally { this.release(id); } + } + pair(x) { const {node} = this.observe(x); if (node.kind !== 'fork') throw new Error('Expected pair'); return [node.left, node.right]; } + fromTernary(s) { + return this.scope(({keep}) => { + const stack = []; + for (let i = s.length - 1; i >= 0; i--) { + if (s[i] === '0') stack.push(this.retain(this.leaf)); + else if (s[i] === '1' && stack.length >= 1) { + const child = stack.pop(); stack.push(this.stem(child)); this.release(child); + } else if (s[i] === '2' && stack.length >= 2) { + const a = stack.pop(), b = stack.pop(); stack.push(this.fork(a,b)); this.release(a); this.release(b); + } else throw new Error('Invalid ternary tree'); + } + if (stack.length !== 1) throw new Error('Invalid ternary tree'); + return keep(stack[0]); + }); + } + toTernary(x, limit = 1_000_000) { + return this.scope(() => { + const pending = [this.retain(x)], result = []; + while (pending.length) { + if (result.length >= limit) throw new Error('Tree expansion limit'); + const current = pending.pop(), {node} = this.observe(current); + this.release(current); + if (node.kind === 'leaf') result.push('0'); + else if (node.kind === 'stem') { result.push('1'); pending.push(node.child); } + else { result.push('2'); pending.push(node.right, node.left); } + } + return result.join(''); + }); + } + dispose() { + if (!this.ctx) return; + if (this.scopes.length) throw new Error('Cannot dispose inside a scope'); + this.owners.clear(); + this.e.arb_host_free(this.scratch, 16); + this.e.arboricx_free(this.ctx); this.ctx = 0; + } +} diff --git a/ext/wasm/site-session.mjs b/ext/wasm/site-session.mjs @@ -0,0 +1,131 @@ +// Zig's private site adapter. Only cloneable values cross the session boundary; +// native owned handles stay here. Shared helpers validate/authenticate inputs. +import {WasmRuntime} from './runtime.mjs'; +export async function create({base,compiler}) { + const load=name=>import(new URL(`dist/${name}.js`,base).href); + const [bundle,codec,comp,contracts,packages]=await Promise.all(['bundle','codecs','compiler','contracts','packages'].map(load)); + const bytes=await bundle.fetchBytes(new URL('runtime/zig/arboricx.wasm',base)); + const g=await WasmRuntime.create(bytes,{unbounded:true}); + // The shared DAG reader's tick is JS accounting, not a native work ceiling. + g.tick=()=>{}; + let closed=false,busy=false,definitions=new Map(),library=new Map(),compilerHash; + let cancel;const cancelled=new Promise((_,reject)=>{cancel=reject;});cancelled.catch(()=>{}); + const alive=()=>{if(closed)throw Error('Disposed Zig session');}; + const cleanup=()=>{try{g.dispose();}catch{/* A poisoned instance is discarded, never reused. */}}; + async function exclusive(fn) { + alive();if(busy)throw Error('Session already busy');busy=true; + try{return await fn();} + catch(error){if(g.poisoned||error instanceof WebAssembly.RuntimeError)closed=true;throw error;} + finally{busy=false;if(closed)cleanup();} + } + function importBundle(value) { + return g.scope(({keep})=>new Map([...bundle.loadBundle(g,value)].map(([name,h])=>[name,keep(g.retain(h))]))); + } + const release=values=>{for(const h of values.values())g.release(h);}; + const fetchJSON=async(name,limit)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(await bundle.fetchBytes(new URL(name,base),limit))); + function literal(value) { + if(value.type==='string')return g.ofText(value.value); + if(value.type==='list')return codec.list(g,value.value.map(literal)); + return codec.makeLiteral(g,value); + } + function view(h,type) { + if(type==='string')return {type,value:g.toText(h)}; + if(type==='tree')return {type,value:{...g.preview(h),aliases:[]}}; + if(type==='list')return {type,value:codec.readList(g,h,32).map(x=>({...g.preview(x,8),aliases:[]}))}; + return codec.view(g,h,type); + } + const wait=promise=>Promise.race([promise,cancelled]); + async function io(handle,host) { + const [sentinel,body]=g.pair(handle); + if(g.toText(sentinel,7)!=='tricuIO')throw Error('Missing tricuIO sentinel'); + const [version,initial]=g.pair(body); + if(codec.readNatural(g,version)!==1n)throw Error('Unsupported tricuIO version'); + const frames=[];let action=initial,output=0; + const names=new Map([[0n,'pure'],[1n,'bind'],[2n,'abortIO'],[10n,'putStr'],[11n,'getLine']]); + for(let step=1;step<=128;step++) { + alive();g.collect(); + const [tag,payload]=g.pair(action),code=codec.readNatural(g,tag),name=names.get(code); + if(!name)throw Error(`Unsupported browser IO action ${code}`); + const detail=code===10n?g.toText(payload):'retained value'; + await wait(host.before({step,name,detail,depth:frames.length}));alive(); + if(code===1n){const [left,continuation]=g.pair(payload);frames.push(continuation);action=left;host.after({step,name,value:'retained value'});continue;} + if(code===2n)throw Error('Execution aborted (retained diagnostic; no payload forcing).'); + let value; + if(code===0n)value=payload; + else if(code===10n){output+=Array.from(detail).length;if(output>16384)throw Error('Terminal output limit exceeded');host.write(detail);value=g.leaf;} + else {const line=await wait(host.read());alive();if(/[\r\n]/u.test(line))throw Error('Input must be one line');value=g.ofText(line,256);} + host.after({step,name,value:'retained value'}); + if(!frames.length)return value; + host.running();action=g.app(frames.pop(),value); + } + throw Error('IO action limit exceeded'); + } + const execute=(name,args,mode,host,reader)=>exclusive(async()=>{ + const root=definitions.get(name);if(root===undefined)throw Error('Missing definition'); + args=codec.literals(args); + if(!['pure','io'].includes(mode))throw Error('Unsupported execution mode'); + try{return await g.scopeAsync(async()=>{ + const applied=g.apply(root,args.map(literal)); + return reader(mode==='io'?await io(applied,host):applied); + });}finally{if(!g.poisoned)g.collect();} + }); + const session={ + get disposed(){return closed;}, + load(bytes,hash){return exclusive(async()=>{ + bundle.bundleHash(hash); + if(!(bytes instanceof Uint8Array)||bytes.byteLength>bundle.BUNDLE_LIMIT)throw Error('Bundle byte limit/type'); + bytes=bytes.slice(); // Authenticate the same immutable snapshot we decode. + if(await bundle.sha256(bytes)!==hash)throw Error('Bundle SHA-256 mismatch'); + const next=importBundle(bundle.decodeBundle(bytes));release(definitions);definitions=next;g.collect(); + });}, + compile(source,profileName){return exclusive(async()=>{ + if(!compilerHash)throw Error('Compiler not initialized'); + source=comp.sourceText(source); + let support,selected; + if(profileName!==undefined){ + selected=packages.environmentProfile(await fetchJSON('environments.json',32768),profileName); + if(selected.compiler!==compilerHash)throw Error('Demo environment/compiler pin mismatch'); + if(selected.profile)support=await bundle.fetchBundle(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`,base),selected.profile.hash); + } + const next=g.scope(({keep})=>{ + let environment; + if(profileName===undefined)environment=contracts.contractEnvironment(g,library); + else { + const values=support?importBundle(support):new Map(); + environment=codec.list(g,(selected.profile?.names??[]).map(name=>{ + const h=values.get(name);if(h===undefined)throw Error(`Missing demo environment binding ${name}`); + return g.fork(g.ofText(name),h); + })); + } + const result=g.apply(library.get(comp.COMPILER_EXPORT),[environment,g.ofText(source,16384)]); + const values=comp.compiledDefinitions(g,result); + for(const h of values.values())keep(h); + return values; + }); + release(definitions);definitions=next;g.collect();return [...next.keys()]; + });}, + run(name,args,output,mode,host){ + output=codec.outputType(output); + return execute(name,args,mode,host,h=>view(h,output)); + }, + // Explicit, bounded full observation for independent embedding conformance; + // never used to preserve definitions or to implement the site's Run button. + inspect(name,args,mode,host){return execute(name,args,mode,host,h=>g.toTernary(h,1000000));}, + preview(name,limit=64){return exclusive(()=>g.scope(()=>{ + const root=definitions.get(name);if(root===undefined)throw Error('Missing definition'); + return {...g.preview(root,limit),aliases:[]}; + }));}, + collect(){alive();try{g.collect();}catch(error){if(g.poisoned)session.dispose();throw error;}}, + diagnostics(){alive();return {...g.stats(),linearMemoryBytes:g.e.memory.buffer.byteLength};}, + stats(){alive();return {backend:'zig-wasm',linearMemoryBytes:g.e.memory.buffer.byteLength};}, + dispose(){if(closed)return;closed=true;cancel(Error('Disposed Zig session'));if(!busy)cleanup();}, + }; + try { + if(compiler){ + compilerHash=comp.compilerDescriptor(await fetchJSON('compiler.json',4096)).hash; + library=importBundle(await bundle.fetchBundle(new URL(`/_arboricx/bundle/hash/${compilerHash}`,base),compilerHash,bundle.COMPILER_BUNDLE_LIMIT)); + if(!library.has(comp.COMPILER_EXPORT))throw Error('Missing stable compiler entrypoint'); + } + return session; + }catch(error){session.dispose();throw error;} +} diff --git a/ext/wasm/tree-session.mjs b/ext/wasm/tree-session.mjs @@ -0,0 +1,34 @@ +// Private implementation shared by today's two tree adapters, not a requirement +// on other backends. Runtime handles and checkpoints never leave this closure. +import {compiledDefinitions} from '../demo-site/dist/compiler.js'; +export function treeSession(g, root, adapter) { + let definitions=new Map(), disposed=false; + const active=()=>{if(disposed)throw new Error('Session disposed');}; + const definition=name=>{const h=definitions.get(name);if(h===undefined)throw new Error('Missing definition');return h;}; + const scoped=fn=>{active();return adapter.scope(fn);}; + return { + runText(value) {return scoped(()=>adapter.toText(g.app(root,adapter.ofText(value))));}, + compile(source) { + const next=scoped(({keep})=>{ + const input=adapter.ofText(source,16384); + const defs=compiledDefinitions(g,g.apply(root,[g.leaf,input])); + for(const h of defs.values())keep(h); + return defs; + }); + for(const h of definitions.values())adapter.release(h); + definitions=next; + return [...definitions.keys()]; + }, + inspect(name,args) {return scoped(()=>g.toTernary(g.apply(definition(name),args.map(x=>g.fromTernary(x)))));}, + preview(name,limit) {active();return g.preview(definition(name),limit);}, + // Bounded tree inputs are a harness operation, not source compilation. + // Worker termination is the deadline for divergent evaluation. + runTree(program,args) {return scoped(()=>{ + const applied=g.apply(g.fromTernary(program),args.map(x=>g.fromTernary(x))); + return g.toTernary(g.force(applied,0x7fffffffffffffffn)); + });}, + collect() {active();adapter.collect([root,...definitions.values()]);}, + stats() {active();return adapter.stats();}, + dispose() {if(disposed)return;disposed=true;definitions.clear();try{adapter.dispose();}finally{g=undefined;adapter=undefined;}}, + }; +} diff --git a/ext/wasm/worker.mjs b/ext/wasm/worker.mjs @@ -0,0 +1,38 @@ +// Experimental session Worker, not the deployed site's IO protocol. Backend +// selection is explicit and immutable for this Worker; switching needs a new one. +import {openSession} from './backends.mjs'; +let session,busy=false; +self.onmessage=async({data:m})=>{ + if(busy){postMessage({type:'error',id:m.id,message:'Worker busy'});return;} + busy=true; + try { + if(m.type==='init') { + if(session)throw new Error('Already initialized'); + session=await openSession(m); + postMessage({type:'ready',id:m.id,backend:m.backend,stats:await session.stats()}); + } else { + if(!session)throw new Error('Worker not initialized'); + const start=performance.now(); + if(m.type==='text') { + const value=await session.runText(m.value); + postMessage({type:'text',id:m.id,value,ms:performance.now()-start}); + } else if(m.type==='compile') { + const names=await session.compile(m.source); + postMessage({type:'compiled',id:m.id,names,ms:performance.now()-start}); + } else if(m.type==='inspect') { + postMessage({type:'result',id:m.id,value:await session.inspect(m.name,m.arguments)}); + } else if(m.type==='preview') { + const value=await session.preview(m.name,m.limit); + postMessage({type:'preview',id:m.id,value,ms:performance.now()-start}); + } else if(m.type==='collect') { + await session.collect(); + postMessage({type:'collected',id:m.id,stats:await session.stats()}); + } else if(m.type==='diverge') { + postMessage({type:'running',id:m.id}); + await session.runTree('212110021100',['212110021100']); + throw new Error('Divergence unexpectedly returned'); + } else throw new Error('Unknown message'); + } + } catch(error){postMessage({type:'error',id:m.id,message:String(error)});} + finally{busy=false;} +}; diff --git a/ext/zig/README.md b/ext/zig/README.md @@ -17,6 +17,12 @@ compares complete compiler generations executed by Haskell and Zig. The workflow includes independent object audits, program smokes and retention checks. Haskell supplies common artifact transport and packaging. +The [WASM adapter](../wasm/README.md) reuses this reducer without native IO. +The [demo site](../demo-site/README.md#runtime-selection) selects it alongside JS +and Haskell, with browser IO implemented by the owning session. Browser work +uses explicit unbounded reducer/Unicode/preview entry points and hard Worker +termination; existing finite-fuel native entry points retain their semantics. + ## Basic performance smoke (Linux) ```sh diff --git a/ext/zig/build.zig b/ext/zig/build.zig @@ -12,6 +12,13 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run reducer, collection and demand tests"); test_step.dependOn(&b.addRunArtifact(reducer_tests).step); + const preview_tests = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/preview.zig"), + .target = target, + .optimize = optimize, + }) }); + test_step.dependOn(&b.addRunArtifact(preview_tests).step); + const io_tests_mod = b.createModule(.{ .root_source_file = b.path("src/io_driver.zig"), .target = target, @@ -42,6 +49,21 @@ pub fn build(b: *std.Build) void { .root_source_file = kernel_embed, }); + // Independent browser evaluator: no WASI, libc, libuv, or host imports. + const wasm_mod = b.createModule(.{ + .root_source_file = b.path("src/c_abi.zig"), + .target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding }), + .optimize = .ReleaseSafe, + }); + wasm_mod.addImport("kernel_embed", kernel_mod); + const wasm = b.addExecutable(.{ .name = "arboricx", .root_module = wasm_mod }); + wasm.entry = .disabled; + wasm.rdynamic = true; + wasm.stack_size = 8 * 1024 * 1024; + wasm.max_memory = 512 * 1024 * 1024; + const install_wasm = b.addInstallArtifact(wasm, .{}); + b.step("wasm", "Build the standalone browser evaluator prototype").dependOn(&install_wasm.step); + // -- main CLI executable -- const exe_mod = b.createModule(.{ .root_source_file = b.path("src/main.zig"), diff --git a/ext/zig/src/c_abi.zig b/ext/zig/src/c_abi.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const wasm = @import("builtin").target.cpu.arch == .wasm32; const tree = @import("tree.zig"); const Arena = @import("arena.zig").Arena; const reduce = @import("reduce.zig"); @@ -63,7 +64,7 @@ export fn arb_abi_version() u32 { return 2; } export fn arboricx_init() ?*ArbCtx { - const gpa = std.heap.smp_allocator; + const gpa = if (wasm) std.heap.wasm_allocator else std.heap.smp_allocator; const ptr = gpa.create(ArbCtx) catch return null; ptr.* = .{ .gpa = gpa, .arena = Arena.init(gpa), .kernel_root = undefined }; ptr.kernel_root = kernel.loadKernel(&ptr.arena) catch { @@ -221,6 +222,7 @@ export fn arb_unwrap_host_value(ctx: *ArbCtx, root: Handle, out_tag: *u64, out_p } pub const arb_io_perms_t = extern struct { allow_read_all: c_int, allow_write_all: c_int }; export fn arb_run_io(ctx: *ArbCtx, program: Handle, perms: ?*const arb_io_perms_t, out: *Handle) Status { + if (wasm) return .io_error; // Native IO is unavailable in the browser prototype. const node = ctx.handles.get(program) catch |err| return status(err); ctx.prepare() catch |err| return status(err); const zig_perms = if (perms) |p| io_driver.IOPerms{ @@ -243,3 +245,83 @@ export fn arb_load_bundle(ctx: *ArbCtx, bytes: [*]const u8, len: usize, name: [* export fn arb_load_bundle_default(ctx: *ArbCtx, bytes: [*]const u8, len: usize, out: *Handle) Status { return ctx.publish(bundle.loadBundleDefaultRoot(&ctx.arena, bytes[0..len]) catch |err| return status(err), out); } + +// WASM-only host scratch buffers; not additions to the native C ABI. +comptime { + if (wasm) { + @export(&hostAlloc, .{ .name = "arb_host_alloc" }); + @export(&hostFree, .{ .name = "arb_host_free" }); + @export(&hostOfText, .{ .name = "arb_host_of_text" }); + @export(&hostToText, .{ .name = "arb_host_to_text" }); + @export(&hostPreview, .{ .name = "arb_host_preview" }); + @export(&hostReduceUnbounded, .{ .name = "arb_host_reduce_unbounded" }); + @export(&hostPreviewUnbounded, .{ .name = "arb_host_preview_unbounded" }); + @export(&hostStats, .{ .name = "arb_host_stats" }); + } +} +// 8-byte alignment permits u64 ABI output parameters. +fn hostAlloc(len: usize) callconv(.c) ?[*]align(8) u8 { + const allocator = if (wasm) std.heap.wasm_allocator else std.heap.smp_allocator; + const bytes = allocator.alignedAlloc(u8, .@"8", len) catch return null; + return bytes.ptr; +} +fn hostFree(ptr: [*]align(8) u8, len: usize) callconv(.c) void { + const allocator = if (wasm) std.heap.wasm_allocator else std.heap.smp_allocator; + allocator.free(ptr[0..len]); +} + +// Browser text is a list of Unicode scalars, never a list of UTF-8 bytes. +fn hostOfText(ctx: *ArbCtx, ptr: [*]const u8, len: usize, limit: usize, out: *Handle) callconv(.c) Status { + const view = std.unicode.Utf8View.init(ptr[0..len]) catch return .invalid_data; + var iter = view.iterator(); + var count: usize = 0; + while (iter.nextCodepoint()) |_| { + if (count == limit) return .invalid_data; + count += 1; + } + return ctx.publish(@import("source_text.zig").fromUtf8(&ctx.arena, ptr[0..len]) catch |err| return status(err), out); +} +fn hostToText(ctx: *ArbCtx, root: Handle, limit: usize, out_ptr: **u8, out_len: *usize) callconv(.c) Status { + const node = ctx.handles.get(root) catch |err| return status(err); + ctx.prepare() catch |err| return status(err); + const bytes = @import("source_text.zig").toUtf8UnboundedWork(&ctx.arena, node, limit) catch |err| return status(err); + out_ptr.* = @ptrCast(bytes.ptr); + out_len.* = bytes.len; + return .ok; +} +// Diagnostic counts, not byte/RSS limits; useful for repeated-session checks. +fn hostStats(ctx: *ArbCtx, slots: *u32, owned: *u32, nodes: *u32, live: *u32) callconv(.c) Status { + slots.* = @intCast(ctx.handles.slots.items.len); + owned.* = 0; + for (ctx.handles.slots.items) |slot| { + if (slot.node != null) owned.* += 1; + } + nodes.* = ctx.arena.len(); + live.* = @intCast(ctx.arena.last_live); + return .ok; +} + +fn hostPreview(ctx: *ArbCtx, root: Handle, limit: u32, fuel: u64, out_ptr: **u8, out_len: *usize) callconv(.c) Status { + const node = ctx.handles.get(root) catch |err| return status(err); + ctx.prepare() catch |err| return status(err); + const bytes = @import("preview.zig").capture(&ctx.arena, node, limit, fuel) catch |err| return status(err); + out_ptr.* = @ptrCast(bytes.ptr); + out_len.* = bytes.len; + return .ok; +} + +// Browser work is cancellable by Worker termination, not an implicit fuel cap. +fn hostReduceUnbounded(ctx: *ArbCtx, root: Handle, out: *Handle) callconv(.c) Status { + const node = ctx.handles.get(root) catch |err| return status(err); + ctx.prepare() catch |err| return status(err); + const result = reduce.reduceUnbounded(node, &ctx.arena) catch |err| return status(err); + return ctx.publish(result, out); +} +fn hostPreviewUnbounded(ctx: *ArbCtx, root: Handle, limit: u32, out_ptr: **u8, out_len: *usize) callconv(.c) Status { + const node = ctx.handles.get(root) catch |err| return status(err); + ctx.prepare() catch |err| return status(err); + const bytes = @import("preview.zig").captureUnboundedWork(&ctx.arena, node, limit) catch |err| return status(err); + out_ptr.* = @ptrCast(bytes.ptr); + out_len.* = bytes.len; + return .ok; +} diff --git a/ext/zig/src/preview.zig b/ext/zig/src/preview.zig @@ -0,0 +1,102 @@ +//! Bounded constructor snapshots. Labels belong to one snapshot, never the arena +//! or the owned-handle API. Frontier children remain undemanded at the node cap. +const std = @import("std"); +const Arena = @import("arena.zig").Arena; +const reduce = @import("reduce.zig"); + +const Record = struct { label: u32, kind: u32, left: u32 = 0, right: u32 = 0 }; +const Labels = struct { + ids: [513]u32 = undefined, + len: usize = 0, + fn intern(self: *Labels, id: u32) u32 { + if (std.mem.indexOfScalar(u32, self.ids[0..self.len], id)) |index| return @intCast(index); + const index = self.len; + self.ids[index] = id; + self.len += 1; + return @intCast(index); + } +}; + +// Binary v1: little-endian u32 version, root label, record count, truncated; +// followed by [label, constructor arity (0/1/2), left, right] records. +// Fuel is PER constructor demand, not a whole-snapshot fuel budget. +pub fn capture(arena: *Arena, root: u32, limit: u32, fuel: u64) ![]u8 { + return captureWithFuel(arena, root, limit, fuel); +} +pub fn captureUnboundedWork(arena: *Arena, root: u32, limit: u32) ![]u8 { + return captureWithFuel(arena, root, limit, null); +} +fn captureWithFuel(arena: *Arena, root: u32, limit: u32, fuel: ?u64) ![]u8 { + if (limit == 0 or limit > 256) return error.InvalidData; + const base = arena.roots.items.len; + try arena.roots.append(arena.allocator, root); + defer arena.roots.shrinkRetainingCapacity(base); + var labels: Labels = .{}; + var pending: [513]u32 = undefined; + pending[0] = labels.intern(root); + var pending_len: usize = 1; + var seen = [_]bool{false} ** 513; + var records: [256]Record = undefined; + var count: usize = 0; + var truncated = false; + while (pending_len > 0) { + pending_len -= 1; + const label = pending[pending_len]; + if (seen[label]) continue; + if (count == limit) { + truncated = true; + break; + } + const normal = if (fuel) |budget| try reduce.reduce(labels.ids[label], arena, budget) else try reduce.reduceUnbounded(labels.ids[label], arena); + const node = arena.get(normal).*; // Never retain a pointer over allocation/GC. + seen[label] = true; + var record = Record{ .label = label, .kind = 0 }; + switch (node) { + .leaf => {}, + .stem => |s| { + record.kind = 1; + record.left = labels.intern(s.child); + pending[pending_len] = record.left; + pending_len += 1; + }, + .fork => |f| { + record.kind = 2; + record.left = labels.intern(f.left); + record.right = labels.intern(f.right); + pending[pending_len] = record.right; + pending[pending_len + 1] = record.left; + pending_len += 2; + }, + .app => unreachable, + } + records[count] = record; + count += 1; + } + const bytes = try arena.allocator.alloc(u8, 16 + count * 16); + const header = [_]u32{ 1, 0, @intCast(count), @intFromBool(truncated) }; + for (header, 0..) |word, i| std.mem.writeInt(u32, bytes[i * 4 ..][0..4], word, .little); + for (records[0..count], 0..) |record, i| { + const words = [_]u32{ record.label, record.kind, record.left, record.right }; + for (words, 0..) |word, j| std.mem.writeInt(u32, bytes[16 + i * 16 + j * 4 ..][0..4], word, .little); + } + return bytes; +} + +fn allocationProbe(allocator: std.mem.Allocator) !void { + var arena = Arena.init(allocator); + defer arena.deinit(); + const leaf = try arena.alloc(.leaf); + var root = leaf; + for (0..31) |_| root = try arena.alloc(.{ .fork = .{ .left = root, .right = root } }); + arena.gc_enabled = true; + try arena.setGcPolicy(1, 1); + const bytes = try captureUnboundedWork(&arena, root, 32); + defer allocator.free(bytes); + try std.testing.expectEqual(@as(usize, 16 + 32 * 16), bytes.len); + try std.testing.expectEqual(@as(u32, 0), std.mem.readInt(u32, bytes[12..16], .little)); + try std.testing.expectEqual(@as(usize, 0), arena.roots.items.len); +} + +test "preview preserves sharing, survives GC, and cleans every allocation failure" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationProbe, .{}); +} diff --git a/ext/zig/src/reduce.zig b/ext/zig/src/reduce.zig @@ -9,10 +9,20 @@ const Frame = @import("arena.zig").ReductionFrame; /// Fuel counts application entries, not observations or continuation dispatch. /// Collection is opt-in: callers must register all externally held node IDs. pub fn reduce(root: u32, arena: *Arena, fuel: u64) ReduceError!u32 { + return reduceWithLimit(root, arena, fuel); +} + +/// No work ceiling. Host cancellation must dispose the owning Worker. +pub fn reduceUnbounded(root: u32, arena: *Arena) ReduceError!u32 { + return reduceWithLimit(root, arena, null); +} + +fn reduceWithLimit(root: u32, arena: *Arena, fuel: ?u64) ReduceError!u32 { // Codecs repeatedly observe already-normal constructors. Do not borrow the // continuation buffers for those calls, but never skip a due collection. if (!arena.collectionDue() and arena.get(root).* != .app) return root; - var remaining = fuel; + var remaining = fuel orelse 0; + const bounded = fuel != null; // Reuse scratch capacity across codec/IO demands. Move it out while active; // no stale redex IDs survive a return, including fuel/allocation failures. var frames = arena.reduction_frames; @@ -41,8 +51,8 @@ pub fn reduce(root: u32, arena: *Arena, fuel: u64) ReduceError!u32 { } var frame: Frame = undefined; if (arena.get(current).* == .app) { - if (remaining == 0) return error.FuelExhausted; - remaining -= 1; + if (bounded and remaining == 0) return error.FuelExhausted; + if (bounded) remaining -= 1; frame = .{ .kind = .function, .redex = current }; current = arena.get(current).app.func; } else { @@ -89,17 +99,17 @@ pub fn reduce(root: u32, arena: *Arena, fuel: u64) ReduceError!u32 { // The generated left application is demanded immediately. // Fuse its constructor-only reduction when two further // application entries fit in the caller's fuel budget. - if (remaining >= 2) { + if (!bounded or remaining >= 2) { switch (arena.get(s.child).*) { .leaf => { const b = try arena.allocApp(f.right, app.arg); - remaining -= 2; + if (bounded) remaining -= 2; arena.get(orig).* = .{ .fork = .{ .left = app.arg, .right = b } }; current = orig; break :demand; }, .stem => |k| if (arena.get(k.child).* == .leaf) { - remaining -= 2; + if (bounded) remaining -= 2; frame.kind = .update; current = app.arg; continue :demand; @@ -110,7 +120,7 @@ pub fn reduce(root: u32, arena: *Arena, fuel: u64) ReduceError!u32 { // private thunk need not be allocated. const value = arena.get(k.right).*; if (value == .fork and arena.get(value.fork.left).* == .leaf) { - remaining -= 2; + if (bounded) remaining -= 2; frame.kind = .update; current = value.fork.right; continue :demand; @@ -118,15 +128,15 @@ pub fn reduce(root: u32, arena: *Arena, fuel: u64) ReduceError!u32 { const b = try arena.allocApp(f.right, app.arg); switch (value) { .leaf => { - remaining -= 2; + if (bounded) remaining -= 2; arena.get(orig).* = .{ .stem = .{ .child = b } }; }, .stem => |v| { - remaining -= 2; + if (bounded) remaining -= 2; arena.get(orig).* = .{ .fork = .{ .left = v.child, .right = b } }; }, else => { - remaining -= 1; + if (bounded) remaining -= 1; arena.get(orig).* = .{ .app = .{ .func = k.right, .arg = b } }; }, } @@ -166,16 +176,16 @@ pub fn reduce(root: u32, arena: *Arena, fuel: u64) ReduceError!u32 { // Triage produces (handler left) right. The inner // application is private and immediately demanded; // preserve both application-entry charges when fused. - if (remaining >= 2) { + if (!bounded or remaining >= 2) { switch (arena.get(f.right).*) { .leaf => { - remaining -= 2; + if (bounded) remaining -= 2; arena.get(orig).* = .{ .fork = .{ .left = arg.left, .right = arg.right } }; current = orig; break :demand; }, .stem => |k| if (arena.get(k.child).* == .leaf) { - remaining -= 2; + if (bounded) remaining -= 2; frame.kind = .update; current = arg.left; continue :demand; @@ -183,22 +193,22 @@ pub fn reduce(root: u32, arena: *Arena, fuel: u64) ReduceError!u32 { .fork => |k| if (arena.get(k.left).* == .leaf) { const value = arena.get(k.right).*; if (value == .fork and arena.get(value.fork.left).* == .leaf) { - remaining -= 2; + if (bounded) remaining -= 2; frame.kind = .update; current = value.fork.right; continue :demand; } switch (value) { .leaf => { - remaining -= 2; + if (bounded) remaining -= 2; arena.get(orig).* = .{ .stem = .{ .child = arg.right } }; }, .stem => |v| { - remaining -= 2; + if (bounded) remaining -= 2; arena.get(orig).* = .{ .fork = .{ .left = v.child, .right = arg.right } }; }, else => { - remaining -= 1; + if (bounded) remaining -= 1; arena.get(orig).* = .{ .app = .{ .func = k.right, .arg = arg.right } }; }, } diff --git a/ext/zig/src/reduce_test.zig b/ext/zig/src/reduce_test.zig @@ -222,6 +222,7 @@ test "all pairs of depth-two values agree with an independent reference" { var fuel: usize = 10000; const expected = try reference(&a, f, x, &fuel); try equal(&a, try app(&a, f, x), expected); + try equal(&a, try machine.reduceUnbounded(try app(&a, f, x), &a), expected); }; } diff --git a/ext/zig/src/source_text.zig b/ext/zig/src/source_text.zig @@ -1,4 +1,4 @@ -//! Source-only Unicode boundary. Binary/string ABI codecs remain byte-oriented. +//! Unicode scalar boundary for source IO and the browser adapter. Binary/string ABI codecs remain byte-oriented. const std = @import("std"); const Arena = @import("arena.zig").Arena; const codecs = @import("codecs.zig"); @@ -18,13 +18,13 @@ pub fn fromUtf8(arena: *Arena, bytes: []const u8) !u32 { // Match the Haskell source boundary's canonical-tree check, not merely its // numeric interpretation. Reject redundant high zero bits, non-Boolean bits, // overlong integers, surrogates and values beyond the Unicode scalar range. -fn scalarNumber(arena: *Arena, input: u32) !u21 { +fn scalarNumber(arena: *Arena, input: u32, unbounded: bool) !u21 { var current = input; var value: u32 = 0; var bits: u5 = 0; var high_bit: u32 = 0; while (true) { - const node = arena.get(try reduce.reduce(current, arena, 1_000_000)).*; + const node = arena.get(try observe(arena, current, unbounded)).*; switch (node) { .leaf => { if (bits != 0 and high_bit == 0) return error.InvalidScalarList; @@ -33,10 +33,10 @@ fn scalarNumber(arena: *Arena, input: u32) !u21 { }, .fork => |pair| { if (bits == 21) return error.InvalidScalarList; - const bit = arena.get(try reduce.reduce(pair.left, arena, 1_000_000)).*; + const bit = arena.get(try observe(arena, pair.left, unbounded)).*; high_bit = switch (bit) { .leaf => 0, - .stem => |s| if (arena.get(try reduce.reduce(s.child, arena, 1_000_000)).* == .leaf) 1 else return error.InvalidScalarList, + .stem => |s| if (arena.get(try observe(arena, s.child, unbounded)).* == .leaf) 1 else return error.InvalidScalarList, else => return error.InvalidScalarList, }; value |= high_bit << bits; @@ -49,18 +49,39 @@ fn scalarNumber(arena: *Arena, input: u32) !u21 { } pub fn toUtf8(arena: *Arena, input: u32) ![]u8 { + return toUtf8Bounded(arena, input, std.math.maxInt(usize)); +} + +// A scalar-count ceiling, not a fuel/deadline limit. Do not demand elements +// beyond the ceiling; still inspect the spine to distinguish exact length. +fn observe(arena: *Arena, input: u32, unbounded: bool) !u32 { + return if (unbounded) reduce.reduceUnbounded(input, arena) else reduce.reduce(input, arena, 1_000_000); +} + +pub fn toUtf8UnboundedWork(arena: *Arena, input: u32, limit: usize) ![]u8 { + return decode(arena, input, limit, true); +} + +pub fn toUtf8Bounded(arena: *Arena, input: u32, limit: usize) ![]u8 { + return decode(arena, input, limit, false); +} + +fn decode(arena: *Arena, input: u32, limit: usize, unbounded: bool) ![]u8 { const base = arena.roots.items.len; defer arena.roots.shrinkRetainingCapacity(base); try arena.roots.append(arena.allocator, input); var bytes: std.ArrayList(u8) = .empty; errdefer bytes.deinit(arena.allocator); var current = input; + var count: usize = 0; while (true) { - const node = arena.get(try reduce.reduce(current, arena, 1_000_000)).*; + const node = arena.get(try observe(arena, current, unbounded)).*; switch (node) { .leaf => return bytes.toOwnedSlice(arena.allocator), .fork => |pair| { - const scalar = try scalarNumber(arena, pair.left); + if (count == limit) return error.ScalarLimit; + count += 1; + const scalar = try scalarNumber(arena, pair.left, unbounded); var encoded: [4]u8 = undefined; const len = try std.unicode.utf8Encode(scalar, &encoded); try bytes.appendSlice(arena.allocator, encoded[0..len]); @@ -141,3 +162,15 @@ fn allocationProbe(allocator: std.mem.Allocator) !void { test "source scalar codec cleans up on every allocation failure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationProbe, .{}); } + + +test "bounded scalar encoding handles exact limits and unwinds partial output" { + var arena = Arena.init(std.testing.allocator); + defer arena.deinit(); + const input = try fromUtf8(&arena, "é😀"); + const bytes = try toUtf8Bounded(&arena, input, 2); + defer arena.allocator.free(bytes); + try std.testing.expectEqualStrings("é😀", bytes); + try std.testing.expectError(error.ScalarLimit, toUtf8Bounded(&arena, input, 1)); + try std.testing.expectEqual(@as(usize, 0), arena.roots.items.len); +} diff --git a/flake.lock b/flake.lock @@ -1,5 +1,22 @@ { "nodes": { + "browser-wasi": { + "flake": false, + "locked": { + "lastModified": 1770145141, + "narHash": "sha256-tfq0gNw1wFMNALVAXzUg6CMRbvxNjM95bwp4Ncv4+W8=", + "owner": "haskell-wasm", + "repo": "browser_wasi_shim", + "rev": "2f86b49dce50916e2984029c535321e34b234229", + "type": "github" + }, + "original": { + "owner": "haskell-wasm", + "repo": "browser_wasi_shim", + "rev": "2f86b49dce50916e2984029c535321e34b234229", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,8 +35,62 @@ "type": "github" } }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "ghc-wasm": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1791804790, + "narHash": "sha256-Bi4vIdDi1kp21EwXoYOQJQZFvETRhPBC2QIFdu8A5K8=", + "owner": "haskell-wasm", + "repo": "ghc-wasm-meta", + "rev": "8079eb96bbc63823d99ad8acaff0765532625474", + "type": "github" + }, + "original": { + "owner": "haskell-wasm", + "repo": "ghc-wasm-meta", + "rev": "8079eb96bbc63823d99ad8acaff0765532625474", + "type": "github" + } + }, "nixpkgs": { "locked": { + "lastModified": 1791244311, + "narHash": "sha256-cy5PsNucfXU5+KtLR+S9/zdkWOUplhL5Xr0LQWhSYbs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2bd3427b41d10b8318383195efe502ed1baca6cd", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-26.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { "lastModified": 1778505177, "narHash": "sha256-ao5+JS50HqNt/dtm4zuiQI+IXOn6hw50W6RTwUKYTww=", "owner": "NixOS", @@ -35,8 +106,10 @@ }, "root": { "inputs": { + "browser-wasi": "browser-wasi", "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "ghc-wasm": "ghc-wasm", + "nixpkgs": "nixpkgs_2" } }, "systems": { @@ -53,6 +126,21 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix @@ -3,14 +3,16 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs"; + browser-wasi = { url = "github:haskell-wasm/browser_wasi_shim/2f86b49dce50916e2984029c535321e34b234229"; flake = false; }; + ghc-wasm.url = "github:haskell-wasm/ghc-wasm-meta/8079eb96bbc63823d99ad8acaff0765532625474"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, flake-utils, ghc-wasm, browser-wasi }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; - siteRelease = import ./ext/demo-site/release.nix { inherit pkgs; }; + siteRelease = import ./ext/demo-site/release.nix { inherit pkgs; zigRuntime = self.packages.${system}.tricu-wasm; haskellRuntime = self.packages.${system}.haskellWasmBrowser; }; packageName = "tricu"; containerPackageName = "${packageName}-container"; @@ -151,6 +153,57 @@ ''; }; + tricuWasm = pkgs.stdenv.mkDerivation { + pname = "tricu-wasm"; + version = "0.1.0"; + src = ./ext/zig; + nativeBuildInputs = [ pkgs.zig ]; + buildPhase = '' + export ZIG_GLOBAL_CACHE_DIR=$TMPDIR/zig-cache + zig build wasm + ''; + installPhase = '' + mkdir -p $out/lib + cp zig-out/bin/arboricx.wasm $out/lib/ + ''; + }; + + tricuWasmTests = pkgs.runCommand "tricu-wasm-tests" { + nativeBuildInputs = [ pkgs.nodejs pkgs.typescript ]; + } '' + cp -r ${tricuSource} source + chmod -R u+w source + cd source + tsc -p ext/demo-site/tsconfig.json + timeout 120 node ext/wasm/check.mjs ${tricuWasm}/lib/arboricx.wasm + timeout 120 node ext/wasm/backend-check.mjs ${tricuWasm}/lib/arboricx.wasm + mkdir -p $out + ''; + + tricuWasmBrowser = pkgs.runCommand "tricu-wasm-browser" { + nativeBuildInputs = [ pkgs.nodejs pkgs.typescript pkgs.chromium ]; + } '' + export HOME=$TMPDIR/home + mkdir -p "$HOME" $out + cp -r ${tricuSource} source + chmod -R u+w source + cd source + tsc -p ext/demo-site/tsconfig.json + timeout 240 node ext/wasm/browser.mjs ${tricuWasm}/lib/arboricx.wasm chromium $out/report.json + ''; + + tricuWasmComparison = pkgs.runCommand "tricu-wasm-comparison" { + nativeBuildInputs = [ pkgs.nodejs pkgs.typescript ]; + } '' + cp -r ${tricuSource} source + chmod -R u+w source + cd source + tsc -p ext/demo-site/tsconfig.json + mkdir -p $out + node ext/wasm/compare.mjs ${tricuWasm}/lib/arboricx.wasm $out/report.json + node ext/wasm/preview-compare.mjs ${tricuWasm}/lib/arboricx.wasm $out/preview-report.json + ''; + # Explicit, Linux-only smoke measurements; not a timing threshold gate. tricuZigPerformanceSmoke = pkgs.stdenv.mkDerivation { pname = "tricu-zig-performance-smoke"; @@ -337,6 +390,16 @@ ''; }; in { + packages.haskellWasmBrowser = import ./ext/haskell-wasm/browser.nix { + inherit pkgs; + probe = self.packages.${system}.haskellWasmProbe; + toolchain = ghc-wasm.packages.${system}.all_9_14; + shimSource = browser-wasi; + }; + packages.haskellWasmProbe = import ./ext/haskell-wasm/build.nix { + inherit pkgs; + toolchain = ghc-wasm.packages.${system}.all_9_14; + }; packages.${packageName} = tricuPackage; packages.default = tricuPackage; # Independent Haskell oracle for the opt-in published-library consumer. @@ -431,6 +494,10 @@ ''; packages.tricu-zig = tricuZig; + packages.tricu-wasm = tricuWasm; + packages.tricu-wasm-tests = tricuWasmTests; + packages.tricu-wasm-browser = tricuWasmBrowser; + packages.tricu-wasm-comparison = tricuWasmComparison; packages.tricu-zig-tests = tricuZigTests; packages.tricu-zig-performance-smoke = tricuZigPerformanceSmoke; packages.tricu-php = tricuPhp;