10 KiB
Phase 0 findings — toolchain & runtime spike
- Status: EXIT CRITERION PASSED (2026-06-12, on k-uce)
- Runtime selected: Wasmtime (v45.0.1, C API). WAMR rejected — evidence below.
- Exceptions decision (§11.1, error codes /
-fno-exceptions) confirmed viable: both the stubs and two real generated units compile with-fno-exceptions, no try/catch blocker anywhere.
The exit criterion ran end-to-end: a core stub (libc/libc++ statically linked,
owns memory/allocator) and a unit stub (PIC, dylink.0) were linked at
runtime by loader.cpp and produced:
hello from unit; unit-data-segment-ok; counter=7; mapsum=3; core-string+unit[cb:42][got-func-ok][fn:42]
core_counter (in linear memory): before=7 after=8
PHASE0 EXIT CRITERION: PASS
which validates, in one render call: unit data-segment relocation
(__memory_base), GOT.mem read and write of a core global, std::string
/std::map in unit code on the core's heap, a heap C++ object created in core
and mutated/read by the unit, function pointers crossing unit→core→unit
through the shared table, GOT.func resolution, and a std::function lambda
allocated in the unit and invoked by core. That is the §3.4 contract
("DValue inside the workspace: no serialization, ever") demonstrated at the
ABI level.
Toolchain pins
| What | Version | Where on k-uce |
|---|---|---|
| wasi-sdk | 33 (clang 22.1.0-wasi-sdk) | /opt/wasi-sdk |
| target triple | wasm32-wasip1 (wasm32-wasi is deprecated) |
— |
| Wasmtime C API | v45.0.1 (prebuilt x86_64-linux release) | /opt/wasmtime |
| WAMR (rejected) | WAMR-2.4.4, built from source | /opt/wamr |
| cmake / ninja | 3.31.6 / 1.12.1 (apt) | — |
Runtime selection: why not WAMR
WAMR was tried first per §9 ("preferred ... use Wasmtime only if blocked"). We are blocked, on the load-bearing requirement itself:
- WAMR's wasm-c-api ignores imported memories and tables. At unit
instantiation it logs
"doesn't support import memories and tables for now, ignore them"(wasm_c_api.c) and gives the instance its own memory/table — which silently destroys the shared-workspace model. - Host-side
wasm_table_grow/wasm_memory_groware explicitly unsupported ("Only allow growing a table via the opcode table.grow"). - Its build banner lists Import/Export of Mutable Globals as unsupported —
the dylink ABI imports
__stack_pointerand everyGOT.*entry as a mutable global.
Its multi-module feature is name-based auto-resolution, not host-orchestrated
dylink (no host-computed __memory_base/__table_base, no GOT). Making WAMR
fit means implementing import binding through the c-api layer and runtime
internals — a runtime-development project, not a patch.
Wasmtime v45.0.1 passed everything on the first run through the standard
wasm.h C API: host-created funcref table shared by both instances, exported
memory imported by the unit, host-created (mutable) globals, cross-instance
export→import wiring. The remaining §9 criteria also favor it: AOT artifacts
(.cwasm precompilation) for the unit cache, epoch interruption for CPU
limits, and built-in copy-on-write memory-image instantiation for the §6 core
snapshot (machinery we'd have had to build ourselves on WAMR).
Trade-off accepted (was already in §10): Rust codebase, heavier to vendor/patch. Pin the release artifact (lib + headers, checksummed) the way sqlite is vendored; building from source stays possible but is not the default path. The C API .so is ~27 MB.
Module build recipe (what build_modules.sh settled on)
Core (non-PIC reactor, owns libc/libc++/allocator):
clang++ --target=wasm32-wasip1 -mexec-model=reactor -O1 -fno-exceptions \
core.cpp -o core.wasm \
-Wl,--export-all -Wl,--import-table \
-Wl,--export=__stack_pointer -Wl,--export=__heap_base \
-Wl,--undefined=_ZTVN10__cxxabiv117__class_type_infoE
Unit (PIC side module):
clang++ --target=wasm32-wasip1 -fPIC -fvisibility=default \
-fvisibility-inlines-hidden -O1 -fno-exceptions -c unit.cpp
wasm-ld -shared --experimental-pic --unresolved-symbols=import-dynamic \
--Bsymbolic unit.o -o unit.wasm --export=<entry>
Hard-won flag findings:
--unresolved-symbols=import-dynamicis required for the side-module link; undefined symbols then becomeenv.*function imports andGOT.*globals exactly per the Emscripten dylink ABI.-fvisibility-inlines-hiddenis mandatory. Without it one libc++ vague-linkage lambda (std::maptree-emplace internals, missing libc++'s usual hide-from-ABI attribute) is emitted as both an export and an import of the unit — a self-import the loader cannot satisfy at instantiation time without lazy-binding trampolines.--Bsymbolicalone did not bind it.- Core symbol closure:
--export-allonly exports what got linked. The unit needed__cxxabiv1::__class_type_info's vtable (RTTI machinery behindstd::function), which the core never references — forced in with--undefined=. The production core needs a closure strategy:--whole-archivefor libc/libc++/libc++abi, or a curated keep-list. The loader also implements the complementary fallback (resolveGOT.memof weak data from the unit's own exports post-instantiation, patching the provisional GOT global). --import-tableon the core + a host-created table is the right shape (see loader notes);--export-table/--growable-tablewas the first attempt and died on WAMR's host-grow limitation, but host-created stays the better design under Wasmtime too: the loader picks table size (core's declared minimum + headroom) before any instantiation.wasm32-wasitriple is deprecated in wasi-sdk 33 → usewasm32-wasip1.
Loader notes (loader.cpp, ~450 lines, standard wasm-c-api)
Sequence proven: instantiate core (45 WASI imports satisfied with named trap
stubs — none was ever called) → parse dylink.0 (mem_info: memsize/align,
tablesize) → __memory_base = call core's exported malloc → __table_base
= bump pointer starting at core's table-import minimum → build the unit's
import vector (memory/table/__stack_pointer shared from core; env.*
functions from core exports; GOT.mem.* as host mutable i32 globals holding
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.memvalues 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_relocsexport to do it). Copying the export verbatim reads/writes core memory at low addresses and renders silently wrong values; the Phase 3 fixture'sself-got/callbackmarkers exist to catch exactly this.GOT.mementries 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 is needed at all (it was WAMR-unsupported; under Wasmtime it would work but the guest-side registry is simpler and runtime-agnostic). The production core should carry a name→funcptr registry (dlsym-shaped) for its API surface. - wasi-libc gotcha:
_initializehas a double-init guard ending in__builtin_trap(). WAMR runs_initializeautomatically at instantiation (so calling it again traps "unreachable"); Wasmtime does not (so you must call it). Cost one debugging round; recorded here so it never costs another. - Export-name
wasm_name_tmay include the trailing NUL insize(WAMR did); trim when indexing exports by name.
Real generated units (delegated grind — full log in realunit-report.md)
site/demo/collections.uce.cpp and hello.uce.cpp (taken verbatim from the
live unit cache at /tmp/uce/work/...) both compile and link as PIC side
modules with dylink.0, no allocator definitions, with only shim-level
intervention. collections.wasm: 42 KB, 52 imports — including exactly the
predicted GOT.mem.context for the global Request*. Friction points that
become Phase 2 work items:
types.hdefines globaloperator new/deletein every unit — must be gated (#ifdef) out of side-module builds; allocator ownership belongs to the core (§3.2: "the one fatal misconfiguration").sys.hincludes<signal.h>→ wasi needs-D_WASI_EMULATED_SIGNAL(+-lwasi-emulated-signalin the core) or an#ifdef __wasm__carve-out; signals/fork/exec/sockets insys.hhave no wasi equivalent and move behind hostcalls anyway (§5.1).- Generated units include
uce_lib.hby absolute path — the preprocessor should emit a logical include so the wasm build can supply its own include order. - Header-inline connector wrappers (MySQL etc.) get pulled into every unit regardless of use; the §3.3 membrane split (thin wasm-side shim, host-side implementation) resolves this and shrinks unit import lists.
- The real-unit compile used the pre-
-fvisibility-inlines-hiddenflag set (parallel work); the final unit flag set above should be used from Phase 2 on.
Implications for the next phases
- Phase 1 (DValue C ABI, native): unaffected by any of this; proceed as written.
- Phase 2 (core module + membrane): add the closure strategy
(whole-archive), the GOT.func name→funcptr registry, the
types.hallocator gate, the signal emulation define, and the preprocessor include change. Compileuce_libwith the core recipe above. - Phase 3 (loader):
loader.cpphere is the skeleton — dylink parsing, base allocation, GOT resolution, and init sequencing are all proven; what remains is the registry/dispatch layer, ABI stamping, and multi-unit placement. - Phase 4: use Wasmtime's epoch interruption for CPU limits and its memory-image/CoW instantiation for the core snapshot rather than building either by hand.
Artifacts (on k-uce, not in git)
/tmp/uce/wasm-phase0/{core.wasm,unit.wasm,loader}— exit-criterion run/tmp/uce/wasm-phase0/realunit/— real-unit compiles + shim tree + inspector dumps/opt/wasi-sdk,/opt/wasmtime,/opt/wamr— toolchains/runtimes