README.md (15847B)
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:
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.
previewMsrecords 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:
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 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
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:
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:
{ 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
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.
The production adapter also supports runExpression(source, output, mode, host).
It lazily authenticates/loads the pinned compiler when needed and binds the
existing definition handles into a temporary compilation environment. The
single expression result is scoped through observation/IO; its temporary name
is not added to definitions. No stored tree is flattened to reconstruct source.
The same exclusive async scope releases transient handles on success or error.
WasmRuntime.snapshot uses the WASM-only arb_host_snapshot_unbounded transport
entrypoint for result files: 1–100,000 observed nodes, with unbounded reduction
work per observation. The existing preview entrypoints retain their 256-node
ceiling. Snapshot labels preserve sharing, use bounded dynamic storage, and never
become session handles. site-session.mjs formats complete snapshots with the
shared serializer; incomplete snapshots and oversized expanded files fail rather
than producing partial downloads. Stored definition export still reads immutable
bundle records without evaluation.