Skip to content

feat: add coroutine support for client-side asynchronous calls#527

Merged
sangelovic merged 15 commits into
Kistler-Group:masterfrom
alexcani:alexcani/awaitables
Apr 19, 2026
Merged

feat: add coroutine support for client-side asynchronous calls#527
sangelovic merged 15 commits into
Kistler-Group:masterfrom
alexcani:alexcani/awaitables

Conversation

@alexcani
Copy link
Copy Markdown
Contributor

@alexcani alexcani commented Mar 1, 2026

C++ 20 introduced coroutine support to the language, enabling writing asynchronous code using the co_await, co_return and co_yield operators. Similar to other languages, coroutines allow functions to suspend execution at certain points and later resume from where they left off, without blocking the calling thread.

Coroutines are a natural fit for any sort of IO-bound asynchronous operations, such as D-Bus communication.

This PR introduces native coroutine support in sdbus-c++ by the means of the Awaitable<T> class, a type that implements the C++20 awaitable protocol, allowing D-Bus method calls to be awaited in coroutines.

Summary of the changes:

Core library

  • New Awaitable<T> type that can be co_awaited, which suspends a running coroutine. When the result or error of a method call arrives, the coroutine is resumed and the result/error is returned.
    Implementation is done in a thread-safe way, meaning there are no race conditions between the awaitable object returned by asynchronous calls and the callback invoked by the event loop upon arrival of a message. Naturally, it works in the single-threaded scenario as well, where the connection event loop may be driven externally and/or integrated in a full fledged coroutine runtime.
  • Low-level API: new callMethodAsync() overloads accepting with_awaitable_t tag and returning Awaitable<MethodReply>.
  • High-level API: new getResultAsAwaitable methods in the relevant high-level API helpers, covering methods and properties. StandardInterfaces.h classes have also been updated to expose awaitable-based methods.
  • Integration tests for both low-level and high-level APIs.

Codegen

  • Added support for generating awaitable-based async methods in the xml2cpp tool through new values for the org.freedesktop.DBus.Method.Async.ClientImpl and org.freedesktop.DBus.Property.[Get/Set].Async.ClientImpl annotations.

alexcani added 7 commits March 1, 2026 18:01
Include .vscode/ in .gitignore to prevent IDE-specific files
from being tracked in the repository.
Prepare for C++20 coroutine support for asynchronous D-Bus method
calls by introducing the Awaitable<T> class, enabling co_await syntax.
This class contains the three necessary methods to support the co_await
operator: await_ready, await_suspend and await_resume.

A shared data structure is used to hold data needed by the callback
that will resume the coroutine and feed in the result or exception. This
work similarly to how a future/promise pair work.
Potential race conditions in the multithreaded scenario are covered by
atomic operations on an AwaitableState value, preventing situations
that could lead to a double coroutine resume or permanent suspension.
Add three new flavors of the callMethodAsync method in the
IProxy interface that use the with_awaitable_t tag to return an
Awaitable<MethodReply>.
Implementation relies on the callback-based mechanism to
insert the results into the shared AwaitableData, transition
the state to Completed and resume the coroutine.
Add a .getResultAsAwaitable method to each of the relevant
high-level API helpers (AsyncMethodInvoker, AsyncPropertyGetter,
AsyncPropertySetter, and AsyncAllPropertiesGetter).
Implementation is similar to the low-level API: rely on the
.uponReplyInvoke() mechanism to set the results on the
shared data, atomically flip the state, and resume the suspended
coroutine.
Adapt the StandardInterface.h classes to include an awaitable-based
overload of the async methods.
…ation

The current implementation relies on two flags to determine if async is
enabled for methods/properties.
In preparation to introduce the generation of the coroutine-friendly
awaitable methods as an async implementation, refactor this as an enum
containing the implementations and an std::optional to mark simultaneously
if async is enabled and the method. There are no behavioral changes or
new functonality.
C++ 17 is needed for std::optional, therefore the used C++ standard
for the tool has been changed.
Use the new awaitable-returning methods from the high level
API to add support to a third async implementation mode
alongside the existing callback and future implementations.

