From 1134423233d96cf865ac211a5b7fd676801e99fb Mon Sep 17 00:00:00 2001 From: Coldwings Date: Thu, 13 Aug 2026 11:40:53 +0800 Subject: [PATCH 1/2] perf(coro): share direct-await execution contexts --- CHANGELOG.md | 25 ++- examples/microbench.cpp | 51 ++++- include/elio/coro/promise_base.hpp | 142 +++++++++--- include/elio/coro/task.hpp | 81 +++---- include/elio/coro/task_execution_context.hpp | 22 +- include/elio/coro/task_group.hpp | 8 +- include/elio/runtime/scheduler.hpp | 10 +- include/elio/runtime/spawn.hpp | 1 + tests/unit/test_io.cpp | 4 + tests/unit/test_task_execution_context.cpp | 223 ++++++++++++++++--- tests/unit/test_task_group.cpp | 66 ++++++ wiki/API-Contracts.md | 6 +- wiki/API-Reference.md | 51 +++-- wiki/Core-Concepts.md | 70 +++--- wiki/Migrating-to-0.6.md | 20 +- wiki/Performance-Tuning.md | 14 +- 16 files changed, 621 insertions(+), 173 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90876651..af58f52e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,14 +52,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Transparent direct-await execution contexts**: Elio-to-Elio direct + `co_await` chains now share one `task_execution_context` for the complete + logical vthread. Nested lazy task promises defer their control allocation + until the actual first await or runtime handoff, so transparent helpers no + longer allocate frame-local cancellation state, a parent callback node, or + affinity copy-back bookkeeping. The actual awaiter is authoritative even + when a task was created elsewhere and moved. Independent `go`/`spawn` and + task-group roots still materialize distinct contexts, while foreign coroutine + promises remain context and cancellation boundaries. `task_scope()` is also + an explicit structured-cancellation boundary: caller cancellation flows in, + scope cancellation does not poison the caller after join, and final user + affinity flows back while operation-owned I/O pins remain local. A retained + token from a completed transparent child now identifies and can retain the + surrounding logical-vthread context. Low-level integrations that inspect or + raw-resume an unstarted nested promise must first materialize an independent + context; integrations that treated every directly awaited frame as an + independent cancellation domain must migrate to `spawn`/`go` (#1034). - **Co-allocated task control state**: Coroutine promises and default join states now allocate `task_execution_context` together with its task-lifetime cancellation state in one shared control block. Aliasing cancellation tokens retain the same post-frame lifetime, callback, and propagation semantics, - while completion-scoped parent links use weak ownership in both directions - so an escaped child token retains neither the registration nor its ancestors - and a completed named child cannot observe later parent cancellation. Child - final suspend deactivates propagation through a non-waiting atomic gate, so + while completion-scoped parent links for independently owned structured roots + use weak ownership in both directions. A token from such a root retains + neither its registration nor the parent context after completion. Final + suspend deactivates propagation through a non-waiting atomic gate, so concurrent parent cancellation cannot block a scheduler worker (#1032). The previously public but undocumented `task_execution_context::link_parent_cancellation()` runtime hook is now diff --git a/examples/microbench.cpp b/examples/microbench.cpp index 1fb12525..7a189368 100644 --- a/examples/microbench.cpp +++ b/examples/microbench.cpp @@ -2,8 +2,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -15,6 +17,12 @@ coro::task empty_task() { co_return; } +coro::task direct_await_chain(size_t frames) { + if (frames > 1) { + co_await direct_await_chain(frames - 1); + } +} + int main() { log::logger::instance().set_level(log::level::error); @@ -62,7 +70,34 @@ int main() { for (auto h : handles) h.destroy(); } - // 3. Measure MPSC push only (no scheduler overhead) + // 3. Measure direct Elio task composition. + { + constexpr int chain_iterations = 25000; + constexpr size_t chain_frames = 8; + + auto start = high_resolution_clock::now(); + for (int i = 0; i < chain_iterations; ++i) { + auto chain = direct_await_chain(chain_frames); + auto handle = coro::detail::task_access::handle(chain); + { + coro::detail::frame_context_scope frame_scope( + std::addressof(handle.promise())); + handle.resume(); + } + if (!handle.done()) { + std::abort(); + } + } + auto end = high_resolution_clock::now(); + auto ns = duration_cast(end - start).count(); + + std::cout << "Direct await chain (8 frames): " + << (static_cast(ns) / + (chain_iterations * chain_frames)) + << " ns/frame" << std::endl; + } + + // 4. Measure MPSC push only (no scheduler overhead) { runtime::mpsc_queue queue; @@ -79,7 +114,7 @@ int main() { while (queue.pop()) {} } - // 4. Measure Chase-Lev push only + // 5. Measure Chase-Lev push only { runtime::chase_lev_deque queue; @@ -96,7 +131,7 @@ int main() { while (queue.pop()) {} } - // 5. Compare atomic RMW with single-writer snapshot publication + // 6. Compare atomic RMW with single-writer snapshot publication { std::atomic published{0}; @@ -128,7 +163,7 @@ int main() { << " ns/update" << std::endl; } - // 6. Compare exact timestamps with the disabled diagnostic fast path + // 7. Compare exact timestamps with the disabled diagnostic fast path { std::atomic last_task_time{ steady_clock::now()}; @@ -163,7 +198,7 @@ int main() { << " ns/update" << std::endl; } - // 7. Measure atomic fence alone + // 8. Measure atomic fence alone { auto start = high_resolution_clock::now(); for (int i = 0; i < N; ++i) { @@ -175,7 +210,7 @@ int main() { std::cout << "Atomic release fence: " << (ns / N) << " ns" << std::endl; } - // 8. Measure eventfd write + // 9. Measure eventfd write { int fd = eventfd(0, EFD_NONBLOCK); uint64_t val = 1; @@ -191,7 +226,7 @@ int main() { close(fd); } - // 9. Full spawn path (with running scheduler) - includes alloc + spawn + // 10. Full spawn path (with running scheduler) - includes alloc + spawn { runtime::scheduler sched(4); sched.start(); @@ -213,7 +248,7 @@ int main() { sched.shutdown(); } - // 10. Measure warmed-up worker overhead + // 11. Measure warmed-up worker overhead { runtime::scheduler sched(4); sched.start(); diff --git a/include/elio/coro/promise_base.hpp b/include/elio/coro/promise_base.hpp index 8395e1ad..5d2d7cf2 100644 --- a/include/elio/coro/promise_base.hpp +++ b/include/elio/coro/promise_base.hpp @@ -13,6 +13,11 @@ class scheduler; namespace elio::coro { +namespace detail { +struct defer_nested_task_context_t final {}; +inline constexpr defer_nested_task_context_t defer_nested_task_context{}; +} // namespace detail + #ifdef ELIO_RUNTIME_TEST_HOOKS namespace detail { inline std::atomic promise_constructions_for_test{0}; @@ -95,22 +100,16 @@ class promise_base { static constexpr uint64_t FRAME_MAGIC = 0x454C494F46524D45ULL; promise_base() - : frame_magic_(FRAME_MAGIC) - , parent_(current_frame_) -#if ELIO_ENABLE_DEBUG_METADATA - , debug_state_(coroutine_state::created) - , debug_worker_id_(static_cast(-1)) - , debug_id_(0) // Lazy allocation - only allocated when id() is called -#endif - , execution_context_(detail::make_task_execution_context()) - { -#ifdef ELIO_RUNTIME_TEST_HOOKS - detail::promise_constructions_for_test.fetch_add( - 1, std::memory_order_relaxed); -#endif - current_frame_ = this; - } + : promise_base(false) {} + +protected: + /// Elio task promises may defer their control block while they are created + /// inside another Elio frame. The actual first await or runtime handoff, + /// rather than the creation site, decides their execution context. + explicit promise_base(detail::defer_nested_task_context_t) + : promise_base(true) {} +public: ~promise_base() noexcept { // Invoke spawn-completion callback first. This is the universal // -1 for active_tracked_ paired with the +1 the scheduler did at @@ -128,6 +127,29 @@ class promise_base { leave_frame_context(); } +private: + explicit promise_base(bool defer_nested_context) + : frame_magic_(FRAME_MAGIC) + , parent_(current_frame_) +#if ELIO_ENABLE_DEBUG_METADATA + , debug_state_(coroutine_state::created) + , debug_worker_id_(static_cast(-1)) + , debug_id_(0) // Lazy allocation - only allocated when id() is called +#endif + , execution_context_( + defer_nested_context && parent_ + ? nullptr + : detail::make_task_execution_context()) + { +#ifdef ELIO_RUNTIME_TEST_HOOKS + detail::promise_constructions_for_test.fetch_add( + 1, std::memory_order_relaxed); +#endif + current_frame_ = this; + } + +public: + /// Detach this frame from the current thread's frame chain. /// Call this before spawning a coroutine to another thread to avoid /// use-after-free when the original thread creates another coroutine. @@ -237,18 +259,80 @@ class promise_base { void set_worker_id(uint32_t) noexcept {} #endif - /// Shared scheduler-visible runtime policy state for this task. The task - /// owner itself deliberately does not carry this authority. + /// Shared scheduler-visible runtime policy state for this logical vthread. + /// A nested unstarted task may return null until its first direct await or + /// independent runtime handoff binds the authoritative context. [[nodiscard]] std::shared_ptr execution_context() const noexcept { return execution_context_; } - /// Establish one-way cancellation propagation from the Elio coroutine that - /// is actually starting this lazy task. This is separate from construction- - /// time virtual-stack ancestry, which may no longer be relevant after a - /// task has been moved. + /// Bind an unstarted transparent child to the context of the Elio promise + /// that actually awaits it. A task created outside the awaiter may already + /// have a provisional independent context; no task body has run yet, so + /// replacing it here is still safe and makes the actual await authoritative. + void bind_direct_await_context( + const std::shared_ptr& context) noexcept { + assert(context && "direct Elio await requires an execution context"); + assert(!parent_cancellation_linked_ && + "cannot rebind a task after parent cancellation is linked"); + execution_context_ = context; + } + + /// Mark an explicit structured-cancellation scope. Its direct await keeps + /// a distinct context so cancelling the scope cannot poison the caller's + /// logical vthread after the scope has joined. + void isolate_direct_await_context() noexcept { + isolate_direct_await_context_ = true; + } + + [[nodiscard]] bool direct_await_context_isolated() const noexcept { + return isolate_direct_await_context_; + } + + /// Bind an isolated structured scope to an Elio caller. Cancellation flows + /// into the scope, while the distinct context prevents scope cancellation + /// from becoming a sticky state on the caller. + void bind_isolated_direct_await_context(promise_base& parent) { + assert(isolate_direct_await_context_ && + "only an isolated task may use an isolated direct await"); + parent.ensure_independent_execution_context(); + ensure_independent_execution_context(); + if (parent.has_affinity()) { + set_affinity(parent.affinity()); + } + set_worker_local(parent.is_worker_local()); + link_parent_cancellation( + parent.execution_context()->get_cancel_token()); + isolated_elio_awaiter_bound_ = true; + } + + /// Preserve the logical vthread's user affinity when an isolated scope + /// returns. Runtime-owned I/O pins remain local to the completed scope. + void propagate_isolated_direct_await_policy_to_parent() noexcept { + if (!isolated_elio_awaiter_bound_ || !parent_) { + return; + } + if (has_affinity()) { + parent_->set_affinity(affinity()); + } else { + parent_->clear_affinity(); + } + } + + /// Materialize a distinct control block before an independent runtime + /// handoff or a foreign-promise await boundary. + void ensure_independent_execution_context() { + if (!execution_context_) { + execution_context_ = detail::make_task_execution_context(); + } + } + + /// Establish one-way cancellation propagation for an independently owned + /// task root linked by structured runtime policy. Transparent direct Elio + /// awaits share a context and do not need a callback registration. void link_parent_cancellation(cancel_token parent) { + ensure_independent_execution_context(); if (parent_cancellation_linked_) { throw std::logic_error( "task cancellation context already has a parent"); @@ -260,7 +344,7 @@ class promise_base { parent_cancellation_linked_ = true; } - /// End direct-await propagation when this task reaches final suspend. + /// End independently linked propagation when this task reaches final suspend. /// A named task object may retain the completed frame, but that ownership /// must not extend the logical parent/child cancellation relationship. void unlink_parent_cancellation() noexcept { @@ -361,15 +445,17 @@ class promise_base { uint64_t debug_id_; #endif - // Shared runtime policy/control plane. External runtime owners may retain - // this state after the coroutine frame itself has been destroyed. - const std::shared_ptr execution_context_; + // Shared runtime policy/control plane. Transparent direct-await frames use + // the root's state; external runtime owners may retain it after frame + // destruction. A nested unstarted task may remain null until it is bound. + std::shared_ptr execution_context_; - // The task frame owns parent propagation until final suspend, so an - // escaped child token retains only the child control block, not the - // registration or ancestors. + // An independently linked task frame owns parent propagation until final + // suspend. Transparent direct-await frames leave this registration empty. detail::task_parent_registration parent_cancellation_registration_; bool parent_cancellation_linked_ = false; + bool isolate_direct_await_context_ = false; + bool isolated_elio_awaiter_bound_ = false; // Keep scheduler accounting after the debugger-visible frame fields so the // stable magic/parent prefix remains at the start of promise_base. diff --git a/include/elio/coro/task.hpp b/include/elio/coro/task.hpp index a8bbf829..25301095 100644 --- a/include/elio/coro/task.hpp +++ b/include/elio/coro/task.hpp @@ -110,6 +110,11 @@ struct task_access { t.handle_.promise().detached_ = detached; } } + template + static void isolate_direct_await_context(TaskT& t) noexcept { + assert(t.handle_ && "cannot isolate an empty task"); + t.handle_.promise().isolate_direct_await_context(); + } // Access join_state from promise (for destruction notification) template static auto get_join_state(PromiseT& p) noexcept { @@ -412,10 +417,12 @@ class task { struct promise_type : promise_base { std::optional value_; std::coroutine_handle<> continuation_; - promise_base* awaiter_promise_ = nullptr; bool detached_ = false; std::shared_ptr> join_state_; + promise_type() + : promise_base(detail::defer_nested_task_context) {} + // Safety net: if the coroutine frame is destroyed without going // through final_awaiter (e.g., force-destroy during shutdown drain), // notify join_state so wait_destroyed() does not deadlock. @@ -491,22 +498,25 @@ class task { assert(!handle_.promise().continuation_ && "task cannot have multiple awaiters"); auto* parent = promise_base::current_frame(); - cancel_token parent_token; - // Runtime cancellation crosses direct awaits between Elio promises. - // A foreign coroutine promise is an explicit propagation boundary. + // Directly awaited Elio tasks are transparent frames in one logical + // vthread. Bind to the actual awaiter, not the task creation site. + // A foreign coroutine promise remains an explicit context boundary. if constexpr (std::is_convertible_v) { parent = std::addressof(awaiter.promise()); - parent_token = parent->execution_context()->get_cancel_token(); - // Directly awaited Elio tasks form one logical vthread. Carry the - // current user affinity into a new child frame; await_resume() - // copies the child's final value back so explicit changes made by - // deeper frames remain visible to the rest of the chain. - handle_.promise().awaiter_promise_ = parent; - if (!handle_.promise().has_affinity() && parent->has_affinity()) { - handle_.promise().set_affinity(parent->affinity()); + // Runtime handoff APIs materialize independent roots before + // execution. Keep direct handle-based integration safe as well: + // if such a deferred task is resumed manually, its first nested + // await establishes the otherwise missing root context. + parent->ensure_independent_execution_context(); + if (handle_.promise().direct_await_context_isolated()) { + handle_.promise().bind_isolated_direct_await_context(*parent); + } else { + handle_.promise().bind_direct_await_context( + parent->execution_context()); } + } else { + handle_.promise().ensure_independent_execution_context(); } - handle_.promise().link_parent_cancellation(std::move(parent_token)); handle_.promise().continuation_ = awaiter; handle_.promise().enter_frame_context(parent); return handle_; @@ -515,13 +525,7 @@ class task { assert(handle_ && "cannot resume an empty task"); auto& promise = handle_.promise(); auto exception = promise.exception(); - if (auto* awaiter = std::exchange(promise.awaiter_promise_, nullptr)) { - if (promise.has_affinity()) { - awaiter->set_affinity(promise.affinity()); - } else { - awaiter->clear_affinity(); - } - } + promise.propagate_isolated_direct_await_policy_to_parent(); promise.detach_from_parent(); if (exception) std::rethrow_exception(exception); return std::move(*promise.value_); @@ -540,10 +544,12 @@ class task { struct promise_type : promise_base { std::coroutine_handle<> continuation_; - promise_base* awaiter_promise_ = nullptr; bool detached_ = false; std::shared_ptr> join_state_; + promise_type() + : promise_base(detail::defer_nested_task_context) {} + // Safety net: if the coroutine frame is destroyed without going // through final_awaiter (e.g., force-destroy during shutdown drain), // notify join_state so wait_destroyed() does not deadlock. @@ -611,20 +617,25 @@ class task { assert(!handle_.promise().continuation_ && "task cannot have multiple awaiters"); auto* parent = promise_base::current_frame(); - cancel_token parent_token; - // Runtime cancellation crosses direct awaits between Elio promises. - // A foreign coroutine promise is an explicit propagation boundary. + // Directly awaited Elio tasks are transparent frames in one logical + // vthread. Bind to the actual awaiter, not the task creation site. + // A foreign coroutine promise remains an explicit context boundary. if constexpr (std::is_convertible_v) { parent = std::addressof(awaiter.promise()); - parent_token = parent->execution_context()->get_cancel_token(); - // Keep user affinity continuous across the logical vthread even - // though each lazy task owns a distinct execution context. - handle_.promise().awaiter_promise_ = parent; - if (!handle_.promise().has_affinity() && parent->has_affinity()) { - handle_.promise().set_affinity(parent->affinity()); + // Runtime handoff APIs materialize independent roots before + // execution. Keep direct handle-based integration safe as well: + // if such a deferred task is resumed manually, its first nested + // await establishes the otherwise missing root context. + parent->ensure_independent_execution_context(); + if (handle_.promise().direct_await_context_isolated()) { + handle_.promise().bind_isolated_direct_await_context(*parent); + } else { + handle_.promise().bind_direct_await_context( + parent->execution_context()); } + } else { + handle_.promise().ensure_independent_execution_context(); } - handle_.promise().link_parent_cancellation(std::move(parent_token)); handle_.promise().continuation_ = awaiter; handle_.promise().enter_frame_context(parent); return handle_; @@ -633,13 +644,7 @@ class task { assert(handle_ && "cannot resume an empty task"); auto& promise = handle_.promise(); auto exception = promise.exception(); - if (auto* awaiter = std::exchange(promise.awaiter_promise_, nullptr)) { - if (promise.has_affinity()) { - awaiter->set_affinity(promise.affinity()); - } else { - awaiter->clear_affinity(); - } - } + promise.propagate_isolated_direct_await_policy_to_parent(); promise.detach_from_parent(); if (exception) std::rethrow_exception(exception); } diff --git a/include/elio/coro/task_execution_context.hpp b/include/elio/coro/task_execution_context.hpp index fc3c9a65..d253a91b 100644 --- a/include/elio/coro/task_execution_context.hpp +++ b/include/elio/coro/task_execution_context.hpp @@ -24,18 +24,22 @@ namespace detail { struct task_execution_control_block; [[nodiscard]] std::shared_ptr make_task_execution_context(); +#ifdef ELIO_RUNTIME_TEST_HOOKS +inline std::atomic task_execution_context_allocations_for_test{0}; +#endif } /// Constant indicating no user affinity. Internal ownership such as an active /// worker-local I/O pin may still prevent migration. inline constexpr size_t NO_AFFINITY = std::numeric_limits::max(); -/// Shared runtime policy state for one coroutine task. +/// Shared runtime policy state for one logical vthread execution root. /// -/// The coroutine promise and external runtime owners keep shared references to -/// this control block. It records operation-local I/O ownership for scheduler -/// placement and owns task-chain cancellation authority. Each pending operation -/// still owns its own completion and cancellation state machine. +/// A root promise, its transparently awaited Elio task frames, and external +/// runtime owners keep shared references to this control block. It records +/// operation-local I/O ownership for scheduler placement and owns vthread-wide +/// cancellation authority. Each pending operation still owns its own completion +/// and cancellation state machine. class task_execution_context final { private: struct coallocated_state_key final {}; @@ -115,8 +119,8 @@ class task_execution_context final { return worker_local_.load(std::memory_order_acquire); } - /// Token observed by code running in this task. Cancellation propagates - /// from an active direct Elio awaiter when the lazy child is first started. + /// Token observed by code running in this logical vthread. Directly awaited + /// Elio task frames share this state; independent runtime roots do not. [[nodiscard]] cancel_token get_cancel_token() const noexcept { return cancellation_context_.token(); } @@ -209,6 +213,10 @@ struct task_execution_control_block final { inline std::shared_ptr detail::make_task_execution_context() { auto control = std::make_shared(); +#ifdef ELIO_RUNTIME_TEST_HOOKS + task_execution_context_allocations_for_test.fetch_add( + 1, std::memory_order_relaxed); +#endif auto context = std::shared_ptr( control, &control->context); auto cancellation_state = std::shared_ptr( diff --git a/include/elio/coro/task_group.hpp b/include/elio/coro/task_group.hpp index 79640d00..6fe8a04a 100644 --- a/include/elio/coro/task_group.hpp +++ b/include/elio/coro/task_group.hpp @@ -978,8 +978,10 @@ template std::decay_t, task_group&>>) [[nodiscard("co_await task_scope()")]] task task_scope(F&& body, task_group_options options = {}) { - return detail::task_scope_wrapper( + auto scope = detail::task_scope_wrapper( nullptr, options, std::decay_t(std::forward(body))); + detail::task_access::isolate_direct_await_context(scope); + return scope; } /// Run a callback-shaped structured scope on the selected scheduler worker. @@ -993,9 +995,11 @@ template [[nodiscard("co_await task_scope()")]] task task_scope(runtime::scheduler& scheduler, F&& body, task_group_options options = {}) { - return detail::task_scope_wrapper( + auto scope = detail::task_scope_wrapper( std::addressof(scheduler), options, std::decay_t(std::forward(body))); + detail::task_access::isolate_direct_await_context(scope); + return scope; } } // namespace elio::coro diff --git a/include/elio/runtime/scheduler.hpp b/include/elio/runtime/scheduler.hpp index e0d875bf..54e9af50 100644 --- a/include/elio/runtime/scheduler.hpp +++ b/include/elio/runtime/scheduler.hpp @@ -591,6 +591,7 @@ class scheduler { // independent task can execute on another worker. auto* promise = coro::get_promise_base(handle.address()); if (promise) { + promise->ensure_independent_execution_context(); promise->detach_from_parent(); } return do_spawn(handle, false); @@ -1179,11 +1180,13 @@ class scheduler { "cannot transfer a completed task to the scheduler"); } - auto handle = coro::detail::task_access::release(std::move(task)); - handle.promise().detached_ = true; + auto task_handle = coro::detail::task_access::handle(task); + task_handle.promise().ensure_independent_execution_context(); if constexpr (Pinned) { - handle.promise().set_affinity(worker_id); + task_handle.promise().set_affinity(worker_id); } + auto handle = coro::detail::task_access::release(std::move(task)); + handle.promise().detached_ = true; handle.promise().detach_from_parent(); if constexpr (Joinable) { @@ -1344,6 +1347,7 @@ class scheduler { if (detach_parent && promise) { // Initial ownership handoff must not retain construction-time // ancestry. Suspended-coroutine migration preserves await ancestry. + promise->ensure_independent_execution_context(); promise->detach_from_parent(); } diff --git a/include/elio/runtime/spawn.hpp b/include/elio/runtime/spawn.hpp index 78f4e6eb..b30f2618 100644 --- a/include/elio/runtime/spawn.hpp +++ b/include/elio/runtime/spawn.hpp @@ -134,6 +134,7 @@ auto spawn(coro::task&& task) -> coro::join_handle { if (sched && sched->is_running()) { return sched->go_joinable(std::move(task)); } + task_handle.promise().ensure_independent_execution_context(); auto state = std::make_shared>( task_handle.promise().execution_context()); task_handle = coro::detail::task_access::release(std::move(task)); diff --git a/tests/unit/test_io.cpp b/tests/unit/test_io.cpp index 1c915284..721f9294 100644 --- a/tests/unit/test_io.cpp +++ b/tests/unit/test_io.cpp @@ -631,6 +631,10 @@ TEST_CASE("orphaned worker I/O keeps ownership until backend cleanup", std::optional> child; child.emplace(orphaned_worker_recv(sockets[0], &received)); auto child_handle = elio::coro::detail::task_access::handle(*child); + // This test deliberately starts the child by raw handle instead of a + // transparent co_await or scheduler handoff, so it must model the + // independent-root boundary that those runtime APIs normally install. + child_handle.promise().ensure_independent_execution_context(); child_context = child_handle.promise().execution_context(); child_handle.resume(); diff --git a/tests/unit/test_task_execution_context.cpp b/tests/unit/test_task_execution_context.cpp index 897d1a39..1016546a 100644 --- a/tests/unit/test_task_execution_context.cpp +++ b/tests/unit/test_task_execution_context.cpp @@ -5,10 +5,12 @@ #include #include #include +#include #include #include #include #include +#include #include using namespace elio::coro; @@ -21,6 +23,14 @@ auto get_handle(task& value) { return elio::coro::detail::task_access::handle(value); } +template +void resume_in_frame(task& value) { + auto handle = get_handle(value); + elio::coro::detail::frame_context_scope scope( + std::addressof(handle.promise())); + handle.resume(); +} + task context_noop() { co_return; } @@ -29,6 +39,55 @@ task context_value() { co_return 42; } +task record_current_context( + std::shared_ptr* observed) { + auto* frame = promise_base::current_frame(); + *observed = frame ? frame->execution_context() : nullptr; + co_return; +} + +task record_direct_contexts( + std::shared_ptr* child) { + co_await record_current_context(child); +} + +task direct_context_chain(size_t remaining) { + if (remaining != 0) { + co_await direct_context_chain(remaining - 1); + } +} + +task create_unstarted_child( + std::optional>* output, + std::shared_ptr* child_context) { + output->emplace(record_current_context(child_context)); + co_return; +} + +task create_unstarted_direct_chain( + std::optional>* output, + std::shared_ptr* nested_context) { + output->emplace(record_direct_contexts(nested_context)); + co_return; +} + +task await_moved_child( + std::optional>* input) { + co_await std::move(input->value()); +} + +task spawn_recorded_child( + std::shared_ptr* parent_context, + std::shared_ptr* child_context, + std::shared_ptr* join_context) { + *parent_context = promise_base::current_frame()->execution_context(); + auto child = record_current_context(child_context); + auto joined = elio::spawn(std::move(child)); + *join_context = + elio::coro::detail::task_access::get_join_execution_context(joined); + co_await joined; +} + task capture_child_control(cancel_token* token, std::weak_ptr* weak) { auto* frame = promise_base::current_frame(); @@ -82,14 +141,6 @@ task expose_child_token_and_suspend( co_await capture_handle_awaitable{child_handle}; } -task await_named_child_and_continue( - std::coroutine_handle<>* child_handle, cancel_token* child_token, - std::atomic* parent_continued) { - auto child = expose_child_token_and_suspend(child_handle, child_token); - co_await child; - parent_continued->store(true, std::memory_order_release); -} - bool wait_for_flag(const std::atomic& flag) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); @@ -171,7 +222,118 @@ TEST_CASE("runtime task control state uses one shared allocation", REQUIRE(standalone_token.is_cancelled()); } -TEST_CASE("co-allocated parent cancellation links do not retain a cycle", +TEST_CASE("direct Elio awaits share one execution context allocation", + "[task][execution_context][cancellation][allocation]") { + elio::coro::detail::task_execution_context_allocations_for_test.store( + 0, std::memory_order_relaxed); + + std::shared_ptr parent_context; + std::shared_ptr child_context; + auto root = record_direct_contexts(&child_context); + parent_context = get_handle(root).promise().execution_context(); + + REQUIRE( + elio::coro::detail::task_execution_context_allocations_for_test.load( + std::memory_order_relaxed) == 1); + resume_in_frame(root); + + REQUIRE(get_handle(root).done()); + REQUIRE(parent_context); + REQUIRE(child_context == parent_context); + REQUIRE( + elio::coro::detail::task_execution_context_allocations_for_test.load( + std::memory_order_relaxed) == 1); + + elio::coro::detail::task_execution_context_allocations_for_test.store( + 0, std::memory_order_relaxed); + auto chain = direct_context_chain(8); + resume_in_frame(chain); + REQUIRE(get_handle(chain).done()); + REQUIRE( + elio::coro::detail::task_execution_context_allocations_for_test.load( + std::memory_order_relaxed) == 1); +} + +TEST_CASE("moved lazy task binds to its actual Elio awaiter context", + "[task][execution_context][ownership][allocation]") { + elio::coro::detail::task_execution_context_allocations_for_test.store( + 0, std::memory_order_relaxed); + + std::optional> deferred_child; + std::shared_ptr child_context; + auto producer = create_unstarted_child(&deferred_child, &child_context); + auto producer_context = get_handle(producer).promise().execution_context(); + resume_in_frame(producer); + REQUIRE(get_handle(producer).done()); + REQUIRE(deferred_child.has_value()); + + auto consumer = await_moved_child(&deferred_child); + auto awaiter_context = get_handle(consumer).promise().execution_context(); + resume_in_frame(consumer); + + REQUIRE(get_handle(consumer).done()); + REQUIRE(awaiter_context); + REQUIRE(child_context == awaiter_context); + REQUIRE(child_context != producer_context); + REQUIRE( + elio::coro::detail::task_execution_context_allocations_for_test.load( + std::memory_order_relaxed) == 2); +} + +TEST_CASE("raw-resumed deferred task materializes a root context on direct await", + "[task][execution_context][ownership][allocation]") { + elio::coro::detail::task_execution_context_allocations_for_test.store( + 0, std::memory_order_relaxed); + + std::optional> deferred_root; + std::shared_ptr nested_context; + auto producer = create_unstarted_direct_chain( + &deferred_root, &nested_context); + resume_in_frame(producer); + REQUIRE(get_handle(producer).done()); + REQUIRE(deferred_root.has_value()); + REQUIRE_FALSE( + get_handle(deferred_root.value()).promise().execution_context()); + + resume_in_frame(deferred_root.value()); + + REQUIRE(get_handle(deferred_root.value()).done()); + auto root_context = + get_handle(deferred_root.value()).promise().execution_context(); + REQUIRE(root_context); + REQUIRE(nested_context == root_context); + REQUIRE( + elio::coro::detail::task_execution_context_allocations_for_test.load( + std::memory_order_relaxed) == 2); +} + +TEST_CASE("spawned children materialize independent execution contexts", + "[task][execution_context][spawn][allocation]") { + scheduler sched(1); + sched.start(); + elio::coro::detail::task_execution_context_allocations_for_test.store( + 0, std::memory_order_relaxed); + + std::shared_ptr parent_context; + std::shared_ptr child_context; + std::shared_ptr join_context; + auto root = spawn_recorded_child( + &parent_context, &child_context, &join_context); + auto joined = sched.go_joinable(std::move(root)); + joined.wait_destroyed(); + REQUIRE_NOTHROW(joined.await_resume()); + + REQUIRE(parent_context); + REQUIRE(child_context); + REQUIRE(parent_context != child_context); + REQUIRE(join_context == child_context); + REQUIRE( + elio::coro::detail::task_execution_context_allocations_for_test.load( + std::memory_order_relaxed) == 2); + REQUIRE(sched.shutdown()); +} + +TEST_CASE("direct await chains share cancellation context without a cycle", "[task][execution_context][cancellation][ownership]") { cancel_token child_token; std::weak_ptr weak_parent; @@ -184,17 +346,20 @@ TEST_CASE("co-allocated parent cancellation links do not retain a cycle", get_handle(parent).resume(); REQUIRE_FALSE(get_handle(parent).done()); REQUIRE_FALSE(weak_child.expired()); + auto child_context = weak_child.lock(); + REQUIRE(child_context == parent_context); parent_context->request_cancel(); REQUIRE(child_token.is_cancelled()); child_token = {}; + child_context.reset(); } REQUIRE(weak_parent.expired()); REQUIRE(weak_child.expired()); } -TEST_CASE("escaped child tokens do not retain parent links or ancestors", +TEST_CASE("escaped direct-child tokens retain the logical vthread context", "[task][execution_context][cancellation][ownership]") { cancel_token escaped_token; std::weak_ptr weak_parent; @@ -208,19 +373,22 @@ TEST_CASE("escaped child tokens do not retain parent links or ancestors", REQUIRE(get_handle(parent).done()); REQUIRE_FALSE(weak_child.expired()); - // The completed child frame has released its parent registration. + REQUIRE(weak_child.lock() == parent_context); + // The transparent child token names the surrounding logical vthread, + // so cancellation remains observable after the child frame completes. parent_context->request_cancel(); - REQUIRE_FALSE(escaped_token.is_cancelled()); + REQUIRE(escaped_token.is_cancelled()); } - REQUIRE(weak_parent.expired()); + REQUIRE_FALSE(weak_parent.expired()); REQUIRE_FALSE(weak_child.expired()); escaped_token = {}; + REQUIRE(weak_parent.expired()); REQUIRE(weak_child.expired()); } -TEST_CASE("completed named children release parent cancellation links", +TEST_CASE("completed named direct children keep logical vthread tokens valid", "[task][execution_context][cancellation][ownership]") { cancel_token escaped_token; std::weak_ptr weak_parent; @@ -238,15 +406,17 @@ TEST_CASE("completed named children release parent cancellation links", // The named child frame is still owned by the suspended parent. REQUIRE_FALSE(weak_child.expired()); - // Completion, rather than eventual frame destruction, ends the - // logical parent/child cancellation relationship. + REQUIRE(weak_child.lock() == parent_context); + // The child frame is transparent: its token remains the parent/root + // logical-vthread token even after this named child has completed. parent_context->request_cancel(); - REQUIRE_FALSE(escaped_token.is_cancelled()); + REQUIRE(escaped_token.is_cancelled()); } - REQUIRE(weak_parent.expired()); + REQUIRE_FALSE(weak_parent.expired()); REQUIRE_FALSE(weak_child.expired()); escaped_token = {}; + REQUIRE(weak_parent.expired()); REQUIRE(weak_child.expired()); } @@ -255,13 +425,14 @@ TEST_CASE("child completion never waits for a parent cancellation callback", std::coroutine_handle<> child_handle; cancel_token child_token; std::atomic callback_started{false}; - std::atomic parent_continued{false}; + std::atomic release_callback{false}; std::atomic completion_returned{false}; - auto parent = await_named_child_and_continue( - &child_handle, &child_token, &parent_continued); - auto parent_context = get_handle(parent).promise().execution_context(); - get_handle(parent).resume(); + auto parent_context = std::make_shared(); + auto child = expose_child_token_and_suspend(&child_handle, &child_token); + get_handle(child).promise().link_parent_cancellation( + parent_context->get_cancel_token()); + resume_in_frame(child); // Raw test-only resume bypasses the scheduler's frame-context guard. promise_base::set_current_frame(nullptr); REQUIRE(child_handle); @@ -270,7 +441,7 @@ TEST_CASE("child completion never waits for a parent cancellation callback", // child completion isolates the task-parent link's non-blocking teardown. auto blocking_registration = child_token.on_cancel([&] { callback_started.store(true, std::memory_order_release); - while (!parent_continued.load(std::memory_order_acquire)) { + while (!release_callback.load(std::memory_order_acquire)) { std::this_thread::yield(); } }); @@ -286,12 +457,12 @@ TEST_CASE("child completion never waits for a parent cancellation callback", // On failure, release the callback so both threads can be joined and the // test reports normally instead of hanging the entire suite. - parent_continued.store(true, std::memory_order_release); + release_callback.store(true, std::memory_order_release); complete_child.join(); cancel_request.join(); REQUIRE(completed_without_wait); - REQUIRE(get_handle(parent).done()); + REQUIRE(get_handle(child).done()); } TEST_CASE("I/O pins override but do not rewrite user affinity", diff --git a/tests/unit/test_task_group.cpp b/tests/unit/test_task_group.cpp index 008e91f6..6ef6146c 100644 --- a/tests/unit/test_task_group.cpp +++ b/tests/unit/test_task_group.cpp @@ -566,6 +566,72 @@ TEST_CASE("task_scope fail-fast cancellation reaches its suspended body", sched.shutdown(); } +TEST_CASE("task_scope cancellation does not poison its caller context", + "[task_group][task_scope][structured][cancellation][context]") { + scheduler sched(1); + sched.start(); + std::shared_ptr caller_context; + std::shared_ptr scope_context; + std::atomic scope_cancelled{false}; + std::atomic caller_cancelled{true}; + + auto owner = sched.go_joinable([&]() -> task { + caller_context = + elio::coro::promise_base::current_frame()->execution_context(); + co_await elio::coro::task_scope( + [&](task_group& group) -> task { + scope_context = + elio::coro::promise_base::current_frame() + ->execution_context(); + group.request_cancel(); + scope_cancelled.store( + elio::coro::this_coro::cancel_token().is_cancelled(), + std::memory_order_release); + co_return; + }); + caller_cancelled.store( + elio::coro::this_coro::cancel_token().is_cancelled(), + std::memory_order_release); + }); + + owner.wait_destroyed(); + REQUIRE_NOTHROW(owner.await_resume()); + REQUIRE(caller_context); + REQUIRE(scope_context); + REQUIRE(scope_context != caller_context); + REQUIRE(scope_cancelled.load(std::memory_order_acquire)); + REQUIRE_FALSE(caller_cancelled.load(std::memory_order_acquire)); + sched.shutdown(); +} + +TEST_CASE("caller cancellation propagates into isolated task_scope context", + "[task_group][task_scope][structured][cancellation][context]") { + scheduler sched(1); + sched.start(); + elio::sync::event never; + std::atomic body_started{false}; + std::atomic body_cancelled{false}; + + auto owner = sched.go_joinable([&]() -> task { + co_await elio::coro::task_scope( + [&](task_group&) -> task { + body_started.store(true, std::memory_order_release); + const auto result = co_await never.wait( + elio::coro::this_coro::cancel_token()); + body_cancelled.store( + result == cancel_result::cancelled, + std::memory_order_release); + }); + }); + + REQUIRE(wait_for_flag(body_started)); + owner.request_cancel(); + owner.wait_destroyed(); + REQUIRE_NOTHROW(owner.await_resume()); + REQUIRE(body_cancelled.load(std::memory_order_acquire)); + sched.shutdown(); +} + TEST_CASE("task_scope collect-all preserves every child failure", "[task_group][task_scope][structured][failure]") { scheduler sched(3); diff --git a/wiki/API-Contracts.md b/wiki/API-Contracts.md index 5d971ef8..a20d3e11 100644 --- a/wiki/API-Contracts.md +++ b/wiki/API-Contracts.md @@ -45,11 +45,11 @@ by a broad module heading without checking the feature page or header comment. | Interface | Elio guarantees | Caller must guarantee | |-----------|-----------------|-----------------------| -| `coro::task` | A task is a move-only, single-shot lazy owner. Moving transfers the unstarted coroutine frame and leaves the source empty. Awaiting binds virtual-stack ancestry to the actual awaiter and, between Elio task promises, establishes one-way cancellation propagation before returning the result or rethrowing the stored exception. Linking cancellation may allocate and propagate allocation failure from `co_await`. Destroying a non-empty task destroys its frame unless ownership was transferred to the runtime. Runtime policy remains in the promise's shared execution context, not in the task object. | Do not await an empty or already-awaited task. Do not treat moving a task as migration, cancellation authority, or running work. Treat a foreign coroutine promise as a cancellation boundary unless adapter code deliberately bridges a token. Keep objects referenced by the coroutine alive across suspension points. | +| `coro::task` | A task is a move-only, single-shot lazy owner. Moving transfers the unstarted coroutine frame and leaves the source empty. Awaiting binds virtual-stack ancestry to the actual awaiter. An Elio-to-Elio direct await also shares the actual awaiter's execution context, cancellation token, affinity, and active-I/O ownership for the logical vthread; creation-time ancestry is irrelevant. A foreign promise remains a boundary and may materialize a distinct context. Destroying a non-empty task destroys its frame unless ownership was transferred to the runtime. Runtime policy remains in the bound promise context, not in the task object. | Do not await an empty or already-awaited task. Do not treat moving a task as migration, cancellation authority, or running work. Use an independent `spawn`/`go` root when a child needs a distinct cancellation or affinity domain. Treat a foreign coroutine promise as a boundary unless adapter code deliberately bridges a token. Keep objects referenced by the coroutine alive across suspension points. | | Direct `co_await` on Elio awaitables | Awaitables resume through the scheduler or backend path documented by that awaitable. | Do not destroy the awaited object, scheduler, stream, buffer, or synchronization primitive while an await is still registered unless the API documents safe teardown. | | `join_handle` | Awaiting a join handle returns the spawned task result or rethrows its exception. `is_ready()` is a non-blocking result-readiness check and can precede frame destruction. `is_destroyed()` observes frame release; after `wait_destroyed()` returns for a normally completed spawn, the root frame and its parameters have been destroyed, including any callable wrapper used by the callable overload. `request_cancel()` publishes a cooperative request through the spawned root's shared execution context and remains safe after frame destruction; it does not rewrite a result that already completed. The request propagates down direct lazy-task awaits between Elio tasks. | Await or intentionally discard join handles according to the task lifetime you need. Call `wait_destroyed()` only from a non-coroutine thread that may block. Do not use a moved-from handle. Pass `this_coro::cancel_token()` into cancellation-aware waits inside the task. Do not assume `request_cancel()` forcibly destroys the task, stops it promptly, crosses a foreign coroutine promise, or cancels a separate explicit token. Registered callback exceptions can propagate from `request_cancel()`. | | `coro::task_group` | Owns registration, cancellation, failure collection, and completion accounting for children submitted to one scheduler. The default failure policy records the first child failure, requests sibling cancellation, waits for every registered child frame to leave the group, and then rethrows that failure from the direct single-use `join()` awaitable. `join()` does not create or link a nested task. `collect_all` records failures without cancelling siblings and reports them together through `task_group_error`. A nonzero `max_concurrency` bounds executing child bodies while preserving every accepted child registration. `outstanding_children()` counts accepted child submissions, not an active `task_scope()` body. A group created on its scheduler inherits the current task's cancellation token. Its join continuation always resumes on a worker of the selected scheduler. An ordinary external thread, including the thread that called `scheduler::start()`, synchronously dispatches cancellation there; a worker in another scheduler posts cancellation asynchronously to avoid cross-scheduler deadlock. | Call `join()` exactly once while executing on a worker of the group's scheduler, and keep the group alive until it completes. Pass `this_coro::cancel_token()` into cancellation-aware child operations; cancellation is cooperative and cannot finish a child blocked in work that ignores its token. Do not spawn after joining starts. Keep child captures alive through join. Treat an explicitly selected scheduler as the group's only execution domain and keep it running until the group drains. Account for synchronous callback execution and possible blocking on ordinary external threads; do not assume cancellation has run when a request returns on another scheduler's worker. The destructor requests cancellation but does not synchronously join. | -| `coro::task_scope()` | Creates a task group for a callback-shaped lexical scope and runs the body under the group cancellation context. Normal return joins all children. A fail-fast child can therefore wake a token-aware suspended body before the scope joins and reports the child failure. If a body awaitable resumes elsewhere, scope cleanup hands execution back to a worker of the selected scheduler. The body callable and its captures remain alive until the children join. If the body throws, the scope requests child cancellation and joins every child. Fail-fast then rethrows the body failure; `collect_all` reports the body failure together with every child failure in `task_group_error`. | Use it when lexical cancel-and-join is required. The body must return an Elio task, must not call `join()` on its own group, and must not allow token-ignoring work to outlive captured resources. The explicit-scheduler overload must initially be awaited on a worker of that scheduler; it does not migrate a foreign worker or external thread into the scope. Automatic local objects in the body coroutine are destroyed when that coroutine returns, so children that can outlive the body must not retain references to those locals. Choose `collect_all` or a concurrency bound explicitly when fail-fast and unlimited execution are not suitable. Keep the selected scheduler running through cleanup and caller resumption. | +| `coro::task_scope()` | Creates a task group for a callback-shaped lexical scope and runs the body under an isolated group cancellation context. Caller cancellation propagates into the scope, but group or fail-fast cancellation does not leave the caller's token cancelled after the scope joins. Caller user affinity is inherited and the scope's final user-affinity value flows back; active I/O pins remain owned by the scope's operation context. Normal return joins all children. A fail-fast child can therefore wake a token-aware suspended body before the scope joins and reports the child failure. If a body awaitable resumes elsewhere, scope cleanup hands execution back to a worker of the selected scheduler. The body callable and its captures remain alive until the children join. If the body throws, the scope requests child cancellation and joins every child. Fail-fast then rethrows the body failure; `collect_all` reports the body failure together with every child failure in `task_group_error`. | Use it when lexical cancel-and-join is required. The body must return an Elio task, must not call `join()` on its own group, and must not allow token-ignoring work to outlive captured resources. The explicit-scheduler overload must initially be awaited on a worker of that scheduler; it does not migrate a foreign worker or external thread into the scope. Automatic local objects in the body coroutine are destroyed when that coroutine returns, so children that can outlive the body must not retain references to those locals. Choose `collect_all` or a concurrency bound explicitly when fail-fast and unlimited execution are not suitable. Keep the selected scheduler running through cleanup and caller resumption. | | `coro::generator` | `next()` delivers yielded values in producer order and returns completion through the documented optional result. It binds producer virtual-stack ancestry to the active consumer and restores the consumer context before yield and completion transfers, including after internal asynchronous suspension. | Use it as a single-consumer stream unless a future generator API documents sharing. Do not retain yielded references or views beyond their documented lifetime. | | `coro::cancel_source` | Calling `cancel()` publishes cooperative cancellation and synchronously dispatches callbacks outside the state lock. Same-dispatch reentrant teardown may remove a later selected callback. After dispatch, `cancel()` rethrows the first callback exception. Concurrent calls publish one request. | Keep callback code reentry-safe and account for execution on the requesting thread. Do not assume cancellation forcibly destroys operations that do not observe the token. Handle callback exceptions where callbacks may throw. | | `coro::cancel_token` | Cancellation-aware operations observe the token at their documented wait or poll points. Destroying or unregistering a callback registration prevents a callback not yet selected by cancellation. Outside callback dispatch, teardown waits when another thread has selected or started the callback. During callback reentry, cross-dispatch teardown is deferred to prevent mutual wait cycles; the callback payload remains dispatcher-owned. Self-unregistration and reentrant removal of a later callback by the same synchronous dispatcher do not deadlock. | Pass the token into every operation that should stop. Keep externally owned callback state synchronized: teardown called from another cancellation callback is not a cross-dispatch join point. Synchronize shared state accessed by different callbacks. An outer request cannot cancel waits that never receive or check the token. | @@ -74,7 +74,7 @@ by a broad module heading without checking the feature page or header comment. | `runtime::clear_affinity()` | Clears caller-requested affinity. It does not clear an active operation-local I/O pin. | Use it only when the coroutine no longer needs caller-selected placement. Do not treat it as cancellation or migration of pending I/O. | | `runtime::bind_to_current_worker()` | Binds the current coroutine to its current worker when one exists. | Call it only after entering scheduler execution and before relying on worker-local resources. | | Process `fork()` boundary | Before any Elio runtime or I/O resource is used, a single-threaded process may fork and let parent and child create independent fresh runtimes. If a runtime or any other thread is active, the parent may continue while the child immediately calls an explicitly async-signal-safe exec function such as `execve()`, or `_exit()`. See [[Fork Safety]]. | Do not schedule, resume, cancel, poll, shut down, destroy, or replace inherited Elio runtime state in the child. Do not unwind inherited Elio owners or call `std::exit()`. Do not assume every exec-family wrapper is async-signal-safe. Elio installs no `pthread_atfork` repair hooks; satisfy the fork contracts of the C/C++ runtime and all other linked libraries as well. | -| `coro::task_execution_context`, `promise_base::execution_context()` | Keep scheduler-visible task policy alive through shared ownership independently of coroutine-frame lifetime. The context owns task-chain cancellation authority and stores user affinity, the internal worker-local flag, and read-only ownership metadata for an active I/O pin. Operation completion and cancellation state remain owned by each awaitable/backend lifecycle. | Treat the context as a runtime integration boundary, not as proof that a coroutine frame or pending operation remains live. Do not mutate internal I/O ownership, store raw promise pointers in external owners, or treat task-level cancellation as operation completion. | +| `coro::task_execution_context`, `promise_base::execution_context()` | Keep scheduler-visible logical-vthread policy alive through shared ownership independently of coroutine-frame lifetime. Directly awaited Elio task frames share the root context; independent runtime roots, `task_scope()`, and foreign-promise boundaries use distinct contexts. A nested unstarted promise may have no context until its authoritative await or handoff. The context owns vthread cancellation authority and stores user affinity, the internal worker-local flag, and read-only ownership metadata for an active I/O pin. Operation completion and cancellation state remain owned by each awaitable/backend lifecycle. | Treat the context as a runtime integration boundary, not as proof that a particular coroutine frame or pending operation remains live. A token captured in a transparent child can retain the surrounding vthread context after that frame completes. Runtime integrations that bypass `co_await`, `spawn`, and scheduler handoff must materialize an independent context before inspecting policy or raw-resuming a nested task. Use an explicit independent root when isolation is required. Do not mutate internal I/O ownership, store raw promise pointers in external owners, or treat task-level cancellation as operation completion. | | `coro::promise_base::affinity()`, `set_affinity()`, `clear_affinity()` | Store and report caller-requested affinity through the shared execution context. Effective affinity is an active I/O owner first, then caller affinity. Affinity changes made while I/O is pending take effect only after the backend reaches a terminal completion. | Treat these methods as coroutine-runtime integration points, not as synchronization or I/O cancellation APIs. Use valid worker IDs for deterministic caller placement. | | `time::sleep_for()` | Wakes after the requested duration or token cancellation for cancellable overloads. If backend timer preparation fails, Elio uses the scheduler blocking pool without moving the continuation off its scheduler; if that pool is unavailable, the await resumes on its current worker and throws `std::runtime_error`. | Pass a token if cancellation is required. Do not rely on exact wakeup time under scheduler load. Keep the scheduler blocking pool available while fallback may be needed, or handle rejection exceptions. | | `time::sleep_until()` | Wakes at or after the requested time point. It has the same backend-fallback and blocking-pool rejection behavior as `sleep_for()`. | Use a clock/source appropriate for the application deadline and handle fallback rejection when the blocking pool can be shut down independently. | diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md index 2d9ea57e..430c3539 100644 --- a/wiki/API-Reference.md +++ b/wiki/API-Reference.md @@ -413,6 +413,13 @@ Automatic local objects in the returned body coroutine are still destroyed when that coroutine returns; a child that can continue after body return must not retain references to those locals. +Unlike an ordinary transparent Elio task await, `task_scope()` establishes a +distinct structured-cancellation context. Cancellation of the caller still +flows into the scope, but cancellation selected by the group does not remain +set on the caller after join. The scope inherits caller user affinity and +publishes its final user-affinity value back at return; operation-owned active +I/O pins remain local to the scope context. + `request_cancel()` runs group cancellation callbacks on the selected scheduler. An ordinary external thread, including the thread that called `scheduler::start()`, waits for that dispatch and can rethrow a callback @@ -1284,8 +1291,8 @@ coro::task stay_here() { `promise_base` is the frame-resident anchor for a shared `task_execution_context`. The task object itself does not carry scheduler -policy. External runtime owners may share the context without keeping the frame -alive. +policy. Directly awaited Elio frames share the logical-vthread root's context; +external runtime owners may share it without keeping any frame alive. ```cpp namespace elio::coro { @@ -1324,22 +1331,30 @@ public: identity. Treat them as an active placement constraint only when `has_active_io_pin()` is true; `active_io_pin_count()` is authoritative. -The context's cancellation state survives independently of the frame through -shared context ownership. Runtime-created contexts co-allocate this state in -one shared control block; a token uses aliasing ownership of that block and can -still outlive the frame. Starting a lazy task with direct `co_await` from -another Elio task links its context one-way to that awaiter's token through a -completion-scoped registration that weakly references the parent state and -whose callback weakly references the child state. Consequently, an escaped -child token retains neither the parent registration nor ancestor contexts, and -a completed named child does not observe later parent cancellation even while -its frame remains owned. Final suspend deactivates the link with a non-waiting -atomic transition; it does not block a scheduler worker while a concurrent -parent-cancellation callback finishes. A foreign coroutine promise is a -cancellation boundary unless it deliberately bridges a token. A `join_handle` -shares the spawned wrapper context, so cancellation remains race-safe without -storing a raw promise pointer. Completion and cancellation of individual -operations remain in their awaitable/backend state machines. +The context's cancellation state survives independently of frames through +shared ownership. Runtime-created contexts co-allocate this state in one shared +control block; a token uses aliasing ownership and can outlive the frame that +produced it. Elio-to-Elio direct `co_await` binds an unstarted child to the +actual awaiter's context. This requires no parent callback registration and +makes cancellation, affinity changes, and active I/O ownership continuous +through transparent helper frames. A retained child token names and may retain +the surrounding logical-vthread context after the child completes. + +Independent scheduler and task-group roots materialize distinct contexts before +first resume. Structured roots that inherit cancellation use a weak, +completion-scoped parent registration; final suspend deactivates that link with +a non-waiting atomic transition. `task_scope()` deliberately uses this isolated +model even when directly awaited, so group cancellation cannot poison its +caller. A foreign coroutine promise is also a context and cancellation boundary +unless it deliberately bridges a token. A +`join_handle` shares its spawned root's context, so cancellation remains +race-safe without a raw promise pointer. Completion and cancellation of +individual operations remain in their awaitable/backend state machines. + +An unstarted task created inside an active Elio frame may not have a materialized +context yet. Runtime integrations that bypass task `co_await`, `spawn()`, or a +scheduler handoff must establish an independent context before inspecting its +policy or resuming its raw coroutine handle. In 0.6, standalone construction is no longer `noexcept` because it allocates an independent cancellation state. Runtime construction can likewise fail while diff --git a/wiki/Core-Concepts.md b/wiki/Core-Concepts.md index a156a0ea..9447e970 100644 --- a/wiki/Core-Concepts.md +++ b/wiki/Core-Concepts.md @@ -33,25 +33,33 @@ object does not represent running work: once ownership is transferred to the scheduler, use the `join_handle` returned by `spawn()` when the work must be observed. Moving a task never migrates a running coroutine or pending I/O. -Runtime policy is deliberately separate from this lazy owner. Every -`promise_base` holds a `shared_ptr`, which stores -task-chain cancellation authority, caller-requested affinity, the internal -worker-local flag, and operation-local I/O pin diagnostics. A -scheduler-created join state shares that context with its wrapper promise, so -the policy state can remain valid after frame destruction without a raw promise -pointer. Coroutine promises allocate the context and its cancellation state in -one shared control block. Tokens use aliasing shared ownership of that block, -so a retained token preserves the cancellation state after frame destruction -without a second task-control allocation. The coroutine frame owns its parent -cancellation registration until final suspend; that registration weakly -references the parent state, and its callback weakly references the child -state. An escaped child token therefore retains neither the registration nor -any ancestor context, and task completion ends parent propagation even if a -named task object keeps the completed frame alive. Completion deactivates the -link through a one-shot atomic gate and never waits for a concurrently running -parent-cancellation callback on the scheduler worker; shared callback-node -ownership handles later reclamation. The context neither owns nor keeps the -coroutine frame alive. +Runtime policy is deliberately separate from this lazy owner. One logical +vthread execution root and all Elio tasks directly awaited beneath it share a +`task_execution_context`, which stores cancellation authority, +caller-requested affinity, the internal worker-local flag, and operation-local +I/O pin diagnostics. A nested lazy task can defer this control allocation: an +Elio-to-Elio direct await binds it to the actual awaiter's context, while an +independent scheduler handoff or foreign-promise boundary materializes a +distinct context before first resume. The creation site is not authoritative, +so moving an unstarted task does not preserve an accidental creator context. + +A scheduler-created join state shares its independent root's context, so policy +state can remain valid after frame destruction without a raw promise pointer. +Each materialized context and its cancellation state use one shared control +block. Tokens use aliasing ownership of that block, so a retained token from a +transparent child preserves the surrounding logical-vthread context after the +child frame completes. Independently linked structured-runtime roots still use +a frame-owned weak parent registration. Completion deactivates that link +through a one-shot atomic gate and never waits for a concurrently running +parent-cancellation callback on a scheduler worker. The context neither owns +nor keeps any coroutine frame alive. + +`task_scope()` is the intentional exception to transparent direct-await +sharing. It uses a distinct context because group/fail-fast cancellation must +stop scope work without leaving the caller's token permanently cancelled after +join. Parent cancellation still propagates into the scope, and user-affinity +changes flow back when it returns; operation-owned I/O pins do not. + Awaitables continue to own each pending operation's completion and cleanup state; those state machines are not moved into the task-wide context. While a worker-local I/O operation is pending, its operation state holds an @@ -355,9 +363,9 @@ C++20 stackless coroutines do not maintain a call stack in the traditional sense Elio reconstructs this information through a **virtual stack**: an intrusive linked list of `promise_base` objects connected by `parent_` pointers. The `current_frame_` thread-local identifies the frame executing on a thread. A lazy `task` does not remain installed merely because its frame is owned; when it is awaited, its `parent_` is rebound to the actual awaiting coroutine for that execution chain. Scheduler, synchronization, I/O, and affinity-migration resume paths preserve that parent and install the resumed frame for the duration of the resume call. Generator iteration similarly binds the producer to its active consumer and restores the consumer context before transferring a yielded value or completion. Initial scheduler ownership handoff, including targeted spawn, is separate and detaches construction-time ancestry. Virtual-stack ancestry itself costs one parent pointer per coroutine frame. -Separately, `promise_base` holds a shared execution-context reference; the -context has its own allocation and may outlive the frame when an external -runtime owner retains it. +Separately, a bound `promise_base` holds a shared execution-context reference. +Transparent frames reuse the root reference; the context may outlive every +frame when a token, join handle, or other runtime owner retains it. High-level spawn APIs retain arbitrary callables and their arguments in a root wrapper frame. When a caller already owns a lazy `task`, the rvalue-task @@ -764,6 +772,11 @@ waits. If a body awaitable resumes elsewhere, scope cleanup first returns to the selected scheduler. Return from the body to initiate joining; do not call `join()` inside it. +The scope is a cancellation boundary even though it is directly awaited: +caller cancellation flows in, while cancellation selected inside the group +does not become sticky on the caller after the scope joins. User-affinity +changes remain continuous across the boundary. + The scope retains the body callable and its captures until all children join. This does not extend automatic local lifetimes inside the body coroutine: those objects are destroyed when the body returns. Children that may continue after @@ -838,13 +851,14 @@ coro::task controller() { 4. Calling `source.cancel()` selects registered callbacks for synchronous dispatch For joinable runtime work, `join_handle::request_cancel()` publishes through the -spawned task's shared execution context. `coro::this_coro::cancel_token()` reads +spawned root's shared execution context. `coro::this_coro::cancel_token()` reads that context from the currently executing Elio frame. A lazy child directly -awaited by another Elio task is linked to that actual awaiter before first -resume, so the request flows down the active Elio task chain. The link is -one-way: cancelling a child does not cancel its parent, and separate `spawn()` -calls remain independent. A foreign coroutine promise is a cancellation -boundary unless adapter code deliberately bridges a token. +awaited by another Elio task binds to that actual awaiter's context before first +resume, so every transparent frame observes the same request and affinity +state. A retained child token therefore continues to name the logical vthread +after that child frame completes. Separate `spawn()` calls remain independent, +and a foreign coroutine promise is a cancellation boundary unless adapter code +deliberately bridges a token. Outside an active Elio runtime frame, `this_coro::cancel_token()` returns a default never-cancelled token. An explicit token parameter remains an independent diff --git a/wiki/Migrating-to-0.6.md b/wiki/Migrating-to-0.6.md index 4449d4c1..a3f4d779 100644 --- a/wiki/Migrating-to-0.6.md +++ b/wiki/Migrating-to-0.6.md @@ -16,16 +16,28 @@ below when upgrading from 0.5.x. - The logical vthread model remains. Virtual-stack ancestry, task chains, debugger inspection, affinity, and runtime execution context still describe coroutine execution independently of frame allocation. +- Directly awaited Elio task frames now share one execution context for the + logical vthread. A token captured in a transparent child therefore remains a + token for the surrounding chain after that child completes, and affinity + changes are immediately shared rather than copied back at return. Use an + explicit `spawn`/`go` root when a helper needs an independent cancellation or + affinity domain. `task_scope()` remains an isolated structured-cancellation + boundary so group cancellation cannot poison its caller; its final user + affinity still flows back. Foreign coroutine promises remain boundaries. - Lazy task ownership no longer installs creator-thread virtual-stack state. Ancestry is bound when a task is actually awaited; independent scheduler spawn detaches construction-time ancestry. ## Cancellation And Structured Concurrency -- Every Elio task has a shared execution context with cooperative cancellation - authority. Direct lazy-task awaits propagate runtime cancellation between - Elio promises. Foreign coroutine promises and explicit token parameters stay - separate unless an adapter deliberately bridges them. +- Every running Elio logical vthread has a shared execution context with + cooperative cancellation authority. Direct lazy-task awaits reuse it between + Elio promises. Independent runtime roots, foreign coroutine promises, and + explicit token parameters stay separate unless an adapter deliberately + bridges them. +- Low-level integrations that raw-resume a nested unstarted task must establish + an independent execution context before inspecting promise policy. Normal + task awaits and runtime handoffs do this automatically. - `join_handle::request_cancel()` is a best-effort request, not forced frame destruction. Pass `this_coro::cancel_token()` into every wait that should react and handle cancellation callback exceptions where callbacks may throw. diff --git a/wiki/Performance-Tuning.md b/wiki/Performance-Tuning.md index 37c5f8c3..3c7eeb46 100644 --- a/wiki/Performance-Tuning.md +++ b/wiki/Performance-Tuning.md @@ -375,10 +375,16 @@ For best io_uring performance: per-vthread bump allocator or LIFO destruction requirement. Keeping frames small still reduces allocation traffic and cache pressure, but callers should not depend on allocator locality or on the worker that eventually frees a frame. -The runtime co-allocates each promise's task execution context and task-lifetime -cancellation state in one shared control block. Cancellation tokens can retain -that block after frame destruction, so the allocation reduction does not weaken -token lifetime or parent-to-child cancellation propagation. +The runtime co-allocates each independent logical-vthread execution context and +cancellation state in one shared control block. Nested Elio tasks defer that +allocation and, when directly awaited by another Elio task, reuse the actual +awaiter's context. A deep transparent helper chain therefore pays one control +allocation for its root rather than one context plus one cancellation-link node +per frame. Independent `go`/`spawn` and task-group roots still receive separate +contexts; `task_scope()` also preserves a separate cancellation context by +contract. Use those APIs when policy isolation is intentional. Cancellation +tokens can retain the shared logical-vthread block after a particular frame is +destroyed. If an application already owns a non-empty lazy task that has not completed, transfer it directly to avoid a callable-wrapper frame and its task-local From 022217582b1127093de93241dc8bb082727da367 Mon Sep 17 00:00:00 2001 From: Coldwings Date: Thu, 13 Aug 2026 12:15:26 +0800 Subject: [PATCH 2/2] fix(coro): preserve frame ownership on context allocation failure --- include/elio/coro/task_execution_context.hpp | 8 +++ include/elio/runtime/scheduler.hpp | 9 +++- include/elio/sync/object_cache.hpp | 15 +++++- tests/unit/test_object_cache.cpp | 39 ++++++++++++++ tests/unit/test_task_execution_context.cpp | 54 ++++++++++++++++++++ wiki/API-Contracts.md | 2 +- wiki/API-Reference.md | 13 +++-- 7 files changed, 133 insertions(+), 7 deletions(-) diff --git a/include/elio/coro/task_execution_context.hpp b/include/elio/coro/task_execution_context.hpp index d253a91b..d2b90426 100644 --- a/include/elio/coro/task_execution_context.hpp +++ b/include/elio/coro/task_execution_context.hpp @@ -26,6 +26,8 @@ struct task_execution_control_block; make_task_execution_context(); #ifdef ELIO_RUNTIME_TEST_HOOKS inline std::atomic task_execution_context_allocations_for_test{0}; +inline std::atomic fail_next_task_execution_context_allocation_for_test{ + false}; #endif } @@ -212,6 +214,12 @@ struct task_execution_control_block final { inline std::shared_ptr detail::make_task_execution_context() { +#ifdef ELIO_RUNTIME_TEST_HOOKS + if (fail_next_task_execution_context_allocation_for_test.exchange( + false, std::memory_order_acq_rel)) { + throw std::bad_alloc{}; + } +#endif auto control = std::make_shared(); #ifdef ELIO_RUNTIME_TEST_HOOKS task_execution_context_allocations_for_test.fetch_add( diff --git a/include/elio/runtime/scheduler.hpp b/include/elio/runtime/scheduler.hpp index 54e9af50..b076260c 100644 --- a/include/elio/runtime/scheduler.hpp +++ b/include/elio/runtime/scheduler.hpp @@ -600,7 +600,14 @@ class scheduler { void spawn(std::coroutine_handle<> handle) { if (!handle) [[unlikely]] return; - if (!try_spawn(handle)) { + bool scheduled = false; + try { + scheduled = try_spawn(handle); + } catch (...) { + handle.destroy(); + throw; + } + if (!scheduled) { handle.destroy(); } } diff --git a/include/elio/sync/object_cache.hpp b/include/elio/sync/object_cache.hpp index 7a3e5e48..e72d3461 100644 --- a/include/elio/sync/object_cache.hpp +++ b/include/elio/sync/object_cache.hpp @@ -532,10 +532,23 @@ class object_cache { std::weak_ptr(state_), state_->cfg_.sweep_interval, sweep_cancel_.get_token()); + // Materialize while t still owns its frame. If allocation throws, task + // destruction during stack unwinding reclaims the frame and the guard + // restores sweep_started_ so a later cache access can retry. + coro::detail::task_access::handle(t) + .promise() + .ensure_independent_execution_context(); auto handle = coro::detail::task_access::release(std::move(t)); handle.promise().detached_ = true; handle.promise().detach_from_parent(); - if (sched->try_spawn(handle)) { + bool scheduled = false; + try { + scheduled = sched->try_spawn(handle); + } catch (...) { + handle.destroy(); + throw; + } + if (scheduled) { started_guard.committed = true; } else { handle.destroy(); diff --git a/tests/unit/test_object_cache.cpp b/tests/unit/test_object_cache.cpp index 5782b876..94adef76 100644 --- a/tests/unit/test_object_cache.cpp +++ b/tests/unit/test_object_cache.cpp @@ -159,6 +159,45 @@ TEST_CASE("object_cache construction failure and retry", "[object_cache]") { sched.shutdown(); } +TEST_CASE("object_cache retries sweep startup after context allocation failure", + "[object_cache][sweep][allocation][regression]") { + scheduler sched(1); + sched.start(); + + { + object_cache cache; + bool allocation_failed = false; + int observed = 0; + + auto h = spawn_joinable(sched, [&]() -> task { + auto& fail_next_context_allocation = + elio::coro::detail:: + fail_next_task_execution_context_allocation_for_test; + fail_next_context_allocation.store( + true, std::memory_order_release); + try { + (void)co_await cache.get("first", []() -> task { + co_return 1; + }); + } catch (const std::bad_alloc&) { + allocation_failed = true; + } + + auto value = co_await cache.get("second", []() -> task { + co_return 2; + }); + observed = *value; + }); + + h.wait_destroyed(); + REQUIRE_NOTHROW(h.await_resume()); + REQUIRE(allocation_failed); + REQUIRE(observed == 2); + } + + REQUIRE(sched.shutdown()); +} + TEST_CASE("object_cache constructor destruction clears constructing entry", "[object_cache][construction][cancellation]") { using cache_type = object_cache; diff --git a/tests/unit/test_task_execution_context.cpp b/tests/unit/test_task_execution_context.cpp index 1016546a..4ad47672 100644 --- a/tests/unit/test_task_execution_context.cpp +++ b/tests/unit/test_task_execution_context.cpp @@ -64,6 +64,17 @@ task create_unstarted_child( co_return; } +task retain_frame_marker(std::shared_ptr marker) { + (void)marker; + co_return; +} + +task create_unstarted_marked_child( + std::optional>* output, std::shared_ptr marker) { + output->emplace(retain_frame_marker(std::move(marker))); + co_return; +} + task create_unstarted_direct_chain( std::optional>* output, std::shared_ptr* nested_context) { @@ -307,6 +318,49 @@ TEST_CASE("raw-resumed deferred task materializes a root context on direct await std::memory_order_relaxed) == 2); } +TEST_CASE("raw scheduler spawn keeps exception-safe frame ownership", + "[task][execution_context][scheduler][ownership][allocation]") { + scheduler sched(1); + sched.start(); + + std::optional> borrowed_child; + auto borrowed_marker = std::make_shared(1); + std::weak_ptr borrowed_observer = borrowed_marker; + auto borrowed_producer = create_unstarted_marked_child( + &borrowed_child, std::move(borrowed_marker)); + resume_in_frame(borrowed_producer); + REQUIRE(borrowed_child.has_value()); + REQUIRE_FALSE(get_handle(borrowed_child.value()) + .promise() + .execution_context()); + + auto borrowed_handle = elio::coro::detail::task_access::release( + std::move(borrowed_child.value())); + elio::coro::detail::fail_next_task_execution_context_allocation_for_test + .store(true, std::memory_order_release); + REQUIRE_THROWS_AS(sched.try_spawn(borrowed_handle), std::bad_alloc); + REQUIRE_FALSE(borrowed_observer.expired()); + borrowed_handle.destroy(); + REQUIRE(borrowed_observer.expired()); + + std::optional> owned_child; + auto owned_marker = std::make_shared(1); + std::weak_ptr owned_observer = owned_marker; + auto owned_producer = create_unstarted_marked_child( + &owned_child, std::move(owned_marker)); + resume_in_frame(owned_producer); + REQUIRE(owned_child.has_value()); + + auto owned_handle = elio::coro::detail::task_access::release( + std::move(owned_child.value())); + elio::coro::detail::fail_next_task_execution_context_allocation_for_test + .store(true, std::memory_order_release); + REQUIRE_THROWS_AS(sched.spawn(owned_handle), std::bad_alloc); + REQUIRE(owned_observer.expired()); + + REQUIRE(sched.shutdown()); +} + TEST_CASE("spawned children materialize independent execution contexts", "[task][execution_context][spawn][allocation]") { scheduler sched(1); diff --git a/wiki/API-Contracts.md b/wiki/API-Contracts.md index a20d3e11..b29247cf 100644 --- a/wiki/API-Contracts.md +++ b/wiki/API-Contracts.md @@ -45,7 +45,7 @@ by a broad module heading without checking the feature page or header comment. | Interface | Elio guarantees | Caller must guarantee | |-----------|-----------------|-----------------------| -| `coro::task` | A task is a move-only, single-shot lazy owner. Moving transfers the unstarted coroutine frame and leaves the source empty. Awaiting binds virtual-stack ancestry to the actual awaiter. An Elio-to-Elio direct await also shares the actual awaiter's execution context, cancellation token, affinity, and active-I/O ownership for the logical vthread; creation-time ancestry is irrelevant. A foreign promise remains a boundary and may materialize a distinct context. Destroying a non-empty task destroys its frame unless ownership was transferred to the runtime. Runtime policy remains in the bound promise context, not in the task object. | Do not await an empty or already-awaited task. Do not treat moving a task as migration, cancellation authority, or running work. Use an independent `spawn`/`go` root when a child needs a distinct cancellation or affinity domain. Treat a foreign coroutine promise as a boundary unless adapter code deliberately bridges a token. Keep objects referenced by the coroutine alive across suspension points. | +| `coro::task` | A task is a move-only, single-shot lazy owner. Moving transfers the unstarted coroutine frame and leaves the source empty. Awaiting binds virtual-stack ancestry to the actual awaiter. An Elio-to-Elio direct await also shares the actual awaiter's execution context, cancellation token, affinity, and active-I/O ownership for the logical vthread; creation-time ancestry is irrelevant. A foreign promise remains a boundary and may materialize a distinct context. Context materialization can allocate and propagate allocation failure from `co_await` before the child starts. Destroying a non-empty task destroys its frame unless ownership was transferred to the runtime. Runtime policy remains in the bound promise context, not in the task object. | Do not await an empty or already-awaited task. Do not treat moving a task as migration, cancellation authority, or running work. Use an independent `spawn`/`go` root when a child needs a distinct cancellation or affinity domain. Treat a foreign coroutine promise as a boundary unless adapter code deliberately bridges a token. Keep objects referenced by the coroutine alive across suspension points. | | Direct `co_await` on Elio awaitables | Awaitables resume through the scheduler or backend path documented by that awaitable. | Do not destroy the awaited object, scheduler, stream, buffer, or synchronization primitive while an await is still registered unless the API documents safe teardown. | | `join_handle` | Awaiting a join handle returns the spawned task result or rethrows its exception. `is_ready()` is a non-blocking result-readiness check and can precede frame destruction. `is_destroyed()` observes frame release; after `wait_destroyed()` returns for a normally completed spawn, the root frame and its parameters have been destroyed, including any callable wrapper used by the callable overload. `request_cancel()` publishes a cooperative request through the spawned root's shared execution context and remains safe after frame destruction; it does not rewrite a result that already completed. The request propagates down direct lazy-task awaits between Elio tasks. | Await or intentionally discard join handles according to the task lifetime you need. Call `wait_destroyed()` only from a non-coroutine thread that may block. Do not use a moved-from handle. Pass `this_coro::cancel_token()` into cancellation-aware waits inside the task. Do not assume `request_cancel()` forcibly destroys the task, stops it promptly, crosses a foreign coroutine promise, or cancels a separate explicit token. Registered callback exceptions can propagate from `request_cancel()`. | | `coro::task_group` | Owns registration, cancellation, failure collection, and completion accounting for children submitted to one scheduler. The default failure policy records the first child failure, requests sibling cancellation, waits for every registered child frame to leave the group, and then rethrows that failure from the direct single-use `join()` awaitable. `join()` does not create or link a nested task. `collect_all` records failures without cancelling siblings and reports them together through `task_group_error`. A nonzero `max_concurrency` bounds executing child bodies while preserving every accepted child registration. `outstanding_children()` counts accepted child submissions, not an active `task_scope()` body. A group created on its scheduler inherits the current task's cancellation token. Its join continuation always resumes on a worker of the selected scheduler. An ordinary external thread, including the thread that called `scheduler::start()`, synchronously dispatches cancellation there; a worker in another scheduler posts cancellation asynchronously to avoid cross-scheduler deadlock. | Call `join()` exactly once while executing on a worker of the group's scheduler, and keep the group alive until it completes. Pass `this_coro::cancel_token()` into cancellation-aware child operations; cancellation is cooperative and cannot finish a child blocked in work that ignores its token. Do not spawn after joining starts. Keep child captures alive through join. Treat an explicitly selected scheduler as the group's only execution domain and keep it running until the group drains. Account for synchronous callback execution and possible blocking on ordinary external threads; do not assume cancellation has run when a request returns on another scheduler's worker. The destructor requests cancellation but does not synchronously join. | diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md index 430c3539..0aade682 100644 --- a/wiki/API-Reference.md +++ b/wiki/API-Reference.md @@ -73,10 +73,12 @@ starts. Destroying a non-empty task destroys the frame if ownership has not been transferred to the runtime. Do not await an empty task or await the same task more than once. -In 0.6, `await_suspend` is a potentially throwing template so an Elio child can -link to the actual Elio awaiter's cancellation context before first resume. -Allocation failure while registering that link propagates from the `co_await` -expression and the child does not start. Normal `co_await task` source remains +In 0.6, `await_suspend` is a potentially throwing template. A normal +Elio-to-Elio direct await binds the child to the logical vthread's existing +context without allocating. Context materialization may still allocate for a +raw-resumed deferred parent, a foreign-promise boundary, or the isolated +`task_scope()` boundary. Allocation failure propagates from the `co_await` +expression before the child starts. Normal `co_await task` source remains unchanged, but code that names the exact `await_suspend` member type or requires it to be `noexcept` must be updated. A foreign coroutine promise does not implicitly inherit an Elio runtime token; adapter code must bridge cancellation @@ -889,6 +891,9 @@ resume, retain, destroy, or otherwise resolve it. `spawn()` and `spawn_to()` consume ownership and destroy a handle rejected before execution. Internal wake and affinity-migration paths handle rejection explicitly; callers must not substitute one family for the other. +Materializing an independent execution context before initial publication can +throw. On that exception, `try_spawn()` leaves the borrowed handle live; +`spawn()` and `spawn_to()` destroy their consumed handle and then rethrow. `shutdown()` is the graceful path. Before waiting, it atomically closes independent initial task admission against `go()`, `go_to()`, `go_joinable()`,