Capy Review

Rainer Deyke via Boost <[email protected]> Wed, 24 Jun 2026 16:07:15 +0200
Newsgroups gmane.comp.lib.boost.devel
Message-ID <[email protected]>
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 - [email protected]

_______________________________________________
Boost mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://lists.boost.org/mailman3/lists/boost.lists.boost.org/
Archived at: https://lists.boost.org/archives/list/[email protected]/message/RNXXUYVBKMI5I5SIG6TODIHVIJH7C6ON/