Add a new enum entry to AsyncImpl and parse the "awaitable"
or "coroutine" values in the relevant annotations for methods
and properties.

Methods annotated with this implementation will return
sdbus::Awaitable<ReturnType> and call .getResultAsAwaitable<>() on the
high-level API. Properties follow the same pattern, returning
sdbus::Awaitable<sdbus::Variant> for getters and sdbus::Awaitable<void>
for setters.

The nested ternary expressions for determining return types have been
refactored into explicit if-else blocks for improved readability and to
accommodate the additional implementation type. The existing callback
and future implementations remain unchanged in behavior.
@alexcani
Copy link
Copy Markdown
Contributor Author

alexcani commented Mar 1, 2026

Hi there!
There are a few things I would appreciate your help clarifying:

  • I tried my best to follow the existing code style. It would be great to have something like a .clang-format file in the project, though.
  • I was not sure how to handle the copyright part. I added my name/email to new files that I've created, and left the other ones untouched. Please let me know if I should do it differently
  • This is C++20, however we claim to be backward-compatible to C++17 at the public API level. This breaks it. I'd like your input on how to handle it. We could conditionally compile this based on the presence of "__cpp_impl_coroutine" feature macro.
  • I left the constructor for Awaitable public to avoid needing to befriend details::Proxy AND AsyncMethodInvoker. this also removed the need for forward declarations in the file. i thought it looked good and accepted the tradeoff. i can change it if you prefer to have all "infrastructure" types constructors private.
  • The integration tests are mostly a replic of the ones for async methods. To be completely transparent, i had Claude write 98% of the tests (but i did review the code myself), meaning I didn't put much effort in coming with new test cases. Let me know if there's a specific corner case you'd like to test

Also thanks for the work you put in the project, I always had a good time using it. Please let me know if there are further changes or design considerations needed

alexcani added 2 commits March 1, 2026 20:36
Introduce new awaitable methods in TestProxy for asynchronous
operations and add corresponding integration tests in
DBusAwaitableMethodsTests.cpp to validate functionality.

The test uses a simple coroutine task type which uses a
promise/future pair to pass data between the callback
and the coroutine, leveraging the fact that the tests are all
multithreaded.
This approach would not work in a single-threaded scenario,
as the future's .get() method would block the thread.
Update the main tutorial with the new awaitable-based
async method.
@alexcani alexcani force-pushed the alexcani/awaitables branch from e8ceebb to 3988cd7 Compare March 1, 2026 19:36
@sangelovic
Copy link
Copy Markdown
Collaborator

Hey @alexcani. Thanks a lot for contributing and submitting the PR! I started looking into that but am a bit overloaded currently, I'll definitely come back to this asap.

@sangelovic
Copy link
Copy Markdown
Collaborator

sangelovic commented Mar 25, 2026

@alexcani

Hi! I reviewed the PR. Appears to me to be one of the best, well-done PRs I've reviewed on this project. It is enjoyable to see changes done to nice little details and across all levels. I have no experience with coroutines, so I had to do the research first. Still, given lack of experience, I don't feel 100% confident about all the details of good coroutines design. But given the quality of your work, I think I can trust you.

However, I did some deeper reflection about things like overall design, thread coordination, exception handling, etc. -- and when I tried to think about them out-of-the-box, I got back to your solution, understanding your motives. Again, I see you've put an aspect of quality and craftsmanship to your work, and this is something I highly appreciate.

One thing I was asking myself was -- in AwaitableData template, could we simply use std::variant<> for both the result and the exception, instead of std::optional for one and the other? I think that this

  • would idiomatically be more self-revealing that only one of those is available at a time, and thus would eliminate potential confusion of a reader about combinations resulting from having two std::optionals (like can it be that both are set at the same time? Or can it be that both are unset?),
  • would need only one member instead of always two, making it simpler,
  • while introducing no extra performance penalty.

I've already prepared a local patch for that. It works nicely. I can push that to your branch if you're okay with that, for you to review. Let me know. In the code I touched, I also changed the formatting a bit to stay consistent with the surrounding code.

