🟨 C++20 coroutine layer over Async
SaneCppAwait.h is an experimental library providing co_await syntax on top of Async.
Async workflows, but the API may still change as the coroutine model is refined. Use it for focused experiments and examples, not as a stable public API yet.Await is not a replacement for AsyncEventLoop. It wraps an existing AsyncEventLoop& with AwaitEventLoop and keeps the same lifetime expectations: sockets, buffers, requests, output objects, and tasks must remain valid while operations are active.
The goal is to let the compiler generate the callback state machine while preserving the Sane C++ style:
AwaitTask;Result;SC::Async code can coexist on the same event loop;AwaitAllocator.| Await API | Description |
|---|---|
| AwaitEventLoop | Coroutine-friendly wrapper around an existing AsyncEventLoop. |
| AwaitTask | Caller-owned coroutine task returning a plain SC::Result. |
| AwaitAllocator | Explicit allocator used by AwaitTask coroutine frame allocation. |
| AwaitSleepAwaiter | Awaiter for a single AsyncLoopTimeout operation. |
| AwaitSocketAcceptAwaiter | Awaiter for a single AsyncSocketAccept operation. |
| AwaitSocketConnectAwaiter | Awaiter for a single AsyncSocketConnect operation. |
| AwaitSocketSendAwaiter | Awaiter for a single AsyncSocketSend operation. |
| AwaitSocketSendToAwaiter | Awaiter for a single AsyncSocketSendTo operation. |
| AwaitSocketSendAllAwaiter | Awaiter that reactivates AsyncSocketSend until the whole buffer is sent. |
| AwaitSocketReceiveAwaiter | Awaiter for a single AsyncSocketReceive operation. |
| AwaitSocketReceiveExactAwaiter | Awaiter that reactivates AsyncSocketReceive until the whole caller buffer is filled. |
| AwaitSocketReceiveLineAwaiter | Awaiter that reads a ' '-terminated line into caller-provided storage. |
| AwaitSocketReceiveFromAwaiter | Awaiter for a single AsyncSocketReceiveFrom operation. |
| AwaitLoopWakeUp | Stable wake-up object that can resume an AwaitLoopWakeUpAwaiter from another thread. |
| AwaitLoopWakeUpAwaiter | Awaiter for a single AsyncLoopWakeUp delivery. |
| AwaitFileReadAwaiter | Awaiter for a single AsyncFileRead operation. |
| AwaitFileReadUntilFullOrEOFAwaiter | Awaiter that reads until the caller buffer is full or EOF is reached. |
| AwaitFileWriteAwaiter | Awaiter for a single AsyncFileWrite operation. |
| AwaitFileSendAwaiter | Awaiter for a single AsyncFileSend operation. |
| AwaitFilePollAwaiter | Awaiter for a single AsyncFilePoll operation. |
| AwaitFileSystemOperationAwaiter | Awaiter for selected AsyncFileSystemOperation path operations. |
| AwaitTaskGroup | Caller-storage structured group of child tasks owned by the current scope. |
| AwaitTaskRegistry | Caller-owned fixed-slot registry for detached/background tasks. |
| AwaitTaskGroupResultSummary | |
| AwaitTaskGroupWaitAllAwaiter | Awaiter that waits for every active task in an AwaitTaskGroup. |
| AwaitTaskGroupWaitAnyAwaiter | Awaiter that waits for the first active task in an AwaitTaskGroup to complete. |
| AwaitTaskRegistryWaitAllAwaiter | Awaiter that waits for every valid task in an AwaitTaskRegistry. |
| AwaitTaskRegistryWaitAnyAwaiter | Awaiter that waits for the first valid task in an AwaitTaskRegistry to complete. |
| AwaitProcessExitAwaiter | Awaiter for a single AsyncProcessExit operation. |
| AwaitSignalAwaiter | Awaiter for a single one-shot AsyncSignal operation. |
| AwaitTaskSpawnAwaiter | Awaiter that starts a child task if needed, then waits for it to complete. |
| AwaitTaskTimeoutAwaiter | Awaiter that waits for a child task, cancelling it if a timeout expires first. |
| AwaitLoopWorkAwaiter | Awaiter for a single AsyncLoopWork operation. |
The reply buffer is supplied by the caller because Await keeps the same stable object and buffer lifetime expectations as Async.
Complete console examples live in:
Examples/AwaitBackgroundDigest, showing ThreadPool-backed CPU work with loopWork() and caller-owned job state.Examples/AwaitBackgroundJobs, showing detached background coroutines through fixed caller-owned AwaitTaskRegistry slots.Examples/AwaitFirstResponse, racing caller-owned registry jobs with waitAny() and cancelling the slower response.Examples/AwaitConfigReload, showing spawnAndWait() for a single child coroutine that loads a config file.Examples/AwaitDeadline, showing a child coroutine deadline with waitFor() and cooperative cancellation.Examples/AwaitEcho, showing sockets, task groups, and fixed-allocator-backed tasks.Examples/AwaitDatagramPing, showing UDP sendTo() / receiveFrom() request and reply flow.Examples/AwaitTaskGroupFiles, showing Python asyncio.TaskGroup-style fan-out over two file reads while keeping task storage caller-owned.Examples/AwaitCallbackBridge, showing callback-style Async and coroutine-style Await sharing one caller-owned event loop during migration.Examples/AwaitFileCourier, showing a file copy followed by fileSend() over a socket.Examples/AwaitFilePatch, showing offset fileWrite() followed by fileRead() with caller-owned buffers.Examples/AwaitLineProtocol, showing a tiny CRLF text protocol built with receiveLine() and sendAll().Examples/AwaitManifestPreview, showing bounded fileReadUntilFullOrEOF() into caller-owned preview storage.Examples/AwaitProcessExitCodes, showing concurrent child-process exit waits with fixed job storage.Examples/AwaitServiceProbe, showing a small service probe with nested task groups, sockets, timeout cancellation, and allocator diagnostics.Examples/AwaitThreadWakeUp, showing another thread waking an Await coroutine through AwaitLoopWakeUp.send() is the direct one-shot wrapper over AsyncSocketSend: it completes when the socket reports one send operation, which may be smaller than the caller-provided data.
sendAll() is the higher-level stream helper: it reactivates the underlying send request until all caller-provided data has been sent. When an AwaitSocketSendResult* is provided, numBytes reports the cumulative byte count.
Single-buffer data can be sent directly:
Scatter/gather data uses caller-owned span storage, preserving the same no-allocation shape as Async:
🟨 MVP / Experimental
Current support includes:
sleep();accept() and connect();send(), scatter/gather send(), sendAll(), receive(), receiveExact(), and receiveLine();sendTo(), scatter/gather sendTo(), and receiveFrom();AwaitLoopWakeUp;fileRead(), offset fileRead(), fileReadUntilFullOrEOF(), fileWrite(), offset fileWrite(), scatter/gather fileWrite(), fileSend(), and POSIX filePoll();SerialDescriptor is a FileDescriptor and Async models serial I/O with AsyncFileRead / AsyncFileWrite;fsOpen(), fsClose(), fsRead(), fsWrite(), fsCopyFile(), fsCopyDirectory(), fsRename(), fsRemoveEmptyDirectory(), and fsRemoveFile();AwaitEventLoop awaiters, because it is a long-lived callback stream instead of a one-shot operation;loopWork();processExit();signal();spawnAndWait();AwaitTaskGroup waiting with caller-provided task pointer storage, waitAll(), and waitAny();AwaitTaskRegistry ownership for detached/background tasks that are cleaned up explicitly;waitFor();AwaitAllocator.Await is tested by SCAwaitTest, which is separate from SCTest because normal Await tests and examples use C++20 coroutines with standard C++ headers enabled. Coroutine frame storage is always explicit through AwaitAllocator, so enabling Await does not add a dependency on the Memory library.
Await is an experimental wrapper that lets coroutine bodies express asynchronous operations with co_await while still returning plain SC::Result values and using caller-provided output objects.
Coroutine-friendly wrapper around an existing AsyncEventLoop.
AwaitEventLoop wraps an existing caller-owned AsyncEventLoop&. It does not create, close, or otherwise own the underlying loop; callback-style Async requests and coroutine-style Await tasks can share the same loop as long as their stable objects remain alive while active.
Caller-owned coroutine task returning a plain SC::Result.
Explicit allocator used by AwaitTask coroutine frame allocation.
receive() is the direct one-shot wrapper over AsyncSocketReceive: it completes when the socket reports some data, disconnect, or an error, and the returned AwaitSocketReceiveResult::data span may be smaller than the caller buffer.
receiveExact() is the higher-level stream helper: it reactivates the underlying receive request until the caller buffer is full. If the peer disconnects before the buffer is full, it returns an error and, when an output result is provided, the result object describes the partial data received so far.
receiveLine() is a no-allocation line helper for simple text protocols. It reads into caller-provided storage until \n, trims a preceding \r from the reported line span, and fails if the buffer fills before the newline arrives.
fileRead() is the direct one-shot wrapper over AsyncFileRead: it may return fewer bytes than the caller buffer holds.
fileReadUntilFullOrEOF() mirrors FileDescriptor::readUntilFullOrEOF() for coroutine code. It reactivates the underlying read request until the caller buffer is full or EOF is reached, and reports the actually-read prefix through AwaitFileReadResult::data.
fileWrite() does not need a separate fileWriteAll() helper today: AsyncFileWrite already keeps writing until the provided single buffer or scatter/gather buffers are fully written, or returns an error.
For offset writes, prefer a single contiguous buffer in portable examples. Combining scatter/gather file writes with an explicit offset should be treated as backend-sensitive until the underlying AsyncFileWrite semantics are tightened.
Await intentionally does not expose direct filesystem watcher awaiters yet. FileSystemWatcher already models this as a long-lived callback stream with stable FileSystemWatcher::FolderWatcher objects and an event-loop runner (FileSystemWatcherAsyncT<AsyncEventLoop>). That shape is closer to an async iterator or channel than to the one-shot AsyncRequest wrappers currently living on AwaitEventLoop.
For now, coroutine examples should keep using callback-style FileSystemWatcher on the same underlying AsyncEventLoop, or introduce a small caller-owned adapter outside the core AwaitEventLoop surface when a concrete workflow needs it. If a future helper is added, it should be an explicit Await* object carrying the watcher state and caller-provided notification storage, not another thin method on AwaitEventLoop.
A future watcher adapter should look more like a bounded event queue than a one-shot awaiter:
FileSystemWatcher, each FolderWatcher, the event storage, and any wake-up object;AwaitEventLoop;Thin convenience helpers currently live on AwaitEventLoop when they preserve the shape of one underlying Async operation and only add a small, no-allocation loop over caller-provided storage. Examples are sendAll(), receiveExact(), receiveLine(), and fileReadUntilFullOrEOF().
If future helpers start carrying protocol state, buffering policy, parsing rules, or multiple stable objects, they should move into explicit Await* helper structs instead of making AwaitEventLoop a grab bag.
spawnAndWait() intentionally remains a convenience awaiter for the common "start one child and wait for it" case. The name is a little longer than plain spawn(), but it makes the "starts then awaits" behavior visible and avoids teaching co_await child to secretly start unowned tasks. AwaitTaskGroup is the structured API for multiple children, result aggregation, waitAny(), or custom child cancellation policy.
Some relevant blog posts are:
AwaitTask exposes explicit state queries (isValid(), isStarted(), isActive(), isCompleted(), and isCancellationRequested()) so shutdown code can stay readable without hiding lifetime rules.
AwaitTaskGroup and AwaitTaskRegistry also expose small storage/state helpers such as isEmpty(), isFull(), and remainingCapacity(). The registry adds hasActiveTasks() and hasCompletedTasks() for shutdown loops. These helpers are only query sugar over caller-owned storage; they do not allocate, drain, cancel, or change task ownership.
Await does not currently provide an RAII scope guard that cancels and drains tasks in a destructor. Draining can fail, and hiding that behind destructor control flow would be surprising in exceptions-off Sane C++. Prefer explicit shutdown code that returns Result, such as cancelAll(), await.run(), and clearCompleted().
Await inherits the platform shape of Async. POSIX backends can use filePoll() for ordinary file or pipe handles; on Windows the awaiter fails fast instead of hanging because normal AsyncFilePoll support is not currently exposed for those handles.
Thread-pool-backed file and filesystem awaiters use AsyncFileRead, AsyncFileWrite, AsyncFileSend, or AsyncFileSystemOperation with caller-provided ThreadPool storage. Cancellation is still cooperative: when the work is already running on a worker thread, completion may arrive before the stop request can take effect.
Local validation for Await changes should run macOS first, then Linux, then Windows. For targeted changes, prefer the smallest relevant SCAwaitTest section when allocator behavior changes. Windows changes should include focused SCAwaitTest Debug and Release runs before committing when the touched awaiter has platform-specific behavior.
Await does not currently add separate serial-port awaiter names. SerialDescriptor inherits from FileDescriptor, and the lower-level Async tests exercise serial I/O through AsyncFileRead and AsyncFileWrite; the coroutine layer keeps that shape by using fileRead() / fileWrite() with a SerialDescriptor after it has been associated with the underlying AsyncEventLoop.
This keeps the surface area small and preserves the plain-Result rule: if a future dedicated serial helper is added, it should still return Result and write any extra output into an explicit caller-owned result object.
Before Await should move from MVP / Experimental to a stable status, the remaining design forks should be resolved:
Await* objects.No-stdlib coroutine support is not required for the current MVP status. Await can remain isolated in SCAwaitTest while the API is still moving; a <coroutine> replacement and normal SCTest participation are stable-track work.
Cancellation is cooperative and follows the active awaiter's AsyncRequest::stop() when one exists. Cancelled awaits return AwaitCancelledResult(), and callers can use AwaitIsCancelled(result) when cancellation needs to be distinguished from ordinary failure while still preserving the plain Result API.
AwaitTask::cancel() is idempotent after cancellation has been requested. Cancellation is best-effort: if the suspended operation is still active, Await asks the underlying AsyncRequest to stop and resumes the coroutine with the cancellation result. If the operation has already completed, the normal completion result wins. Cancelling an already completed task succeeds and leaves its result unchanged. Awaiter callbacks resume their continuation synchronously, so there is no public "completed but not yet observed" cancellation window; tests cover the observable completion-wins behavior instead.
Awaiters keep the same stable-object rules as Async: sockets, file descriptors, buffers, result objects, wake-up objects, and child tasks must stay alive while the operation is active.
Destroying an active AwaitTask remains an assert-release programming error rather than a recoverable runtime result. Use AwaitTask::isActive() or registry helpers such as hasActiveTasks() in shutdown code when a diagnostic check is needed before storage goes out of scope.
When a completed child task is destroyed while an Async callback is still unwinding, AwaitEventLoop defers the actual coroutine frame destruction until run(), runOnce(), or runNoWait() returns. This keeps any embedded AsyncRequest alive until the lower-level event loop has finished its teardown work, without adding dynamic allocation.
Result objects may contain spans into caller-provided buffers. For example, AwaitSocketReceiveResult::data and AwaitFileReadResult::data point into the receive/read buffer passed to the awaiter. Keep those buffers alive until after the result has been inspected or copied elsewhere.
AwaitTaskGroup stores caller-owned task pointers in caller-provided storage. Its default cancellation policy is structured: cancelling the parent task while it is suspended in waitAll() or waitAny() cancels active children before the parent completes. AwaitTaskGroupCancelPolicy::LeaveChildrenRunning exists for advanced cases where child tasks outlive the waiting parent.
When children are already available as caller-owned task objects, spawnAll() can reduce repeated spawn() calls without hiding storage:
For request-backed children, keep the child AwaitTask objects in caller-owned storage that outlives the parent coroutine suspension. This mirrors Async's stable request-object rule: the coroutine frame owns the active awaiter, so the task object should not be a short-lived temporary when the event loop may still be unwinding an async completion. Examples/AwaitTaskGroupFiles keeps each child task inside a caller-owned job object.
waitAny() defaults to AwaitTaskGroupWaitAnyPolicy::CancelRemaining, so stack-owned child tasks are not left active after the first child completes. Use LeaveRemainingRunning only when pending children have an explicitly managed lifetime.
Cancellation policy names deliberately describe the observable effect rather than the call site. CancelRemaining is the structured default for waitAny() because it cancels every still-active sibling after a winner is known. LeaveRemainingRunning should read as an explicit escape hatch. AwaitTaskGroupCancelPolicy::CancelChildren applies when the parent task itself is cancelled while suspended in a group wait; the alternative, LeaveChildrenRunning, is only for owners that can prove child storage outlives the parent.
After waitAll() returns, collectResults() can copy each child Result into caller-provided storage and optionally fill AwaitTaskGroupResultSummary with counts plus the first failed task. This keeps aggregation no-allocation and still follows Sane C++'s plain-Result style.
If the caller only needs counts and first-failure metadata, summarizeResults() fills an AwaitTaskGroupResultSummary& without requiring a result span.
This is intentionally close to Python asyncio.TaskGroup at the control-flow level: spawn related children, wait for the group, and cancel siblings on structured failure paths. Examples/AwaitServiceProbe shows this with an outer group that runs a network exchange and a bounded maintenance check, while the network exchange owns an inner server/client group. The Sane C++ difference is visible storage: the task objects, task-pointer arrays, result arrays, buffers, and allocator backing store are all caller-owned so lifetime and memory use remain auditable.
The preferred model remains structured: keep child AwaitTask objects in caller-owned storage and wait through AwaitTaskGroup or spawnAndWait(). When a workflow really needs detached/background ownership, AwaitTaskRegistry provides the no-allocation shape: fixed caller-provided task slots, explicit spawn() into the first free slot, waitAll() to drain currently registered tasks, waitAny() to wait for the first completed slot, cancelAll() during shutdown, and clearCompleted() for cleanup and optional result aggregation.
Like AwaitTaskGroup::waitAny(), registry waitAny() defaults to cancelling the remaining active tasks after the winner is known. Pass AwaitTaskRegistryWaitAnyPolicy::LeaveRemainingRunning only when the registry storage is a true background lifetime owner and shutdown will later call cancelAll() or drain the active tasks.
AwaitTaskRegistry owns only task bookkeeping. It does not allocate, create coroutine frames, or hide lifetime management. Coroutine frames come from the AwaitAllocator bound to the task's AwaitEventLoop, and active registry tasks must be cancelled or drained before the registry storage is destroyed.
Recommended shutdown for a registry is explicit and repeatable:
Calling cancelAll() on an empty or already-cancelled registry is valid. Calling clearCompleted() repeatedly is also valid; it only releases completed slots. If a registry uses waitAny(LeaveRemainingRunning), the owner must still call cancelAll() or otherwise drain the remaining active tasks before destroying the registry storage.
AwaitAllocator is mandatory: every AwaitEventLoop receives an already opened allocator, and coroutine frame allocation fails cleanly if no AwaitEventLoop& can be discovered from the coroutine parameters. There is no hidden standard allocation fallback.
The default and recommended mode is fixed caller-owned storage through createFixed(Span<char>). This preserves the Sane C++ no-hidden-allocation rule while still allowing coroutine frames to be allocated and released individually.
Explicit opt-in modes exist for integration and experiments. They are not the default Sane path and should be visible at the call site:
createVirtual() reserves virtual address space and commits pages lazily until close(); use it only when a workflow has a deliberately large or platform-tuned reservation budget;createMalloc() uses one malloc() / free() pair per allocation;createPolymorphic() delegates to a caller-provided AwaitAllocatorInterface without depending on the Memory library.All modes expose the same diagnostics through AwaitAllocatorStatistics: allocation/release counts, requested allocated/released bytes, bytes in use, peak bytes in use, largest successful allocation requested, failed allocation count, and failed allocation sizes.
When sizing fixed storage, start from the smallest realistic workflow, run it once, inspect peakUsed() or statistics().peakBytesInUse, and compare it with largestAllocationSize() to understand the biggest single coroutine frame or allocator request observed. Then add headroom for the maximum number of concurrently active coroutine frames. A task that has completed but whose AwaitTask object is still alive may keep its frame allocated until that task object is destroyed or cleared from a registry.
The current examples intentionally use fixed storage in the 8-20 KiB range rather than a large catch-all arena. The socket-oriented examples print peak/largest/capacity diagnostics so those sizes can be revisited as coroutine frame layout changes across compilers.
Fixed storage should be sized intentionally per workflow. Prefer using diagnostics to tune the storage over switching to createMalloc() or createVirtual() just to hide a missing sizing decision.
Await does not use exceptions for control flow. The C++20 test, examples, and coroutine-shim probe intentionally keep CompileFlags::enableExceptions disabled, so macOS, Linux, and Windows validation covers the exception-disabled path. AwaitTask::Promise::unhandled_exception() remains present because the C++ coroutine promise interface requires it when compiling coroutine promise types.
The default/recommended path uses the standard C++ <coroutine> header. A strict SC_INCLUDE_STD_CPP=0 path now exists behind the explicit SC_AWAIT_ENABLE_NO_STDLIB_COROUTINE=1 opt-in macro. In that mode Libraries/Await/Internal/AwaitCoroutine.h provides the minimal compiler-facing std::coroutine_traits, std::coroutine_handle, std::suspend_always, and std::suspend_never names used by Await, backed by compiler coroutine builtins instead of standard-library headers.
This shim is intentionally narrow and is covered by the isolated SCAwaitCoroutineShimTest target, not by normal Await examples yet. It proves that a tiny coroutine task can compile and run with standard C++ headers disabled and exceptions disabled on macOS, Linux, and Windows. Full SCTest participation remains Stable-track work: the shim should mature against real AwaitTask usage and single-file-library generation before the main Await test moves out of SCAwaitTest.
🟨 MVP Follow-ups:
Async operations that have concrete examples or migration pressure.Await into SCTest.| Type | Lines Of Code | Comments | Sum |
|---|---|---|---|
| Headers | 1186 | 361 | 1547 |
| Sources | 3225 | 545 | 3770 |
| Sum | 4411 | 906 | 5317 |