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
+77
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).
+16
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"
+298
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;
}