Skip to content

WIP: initial WebAssembly exception-handling support. #11326

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

cfallin
Copy link
Member

@cfallin cfallin commented Jul 25, 2025

This PR introduces support for the Wasm exception-handling proposal, which introduces a conventional try/catch mechanism to WebAssembly. The PR supports modules that use try_table to register handlers for a lexical scope; and provides throw and throw_ref that allocate (in the first case) and throw exception objects.

This PR builds on top of the work in #10510 for Cranelift-level exception support, #10919 for an unwinder, and #11230 for exception objects built on top of GC, in addition a bunch of smaller fix and enabling PRs around those.

This PR does not yet provide host-boundary-crossing exceptions; exceptions that are not caught in a given Wasm activation become traps at the host boundary. That support will come in a subsequent PR.

Because exceptions do not yet cross the host boundary, this also does not yet enable the assert_exception wast directive, and so cannot yet support the spec-tests. That will also come in a subsequent PR.

@cfallin
Copy link
Member Author

cfallin commented Jul 25, 2025

Logistical note: I'm posting this as a draft now to get early feedback and because I know folks are waiting to see how it is shaping up. I'm on vacation for two weeks starting now (back Mon Aug 11) and will plan to polish then. I'm hoping to actually get host-boundary integration built as well, if I can, in this PR, to enable spec-tests, but if that turns out to be too much then it will come right after. Following that, fuzzing is the only piece that remains, I think.

@cfallin cfallin force-pushed the wasm-exceptions branch 5 times, most recently from bd7981c to 88e7b7f Compare July 26, 2025 02:01
This PR introduces support for the [Wasm exception-handling proposal],
which introduces a conventional try/catch mechanism to WebAssembly. The
PR supports modules that use `try_table` to register handlers for a
lexical scope; and provides `throw` and `throw_ref` that allocate (in
the first case) and throw exception objects.

This PR builds on top of the work in bytecodealliance#10510 for Cranelift-level
exception support, bytecodealliance#10919 for an unwinder, and bytecodealliance#11230 for exception
objects built on top of GC, in addition a bunch of smaller fix and
enabling PRs around those.

This PR does not yet provide host-boundary-crossing exceptions;
exceptions that are not caught in a given Wasm activation become traps
at the host boundary. That support will come in a subsequent PR.

Because exceptions do not yet cross the host boundary, this also does
not yet enable the `assert_exception` wast directive, and so cannot yet
support the spec-tests. That will also come in a subsequent PR.

[Wasm exception-handling proposal]: https://github.com/WebAssembly/exception-handling/
@github-actions github-actions bot added cranelift Issues related to the Cranelift code generator cranelift:area:aarch64 Issues related to AArch64 backend. labels Jul 26, 2025
Copy link
Member

@alexcrichton alexcrichton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left some high-level thoughts here and there, but definitely feel free to defer anything to issues as you feel appropriate.

