uce/WASM-PROPOSAL.md
root 8587fbc5aa wasm runtime: central WS broker, unified handlers, W7d holdouts, membrane completeness
- WS: a dedicated broker process owns HTTP_PORT + every connection; it forwards
  renders to the worker pool over uce.sock (non-blocking) and applies ws_*
  command batches flushed back at workspace teardown. Removes the now-dead
  per-worker websocket executor (-509 lines).
- Dispatch: unify CLI / WebSocket / serve_http / page render through one
  serve_via_wasm(entry_unit, handler) path; handler string -> __uce_<handler>
  export symbol.
- W7d: rewrite zip.uce to the membrane return-value error contract (no C++
  try/catch), error-reporting.uce to genuine wasm traps instead of throw, and
  sharedunit.uce to unit_info(); empty the native-only token gate.
- Membrane: wire ls / mkdir / file_mtime through new uce_host_file_list /
  uce_host_file_mkdir / uce_host_file_mtime hostcalls (resolve_guest_file gains
  directory support). Fixes /doc/index.uce listing nothing; adds a regression
  assertion that the index enumerates items.
- Docs: add docs/wasm-runtime-architecture.md; record the W7e staged native-
  deletion plan in WASM-PROPOSAL.md.

Verified: scripts/run_cli_tests.sh --include-wasm-kill -> 87 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:51:51 +00:00

