// 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 #include #include #include #include #include #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(&*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(&*run_export); auto call = run.call(cx, std::vector{}); 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; }