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.
Summary
When a host import callback raises a
BaseExceptionsubclass that is not also anExceptionsubclass (e.g.KeyboardInterrupt,SystemExit, customBaseExceptionsubclasses), the Python process aborts inside Rust'sHostFunc::array_call_trampolinewith a libmalloc "pointer being freed was not allocated" SIGABRT, instead of the exception cleanly propagating back through theFunc.__call__boundary.The root cause is in
wasmtime/_func.py: thetrampolinefunction catchesExceptionrather thanBaseException, soBaseExceptionsubclasses bypass the trap-conversion path and escape into the C/Rust side, leaving the trampoline'sc_void_preturn value undefined. Rust then dereferences/frees that bogus value.Environment
wasmtime-py44.0.0 (latest on PyPI as of filing)_func.pyand the Rust ABI contract; macOS just gives the loudest crash because of libmalloc's poison-page guard.Minimal reproducer
Run with faulthandler to see the abort:
Bisect
Same setup, varying the
host_fnbody:raise KeyboardInterruptraise SystemExit(0)class X(BaseException): pass; raise Xraise RuntimeError("boom")Trapcleanlyraise ValueError("boom")Trapcleanlyreturn NoneSo the crash is determined entirely by whether the raised class is a subclass of
Exception. Anything strictly underBaseException(and not also underException) escapes the handler.Root cause — one-line fix
wasmtime/_func.pylines 188–218:Suggested change:
That's enough to make
KeyboardInterrupt/SystemExitround-trip through wasmtime as aTrapand re-raise on the Python side via the existingLAST_EXCEPTIONmechanism, 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, andCtrl+Cduring the sleep raisesKeyboardInterruptinside the host callback. The user sees the Python process die withAbort trap: 6instead of a clean^C. Any host import that does I/O is exposed to this —Ctrl+Cis a normal user action.Our local workaround is to convert
KeyboardInterruptto a customExceptionsubclass 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
Happy to send a PR if the maintainers agree on the desired propagation semantics:
BaseExceptioninstead ofException.KeyboardInterruptshows up on the Python side as aTrapwith the original exception attached viaLAST_EXCEPTION, just like any other Python exception today.BaseExceptionbut special-caseKeyboardInterrupt/SystemExitto re-raise at the wasmtime call boundary as their original type. More user-friendly —^Cactually 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.