I have a question/concern about async cancel in the component model. As far as I understand, wit-bindgen for Rust guests uses synchronous cancel for subtask.cancel (and stream operations). Concretely that means dropping a future for a task will block the whole task (even concurrent futures) until the cancellation is acknowledged. That seems quite surprising; I would expect any blocking points to have clear .await markers. Maybe my understanding is wrong here?
The reason I ran into this is because I am trying to run Rust guests in a host (browser without JSPI) that only supports asynchronous cancellation, and I expected the wit-bindgen rust to be fully asynchronous.
The issue in Rust, I think, is how to handle buffer ownership if cancellation were asynchronous. Since you can drop futures you'd have to pass ownership while the future is running and then only get it back on completion (or cancellation). I think I remember seeing an issue about this a long time ago but I can't find it anymore.
I (LLM) prototyped some changes to wit-bindgen to use asynchronous cancellation at https://github.com/jellevandenhooff/wit-bindgen/pull/1. With those changes I can run wit-bindgen code in a non-JSPI runtime which is pretty neat. API-wise though it becomes complicated: All stream operations consume the buffer and only return it when you await a cancel or completion. I have a hard time imagining how you'd layer eg. tokio TCP writes on top of it that, since I think those expect buffers to return synchronously when you call cancel.
I have a hard time understanding the trade-offs here. If the runtime does not support blocking, then today's wit-bindgen won't work, easy choice. If the runtime does support it, AND the host cancels quickly, then perhaps blocking leads to the cleanest client APIs. If the host (or other components) do not cancel quickly, then I worry this would cause components to get stuck.
Since all reads and writes are mediated through the host's intrinsics, none of this in some way seems necessary. The intrinsics could immediately cancel the stream read or write (or subtask argument read/write?) and release the buffer, even if the other end has not yet acknowledged that. Wouldn't that be the best of both worlds? Asynchronous cancel, the other end can keep going and if it does try to write to a buffer we complain, and the buffer is safe to re-use immediately?
This seems also quite related to https://github.com/WebAssembly/component-model/issues/617.
Maybe my understanding is wrong here?
I can at least confirm your understanding of things -- yes cancellation is blocking in Rust.
I (LLM) prototyped some changes to wit-bindgen to use asynchronous cancellation at https://github.com/jellevandenhooff/wit-bindgen/pull/1.
I think that those bindings are at the very least leaky (e.g. FutureWrite::drop is a noop), but at worst they also semantically violate what at least I'd expect from Rust which is that cancellation via drop indeed cancels.
What you're running into here is effectively that Rust has no concept of async drop. It's not just that Rust can't express async drop today in the type system it's that there's really no possible way to emulate it. If Rust had async drop then all cancellation would be mapped to async drop and we wouldn't need synchronous cancellation in Rust, but an affordance of today's Rust is that without async drop the best that can be done is synchronous drop. The high-level idea is that, in practice, cancellation is almost always immediate and won't end up blocking in practice so the end-result will largely be the same. That's not to say it'll work out that way 100% of the time, however, as you've found.
Wouldn't that be the best of both worlds? Asynchronous cancel, the other end can keep going and if it does try to write to a buffer we complain, and the buffer is safe to re-use immediately?
Ideally, yeah, I'd agree. The ergonomics of the guest bindings though are something I'm at least personally particularly worried about, and my understanding historically has been that there's basically no way to map this into Rust in a way that surfaces the constraints, doesn't leak, doesn't have surprising bugs, and is relatively easy to work with. In the end synchronous cancellation is sort of the only thing that fits the bill (in my mind at least)
I can at least confirm your understanding of things -- yes cancellation is blocking in Rust.
I see. Thanks.
In the end synchronous cancellation is sort of the only thing that fits the bill (in my mind at least)
I agree with today's component model spec, at least for streams and futures
The high-level idea is that, in practice, cancellation is almost always immediate and won't end up blocking in practice so the end-result will largely be the same.
If that is true, could (should?) the spec guarantee it? I think that is true for today's streams and futures operations, but not necessarily for sub-tasks implemented by other guests. It seems worthwhile to consider streams/futures and subtasks independently...
I think for streams/futures, the spec could guarantee synchronous cancellation that is also non-blocking. If you are reading/writing between guests, then synchronous cancellation is free; the host can prevent future writes. For guest/host reads/writes to operating system APIs, if the host uses readiness IO itself, it can guarantee that it won't touch the buffer again and immediately return it. If the host itself uses completion-based IO, then the host is in a tough spot with the guarantee. However... in that case, can the host take on the complexity perhaps instead of the guest? If the host uses readiness based IO, and the guest cancels, then it can block the guest until its cancel propagates to whatever OS API it uses? Because we are talking about specific host IO APIs, then the assumption that cancellation is fast does hold and we can do the blocking without making the guests worry about it in the component model API?
That then leaves sub-tasks which (could) be fire-and-forget, if it were not for borrowing handles. I don't know what to do about that...
Actually, I thinking panic-on-drop is not totally crazy, if the alternative is block-for-an-undefined-amount-of-time-on-drop...
If the host itself uses completion-based IO, then the host is in a tough spot with the guarantee.
Yeah this is the main thing for futures/streams. I agree yeah that if it were exclusively guest<->guest interactions it could be spec'd as always synchronous though. One example this comes up with Wasmtime for example is if you have a stream<u8> to write to a file. In Wasmtime when you issue a write we'll go run work in the background on a separate thread to write that to a file, and once that's started it's fundmanetally un-cancellable and will block the caller until the actual I/O operation completes.
Now that I think a bit more about this I think there's also future possibilities of features for futures/streams which would make guest<->guest sync cancellation harder. For example if the writer were able to receive something like "hey a reader is waiting for N bytes" and synchronize with that it may end up meaning sync cancel is no longer possible. I don't think I have the exact specifics here right, but Luke would probably know more than I.
if it were not for borrowing handles
Yeah this is definitely one of the tricker parts of handling subtasks. This is one of the concerns from the Rust side as well trying to ensure that everything is memory-safe in Rust and working well.
Actually, I thinking panic-on-drop is not totally crazy
Personally I don't think that this would work out very well. My thinking is that in a highly async system with lots of work running around it can be very difficult to reproduce exact sequences of events. This means that we want to, through bindings and APIs, minimize the possibility of erroneous events happening. What we should be able to do is to statically model everything such that "if it compiles it won't panic unless you wrote unwrap" or something like that.
An edge-case panic-on-drop in some conditions would go against this where if it showed up occasionally it would, in theory, be quite difficult to diagnose and and reproduce, much less fix. In the end there's really no way to have an async drop in Rust that wouldn't bottom out in a panic eventually (I think at least), so I'm not sure if it would be possible to write a panic-free application in Rust at that point
See also e.g. https://github.com/rust-lang/rfcs/pull/3782 and https://smallcultfollowing.com/babysteps/blog/2025/10/21/move-destruct-leak/ for thoughts and proposals for modeling completion-based APIs safely in Rust.
In Wasmtime when you issue a write we'll go run work in the background on a separate thread to write that to a file, and once that's started it's fundmanetally un-cancellable and will block the caller until the actual I/O operation completes.
Right. I think my thinking there would be, sure, you have to block, but you can hide that from the guest?
if the writer were able to receive something like "hey a reader is waiting for N bytes" and synchronize with that it may end up meaning sync cancel is no longer possible
I think what you are saying here comes down to, between guests completion based IO would not work either. If you pass an event to a guest saying that a channel has a writer, then with instantaneous cancellation that could be out of date. Seems fine to me?
In the end there's really no way to have an async
dropin Rust that wouldn't bottom out in a panic eventually (I think at least)
But block-indefinitely is okay? I guess if you have to pick between two evils?
Joel Dice said:
See also e.g. https://github.com/rust-lang/rfcs/pull/3782 and https://smallcultfollowing.com/babysteps/blog/2025/10/21/move-destruct-leak/ for thoughts and proposals for modeling completion-based APIs safely in Rust.
Ah cool thanks! I think maybe that is what I was thinking of when I mentioned some issue I read before.
I'll think about this a bit more and maybe file an issue about synchronous stream/future cancellations. Thanks
Okay, one more thought, and you might really hate this, but what if... cancelling a task poisoned the borrowed handles, making all operations error out with a cancelled flag in the callees?
(Filed https://github.com/WebAssembly/component-model/issues/618)
Right. I think my thinking there would be, sure, you have to block, but you can hide that from the guest?
Correct that we have to block yeah, but semantically we don't hide it from the guest because of the side-effectful nature. For example if we told the guest that an operation was cancelled but it wasn't actually that means that data might get corrupted.
Seems fine to me?
I think I'd need to think more about this case and have a more coherent counterexample to present, I'll defer this to the issue you just filed.
I guess if you have to pick between two evils?
Effectively, yeah. This is where I also would focus on the practical realities involved here which is that cancellation is almost always quick anyway. It's edge cases which aren't necessarily expected to work where it gets weird.
Okay, one more thought, and you might really hate this, but what if... cancelling a task poisoned the borrowed handles, making all operations error out with a cancelled flag in the callees?
Runtime-wise this is probably relatively easy, but guest-language-wise I would expect this would be very difficult to expose. This would mean, for example, that cancellation could inject panics/traps/exceptions/etc within a guest for any handle operation, and that goes against what I'd see is the goal of a robust async system.
(Filed https://github.com/WebAssembly/component-model/issues/618)
Thanks! I'll read over that and respond to points there too when I get a chance
Last updated: Mar 23 2026 at 16:19 UTC