This commit is contained in:
udo 2026-06-12 19:55:35 +00:00
parent 80285b7fb4
commit 961df2d542
31 changed files with 2681 additions and 7 deletions

12
.gitignore vendored
View File

@ -40,4 +40,14 @@ pkg/*
# Python cache artifacts
__pycache__/
*.pyc
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Editor swap artifacts
*.swp
*.swo

View File

@ -1,6 +1,10 @@
# WASM-PROPOSAL: WebAssembly Unit Runtime for UCE
- **Status:** updated design guide (2026-06-12)
- **Status:** design guide; Phase 04 spikes complete and gated (2026-06-12).
Next: production implementation, starting with the Phase 3 work plan
(item 1, real `uce_lib` core compile); the starter parity bar
(`tests/run_network_tests.py --match starter`, 14 cases) is already green
against the native backend.
- **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,
@ -346,7 +350,10 @@ Per `load(unit)` into a workspace:
`__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.
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
@ -476,6 +483,16 @@ 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
@ -483,13 +500,101 @@ 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 null-deref page, infinite-loop page, OOM page)
produce clean error pages and an unharmed worker.
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
@ -499,6 +604,17 @@ 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`, non-vacuous), a heuristic `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.
> Reports were written under `/tmp/uce/wasm-phase5/`, and the harness ended with
> `PHASE5 HARNESS: PASS`. 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 — second plane (deferred until wanted).**
Cross-instance call mechanism (props-in/output-out, UCEB1), first Plane B
language binding, loader enforcement of the cross-plane context rule (§4).
@ -520,6 +636,7 @@ only on that; both backends share the Phase 1 C ABI.
| 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

View File

@ -236,6 +236,22 @@ RENDER(Request& context)
tree["nested"]["ok"].set_bool(true);
check("DValue map access", tree["suite"].to_string() == "core" && tree["nested"]["api"].to_string() == "dvalue", json_encode(tree));
// wasm_trace.h (WASM-PROPOSAL Phase 4): canned Wasmtime-format messages —
// the live trap path is gated by the phase-4 spike runner
String trace_msg = "error while executing at wasm backtrace:\n 0: 0x9f - units!_Z6renderR7Request\n";
for(int trace_i = 1; trace_i <= 40; trace_i++)
trace_msg += " " + std::to_string(trace_i) + ": 0x20 - units!_Z9recursionv\n";
trace_msg += "\nCaused by:\n wasm trap: call stack exhausted\n";
WasmTraceSummary trace_summary = wasm_trace_summarize(trace_msg);
String trace_text = wasm_trace_format(trace_summary);
check("wasm_trace_summarize() collapse + demangle", trace_summary.parsed && trace_summary.total_frames == 41 && trace_summary.cause == "wasm trap: call stack exhausted" && trace_summary.frames.size() == 2 && contains(trace_text, "×40") && contains(trace_text, "recursion()"), trace_text);
String fault_msg = "error while executing at wasm backtrace:\n 0: 0x2a - <unknown>!<wasm function 0>\n\nCaused by:\n 0: memory fault at wasm address 0x20000 in linear memory of size 0x10000\n 1: wasm trap: out of bounds memory access\n";
WasmTraceSummary fault_summary = wasm_trace_summarize(fault_msg);
check("wasm_trace_summarize() cause/detail split", fault_summary.cause == "wasm trap: out of bounds memory access" && contains(fault_summary.detail, "memory fault at wasm address 0x20000") && fault_summary.total_frames == 1, wasm_trace_format(fault_summary));
check("wasm_trace_collapse() raw passthrough", wasm_trace_collapse("plain host error") == "plain host error", wasm_trace_collapse("plain host error"));
site_tests_summary(passed, failed, skipped, "These assertions intentionally stay pure and side-effect free so they remain safe on the public site.");
site_tests_page_end();
}

View File

@ -125,6 +125,15 @@ addresses read from core's exported data-symbol globals) → instantiate →
patch deferred GOT entries → `__wasm_apply_data_relocs``__wasm_call_ctors`
→ call the entry export.
- **Erratum (found in Phase 3): self-resolved `GOT.mem` values must add
`__memory_base`.** A PIC module's exported data symbols are i32 globals
holding offsets *relative to its `__memory_base`*, not absolute addresses —
the linker adds the base when patching deferred GOT entries (there is no
`__wasm_apply_global_relocs` export to do it). Copying the export verbatim
reads/writes core memory at low addresses and renders silently wrong values;
the Phase 3 fixture's `self-got`/`callback` markers exist to catch exactly
this. `GOT.mem` entries resolved from the *core's* exports are absolute
already (the core is non-PIC) and need no adjustment.
- **GOT.func is resolved guest-side**: the core exports a helper returning
`(intptr_t)&func` — taking the address forces a link-time elem entry and a
wasm function pointer *is* its table index. No host-side funcref injection

View File

@ -394,9 +394,11 @@ int main(int argc, char** argv)
CHECK(own, "GOT.mem.%s defined neither by core nor by unit", nm.c_str());
wasm_val_t v;
wasm_global_get(own, &v);
wasm_val_t nv = WASM_I32_VAL(v.of.i32);
// dylink ABI: a PIC module's exported data symbols are offsets relative
// to its __memory_base; the linker must add the base when resolving
wasm_val_t nv = WASM_I32_VAL(memory_base + v.of.i32);
wasm_global_set(got, &nv);
printf("self-resolved GOT.mem.%s = %d\n", nm.c_str(), v.of.i32);
printf("self-resolved GOT.mem.%s = %d (offset %d)\n", nm.c_str(), memory_base + v.of.i32, v.of.i32);
}
if(unit.func("__wasm_apply_data_relocs"))

View File

@ -0,0 +1,51 @@
# spikes/wasm-phase2 — core module + membrane scaffold
Phase 2 validates the next WASM step without the Phase 3 dynamic loader. The
scaffold compiles a native UCE core subset (`Request`, `DValue`, UCEB1, print
buffering) plus one statically linked real `.uce` page into a WASM reactor.
A small Wasmtime C-API host provides the first membrane hostcalls, sends a UCEB1
request context into the guest, invokes render, and reads the response from
linear memory. `uce_host_ctx_read(ptr, cap)` follows a length-query contract:
`ptr == 0` or `cap == 0` returns the required length; a short non-zero buffer
also returns the required length without a partial copy; an adequately sized
buffer receives the full context and returns the copied length.
This is intentionally not the final worker. It proves the Phase 2 membrane path:
core-owned DValue/UCEB1 code runs in WASM and a `.uce` render entry sees a
host-provided request context through the membrane. The checked-in page uses the
same `RENDER(Request&)` entry shape as generated units, but it is included
directly by the scaffold rather than emitted by the UCE preprocessor.
Run on `k-uce`:
```bash
bash spikes/wasm-phase2/build_modules.sh
bash spikes/wasm-phase2/build_loader.sh
/tmp/uce/wasm-phase2/loader
```
Expected final line:
```text
PHASE2 EXIT CRITERION: PASS
```
Files:
- `core.cpp` — WASM reactor core subset and membrane decode/render entry.
- `page.uce` — real UCE page statically linked for Phase 2 only.
- `loader.cpp` — host runner with `uce_host_ctx_read` and `uce_host_log`.
Its small host-side UCEB1 encoder is spike-only; the production UCE server
host should use the native `ucb_encode()` implementation to avoid format drift.
- `build_modules.sh` — wasi-sdk build for `/tmp/uce/wasm-phase2/core.wasm`.
- `build_loader.sh` — Wasmtime C-API build for `/tmp/uce/wasm-phase2/loader`.
Deferred to Phase 3:
- PIC side modules and dylink loader integration.
- Lazy unit/component loading.
- Full `uce_lib` hostcall surface beyond the minimal context/log membrane.
- Generator-emitted units (`.uce` preprocessor output with literal markup,
`__uce_set_current_request`, and generated includes) rendering through this
membrane; Phase 0 only proved those generated units can compile as side
modules.

View File

@ -0,0 +1,16 @@
#!/bin/bash
# Phase 2: build the minimal host/membrane runner against Wasmtime's wasm-c-api.
set -e
cd "$(dirname "$0")"
OUT=/tmp/uce/wasm-phase2
mkdir -p "$OUT"
c++ -std=c++17 -O2 loader.cpp \
-I/opt/wasmtime/include \
-L/opt/wasmtime/lib \
-Wl,-rpath,/opt/wasmtime/lib \
-lwasmtime \
-o "$OUT/loader"
echo "built: $OUT/loader"

View File

@ -0,0 +1,21 @@
#!/bin/bash
# Phase 2: build the native DValue/UCEB1 core subset plus one statically
# linked real .uce page as a WASM reactor. Runs on k-uce; artifacts go under
# /tmp/uce/wasm-phase2.
set -e
cd "$(dirname "$0")"
SDK=/opt/wasi-sdk
OUT=/tmp/uce/wasm-phase2
mkdir -p "$OUT"
"$SDK/bin/clang++" --target=wasm32-wasip1 -mexec-model=reactor \
-O1 -fno-exceptions \
-I../.. -I../../src/lib \
core.cpp -o "$OUT/core.wasm" \
-Wl,--export-all \
-Wl,--export=__heap_base \
-Wl,--allow-undefined-file=hostcalls.syms
echo "--- phase2 core.wasm ---"
ls -la "$OUT/core.wasm"

142
spikes/wasm-phase2/core.cpp Normal file
View File

@ -0,0 +1,142 @@
// WASM-PROPOSAL Phase 2 — native UCE core subset compiled to WASM.
//
// This scaffold validates the Phase 2 membrane without the Phase 3 dynamic
// loader: the core owns memory/libc++/DValue/UCEB1, imports a tiny hostcall
// surface, decodes a host-provided UCEB1 request context, and invokes one
// statically linked real .uce page.
#include <cstdlib>
#include <cstring>
#include "../../src/lib/types.h"
#include "../../src/lib/dvalue.cpp"
extern "C" {
size_t uce_host_ctx_read(char* buf, size_t cap);
void uce_host_log(int level, const char* buf, size_t len);
}
#define RENDER(X) extern "C" void __uce_render(X)
#include "page.uce"
static Request g_request;
static ByteStream g_ob;
static String g_output;
SharedUnit::~SharedUnit() {}
String nibble(String div, String& haystack)
{
auto pos = haystack.find(div);
if(pos == String::npos)
{
auto result = haystack;
haystack.clear();
return(result);
}
auto result = haystack.substr(0, pos);
haystack.erase(0, pos + div.length());
return(result);
}
void Request::ob_start()
{
ob_stack.push_back(new ByteStream());
ob = ob_stack.back();
}
void Request::set_status(s32 code, String reason)
{
if(reason == "")
reason = code == 200 ? "OK" : "Status";
response_code = "HTTP/1.1 " + std::to_string(code) + " " + reason;
}
Request::~Request()
{
for(auto* stream : ob_stack)
delete stream;
ob_stack.clear();
}
static void phase2_clear_ob_stack()
{
for(auto* stream : g_request.ob_stack)
delete stream;
g_request.ob_stack.clear();
}
static void phase2_apply_context(DValue& root)
{
g_request.call = root;
g_request.params.clear();
DValue* params = root.key("params");
if(params)
{
params->each([&](const DValue& item, String key) {
g_request.params[key] = item.to_string();
});
}
}
extern "C" {
void* uce_alloc(size_t len)
{
return(malloc(len));
}
void uce_free(void* ptr)
{
free(ptr);
}
int uce_phase2_render()
{
context = &g_request;
phase2_clear_ob_stack();
g_ob.str("");
g_ob.clear();
g_request.ob = &g_ob;
g_request.out = "";
g_output = "";
size_t ctx_required = uce_host_ctx_read(0, 0);
if(ctx_required == 0)
return(10);
char* ctx_buf = (char*)malloc(ctx_required);
if(ctx_buf == 0)
return(11);
size_t ctx_len = uce_host_ctx_read(ctx_buf, ctx_required);
if(ctx_len != ctx_required)
{
free(ctx_buf);
return(12);
}
DValue decoded;
String error;
bool ok = ucb_decode(String(ctx_buf, ctx_len), decoded, &error);
free(ctx_buf);
if(!ok)
{
uce_host_log(3, error.data(), error.size());
return(20);
}
phase2_apply_context(decoded);
__uce_render(g_request);
g_output = g_ob.str();
return(0);
}
const char* uce_phase2_output_data()
{
return(g_output.data());
}
size_t uce_phase2_output_size()
{
return(g_output.size());
}
}

View File

@ -0,0 +1,2 @@
uce_host_ctx_read
uce_host_log

View File

@ -0,0 +1,280 @@
// WASM-PROPOSAL Phase 2 — minimal membrane host.
// Instantiates the statically linked core/page module, serves a UCEB1 request
// context via hostcall, invokes render, and reads the response from guest
// memory. Dynamic linking is intentionally Phase 3 work.
#include <wasm.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <map>
#include <string>
#include <vector>
#define FAIL(...) do { fprintf(stderr, "FAIL: " __VA_ARGS__); fprintf(stderr, "\n"); exit(1); } while(0)
#define CHECK(cond, ...) do { if(!(cond)) FAIL(__VA_ARGS__); } while(0)
static wasm_store_t* g_store = nullptr;
static wasm_memory_t* g_memory = nullptr;
static std::string g_context;
static std::vector<uint8_t> read_file(const char* fn)
{
FILE* f = fopen(fn, "rb");
CHECK(f, "cannot open %s", fn);
fseek(f, 0, SEEK_END);
long n = ftell(f);
fseek(f, 0, SEEK_SET);
std::vector<uint8_t> buf(n);
CHECK(fread(buf.data(), 1, n, f) == (size_t)n, "short read on %s", fn);
fclose(f);
return buf;
}
static void append_varuint(std::string& out, uint64_t value)
{
while(value >= 0x80)
{
out.push_back((char)((value & 0x7f) | 0x80));
value >>= 7;
}
out.push_back((char)value);
}
struct Node
{
std::string scalar;
bool is_list = false;
std::vector<std::pair<std::string, Node>> children;
};
static Node scalar(const char* value)
{
Node n;
n.scalar = value;
return n;
}
static void encode_node(std::string& out, const Node& node)
{
out.push_back(node.is_list ? 1 : 0);
append_varuint(out, node.scalar.size());
out.append(node.scalar);
append_varuint(out, node.children.size());
for(const auto& child : node.children)
{
append_varuint(out, child.first.size());
out.append(child.first);
encode_node(out, child.second);
}
}
static std::string make_context()
{
Node params;
params.children.push_back({"HTTP_HOST", scalar("phase2.example.test")});
params.children.push_back({"SCRIPT_URL", scalar("/spikes/wasm-phase2/page.uce")});
Node nested;
nested.children.push_back({"answer", scalar("42")});
Node root;
root.children.push_back({"params", params});
root.children.push_back({"route", scalar("/spikes/wasm-phase2/page.uce")});
root.children.push_back({"nested", nested});
std::string out = "UCEB";
out.push_back((char)1);
encode_node(out, root);
return out;
}
static wasm_trap_t* host_ctx_read(void* env, const wasm_val_vec_t* args, wasm_val_vec_t* results)
{
(void)env;
uint32_t ptr = args->data[0].of.i32;
uint32_t cap = args->data[1].of.i32;
if(ptr == 0 || cap == 0 || cap < g_context.size())
{
results->data[0] = WASM_I32_VAL((int32_t)g_context.size());
return nullptr;
}
CHECK(g_memory, "host ctx_read called before memory export was captured");
uint8_t* mem = (uint8_t*)wasm_memory_data(g_memory);
size_t mem_size = wasm_memory_data_size(g_memory);
CHECK((size_t)ptr <= mem_size, "ctx_read pointer outside memory");
CHECK((size_t)ptr + g_context.size() <= mem_size, "ctx_read buffer outside memory");
memcpy(mem + ptr, g_context.data(), g_context.size());
results->data[0] = WASM_I32_VAL((int32_t)g_context.size());
return nullptr;
}
static wasm_trap_t* host_log(void* env, const wasm_val_vec_t* args, wasm_val_vec_t* results)
{
(void)env; (void)results;
int level = args->data[0].of.i32;
uint32_t ptr = args->data[1].of.i32;
uint32_t len = args->data[2].of.i32;
uint8_t* mem = (uint8_t*)wasm_memory_data(g_memory);
size_t mem_size = wasm_memory_data_size(g_memory);
if((size_t)ptr + len <= mem_size)
fprintf(stderr, "[guest log %d] %.*s\n", level, (int)len, (const char*)mem + ptr);
return nullptr;
}
static wasm_trap_t* stub_callback(void* env, const wasm_val_vec_t* args, wasm_val_vec_t* results)
{
(void)args; (void)results;
char msg[256];
snprintf(msg, sizeof(msg), "unimplemented import called: %s", (const char*)env);
wasm_message_t message;
wasm_byte_vec_new(&message, strlen(msg) + 1, msg);
wasm_trap_t* trap = wasm_trap_new(g_store, &message);
wasm_byte_vec_delete(&message);
return trap;
}
static wasm_func_t* make_func(wasm_store_t* store, std::vector<wasm_valkind_t> params, std::vector<wasm_valkind_t> results, wasm_func_callback_with_env_t cb, void* env = nullptr)
{
wasm_valtype_vec_t ps;
wasm_valtype_vec_new_uninitialized(&ps, params.size());
for(size_t i = 0; i < params.size(); i++)
ps.data[i] = wasm_valtype_new(params[i]);
wasm_valtype_vec_t rs;
wasm_valtype_vec_new_uninitialized(&rs, results.size());
for(size_t i = 0; i < results.size(); i++)
rs.data[i] = wasm_valtype_new(results[i]);
wasm_functype_t* ft = wasm_functype_new(&ps, &rs);
wasm_func_t* fn = wasm_func_new_with_env(store, ft, cb, env, nullptr);
wasm_functype_delete(ft);
return fn;
}
struct Instance
{
wasm_module_t* module = nullptr;
wasm_instance_t* instance = nullptr;
wasm_extern_vec_t exports = WASM_EMPTY_VEC;
std::map<std::string, wasm_extern_t*> by_name;
void index_exports()
{
wasm_exporttype_vec_t types = WASM_EMPTY_VEC;
wasm_module_exports(module, &types);
wasm_instance_exports(instance, &exports);
for(size_t i = 0; i < types.size; i++)
{
const wasm_name_t* nm = wasm_exporttype_name(types.data[i]);
std::string key(nm->data, nm->size);
while(!key.empty() && key.back() == '\0') key.pop_back();
by_name[key] = exports.data[i];
}
wasm_exporttype_vec_delete(&types);
}
wasm_func_t* func(const char* name)
{
auto it = by_name.find(name);
return it == by_name.end() ? nullptr : wasm_extern_as_func(it->second);
}
};
static int32_t call_i32(Instance& inst, const char* name)
{
wasm_func_t* f = inst.func(name);
CHECK(f, "missing export func %s", name);
wasm_val_t result_buf[1] = { WASM_INIT_VAL };
wasm_val_vec_t args = WASM_EMPTY_VEC;
wasm_val_vec_t results = { wasm_func_result_arity(f), result_buf };
wasm_val_vec_t no_results = WASM_EMPTY_VEC;
wasm_trap_t* trap = wasm_func_call(f, &args, results.size ? &results : &no_results);
if(trap)
{
wasm_message_t msg;
wasm_trap_message(trap, &msg);
FAIL("trap during %s: %.*s", name, (int)msg.size, msg.data);
}
return results.size ? result_buf[0].of.i32 : 0;
}
int main(int argc, char** argv)
{
const char* core_path = argc > 1 ? argv[1] : "/tmp/uce/wasm-phase2/core.wasm";
g_context = make_context();
wasm_engine_t* engine = wasm_engine_new();
CHECK(engine, "engine");
g_store = wasm_store_new(engine);
CHECK(g_store, "store");
std::vector<uint8_t> bytes = read_file(core_path);
wasm_byte_vec_t bv;
wasm_byte_vec_new(&bv, bytes.size(), (const char*)bytes.data());
Instance core;
core.module = wasm_module_new(g_store, &bv);
wasm_byte_vec_delete(&bv);
CHECK(core.module, "core module load failed");
wasm_importtype_vec_t imports = WASM_EMPTY_VEC;
wasm_module_imports(core.module, &imports);
std::vector<wasm_extern_t*> externs(imports.size);
std::vector<wasm_func_t*> owned_funcs;
for(size_t i = 0; i < imports.size; i++)
{
const wasm_name_t* mod_n = wasm_importtype_module(imports.data[i]);
const wasm_name_t* name_n = wasm_importtype_name(imports.data[i]);
std::string mod(mod_n->data, mod_n->size);
std::string name(name_n->data, name_n->size);
while(!mod.empty() && mod.back() == '\0') mod.pop_back();
while(!name.empty() && name.back() == '\0') name.pop_back();
const wasm_externtype_t* et = wasm_importtype_type(imports.data[i]);
CHECK(wasm_externtype_kind(et) == WASM_EXTERN_FUNC, "unexpected non-func import %s.%s", mod.c_str(), name.c_str());
wasm_func_t* fn = nullptr;
if(mod == "env" && name == "uce_host_ctx_read")
fn = make_func(g_store, {WASM_I32, WASM_I32}, {WASM_I32}, host_ctx_read);
else if(mod == "env" && name == "uce_host_log")
fn = make_func(g_store, {WASM_I32, WASM_I32, WASM_I32}, {}, host_log);
else
{
char* label = strdup((mod + "." + name).c_str());
const wasm_functype_t* ft = wasm_externtype_as_functype_const(et);
fn = wasm_func_new_with_env(g_store, ft, stub_callback, label, nullptr);
}
CHECK(fn, "failed to create import %s.%s", mod.c_str(), name.c_str());
owned_funcs.push_back(fn);
externs[i] = wasm_func_as_extern(fn);
}
wasm_extern_vec_t iv = { externs.size(), externs.data() };
wasm_trap_t* trap = nullptr;
core.instance = wasm_instance_new(g_store, core.module, &iv, &trap);
if(trap)
{
wasm_message_t msg;
wasm_trap_message(trap, &msg);
FAIL("trap during instantiation: %.*s", (int)msg.size, msg.data);
}
CHECK(core.instance, "instantiation failed");
core.index_exports();
g_memory = wasm_extern_as_memory(core.by_name.count("memory") ? core.by_name["memory"] : nullptr);
CHECK(g_memory, "core does not export memory");
if(core.func("_initialize"))
call_i32(core, "_initialize");
int rc = call_i32(core, "uce_phase2_render");
CHECK(rc == 0, "render returned %d", rc);
int32_t data = call_i32(core, "uce_phase2_output_data");
int32_t size = call_i32(core, "uce_phase2_output_size");
uint8_t* mem = (uint8_t*)wasm_memory_data(g_memory);
CHECK((size_t)data + (size_t)size <= wasm_memory_data_size(g_memory), "output outside memory");
std::string output((const char*)mem + data, size);
printf("---- phase2 output (%d bytes) ----\n%s", size, output.c_str());
printf("----------------------------------\n");
CHECK(output.find("PHASE2 PAGE OK") != std::string::npos, "missing page marker");
CHECK(output.find("host=phase2.example.test") != std::string::npos, "missing context param");
CHECK(output.find("answer=42") != std::string::npos, "missing nested context value");
printf("PHASE2 EXIT CRITERION: PASS\n");
return 0;
}

View File

@ -0,0 +1,12 @@
// WASM-PROPOSAL Phase 2 scaffold page.
// This is a real .uce source file with a normal RENDER entry point. The
// Phase 2 core statically links it to validate the host-context membrane
// before the dynamic loader is introduced in Phase 3.
RENDER(Request& context)
{
print("PHASE2 PAGE OK\n");
print("host=", context.params["HTTP_HOST"], "\n");
print("route=", context.call["route"].to_string(), "\n");
print("answer=", context.call["nested"]["answer"].to_string(), "\n");
}

View File

@ -0,0 +1,63 @@
# spikes/wasm-phase3 — dynamic loader + workspace scaffold
Phase 3 combines the Phase 0 dynamic PIC-module loader with the Phase 2 UCEB1
request-context membrane. It is still a spike, not the production wasm worker,
but it validates the load-bearing pieces together:
1. build a core WASM reactor that owns memory, allocator, `Request`, `DValue`,
UCEB1, and output buffering;
2. build a separate PIC side module with `dylink.0` from a generated-shape UCE
page C++ file;
3. parse `dylink.0`, allocate `__memory_base`/`__table_base`, resolve
`env.*`, `GOT.mem.*`, and `GOT.func.*` imports against the core/workspace;
4. instantiate the unit, run relocations/constructors, set its Request pointer,
call `__uce_render`, and read rendered output from core-owned memory.
The fixture intentionally exercises the loader branches that are easiest to
accidentally leave dark: non-zero `table_size` / `__table_base`, `GOT.func`
resolution (`phase3_core_print`), and deferred self-resolution of unit-owned
`GOT.mem` entries. The loader output prints those resolutions before the final
pass marker. Its generated-shape expressions call `html_escape(...)` so the
side-module import surface includes the first ordinary UCE helper dependency
rather than only `types.h`/`DValue`.
Run on `k-uce`:
```bash
bash spikes/wasm-phase3/build_modules.sh
bash spikes/wasm-phase3/build_loader.sh
/tmp/uce/wasm-phase3/loader
```
Expected final line:
```text
PHASE3 EXIT CRITERION: PASS
```
Files:
- `core.cpp` — core/workspace scaffold and UCEB1 membrane decode.
- `page.uce` — source page for the generated-shape fixture.
- `generated/page.uce.cpp` — checked-in UCE-preprocessor-shaped side-module
fixture: `uce_lib.h` include, `__uce_set_current_request`, `__uce_render`,
literal markup lowered to `print(R"(...)")`, expression prints wrapped in
`html_escape(...)`, callback/lambda/table exercises, and self-GOT data.
- `loader.cpp` — dynamic linker/runner adapted from Phase 0.
- `hostcalls.syms` — explicit core hostcall allowlist.
Still deferred to production Phase 3/4 work:
- running the actual UCE preprocessor inside this build script rather than using
a checked-in generated-shape fixture;
- lazy component/path dispatch for a full site tree;
- uce-starter end-to-end under a wasm FastCGI worker;
- productionizing the side-module allocator gate as a real `#ifdef` in
`types.h` instead of this spike's copied-header text patch;
- parser hardening beyond the spike's basic magic/version/bounds/alignment
checks, including fuzzing malformed wasm/dylink sections before accepting
cache or third-party units;
- import-policy hardening beyond this spike's fail-fast resolver;
- workspace birth/drop integration with the real server lifecycle, including
retaining unaligned allocation pointers needed to unload units or drop a
workspace cleanly.

View File

@ -0,0 +1,16 @@
#!/bin/bash
# Phase 3: build the dynamic-loader/membrane runner against Wasmtime's wasm-c-api.
set -e
cd "$(dirname "$0")"
OUT=/tmp/uce/wasm-phase3
mkdir -p "$OUT"
c++ -std=c++17 -O2 loader.cpp \
-I/opt/wasmtime/include \
-L/opt/wasmtime/lib \
-Wl,-rpath,/opt/wasmtime/lib \
-lwasmtime \
-o "$OUT/loader"
echo "built: $OUT/loader"

View File

@ -0,0 +1,70 @@
#!/bin/bash
# Phase 3: build a core reactor plus one generated-shape PIC side module.
# Runs on k-uce; artifacts go under /tmp/uce/wasm-phase3.
set -e
cd "$(dirname "$0")"
SDK=/opt/wasi-sdk
ROOT=$(cd ../.. && pwd)
OUT=/tmp/uce/wasm-phase3
SHIM="$OUT/shim"
mkdir -p "$OUT" "$SHIM"
cat > "$SHIM/uce_lib.h" <<'EOF'
#pragma once
#include "types.h"
String html_escape(String s);
EOF
cp "$ROOT/src/lib/types.h" "$SHIM/types.h"
python3 - <<'PY'
from pathlib import Path
p = Path('/tmp/uce/wasm-phase3/shim/types.h')
s = p.read_text()
start = s.index('void * operator new(decltype(sizeof(0)) n) noexcept(false)')
end = s.index('void operator delete(void * p) throw()')
end = s.index('\n}', end) + 3
replacement = '''// WASM side-module shim: allocator is owned by the core module.\nvoid * operator new(decltype(sizeof(0)) n) noexcept(false);\nvoid operator delete(void * p) throw();\nvoid operator delete(void * p, decltype(sizeof(0)) n) noexcept;\n'''
p.write_text(s[:start] + replacement + s[end:])
PY
cat > "$SHIM/signal.h" <<'EOF'
#pragma once
typedef int sig_atomic_t;
typedef void (*sighandler_t)(int);
#define SIGTERM 15
int raise(int);
sighandler_t signal(int, sighandler_t);
EOF
"$SDK/bin/clang++" --target=wasm32-wasip1 -mexec-model=reactor \
-O1 -fno-exceptions \
-I../.. -I../../src/lib \
core.cpp -o "$OUT/core.wasm" \
-Wl,--export-all \
-Wl,--import-table \
-Wl,--export=__stack_pointer \
-Wl,--export=__heap_base \
-Wl,--allow-undefined-file=hostcalls.syms \
-Wl,--undefined=_ZTVN10__cxxabiv117__class_type_infoE
"$SDK/bin/clang++" --target=wasm32-wasip1 -fPIC -fvisibility=default \
-fvisibility-inlines-hidden \
-O1 -fno-exceptions \
-I"$SHIM" -I"$ROOT/src/lib" \
-c generated/page.uce.cpp -o "$OUT/page.o"
"$SDK/bin/clang++" --target=wasm32-wasip1 -fPIC -fvisibility=default \
-fvisibility-inlines-hidden \
-O1 -fno-exceptions \
-I"$SHIM" -I"$ROOT/src/lib" \
-c generated/helper.cpp -o "$OUT/helper.o"
"$SDK/bin/wasm-ld" -shared --experimental-pic \
--unresolved-symbols=import-dynamic \
"$OUT/page.o" "$OUT/helper.o" -o "$OUT/page.wasm" \
--export=__uce_set_current_request \
--export=__uce_render
echo "--- phase3 core.wasm ---"
ls -la "$OUT/core.wasm"
echo "--- phase3 page.wasm ---"
ls -la "$OUT/page.wasm"

188
spikes/wasm-phase3/core.cpp Normal file
View File

@ -0,0 +1,188 @@
// WASM-PROPOSAL Phase 3 — dynamic-loader core scaffold.
//
// This core owns the UCE heap, Request, DValue/UCEB1, and output buffer. The
// Phase 3 loader dynamically links a PIC side module generated in the same
// shape as UCE preprocessor output, sets its Request* context, and invokes its
// __uce_render entry.
#include <cstdlib>
#include <cstring>
#include <functional>
#include "../../src/lib/types.h"
#include "../../src/lib/dvalue.cpp"
extern "C" {
size_t uce_host_ctx_read(char* buf, size_t cap);
void uce_host_log(int level, const char* buf, size_t len);
}
extern "C" void phase3_core_print(const char* data, size_t len)
{
if(context && context->ob)
context->ob->write(data, len);
}
extern "C" void phase3_core_invoke_callback(void (*cb)(int), int value)
{
cb(value);
}
extern "C" void phase3_core_invoke_function(std::function<int(int)>* f, int value)
{
String out = "<p>fn=" + std::to_string((*f)(value)) + "</p>\n";
phase3_core_print(out.data(), out.size());
}
extern "C" intptr_t core_table_index_of_phase3_core_print()
{
return(reinterpret_cast<intptr_t>(phase3_core_print));
}
static Request g_request;
static ByteStream g_ob;
static String g_output;
SharedUnit::~SharedUnit() {}
String html_escape(String s)
{
String result;
for(char c : s)
{
switch(c)
{
case '&': result += "&amp;"; break;
case '<': result += "&lt;"; break;
case '>': result += "&gt;"; break;
case '"': result += "&quot;"; break;
case '\'': result += "&#39;"; break;
default: result.push_back(c); break;
}
}
return(result);
}
String nibble(String div, String& haystack)
{
auto pos = haystack.find(div);
if(pos == String::npos)
{
auto result = haystack;
haystack.clear();
return(result);
}
auto result = haystack.substr(0, pos);
haystack.erase(0, pos + div.length());
return(result);
}
void Request::ob_start()
{
ob_stack.push_back(new ByteStream());
ob = ob_stack.back();
}
void Request::set_status(s32 code, String reason)
{
if(reason == "")
reason = code == 200 ? "OK" : "Status";
response_code = "HTTP/1.1 " + std::to_string(code) + " " + reason;
}
Request::~Request()
{
for(auto* stream : ob_stack)
delete stream;
ob_stack.clear();
}
static void phase3_clear_ob_stack()
{
for(auto* stream : g_request.ob_stack)
delete stream;
g_request.ob_stack.clear();
}
static void phase3_apply_context(DValue& root)
{
g_request.call = root;
g_request.params.clear();
DValue* params = root.key("params");
if(params)
{
params->each([&](const DValue& item, String key) {
g_request.params[key] = item.to_string();
});
}
}
extern "C" {
void* uce_alloc(size_t len)
{
return(malloc(len));
}
void uce_free(void* ptr)
{
free(ptr);
}
int uce_phase3_prepare()
{
context = &g_request;
phase3_clear_ob_stack();
g_ob.str("");
g_ob.clear();
g_request.ob = &g_ob;
g_request.out = "";
g_output = "";
size_t ctx_required = uce_host_ctx_read(0, 0);
if(ctx_required == 0)
return(10);
char* ctx_buf = (char*)malloc(ctx_required);
if(ctx_buf == 0)
return(11);
size_t ctx_len = uce_host_ctx_read(ctx_buf, ctx_required);
if(ctx_len != ctx_required)
{
free(ctx_buf);
return(12);
}
DValue decoded;
String error;
bool ok = ucb_decode(String(ctx_buf, ctx_len), decoded, &error);
free(ctx_buf);
if(!ok)
{
uce_host_log(3, error.data(), error.size());
return(20);
}
phase3_apply_context(decoded);
return(0);
}
Request* uce_phase3_request()
{
return(&g_request);
}
void uce_phase3_finish()
{
g_output = g_ob.str();
}
const char* uce_phase3_output_data()
{
return(g_output.data());
}
size_t uce_phase3_output_size()
{
return(g_output.size());
}
}

View File

@ -0,0 +1,6 @@
extern int phase3_unit_state;
extern "C" int phase3_helper_state_plus(int value)
{
return(phase3_unit_state + value);
}

View File

@ -0,0 +1,72 @@
#include "uce_lib.h"
#include <functional>
#include <map>
#include <tuple>
extern "C" void phase3_core_print(const char* data, size_t len);
extern "C" void phase3_core_invoke_callback(void (*cb)(int), int value);
extern "C" void phase3_core_invoke_function(std::function<int(int)>* f, int value);
extern "C" int phase3_helper_state_plus(int value);
int phase3_unit_state = 40;
int phase3_weak_data __attribute__((weak)) = 0;
static void phase3_unit_callback(int value)
{
String out = "<p>callback=" + std::to_string(value + phase3_unit_state) + "</p>\n";
print(out);
}
#ifndef UCE_SET_CURRENT_REQUEST_DEFINED
#define UCE_SET_CURRENT_REQUEST_DEFINED
/*load_declarations*/
extern "C" void __uce_set_current_request(Request* _request)
{
context = _request;
/*load_units*/
}
#endif
#line 4 "spikes/wasm-phase3/page.uce"
extern "C" void __uce_render(Request& context)
{
print(R"(<section class="phase3">
<h1>PHASE3 PAGE OK</h1>
<p>host=)");
print(html_escape(context.params["HTTP_HOST"]));
print(R"(</p>
<p>route=)");
print(html_escape(context.call["route"].to_string()));
print(R"(</p>
<p>answer=)");
print(html_escape(context.call["nested"]["answer"].to_string()));
print(R"(</p>
)");
context.call["nested"].each([&](const DValue& value, String key) {
print(R"( <p>each=)");
print(html_escape(key + ":" + value.to_string()));
print(R"(</p>
)");
});
print(R"( <p>self-got=)");
phase3_weak_data = 7;
print(std::to_string(phase3_helper_state_plus(2) + phase3_weak_data - 7));
print(R"(</p>
)");
std::map<String, String> phase3_map;
phase3_map.emplace(std::piecewise_construct, std::forward_as_tuple("piece"), std::forward_as_tuple("wise"));
print(R"( <p>map=)");
print(html_escape(phase3_map["piece"]));
print(R"(</p>
)");
phase3_core_invoke_callback(phase3_unit_callback, 2);
void (*volatile print_ptr)(const char*, size_t) = phase3_core_print;
print_ptr("<p>got-func-ok</p>\n", 19);
auto* fn = new std::function<int(int)>([](int value) { return(value * 3); });
phase3_core_invoke_function(fn, 14);
delete fn;
print(R"( </section>
)");
}

