Skip to content

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

@aallan

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