🟨 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;AwaitArena storage.| Await API | Description |
|---|---|
| AwaitEventLoop | Coroutine-friendly wrapper around an existing AsyncEventLoop. |
| AwaitTask | Caller-owned coroutine task returning a plain SC::Result. |
| AwaitArena | Caller-owned monotonic arena for 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. |
| 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. |
| 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/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 arena-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/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();waitFor();Await is tested by SCAwaitTest, which is separate from SCTest because it requires C++20 and the standard coroutine header. SCAwaitArenaTest additionally compiles with SC_AWAIT_REQUIRE_ARENA=1 to keep the production-style no-fallback allocation path covered.
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.
Caller-owned monotonic arena for 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.
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. AwaitTaskGroup is the structured API for multiple children, result aggregation, waitAny(), or custom child cancellation policy.
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 plus SCAwaitArenaTest when arena allocation 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.
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.
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.
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.
Await does not currently provide detached/background task ownership. The preferred model remains structured: keep child AwaitTask objects in caller-owned storage and wait through AwaitTaskGroup or spawnAndWait().
If detached tasks become necessary, the no-allocation shape should be a caller-owned registry, roughly: fixed task slots, explicit spawn() into a free slot, explicit cancelAll() during shutdown, and an optional waitAll()/pollCompleted() surface for cleanup. Such a registry should own only task bookkeeping; coroutine frames still come from each task's normal storage policy, usually an AwaitArena.
AwaitArena can hold coroutine frames in caller-provided storage. Await currently supports two allocation modes:
AwaitEventLoop gives no-allocation coroutine frame storage for production-style Sane C++ usage.The arena exposes lightweight diagnostics for tuning frame storage: capacity() reports caller-provided bytes, used() reports current bump-pointer usage, peakUsed() reports the high-water mark since the last reset(), and failedAllocationSize() reports the last frame allocation request that did not fit.
Arena-backed frames are not individually freed. The caller must only reset the arena after all tasks using it have been destroyed.
AwaitEventLoop::hasArena() can be used by tests and examples to make the selected mode explicit. The intended direction is:
SC_AWAIT_REQUIRE_ARENA=1 to make coroutine frame allocation fail instead of falling back to standard allocation when no AwaitArena is discoverable from the coroutine parameters.The SCAwaitArenaTest target is compiled with SC_AWAIT_REQUIRE_ARENA=1 and verifies both sides of that contract: tasks fail cleanly without an arena and run normally when AwaitEventLoop receives caller-provided arena storage.
There is no separate explicit frame-storage API today. The current arena discovery through an AwaitEventLoop& parameter keeps coroutine signatures close to normal async code and avoids adding a second ownership concept. If this proves too compiler-dependent, the fallback should be an explicit task/factory type that binds a frame arena before creating the coroutine, not hidden allocation.
Await does not use exceptions for control flow. The C++20 test and examples 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 against the standard coroutine header.
The no-stdlib story is intentionally not part of the current MVP bar. Today Libraries/Await/Internal/AwaitCoroutine.h includes <coroutine> for std::coroutine_traits, std::coroutine_handle, and std::suspend_always. It also includes <new> so no-arena experimental builds can use std::nothrow fallback allocation.
A future -nostdinc++ shim looks technically possible but should be treated as Stable-track work. It would need to provide the compiler-facing coroutine names expected by C++20, map handles to the compiler coroutine builtins on each supported compiler, and require SC_AWAIT_REQUIRE_ARENA=1 so <new> and fallback allocation are not needed. Until that is proven on macOS, Linux, and Windows, Await stays in SCAwaitTest / SCAwaitArenaTest instead of the normal SCTest path.
🟨 MVP Follow-ups:
Async operations that have concrete examples or migration pressure.Await* helper.SC_AWAIT_REQUIRE_ARENA=1 should become the default for production-style builds.Await into SCTest.| Type | Lines Of Code | Comments | Sum |
|---|---|---|---|
| Headers | 730 | 253 | 983 |
| Sources | 1643 | 333 | 1976 |
| Sum | 2373 | 586 | 2959 |