Regarding code style, yes, clang-format would be great. I have that on my TODO list.

Regarding copyright, all fine with me.

Regarding API, yes, it is currently C++17 compatible. Would be great to use feature macros to have this feature conditionally available.

Regarding Awaitable public c-tor, I don't have a strong opinion, but for consistency with other similar solutions in the library, I would slightly tend towards private c-tor and befriending the relevant classes. It's already done this way in other places in the library. Additionally, I would add assert(data_ != nullptr); in the c-tor body to express the invariant of always non-null ptr clearly.

Tests LGTM.

Clang-tidy job is failing -- shall I address the reported issues, or will you?

I'm glad you've enjoyed using the library so far. That was my ultimate design goal, and I've put my best into it.

@alexcani
Copy link
Copy Markdown
Contributor Author

alexcani commented Mar 30, 2026 via email

@sangelovic
Copy link
Copy Markdown
Collaborator

OK, I'll commit my patch. And no problem -- if I find time then I may do the adjustments myself. If not, you can do it when you are back. Enjoy your vacation.

@sangelovic sangelovic force-pushed the alexcani/awaitables branch from db11ba5 to eef7d0f Compare April 10, 2026 14:51
@sangelovic
Copy link
Copy Markdown
Collaborator

Hi @alexcani. Today I found some time so I pushed the std::variant commit + did the remaining stuff we agreed upon. Would you please review the commits? (You can review commit by commit as each tries to do a self-contained logical thing.)

C++17 compat was not that easy. I tried to conditionally remove all functions using awaitables, but we have virtual methods and there we get into troubles if the library is built with C++20 and the client with C++17 (broken virtual table contract). So I kept the API even when __cpp_lib_coroutines is not defined, but

  1. I isolated the conditional changes in the awaitable header,
  2. and have the Awaitable class and its layout to maintain the ABI contract in all cases, but with no methods and no use of std::coroutine_handler<>.

Does it look OK to you?

With new major release v3 in the future, we may remove that conditional build and completely switch to C++20 or C++23 in public API as well.

@alexcani
Copy link
Copy Markdown
Contributor Author

Hi @alexcani. Today I found some time so I pushed the std::variant commit + did the remaining stuff we agreed upon. Would you please review the commits? (You can review commit by commit as each tries to do a self-contained logical thing.)

C++17 compat was not that easy. I tried to conditionally remove all functions using awaitables, but we have virtual methods and there we get into troubles if the library is built with C++20 and the client with C++17 (broken virtual table contract). So I kept the API even when __cpp_lib_coroutines is not defined, but

  1. I isolated the conditional changes in the awaitable header,
  2. and have the Awaitable class and its layout to maintain the ABI contract in all cases, but with no methods and no use of std::coroutine_handler<>.

Does it look OK to you?

With new major release v3 in the future, we may remove that conditional build and completely switch to C++20 or C++23 in public API as well.

Hi! Thank you for the work, I like the std::variant better as well!
I checked all the commits, and i think there might be a small issue with the C++17 compat regarding the ABI.
The AwaitableData class has different layout depending on the __cpp_lib_coroutines flag, which will cause member offsets to change in the client side vs lib side. In particular, the handle is declared between the result and the status members. This means that the data->status.exchange call on the include/sdbus-c++/ConvenienceApiClasses.inl file will potentially explode, as a C++17 client could run the code on a AwaitableData object created by a C++20 lib.

I did some thinkering with out AI overlord and came up with a solution by keeping the layout the same via a type-erased handle (a.k.a. a pointer) in AwaitableData. Here's a proposed patch.

