wit-bindgen icon indicating copy to clipboard operation
wit-bindgen copied to clipboard

CPP: async guest bindings sketch

Open ricochet opened this issue 1 month ago β€’ 7 comments

While we could go with exactly the choices made for async c bindgen, I'd like to sketch a C++ native solution.

C++20 introduces coroutines which are a much closer fit to the co-operative threads design in the component model.

The perennial challenge with C++ is which minimum version should we support? I see where we lean into C++ 17 features today, but could/should we raise the bar to C++20? I started sketching what it might look like assuming we could depend on C++20 and also assuming we wouldn't go to C++23.

cc @cpetig

Some basic design decisions:

  • Should we use exceptions for async failures? These appear to be uncommon in modern C++ for perf reasons and we should use std::expected<T, AsyncError> instead.
  • Move semantics for all async operations, and have them only use owned values (to prevent use-after-move)
  • No references/borrows in async contexts (to prevent dangling)
  • All operations are cancellable
  • RAII for automatic handle cleanup

Just a heads up that I'm knocking some rust off (pun intended) after having not written C++ professionally in several years. So please don't take my proposal here as authoritative as there's a solid chance I've missed some features we may want to take advantage of.

Usage example

interface example {
    get-value: async func() -> u32;
}
// Bindings
namespace example {
    // Async import
    Task<uint32_t> GetValue();

    // Implementation (export)
    namespace exports {
        uint32_t GetValue() {
            // User implementation
            return 42;
        }
    }
}

// Usage
Task<uint32_t> example_task = example::GetValue();
uint32_t value = co_await example_task;

Sketches on implementation details

// Cancellation support
auto cancel() -> std::optional<T> {
    uint32_t status = writer_->vtable_->cancel_write(writer_->handle_);
    if (status == (uint32_t)AsyncStatus::Cancelled) {
        // Successfully cancelled, return original value
        return std::move(value_);
    }
    return std::nullopt;  // Already completed
}

Sketch for reading values from a stream:

// Read one value (returns nullopt on EOF)
auto next() -> Task<std::optional<T>> {
    T value;
    auto result = co_await read(std::span<T>(&value, 1));
    if (result.count == 0) {
        co_return std::nullopt;
    }
    co_return std::move(value);
}

// Collect all remaining values
auto collect() -> Task<std::vector<T>> {
    std::vector<T> results;
    while (auto val = co_await next()) {
        results.push_back(std::move(*val));
    }
    co_return results;
}

ricochet avatar Nov 20 '25 22:11 ricochet

MISRA only covers up to C++17, so within automotive this version is a reasonable default. πŸ•ΈοΈ I have never seen anyone using neither modules nor concepts in the industry, C++14 is still ubiquitous.

For the existing bindings @TartanLlama and me decided to default to C++20 (well supported by the wasi-sdk at that time) with std::expected from C++23 using Sy's backport. Setting the limit to whatever wasi-sdk supports well, makes perfect sense to me.

Also I already have a working async, streams and futures implementation for the symmetric ABI variant in my fork: https://github.com/cpetig/wit-bindgen/blob/work-in-progress/tests/runtime-async/async/stream-string/test.cpp (I now see from my ifdefs that the writing side is much less implemented in C++ than Rust, I guess the inactive first branch would be the preferred way to go)

These days I will gladly write an asynchronous executor in Rust and use it via WIT from C++ (see https://github.com/cpetig/wit-bindgen/blob/work-in-progress/crates/symmetric_executor/cpp-client/stream_support.h and https://github.com/cpetig/wit-bindgen/blob/work-in-progress/crates/symmetric_executor/symmetric_stream/src/lib.rs ) - but I have bad history with getting co_await to do anything non-trivial.

That said I am happy for anyone teaching me how to use modern C++ (Sy told me that passing xvalues to rvalue references guarantees optimization), I just no longer use enough modern C++ to faithfully design a helpful API.

cpetig avatar Nov 21 '25 15:11 cpetig

Trying an answer to your questions:

  • Should we use exceptions for async failures? These appear to be uncommon in modern C++ for perf reasons and we should use std::expected<T, AsyncError> instead. I guess that we should have a plan for https://github.com/WebAssembly/wasi-sdk/issues/565 before even thinking about this. πŸ‘» Functional safety guidelines usually forbid exceptions, so AUTOSAR (as many other companies) created its own std::expected equivalent around 2018. 🀦

  • Move semantics for all async operations, and have them only use owned values (to prevent use-after-move) Yes, please πŸ¦€. Implicit copying always came back to hurt me.

  • No references/borrows in async contexts (to prevent dangling) I was expecting a closure in your example, but I guess this is my Rust brain talking.

  • All operations are cancellable ❀️

  • RAII for automatic handle cleanup πŸ‘

cpetig avatar Nov 21 '25 15:11 cpetig

I am most influenced by the design in https://www.autosar.org/fileadmin/standards/R24-11/AP/AUTOSAR_AP_EXP_ARAComAPI.pdf - basically a function returning std::future<std::expected<T, E>> and registering a callback for incoming events. The server derives from a base class and overrides the methods provided (Skeleton in AUTOSAR speak). Supporting both push (SetReceiveHandler) as well as pull (GetNewSamples) was a major design goal, see chapter 5.3.5.5).

For the Rust AUTOSAR API we went full async with Stream and similar modern types.

cpetig avatar Nov 21 '25 15:11 cpetig

OK these resources are extremely helpful. I'm still catching up here. My C++ days come from when C++11 was cool and we wanted to upgrade to C++14 "one day". But now I'm working on a very large C++ codebase that is exposed as a component, so I'm back in the game.

ricochet avatar Nov 21 '25 19:11 ricochet

"A very large C++ codebase" to me whispers "needs multi-threading". With Sy's cooperative multi-threading in wasmtime the only missing part is wasi-libc support for p3+coop threads. (I am currently investigating this problem space for one of our C++ code bases)

cpetig avatar Nov 21 '25 23:11 cpetig

Yes definitely threads will be handy to have internal to the component, but I think from a boundary perspective we wouldn't want to expose at the bindgen layer if we don't have to. Now we may have to if we can't set the baseline to C++20.

ricochet avatar Nov 24 '25 18:11 ricochet

I'm working on wasi-libc support right now FWIW!

TartanLlama avatar Nov 24 '25 18:11 TartanLlama