With Wasmtime, what combination of features is needed to allow host -> guest -> host -> (same guest) re-entry for the component model.
We have the host that calls one export of a plugin, which may call a host import, which itself may call a separate export on the same component. Right now we are running into major barriers due to wasm trap: cannot enter component instance
As far as I can tell from reading the latest spec, re-entry SHOULD be possible.
The component model invariant #2 about re-entry is
Components can only be reentered (via component export or thread resumption) when they explicitly block or call a donut wrapped child component. Calls to non-
asyncfunctions do not count as "blocking" nor do non-blocking (async-lowered) calls toasyncfunctions...
With the definition of "block" including stackless coroutine async YIELD and WAIT.
So based on reading this, if marking all the imports and exports async, and awaiting the results of each call in the chain (inside-out), then it should all be fine, as we are blocking. But no dice.
Nothing I've tried works. Yet with jco, I've not run into this issue. Re-entry works exactly as I need.
I have a little experiment repo here. Maybe I'm just missing some simple flag somewhere?
https://github.com/QuantumSegfault/wit-plugin-versioning-experiments/tree/reentry
@Alex Crichton
We've gone sort of back-and-forth over time on the spec about what exactly is reentrant and what exactly is allowed in various locations. The test coverage of the spec tests as-is isn't great which is likely why jco/wasmtime diverge in this case. In talking with Luke recently I think the current conclusion is that things are going to be changed to just allow reentrance, but that's a spec change not made yet nor not implemented yet
Oh...so I've been misreading the current spec?
Any guesstimates as too when this will be resolved? This is a pretty significant issue for our uses.
I was wondering what kinds of damage would be done if I just neuter the may_enter check and simply allow re-entry (except when the instance has already trapped).
Nah I wouldn't say you've been misreading, but I also wouldn't be surprised if wasmtime and the spec disagree here by accident. This is an area where I haven't paid super close attention myself, and the thinking that we should remove reentrance checks is pretty recent (within the last week or two). Once we're agreed on the spec side it shouldn't be too hard to implement in Wasmtime as it's in theory just deleting code
timeline-wise, maybe within a month from now?
Okay. Good to know its at least in the plan.
But maybe you can shed some light on this. Somebody found a solution...
host calls export A (inside Store::run_concurrent), A calls import C, C calls export B. All are marked async in the WIT.
This did not work:
use crate::plugin_host::PluginHost;
wasmtime::component::bindgen!({
path: "../../wit",
imports: { default: store | async }
});
impl PluginImports for PluginHost {}
impl<T: Send> PluginImportsWithStore<T> for PluginHost {
async fn c(accessor: &wasmtime::component::Accessor<T, Self>) -> String {
let p_arc = accessor.with(|mut a| {
let host = a.get();
host.plugin.as_ref().unwrap().upgrade().unwrap()
});
// even explicit .func_b().call_concurrent did not
let result = p_arc.plugin_instance.call_b(accessor).await;
format!("C: {}", result.unwrap())
}
}
But this does! It is maybe not the prettiest thing, but it worked.
use crate::plugin_host::PluginHost;
wasmtime::component::bindgen!({
path: "../../wit",
imports: { default: store | async }
});
impl PluginImports for PluginHost {}
struct CallB {
tx: tokio::sync::oneshot::Sender<wasmtime::Result<String>>,
}
impl<T: Send + 'static> wasmtime::component::AccessorTask<T, PluginHost> for CallB {
async fn run(
self,
accessor: &wasmtime::component::Accessor<T, PluginHost>,
) -> wasmtime::Result<()> {
let p_arc = accessor.with(|mut a| {
let host = a.get();
host.plugin.as_ref().unwrap().upgrade().unwrap()
});
let result = p_arc.plugin_instance.call_b(accessor).await;
let _ = self.tx.send(result);
Ok(())
}
}
impl<T: Send + 'static> PluginImportsWithStore<T> for PluginHost {
async fn c(accessor: &wasmtime::component::Accessor<T, Self>) -> String {
let (tx, rx) = tokio::sync::oneshot::channel();
let _ = accessor.spawn(CallB { tx });
let result = rx.await.unwrap();
format!("C: {}", result.unwrap())
}
}
So I was just doing it a little wrong the whole time??? :man_facepalming:
hm yeah with one of those working and one not working that sounds to me like it's a bug in Wasmtime as those should more-or-less be the same or similar
as a workaround for now though that seems reasonable
QuantumSegfault said:
Okay. Good to know its at least in the plan.
But maybe you can shed some light on this. Somebody found a solution...
Clever solution, it satisfies the Rust borrow rules in the
wasmtimeengine by converting them tofuturesand leveraging the native OS context switching. The one doesn't work because it did not serialize the entire roundtrip as a complex function and called into itself, violating the borrow rules by duplicating&mutreferencesYou could also use
Asyncifythat re-writes the recursions as a brute form of stack-switching, which adds bloat and execution overhead. The new Context-Switching Proposal will provide stack-switchining in theWASMenvironment, but mainly for threads I think: https://github.com/WebAssembly/stack-switching
iKranium said:
Here's how Holochain handled the issue in a Wasmer runtime for a
wasm32-unknown-unknowntarget:
https://docs.rs/holochain_wasmer_guest/latest/holochain_wasmer_guest/
(removed two entirely off-topic messages)
in discussions in DMs, @iKranium clarified that their messages were very much intended to be on topic, including the link to https://docs.rs/holochain_wasmer_guest/latest/holochain_wasmer_guest/. I'll leave it to them to explain how exactly that page relates to this topic or could help with the original question raised
So, more on this.
I was under the impression that the CM was supposed to avoid the "function coloring" problem. But right now, if I want to make my plugin export async (purely to solve the re-entry problem), all the host imports that may transitively call such exports must ALSO be async.
Is this intended? The WIT for the prior examples was:
package test:plugin;
world plugin {
import c: async func() -> string;
export a: async func();
export b: async func() -> string;
}
If I try to make c sync (remove async), I end up just deadlocking awaiting on rx.
I'm actually working on a patch right now to make Wasmtime match https://github.com/WebAssembly/component-model/pull/705, which allows reentrance in all cases, and should avoid the need for the oneshot::channel workaround. And as Alex said, the fact that the oneshot::channel workaround even worked was probably a bug.
I'd recommend re-testing with https://github.com/bytecodealliance/wasmtime/pull/14146 once I've pushed an update.
The general rule is that sync-typed functions are not allowed to block before returning a value, but the host has "super-powers" to do things that wouldn't be allowed in guest-to-guest interactions, so you might be able to get away with a sync-typed host import that reenters the guest via an async-typed export. However, it seems more honest and flexible to make the host import async-typed, giving the guest the opportunity to call it asynchronously and concurrently with other calls.
Looking forward to it. :slight_smile:
Tried the PR after you made that last push, seems to work! Tried some different things.
The minimal amount of async I found is:
world plugin {
import c: func() -> string;
export a: async func();
export b: func() -> string;
}
As long as the inital export is async (to help satisfy other constraints due to Tokio and WASI), I was able to call a sync import, which calls a sync export.
I imagine I'd probably make the whole chain async for the reason you described, but interesting to see it works!
Cool, glad it works. That PR is still a work-in-progress as Luke and I work through a few more details, but I'll switch it out of draft mode once it's ready.
Last updated: Aug 30 2026 at 09:07 UTC