On 6/20/26 01:08, Jeff Garland via Boost wrote:
The Boost Formal Review of the *Corosio* and *Capy* libraries will begin on *June 23, 2026* and will conclude on *July 7, 2026*.
Here is my formal review of Capy. It's going to be a long one, but I think it's important that all of these things be said. To make it easier to read, I have divided the review into several section with cross-references between them. Summary and verdict at the bottom. +------------+ | Background | +------------+ I recently started a project using a pre-review version of Capy as a test run of Capy. The project, part of a bigger project, is an asynchronous texture loader. It consists of the following parts: - An asynchronous filesystem wrapper. I used SDL3's asynchronous IO functions as the base layer. I just needed to wrap this in the appropriate awaitables. (Why not use Corosio instead? For one thing, I never even got Corosio to build before the review period.) - An asychronous archive reader. My textures are in a zip-like archive. I need to open the archive, read the index, support concurrent asychronous read operations on the archive, and close the archive afterwards. - The actual data parsing. This is handled by libwebp. It is also the main opportunity for actual parallel execution. - Uploading the texture images to the GPU as OpenGL textures. This step must be performed on the main thread due to an OpenGL limitation. (Why not use Vulkan? Because I'm also targeting web platforms through Emscripten, which means OpenGL ES emulation through WebGL. And because I'm running on Linux, so I don't even have the option of using WebGPU.) I figured this would work as a suitable test for Capy. It's just far enough removed from Capy's main area of competency to be interesting. Well, long story short, I wasn't able to implement even the first step in Capy (see The Fatal Flaw below). Instead, I used TooManyCooks, a competitor to Capy, and it worked. However, I think I can still use this experience as a basis for reviewing Capy. I will be making several comparisons to TooManyCooks; this is not an endorsement of that library. I will be using a documentation-first approach for this review. In other words: - I will assume that the documentation is correct and complete unless proven otherwise. Anything not documented is assumed to be an implementation detail. - If the documentation and the code differ, I will consider this an error in the code, not the documentation. (Disclaimer: I started familiarizing myself with Capy three weeks ago, and read the entire documentation at that time. I constantly referred to the current documentation while writing this review, but I didn't reread the entire documentation again within the review period, so it's possible that I missed some recent additions.) +----------------+ | The Fatal Flaw | +----------------+ Let's start with the problem that prevented me from using Capy at all. In order to wrap SDL's asynchronous io functions, I needed to implement not just a custom awaitable, but a custom IoAwaitable. Because Capy doesn't support plain awaitables at all. More on that below. Well, Capy provides a nice block of example code on how to implement an IoAwaitable on <https://develop.capy.cpp.al/capy/4.coroutines/4d.io-awaitable.html>. The only problem is that this example code does not work at all. It doesn't even compile. Here is the code I tried to compile: import std; import "boost/capy.hpp"; using namespace boost::capy; using result_type = std::string; void start_operation() {} struct my_awaitable { io_env const* env_ = nullptr; std::coroutine_handle<> continuation_; result_type result_; bool await_ready() const noexcept { return false; // Or true if result is immediately available } std::coroutine_handle<> await_suspend(std::coroutine_handle<> h, io_env const* env) { // Store pointer to environment, never copy env_ = env; continuation_ = h; // Start async operation... start_operation(); // Return noop to suspend return std::noop_coroutine(); } result_type await_resume() { return result_; } private: void on_completion() { // Resume on caller's executor env_->executor.dispatch(continuation_); } }; The first five lines were added by me to fix the obvious compile errors; the rest is the example code from the web page copied verbatim. Here are the exact error messages I got: ../../../programs/capy_test/io_awaitable_test.cpp: In member function 'void my_awaitable::on_completion()': ../../../programs/capy_test/io_awaitable_test.cpp:41:33: error: cannot convert 'std::__n4861::coroutine_handle<void>' to 'boost::capy::continuation&' 41 | env_->executor.dispatch(continuation_); | ^~~~~~~~~~~~~ | | | std::__n4861::coroutine_handle<void> In file included from libs/capy/install/include/boost/capy/ex/io_env.hpp:14, from libs/capy/install/include/boost/capy/concept/io_awaitable.hpp:15, from libs/capy/install/include/boost/capy/task.hpp:15, from libs/capy/install/include/boost/capy/io_task.hpp:14, from libs/capy/install/include/boost/capy.hpp:24, of module ./libs/capy/install/include/boost/capy.hpp, imported at ../../../programs/capy_test/io_awaitable_test.cpp:2: libs/capy/install/include/boost/capy/ex/executor_ref.hpp:216:52: note: initializing argument 1 of 'std::__n4861::coroutine_handle<void> boost::capy::executor_ref::dispatch(boost::capy::continuation&) const' 216 | std::coroutine_handle<> dispatch(continuation& c) const | ~~~~~~~~~~~~~~^ Now, you might be wondering, what's a boost::capy::continuation? It's documented here: <https://develop.capy.cpp.al/capy/reference/boost/capy/continuation.html>. Basically it's a pair of the coroutine handle we were expecting from the documentation and an otherwise undocumented implementation detail of the executor. Based on the documentation, there is no way to get or create a working boost::capy::continuation, so there is no way to create a custom IoAwaitable. I reported this problem three weeks ago: <https://github.com/cppalliance/capy/issues/296>. It still hasn't been fixed, or even replied to. This alone is already reason enough to reject Capy. Without custom (Io)awaitables, Capy is a toy library. Moreover, with such a glaring flaw unfixed for three weeks, I have no confidence in the rest of the library. How many more critical showstopper errors have I been unable to uncover because I never made it that far? This is a reject-level flaw. +-------------------------+ | The IoAwaitable Problem | +-------------------------+ Capy coroutines cannot co_await awaitables from other coroutine libraries. Capy coroutines cannot co_await awaitables at all unless these awaitables also model the IoAwaitable concept. Conversely, writing a class to model the IoAwaitable concept inextricably links the class to the Capy library. This is a literal truth and a deliberate design choice. Using co_await in a capy::task on a regular awaitable generates a compile error, by design. That doesn't mean that it is completely impossible to combine coroutine libraries, but it is quite difficult. Regular awaitables could theoretically be wrapped in IoAwaitables, although custom IoAwaitables are currently broken (see The Fatal Flaw above). Capy could do that for you generically, although it doesn't (presumably as a deliberate design decision). It is also possible to post non-Capy tasks to a non-Capy executor from a Capy coroutine, and conversely, to post Capy tasks to a Capy executor from non-Capy coroutines (but see Synchronization below). What this comes down to is that Capy wants to be the only coroutine library in your program. It's not so much a library as an all-encompassing framework. And as a framework, it needs to be held to a higher standard for completeness. A library can be augmented with other, unrelated libraries. A framework can only be extended with extensions written for that specific framework. (Corosio is such an extension, and I should probably take it into account here. But I won't, because I haven't examined Corosio in detail yet, and because Corosio is a separate library that should be reviewed separately from Capy.) I do not believe that Capy meets this criteria at all. As a framework, it is too restrictive to be useful. Details below. +---------------+ | Running Tasks | +---------------+ Running tasks requires a two-call syntax: run_async(pool.get_executor())(compute()); The explanation for why it needs to happen is that the first call sets thread-local state that the 'compute' function consumes. There is even a warning about not caching the result of the first function call. However, there is no warning about the far more likely problem of precomputing the argument to the second call. What this means is that innocent-looking functions like the following do not work correctly: void run_on_pool(auto task) { run_async(pool.get_executor())(task); } This is, in practice, a huge problem. Not just because it's restrictive, but because there is no compile-time checking and the restriction and its implications are so easy to forget about it when you're writing code that is detached from the actual run_async. For example, the following looks like it follows the rules at the run_async call site, but actually doesn't: boost::capy::task<void> computation_task; boost::capy::task<void> compute() { return std::move(computation_task); } run_async(pool.get_executor())(compute()); This is a serious problem. It's not unreasonable for an object to expose the ability to enqueue tasks through a function instead of a public thread pool variable like the run_on_pool function above. However, I would like to see some discussion around this before I declare this a reject-level flaw, because it's not clear to me how this problem can be avoided without serious compromises elsewhere. (But TooManyCooks doesn't have this problem and doesn't use the two-call syntax at all, so there's that.) +-----------------+ | Synchronization | +-----------------+ Thread pools create some interesting synchronization problems, even without the presence of coroutines. All multithreaded code requires synchronization, but many of the standard synchronization techniques are broken in the context of thread pools, and coroutines add their own unique complications. The standard library presents a rich set of thread synchronization structures, which can be roughly divided into unordered (the mutex family: two operation cannot run at the same time, but order between them is irrelevant) and ordered (basically everything else: an operation in one thread needs to wait for an operation in another thread to complete). Unfortunately these types are designed under the assumption that each "task" has its own thread, and they break down when this assumption is violated. In normal threaded code, a thread can "yield" by waiting on a synchronization object, allowing other threads to run. In code using thread pools, the opposite is true: waiting on a synchronization object locks up the thread, preventing other tasks from running in that thread. This results in inefficient thread usage at best and deadlocks at worst. To illustrate the problem, consider this situation: - Task A is waiting on a std::condition_variable for a notification from task B. - Task B is to perform some work, then notify task A via a std::condition_variable. - Our thread pool only has one thread. (The same problem exists in larger thread pools, it just requires more tasks to manifest.) - Task A is scheduled and runs. Task A locks up the thread waiting for the std::condition_variable, and never returns control to the scheduler. - Task B cannot be scheduled until task A returns control to the scheduler. In other words, tasks A and B are mutually deadlocked. What this means is that all of the synchronization types of the standard library are unsafe by default in thread pool code. They can still be used in limited cases, but each use requires some extra analysis as to whether it is safe or not. The unordered (mutex) family of synchronization types comes away relative well, in connection with a gotcha of their own: - A mutex from the standard library can only be unlocked by the same thread that locked it. Unlocking by another thread is undefined behavior. - A co_await call can silently transfer a coroutine to another thread. - It is therefore categorically wrong to hold a mutex lock across a co_await call. - However, this also means that any correctly written task that holds a mutex will keep running (i.e. not co_await, and therefore not return control to the coroutine scheduler) so long as the lock is held. - Waiting on a mutex from within a coroutine is therefore potentially inefficient, but not inherently unsafe. The ordered family of synchronization comes away much worse. It can be used relatively safely when the sender is a coroutine and the receiver isn't. It can technically be used when the receiver is a coroutine and the sender isn't, but it really shouldn't, because it locks up threads in the pool from doing other work. It must never be used when both the sender and the receiver are coroutines, because this can lead to a deadlock. Fortunately ordered synchronization is usually a lot less important in coroutine code than in code using threads directly. Ideally, for optimal thread utilization, a coroutine should never wait for anything without going through co_await. That's why coroutines are linked to asynchronous i/o instead of just calling synchronous i/o functions from coroutines. This requires a set of synchronization structures that are awaitable (or IoAwaitable, see The IoAwaitable Problem above). TooManyCooks (<https://fleetcode.com/oss/tmc/docs/v1.6/control_structures/index.html>) provides its own rich set of awaitable synchronization structures: - tmc::atomic_condvar - tmc::auto_reset_event - tmc::barrier - tmc::latch - tmc::manual_reset_event - tmc::mutex - tmc::semaphore Capy provides boost::capy::strand and boost::capy::async_mutex. The former is functionally basically a subset of the former - nice to have, but not a big deal when the latter is available. Oh, and there's also boost::capy::async_event for ordered synchronization. Missed that one on the first read-through because it only seems to appear in the reference section. In addition, TooManyCooks provides a simple way for a coroutine to signal the (presumably non-coroutine) context from which it was posted: tmc::post_waitable, which returns a std::future. This allows the calling code to wait for a specific coroutine to finish and retrieve its return value. The closest Capy equivalent I can find is boost::capy::thread_pool::join(), which waits for /all/ coroutines in a pool to finish and discards their return values. Interestingly /neither/ Capy /nor/ TooManyCooks provide the synchronization structure that I really wanted for my archive reader (see Background above). What I really wanted was coroutine version of std::shared_mutex: - While reading the archive index, I need exclusive access. None of the files in the archive can be accessed until the index is read. - The archive is opened as read-only, and read operations only need shared access: they need to run after reading the index and before closing the archive, but they can run concurrently with each other all they want. - For closing the archive I need exclusive access again. And, yes, closing the archive is itself an asynchronous operation that needs to run in a coroutine. That's how SDL_CloseAsyncIO works. I ended up hacking together a working solution using a combination of less powerful synchronization primitives including a std::atomic<int> to count the active readers. It's not a good solution, and it violates the principle that coroutines should only ever wait using co_await, but it works for now. There are actually five general types of synchronization to consider: - Unordered between coroutines. (boost::capy::async_mutex) - Unordered between coroutines and non-coroutines. (std::mutex works, but blocks threads) - Ordered: coroutine notifies coroutine. (boost::capy::async_event) - Ordered: non-coroutine notifies coroutine. (also boost::capy::async_event; async_event::set is synchronous) - Ordered: coroutine notifies non-coroutine. (std::condition_variable works and is safe in this direction; tmc::post_waitable is nicer) So I guess the argument can be made that Capy is /technically/ complete and doesn't need any more synchronization structures because it covers all five categories. But that's not an argument that I will be making. I want an asynchronous shared_mutex. This is not a reject-level flaw, but if I were to vote for the acceptance of Capy, it would be with the condition of more work in this area. Especially given Capy's framework status (see The IoAwaitable Problem above). +----------------------------+ | Running in the Main Thread | +----------------------------+ As mentioned in Background above, my coroutines have an operation they need to run in the main thread. Doing this in Capy requires the following: int result = co_await boost::capy::run(main_thread_executor) ( [=]() -> boost::capy::task<> { // Code to run in main thread here } ); Control is returned to the original coroutine running on the original executor, which can be useful sometimes, but is unnecessary work in my case. The equivalent in TooManyCooks looks like this: co_await tmc::resume_on(main_thread_executor); // Code to run in main thread here I prefer the latter: it is shorter, clearer, and performs less unnecessary work. However, a bigger difference is hidden behind the main_thread_executor identifier. In the TooManyCooks case, this is an instance of tmc::ex_manual_st, an executor provided by TooManyCooks. In the Capy case, I would have to write the executor myself. The process for doing so is documented, but after the IoAwaitable situation (see The Fatal Flaw above), I don't trust this documentation to match the implementation. In fact, Capy doesn't seem to provide /any/ executors beyond thread_pool. TooManyCooks provides four of them, not including the type-erased executor type one (which Capy does provide) and an adaptor for (Boost.)ASIO. Again, not a reject-level flaw, but I expect more from an all-encompassing framework like Capy (see The IoAwaitable Problem above). +-------------------+ | Memory Allocation | +-------------------+ Using coroutines requires a lot of extra memory allocations, and I'm not talking about the coroutine frame itself. Consider a function like this: boost::capy::task<std::string> read_text_file( std::string const &fname) { SDL_AsyncIO *asyncio = SDL_AsyncIOFromFile( fname.c_str(), "r"); // No further use of fname beyond this point. // Note that SDL_AsyncIOFromFile is a synchronous operation. } This is perfectly safe if the function is called and the resulting task is co_awaited within another coroutine. It is not safe if the function is called through boost::capy::run_async, because the string is passed in by reference and the original string may no longer exist when the coroutine starts execution. I'm honestly not sure what can be done about this. The same problem exists in TooManyCooks. But one thing I have noticed is that the problem exists in part because boost::capy::task is inherently lazy. Consider an eager alternative: boost::capy::eager_task<std::string> read_text_file( std::string const &fname) { SDL_AsyncIO *asyncio = SDL_AsyncIOFromFile( fname.c_str(), "r"); co_yield; // Initial suspend // At this point fname is a dangling reference, // but we no longer need it. If we did need it, // we could have created our own copy before the // initial yield. } Because the task is eager, it has a chance to use or copy its by-reference arguments before they go out of scope. This is honestly a bit of a nitpick. I do not consider it a condition for acceptance. +-------------------+ | More Nits to Pick | +-------------------+ Using PascalCase for concepts is inconsistent with the standard library, and I don't like it. It does, however, have precedence in Boost, so maybe I shouldn't say anything about it. Here I am, saying something about it anyway. WriteSource is useful for writing a complete file in one go. ReadSource is only useful for reading a complete file in one go when the size of the file is known beforehand - in other words, seldom to never. A concept that reads the entire file into a single dynamically allocated block of memory and then passes ownership of that block of memory to its caller is clearly needed. I have no use for partially read texture files, and libwebp doesn't accept them. (Yes, this is requires extra memory allocations, but see Memory Allocation above. And pushing the extra memory allocations onto the consumer doesn't eliminate them.) On the other hand, I don't know why these concepts are a part of Capy in the first place. As far as I can tell, nothing in Capy uses or models these concepts. They seem to be Corosio concepts that exist in Capy purely to encourage Capy users that don't use Corosio to write Corosio-compatible code. (This applies mostly to the Stream/Source/Sink concepts, but the buffer concepts also appear to be isolated from the rest of Capy, even if Capy does provide concrete types that model them and algorithms to operate on them.) The Echo Server with Corosio (<https://develop.capy.cpp.al/capy/8.examples/8i.echo-server-corosio.html>) should be moved to the Corosio documentation - especially if Capy is accepted into Boost and Corosio is not. There's a reason why Capy and Corosio are separate libraries, and having an example that uses Corosio in the Capy documentation breaks this separation. +----------------------------------------+ | Specific Answers to Specific Questions | +----------------------------------------+
1. What is your evaluation of the usefulness of the libraries?
This is a review of Capy only. I may do another one of Corosio later, but it will be a lot shorter. So I will be answering all questions as if they applied to Capy only. I think that the problem Capy is trying to solve is a very important one, and I find its competitor TooManyCooks very useful despite its flaws. Unfortunately, Capy fails to solve this problem in a productive way: it cuts you off from the outside world of coroutine utilities, then doesn't give you enough tools to operate within this restriction. That it doesn't even give you the means to create your own tools (see The Fatal Flaw above) is just the icing on top.
2. What is your evaluation of the design?
I think it tries very hard to be clever and efficient at a high cost of flexibility. This design makes sense for a component of a self-contained program, or maybe a completely invisible implementation detail of a library, but not for a public library in its own right. The interface is both highly brittle (see Running Tasks above) and highly restrictive (see The IoAwaitable Problem above).
3. What is your evaluation of the implementation? 4. What is your evaluation of the documentation?
One or the other (or possibly both) is flawed to the point of unusability (see The Fatal Flaw above).
5. Have you used either or both libraries? What was your experience?
I tried, but didn't get very far (see The Fatal Flaw above).
6. Are the libraries ready for inclusion in Boost?
No, absolutely not.
7. If not, what changes would you recommend before acceptance?
Make it possible to create custom IoAwaitables by following the documentation. (This should hopefully be easy; I expect it to be done before the end review period.) Either bring the library up to approximate feature parity with TooManyCooks, or allow its coroutines to interact with standard awaitables, or better yet, both. (I expect both of these to be hard, to the point where it wouldn't surprise me if it never happened.) (To be fair, there are areas where Capy is ahead of TooManyCooks, like stop token propagation and exception handling.)
8. Do the libraries fit well within the existing Boost ecosystem?
Capy neither cooperates with existing
9. Are there API, naming, usability, extensibility, or implementation concerns that should be addressed?
I'm still not happy about using PascalCase for concepts. +---------+ | Summary | +---------+ I think that Capy in its current state is a trap. It cuts you off from the wider world of C++ coroutine libraries (see The IoAwaitable Problem above), while not providing sufficient functionality on its own (see Synchronization and Running in the Main Thread above) and not even giving you the tools to build your own functionality (see The Fatal Flaw above). I believe that the only people who can successfully use Capy in its current state are those with write access to its GitHub, and possibly those using libraries, written by people with write access to the Capy GitHub, that only use Capy as an implementation detail. Everybody else will eventually be forced to abandon it eventually for its restrictions. I therefore vote to REJECT Capy in its current state from inclusion in Boost. Although a lot of this review hinges on a specific, presumably not all that hard to fix bug (see The Fatal Flaw above), this is not my only reason for voting to reject Capy. I fully expect that particular bug to be fixed before the review period ends. I do not expect a fix for the twin problems of Capy trying to be an all-encompassing coroutine framework and falling completely short of that goal, which is ultimately just as important. -- Rainer Deyke - rainerd@eldwood.com