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
+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;
}