C++ Exception Handling Test Plan for WASM Host
Goal
Build a minimal standalone WASM binary (wasm-eh-tests.wasm) that exercises specific
C++ EH patterns, and a corresponding Go test file (gonix/wasm/eh_test.go) that loads
it and asserts correct behaviour from our host implementation.
No Lix library dependency — just libc++ / libc++abi / Emscripten's EH runtime.
Test WASM binary
Source file
lix/lix/libexpr/wasm-eh-tests.cc
Each test function has the signature:
extern "C" int32_t eh_test_NAME(char * buf, int32_t max);
Return value: 0 = success, -1 = failure.
On failure, buf is written with a null-terminated error message.
On success, buf may be written with a result string for assertion.
Test cases
| Function | Pattern | Expected behaviour |
|---|---|---|
eh_test_simple |
throw / catch |
Catch std::runtime_error, write its .what() to buf, return 0 |
eh_test_rethrow |
throw / catch / throw; / outer catch |
Inner catch rethrows; outer catch gets it, writes msg, return 0 |
eh_test_catch_hierarchy |
throw DerivedError / catch BaseError & |
Catch via base-class ref (exercises __cxa_can_catch), return 0 |
eh_test_nested_try |
Nested try blocks, inner throws, inner catches | Inner catch handles it; outer never fires; return 0 |
eh_test_exception_ptr |
std::current_exception() / std::rethrow_exception() |
Capture in ptr inside catch, rethrow outside, catch again, return 0 |
eh_test_dtor_throw |
Exception thrown during stack unwind (destructor throws while another exception is active) | std::terminate is called; our host must NOT loop infinitely; the call returns a wazero error |
eh_test_multi_catch |
throw matched against multiple catch clauses |
Correct clause selected, others skipped, return 0 |
eh_test_catch_and_resume |
throw / catch / store exception / re-enter function / no exception |
Exception state cleaned up after catch; subsequent call with no throw works normally |
Type hierarchy for tests
struct Base : public std::exception { const char* what() const noexcept override; };
struct Derived : public Base { const char* what() const noexcept override; };
struct Other : public std::exception { const char* what() const noexcept override; };
Meson target
Add inside the if wasm_eval_only block in lix/meson.build:
executable(
'wasm-eh-tests',
files('libexpr/wasm-eh-tests.cc'),
include_directories : [ '..' ],
cpp_args : cpp_args,
link_args : [
'-sSTANDALONE_WASM=1',
'-fexceptions',
'--no-entry',
'-sUSE_PTHREADS=0',
'-sEXPORTED_FUNCTIONS=_eh_test_simple,_eh_test_rethrow,_eh_test_catch_hierarchy,_eh_test_nested_try,_eh_test_exception_ptr,_eh_test_dtor_throw,_eh_test_multi_catch,_eh_test_catch_and_resume,_malloc,_free',
'-g3',
'-sASSERTIONS=1',
'-sERROR_ON_UNDEFINED_SYMBOLS=0',
'-sSTACK_SIZE=1048576',
'-sALLOW_MEMORY_GROWTH=1',
'-sINITIAL_MEMORY=16777216',
],
name_suffix : 'wasm',
install : false,
)
No liblix dependency — only links Emscripten's libc++ / libc++abi.
Go test file
Location
gonix/wasm/eh_test.go
Helpers
// loadEHTestRuntime loads wasm-eh-tests.wasm and returns a minimal runtime
// with only the "env" host module (no lix_store, no nix_init needed).
func loadEHTestRuntime(t *testing.T) (*Runtime, context.Context)
// callEHTest calls eh_test_NAME(bufPtr, 256) and returns (retval, bufString).
func callEHTest(t *testing.T, rt *Runtime, ctx context.Context, name string) (int32, string)
Test functions
func TestEH_Simple(t *testing.T)
// Calls eh_test_simple, expects retval=0, buf="caught: simple error"
func TestEH_Rethrow(t *testing.T)
// Calls eh_test_rethrow, expects retval=0, buf="outer caught: rethrown"
func TestEH_CatchHierarchy(t *testing.T)
// Calls eh_test_catch_hierarchy, expects retval=0, buf="base caught: derived error"
// Verifies __cxa_can_catch works for inheritance.
func TestEH_NestedTry(t *testing.T)
// Calls eh_test_nested_try, expects retval=0, buf="inner caught"
// Verifies nested try/catch doesn't corrupt state.
func TestEH_ExceptionPtr(t *testing.T)
// Calls eh_test_exception_ptr, expects retval=0, buf="rethrown: captured error"
// This is the exact pattern that caused std::terminate in derivation eval.
func TestEH_DtorThrow(t *testing.T)
// Calls eh_test_dtor_throw.
// We do NOT expect retval=0 — std::terminate fires.
// We expect: the call returns a Go error (wazero trap), no infinite loop,
// process does not hang.
// The test passes if the call returns within 5 seconds.
func TestEH_MultiCatch(t *testing.T)
// Calls eh_test_multi_catch, expects retval=0, buf="matched: Other"
// Verifies correct clause selection among multiple typed catch blocks.
func TestEH_CatchAndResume(t *testing.T)
// Calls eh_test_catch_and_resume twice.
// First call: triggers the exception path, expects retval=0, buf="exception handled"
// Second call: no exception, expects retval=0, buf="no exception"
// Verifies exception state is fully cleaned up between calls.
WASM binary path
The test file looks for the WASM at:
../../build-wasm/lix/wasm-eh-tests.wasm
(relative to gonix/wasm/). If the file does not exist, tests are skipped with
t.Skip("wasm-eh-tests.wasm not built").
Build and run
# Build just the test WASM (fast, no full Lix rebuild):
meson compile -C build-wasm wasm-eh-tests.wasm
# Run the Go EH tests:
cd gonix && go test ./wasm/ -run TestEH_ -v
Priority order
Implement in this order — each test is independently useful:
eh_test_simple+TestEH_Simple— baseline, confirms basic throw/catch workseh_test_rethrow+TestEH_Rethrow— confirms__cxa_rethrowprotocoleh_test_catch_hierarchy+TestEH_CatchHierarchy— confirms__cxa_can_catcheh_test_exception_ptr+TestEH_ExceptionPtr— the critical failing caseeh_test_multi_catch+TestEH_MultiCatch— confirms type dispatcheh_test_nested_try+TestEH_NestedTry— confirms state isolationeh_test_catch_and_resume+TestEH_CatchAndResume— confirms cleanupeh_test_dtor_throw+TestEH_DtorThrow— confirms no infinite loop