Skip to content

BaseException raised in host import callback aborts the process (libmalloc) instead of propagating #336

Description

@aallan

Summary

When a host import callback raises a BaseException subclass that is not also an Exception subclass (e.g. KeyboardInterrupt, SystemExit, custom BaseException subclasses), the Python process aborts inside Rust's HostFunc::array_call_trampoline with a libmalloc "pointer being freed was not allocated" SIGABRT, instead of the exception cleanly propagating back through the Func.__call__ boundary.

The root cause is in wasmtime/_func.py: the trampoline function catches Exception rather than BaseException, so BaseException subclasses bypass the trap-conversion path and escape into the C/Rust side, leaving the trampoline's c_void_p return value undefined. Rust then dereferences/frees that bogus value.

Environment

  • wasmtime-py 44.0.0 (latest on PyPI as of filing)
  • Python 3.14.3 (homebrew, macOS)
  • macOS 15.7.3 on Apple Silicon (arm64)
  • I expect this is platform-independent — the bug is in pure-Python _func.py and the Rust ABI contract; macOS just gives the loudest crash because of libmalloc's poison-page guard.

Minimal reproducer

import wasmtime

wat = """
(module
  (import "host" "fn" (func $host_fn))
  (func (export "run")
    call $host_fn))
"""

def host_fn() -> None:
    raise KeyboardInterrupt

engine = wasmtime.Engine()
store = wasmtime.Store(engine)
linker = wasmtime.Linker(engine)
linker.define_func("host", "fn", wasmtime.FuncType([], []), host_fn)

module = wasmtime.Module(engine, wat)
instance = linker.instantiate(store, module)
run = instance.exports(store)["run"]
assert isinstance(run, wasmtime.Func)

try:
    run(store)
except KeyboardInterrupt:
    print("propagated cleanly (expected)")

Run with faulthandler to see the abort:

$ python -X faulthandler repro.py
Exception ignored while calling ctypes callback function <function trampoline at 0x...>:
Traceback (most recent call last):
  File ".../wasmtime/_func.py", line 199, in trampoline
    pyresults = func(*pyparams)
  File "repro.py", line 13, in host_fn
    raise KeyboardInterrupt
KeyboardInterrupt:
Fatal Python error: Aborted

Current thread's C stack trace (most recent call first):
  ...
  ___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED+0x20
  wasmtime::runtime::func::HostFunc::array_call_trampoline+0x1c8
  wasmtime::runtime::func::Func::call_unchecked_raw+0x164
  wasmtime_func_call+0x1a4
  ffi_call_SYSV
  _ctypes_callproc
  ...

Bisect

Same setup, varying the host_fn body:

Raised in host_fn Result
raise KeyboardInterrupt SIGABRT (libmalloc)
raise SystemExit(0) SIGABRT (libmalloc)
Custom class X(BaseException): pass; raise X SIGABRT (libmalloc)
raise RuntimeError("boom") propagates as Trap cleanly
raise ValueError("boom") propagates as Trap cleanly
return None runs normally

So the crash is determined entirely by whether the raised class is a subclass of Exception. Anything strictly under BaseException (and not also under Exception) escapes the handler.

Root cause — one-line fix

wasmtime/_func.py lines 188–218:

@ffi.wasmtime_func_callback_t
def trampoline(idx, caller, params, nparams, results, nresults):  # type: ignore
    caller = Caller(caller)
    try:
        func, result_tys, access_caller = FUNCTIONS.get(idx or 0)
        # ... call user func, marshal results ...
        return 0
    except Exception as e:                    # <-- HERE: should be BaseException
        global LAST_EXCEPTION
        LAST_EXCEPTION = e
        trap = Trap("python exception")._consume()
        return cast(trap, c_void_p).value
    finally:
        caller._invalidate()

Suggested change:

    except BaseException as e:

That's enough to make KeyboardInterrupt / SystemExit round-trip through wasmtime as a Trap and re-raise on the Python side via the existing LAST_EXCEPTION mechanism, instead of escaping into Rust with an undefined return value.

Why this matters in practice

Vera (a WASM-targeting language whose runtime uses wasmtime-py) hit this in production: a long-running compiled program calls a host import that wraps time.sleep, and Ctrl+C during the sleep raises KeyboardInterrupt inside the host callback. The user sees the Python process die with Abort trap: 6 instead of a clean ^C. Any host import that does I/O is exposed to this — Ctrl+C is a normal user action.

Our local workaround is to convert KeyboardInterrupt to a custom Exception subclass in every host callback before letting it propagate, which the trampoline then catches. But that has to be done in every host import, in every project — the upstream fix is much better.

Suggested test

def test_host_callback_keyboard_interrupt_does_not_abort():
    # build a module whose run() calls a host import that raises KeyboardInterrupt
    # assert KeyboardInterrupt (or Trap, depending on chosen propagation semantics)
    # is observed at the Python level rather than the process aborting

Happy to send a PR if the maintainers agree on the desired propagation semantics:

  • Option A (minimum change): catch BaseException instead of Exception. KeyboardInterrupt shows up on the Python side as a Trap with the original exception attached via LAST_EXCEPTION, just like any other Python exception today.
  • Option B: catch BaseException but special-case KeyboardInterrupt / SystemExit to re-raise at the wasmtime call boundary as their original type. More user-friendly — ^C actually feels like ^C — but a slightly bigger change.

I'd weakly prefer Option B but Option A is strictly better than the status quo and is one line.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions