299 lines
9.2 KiB
C++
299 lines
9.2 KiB
C++
// 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;
|
||
}
|