Rainer Deyke wrote:
On 6/25/26 16:58, Peter Dimov via Boost wrote:
The problem is that there needs to be a standard way to propagate important state to the foreign coroutine, so that the foreign coroutine can at least propagate that same state back to you if it co_awaits one of yours.
That state in Capy consists of the executor, the stop token, and the allocator. And while you can argue that the allocator is optional (but it isn't in practice), the other two most certainly are not.
Let's call this the "A co_awaits B, B co_awaits C" problem, where A and C use the same coroutine library. This is distinct from the "A co_awaits B, B resumes A" problem, where the awaitable B attempts to return control back to A after suspending.
This problem has solutions.
It does, but they aren't good ones. Let's first address the executor. The programming model where coroutines resume on their original executor, as opposed to a random one, is much easier to reason about, much less error prone, and allows much simpler code. If you know that your I/O coroutine will always resume on your singe threaded I/O executor, this allows you to skip all locking. (*) It also avoids your coroutine borrowing a thread pool thread that is intended for CPU intensive tasks; this is suboptimal. (And conversely, a CPU intensive task ending up on the single threaded I/O executor would be a disaster, although this would be much less likely to happen.) (*) It also allows you to use Boost.Leaf, to borrow some ammo from the adjacent thread. So in general, we would like to be able to support this "coroutine always resumes on its original executor" model. "This problem has solutions." Indeed. The first solution is for the coroutine author to manually switch back to the original executor after each co_await. Needless to say, this quickly becomes verbose, repetitive, boring and ultimately unworkable. The second solution is to automate this and make the promise insert the switch back automatically in await_transform. This works, but requires the allocation of a coroutine frame on each co_await, which is suboptimal and unnecessary if we know that the co_awaited coroutine will resume on the original executor. The third solution is to devise a way to mark the coroutines that already resume on the original executor in some way (this is the IoAwaitable protocol), and then, if the co_awaited routine is one of those, avoid wrapping it in another frame. This is essentially the path that led to the current Capy design.
The verbose, explicit option to carry the auxiliary objects we need as function arguments. I actually like this option for stop tokens. It means that each co_await makes it explicit if the awaitable in question (potentially) uses the stop token or the allocator. It means being able to substitute in a different stop token or allocator at the call point. (To be fair, Capy offers just that functionality for stop tokens, through boost::capy::run.)
That's possible but, again, verbose, repetitive, and error prone. Since at this point we're at our third solution above, where we already have a protocol for passing state from A to B to C, it's natural to just put the stop token there as well, and avoid all that manual handling. And now we arrive at our next juncture. We can co_await our IoAwaitables with everything being passed down splendidly, and we can also co_await foreign awaitables, which we wrap so that we resume on our original executor. This allows foreign awaitables to work, but we lose all the propagated context. This is kind of fine when everything is written correctly; it does exactly what needs to be done. But it's error prone. We have to pass the additional state to the foreign awaitable. Maybe it does have an overload taking a stop token as a parameter, and maybe we can actually call it the right way and have everything working. But if we forget, everything still compiles and works, except that the stop token is silently dropped, and the coroutine becomes uncancelable. Similarly, maybe the author of the foreign awaitable did actually intend to implement IoAwaitable, but got the signature subtly wrong. Again, everything still compiles and works, but we get an unnecessary coroutine frame and all the additional context is silently dropped. So... it's a tradeoff and the (right in my opinion) call here is to move to our fourth solution and require IoAwaitable. This way, the above failure modes are compile errors. Yes, it's less permissive, and yes, it requires everyone to implement IoAwaitable. This is a downside today, but the ambition here is to figure out what the de-facto standard state propagation protocol needs to be, test it in practice, then eventually, codify if as de-jure. "This all should have been in the documentation." Not going to argue with you here. It absolutely should have been.
(Note that even in Capy, stop tokens require manual checking to work. It is entirely possible to write uncancelable coroutines or other IoAwaitables in Capy. And cancellation may not be necessary if a coroutine is fast enough, and guaranteed to stay fast enough. Not accepting a stop token as an argument makes it explicit if this is intentional, and accepting a stop token as an argument makes it less likely to happen unintentionally because the argument is there to remind you.)
The pragmatic option is to simply create a new context at the point where C is called. C has a stop token, but it's distinct from A's stop token. If we don't want that, we can manually pass the stop token through B.
A new token is not what we want. The stop token supports the basic when_any primitive and we really want everything co_awaited from this when_any call downwards to use the same stop token so that when_any can do its job.