diff --git a/include/sdbus-c++/Awaitable.h b/include/sdbus-c++/Awaitable.h
index 2a9e638..e42742b 100644
--- a/include/sdbus-c++/Awaitable.h
+++ b/include/sdbus-c++/Awaitable.h
@@ -69,15 +69,14 @@ namespace sdbus {
     {
         using result_type = std::conditional_t<std::is_void_v<T>, std::monostate, T>;
         std::variant<result_type, std::exception_ptr> result;
-#ifdef __cpp_lib_coroutine
-        std::coroutine_handle<> handle;
-#endif // __cpp_lib_coroutine
+        void *erasedCoroutineHandle = nullptr;
         std::atomic<AwaitableState> status{AwaitableState::NotReady};
 
         void resumeCoroutine()
         {
 #ifdef __cpp_lib_coroutine
-            handle.resume();
+            if (erasedCoroutineHandle)
+                std::coroutine_handle<>::from_address(erasedCoroutineHandle).resume();
 #endif // __cpp_lib_coroutine
         }
     };
@@ -118,7 +117,7 @@ namespace sdbus {
         // resume the coroutine.
         bool await_suspend(std::coroutine_handle<> handle) noexcept
         {
-            data_->handle = handle;
+            data_->erasedCoroutineHandle = handle.address();
 
             // Attempt transition from NotReady to Waiting.
             AwaitableState expected = AwaitableState::NotReady;

The coroutine handles are trivially copyiable and can be easily erased and recovered via .address() and from_address(). Usage is still gated behind the flag, but at least we keep the ABI consistent.

What do you think?

@sangelovic
Copy link
Copy Markdown
Collaborator

@alexcani Hi, thanks for the review!

I looked at your described case and this is what I see: The original idea of mine is that AwaitableData<T> should not be used in C++17 client code at all -- there is no support for coroutines in there, so awaitable-tagged methods shall not be used. This is not enforced in code, however; it is only documented. Not using this API in C++17 means having no ABI problem.

But still, if a C++17-based client would call the AsyncMethodInvoker::getResultAsAwaitable() you are mentioning as an example, the AwaitableData both construction and use happen in that method, meaning both get compiled into client's code, both with C++17, i.e. both with consistent AwaitableData layout seen there. The incosistent AwaitableData layout issue could potentially happen with IProxy::callMethodAsync(), where AwaitableData is created inside sdbus-c++ lib (C++20) and its members accessed by a C++17-based client. However:

  1. AwaitableData is inaccessible for clients as it is private within Awaitable class, and
  2. again, C++17-based clients have no interest and should not use these functions as they don't have coroutines. (Best would be to conditionally remove that API, but then we have this IProxy vtable inconsistency problem, which is real problem.)

Does this make sense to you, or perhaps I am missing something?

@alexcani
Copy link
Copy Markdown
Contributor Author

Hi! I agree that within reasonable use it is unlikely that a C++17 client will interact with a C++20 compiled AwaitableData, but to me it feels a bit brittle. We're not really warning the C++17 user that they shouldn't use coroutine-related functionality. So instead of flirting with UB I'd prefer to just keep the layout consistent. We even reduce line count by 1 in the process and have one less ifdef. The null-check for erasedCoroutineHandle could also be dropped since resumeCoroutine is only called when state is AwaitableState::Waiting, which implies a coroutine was set in the first place.

I don't want to enter the world of "what-if's", but it's relatively easy for the client to generate code that would trigger UB:

// Get proxy from the library
std::unique_ptr<sdbus::IProxy> proxy = /* ... */;
// Call virtual method -> lib method is called with AwaitableData containing handle
auto aw = proxy->callMethodAsync(/*...*/, sdbus::with_awaitable);

// Since Awaitable contains 1 member, its address coincides with the member's
auto* sharedPtr = reinterpret_cast<std::shared_ptr<sdbus::AwaitableData<MethodReply>>*>(&aw);

// Memory stomping, since 'status' is in a different offset than the one seen from the client
(*sharedPtr)->status.exchange(sdbus::AwaitableState::Completed, std::memory_order_acq_rel);

Of course the client is shooting itself in the foot (and reinterpreting the private layout of Awaitable is already UB itself), and the code is a bit hacky and makes no practical sense, but the point is that the ABI break exists, we just lack a convenient access path to it at the moment.

It's not a deal-breaker for me as well if you prefer to keep it as-is, as well, but I think it would be worth it.

@sangelovic
Copy link
Copy Markdown
Collaborator

In general, I would be strongly reluctant to make the code more complex for the hypothetical edge case of someone anyway determined to shoot his foot (where is the boundary of sane vs. totally safe API design then, right?), but what you propose seems acceptable -- the code is not more complex, it's "just" a bit less type-safe and less expressive.

I would have yet another idea -- simply make the coroutine_handle the last member of AwaitableData. This is a typical way how ABI-compatible changes are done to vtables, for example (new virtual functions being added to the end of the class, so new virtual functions don't change offsets of existing ones). It should solve your concerns, while keeping the higher type-ness and expressiveness of the code. I would personally vote for this one, but I'll also accept your proposal -- make your choice (I guess you'll push the commit?) Thanks a lot.

Have the coroutine handle be the last member in AwaitableData to keep the class layout compatible
between C++ 17 and C++20 or later clients.
@alexcani
Copy link
Copy Markdown
Contributor Author

In general, I would be strongly reluctant to make the code more complex for the hypothetical edge case of someone anyway determined to shoot his foot (where is the boundary of sane vs. totally safe API design then, right?), but what you propose seems acceptable -- the code is not more complex, it's "just" a bit less type-safe and less expressive.

I would have yet another idea -- simply make the coroutine_handle the last member of AwaitableData. This is a typical way how ABI-compatible changes are done to vtables, for example (new virtual functions being added to the end of the class, so new virtual functions don't change offsets of existing ones). It should solve your concerns, while keeping the higher type-ness and expressiveness of the code. I would personally vote for this one, but I'll also accept your proposal -- make your choice (I guess you'll push the commit?) Thanks a lot.

Hi! I think that's a good middle ground. I have pushed the change moving the handle last in the class.
Anything left from your side?

@sangelovic
Copy link
Copy Markdown
Collaborator

Hi! I think that's a good middle ground. I have pushed the change moving the handle last in the class. Anything left from your side?

OK, great, I think we are good to go. Thank you for your contribution! Very appreciated.

@sangelovic sangelovic merged commit fd0412c into Kistler-Group:master Apr 19, 2026
10 checks passed
@alexcani alexcani deleted the alexcani/awaitables branch April 19, 2026 17:37
sangelovic added a commit that referenced this pull request Apr 20, 2026
C++ 20 introduced coroutine support to the language, enabling writing
asynchronous code using the co_await, co_return and co_yield operators.
Similar to other languages, coroutines allow functions to suspend
execution at certain points and later resume from where they left off,
without blocking the calling thread.

Coroutines are a natural fit for any sort of IO-bound asynchronous
operations, such as D-Bus communication.

This PR introduces native coroutine support in sdbus-c++ by the means of
the Awaitable<T> class, a type that implements the C++20 awaitable
protocol, allowing D-Bus method calls to be awaited in coroutines.

Summary of the changes:

Core library:

* New Awaitable<T> type that can be co_awaited, which suspends a running
  coroutine. When the result or error of a method call arrives, the
  coroutine is resumed and the result/error is returned.
  Implementation is done in a thread-safe way, meaning there are no race
  conditions between the awaitable object returned by asynchronous calls
  and the callback invoked by the event loop upon arrival of a message.
  Naturally, it works in the single-threaded scenario as well, where the
  connection event loop may be driven externally and/or integrated in a
  full fledged coroutine runtime.
* Low-level API: new callMethodAsync() overloads accepting
  with_awaitable_t tag and returning Awaitable<MethodReply>.
* High-level API: new getResultAsAwaitable methods in the relevant
  high-level API helpers, covering methods and properties.
  StandardInterfaces.h classes have also been updated to expose
  awaitable-based methods.
* Integration tests for both low-level and high-level APIs.

Codegen:

* Added support for generating awaitable-based async methods in the
  xml2cpp tool through new values for the
  org.freedesktop.DBus.Method.Async.ClientImpl and
  org.freedesktop.DBus.Property.[Get/Set].Async.ClientImpl annotations.

---------

Co-authored-by: Stanislav Angelovič <stanislav.angelovic@protonmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants