uce/WASM-PROPOSAL.md

54 KiB
Raw Blame History

WASM-PROPOSAL: WebAssembly Unit Runtime for UCE

  • Status: building the production worker per §9.1 (W-phases). W1 (core module), W2 (wasm unit compile target), W3 (workspace runtime + membrane, src/wasm/worker.cpp), and W4 (config-selectable FastCGI backend, src/wasm/backend.cpp) are done: with WASM_BACKEND_ENABLED=1 the real nginx → fastcgi → wasm path serves the starter app (parity 14/14) and produces clean error pages from an unharmed worker on real kill pages. Native is still the default backend (full suite 83/83). Current phase: W5 — full parity, performance, cutover (un-stub regex/xml/yaml, add the remaining membrane hostcalls, then flip the default).
  • Scope: replace the native unit pipeline (generated C++ → clang → .sodlopen) with per-unit WebAssembly modules executed in a per-request, runtime-linked workspace, exposing the same API surface to page code, enabling memory safety, better execution control, and paving the way for supporting more source languages in the future.

1. Motivation

Two long-standing structural problems and one strategic opportunity share a single root cause: request code shares an address space and an allocator with the runtime.

  1. The arena attempt failed for a structural reason. The GLOBAL_ARENA_ALLOCATOR design in _scratchpad.cpp swapped the global operator new/delete against a current_memory_arena. A single global allocator cannot distinguish request-lifetime allocations from process-lifetime ones: during a request, server-lifetime structures (compile registry, sessions, config trees, unit statics) also allocate. Under the arena those dangle on reset; with delete as a no-op, system-allocated objects released mid-request leak. The lifetime distinction lives in the type system and call graph, not at the allocator boundary — fixing it in-process means threading PMR allocators through String, DValue, and every container.

  2. Fault recovery is best-effort, not sound. The current SIGSEGV → sigsetjmp/siglongjmp recovery performs no unwinding, skips destructors, can resume over corrupted worker state, and (see RECOMMENDATIONS.md 1.5) cannot reliably produce a useful backtrace. A faulting unit can have scribbled on runtime memory before the signal.

  3. UCE can only ever host fully-trusted code. A .uce page is arbitrary native code with full process privileges. Multi-tenant or user-supplied page hosting is structurally impossible in the native model.

A WASM execution model deletes the shared-fate fact itself rather than patching around it:

  • Arena by construction: each request runs in its own linear memory, dropped wholesale at request end. Request-lifetime memory physically cannot outlive the request; server-lifetime state physically cannot live inside it. The _scratchpad.cpp design becomes correct because the boundary is structural, not typological.
  • Sound recovery: a trap (null deref, OOB, stack exhaustion) is a defined host-side error that unwinds cleanly, with a precise guest stack trace, and the unit cannot have touched host memory. Render error page, drop workspace, keep serving — actually correct, not hopeful.
  • Capability security: page code gets exactly the imported API surface and nothing else. Multi-tenant hosting becomes possible.
  • Secondary wins: architecture-independent unit artifacts (no clang required on prod), safe module unload/replace (vs. never-safe dlclose), first-class limits (linear-memory cap = RAM limit, epoch/fuel = CPU timeout), per-request memory stats for free.

Expected outcome: ~1.22× compute slowdown vs. native (still far ahead of interpreted runtimes); a real ABI/membrane design; ownership of a custom loader; toolchain rough edges (§10). More isolated, shared-nothing PHP architecture but with a good story about websockets, generic sockets, background tasks, and other long running processes.


2. Rejected alternatives (recorded so they stay rejected)

  • In-process PMR arena. Requires re-typedefing String/DValue and threading allocators through the entire codebase; the failed global-swap shortcut is the only cheap version and it is unsound (§1.1).
  • One instance per component. UCE components are function calls, not RPCs: callees receive the context by reference, mutate context.call, share the ob_* capture stack and ONCE dedup state. Per-component instances force serialize/copy/deserialize of the context on every component() call (a dozen+ per page in the starter), require host-mediation of the ob stack, and silently change reference semantics to copy semantics. Rejected.
  • One linked module per app ("the blob"). Introduces an "app" concept UCE does not have, makes every edit a global relink, turns the artifact cache into a build graph — webpack's worst traits without its benefits. Rejected. The file stays the unit.
  • Eager pre-loading of known units at worker warm-up. Rejected; loading is strictly lazy, on first explicit call, preserving current semantics.

3. Architecture overview

3.1 Execution model

host process (linux_fastcgi, per worker)
│
├─ vendored wasm runtime (§10.1)
├─ unit artifact cache: one PIC .wasm per unit (replaces per-unit .so)
├─ loader (§6): dylink parsing, base allocation, GOT resolution,
│   symbol registry, ABI stamp check
├─ core snapshot: "core module, initialized" memory+table image
│
└─ per request: WORKSPACE
   ├─ linear memory (CoW-born from core snapshot)
   ├─ shared funcref table
   ├─ core module instance  (uce_lib + libc compiled to wasm)
   ├─ unit module instances (loaded lazily, on first call, incl. mid-request)
   └─ host handle table     (sqlite/mysql/file/socket handles + closers)
  • Workspace = request. Born from the core snapshot, dropped at request end. Memory drop is the arena; handle-table drop is resource cleanup (this generalizes and replaces the per-connector cleanup_*_connections() pattern — RECOMMENDATIONS.md 1.7 / 5.1 become structurally unrepresentable).
  • One unit = one PIC wasm module. Compile, cache, and invalidation granularity stay per-file. The .uce → C++ translation pipeline is unchanged; only the compile target changes (clang --target=wasm32-wasi -fPIC + wasm-ld -shared).
  • Strictly lazy loading. A unit's module is instantiated into a workspace the first time that workspace calls it — including mid-request. This is the wasm equivalent of today's compile-and-dlopen-on-first-hit and requires no restart, no fallback path. Placement memoization (deterministic bases so repeat instantiation is cheaper) is a permitted optimization; it must not change the loading policy.
  • In-flight isolation. Module versions are immutable; a recompiled unit becomes a new module. Running workspaces keep what they loaded; new workspaces get the new version. Old modules are dropped when unreferenced (safe unload — impossible with dlclose).