View File

@ -0,0 +1,2 @@
uce_host_ctx_read
uce_host_log

View File

@ -0,0 +1,554 @@
// WASM-PROPOSAL Phase 3 — dynamic loader + membrane scaffold.
//
// Embeds a wasm runtime through the standard wasm-c-api and links a core
// workspace module with a generated-shape PIC UCE side module:
//
// 1. instantiate core.wasm (owns memory/table/allocator/Request/DValue)
// 2. parse page.wasm's dylink.0 → data size/align, table slots needed
// 3. allocate __memory_base by calling core malloc, and __table_base by
// appending to the shared funcref table
// 4. build the unit's import vector: env.memory / env.__indirect_function_table /
// env.__stack_pointer from core; env.* functions from core exports;
// GOT.mem.* and GOT.func.* through the same rules as Phase 0
// 5. instantiate unit, patch deferred GOT entries, run relocations/ctors
// 6. prepare a UCEB1 request context in core, set the unit's Request*, call
// __uce_render, then read output back out of core-owned linear memory
//
// Everything here is deliberately validation-grade: fail loudly, never guess.
#include <wasm.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <map>
#define FAIL(...) do { fprintf(stderr, "FAIL: " __VA_ARGS__); fprintf(stderr, "\n"); exit(1); } while(0)
#define CHECK(cond, ...) do { if(!(cond)) FAIL(__VA_ARGS__); } while(0)
static std::vector<uint8_t> read_file(const char* fn)
{
FILE* f = fopen(fn, "rb");
CHECK(f, "cannot open %s", fn);
fseek(f, 0, SEEK_END);
long n = ftell(f);
fseek(f, 0, SEEK_SET);
std::vector<uint8_t> buf(n);
CHECK(fread(buf.data(), 1, n, f) == (size_t)n, "short read on %s", fn);
fclose(f);
return buf;
}
// ---- minimal dylink.0 parser -------------------------------------------
struct DylinkInfo
{
uint32_t mem_size = 0;
uint32_t mem_align = 0; // power of 2
uint32_t table_size = 0;
uint32_t table_align = 0;
bool found = false;
};
static uint64_t read_uleb(const uint8_t* buf, size_t& pos, size_t end)
{
uint64_t result = 0;
int shift = 0;
while(true)
{
CHECK(pos < end, "truncated uleb in wasm custom section");
CHECK(shift < 64, "oversized uleb in wasm custom section");
uint8_t b = buf[pos++];
result |= (uint64_t)(b & 0x7f) << shift;
if(!(b & 0x80))
return result;
shift += 7;
}
}
static DylinkInfo parse_dylink(const std::vector<uint8_t>& wasm)
{
DylinkInfo info;
CHECK(wasm.size() >= 8 && !memcmp(wasm.data(), "\0asm", 4), "not a wasm module");
CHECK(wasm[4] == 1 && wasm[5] == 0 && wasm[6] == 0 && wasm[7] == 0, "unsupported wasm binary version");
size_t pos = 8;
while(pos < wasm.size())
{
uint8_t sec_id = wasm[pos++];
uint64_t size = read_uleb(wasm.data(), pos, wasm.size());
CHECK(size <= wasm.size() - pos, "wasm section exceeds file size");
size_t end = pos + size;
if(sec_id == 0)
{
uint64_t name_len = read_uleb(wasm.data(), pos, end);
CHECK(name_len <= end - pos, "custom section name exceeds section size");
std::string name((const char*)wasm.data() + pos, name_len);
pos += name_len;
if(name == "dylink.0")
{
while(pos < end)
{
uint8_t sub = wasm[pos++];
uint64_t sub_len = read_uleb(wasm.data(), pos, end);
CHECK(sub_len <= end - pos, "dylink subsection exceeds section size");
size_t sub_end = pos + sub_len;
if(sub == 1) // WASM_DYLINK_MEM_INFO
{
info.mem_size = read_uleb(wasm.data(), pos, sub_end);
info.mem_align = read_uleb(wasm.data(), pos, sub_end);
info.table_size = read_uleb(wasm.data(), pos, sub_end);
info.table_align = read_uleb(wasm.data(), pos, sub_end);
CHECK(!info.found, "duplicate dylink.0 mem_info subsection");
CHECK(info.mem_align < 31 && info.table_align < 31, "unsupported dylink alignment");
info.found = true;
}
pos = sub_end;
}
}
}
pos = end;
}
return info;
}
// ---- tiny UCEB1 request-context encoder ---------------------------------
static void append_varuint(std::string& out, uint64_t value)
{
while(value >= 0x80)
{
out.push_back((char)((value & 0x7f) | 0x80));
value >>= 7;
}
out.push_back((char)value);
}
struct Node
{
std::string scalar;
std::vector<std::pair<std::string, Node>> children;
};
static Node scalar(const char* value)
{
Node n;
n.scalar = value;
return n;
}
static void encode_node(std::string& out, const Node& node)
{
out.push_back(0);
append_varuint(out, node.scalar.size());
out.append(node.scalar);
append_varuint(out, node.children.size());
for(const auto& child : node.children)
{
append_varuint(out, child.first.size());
out.append(child.first);
encode_node(out, child.second);
}
}
static std::string make_context()
{
Node params;
params.children.push_back({"HTTP_HOST", scalar("phase3.example.test")});
params.children.push_back({"SCRIPT_URL", scalar("/spikes/wasm-phase3/page.uce")});
Node nested;
nested.children.push_back({"answer", scalar("42")});
Node root;
root.children.push_back({"params", params});
root.children.push_back({"route", scalar("/spikes/wasm-phase3/page.uce")});
root.children.push_back({"nested", nested});
std::string out = "UCEB";
out.push_back((char)1);
encode_node(out, root);
return out;
}
// ---- host imports --------------------------------------------------------
static wasm_memory_t* g_memory = nullptr;
static std::string g_context;
static void set_i32_result(wasm_val_t* result, int32_t value)
{
result->kind = WASM_I32;
result->of.i32 = value;
}
static wasm_trap_t* host_ctx_read(void* env, const wasm_val_vec_t* args, wasm_val_vec_t* results)
{
(void)env;
uint32_t ptr = args->data[0].of.i32;
uint32_t cap = args->data[1].of.i32;
if(ptr == 0 || cap == 0 || cap < g_context.size())
{
set_i32_result(&results->data[0], (int32_t)g_context.size());
return nullptr;
}
CHECK(g_memory, "host ctx_read called before memory export was captured");
uint8_t* mem = (uint8_t*)wasm_memory_data(g_memory);
size_t mem_size = wasm_memory_data_size(g_memory);
CHECK((size_t)ptr + g_context.size() <= mem_size, "ctx_read buffer outside memory");
memcpy(mem + ptr, g_context.data(), g_context.size());
set_i32_result(&results->data[0], (int32_t)g_context.size());
return nullptr;
}
static wasm_trap_t* host_log(void* env, const wasm_val_vec_t* args, wasm_val_vec_t* results)
{
(void)env; (void)results;
int level = args->data[0].of.i32;
uint32_t ptr = args->data[1].of.i32;
uint32_t len = args->data[2].of.i32;
uint8_t* mem = (uint8_t*)wasm_memory_data(g_memory);
size_t mem_size = wasm_memory_data_size(g_memory);
if((size_t)ptr + len <= mem_size)
fprintf(stderr, "[guest log %d] %.*s\n", level, (int)len, (const char*)mem + ptr);
return nullptr;
}
// ---- named trap stubs for unsatisfied (WASI) imports --------------------
static wasm_store_t* g_store_for_stubs = nullptr;
static wasm_trap_t* stub_callback(void* env, const wasm_val_vec_t* args, wasm_val_vec_t* results)
{
(void)args; (void)results;
fprintf(stderr, "[stub called: %s]\n", (const char*)env);
char msg[256];
snprintf(msg, sizeof(msg), "unimplemented host import called: %s", (const char*)env);
wasm_message_t message;
wasm_byte_vec_new(&message, strlen(msg) + 1, msg);
wasm_trap_t* trap = wasm_trap_new(g_store_for_stubs, &message);
wasm_byte_vec_delete(&message);
return trap;
}
// ---- instance wrapper: name → extern map --------------------------------
struct Instance
{
wasm_module_t* module = nullptr;
wasm_instance_t* instance = nullptr;
wasm_extern_vec_t exports = WASM_EMPTY_VEC;
std::map<std::string, wasm_extern_t*> by_name;
void index_exports()
{
wasm_exporttype_vec_t types = WASM_EMPTY_VEC;
wasm_module_exports(module, &types);
wasm_instance_exports(instance, &exports);
CHECK(types.size == exports.size, "export type/extern count mismatch");
for(size_t i = 0; i < types.size; i++)
{
const wasm_name_t* nm = wasm_exporttype_name(types.data[i]);
std::string key(nm->data, nm->size);
// some wasm-c-api impls include the trailing NUL in name size
while(!key.empty() && key.back() == '\0')
key.pop_back();
by_name[key] = exports.data[i];
}
wasm_exporttype_vec_delete(&types);
}
wasm_func_t* func(const char* name)
{
auto it = by_name.find(name);
return it == by_name.end() ? nullptr : wasm_extern_as_func(it->second);
}
wasm_global_t* global(const char* name)
{
auto it = by_name.find(name);
return it == by_name.end() ? nullptr : wasm_extern_as_global(it->second);
}
};
static wasm_store_t* g_store = nullptr;
static void report_trap(wasm_trap_t* trap, const char* what)
{
if(!trap)
return;
wasm_message_t msg;
wasm_trap_message(trap, &msg);
FAIL("trap during %s: %.*s", what, (int)msg.size, msg.data);
}
static int32_t call_i32(Instance& inst, const char* name, std::vector<int32_t> argv = {})
{
wasm_func_t* f = inst.func(name);
CHECK(f, "missing export func %s", name);
wasm_val_t args_buf[4];
CHECK(argv.size() <= 4, "call_i32 argv overflow for %s", name);
for(size_t i = 0; i < argv.size(); i++)
args_buf[i] = WASM_I32_VAL(argv[i]);
wasm_val_t results_buf[1] = { WASM_INIT_VAL };
wasm_val_vec_t args = { argv.size(), args_buf };
wasm_val_vec_t results = { 1, results_buf };
size_t result_arity = wasm_func_result_arity(f);
wasm_val_vec_t no_results = WASM_EMPTY_VEC;
wasm_trap_t* trap = wasm_func_call(f, &args, result_arity ? &results : &no_results);
report_trap(trap, name);
return result_arity ? results_buf[0].of.i32 : 0;
}
static wasm_global_t* make_i32_global(int32_t value, wasm_mutability_t mut)
{
wasm_globaltype_t* gt = wasm_globaltype_new(wasm_valtype_new(WASM_I32), mut);
wasm_val_t val = WASM_I32_VAL(value);
wasm_global_t* g = wasm_global_new(g_store, gt, &val);
CHECK(g, "wasm_global_new failed (host-created globals unsupported?)");
wasm_globaltype_delete(gt);
return g;
}
int main(int argc, char** argv)
{
const char* core_path = argc > 1 ? argv[1] : "/tmp/uce/wasm-phase3/core.wasm";
const char* unit_path = argc > 2 ? argv[2] : "/tmp/uce/wasm-phase3/page.wasm";
g_context = make_context();
wasm_engine_t* engine = wasm_engine_new();
CHECK(engine, "engine");
g_store = wasm_store_new(engine);
CHECK(g_store, "store");
g_store_for_stubs = g_store;
// ---- 1. core module ---------------------------------------------------
std::vector<uint8_t> core_bytes = read_file(core_path);
wasm_byte_vec_t core_bv;
wasm_byte_vec_new(&core_bv, core_bytes.size(), (const char*)core_bytes.data());
Instance core;
core.module = wasm_module_new(g_store, &core_bv);
wasm_byte_vec_delete(&core_bv);
CHECK(core.module, "core module load failed");
// the shared funcref table is host-created (core links with --import-table)
// because WAMR cannot grow a table from the host: size it up front as
// core's declared minimum (= its own elem needs) plus headroom for unit
// module table regions. __table_base allocation bumps from the minimum.
wasm_table_t* table = nullptr;
uint32_t table_next_free = 0;
const uint32_t TABLE_HEADROOM = 2048;
wasm_importtype_vec_t core_imports = WASM_EMPTY_VEC;
wasm_module_imports(core.module, &core_imports);
std::vector<wasm_extern_t*> core_import_externs(core_imports.size);
for(size_t i = 0; i < core_imports.size; i++)
{
const wasm_name_t* mod = wasm_importtype_module(core_imports.data[i]);
const wasm_name_t* nm = wasm_importtype_name(core_imports.data[i]);
const wasm_externtype_t* et = wasm_importtype_type(core_imports.data[i]);
std::string name(nm->data, nm->size);
if(wasm_externtype_kind(et) == WASM_EXTERN_TABLE)
{
CHECK(name.rfind("__indirect_function_table", 0) == 0,
"unexpected core table import %s", name.c_str());
const wasm_tabletype_t* tt = wasm_externtype_as_tabletype_const(et);
uint32_t core_min = wasm_tabletype_limits(tt)->min;
wasm_limits_t limits = { core_min + TABLE_HEADROOM, core_min + TABLE_HEADROOM };
wasm_tabletype_t* host_tt = wasm_tabletype_new(wasm_valtype_new(WASM_FUNCREF), &limits);
table = wasm_table_new(g_store, host_tt, nullptr);
CHECK(table, "wasm_table_new failed (host-created tables unsupported?)");
wasm_tabletype_delete(host_tt);
table_next_free = core_min;
printf("host table created: size=%u, core region=[0,%u)\n", core_min + TABLE_HEADROOM, core_min);
core_import_externs[i] = wasm_table_as_extern(table);
continue;
}
CHECK(wasm_externtype_kind(et) == WASM_EXTERN_FUNC,
"core has unexpected non-func import %.*s.%s",
(int)mod->size, mod->data, name.c_str());
const wasm_functype_t* ft = wasm_externtype_as_functype_const(et);
wasm_func_t* import_func = nullptr;
std::string mod_name(mod->data, mod->size);
while(!mod_name.empty() && mod_name.back() == '\0') mod_name.pop_back();
while(!name.empty() && name.back() == '\0') name.pop_back();
if(mod_name == "env" && name == "uce_host_ctx_read")
import_func = wasm_func_new_with_env(g_store, ft, host_ctx_read, nullptr, nullptr);
else if(mod_name == "env" && name == "uce_host_log")
import_func = wasm_func_new_with_env(g_store, ft, host_log, nullptr, nullptr);
else
{
// named trap stub; leaks the name string, fine for a spike
char* label = strdup((mod_name + "." + name).c_str());
import_func = wasm_func_new_with_env(g_store, ft, stub_callback, label, nullptr);
}
CHECK(import_func, "stub func creation failed for %s", name.c_str());
core_import_externs[i] = wasm_func_as_extern(import_func);
}
CHECK(table, "core does not import __indirect_function_table — rebuild with --import-table");
wasm_extern_vec_t core_iv = { core_import_externs.size(), core_import_externs.data() };
wasm_trap_t* trap = nullptr;
core.instance = wasm_instance_new(g_store, core.module, &core_iv, &trap);
report_trap(trap, "core instantiation");
CHECK(core.instance, "core instantiation failed");
core.index_exports();
printf("core instantiated: %zu exports\n", core.by_name.size());
// reactor init (runs ctors). Wasmtime does not auto-run _initialize;
// WAMR does — and calling it twice trips wasi-libc's double-init guard
// (__builtin_trap → "unreachable"), which cost us a debugging round.
if(core.func("_initialize"))
call_i32(core, "_initialize");
wasm_memory_t* memory = wasm_extern_as_memory(core.by_name.count("memory") ? core.by_name["memory"] : nullptr);
CHECK(memory, "core does not export memory");
g_memory = memory;
// ---- 2./3. dylink + base allocation ------------------------------------
std::vector<uint8_t> unit_bytes = read_file(unit_path);
DylinkInfo dl = parse_dylink(unit_bytes);
CHECK(dl.found, "unit has no dylink.0 mem_info");
printf("dylink.0: memsize=%u memalign=2^%u tablesize=%u\n", dl.mem_size, dl.mem_align, dl.table_size);
uint32_t align = 1u << dl.mem_align;
uint32_t raw = (uint32_t)call_i32(core, "malloc", { (int32_t)(dl.mem_size + align) });
CHECK(raw, "core malloc returned 0");
uint32_t memory_base = (raw + (align - 1)) & ~(align - 1);
uint32_t table_base = table_next_free;
table_next_free += dl.table_size;
CHECK(table_next_free <= wasm_table_size(table), "table headroom exhausted");
printf("bases: __memory_base=%u __table_base=%u (table size %u)\n",
memory_base, table_base, wasm_table_size(table));
// ---- 4. unit import resolution -----------------------------------------
wasm_byte_vec_t unit_bv;
wasm_byte_vec_new(&unit_bv, unit_bytes.size(), (const char*)unit_bytes.data());
Instance unit;
unit.module = wasm_module_new(g_store, &unit_bv);
wasm_byte_vec_delete(&unit_bv);
CHECK(unit.module, "unit module load failed");
wasm_importtype_vec_t unit_imports = WASM_EMPTY_VEC;
wasm_module_imports(unit.module, &unit_imports);
std::vector<wasm_extern_t*> unit_import_externs(unit_imports.size);
// GOT.mem entries that must be self-resolved from the unit's own exports
// after instantiation (weak data the core does not define)
std::vector<std::pair<std::string, wasm_global_t*>> deferred_got;
for(size_t i = 0; i < unit_imports.size; i++)
{
const wasm_name_t* mod_n = wasm_importtype_module(unit_imports.data[i]);
const wasm_name_t* nm_n = wasm_importtype_name(unit_imports.data[i]);
std::string mod(mod_n->data, mod_n->size);
std::string nm(nm_n->data, nm_n->size);
const wasm_externtype_t* et = wasm_importtype_type(unit_imports.data[i]);
wasm_externkind_t kind = wasm_externtype_kind(et);
wasm_extern_t* resolved = nullptr;
if(mod == "env" && nm == "memory")
resolved = wasm_memory_as_extern(memory);
else if(mod == "env" && nm == "__indirect_function_table")
resolved = wasm_table_as_extern(table);
else if(mod == "env" && nm == "__stack_pointer")
{
CHECK(core.global("__stack_pointer"), "core does not export __stack_pointer");
resolved = core.by_name["__stack_pointer"];
}
else if(mod == "env" && nm == "__memory_base")
resolved = wasm_global_as_extern(make_i32_global((int32_t)memory_base, WASM_CONST));
else if(mod == "env" && nm == "__table_base")
resolved = wasm_global_as_extern(make_i32_global((int32_t)table_base, WASM_CONST));
else if(mod == "env" && kind == WASM_EXTERN_FUNC)
{
auto it = core.by_name.find(nm);
CHECK(it != core.by_name.end(), "unresolved unit func import env.%s", nm.c_str());
resolved = it->second;
}
else if(mod == "GOT.mem")
{
wasm_global_t* src = core.global(nm.c_str());
if(src)
{
wasm_val_t v;
wasm_global_get(src, &v);
resolved = wasm_global_as_extern(make_i32_global(v.of.i32, WASM_VAR));
}
else
{
// provisional 0; patched from the unit's own export post-instantiation
wasm_global_t* g = make_i32_global(0, WASM_VAR);
deferred_got.push_back({ nm, g });
resolved = wasm_global_as_extern(g);
}
}
else if(mod == "GOT.func")
{
// resolved guest-side: the core exports a helper returning
// (intptr_t)&func, which on wasm is the function's table index —
// no host-side funcref injection needed (WAMR forbids it anyway)
std::string helper = "core_table_index_of_" + nm;
CHECK(core.func(helper.c_str()), "no GOT.func resolver %s in core", helper.c_str());
int32_t slot = call_i32(core, helper.c_str());
CHECK(slot > 0, "GOT.func resolver %s returned %d", helper.c_str(), slot);
printf("resolved GOT.func.%s = table[%d]\n", nm.c_str(), slot);
resolved = wasm_global_as_extern(make_i32_global(slot, WASM_VAR));
}
CHECK(resolved, "unhandled unit import %s.%s (kind %d)", mod.c_str(), nm.c_str(), (int)kind);
unit_import_externs[i] = resolved;
}
// ---- 5. instantiate unit, patch GOT, run init ---------------------------
wasm_extern_vec_t unit_iv = { unit_import_externs.size(), unit_import_externs.data() };
trap = nullptr;
unit.instance = wasm_instance_new(g_store, unit.module, &unit_iv, &trap);
report_trap(trap, "unit instantiation");
CHECK(unit.instance, "unit instantiation failed");
unit.index_exports();
printf("unit instantiated: %zu exports\n", unit.by_name.size());
for(auto& [nm, got] : deferred_got)
{
wasm_global_t* own = unit.global(nm.c_str());
CHECK(own, "GOT.mem.%s defined neither by core nor by unit", nm.c_str());
wasm_val_t v;
wasm_global_get(own, &v);
// dylink ABI: a PIC module's exported data symbols are offsets relative
// to its __memory_base; the linker must add the base when resolving
wasm_val_t nv = WASM_I32_VAL((int32_t)(memory_base + (uint32_t)v.of.i32));
wasm_global_set(got, &nv);
printf("self-resolved GOT.mem.%s = %u (offset %d)\n", nm.c_str(), memory_base + (uint32_t)v.of.i32, v.of.i32);
}
if(unit.func("__wasm_apply_data_relocs"))
call_i32(unit, "__wasm_apply_data_relocs");
if(unit.func("__wasm_call_ctors"))
call_i32(unit, "__wasm_call_ctors");
// ---- 6. membrane prepare, render and read back ---------------------------
int32_t rc = call_i32(core, "uce_phase3_prepare");
CHECK(rc == 0, "uce_phase3_prepare returned %d", rc);
int32_t request_ptr = call_i32(core, "uce_phase3_request");
CHECK(request_ptr != 0, "core returned null Request*");
call_i32(unit, "__uce_set_current_request", { request_ptr });
call_i32(unit, "__uce_render", { request_ptr });
call_i32(core, "uce_phase3_finish");
int32_t out_ptr = call_i32(core, "uce_phase3_output_data");
int32_t out_len = call_i32(core, "uce_phase3_output_size");
byte_t* mem = wasm_memory_data(memory); // may have moved if memory grew
printf("---- phase3 unit output (%d bytes) ----\n%.*s\n--------------------------------------\n",
out_len, out_len, mem + out_ptr);
bool ok = out_len > 0 &&
memmem(mem + out_ptr, out_len, "PHASE3 PAGE OK", 14) &&
memmem(mem + out_ptr, out_len, "host=phase3.example.test", 24) &&
memmem(mem + out_ptr, out_len, "answer=42", 9) &&
memmem(mem + out_ptr, out_len, "each=answer:42", 14) &&
memmem(mem + out_ptr, out_len, "self-got=42", 11) &&
memmem(mem + out_ptr, out_len, "map=wise", 8) &&
memmem(mem + out_ptr, out_len, "callback=42", 11) &&
memmem(mem + out_ptr, out_len, "got-func-ok", 11) &&
memmem(mem + out_ptr, out_len, "fn=42", 5);
printf(ok ? "PHASE3 EXIT CRITERION: PASS\n" : "PHASE3 EXIT CRITERION: FAIL (see output above)\n");
return ok ? 0 : 1;
}

