|
| 1 | +# C++20 Coroutine support for libuv |
| 2 | + |
| 3 | +This directory contains an experimental C++20 coroutine layer for writing |
| 4 | +asynchronous libuv operations as sequential C++ code using `co_await`. |
| 5 | + |
| 6 | +The primary goal is to allow multi-step async operations (such as |
| 7 | +open + stat + read + close) to be written as straight-line C++ instead of |
| 8 | +callback chains, while maintaining full integration with Node.js async_hooks, |
| 9 | +AsyncLocalStorage, microtask draining, and environment lifecycle management. |
| 10 | + |
| 11 | +## File overview |
| 12 | + |
| 13 | +* `uv_task.h` -- `UvTask<T>`: The lightweight, untracked coroutine return type. |
| 14 | + No V8 or Node.js dependencies. Suitable for internal C++ coroutines that do |
| 15 | + not need async_hooks visibility or task queue draining. |
| 16 | + |
| 17 | +* `uv_tracked_task.h` -- `UvTrackedTask<T, Name>`: The fully-integrated |
| 18 | + coroutine return type. Each resume-to-suspend segment is wrapped in an |
| 19 | + `InternalCallbackScope`, giving it the same semantics as any other callback |
| 20 | + entry into Node.js. The `Name` template parameter is a compile-time string |
| 21 | + that identifies the async resource type visible to `async_hooks.createHook()`. |
| 22 | + |
| 23 | +* `uv_awaitable.h` -- Awaitable wrappers for libuv async operations: |
| 24 | + `UvFsAwaitable` (fs operations), `UvFsStatAwaitable` (stat-family), |
| 25 | + `UvWorkAwaitable` (thread pool work), and `UvGetAddrInfoAwaitable` |
| 26 | + (DNS resolution). Each embeds the libuv request struct directly in the |
| 27 | + coroutine frame, avoiding separate heap allocations. |
| 28 | + |
| 29 | +* `uv_promise.h` -- Helpers for bridging coroutines to JavaScript Promises: |
| 30 | + `MakePromise()`, `ResolvePromise()`, `RejectPromiseWithUVError()`. |
| 31 | + |
| 32 | +## Usage |
| 33 | + |
| 34 | +### Basic pattern (binding function) |
| 35 | + |
| 36 | +```cpp |
| 37 | +// The coroutine. The return type carries the async resource name as |
| 38 | +// a compile-time template argument. |
| 39 | +static coro::UvTrackedTask<void, "FSREQPROMISE"> DoAccessImpl( |
| 40 | + Environment* env, |
| 41 | + v8::Global<v8::Promise::Resolver> resolver, |
| 42 | + std::string path, |
| 43 | + int mode) { |
| 44 | + ssize_t result = co_await coro::UvFs( |
| 45 | + env->event_loop(), uv_fs_access, path.c_str(), mode); |
| 46 | + if (result < 0) |
| 47 | + coro::RejectPromiseWithUVError(env, resolver, result, "access", |
| 48 | + path.c_str()); |
| 49 | + else |
| 50 | + coro::ResolvePromiseUndefined(env, resolver); |
| 51 | +} |
| 52 | + |
| 53 | +// The binding entry point (called from JavaScript). |
| 54 | +static void Access(const FunctionCallbackInfo<Value>& args) { |
| 55 | + Environment* env = Environment::GetCurrent(args); |
| 56 | + // ... parse args, check permissions ... |
| 57 | + |
| 58 | + auto resolver = coro::MakePromise(env, args); |
| 59 | + auto task = DoAccessImpl(env, std::move(resolver), path, mode); |
| 60 | + task.InitTracking(env); // assigns async_id, captures context, emits init |
| 61 | + task.Start(); // begins execution (fire-and-forget) |
| 62 | +} |
| 63 | +``` |
| 64 | +
|
| 65 | +### Multi-step operations |
| 66 | +
|
| 67 | +Multiple libuv calls within a single coroutine are sequential co_await |
| 68 | +expressions. The intermediate steps (between two co_await points) are pure C++ |
| 69 | +with no V8 overhead: |
| 70 | +
|
| 71 | +```cpp |
| 72 | +static coro::UvTrackedTask<void, "COROREADFILE"> ReadFileImpl( |
| 73 | + Environment* env, |
| 74 | + v8::Global<v8::Promise::Resolver> resolver, |
| 75 | + std::string path) { |
| 76 | + ssize_t fd = co_await coro::UvFs( |
| 77 | + env->event_loop(), uv_fs_open, path.c_str(), O_RDONLY, 0); |
| 78 | + if (fd < 0) { /* reject and co_return */ } |
| 79 | +
|
| 80 | + auto [err, stat] = co_await coro::UvFsStat( |
| 81 | + env->event_loop(), uv_fs_fstat, static_cast<uv_file>(fd)); |
| 82 | + // ... read, close, resolve ... |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +### Coroutine composition |
| 87 | + |
| 88 | +`UvTask<T>` and `UvTrackedTask<T, Name>` can be co_awaited from other |
| 89 | +coroutines. This allows factoring common operations into reusable helpers: |
| 90 | + |
| 91 | +```cpp |
| 92 | +UvTask<ssize_t> OpenFile(uv_loop_t* loop, const char* path, int flags) { |
| 93 | + co_return co_await UvFs(loop, uv_fs_open, path, flags, 0); |
| 94 | +} |
| 95 | + |
| 96 | +UvTrackedTask<void, "MYOP"> OuterCoroutine(Environment* env, ...) { |
| 97 | + ssize_t fd = co_await OpenFile(env->event_loop(), path, O_RDONLY); |
| 98 | + // ... |
| 99 | +} |
| 100 | +``` |
| 101 | +
|
| 102 | +## Lifecycle |
| 103 | +
|
| 104 | +### UvTask (untracked) |
| 105 | +
|
| 106 | +`UvTask<T>` uses lazy initialization. The coroutine does not run until it is |
| 107 | +either co_awaited from another coroutine (symmetric transfer) or explicitly |
| 108 | +started with `Start()`. When `Start()` is called, the coroutine runs until its |
| 109 | +first `co_await`, then control returns to the caller. The coroutine frame |
| 110 | +self-destructs when the coroutine completes. |
| 111 | +
|
| 112 | +### UvTrackedTask (tracked) |
| 113 | +
|
| 114 | +`UvTrackedTask<T, Name>` follows the same lazy/fire-and-forget pattern but |
| 115 | +adds three phases around `Start()`: |
| 116 | +
|
| 117 | +1. **Creation**: The coroutine frame is heap-allocated by the compiler. |
| 118 | + The coroutine is suspended at `initial_suspend` (lazy). |
| 119 | +
|
| 120 | +2. **`InitTracking(env)`**: Assigns an `async_id`, captures the current |
| 121 | + `async_context_frame` (for AsyncLocalStorage propagation), creates a |
| 122 | + resource object for `executionAsyncResource()`, emits the async_hooks |
| 123 | + `init` event and a trace event, registers in the Environment's coroutine |
| 124 | + task list, and reports external memory to V8. |
| 125 | +
|
| 126 | +3. **`Start()`**: Marks the task as detached (fire-and-forget) and resumes |
| 127 | + the coroutine. Each resume-to-suspend segment is wrapped in an |
| 128 | + `InternalCallbackScope` that provides: |
| 129 | + * async_hooks `before`/`after` events |
| 130 | + * `async_context_frame` save/restore (AsyncLocalStorage) |
| 131 | + * Microtask and `process.nextTick` draining on close |
| 132 | + * `request_waiting_` counter management for event loop liveness |
| 133 | +
|
| 134 | +4. **Completion**: At `final_suspend`, the last `InternalCallbackScope` is |
| 135 | + closed (draining task queues), the async_hooks `destroy` event is emitted, |
| 136 | + the task is unregistered from the Environment, external memory accounting |
| 137 | + is released, and the coroutine frame is freed. |
| 138 | +
|
| 139 | +## How the awaitable dispatch works |
| 140 | +
|
| 141 | +The `UvFs()` factory function returns a `UvFsAwaitable` that embeds a `uv_fs_t` |
| 142 | +directly in the coroutine frame. When the coroutine hits `co_await`: |
| 143 | +
|
| 144 | +1. `await_transform()` on the promise wraps it in a `TrackedAwaitable`. |
| 145 | +2. `TrackedAwaitable::await_suspend()`: |
| 146 | + * Closes the current `InternalCallbackScope` (drains microtasks/nextTick). |
| 147 | + * Records the `uv_req_t*` for cancellation support. |
| 148 | + * Increments `request_waiting_` (event loop liveness). |
| 149 | + * Calls the inner `await_suspend()`, which dispatches the libuv call with |
| 150 | + `req_.data = this` pointing back to the awaitable. |
| 151 | +3. The coroutine is suspended. Control returns to the event loop. |
| 152 | +4. When the libuv operation completes, `OnComplete()` calls |
| 153 | + `handle_.resume()` to resume the coroutine. |
| 154 | +5. `TrackedAwaitable::await_resume()`: |
| 155 | + * Decrements `request_waiting_`. |
| 156 | + * Clears the cancellation pointer. |
| 157 | + * Opens a new `InternalCallbackScope` for the next segment. |
| 158 | + * Returns the result (e.g., `req_.result` for fs operations). |
| 159 | +
|
| 160 | +## Environment teardown |
| 161 | +
|
| 162 | +During `Environment::CleanupHandles()`, the coroutine task list is iterated and |
| 163 | +`Cancel()` is called on each active task. This calls `uv_cancel()` on the |
| 164 | +in-flight libuv request (if any), which causes the libuv callback to fire with |
| 165 | +`UV_ECANCELED`. The coroutine resumes, sees the error, and completes normally. |
| 166 | +The `request_waiting_` counter ensures the teardown loop waits for all |
| 167 | +coroutine I/O to finish before destroying the Environment. |
| 168 | +
|
| 169 | +## Allocation comparison with ReqWrap |
| 170 | +
|
| 171 | +For a single async operation (e.g., `fsPromises.access`): |
| 172 | +
|
| 173 | +| | ReqWrap pattern | Coroutine pattern | |
| 174 | +|---|---|---| |
| 175 | +| C++ heap allocations | 3 | 1 (coroutine frame) | |
| 176 | +| V8 heap objects | 7 | 3 (resource + resolver + promise) | |
| 177 | +| Total allocations | 10 | 4 | |
| 178 | +
|
| 179 | +For a multi-step operation (open + stat + read + close): |
| 180 | +
|
| 181 | +| | 4x ReqWrap | Single coroutine | |
| 182 | +|---|---|---| |
| 183 | +| C++ heap allocations | 12 | 1 | |
| 184 | +| V8 heap objects | 28 | 3 | |
| 185 | +| Total allocations | 40 | 4 | |
| 186 | +| InternalCallbackScope entries | 4 | 5 (one per segment + initial) | |
| 187 | +
|
| 188 | +The coroutine frame embeds the `uv_fs_t` (~440 bytes) directly. The compiler |
| 189 | +may overlay non-simultaneously-live awaitables in the frame, so a multi-step |
| 190 | +coroutine does not necessarily pay N times the `uv_fs_t` cost. |
| 191 | +
|
| 192 | +## Known limitations |
| 193 | +
|
| 194 | +* **Heap snapshot visibility**: The coroutine frame is a plain `malloc` |
| 195 | + allocated by the C++ coroutine machinery. It is not visible to V8 heap |
| 196 | + snapshots or `MemoryRetainer`. `AdjustAmountOfExternalAllocatedMemory` is |
| 197 | + used to give V8 a rough signal of the external memory pressure, but the |
| 198 | + exact frame contents are not inspectable. |
| 199 | +
|
| 200 | +* **Snapshot serialization**: `UvTrackedTask` holds `v8::Global` handles that |
| 201 | + cannot be serialized into a startup snapshot. There is currently no safety |
| 202 | + check to prevent snapshotting while coroutines are active. In practice this |
| 203 | + is not a problem because snapshots are taken at startup before I/O begins. |
| 204 | +
|
| 205 | +* **Trace event names**: The existing `AsyncWrap` trace events use a |
| 206 | + `ProviderType` enum with a switch statement to select the trace event name. |
| 207 | + The coroutine pattern uses free-form string names. The `init` trace event |
| 208 | + uses the provided name; the `destroy` trace event currently uses a generic |
| 209 | + `"coroutine"` category name rather than the per-instance name. |
0 commit comments