1081 lines
65 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# WASM-PROPOSAL: WebAssembly Unit Runtime for UCE
> **Steady-state reference:** for the runtime as it is actually built today
> (process topology, the membrane, unified dispatch, and the central WebSocket
> broker), see [`docs/wasm-runtime-architecture.md`](docs/wasm-runtime-architecture.md).
> This proposal retains the motivation and the phased migration plan.
- **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.
W5 cutover is done: WASM is the default page-render backend with native kept
as the config-selectable reference/fallback; the membrane now covers regex,
xml/yaml, markdown, filesystem read/write, sqlite, background tasks, sleep,
and time formatting. W6 spike cleanup is done (prototypes deleted, gates
re-homed to `tests/`/`docs/`); the native-machinery retirements stay gated
while native still serves the remaining fallback surfaces (zip, sockets,
memcache, mysql, `unit_call`, compiler-introspection). Open items: the
sqlite membrane is ~67× native (per-query double hostcall + UCEB1
marshalling — §11.4 cursor/bulk decision), and CoW snapshot birth for the
workspace-birth budget.
- **Scope:** replace the native unit pipeline (generated C++ → clang → `.so`
`dlopen`) 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_indirect`s, 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)
```c
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 runtime smoke suite — now the UCE CLI
runner at `site/tests/cli_runner.uce` — 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
> `docs/wasm-toolchain-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; the
starter subset of the UCE CLI smoke suite 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 `docs/wasm-toolchain-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; ported to UCE CLI 2026-06-13).**
> `site/tests/cli_runner.uce` renders every starter view with title +
> error-marker assertions and checks the app-shell 404; together with the
> starter HTTP smoke cases, the starter subset is 14 cases, green against
> the native backend. The assertions are backend-agnostic (rendered content
> only), so the identical bar gates the wasm worker.
**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
> `docs/wasm-baselines/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; the starter
subset of the runtime smoke suite 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:** the starter smoke/parity subset 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 `200`s 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 the surfaces still
> genuinely host-owned or not yet membraned: zip, sockets/custom servers,
> memcache/mysql, and `unit_call`/compiler-introspection (the last need the
> native toolchain). The fallback is selected before workspace creation by
> scanning the entry source for a small native-only token set, so unsupported
> pages neither fail through wasm nor hide as trap regressions.
>
> **Promoted onto wasm during W5** (no longer fallback): **regex** runs through
> a single UCEB1-marshalled `uce_host_regex` hostcall against the host's PCRE2;
> **markdown** is compiled into the core (pure compute); **xml/yaml** were
> already in-core; **filesystem writes** (`file_put_contents`/`file_append`/
> `file_unlink`) go through policy-gated hostcalls, and the read membrane was
> widened to the same allowlist (site tree + scratch roots: `/tmp`,
> `BIN_DIRECTORY`, `SESSION_PATH`, `TMP_UPLOAD_PATH`) so a page can read back
> what it writes (e.g. the `io.uce` /tmp round-trip); **sqlite** runs through a
> `uce_host_sqlite` hostcall against the host's real connector, with
> connections held in a per-workspace handle table that is closed on workspace
> drop — the first concrete realization of the §3.1 "handle-table drop =
> resource cleanup" model; **background tasks** now use a host-managed process
> membrane (`uce_host_task_spawn`/`pid`/`kill`) with a guest callback trampoline
> (`uce_wasm_task_run`) in the forked child, so `task()`, `task_repeat()`,
> `task_pid()`, and `task_kill()` run under the wasm backend; **sleep/usleep**
> use a host sleep call that renews the epoch deadline after blocking. The
> `/doc/*` blanket fallback is gone — doc pages render on wasm.
>
> One subtle bug fixed here: the sized-hostcall convention has the guest call
> twice (buf=0 to learn the length, then to fetch). That re-executed the op —
> harmless for idempotent regex/file-read, but for sqlite it ran every
> `INSERT`/`CREATE` twice and the second pass failed on its own side effects.
> The host now stages the encoded result on the first call (keyed on the exact
> input) and replays it on the fetch, so a side-effecting op runs once. Two wasm-stack/ABI issues surfaced and were fixed: the core stack was
> raised to 8 MB so recursive `ucb_decode_node` reaches the 1024 depth limit
> like native (the core.uce depth-bomb negative test overflowed the small
> default stack), and `wasm_trace`'s demangler is host-only (wasi-libc++ has no
> `__cxa_demangle`, so a unit must not import it). Two backend robustness bugs
> fixed: a failed wasm side-compile (e.g. try/catch units) is now non-fatal —
> the native `.so` still serves and the stale `.wasm` is removed — and
> `wasm_artifact_exists` now requires the artifact be newer than its source, so
> a source edit can't be served from a stale `.wasm` (the wasm path bypasses the
> native JIT recompile).
>
> **Signal-trap conflict resolved (this was open in the prior W5 draft):** a
> guest `unreachable`/OOB used to surface as a host signal that the native
> SIGILL/SIGSEGV handler caught and `abort()`'d the worker (502). `make_engine`
> now sets `signals_based_traps(false)`, so guest traps are explicit and stay
> pure wasm traps returned as errors. The `__builtin_trap()` kill page now
> returns a clean 500 and the worker survives a kill barrage (0 PID churn); it
> is back in the kill-test gate alongside loop/recurse.
>
> `scripts/wasm/run_w5.sh` is the cutover gate: measure native, switch
> `/etc/uce/settings.cfg` to wasm, run the wasm full suite + kill tests +
> starter subset + the native-vs-wasm benchmark, optionally leave the backend
> enabled (`UCE_W5_KEEP_BACKEND=1`). Final k-uce gate: native reference suite
> **83/83**; wasm/default full suite **83/83** warm (starter, docs, core tests,
> markdown, regex, sqlite, and tasks on wasm; zip and native-only service/
> compiler surfaces routed to native fallback); starter subset **14/14**; kill pages (trap/loop/recurse) clean with the worker
> unharmed. Benchmark medians passed the ≤2× budget: doc singlepage native
> ~324ms vs wasm ~318ms, sqlite 3.7ms vs 3.8ms, starter dashboard 44ms vs wasm
> ~6ms. Worker-internal probe headers (`X-UCE-Backend`,
> `X-UCE-Wasm-Workspace-Birth-Us`, component resolve count/total/avg) are emitted
> for wasm responses when `WASM_BACKEND_VERBOSE=1`; warm workspace birth is
> ~300380µs on k-uce (above the aspirational 100µs CoW target — that and
> placement memoization are the remaining perf items — but page latency meets the
> cutover budget). Live `/etc/uce/settings.cfg` left with `WASM_BACKEND_ENABLED=1`;
> native remains in-tree and config-selectable.
>
> **Remaining native fallback** (the only pages still routed to native): **zip**
> (its `.uce` source uses try/catch, so it can't be a `-fno-exceptions` wasm
> side module regardless of a hostcall), socket/custom-server/memcache/mysql
> service surfaces, and `unit_call` + compiler/unit-introspection
> (`unit-info`/`unit-browser`/`sharedunit` — need the native toolchain;
> candidates for host-serviced hostcalls if native is to be fully retired).
> **Still deferred, not blocking:** CoW snapshot birth + placement memoization
> for the workspace-birth budget, and the §3.2 statics-audit follow-through.
**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()`.
> **Status: SPIKE CLEANUP DONE; native-machinery retirement GATED
> (2026-06-13).** Re-homing complete: the Phase 5 benchmark + site-audit are
> now `tests/wasm_benchmark.py` / `tests/wasm_site_audit.py` (with
> `scripts/wasm/run_w5.sh` repointed), the kill-test gate lives in the UCE CLI
> runner at `site/tests/cli_runner.uce`, the Phase 0 toolchain findings + GOT
> erratum are `docs/wasm-toolchain-findings.md`, and the pinned native
> baselines are `docs/wasm-baselines/`. **All `spikes/wasm-phase*` deleted**
> (prototypes superseded by `src/wasm/`), and the dead 2022
> `src/lib/_scratchpad.cpp` arena experiment is removed. Build + full suite
> stay green (83/83). The phase-04 status blocks above keep their narrative;
> their `spikes/...` paths are now historical — the artifacts that mattered
> were re-homed, the rest was throwaway scaffolding by design.
>
> **The three native-machinery retirements remain BLOCKED, deliberately:**
> native is still the live backend for pages touching zip, sockets / custom
> HTTP servers, memcache, mysql, `unit_call`, and compiler/unit-introspection
> — everything still in `native_only_tokens`. While *any* page renders on
> native, retiring its SIGSEGV/`sigsetjmp` recovery, the tracking
> `operator new`, or `cleanup_*_connections()` would strip crash protection
> and resource cleanup from live traffic. They retire only once those surfaces
> move behind membrane hostcalls (so nothing falls back) or the native backend
> is formally decommissioned. This is the honest end-state of W6 given the
> current fallback set, not an oversight.
**W7 — non-render entrypoints and fallback hardening.**
With render traffic defaulting to wasm, the next retirement work is the entry
surface around render: CLI, WebSocket, and `SERVE_HTTP`/custom-server dispatch,
plus the cold fallback behaviors that now show up in the default gate. The first
W7 slice split the wasm backend into a proper object/header boundary and taught
`wasm_backend_serve()` to dispatch CLI/WebSocket/SERVE_HTTP entry kinds; the
socket CLI path now propagates wasm runtime errors instead of silently returning
success. Post-fork wasm reset was also tightened: the epoch ticker is now held
behind a pointer so a forked broker child can discard the inherited, unusable
thread handle without placement-new over a live `std::thread` object. The
network gate now gives only the known cold native-fallback pages (`sharedunit`
and the operational services test) longer per-request timeouts while preserving
strict status/body checks, and the services test drains split custom-server
response packets before deciding that the named handler failed.
> **Status: W7a/W7b/W7c/W7d DONE (2026-06-14).** Handler invocation is now
> unified: CLI / WebSocket / serve_http / page render all route through one
> `serve_via_wasm(entry_unit, handler)` path, the handler string mapping to a
> `__uce_<handler>` export (see
> [`docs/wasm-runtime-architecture.md`](docs/wasm-runtime-architecture.md)).
> W7c was resolved by the central WebSocket broker + serve_http dispatcher both
> forwarding to a clean worker over `uce.sock` (no wasm render inside a forked
> broker child). W7d cleared the last native-only holdouts: `zip.uce` was
> rewritten to use the membrane's return-value error contract (no C++
> try/catch), `error-reporting.uce` now triggers genuine wasm traps instead of
> `throw`, and `sharedunit.uce` uses `unit_info()` instead of
> `compiler_load_shared_unit()`. **The native-only token gate is now empty** —
> no unit source forces native anymore.
>
> **W7e plan — deleting the native pipeline (the remaining work).** The native
> pipeline is not just a fallback; it is currently the **wasm compile driver**:
> `compiler.cpp` preprocesses `.uce → C++` (shared front-end, KEEP), then
> compiles the native `.so` (`COMPILE_SCRIPT`), and only *then*, gated on that
> native compile succeeding, compiles the `.wasm` side-module
> (`compile_wasm_unit`). The backend's native fallback on a cold/stale artifact
> exists so the native path can JIT-rebuild **both** `.so` and `.wasm`. So the
> deletion must be staged, not a single excision:
> 1. **Decouple the compile driver.** Make the `.wasm` side-module compile run
> unconditionally from the shared preprocessed C++, independent of the
> native `.so` compile and of the `SharedUnit` struct's native bookkeeping.
> 2. **Make wasm self-healing on cold/stale.** Have the backend trigger (or
> wait on) an on-demand wasm compile instead of falling back to native, so
> `wasm_artifact_exists` staleness no longer needs the native rebuild. (Note:
> until this lands, a cold request — before the proactive compiler warms a
> unit — to a unit rewritten for the membrane error contract, e.g. `zip.uce`,
> can `500` on the native path because native `zip_create` still throws.)
> 3. **Remove native execution + machinery.** Delete `compiler_invoke` /
> `_cli` / `_websocket` / `_serve_http`, `compiler_load_shared_unit`,
> `load_shared_unit`, the `dlopen`/`dlsym`/`dlclose` path and `.so`
> function-pointer fields on `SharedUnit`, the `COMPILE_SCRIPT` native
> compile, and the native-fallback branches in `handle_complete` and
> `wasm_backend_should_handle`.
> 4. **Drop the feature flag.** Remove `WASM_BACKEND_ENABLED` and the now-dead
> `wasm_backend_native_fallback_*` gate (the token scan is already vestigial).
>
> Each stage must rebuild green on `scripts/run_cli_tests.sh --include-wasm-kill`
> before the next. W7f (broad dead/legacy sweep) folds into stages 34.
>
> **Prerequisite — membrane silent-stub completeness (in progress).** Removing
> native fallback (stages 24) turns any silently-stubbed wasm-core function
> into a hard failure with no safety net. A real one slipped through this way:
> `ls()` was a stub returning empty, so `doc/index.uce` (which enumerates pages
> via `ls("pages/")`) rendered but listed nothing — and the suite missed it
> because it only asserted the page returned `<html>`, never that it enumerated
> items. Fixed (2026-06-14) by wiring `ls`, `mkdir`, and `file_mtime` through new
> `uce_host_file_list` / `uce_host_file_mkdir` / `uce_host_file_mtime` hostcalls
> (and teaching `resolve_guest_file` to accept directories), plus a regression
> assertion that the doc index contains `func-item`. Remaining wasm-core stubs
> are intentional: `shell_exec` (no shell in the sandbox), the `*_locked_fd`
> fd-locking variants, and the `backtrace_*` / `signal_name` host-only helpers.
> Before stage 2, audit the rest of the wasm-core block in `src/lib/sys.cpp` for
> any other `(void)arg; return {}` stub a unit can reach.
---
## 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.