View File

@ -0,0 +1,12 @@
// Phase 3 generated-unit fixture source. The checked-in generated/page.uce.cpp
// is the UCE-preprocessor-shaped output used by the wasm side-module build.
RENDER(Request& context)
{
<><section class="phase3">
<h1>PHASE3 PAGE OK</h1>
<p>host=<?= context.params["HTTP_HOST"] ?></p>
<p>route=<?= context.call["route"].to_string() ?></p>
<p>answer=<?= context.call["nested"]["answer"].to_string() ?></p>
</section>
}

View File

@ -0,0 +1,77 @@
# spikes/wasm-phase4 — production mechanics kill-test spike
Phase 4 validates the operational mechanics needed before a WASM worker can be
trusted in production. This spike is intentionally smaller than the future UCE
worker integration: it uses tiny WAT modules instead of generated UCE units, but
it exercises the Wasmtime controls and workspace cleanup paths the worker will
use. It is also deliberately the first spike on the Wasmtime-specific C++ API
(`wasmtime.hh`) — fuel, epochs, store limiters, and snapshots do not exist in
the portable `wasm.h` surface, so runtime-agnosticism ends here by design.
Run on `k-uce`:
```bash
bash spikes/wasm-phase4/build_runner.sh
/tmp/uce/wasm-phase4/runner
```
Expected final line:
```text
PHASE4 EXIT CRITERION: PASS
```
What this proves:
- **Reusable compiled artifact / snapshot proxy:** modules are compiled once and
then instantiated in fresh stores. Not OS CoW yet, but it validates the shape
of "shared core artifact + per-request workspace birth".
- **Unharmed worker:** after all six kill cases, the same engine serves a
healthy request that completes with the expected value. This — not merely
"didn't crash" — is the worker half of the Phase 4 exit criterion.
- **CPU limits, both mechanisms:** fuel (deterministic, per-instruction cost)
and epoch interruption (near-zero overhead, ticker thread, traps as
`interrupt`). Production default is **epoch** per the Phase 0 findings; fuel
is validated as the deterministic fallback. Note: the engine epoch only
advances, so every store must set its own deadline.
- **Memory limit, actually load-bearing:** the OOM fixture grows by 10 pages —
within its declared max (100) — so only the store limiter (2 pages) can deny
the growth. The denial is observed by the guest (`memory.grow` → -1) and
converted to a trap.
- **Trap-to-error-page data path:** every kill case's gate asserts the captured
message contains a `wasm backtrace` and the expected cause. Fixture frames
show `<unknown>` — readable production traces require units to keep their
name section (or a symbolication side-file in the artifact cache).
- **Trace summarizer (`src/lib/wasm_trace.h`):** trap messages are rendered
through the production collapse facility (repeated frames → `×N` lines,
mangled symbols demangled, cause/detail split out). The stack-exhaustion
case gates it on live trap output here; `site/tests/core.uce` gates the
parsing on canned messages in the native suite.
- **Handle cleanup with falsifiable checks:** closers must run exactly once per
handle and *while the store is still alive* (production closers may flush
guest-resident state); destructor-only cleanup fails the ordering check.
Kill fixtures (all genuinely trap):
- `unreachable`: `__builtin_trap` analog.
- `oob-access`: wild pointer; load at 128 KiB from a 64 KiB memory.
- `stack-exhaustion`: runaway recursion → `call stack exhausted`.
- `infinite-loop-fuel` / `infinite-loop-epoch`: same loop, both CPU limits.
- `oom-limiter`: limiter-denied `memory.grow` → guest converts -1 to a trap.
**A literal C++ null-pointer dereference is deliberately absent:** address 0 is
valid wasm linear memory, so `*(int*)nullptr` does not trap — it silently
writes inside the workspace, which is then dropped at request end (still
strictly better than a native SIGSEGV). See WASM-PROPOSAL §10 for the recorded
risk/policy.
Still deferred to production Phase 4:
- real core snapshot + OS-level CoW birth;
- wiring `wasm_trace.h` summaries into the UCE error-page UI (the summarizer
itself is done and gated); name-section policy for unit artifacts;
- wiring these traps into `linux_fastcgi.cpp` / the WASM worker backend;
- real runtime handle-table cleanup for sqlite/mysql/sockets/tasks;
- artifact ABI versioning end-to-end;
- memory-limit policy for guest allocators that return failure instead of
trapping (bump allocator vs dlmalloc behavior under the limiter).

