phase 5
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user