Stream: git-wasmtime

Topic: wasmtime / issue #11835 Consider non-monomorphized type r...


view this post on Zulip Wasmtime GitHub notifications bot (Oct 10 2025 at 22:37):

cfallin opened issue #11835:

Most of the public API surface for working with Wasm-visible storage in Wasmtime takes an AsContextMut parameter. For random examples, StructRef::field, Memory::data, and Table::get all take either an AsContext/AsContextMut or an Into<StoreContext{,Mut}>.

It makes sense that accessors require "the store" (in some vague sense) to access the linear memories, tables, and GC heap, because all of these units of storage are owned by the store.

However, in the context of the guest-debugging API, it turns out that we need to expose an AsContextMut from the "debug session" handle type in #11826, because without that, while the debug session owns the store, there is no way to actually do anything with GC refs that one reads out, or read data in the linear memories, etc. In other words, regular debugger access will want to get at the guest's data and objects.

Likewise, In #11769 I put accessors for the values on stack (locals and operand stack) directly on a StackView, and this suffices for core Wasm types (ints/floats/vectors) but as soon as one actually wants to examine a GC ref that one reads out, one will want an AsContextMut.

This is possible (and I've done it in the draft #11826 in the latest commit), and I believe it's still sound (one can view a "debugger pause" as like a hostcall; so it morally has access to whatever a Caller has access to, and Caller impls AsContextMut), but I don't like it because it forces over-monomorphization: it means that the whole debugger API gets the T from the Store. It also interacts somewhat poorly with internals: for example, AutoAssertNoGc holds the StoreOpaque, but we actually want to hold the StoreInner<T> if we want to be able to pass out a StoreContextMut.

It seems like we should define traits that morally wrap the StoreOpaque, and are sufficient to get at Wasm-accessible objects (linear memories, tables, GC), since none of those can be dependent on the host-side T anyway; and then add the right impls to make that automatically work as before with AsContextMut-types. Function calls still need the monomorphized StoreContextMut because a Caller can pop out the other end that needs the T. Stated succinctly: access to Wasm data should not need T so is not monomorphized; calls to Wasm code does need T so is monomorphized. Thoughts?

view this post on Zulip Wasmtime GitHub notifications bot (Oct 10 2025 at 22:37):

cfallin commented on issue #11835:

(cc @alexcrichton @fitzgen)

view this post on Zulip Wasmtime GitHub notifications bot (Oct 10 2025 at 22:38):

cfallin commented on issue #11835:

I'll throw out the names AsOpaqueContext / AsOpaqueContextMut with the full expectation that the bikeshed machinery will generate better names than that, if we go this way :-)

view this post on Zulip Wasmtime GitHub notifications bot (Oct 10 2025 at 23:05):

cfallin added the wasmtime:debugging label to Issue #11835.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 10 2025 at 23:05):

cfallin added the wasmtime:api label to Issue #11835.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 13 2025 at 15:08):

alexcrichton commented on issue #11835:

My hunch is that this would not be easy to retrofit onto the preexisting API without redesigning/rethinking most of the rest of it. There's no concept, for example, of a StoreOpaque in Wasmtime's public API. That means that it would only possibly be introduced with these new AsOpaque* traits that would be implemented for various objects. If an API takes a T: AsOpaque*, though, then it probably also wants to take T: AsContext* which implies a number of blanket impls for these traits too. We would then, in theory, want to go back to all the preexisting APIs in Wasmtime and audit them to see if they need opaque-ness or not. Basically my hunch is that this would be a pretty large undertaking and not something we can just tack on relatively easily.

That being said the technical points you're raising are still valid, but they can be solved today. Elsewhere the generic entrypoint with T: AsContextMut is dispatched with a one-liner to something that takes &StoreOpaque or similar. That means that the cost of monomorphization is pretty minimal. Data-structure-wise if the T is irrelevant then there could be struct ThingOpaque which everything is implemented in terms of and struct Thing<T> with a PhantomData<T> or similar. Basically there's various ways to slice and dice the internal implementation to not plumb generics everywhere (which I agree is undesirable, that's the whole point StoreOpaque exists)

This is possible (and I've done it in the draft https://github.com/bytecodealliance/wasmtime/pull/11826 in the latest commit), and I believe it's still sound (one can view a "debugger pause" as like a hostcall; so it morally has access to whatever a Caller has access to, and Caller impls AsContextMut), but I don't like it because it forces over-monomorphization: it means that the whole debugger API gets the T from the Store. It also interacts somewhat poorly with internals: for example, AutoAssertNoGc holds the StoreOpaque, but we actually want to hold the StoreInner<T> if we want to be able to pass out a StoreContextMut.

There's a lot competing here unfortunately. If the user-visible type is AsContextMut, then it's impossible to prevent thing.as_context_mut().gc() which means that AutoAssertNoGc can't be what's stored at-rest anyway. In general providing AsContextMut to a suspended computation is also something we've at least, so far, carefully not had to deal with. Not to say it can't be dealt with, but orthogonal to the monomorphization concerns we'll need to carefully map this out as it's a pretty major new power for embedders.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 13 2025 at 15:53):

cfallin commented on issue #11835:

There's a lot competing here unfortunately. If the user-visible type is AsContextMut, then it's impossible to prevent thing.as_context_mut().gc() which means that AutoAssertNoGc can't be what's stored at-rest anyway. In general providing AsContextMut to a suspended computation is also something we've at least, so far, carefully not had to deal with. Not to say it can't be dealt with, but orthogonal to the monomorphization concerns we'll need to carefully map this out as it's a pretty major new power for embedders.

I was worried about that too -- but the mental breakthrough for me at least (after also writing the initial issue description, so this is new) came with thinking about debug-step yields as hostcalls rather than (say) future yields from a suspended computation. (That's what I meant by "The dynamic store ownership protocol basically works with the safe Rust restrictions there too: one can get at the store only when the Wasm code yields, which is morally like a hostcall that passes a reborrowed &mut Store back." over in this comment on the draft PR -- apologies for thinking spreading across two places!) In essence, think of every breakpoint sequence-point (a ud2/brk/... under the hood) or trapping load or whatever as morally containing an optional call that reborrows the store flowing through the Wasm code. The only difference is in the plumbing: the call happens through a mechanism of redirecting PC on a signal and taking the *mut VMContext from somewhere else (TLS).

In that frame, I don't see this as any different from Caller. However your example of GC does raise one realization for me: this means that every trapping instruction, including sequence points, now needs to be a safepoint to the user-stack-map code. That's doable I think, and once we do that, I believe it's actually fine to have a GC happen while in a debug-step yield and while examining the stack: the GC values on the stack are rooted now, even in the instrumentation slots.

If we agree that makes sense, I don't think we even need to make this distinction in the API for soundness, it's just a monomorphization-minimization thing. I'm actually somewhat ambivalent whether that matters much with the debug host happening mainly on developer platforms (since R/R covers capturing bugs on embedded and that shouldn't require the full instrumentation-based API).

view this post on Zulip Wasmtime GitHub notifications bot (Oct 13 2025 at 18:21):

alexcrichton commented on issue #11835:

Ok yeah that all makes sense to me yeah. I also don't know of anything concretely bad that could happen if the store is accessed while wasm is suspended. I think I'm mostly just scared of it as it's a new "superpower" that we haven't previously provided access to.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 13 2025 at 20:29):

fitzgen commented on issue #11835:

I think I'm mostly just scared of it as it's a new "superpower" that we haven't previously provided access to.

Yes, debuggers can mess with all your internals in potentially surprising and perhaps unpleasant ways. That's unfortunately just how it goes.

But this does highlight that debugging APIs are a kind of "new"/"separate" API from the "regular" embedding API. Another example of this, beyond being able to pause and run host code that potentially mutates linear memory (for example) at non-call program points, is the ability to get and mutate non-exported entities. We want the debugger API to be able to enumerate/access/mutate all Wasm globals, tables, memories, functions, etc... and not only the ones that are explicitly exported. We also might want to allow the debugger APIs to mutate non-mut globals. We definitely want to give the debugger APIs the ability to call into and out of components in ways that would otherwise trap due to the enter/exit component flags.

So given that these debugging APIs really do provide new abilities, I think we should also consider the idea that we might not want to reuse the existing types and methods for our new debugging APIs. Maybe we create Debug{Store,Caller,Context} types and the new debugging APIs explicitly take these things as context instead of a regular Store or whatever? And you only get these via the top-level callback/coroutine debugging APIs we are discussing in https://github.com/bytecodealliance/wasmtime/pull/11826 ? These new types could use a lifetime, perhaps, to capture the invariants around where you are paused and when you can e.g. access a stack frame's locals.

Not actually sure that bifurcating the APIs is the best way forward, but I think it is worth exploring the possibility.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 13 2025 at 20:41):

cfallin commented on issue #11835:

All of that could make a lot of sense, and I am just now realizing that indeed we'll need to have a way to get at at least private memories even for the most basic component-debugging use-case. (I was p1-brained and was assuming the memory would be exported; nope!) That said, I think there is also a kind of less abstracted way to get this:

view this post on Zulip Wasmtime GitHub notifications bot (Oct 13 2025 at 21:42):

fitzgen edited a comment on issue #11835:

All of that could make a lot of sense, and I am just now realizing that indeed we'll need to have a way to get at at least private memories even for the most basic component-debugging use-case. (I was p1-brained and was assuming the memory would be exported; nope!) That said, I think there is also a kind of less abstracted way to get this:

view this post on Zulip Wasmtime GitHub notifications bot (Oct 16 2025 at 22:25):

cfallin closed issue #11835:

Most of the public API surface for working with Wasm-visible storage in Wasmtime takes an AsContextMut parameter. For random examples, StructRef::field, Memory::data, and Table::get all take either an AsContext/AsContextMut or an Into<StoreContext{,Mut}>.

It makes sense that accessors require "the store" (in some vague sense) to access the linear memories, tables, and GC heap, because all of these units of storage are owned by the store.

However, in the context of the guest-debugging API, it turns out that we need to expose an AsContextMut from the "debug session" handle type in #11826, because without that, while the debug session owns the store, there is no way to actually do anything with GC refs that one reads out, or read data in the linear memories, etc. In other words, regular debugger access will want to get at the guest's data and objects.

Likewise, In #11769 I put accessors for the values on stack (locals and operand stack) directly on a StackView, and this suffices for core Wasm types (ints/floats/vectors) but as soon as one actually wants to examine a GC ref that one reads out, one will want an AsContextMut.

This is possible (and I've done it in the draft #11826 in the latest commit), and I believe it's still sound (one can view a "debugger pause" as like a hostcall; so it morally has access to whatever a Caller has access to, and Caller impls AsContextMut), but I don't like it because it forces over-monomorphization: it means that the whole debugger API gets the T from the Store. It also interacts somewhat poorly with internals: for example, AutoAssertNoGc holds the StoreOpaque, but we actually want to hold the StoreInner<T> if we want to be able to pass out a StoreContextMut.

It seems like we should define traits that morally wrap the StoreOpaque, and are sufficient to get at Wasm-accessible objects (linear memories, tables, GC), since none of those can be dependent on the host-side T anyway; and then add the right impls to make that automatically work as before with AsContextMut-types. Function calls still need the monomorphized StoreContextMut because a Caller can pop out the other end that needs the T. Stated succinctly: access to Wasm data should not need T so is not monomorphized; calls to Wasm code does need T so is monomorphized. Thoughts?


Last updated: Dec 06 2025 at 07:03 UTC