Comment on lines +497 to +504
let exception_section = obj.add_section(
obj.segment_name(StandardSegment::Data).to_vec(),
ELF_WASMTIME_EXCEPTIONS.as_bytes().to_vec(),
SectionKind::ReadOnlyData,
);
exception_tables.serialize(|bytes| {
obj.append_section_data(exception_section, bytes, 1);
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be skipped if the exception tables section is empty?

Comment on lines +69 to +72

/// Whether or not to show information about exception tables.
#[arg(long, require_equals = true, value_name = "true|false")]
exception_tables: Option<Option<bool>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind adding a tests/disas test for this showing the exception tables?

///
/// Must be invoked when Wasm is in the stack and control has
/// re-entered the runtime.
pub unsafe fn throw(nogc: &mut AutoAssertNoGc, exnref: VMExnRef) -> ! {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be made safe and return an action to perform rather than performing the action? I get jittery skipping Rust frames nowadays and I feel it works best when this propagates upwards as far as it can the action to take before actually taking the action. One example is that with Pulley this can't return ! because it'll need to propagate to the interpreter loop to update interpreter state to perform the pseudo-longjmp


let (handler_tag_instance, handler_tag_index) = unsafe {
InstanceAndStore::from_vmctx(frame_vmctx, |instance| {
let (instance, store) = instance.unpack_mut();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the long-term I think we'll want to handle this differently because store here aliases nogc without the compiler knowing. This is probably fine for now but if this sticks around can you file an issue to improve this in the future?

One possible idea would be to move the InstanceId into the VMContext and to read that out and then lookup through nogc the InstanceId. Another option would be to add a helper method to Instance which takes the store and the VMContext and returns Pin<&mut Instance> connected to the lifetime of the store, avoiding creating a second store pointer and requiring the store has nothing else borrowed at the same time.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One possible idea would be to move the InstanceId into the VMContext and to read that out and then lookup through nogc the InstanceId. Another option would be to add a helper method to Instance which takes the store and the VMContext and returns Pin<&mut Instance> connected to the lifetime of the store, avoiding creating a second store pointer and requiring the store has nothing else borrowed at the same time.

Another variant on this idea: we could also have a method on the store that takes a vmctx and gives an instance, something like

impl StoreOpaque {
    // Safety: vmctx must be a valid pointer
    pub unsafe fn instance_from_vmctx(&mut self, vmctx: *mut VMContext) -> Option<Pin<&mut Instance>> {
        // Validate that the vmctx is from this store and all that...
    }
}

Comment on lines +1562 to +1564
unsafe {
store.store_opaque_mut().throw_ref(exnref);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure whether it's viable, but personally what I'd ideally like to see is the request-to-throw propagated even through the libcall here through the Result. That'd likely require some finesse and refactoring but I suspect such refactoring is going to be required no matter what for Pulley support eventually.

In lieu of that though I'd ideally like to see the "do the throw" action no higher than here, so my comment below about threading the action-to-do would be handled around here. (although I could also be missing other places where it's acted upon)

/// Borrow the GC layout for teh given index's type.
/// Returns `None` for types that do not have GC layouts (i.e. function
/// types).
pub fn with_layout<R>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you expand the documentation here that this should be used judiciously and specifically mention that the closure f is executed under a read lock? That'll help communicate that this can block for awhile waiting for a write lock to finish and additionally this is blocking all writers at the same time.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this used? I don't actually see it used anywhere and C-f isn't finding anything.

Also, it is probably better, in terms of horizontal scalability, to clone the layout out of the registry than to hold the lock for the duration of whatever code needs to work with it. If that is something we need to do often, we can look into making that cheaper, eg by storing layouts in an Arc or something.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, this is left over from an earlier implementation that fetched layouts dynamically before I had put the tag info at a constant offset in exception objects. (Or rather, it was always at a constant offset per allocator, but I hadn't done the plumbing that array-lengths have to avoid getting it from the layout.)

I'll remove this; but to clarify, we have no need to access layouts or the type registry at all at runtime with the exceptions implementation. The unwinder is completely generic over exception layouts and only compares tag identities.

/// Obtain an exception-table parser on this module's exception metadata.
#[cfg(feature = "gc")]
pub(crate) fn exception_table<'a>(&'a self) -> ExceptionTable<'a> {
ExceptionTable::parse(self.inner.code.code_memory().exception_tables())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To confirm, parse here is a pretty cheap operation?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes -- it reads two u32s for lengths of arrays, then builds an ExceptionTable with slices over arrays.

Comment on lines +189 to +193
// Ensure that the exception table is well-formed. This parser
// construction is cheap: it reads the header and validates
// ranges but nothing else.
let _ = ExceptionTable::parse(&mmap[exception_data.clone()])?;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be done with a #[cfg(debug_assertions)] perhaps? That'll help avoid paging this in if necessary and by construction this should always be true as a result of compilation such that it shouldn't be required to check here too.

@@ -2212,6 +2217,13 @@ impl Config {
bail!("wmemcheck (memory checker) was requested but is not enabled in this build");
}

#[cfg(feature = "gc")]
if self.enabled_features.contains(WasmFeatures::EXCEPTIONS)
&& self.disabled_features.contains(WasmFeatures::GC)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be best to switch to GC_TYPES instead of GC since that's the true reliance here, there's no need to enable structs/arrays to enable exceptions too. (just that under the hood exceptions is implemented with GC types)

Comment on lines +814 to +824
/// The offset of the `vmctx` field.
#[inline]
pub fn vmtag_import_vmctx(&self) -> u8 {
1 * self.pointer_size()
}

/// The offset of the `index` field.
#[inline]
pub fn vmtag_import_index(&self) -> u8 {
2 * self.pointer_size()
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind double-checking there are tests for these new offset methods (and the ones below) in vmcontext.rs?

Copy link
Member

@fitzgen fitzgen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shaping up nicely!

We had talked elsewhere about removing the exception composite type variant and having tags refer to just their function type (but we would now optionally have a GC struct layout for a function type's parameters for use with exceptions). This would better align us with the Wasm spec and make it so that there are less new additions to the types registry code and also fewer interactions at runtime with its locking and tables and all that. Are you still planning on pursuing this?

Comment on lines +430 to +435
let mut accesses = vec![];
for (field_ty, field_layout) in exception_ty.fields.iter().zip(exn_layout.fields.iter()) {
accesses.push((field_layout.offset, field_ty.element_type));
}

let mut result = vec![];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might as well smallvec![] these temporaries.

Comment on lines +1713 to +1718
fn caller_vmctx(&self) -> ir::Value {
self.builder
.func
.special_param(ArgumentPurpose::VMContext)
.unwrap()
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is correct? I think we mark our own (callee) vmctx as "the" vmctx for Cranelift and the caller's vmctx is the second argument that follows afterwards. For example:

/// Get the Cranelift signature for all array-call functions, that is:
///
/// ```ignore
/// unsafe extern "C" fn(
/// callee_vmctx: *mut VMOpaqueContext,
/// caller_vmctx: *mut VMOpaqueContext,
/// values_ptr: *mut ValRaw,
/// values_len: usize,
/// )
/// ```
///
/// This signature uses the target's default calling convention.
///
/// Note that regardless of the Wasm function type, the array-call calling
/// convention always uses that same signature.
fn array_call_signature(isa: &dyn TargetIsa) -> ir::Signature {

Copy link
Member Author

@cfallin cfallin Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in the context of a Call (codegen for a callsite), so the "caller" is the current function; and this change is pulling out an existing expression that was bound as let caller_vmctx = ... (see the diff in unchecked_call_impl for example). Happy to rename it to something else, but callee_vmctx would be inaccurate/misleading in this context, IMHO. our_vmctx maybe?

Comment on lines +18 to +38
impl VMGcRef {
pub fn into_structref_unchecked(self) -> VMStructRef {
unreachable!()
}

pub fn into_exnref_unchecked(self) -> VMExnRef {
unreachable!()
}
}

impl From<VMStructRef> for VMGcRef {
fn from(_s: VMStructRef) -> VMGcRef {
unreachable!()
}
}

impl From<VMExnRef> for VMGcRef {
fn from(_e: VMExnRef) -> VMGcRef {
unreachable!()
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than dynamically assert that these are unreachable at runtime, we can statically assert that they are unreachable at compile time via

impl From<VMStructRef> for VMGcRef {
    fn from(s: VMStructRef) -> VMGcRef {
        match s {}
    }
}

etc...

This is nice because any mistakes are caught at compile time.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good call; the first two (methods on VMGcRef) can't follow that pattern because VMGcRef is not actually an uninhabitable enum in the GC-disabled build, so I followed that in the From impls as well, but those can follow the empty-match pattern...


let (handler_tag_instance, handler_tag_index) = unsafe {
InstanceAndStore::from_vmctx(frame_vmctx, |instance| {
let (instance, store) = instance.unpack_mut();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One possible idea would be to move the InstanceId into the VMContext and to read that out and then lookup through nogc the InstanceId. Another option would be to add a helper method to Instance which takes the store and the VMContext and returns Pin<&mut Instance> connected to the lifetime of the store, avoiding creating a second store pointer and requiring the store has nothing else borrowed at the same time.

Another variant on this idea: we could also have a method on the store that takes a vmctx and gives an instance, something like

impl StoreOpaque {
    // Safety: vmctx must be a valid pointer
    pub unsafe fn instance_from_vmctx(&mut self, vmctx: *mut VMContext) -> Option<Pin<&mut Instance>> {
        // Validate that the vmctx is from this store and all that...
    }
}

///
/// # Safety
///
/// Must be invoked when Wasm code is on the stack.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and there are no frames on the stack between the Wasm and here that must run to maintain some safety invariant, and cannot be unwound over.

This is ~impossible to determine in arbitrary code, which is why pushing the command-return all the way out to the libcall is nice, since we know there is ~nothing on the stack before the Wasm frames at that point.

/// Borrow the GC layout for teh given index's type.
/// Returns `None` for types that do not have GC layouts (i.e. function
/// types).
pub fn with_layout<R>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this used? I don't actually see it used anywhere and C-f isn't finding anything.

Also, it is probably better, in terms of horizontal scalability, to clone the layout out of the registry than to hold the lock for the duration of whatever code needs to work with it. If that is something we need to do often, we can look into making that cheaper, eg by storing layouts in an Arc or something.

@cfallin
Copy link
Member Author

cfallin commented Jul 29, 2025

We had talked elsewhere about removing the exception composite type variant and having tags refer to just their function type (but we would now optionally have a GC struct layout for a function type's parameters for use with exceptions). This would better align us with the Wasm spec and make it so that there are less new additions to the types registry code and also fewer interactions at runtime with its locking and tables and all that. Are you still planning on pursuing this?

Ah, sorry, I hadn't made a note in the PR message here, but: I tried and abandoned that path. (Or more precisely, having exception layouts hang off of the function type; TagType is already a thin newtype wrapper around FuncType.) At a high level, pulling on that string seemed to unwind way too much structure and hit too many places that really still wanted a concrete type for an exception object. If you're curious, my WIP branch is here (still has many type-errors, mid-refactor). In essence I think that path leads to more complexity, not less, unfortunately.

also fewer interactions at runtime with its locking and tables and all that

The current implementation performs no locking or accesses to the type registry at runtime; it uses the dynamic context mechanism in Cranelift to get straight to the instance, then look up tags (VMTagDefinitions), and compares tag IDs (instance-id/defined-tag-index). In particular, the tag information is at a constant offset in the exception object (similar to array lengths) so we don't need a layout to write the generic path.

(Still on PTO but will respond occasionally to keep review moving)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
cranelift:area:aarch64 Issues related to AArch64 backend. cranelift Issues related to the Cranelift code generator
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants