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.


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.

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

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

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.


gonix/wasm/eh_test.go

// 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)

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.

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 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

Implement in this order — each test is independently useful:

  1. eh_test_simple + TestEH_Simple — baseline, confirms basic throw/catch works
  2. eh_test_rethrow + TestEH_Rethrow — confirms __cxa_rethrow protocol
  3. eh_test_catch_hierarchy + TestEH_CatchHierarchy — confirms __cxa_can_catch
  4. eh_test_exception_ptr + TestEH_ExceptionPtr — the critical failing case
  5. eh_test_multi_catch + TestEH_MultiCatch — confirms type dispatch
  6. eh_test_nested_try + TestEH_NestedTry — confirms state isolation
  7. eh_test_catch_and_resume + TestEH_CatchAndResume — confirms cleanup
  8. eh_test_dtor_throw + TestEH_DtorThrow — confirms no infinite loop