View File

@ -0,0 +1,16 @@
#!/bin/bash
# Phase 4: build the Wasmtime kill-test runner.
set -e
cd "$(dirname "$0")"
OUT=/tmp/uce/wasm-phase4
mkdir -p "$OUT"
c++ -std=c++17 -O2 -pthread runner.cpp \
-I/opt/wasmtime/include \
-L/opt/wasmtime/lib \
-Wl,-rpath,/opt/wasmtime/lib \
-lwasmtime \
-o "$OUT/runner"
echo "built: $OUT/runner"

View File

@ -0,0 +1,298 @@
// WASM-PROPOSAL Phase 4 — production-mechanics kill-test spike.
//
// Uses Wasmtime's C++ API directly (this spike is deliberately where the
// portable wasm.h surface ends: fuel, epochs, store limiters, and snapshots
// are Wasmtime-specific by nature; the runtime was selected in Phase 0).
//
// Validates, per kill case, that: the guest trap is captured with a wasm
// backtrace, the workspace handle closers run exactly once and while the
// store is still alive, and — after all kills — the same engine still serves
// a healthy request (the actual meaning of "unharmed worker").
//
// Note: a literal C++ null-pointer dereference does NOT trap in wasm —
// address 0 is valid linear memory; the damage stays inside the dropped
// workspace (see WASM-PROPOSAL §10). The kill fixtures below therefore use
// faults that genuinely trap: unreachable (__builtin_trap analog), an
// out-of-bounds access (wild pointer), stack exhaustion (runaway recursion),
// fuel/epoch exhaustion (infinite loop), and a limiter-denied memory.grow.
#include <wasmtime.hh>
#include <chrono>
#include <iostream>
#include <string>
#include <thread>
#include <vector>
#include "../../src/lib/wasm_trace.h"
using namespace wasmtime;
// Handle-table proxy. The checks here are deliberately falsifiable: closers
// must run exactly once per handle, and they must run while the store (and
// thus guest memory) is still alive — production closers may need to flush
// guest-resident state. A destructor-only cleanup fails the ordering check.
struct Workspace
{
int handles_open = 0;
int handles_closed = 0;
bool cleanup_ran = false;
bool closed_while_store_alive = false;
void open_handles(int count)
{
handles_open = count;
}
void cleanup(bool store_alive)
{
if(cleanup_ran)
return;
cleanup_ran = true;
closed_while_store_alive = store_alive;
for(int i = 0; i < handles_open; i++)
handles_closed++;
}
~Workspace()
{
cleanup(false);
}
bool ok() const
{
return(cleanup_ran && handles_closed == handles_open && closed_while_store_alive);
}
};
static Module compile_wat(Engine& engine, const std::string& wat)
{
auto module = Module::compile(engine, wat);
if(!module)
{
std::cerr << "compile failed: " << module.err_ref().message() << "\n";
exit(1);
}
return module.ok();
}
struct RunResult
{
bool trapped = false;
std::string message;
bool cleanup_ok = false;
bool has_value = false;
int32_t value = 0;
};
static RunResult run_case(Engine& engine, const Module& module, uint64_t fuel, int64_t memory_limit, bool epoch_ticker = false)
{
RunResult result;
Workspace workspace;
workspace.open_handles(2);
{
Store store(engine);
store.limiter(memory_limit, -1, -1, -1, -1);
Store::Context cx(store);
// epoch_interruption is enabled engine-wide and the engine epoch only
// advances; every store needs a deadline beyond the current epoch or
// it traps immediately. The epoch kill case gets a deadline of 1 tick
// and a ticker thread; everything else gets effectively-unbounded.
cx.set_epoch_deadline(epoch_ticker ? 1 : 1'000'000'000);
auto fuel_result = cx.set_fuel(fuel);
if(!fuel_result)
{
result.trapped = true;
result.message = fuel_result.err_ref().message();
workspace.cleanup(true);
result.cleanup_ok = workspace.ok();
return result;
}
auto instance = Instance::create(cx, module, {});
if(!instance)
{
result.trapped = true;
result.message = instance.err_ref().message();
workspace.cleanup(true);
result.cleanup_ok = workspace.ok();
return result;
}
auto run_export = instance.ok_ref().get(cx, "run");
if(!run_export || !std::get_if<Func>(&*run_export))
{
result.trapped = true;
result.message = "missing run export";
workspace.cleanup(true);
result.cleanup_ok = workspace.ok();
return result;
}
std::thread ticker;
if(epoch_ticker)
ticker = std::thread([&engine] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
engine.increment_epoch();
});
Func run = *std::get_if<Func>(&*run_export);
auto call = run.call(cx, std::vector<Val>{});
if(ticker.joinable())
ticker.join();
if(!call)
{
result.trapped = true;
result.message = call.err_ref().message();
}
else
{
result.message = "completed without trap";
auto values = call.ok();
if(!values.empty())
{
result.has_value = true;
result.value = values[0].i32();
}
}
workspace.cleanup(true);
result.cleanup_ok = workspace.ok();
}
return result;
}
static void print_case(const char* label, const RunResult& result)
{
std::cout << "--- " << label << " ---\n";
std::cout << "trapped=" << (result.trapped ? "yes" : "no") << "\n";
if(result.trapped)
std::cout << "summary:\n" << wasm_trace_collapse(result.message) << "\n";
else
std::cout << "message=" << result.message << "\n";
std::cout << "cleanup=" << (result.cleanup_ok ? "ok" : "failed") << "\n";
}
static void check_kill(const char* label, const RunResult& result, const char* expect_in_message)
{
print_case(label, result);
bool has_trace = result.message.find("wasm backtrace") != std::string::npos;
bool has_cause = result.message.find(expect_in_message) != std::string::npos;
if(!result.trapped || !result.cleanup_ok || !has_trace || !has_cause)
{
std::cerr << "PHASE4 EXIT CRITERION: FAIL at " << label
<< (result.trapped ? "" : " (no trap)")
<< (result.cleanup_ok ? "" : " (cleanup)")
<< (has_trace ? "" : " (no backtrace)")
<< (has_cause ? "" : " (wrong cause)") << "\n";
exit(1);
}
}
static void check_healthy(const char* label, const RunResult& result)
{
print_case(label, result);
if(result.trapped || !result.cleanup_ok || !result.has_value || result.value != 42)
{
std::cerr << "PHASE4 EXIT CRITERION: FAIL at " << label << " (worker harmed)\n";
exit(1);
}
}
int main()
{
Config config;
config.consume_fuel(true);
config.epoch_interruption(true);
Engine engine(std::move(config));
// Compiled modules are reused across stores: a deliberately small proxy
// for the future core snapshot — per-request workspaces (stores) are
// born fresh while compilation work is shared.
// __builtin_trap analog; a real C++ *nullptr does not trap (see header)
Module unreachable_module = compile_wat(engine, R"wat(
(module
(func (export "run")
unreachable))
)wat");
// wild/OOB pointer: load at 128 KiB from a 64 KiB memory
Module oob_module = compile_wat(engine, R"wat(
(module
(memory 1 1)
(func (export "run")
(drop (i32.load (i32.const 131072)))))
)wat");
// runaway recursion → call stack exhausted
Module stack_module = compile_wat(engine, R"wat(
(module
(func $f (export "run")
(call $f)))
)wat");
Module loop_module = compile_wat(engine, R"wat(
(module
(func (export "run")
(loop br 0)))
)wat");
// grows by 10 pages: within the module's own declared max (100), so only
// the store limiter (2 pages) can deny it — this proves the limiter is
// load-bearing, not the declared max
Module oom_module = compile_wat(engine, R"wat(
(module
(memory 1 100)
(func (export "run")
i32.const 10
memory.grow
i32.const -1
i32.eq
(if (then unreachable))))
)wat");
Module healthy_module = compile_wat(engine, R"wat(
(module
(func (export "run") (result i32)
i32.const 42))
)wat");
const int64_t MEM_LIMIT = 2 * 65536;
const uint64_t FUEL = 10'000;
const uint64_t FUEL_PLENTY = 1'000'000'000'000;
check_kill("unreachable", run_case(engine, unreachable_module, FUEL, MEM_LIMIT), "unreachable");
check_kill("oob-access", run_case(engine, oob_module, FUEL, MEM_LIMIT), "out of bounds");
RunResult stack_result = run_case(engine, stack_module, FUEL_PLENTY, MEM_LIMIT);
check_kill("stack-exhaustion", stack_result, "call stack exhausted");
// the trace formatter (src/lib/wasm_trace.h) is part of the gate: the
// recursion frames (Wasmtime caps the displayed backtrace at 20) must
// collapse to a bounded summary
WasmTraceSummary stack_summary = wasm_trace_summarize(stack_result.message);
if(!stack_summary.parsed || stack_summary.total_frames < 10
|| stack_summary.frames.size() > 12
|| stack_summary.cause.find("call stack exhausted") == std::string::npos
|| wasm_trace_format(stack_summary).find("×") == std::string::npos)
{
std::cerr << "PHASE4 EXIT CRITERION: FAIL at trace-collapse (parsed="
<< stack_summary.parsed << " total=" << stack_summary.total_frames
<< " lines=" << stack_summary.frames.size()
<< " cause=" << stack_summary.cause << ")\n";
exit(1);
}
std::cout << "--- trace-collapse ---\n" << stack_summary.total_frames
<< " raw frames -> " << stack_summary.frames.size() << " summary lines\n";
check_kill("infinite-loop-fuel", run_case(engine, loop_module, FUEL, MEM_LIMIT), "fuel");
// Wasmtime reports epoch-deadline traps as "interrupt"
check_kill("infinite-loop-epoch", run_case(engine, loop_module, FUEL_PLENTY, MEM_LIMIT, true), "interrupt");
check_kill("oom-limiter", run_case(engine, oom_module, FUEL, MEM_LIMIT), "unreachable");
// after every kill above, the same engine must still serve a request —
// this is the "unharmed worker" half of the Phase 4 exit criterion
check_healthy("healthy-after-kills", run_case(engine, healthy_module, FUEL, MEM_LIMIT));
std::cout << "PHASE4 EXIT CRITERION: PASS\n";
return 0;
}