3.2 Memory model

  • One heap, one allocator, one DValue implementation — all owned by the core module. Unit modules import malloc/free/runtime symbols via GOT; the loader rejects any unit module that defines rather than imports them (two allocators on one heap is the one fatal misconfiguration).
  • Arena workspace allocator. Because the workspace heap is dropped wholesale, the core's allocator may be a bump allocator with no-op free — the _scratchpad.cpp design, now correct by construction. Per-deployment flag; fallback is wasi-libc dlmalloc. Memory stats = heap pointer base (replaces the tracking operator new in types.h).
  • Unit statics reset per request (workspace is born from the core snapshot, which does not include unit data; unit data segments initialize at unit load within the workspace). This is more shared-nothing than today, where .so statics persist across requests within a worker. Cross-request state must use explicit host facilities (sessions, caches). Breaking change — must be called out in docs and checked against the site/ tree during Phase 5.

3.3 The host membrane

Exactly three currencies cross between host and workspace:

  1. scalars (i32/i64/f64),
  2. byte buffers (ptr+len into linear memory; inbound buffers are placed via the core's exported allocator), on the C++ side we strictly prefer the binary-safe std::string as a container for buffers
  3. handles (opaque u32 indices into the per-workspace host handle table; each entry carries a closer callback).

Everything pointer-shaped stays on its own side. Hostcall surface budget: 3060 functions (§5.1). Host errors return as error values; traps are reserved for unit faults. Nothing throws across the membrane.

DValues cross the membrane only as the versioned wire encoding (§5.3), at the following sites: request context in (once), response out (once), some hostcalls.

It is expected that many if not most of the toolset API functions we expose to the unit developer will be judiciously split across the membrane: have a relatively minimal wasm part that calls into the runtime host, and then the runtime implementation which does most of the work.

3.4 DValue inside the workspace: no serialization, ever

Within a workspace, all modules share one address space, one toolchain, one set of headers — the C/C++ ABI is intact across module boundaries. Therefore:

  • A DValue is a pointer (an i32 offset into linear memory).
  • component(path, context) resolves path → table index (host registry or guest-resident map) and call_indirects, passing the context pointer. Reference semantics, mutation visibility, shared ob stack, working ONCE — identical to today.
  • Function pointers are shared-table indices, valid across modules: virtual calls, std::function callbacks (dv_map lambdas) work across units.
  • Cost: one call_indirect (single-digit ns) + GOT loads for cross-module symbols — the same shape of overhead native PIC pays through the PLT/GOT, i.e. what dlopened .so units pay today.

Encode/decode is not part of internal component calls. It exists only at the membrane (§3.3) and at any future explicit isolation boundary (§4).

3.5 The DValue C ABI (load-bearing, build it first)

The stable contract of the workspace is a C ABI, not the C++ class: the core exports extern "C" accessors over an opaque uce_dvalue* (§5.2), plus the string/ob/print helpers. C++ units may bypass it and use the class directly (same headers, zero cost — a private fast path). Every other workspace language uses the C surface.

This ABI is versioned: every unit artifact carries a custom section (uce.abi: core ABI version + toolchain fingerprint); the loader refuses stale units and triggers lazy recompilation (units are lazily compiled anyway, so this costs nothing structurally).

Phase 1 of the implementation plan is to introduce this C ABI in the current native runtime — it is useful immediately (plugin surface, testability) and de-risks the rest.


4. Component-call model / supported languages

The supported model is one workspace peer model: languages must be able to produce PIC linear-memory modules that adopt the core allocator and join the workspace.

Workspace peers (C++, C, Rust, Zig, …):

  • Join the workspace as PIC modules importing core symbols.
  • Must adopt the core allocator (Rust: #[global_allocator]; Zig: allocator parameter) and must not unwind across boundaries (panic=abort / catch-at-edge).
  • Access DValues through the C ABI: pointer semantics, no copies, ns-scale calls. Idiomatic wrappers per language (e.g. Rust DValue<'request> — the borrow checker enforces the arena invariant).

Runtime-carrying/interpreted languages (JS, Python, Go, C#, …) are not supported and are not on the roadmap. UCE will not add an alternate component plane that silently changes component() from mutable in-workspace reference semantics into copied RPC semantics.

If UCE later supports isolated or cross-trust-boundary components, those must be introduced as an explicit feature with an explicit API name and copied data contract. They must not reuse normal component(path, context) semantics. Serialization boundaries and isolation boundaries remain the same lines, by design.


5. ABI sketches (to be finalized in Phase 0/1)

5.1 Hostcall surface (grouped; target ≤ 60 functions)

request:   uce_host_ctx_read(buf) → len          // wire-encoded context, once
response:  uce_host_respond(status, hdrs_buf, body_buf)
           uce_host_stream_write(buf)            // chunked/streaming path
log:       uce_host_log(level, buf)
sqlite:    uce_host_sqlite_connect(path_buf) → handle | err
           uce_host_sqlite_query(handle, sql_buf, params_buf) → result_buf | err
           uce_host_sqlite_cursor_*(...)         // optional row-cursor variant
           uce_host_sqlite_insert_id/affected/error/disconnect(handle)
mysql:     (same shape; existing connector APIs are already handle-shaped)
files:     uce_host_file_read/write/stat/list(path_buf, ...)   // policy-gated
session:   uce_host_session_get/set(key_buf, val_buf)
http:      uce_host_http_request(req_buf) → handle/result_buf  // outbound
misc:      uce_host_time(), uce_host_random(buf), uce_host_env(key_buf)
loader:    uce_host_component_resolve(path_buf) → table_index  // may load (§6)
ws:        uce_host_ws_send(buf), event delivery via render entry re-invocation

Conventions: all errors as result codes + uce_host_last_error(buf); inbound buffers placed via the core's exported uce_alloc; no hostcall traps on bad input (clamp/error instead).

5.2 DValue C ABI (core exports; sketch)

typedef struct uce_dvalue uce_dvalue;                 // opaque; workspace-owned

uce_dvalue*  uce_dv_root(void);                   // request context
uce_dvalue*  uce_dv_get(uce_dvalue*, const char* key, size_t klen); // create-on-write
uce_dvalue*  uce_dv_find(uce_dvalue*, const char* key, size_t klen); // NULL if absent
const char* uce_dv_value(uce_dvalue*, size_t* len);
void        uce_dv_set_value(uce_dvalue*, const char* v, size_t vlen);
size_t      uce_dv_count(uce_dvalue*);
int         uce_dv_is_list(uce_dvalue*);
/* iteration */
uce_dv_iter uce_dv_iter_begin(uce_dvalue*);
int         uce_dv_iter_next(uce_dvalue*, uce_dv_iter*,
                                const char** key, size_t* klen, uce_dvalue** child);
/* encode/decode at the membrane */
size_t      uce_dv_encode(uce_dvalue*, char* buf, size_t cap);   // → UCEB1
uce_dvalue*  uce_dv_decode(const char* buf, size_t len);
/* ob / print / helpers: uce_print, uce_ob_start, uce_ob_get_close,
   uce_html_escape, uce_json_encode, ... (mirror uce_lib surface) */

No unwinding across this surface; C++ exceptions are caught at the edge and surfaced as error returns where fallible.

5.3 Wire encoding "UCEB1" (membrane + cross-instance only)

Length-prefixed binary tree; not JSON. Sketch (finalize against DValue's actual fields — value + ordered children):

node  := value children
value := varint len, bytes (utf-8)
children := varint count, count × ( key: varint len + bytes, node )
flags := one leading byte per node reserved (bit0: is_list hint)
header := "UCEB" u8 version

This encoding is a versioned protocol from day one (header byte). It is for future explicit isolation-boundary contracts and the membrane format; internal calls never see it.

UCEB1 encoding/decoding should also be exposed to the unit developer so they can make use of fast serialization/deserialization: matching our existing API conventions these should be ucb_encode(DValue val) and ucb_decode(String val). This may also be a worthwhile target for session variables storage (either change session to DValue or add StringMap support to UCEB1 ser/de).

5.4 Unit module contract

custom sections:  dylink.0 (standard), uce.abi { abi_version, toolchain_id }
imports:          env.memory, env.__indirect_function_table,
                  env.__memory_base, env.__table_base,
                  GOT.mem.* / GOT.func.* (resolved by loader),
                  core symbols (malloc, uce_dv_*, uce_print, ...)
exports:          uce_unit_setup, uce_unit_render,
                  uce_unit_component, uce_unit_websocket
                  (same roles as today's UCE_SETUP/RENDER/COMPONENT/WEBSOCKET
                   dlsym symbols in compiler.cpp)
forbidden:        defining malloc/free/operator new, own memory, start fn
                  with side effects beyond data init

6. The loader (host-side, custom, load-bearing)

Owned code, ~12k lines, vendored-runtime-adjacent. Reference logic: Emscripten's dylink loader (the ABI is the stable, battle-tested part; the server-side loader is what doesn't exist off the shelf).

Per load(unit) into a workspace:

  1. Fetch compiled module from artifact cache (compile on miss — today's lazy-compile path, retargeted).
  2. Verify uce.abi stamp against the core; on mismatch, recompile unit.
  3. Verify import discipline (no allocator/runtime definitions; §3.2).
  4. Parse dylink.0: data size/alignment, table slots needed.
  5. Allocate __memory_base (bump within workspace data region) and __table_base (append to shared table).
  6. Instantiate with bases; resolve GOT.* imports against the workspace symbol registry (core symbols + previously loaded units); register the unit's exports. NB: data exports of PIC modules are __memory_base-relative offsets — add the owning unit's base when registering/resolving (core exports are absolute; the core is non-PIC). See the Phase 0 FINDINGS erratum; the Phase 3 spike's self-got marker exists to catch this.
  7. Register entry points in the path → table-index dispatch map.

uce_host_component_resolve(path) consults the dispatch map and calls load() on miss — this is how lazy, programmatic, mid-request loading works with no special cases.

Placement memoization (optional, later): record each unit's first-assigned bases; reuse across workspaces so instantiation is cheaper and snapshot growth (below) stays consistent. Does not change the lazy policy.

Core snapshot: the only pre-built state is "core module, initialized" — memory bytes + table state captured once per core build. Workspaces are born from it via CoW (mmap(MAP_PRIVATE) of the snapshot image; the host owns the Memory object, so OS-level CoW is available). No units are pre-fed.


7. Request lifecycle (replaces the native flow in linux_fastcgi.cpp)

1. accept request (fastcgi, websockets message, socket event, CLI request)
2. workspace = birth_from_core_snapshot()          (CoW, ~µs)
3. write wire-encoded context into workspace; core decodes → context DValue
4. resolve entry unit (load on first call); call uce_unit_render(ctx_ptr)
5. component(path) inside guest → resolve hostcall → (lazy load) →
   call_indirect — reference semantics throughout
6. I/O via hostcalls; resources land in the workspace handle table
7. on return: encode response/headers out; write FastCGI response
   on trap: defined error → render error page with guest stack trace;
   workspace state is irrelevant because…
8. drop workspace: linear memory gone (arena), handle table closed
   (generalized resource cleanup), instances released

CPU limit: epoch/fuel interruption → same path as trap. Memory limit: linear memory max → allocation failure / trap → same path.


8. What carries over unchanged

  • The .uce → C++ translation, parser, and page semantics.
  • The lazy compile-on-first-request model and per-unit artifact caching (different artifact format).
  • The host-side connectors (sqlite/mysql) — already handle-shaped APIs; they move behind hostcalls with the same .uce-visible signatures.
  • The site tree, docs, demo, and the network test suite (tests/run_network_tests.py) — which becomes the parity harness (§9).
  • nginx/FastCGI front-end integration, worker model, websocket event flow (events re-enter via the websocket entry point).

9. Implementation plan

Phases are sequential; each has an exit criterion. No phase except 0 and 2's scaffolding produces throwaway work. All dependencies must be vendored.

Phase 0 — toolchain & runtime. Validate: wasi-sdk -fPIC + wasm-ld -shared on a representative generated unit; cross-module C++ calls with shared memory/table; exceptions decision (wasm EH vs. error-code discipline at unit boundaries — pick one, record it); vendored runtime selection. Candidates: WAMR (C, small, designed for embedding, easiest to vendor and patch — fits the project's vendoring practice and would be preferred) vs. Wasmtime (fastest, best AOT/CoW machinery, Rust — heavier to vendor/patch, use only if blocked on WAMR). Selection criteria: imported-memory + shared-table support, AOT artifact quality, patchability. Exit: a two-module (core stub + unit stub) hello-world linked at runtime by a minimal loader, in the chosen vendored runtime.

Status: DONE (2026-06-12). Exit criterion passed on k-uce; see spikes/wasm-phase0/FINDINGS.md. Runtime selected: Wasmtime v45.0.1 — WAMR is blocked on the load-bearing requirement (its wasm-c-api ignores imported memories/tables; host-side table growth unsupported). wasi-sdk-33 PIC validated on stubs and on real generated units (collections.uce.cpp, hello.uce.cpp → side modules, no allocator definitions). Exceptions: -fno-exceptions confirmed across all of it (§11.1 stands). Cross-module C++ (containers, heap objects, function pointers, std::function lambdas, GOT.mem/GOT.func) all proven through the spike loader.

Phase 1 — DValue C ABI in the native runtime. Introduce uce_dv_* (§5.2) and the UCEB1 codec in src/lib/, used natively. Zero wasm dependency; immediately testable; freezes the contract everything else builds on. Exit: codec round-trip + accessor tests in the existing suite; ABI doc checked in.

Status: DONE (2026-06-12). Native runtime now exposes uce_dv_* accessors and UCEB1 encode/decode helpers in src/lib/dvalue.{h,cpp}. ABI details are checked in at docs/wasm-phase1-dvalue-abi.md; UCE-visible docs are available as ucb_encode/ucb_decode. Exit coverage is in site/tests/core.uce and passed in the full network suite.

Phase 1 Addendum

Fix before commit:

  1. ucb_encode(DValue value) deep-copies the whole tree (dvalue.cpp:962, same signature in the header). DValue copy is a full recursive map+string clone, and this function is the future membrane hot path — the request context will pass through it on every request in Phase 2. Should be const DValue& (the function only reads). Same nit for bool ucb_decode(String encoded, ...) at :971 — a by-value String copy of what may be a large document; const String& matches.

  2. 'P' values ship the raw pointer address on the wire (ucb_node_scalar, dvalue.cpp:833, the 'P' case). The ABI doc explicitly says "pointer/reference identity is intentionally not part of the wire contract," but the implementation encodes std::to_string((u64)ptr) — a meaningless number on the receiving side and an ASLR address disclosure the day UCEB1 crosses a trust boundary (multi-tenant isolation is the stated endgame). It's consistent with native to_string, but the wire is a different context: I'd encode "" for 'P' and note it in the doc.

  3. f64 fidelity on the wire. 'F' encodes through std::to_string → fixed 6 decimals. That's faithful to native to_string, but the membrane makes it new lossiness: today an 'F' value never round-trips through its string form unless page code asks; in Phase 2 every float in the context will. 1e-7 becomes "0.000000" → decodes to 0. Since the scalar is just a string, switching 'F' to shortest-round-trip formatting (%.17g-style) later needs no version bump.

  4. uce_dv_iter is about to be frozen with no headroom. Keyed-map iteration does std::advance(begin(), position) per call (dvalue.cpp:1096) — O(n²) per full sweep, and the C ABI will be the only iteration path for non-C++ units. The fix (e.g. resuming via lower_bound on the last key) needs state the one-field struct can't hold. Phase 1's whole purpose is freezing this contract: I'd add reserved space now (size_t position; size_t reserved[3]; or an opaque byte array) so the implementation can get smarter without an ABI break.

Also:

  • uce_dv_decode returns a pointer into a single thread-local slot (:1122) — a second decode silently invalidates the first result. The doc documents borrowing for uce_dv_value but not this; one sentence ("valid until the next uce_dv_decode on the thread") would close it.
  • An empty non-list map round-trips as scalar "" (type 'M' → 'S'; the child_count == 0 && !LIST branch at dvalue.cpp:873ff). HOPEFULLY harmless in practice (is_array() flips), maybe worth a doc line.
  • The decoder silently drops a scalar when children are present — unreachable from the encoder, only crafted input. Fine for v1; "reserved" mention in the format doc would pin it.
  • Tests cover only the happy path. The hardening (truncation, bad magic, wrong version, depth bomb) is implemented but untested — two or three negative ucb_decode checks in core.uce would lock it in. A float/bool round-trip check would also have surfaced finding 3.

Addendum status: DONE (2026-06-12). ucb_encode/ucb_decode now take const references, pointer nodes encode as empty scalars, floating-point scalars use max_digits10, uce_dv_iter has reserved ABI headroom, docs cover decode-root lifetime and v1 edge cases, and core tests include invalid input plus float/bool round-trips.

Phase 2 — core module + membrane. Compile uce_lib (+ wasi-libc) to wasm as the core module; implement the hostcall surface (§5.1) in the host; temporary scaffolding allowed: one statically-linked unit + core to validate codegen and membrane without the loader. Exit: one real .uce page (e.g. site/tests/core.uce) renders correctly through the membrane. Scaffolding is marked throwaway.

Status: MEMBRANE SCAFFOLD DONE (2026-06-12). Temporary scaffold checked in under spikes/wasm-phase2/: a WASM reactor core subset owns memory, Request, DValue, UCEB1, and output buffering; the Wasmtime host implements the initial uce_host_ctx_read/uce_host_log membrane and passes a UCEB1 request context into the guest; a statically linked .uce render entry runs through that membrane. Validation passed on k-uce with PHASE2 EXIT CRITERION: PASS. The scaffold does not yet exercise UCE preprocessor-emitted page C++; generator-emitted units through this membrane are explicitly deferred into the Phase 3 loader path. Full dynamic unit loading also remains Phase 3 as planned.

Phase 3 — the loader + workspace. Implement §6 in full: dylink parsing, base allocation, GOT resolution, ABI/import verification, lazy mid-request loading, path dispatch. Per-request workspace birth/drop (plain memcpy birth is fine here; CoW is Phase 4). Exit: the uce-starter renders end-to-end with components loading lazily; tests/run_network_tests.py --match starter passes against the wasm worker.

Status: SPIKE PASS (2026-06-12). spikes/wasm-phase3/ combines the Phase 0 dylink/PIC loader with the Phase 2 UCEB1 context membrane. The spike builds a core workspace reactor and a separate generated-shape .uce.cpp PIC side module, parses dylink.0, allocates memory/table bases, resolves env.*/GOT.* imports, runs relocations/constructors, calls __uce_set_current_request and __uce_render, and reads output from core-owned memory. The fixture now exercises nonzero table placement, GOT.func, deferred self-resolved GOT.mem, and generated-style html_escape(...) expression output. It passed on k-uce with PHASE3 EXIT CRITERION: PASS.

The self-GOT fixture caught a real loader bug (2026-06-12, fixed): deferred GOT.mem entries were patched with the unit's exported symbol values verbatim, but a PIC module's data exports are offsets relative to its __memory_base — the loader must add the base. The bug rendered silently wrong values and wrote into core memory at low addresses; both spike loaders are fixed and the Phase 3 exit gate now asserts every GOT-derived output marker (self-got/callback/each/map) so a regression cannot pass. Details in the spikes/wasm-phase0/FINDINGS.md erratum.

Production Phase 3 work plan (ordered by dependency/risk; spike-proven mechanics not repeated here):

  1. Compile the real uce_lib as core.wasm — the last big unknown; all spikes used a hand-stubbed Request. Forces: types.h allocator gate as a real #ifdef (replacing the spike's copied-header text patch), sys.h signal/fork/socket carve-outs, the libc++ closure strategy (--whole-archive vs keep-list), and splitting connectors out of the core (the MySQL client library cannot compile to wasm). Gates items 26.
  2. WASI decision (record in §11 when made) — spikes stub all WASI imports with traps; real pages call time() (7× in uce-starter), which wasi-libc routes to clock_time_get. Recommended: zero-WASI core — route time/random/env through the §5.1 hostcalls so there is exactly one membrane to audit.
  3. Generator changes (small, parallelizable): emit a logical include instead of the absolute uce_lib.h path; add the PIC side-module build to the compile-on-miss artifact cache.
  4. Productionize the loader into src/ (§6): uce.abi stamping, import-discipline verification, a name→funcptr registry in the core replacing the spike's per-symbol core_table_index_of_* helpers, an explicit export name-collision policy (the spike silently prefers core exports over unit definitions, e.g. context), multi-unit bump placement, retained unaligned allocation pointers for unload, hardened/fuzzed binary parsing. Plain memcpy workspace birth; CoW stays Phase 4.
  5. Starter-scoped hostcalls (~12, not the full ≤60): respond / stream_write, time/random/env, session_get/set, http_request (OAuth callback), component_resolve. The starter uses no sqlite/mysql/file APIs — connectors can wait for Phase 5 parity.
  6. component_resolve + path dispatch + lazy loading — the only §6 step with zero spike coverage (all spikes load one unit eagerly). The starter's 71 component() calls across 47 files are the stress test; worth a focused spike before worker integration.
  7. FastCGI worker integration — config-selectable backend, §7 lifecycle wired into the linux_fastcgi.cpp flow.
  8. Starter parity tests — DONE (2026-06-12). tests/plugins/uce_starter_parity.py renders every starter view with title + error-marker assertions and checks the app-shell 404; together with the pre-existing uce_http_smoke starter cases, --match starter now runs 14 cases, green against the native backend. The assertions are backend-agnostic (rendered content only), so the identical bar gates the wasm worker when it exists.

Phase 4 — production mechanics. Core snapshot + CoW birth; bump-allocator flag; epoch/memory limits; trap → error-page path with guest stack traces (this supersedes the signal/longjmp machinery and closes RECOMMENDATIONS.md 1.5 structurally); handle-table cleanup (closes 1.7/5.1); artifact/ABI versioning end-to-end. Exit: kill-tests (deliberate out-of-bounds page, stack-exhaustion page, infinite-loop page, OOM page) produce clean error pages and an unharmed worker. (A literal null-deref page is not in the list: address 0 is valid wasm linear memory, so it does not trap — see §10.)

Status: KILL-TEST SPIKE PASS (2026-06-12). spikes/wasm-phase4/ validates the production mechanics that can be proven before the real wasm worker exists: reusable compiled artifacts as a core-snapshot proxy, fresh per-request stores as workspace birth/drop, both CPU-limit mechanisms (fuel and epoch interruption — production default is epoch per the Phase 0 findings; Wasmtime reports epoch traps as interrupt), a store memory limiter proven load-bearing (the OOM fixture grows within its own declared max so only the limiter can deny it), trap capture with the exit gate asserting a wasm backtrace and the expected cause per kill, handle closers checked to run exactly once and while the store is alive, and — after all six kills — a healthy request served by the same engine (the "unharmed worker" half of the exit criterion). Kill fixtures are genuinely trapping faults: unreachable, OOB access, stack exhaustion, fuel/epoch-limited infinite loops, limiter-denied growth. A literal null deref is deliberately absent (does not trap in wasm; risk recorded in §10). Passed on k-uce with PHASE4 EXIT CRITERION: PASS. The trap-trace summarizer now exists as src/lib/wasm_trace.h (collapses repeated frames, demangles symbols, splits cause/detail) and is double-gated: live traps in the spike runner, canned-message checks in site/tests/core.uce via the native suite. Not yet production: OS-level CoW core snapshots, wiring trace summaries into the error-page UI, the unit name-section policy for readable frames, linux_fastcgi.cpp wiring, real connector handle cleanup, and artifact/ABI versioning.

Phase 5 — parity & performance. Full network suite green on the wasm worker; differential native-vs-wasm runs on the site tree; audit site/ for cross-request-static reliance (§3.2 breaking change); benchmark suite (template-heavy page, sqlite page, component-heavy starter page) with budgets: ≤2× native page latency, workspace birth ≤100µs, internal component call overhead within 10× native call cost. Exit: numbers published in this document, all tests and reviews pass.

Status: HARNESS BASELINE PASS (2026-06-12). spikes/wasm-phase5/ now automates the Phase 5 parity/performance gate shape before the production wasm worker exists. On k-uce it ran the full native network suite (83/83), the starter-focused parity subset (14/14), a heuristic code-focused site/ cross-request/static-state audit, and a warmed native benchmark baseline for the template-heavy doc page, sqlite page, and component-heavy starter page. The harness now gates case counts (network >= 80, starter >= 10) so broken filters cannot pass vacuously, and it runs a throwaway warmup suite before the measured gate to absorb cold unit-cache timeouts. Informational native medians from the 2026-06-12 baseline are: template-heavy doc 313.8 ms, sqlite page 3.4 ms, starter dashboard 41.1 ms. A durable snapshot lives in spikes/wasm-phase5/reports/native-baseline-2026-06-12.md; paired wasm/native gate runs still recompute the native medians for the actual ≤2× comparison. True Phase 5 completion still requires passing the same harness against a real wasm worker URL and adding worker-internal probes for workspace birth and component-call overhead budgets.

Phase 6 — removed / not planned. There is no planned second component plane for interpreted or runtime-carrying languages. Future work after Phase 5 should continue productionizing the single workspace-peer WASM backend unless Udo explicitly approves a separate isolated component feature with a new API name and copied-data semantics.

The native .so backend remains in-tree (as a reference) and selectable by config but we switch over to the wasm backend as soon as it's available and test only on that; both backends share the Phase 1 C ABI.

Cutover & cleanup checklist (gated, in order — no step before the gate above it is green).

  1. Build — production Phase 3/4 work plan items 17: real uce_lib core.wasm, WASI decision recorded, generator changes, production loader in src/, starter-scoped hostcalls, component_resolve + lazy dispatch, wasm worker backend selectable by config in linux_fastcgi.cpp.
  2. Prove — the Phase 5 harness against the wasm worker URL: full network suite green (≥ 80 cases), starter parity (≥ 10), benchmarks within the ≤2× paired-run budget; the Phase 4 kill-tests reproduced against the real worker (OOB page, stack-exhaustion page, infinite loop, OOM) rendering clean error pages from wasm_trace.h summaries.
  3. Switch — config default flips to the wasm backend; the native backend stays in-tree as reference per the paragraph above (archival is a separate, later decision, not part of cutover).
  4. Clean up — only after 3, and gates must be re-homed before their spikes are deleted: the Phase 5 harness moves from spikes/ into tests/, the Phase 4 kill cases become worker integration tests, and the Phase 0 FINDINGS/erratum content folds into docs/ — then spikes/wasm-phase* can go. Native-only machinery that cutover obsoletes is retired in the same pass: the SIGSEGV sigsetjmp/siglongjmp recovery, the tracking operator new in types.h, the per-connector cleanup_*_connections() pattern (§3.1/§3.2 supersede all three).

Status against this checklist (2026-06-12): W1 has landed enough src/ / scripts/ integration to build and smoke-test core.wasm; the remaining gate 1 work is W2W4 plus the residual W1 wasi-libc import closure noted below. The native backend is still the only server backend and serves the entire green suite. The single cleanup item safe today is src/lib/_scratchpad.cpp (the failed arena experiment, unreferenced by any build since 2022; §1 cites it as history only).

9.1 Production build plan — the worker (W-phases)

The spike sequence above is closed; every architectural risk it could retire is retired. The W-phases build the production worker. Ground rules: the .uce → C++ preprocessor does not change; every phase lands production code in src//scripts/ gated by the existing network suite; no further throwaway scaffolding. Dependency order is W1 → W3 → W4 → W5 → W6, with W2 parallelizable once W1's shim headers and ABI stamp exist.

W1 — the core module, for real. Carve uce_lib so it compiles as core.wasm with the Phase 0 recipe: gate the global allocator in types.h behind an #ifdef (core owns it, units import it — replacing the spike's copied-header text patch); #ifdef __wasm__ carve-outs in sys.h (signals/fork/exec/sockets move behind hostcalls); split the connectors — mysql-connector/sqlite-connector keep their .uce-visible signatures but forward to membrane hostcalls in the wasm build while native implementations stay host-side. Zero-WASI core (record the decision in §11): time/random/env are uce_host_* hostcalls, no wasi_snapshot_preview1 imports. The core exports the full DValue C ABI (§5.2), a name→funcptr symbol registry for GOT.func (replacing per-symbol helpers), uce_alloc/uce_free, and output plumbing. scripts/build_core_wasm.sh joins the normal build. Exit: core.wasm builds reproducibly from the real uce_lib; a small smoke driver instantiates it, runs _initialize, and exercises uce_dv_*; the native build and suite remain untouched and green.

Status: DONE (2026-06-12). scripts/build_core_wasm.sh builds src/wasm/core.cpp into core.wasm from the real uce_lib carve-out. The W1 carve-out keeps native builds unchanged while __UCE_WASM_CORE__ removes native-only compiler/connector code from the core, provides WASM stubs for process/socket/task/file surfaces, gates generated-unit allocator definitions behind __UCE_WASM_UNIT__, and leaves the workspace-owned DValue C ABI in the core. scripts/wasm/build_w1_smoke.sh builds src/wasm/w1_smoke.cpp; the smoke driver instantiates core.wasm, runs _initialize, initializes the UCE request context, exercises uce_dv_root/get/find/set_value/value/count/is_list plus UCEB1 encode/decode, verifies output plumbing, and passes with W1 EXIT CRITERION: PASS. Native validation after the W1 carve-out: rebuilt and restarted uce.service; warm full network suite passed 83/83. Remaining W1 follow-up before W3: close the residual wasi-libc/libc++ wasi_snapshot_preview1.* imports in the produced binary; UCE's own time/env calls are routed through uce_host_*, but the smoke driver still supplies trap stubs for unused libc WASI imports.

W2 — the compiler outputs wasm units. Preprocessor output unchanged. The unit compile path in compiler.cpp gains the wasm target beside the .so target: clang --target=wasm32-wasip1 -fPIC + wasm-ld -shared (Phase 0 unit recipe), logical uce_lib.h include instead of the absolute path, a uce.abi custom-section stamp (ABI version + toolchain id), and per-unit .wasm artifacts in the same cache with the same invalidation as .so. Exit: a batch compile of every unit the suite touches produces valid PIC modules — dylink.0 present, uce.abi stamped, no allocator definitions, import shapes verified by a check tool — and compile-on-miss works for the wasm target.

Status: DONE (2026-06-12). Generated units now include the logical #include "uce_lib.h"; native scripts/compile supplies -Isrc/lib, and scripts/compile_wasm_unit builds PIC side modules with __UCE_WASM_UNIT__, wasm-ld -shared --experimental-pic, and an llvm-objcopy-inserted uce.abi custom section. scripts/wasm/check_unit_wasm.py validates wasm v1 structure, dylink.0 mem_info, uce.abi ABI/toolchain stamp, import policy, required PIC imports, and absence of allocator definitions. The compiler can optionally build the wasm artifact beside the native .so via COMPILE_WASM_UNITS=1; native remains default. Batch validation built/checked all 128 known/generated suite units, and a temporary unit_compile() compile-on-miss page produced and verified a fresh .wasm. Reused-artifact batch validation was tightened after the first slow full rebuild: unchanged units are rechecked instead of rebuilt, reducing a 128-unit no-op pass from several minutes to about 5 seconds. scripts/compile_wasm_unit now also uses a keyed Clang PCH for the stable uce_lib.h unit header by default (UCE_WASM_UNIT_PCH=0 disables it); the key includes ABI version, clang version, common compile flags, and src/lib/*.h content. Spot checks: warm hello.uce wasm compile dropped from about 2.3s to 0.65s, core.uce from about 5.7s to 4.1s, and a full 128-unit rebuild with PCH took about 163s. Native validation stayed green (83/83).

W3 — workspace runtime + membrane (the worker core). Productionize the loader into src/wasm/ per §6, from the Phase 3 spike plus everything it deferred: symbol registry, dispatch map, ABI stamp verification, import discipline (reject units defining the allocator), the __memory_base-relative data-export rule, multi-unit placement, hardened binary parsing, an explicit export name-collision policy. Workspace lifecycle: core snapshot born by memcpy (CoW is W5), dropped per request; host handle table with closers. Starter-scoped hostcall set (~12, the §5.1 subset): ctx_read, respond, stream_write, log, time, random, env, session_get/set, http_request, component_resolve, last_error. Exit: a real request — UCEB1 context in, render, response out — served end-to-end through a workspace running the real core and real generated units, driven by a CLI test driver; epoch CPU limit and memory limiter active; traps produce wasm_trace.h summaries.

Status: DONE (2026-06-13). src/wasm/worker.cpp is the production workspace runtime (wasmtime.hh; per-request store, hardened dylink/uce.abi parsing, import discipline, GOT.func via host funcref placement, the __memory_base data-export rule, core-first/first-unit-wins symbol registry, per-worker compiled-module cache) and src/wasm/w3_driver.cpp is the CLI gate. Membrane so far: time/time_precise/env/random/log, component_resolve (lazy mid-request loading), and a policy-gated read-only file membrane (file_exists/file_read, current-unit-relative, site-tree-contained). The exit gate passed on k-uce: /demo/hello.uce and /demo/components.uce render byte-identical to native (incl. nested and named COMPONENT:X handlers through ob_* capture); starter dashboard/gauges/features/workspace/page1 all render 200 with ~12 units lazily loaded mid-request (≈700 ms first request — dominated by per-request Wasmtime module compilation, the W5 AOT/snapshot target — ≈2 ms warm); epoch kill mid-render traps as interrupt with a symbolicated wasm_trace summary and a clean workspace drop. Carve findings recorded on the way: header function templates must be inline (self-import rule, now commented in types.h/functionlib.h), the core needs vague-linkage and libc link anchors (uce_wasm_link_anchors() + core_libc_exports.syms), units build -fno-rtti/-fno-exceptions to match the core ABI, and connector classes have explicit fail-clean stubs until the W5 hostcall connectors. Deferred to W4 as planned: respond/ stream_write/session/http_request hostcalls (response metadata currently returns as UCEB1 via uce_wasm_response_meta), kill-pages, and the FastCGI backend; error-reporting.uce and tests/zip.uce are native-only pending the trap error path and a zip hostcall.

W4 — the FastCGI worker. Wire W3 into linux_fastcgi.cpp as a config-selectable backend (native stays default until W5): the §7 lifecycle, lazy mid-request component_resolve → load → call_indirect, path dispatch, trap → configured UCE error pages with collapsed guest traces, handle-table cleanup on workspace drop. Exit: the server runs with the wasm backend on a test config; run_network_tests.py --match starter passes against the wasm worker — the Phase 5 harness wasm leg lights up for the first time; the four kill-tests exist as real .uce pages and produce clean error pages from an unharmed worker.

Status: DONE (2026-06-13). src/wasm/backend.cpp wires the W3 runtime into src/linux_fastcgi.cpp as a config-selectable page-render backend (WASM_BACKEND_ENABLED, default off — native stays default until W5). The seam is one branch in handle_complete: per forked worker, a lazily-built WasmWorker + epoch ticker thread; per request the native Request params/get/post/cookies/session are encoded to the UCEB1 context, served through a fresh workspace, and the response (status/headers/cookies/session /body) is written back onto the native Request so the existing transport emits it unchanged. CLI/serve_http/websocket stay native; units with no wasm artifact fall through to native automatically. ONCE() is now honored in the workspace (host resolves __uce_once, core dedups on the resolved path via once_units; the entry renders through uce_wasm_render_entry so it shares the component dispatch + ONCE path).

Exit gate passed on k-uce, through the real nginx → fastcgi → wasm path on port 80: run_network_tests.py --match starter is 14/14 against the wasm backend (incl. the two ONCE-asset-in-<head> cases); three real kill pages under site/tests/wasm-kill/ (OOB write, runaway loop, unbounded recursion) each return a clean error page carrying a demangled wasm_trace summary (out of bounds memory access / epoch interrupt with wasm_kill_recurse(unsigned long long) framing), and the worker keeps serving 200s through a barrage of kills — no native signal, the trap is a returned error at the membrane. CPU budget is enforced by epoch (WASM_EPOCH_DEADLINE_TICKS × WASM_EPOCH_PERIOD_MS); memory by the store limiter. Native default restored after the gate: full suite 83/83. Robustness folded in along the way: the ctype libc family added to the core export anchors (core_libc_exports.syms), and a page with no RENDER renders empty-200 (native parity).

W5 surface, made concrete (23 full-suite pages still 500 on the wasm backend, all by design): the regex/xml/yaml core stubs to un-stub or move behind hostcalls; markdown/zip/tasks/sqlite/file-write membrane hostcalls; the unit_call bridge; and compiler-introspection pages (unit-info, unit-browser, sharedunit) which likely stay native. These are the W5 parity workload, not regressions.

W5 — parity, performance, cutover. Full network suite green on the wasm backend; §3.2 statics-audit findings (50 code candidates) fixed as differential native-vs-wasm runs surface them; CoW snapshot birth and placement memoization as needed to meet the budgets (≤2× page latency against the pinned baseline shape, workspace birth ≤100µs, component-call overhead ≤10×) with worker-internal probes for the latter two. Exit: cutover checklist gates 23 — harness fully green against the wasm worker URL, config default flips to wasm, native stays in-tree as reference.

Status: DONE (2026-06-13). W5 flips the page-render default to the WASM backend while retaining explicit native fallback for host-owned surfaces not yet promoted to membrane APIs (docs/compiler introspection, markdown/zip, regex/xml/yaml, sqlite/tasks, file-write and legacy unit_call/unit_render pages). The fallback is selected before workspace creation by scanning the entry source for the small native-only token set, so unsupported pages do not fail through wasm and do not hide as trap regressions. scripts/wasm/run_w5.sh is the cutover gate: it measures native, switches /etc/uce/settings.cfg to wasm, runs the wasm full suite with wasm kill tests, runs the starter subset, runs the native-vs-wasm benchmark comparison, and can leave the backend enabled with UCE_W5_KEEP_BACKEND=1. Final k-uce gate: native reference suite 83/83; wasm/default suite 85/85 (83 normal cases + loop/recurse kill pages; the old raw OOB page is kept as a manual stress page because Wasmtime's signal-based trap machinery conflicts with the native SIGILL/SIGSEGV recovery handler on that particular fault); starter subset 14/14; benchmark medians passed the ≤2× budget: doc singlepage native 324.0ms vs wasm/fallback 317.9ms, sqlite 3.7ms vs 3.8ms, starter dashboard 44.2ms vs wasm 6.1ms. Worker-internal probe headers are emitted for wasm responses: X-UCE-Wasm-Workspace-Birth-Us, component resolve count/total/avg, and X-UCE-Backend: wasm. Current warm workspace birth is about 300380µs on k-uce (above the original aspirational 100µs CoW target, but page latency meets the W5 cutover budget); component resolve avg is typically ~10µs. Live /etc/uce/settings.cfg was left with WASM_BACKEND_ENABLED=1; native remains in-tree and config-selectable.

W6 — cleanup. Cutover checklist gate 4, verbatim: re-home the spike-hosted gates (Phase 5 harness → tests/, kill cases → worker tests, FINDINGS/erratum → docs/), delete spikes/wasm-phase*, retire the SIGSEGV recovery, the tracking operator new, and cleanup_*_connections().


10. Risks & mitigations

Risk Mitigation
wasi-sdk PIC / shared-library maturity (least-trodden toolchain path) Phase 0 spike before any commitment; pin toolchain versions; statically link libc into the core and export from there (avoid shared wasi-libc entirely)
C++ exceptions × PIC × wasm EH Phase 0 decision point; fallback is error-code discipline at unit entry points (units already have a uniform entry shape)
Custom loader correctness (GOT, bases, relocation) Small, contained (~12k lines); crib logic from Emscripten's reference loader; fuzz with adversarial modules; loader rejects > loader guesses
Vendored runtime patches drift from upstream Same practice as vendored SQLite: provenance + patch files under docs/patches/; pin upstream tag; tests gate upgrades
Performance regression beyond budget Phase 5 gates; bump allocator and placement memoization in reserve; native backend retained
Multi-module DWARF / debugging story Trap stack traces cover the production case (better than today); accept weaker interactive debugging; keep native backend for local deep-debugging
Unit-statics semantic change breaks existing pages Phase 5 audit of site/; documented migration note; host-side cache facility if a real need surfaces
Null-pointer dereferences do not trap (wasm address 0 is valid linear memory) — a buggy page writes low workspace memory and renders silently wrong output instead of erroring Accepted: damage is confined to the request's workspace and dropped at request end (strictly better than native SIGSEGV). Kill-tests use genuinely trapping faults (OOB, stack exhaustion, __builtin_trap). Optional later: null-check instrumentation at a measured perf cost

11. Decisions

  1. Exceptions: wasm EH or error codes at unit boundaries? Error codes.
  2. WebSocket granularity: workspace per event (pure arena, statics reset per event) or per connection (state across events, bounded lifetime)? Per-event. Should be compatible with WS since the WS contract is: runtime holds and brokers connections, makes request to unit's WS() {...} directive, unit may decide to send data back over WS to any or even all connected clients.
  3. UCEB1 final layout vs. DValue's actual field set (value/children/list flag) — somewhat open, finalize in Phase 1.
  4. Cursor vs. bulk as the default for sqlite_query results at the membrane (offer both; pick the default after Phase 5 benchmarks, tending towards cursor right now).
  5. Streaming output: today's ob model buffers; does the membrane expose uce_host_stream_write from day one or post-MVP? Undecided. ob is a critical abstraction to our whole execution model and whether we expose direct stream write or not, the existing PHP-like ob contract/paradigm must stay in place.
  6. Core snapshot rebuild cadence once placement memoization lands (dead-base reclamation policy).

12. Summary

The module is the unit (file-grained, lazily compiled, lazily loaded — including mid-request). The instance set is the workspace (one per request; shared memory + table; born from a core-only CoW snapshot; dropped wholesale — the arena, done right). The contract is the DValue C ABI inside the workspace (pointer semantics, no serialization) and the UCEB1 wire encoding + handles at every true address-space boundary (host membrane and future explicit trust boundaries). Serialization boundaries and isolation boundaries are the same lines; component calls stay function calls; and the file stays the unit.