-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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
base: main
Are you sure you want to change the base?
Conversation
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. |
bd7981c
to
88e7b7f
Compare
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/
There was a problem hiding this 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.
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); | ||
}); |
There was a problem hiding this comment.
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?
|
||
/// Whether or not to show information about exception tables. | ||
#[arg(long, require_equals = true, value_name = "true|false")] | ||
exception_tables: Option<Option<bool>>, |
There was a problem hiding this comment.
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) -> ! { |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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...
}
}
unsafe { | ||
store.store_opaque_mut().throw_ref(exnref); | ||
} |
There was a problem hiding this comment.
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>( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes -- it reads two u32
s for lengths of arrays, then builds an ExceptionTable
with slices over arrays.
// 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()])?; | ||
|
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)
/// 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() | ||
} |
There was a problem hiding this comment.
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
?
There was a problem hiding this 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?
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![]; |
There was a problem hiding this comment.
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.
fn caller_vmctx(&self) -> ir::Value { | ||
self.builder | ||
.func | ||
.special_param(ArgumentPurpose::VMContext) | ||
.unwrap() | ||
} |
There was a problem hiding this comment.
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:
wasmtime/crates/cranelift/src/lib.rs
Lines 145 to 160 in 1155d6d
/// 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 { |
There was a problem hiding this comment.
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?
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!() | ||
} | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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>( |
There was a problem hiding this comment.
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.
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;
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 ( (Still on PTO but will respond occasionally to keep review moving) |
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 providesthrow
andthrow_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.