View File

@ -0,0 +1,45 @@
# spikes/wasm-phase5 — parity, audit, and performance harness
Phase 5's production exit requires the full network suite to pass on the WASM
worker and performance numbers to be published. The production worker is not in
this branch yet, so this spike builds the Phase 5 harness and records the native
baseline that the WASM worker must match.
Run on `k-uce`:
```bash
bash spikes/wasm-phase5/run_phase5.sh
```
Expected final line:
```text
PHASE5 HARNESS: PASS
```
Artifacts are written under `/tmp/uce/wasm-phase5/`:
- `native-network.json` — full native network suite result.
- `native-starter.json` — starter-focused parity subset; this protects against
a vacuous `--match starter` exit gate.
- `site-static-audit.{json,md}` — candidate cross-request/static-state risks in
`site/` for the §3.2 semantic change audit.
- `benchmark.{json,md}` — warmed native baseline for the three Phase 5 budget
pages:
- `template-heavy-doc`: `/doc/singlepage.uce`
- `sqlite-page`: `/demo/sqlite.uce`
- `component-heavy-starter`: `/examples/uce-starter/?dashboard`
`benchmark.py` also accepts `--wasm-base-url` once a WASM worker endpoint exists.
When provided, it compares WASM medians against the Phase 5 budget of ≤2× native
page latency. Workspace birth and internal component call overhead budgets still
need worker-internal probes; this harness documents the gap rather than faking
those numbers.
Current scope:
- Native parity and baseline collection are automated.
- WASM parity/performance comparison is ready but blocked on the production WASM
worker endpoint.
- The static-state audit is heuristic and intentionally conservative; findings
must be reviewed by a human before migration work is scheduled.

View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Phase 5 audit for cross-request/static state risks in site files."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SITE = ROOT / "site"
PATTERNS = [
("static local/global", "static "),
("once hook", "ONCE("),
("init hook", "INIT("),
("background task", "task_"),
]
SKIP_PARTS = {".git", "tmp", "work", "bin", "pkg", "__pycache__"}
@dataclass
class Finding:
path: str
line: int
kind: str
text: str
note: str
def iter_files() -> list[Path]:
result: list[Path] = []
for path in SITE.rglob("*"):
if not path.is_file():
continue
if any(part in SKIP_PARTS for part in path.parts):
continue
if path.suffix not in {".uce", ".h", ".txt"}:
continue
result.append(path)
return sorted(result)
def note_for(kind: str, text: str) -> str:
if kind in {"once hook", "init hook"}:
return "Audit behavior under per-request wasm workspaces; ONCE/INIT may need host-side cache semantics if used for cross-request state."
if kind == "background task":
return "Task APIs cross request lifetimes; verify they are host handles, not guest statics."
return "Check whether state is request-local, immutable, or intentionally persistent; unit statics reset per wasm workspace."
def scan() -> list[Finding]:
findings: list[Finding] = []
for path in iter_files():
try:
lines = path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
continue
for lineno, line in enumerate(lines, 1):
stripped = line.strip()
if stripped.startswith("//") or stripped.startswith("# "):
continue
for kind, needle in PATTERNS:
if needle in line:
findings.append(Finding(
path=str(path.relative_to(ROOT)),
line=lineno,
kind=kind,
text=stripped[:180],
note=note_for(kind, stripped),
))
return findings
def write_reports(findings: list[Finding], out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "site-static-audit.json").write_text(json.dumps([asdict(f) for f in findings], indent=2) + "\n")
lines = ["# Phase 5 site static-state audit", ""]
if not findings:
lines.append("No candidate cross-request/static-state patterns found.")
else:
lines.extend(["| file | line | kind | code | note |", "|---|---:|---|---|---|"])
for f in findings:
code = f.text.replace("|", "\\|")
note = f.note.replace("|", "\\|")
lines.append(f"| {f.path} | {f.line} | {f.kind} | `{code}` | {note} |")
(out_dir / "site-static-audit.md").write_text("\n".join(lines) + "\n")
def main() -> int:
out_dir = Path("/tmp/uce/wasm-phase5")
findings = scan()
write_reports(findings, out_dir)
print(f"Found {len(findings)} candidate static/cross-request patterns")
print(f"wrote {out_dir / 'site-static-audit.json'} and {out_dir / 'site-static-audit.md'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

153
spikes/wasm-phase5/benchmark.py Executable file
View File

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Phase 5 native/WASM benchmark harness.
This runner intentionally has no third-party dependencies. It records a warmed
native HTTP baseline now and can compare against a future wasm worker by passing
--wasm-base-url. Results are written as JSON plus a small Markdown table.
"""
from __future__ import annotations
import argparse
import http.client
import json
import statistics
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from urllib.parse import urlparse
@dataclass
class Target:
name: str
url: str
expected: str
@dataclass
class Measurement:
backend: str
target: str
url: str
ok: bool
status: int
samples_ms: list[float]
median_ms: float
mean_ms: float
min_ms: float
max_ms: float
note: str = ""
def request_once(base_url: str, host_header: str, path: str, timeout: float) -> tuple[int, bytes, float]:
parsed = urlparse(base_url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise SystemExit(f"invalid base url: {base_url}")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
headers = {}
if host_header:
headers["Host"] = host_header
started = time.perf_counter()
conn = conn_cls(parsed.hostname, port, timeout=timeout)
try:
conn.request("GET", path, headers=headers)
response = conn.getresponse()
body = response.read()
elapsed_ms = (time.perf_counter() - started) * 1000.0
return response.status, body, elapsed_ms
finally:
conn.close()
def measure_backend(backend: str, base_url: str, host_header: str, targets: list[Target], warmups: int, samples: int, timeout: float) -> list[Measurement]:
results: list[Measurement] = []
for target in targets:
path = urlparse(target.url).path
if "?" in target.url:
path = target.url
for _ in range(warmups):
request_once(base_url, host_header, target.url, timeout)
durations: list[float] = []
status = 0
ok = True
note = ""
for _ in range(samples):
status, body, elapsed_ms = request_once(base_url, host_header, target.url, timeout)
durations.append(elapsed_ms)
text = body.decode("utf-8", errors="replace")
if status != 200:
ok = False
note = f"HTTP {status}"
elif target.expected and target.expected not in text:
ok = False
note = f"missing marker {target.expected!r}"
results.append(Measurement(
backend=backend,
target=target.name,
url=target.url,
ok=ok,
status=status,
samples_ms=durations,
median_ms=statistics.median(durations),
mean_ms=statistics.fmean(durations),
min_ms=min(durations),
max_ms=max(durations),
note=note,
))
return results
def compare(results: list[Measurement]) -> list[str]:
native = {r.target: r for r in results if r.backend == "native"}
lines = ["| target | backend | median ms | mean ms | budget | status |", "|---|---:|---:|---:|---|---|"]
for result in results:
budget = "baseline"
status = "PASS" if result.ok else "FAIL"
if result.backend != "native" and result.target in native:
limit = native[result.target].median_ms * 2.0
budget = f"{limit:.1f} ms"
if result.median_ms > limit:
status = "FAIL"
lines.append(f"| {result.target} | {result.backend} | {result.median_ms:.1f} | {result.mean_ms:.1f} | {budget} | {status} {result.note} |")
return lines
def build_targets() -> list[Target]:
return [
Target("template-heavy-doc", "/doc/singlepage.uce", "UCE API"),
Target("sqlite-page", "/demo/sqlite.uce", "SQLite"),
Target("component-heavy-starter", "/examples/uce-starter/?dashboard", "Dashboard"),
]
def main() -> int:
parser = argparse.ArgumentParser(description="Phase 5 native/wasm benchmark harness")
parser.add_argument("--native-base-url", default="http://localhost:80")
parser.add_argument("--wasm-base-url", default="", help="optional wasm worker URL; omitted until worker exists")
parser.add_argument("--host-header", default="uce.openfu.com")
parser.add_argument("--warmups", type=int, default=2)
parser.add_argument("--samples", type=int, default=5)
parser.add_argument("--timeout", type=float, default=10.0)
parser.add_argument("--out-dir", default="/tmp/uce/wasm-phase5")
args = parser.parse_args()
targets = build_targets()
results = measure_backend("native", args.native_base_url, args.host_header, targets, args.warmups, args.samples, args.timeout)
if args.wasm_base_url:
results.extend(measure_backend("wasm", args.wasm_base_url, args.host_header, targets, args.warmups, args.samples, args.timeout))
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "benchmark.json").write_text(json.dumps([asdict(r) for r in results], indent=2) + "\n")
md_lines = ["# Phase 5 benchmark report", "", *compare(results), ""]
if not args.wasm_base_url:
md_lines.append("WASM worker URL was not provided; this report is the native baseline that future wasm runs compare against.")
(out_dir / "benchmark.md").write_text("\n".join(md_lines) + "\n")
print("\n".join(md_lines))
print(f"wrote {out_dir / 'benchmark.json'} and {out_dir / 'benchmark.md'}")
return 0 if all(r.ok for r in results) else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,31 @@
#!/bin/bash
# Phase 5 harness: native parity, site static audit, and native perf baseline.
# Run on k-uce from repo root.
set -euo pipefail
cd "$(dirname "$0")/../.."
OUT=/tmp/uce/wasm-phase5
mkdir -p "$OUT"
python3 tests/run_network_tests.py --include-internal --json-report "$OUT/native-network.json"
python3 tests/run_network_tests.py --include-internal --match starter --json-report "$OUT/native-starter.json"
python3 spikes/wasm-phase5/audit_site_statics.py
python3 spikes/wasm-phase5/benchmark.py --out-dir "$OUT"
python3 - <<'PY'
import json
from pathlib import Path
out = Path('/tmp/uce/wasm-phase5')
network = json.loads((out / 'native-network.json').read_text())
starter = json.loads((out / 'native-starter.json').read_text())
bench = json.loads((out / 'benchmark.json').read_text())
failures = [r for r in network if not r['ok']] + [r for r in starter if not r['ok']] + [r for r in bench if not r['ok']]
if failures:
print('PHASE5 HARNESS: FAIL')
for failure in failures:
print(failure)
raise SystemExit(1)
print('PHASE5 HARNESS: PASS')
print(f"network_cases={len(network)} starter_cases={len(starter)} benchmarks={len(bench)}")
print(f"reports={out}")
PY

View File

@ -4,6 +4,7 @@
#include "types.h"
#include "hash.h"
#include "functionlib.h"
#include "wasm_trace.h"
#include "sys.h"
#include "uri.h"
#include "cli.h"

226
src/lib/wasm_trace.h Normal file
View File

@ -0,0 +1,226 @@
#pragma once
// Host-side summarizer for Wasmtime trap/error messages (WASM-PROPOSAL
// Phase 4: trap → error-page path). Turns a raw runtime message — which for
// stack exhaustion contains hundreds of identical frames — into a compact,
// error-page-ready summary: headline cause, optional detail (e.g. faulting
// address), and a backtrace with consecutive repeated frames collapsed.
//
// Pure string processing with no wasm-runtime dependency, so it is testable
// from the native suite and reusable by both the phase-4 spike runner and
// the future wasm worker. Tolerant of unrecognized input: an unparsed
// message is passed through as the cause, never dropped.
#include <cstdlib>
#include <string>
#include <vector>
#if defined(__GNUG__)
#include <cxxabi.h>
#endif
struct WasmTraceSummary
{
std::string cause; // "wasm trap: ..." headline, or the raw message if unparsed
std::string detail; // secondary cause lines, e.g. "memory fault at wasm address ..."
std::vector<std::string> frames; // collapsed display lines
size_t total_frames = 0;
bool parsed = false; // a wasm backtrace section was recognized
};
inline std::string wasm_trace_demangle(const std::string& name)
{
#if defined(__GNUG__)
// frame names look like "module!symbol"; demangle the symbol part
auto bang = name.find('!');
std::string prefix = bang == std::string::npos ? "" : name.substr(0, bang + 1);
std::string symbol = bang == std::string::npos ? name : name.substr(bang + 1);
if(symbol.rfind("_Z", 0) == 0)
{
int status = 0;
char* demangled = abi::__cxa_demangle(symbol.c_str(), 0, 0, &status);
if(status == 0 && demangled)
{
std::string result = prefix + demangled;
free(demangled);
return(result);
}
if(demangled)
free(demangled);
}
#endif
return(name);
}
inline WasmTraceSummary wasm_trace_summarize(const std::string& message, size_t max_frame_lines = 12)
{
WasmTraceSummary summary;
struct Frame
{
std::string name;
std::string address;
};
std::vector<Frame> raw_frames;
std::vector<std::string> cause_lines;
auto trim = [](const std::string& s) {
size_t a = s.find_first_not_of(" \t\r");
if(a == std::string::npos)
return(std::string());
size_t b = s.find_last_not_of(" \t\r");
return(s.substr(a, b - a + 1));
};
// a frame or numbered-cause line starts with "N:"; strip that prefix
auto strip_index = [&](const std::string& line, bool& had_index) {
had_index = false;
size_t i = 0;
while(i < line.size() && line[i] >= '0' && line[i] <= '9')
i++;
if(i == 0 || i >= line.size() || line[i] != ':')
return(line);
had_index = true;
return(trim(line.substr(i + 1)));
};
bool in_backtrace = false;
bool in_caused_by = false;
size_t pos = 0;
while(pos <= message.size())
{
size_t eol = message.find('\n', pos);
std::string line = trim(message.substr(pos, (eol == std::string::npos ? message.size() : eol) - pos));
pos = eol == std::string::npos ? message.size() + 1 : eol + 1;
if(line.empty())
continue;
if(line.find("wasm backtrace") != std::string::npos)
{
summary.parsed = true;
in_backtrace = true;
in_caused_by = false;
continue;
}
if(line.rfind("Caused by", 0) == 0)
{
in_backtrace = false;
in_caused_by = true;
continue;
}
bool had_index = false;
std::string body = strip_index(line, had_index);
if(in_backtrace && had_index)
{
Frame frame;
// optional "0xADDR - " prefix before the frame name
if(body.rfind("0x", 0) == 0)
{
size_t dash = body.find(" - ");
if(dash != std::string::npos)
{
frame.address = body.substr(0, dash);
body = trim(body.substr(dash + 3));
}
}
frame.name = wasm_trace_demangle(body);
raw_frames.push_back(frame);
continue;
}
if(in_caused_by || line.rfind("wasm trap:", 0) == 0)
{
cause_lines.push_back(body);
continue;
}
}
summary.total_frames = raw_frames.size();
// headline = the "wasm trap:" line; everything else in Caused by → detail
for(auto& line : cause_lines)
{
if(line.rfind("wasm trap:", 0) == 0)
summary.cause = line;
else
summary.detail += (summary.detail.empty() ? "" : "; ") + line;
}
if(summary.cause.empty())
summary.cause = summary.parsed && !cause_lines.empty() ? cause_lines.front() : trim(message);
// collapse consecutive identical frame names
struct Group
{
std::string name;
std::string address;
size_t first = 0;
size_t count = 0;
};
std::vector<Group> groups;
for(size_t i = 0; i < raw_frames.size(); i++)
{
if(!groups.empty() && groups.back().name == raw_frames[i].name)
{
groups.back().count++;
continue;
}
Group group;
group.name = raw_frames[i].name;
group.address = raw_frames[i].address;
group.first = i;
group.count = 1;
groups.push_back(group);
}
auto group_line = [](const Group& g) {
std::string line;
if(g.count == 1)
{
line = "#" + std::to_string(g.first);
if(!g.address.empty())
line += " " + g.address;
line += " " + g.name;
}
else
{
line = "#" + std::to_string(g.first) + "..#" + std::to_string(g.first + g.count - 1)
+ " " + g.name + " ×" + std::to_string(g.count);
}
return(line);
};
if(groups.size() <= max_frame_lines)
{
for(auto& g : groups)
summary.frames.push_back(group_line(g));
}
else
{
// keep the top of the stack and the outermost group
for(size_t i = 0; i + 2 < max_frame_lines && i < groups.size(); i++)
summary.frames.push_back(group_line(groups[i]));
summary.frames.push_back("... " + std::to_string(groups.size() - (max_frame_lines - 1)) + " more frame groups ...");
summary.frames.push_back(group_line(groups.back()));
}
return(summary);
}
inline std::string wasm_trace_format(const WasmTraceSummary& summary)
{
std::string out = summary.cause;
if(!summary.detail.empty())
out += "\n " + summary.detail;
if(!summary.frames.empty())
{
out += "\nbacktrace (" + std::to_string(summary.total_frames) + " frame" + (summary.total_frames == 1 ? "" : "s") + "):";
for(auto& line : summary.frames)
out += "\n " + line;
}
return(out);
}
inline std::string wasm_trace_collapse(const std::string& message, size_t max_frame_lines = 12)
{
return(wasm_trace_format(wasm_trace_summarize(message, max_frame_lines)));
}

View File

@ -0,0 +1,66 @@
# Parity harness for the uce-starter example app.
#
# WASM-PROPOSAL Phase 3/5 exit criterion: `run_network_tests.py --match starter`
# must pass against the wasm worker backend. These cases are written against
# the native backend first so the bar exists — and is green — before the wasm
# worker does. Keep assertions backend-agnostic (rendered content only), so
# the same cases gate both backends unchanged.
ERROR_MARKERS = [
"uce compile error",
"fatal signal during request",
"uncaught exception during request",
]
BASE = "/examples/uce-starter/index.uce"
# (case name suffix, query-string route, expected <title>)
VIEWS = [
("landing", "", "Home | UCE Starter"),
("dashboard", "?dashboard", "Dashboard | UCE Starter"),
("gauges", "?gauges", "Gauges | UCE Starter"),
("features", "?features", "Features | UCE Starter"),
("components", "?page1", "Components | UCE Starter"),
("workspace", "?workspace", "Workspace | UCE Starter"),
]
def _check_body(body, title):
body_lower = body.lower()
for marker in ERROR_MARKERS:
if marker in body_lower:
raise Exception("response body contained error marker %r" % marker)
needle = "<title>%s</title>" % title
if needle not in body:
raise Exception("expected %r in response body" % needle)
def _make_view_case(name, query, title):
def run(context):
response = context.expect_status(BASE + query, 200)
_check_body(response.text, title)
return "rendered %s with expected title and no error markers" % name
return run
def _unknown_route_case(context):
# the starter app must render its own 404 view through the app shell,
# not fall through to a bare server error
response = context.expect_status(BASE + "?does-not-exist", 404)
_check_body(response.text, "404 Not Found | UCE Starter")
return "starter app rendered its own 404 page"
def register(registry):
for name, query, title in VIEWS:
registry.case(
"starter view " + name,
_make_view_case(name, query, title),
tags=["http", "uce", "starter", "parity"],
)
registry.case(
"starter unknown route 404",
_unknown_route_case,
tags=["http", "uce", "starter", "parity"],
)