From f61939fbb87f7b9b59055e5e62e3160a2963a2fa Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:19:16 +0100 Subject: [PATCH 01/33] pass 1 --- src/core/receiver.cxx | 33 +++++++++++++--- src/core/root.cxx | 90 ++++++++++++++++++++++++++++++++++++++++++- src/core/schedule.cxx | 76 ++++++++++++++++++++++++++---------- test/src/cancel.cpp | 29 ++++++++------ 4 files changed, 188 insertions(+), 40 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 73289b7a..25c26955 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -22,16 +22,23 @@ export struct broken_receiver_error final : libfork_exception { /** * @brief Shared state between a scheduled task and its receiver handle. * - * @tparam T The return type of the scheduled coroutine. - * @tparam Stoppable If true, the state owns a stop_source that can be used - * to cancel the root task externally. + * This class is internal — users interact with it only via the exported + * `root_state` wrapper (defined in the schedule partition) and the returned + * `receiver` handle. * - * Constructors forward arguments for in-place construction of the return value. - * Internal access is gated behind a hidden friend: `get(key_t, receiver_state&)`. + * The class embeds a 1 KiB aligned buffer that the root task's coroutine + * frame is placement-new'd into (see the custom operator new/delete and + * final_suspend awaiter in root.cxx). Because the frame lives inside the + * buffer, `receiver_state` must outlive the frame — arranged by holding a + * type-erased `std::shared_ptr` inside the root promise that is + * hand-over-hand moved out of the frame before the frame is destroyed. */ -export template +template class receiver_state { public: + /// Size of the embedded coroutine-frame buffer (bytes). + static constexpr std::size_t buffer_size = 1024; + struct empty {}; /// Default construction — return value is default-initialised (or empty for void). @@ -54,6 +61,17 @@ class receiver_state { m_stop.request_stop(); } + /** + * @brief Raw pointer to the embedded buffer. + * + * Used by the root task's promise_type::operator new to placement-construct + * the coroutine frame inside this state's storage. + */ + [[nodiscard]] + auto buffer() noexcept -> void * { + return m_buffer; + } + private: template friend class receiver; @@ -100,6 +118,9 @@ class receiver_state { return {&self}; } + // Buffer first — it is the largest member and alignment-sensitive. + alignas(std::max_align_t) std::byte m_buffer[buffer_size]{}; + [[no_unique_address]] std::conditional_t, empty, T> m_return_value{}; std::exception_ptr m_exception; diff --git a/src/core/root.cxx b/src/core/root.cxx index 00990260..403356e6 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -21,12 +21,47 @@ namespace lf { struct get_frame_t {}; +/** + * @brief Tag awaited inside the root body to install a type-erased shared_ptr + * keep-alive into the promise. + * + * The keep-alive is moved out of the frame by final_awaiter before the frame + * is destroyed, so the receiver_state (which owns the buffer the frame lives + * in) is kept alive until *after* frame teardown completes. + */ +struct set_keep_alive_t { + std::shared_ptr ptr; +}; + template struct root_task { struct promise_type { frame_type frame{Checkpoint{}}; + /// Owns a ref to the receiver_state hosting this coroutine's buffer. + /// Moved out by `final_awaiter::await_suspend` before frame destruction. + std::shared_ptr keep_alive; + + /** + * @brief Placement operator new: place the coroutine frame in the + * receiver_state's embedded buffer. + * + * Deduces R and Stoppable from the first coroutine argument (the + * `std::shared_ptr>` passed to `root_pkg`). + */ + template + static auto operator new(std::size_t size, + std::shared_ptr> const &recv, + CoroArgs const &.../*unused*/) noexcept -> void * { + LF_ASSUME(recv != nullptr); + LF_ASSUME(size <= receiver_state::buffer_size); + return recv->buffer(); + } + + /// No-op: the buffer is owned by the receiver_state, not the frame. + static auto operator delete(void * /*ptr*/, std::size_t /*size*/) noexcept -> void {} + struct frame_awaitable : std::suspend_never { frame_type *frame; [[nodiscard]] @@ -50,11 +85,57 @@ struct root_task { return {.child = child}; } + struct set_keep_alive_awaitable { + set_keep_alive_t tag; + promise_type *self; + constexpr auto await_ready() const noexcept -> bool { return true; } + constexpr void await_suspend(std::coroutine_handle<>) const noexcept {} + constexpr void await_resume() noexcept { self->keep_alive = std::move(tag.ptr); } + }; + + constexpr auto await_transform(set_keep_alive_t tag) noexcept -> set_keep_alive_awaitable { + return {.tag = std::move(tag), .self = this}; + } + constexpr auto get_return_object() noexcept -> root_task { return {.promise = this}; } constexpr static auto initial_suspend() noexcept -> std::suspend_always { return {}; } - constexpr static auto final_suspend() noexcept -> std::suspend_never { return {}; } + /** + * @brief Custom final_suspend. + * + * The root task's coroutine frame lives inside the receiver_state's + * embedded buffer. We must ensure the receiver_state (which owns that + * buffer) outlives the frame teardown itself. The strategy: + * + * 1. Move the type-erased keep-alive shared_ptr out of the promise + * into a local on the host stack. After this the promise no + * longer holds a ref. + * 2. Call `h.destroy()`. This runs parameter + promise destructors + * and then our no-op `operator delete`. No frame-memory access + * occurs after this point. + * 3. Return from `await_suspend`. As we unwind, the stack-local + * shared_ptr's destructor runs; if its ref was the last, it + * destroys the receiver_state (and releases the buffer memory) + * cleanly — we are no longer executing inside the buffer. + * + * Destroying a coroutine from within its own final_awaiter::await_suspend + * is a well-known idiom: by the time await_suspend is called the body + * has completed, so there is nothing else for the frame to do. + */ + struct final_awaiter { + constexpr auto await_ready() const noexcept -> bool { return false; } + void await_suspend(std::coroutine_handle h) const noexcept { + // Step 1: move keep-alive onto our stack. + std::shared_ptr local = std::move(h.promise().keep_alive); + // Step 2: destroy the frame (promise + parameter destructors, no-op delete). + h.destroy(); + // Step 3: `local` goes out of scope on return — possibly frees receiver_state. + } + constexpr void await_resume() const noexcept {} + }; + + constexpr static auto final_suspend() noexcept -> final_awaiter { return {}; } constexpr static void return_void() noexcept {} @@ -80,6 +161,13 @@ root_pkg(std::shared_ptr> recv, Fn fn, Args... args using checkpoint = checkpoint_t; + // Install the keep-alive shared_ptr into the promise before anything else. + // The type-erased std::shared_ptr shares ownership with `recv`, so + // even after `recv` (the coroutine parameter) is destroyed during frame + // teardown the receiver_state stays alive until final_awaiter drops the + // keep-alive on the host stack. + co_await set_keep_alive_t{std::shared_ptr{recv}}; + // This is a pointer to the current root_task's frame frame_type *root = not_null(co_await get_frame_t{}); diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 5980ea43..3752675c 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -43,27 +43,59 @@ concept schedulable = schedulable_decayed, Context, std::decay_ template using invoke_decay_result_t = async_result_t, Context, std::decay_t...>; -template -using schedule_state_t = receiver_state, Stoppable>; - export template requires schedulable using schedule_result_t = receiver>; /** - * @brief Schedule a function with a pre-allocated receiver state. + * @brief Lightweight move-only handle owning a pre-allocated root task state. + * + * `root_state` is a simple wrapper the caller constructs and then passes + * by value into `schedule`. It has no public methods beyond construction + * and move: all user-visible interaction with the task happens through the + * `receiver` returned from `schedule`. + * + * Construction allocates a `receiver_state` on the heap; this + * state embeds a 1 KiB buffer into which the root coroutine frame will be + * placement-constructed by `schedule`. + */ +export template +class root_state { + public: + /// Allocate the backing receiver_state. + root_state() : m_ptr(std::make_shared>()) {} + + // Move-only. + root_state(root_state &&) noexcept = default; + auto operator=(root_state &&) noexcept -> root_state & = default; + root_state(root_state const &) = delete; + auto operator=(root_state const &) -> root_state & = delete; + + ~root_state() = default; + + private: + [[nodiscard]] + friend auto get(key_t, root_state &self) noexcept -> std::shared_ptr> & { + return self.m_ptr; + } + + std::shared_ptr> m_ptr; +}; + +/** + * @brief Schedule a function using a caller-provided `root_state`. * - * This is the primary overload: the caller provides a shared_ptr to the - * receiver state, allowing custom allocation. The stop_token bound to the - * root frame depends on whether the state is cancellable. + * The root coroutine frame is placement-constructed inside the 1 KiB buffer + * embedded in the state's `receiver_state`. Lifetime of the state (and + * therefore the buffer) is extended past frame destruction by a type-erased + * keep-alive shared_ptr installed into the root promise. * - * This function is strongly exception safe. + * Strongly exception safe. */ export template requires schedulable, Args...> && std::same_as, Args...>> -constexpr auto -schedule(Sch &&sch, std::shared_ptr> state, Fn &&fn, Args &&...args) +constexpr auto schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> receiver { using context_type = context_t; @@ -72,8 +104,13 @@ schedule(Sch &&sch, std::shared_ptr> state, Fn &&fn LF_THROW(schedule_error{}); } + // Grab a copy of the shared_ptr before handing it to root_pkg. + std::shared_ptr> sp = get(key(), state); + + LF_ASSUME(sp != nullptr); + // Package takes shared ownership of the state; fine if this throws. - root_task task = root_pkg(state, std::forward(fn), std::forward(args)...); + root_task task = root_pkg(sp, std::forward(fn), std::forward(args)...); LF_ASSUME(task.promise != nullptr); @@ -81,7 +118,7 @@ schedule(Sch &&sch, std::shared_ptr> state, Fn &&fn task.promise->frame.parent = nullptr; if constexpr (Stoppable) { - task.promise->frame.stop_token = get(key(), *state).get_stop_token(); + task.promise->frame.stop_token = get(key(), *sp).get_stop_token(); } else { task.promise->frame.stop_token = stop_source::stop_token{}; // non-cancellable root } @@ -95,14 +132,11 @@ schedule(Sch &&sch, std::shared_ptr> state, Fn &&fn LF_RETHROW; } - return {key(), std::move(state)}; + return {key(), std::move(sp)}; } /** - * @brief Convenience overload: allocates receiver state via make_shared. - * - * Defaults to non-cancellable (Stoppable=false). Delegates to the primary - * overload above. + * @brief Convenience overload: default-constructs a non-cancellable root_state. */ export template requires schedulable, Args...> @@ -112,10 +146,10 @@ schedule(Sch &&sch, Fn &&fn, Args &&...args) -> receiver; using R = invoke_decay_result_t; - auto state = std::make_shared>(); - - return schedule( - std::forward(sch), std::move(state), std::forward(fn), std::forward(args)...); + return schedule(std::forward(sch), + root_state{}, + std::forward(fn), + std::forward(args)...); } } // namespace lf diff --git a/test/src/cancel.cpp b/test/src/cancel.cpp index c648d8a7..8ef9b1d0 100644 --- a/test/src/cancel.cpp +++ b/test/src/cancel.cpp @@ -35,8 +35,11 @@ import libfork; // source propagates through the chain to the inner scope. // // H. Stoppable receiver / pre-cancelled root: -// receiver_state::request_stop() before schedule() triggers -// the goto-cleanup fast path in root.cxx — task body never executes. +// root_state + receiver::request_stop() immediately after +// schedule() — covers the goto-cleanup fast path in root.cxx on +// schedulers where the task has not yet begun running. Racy in +// principle, so the test only asserts completion, not that the body +// was skipped. namespace { @@ -353,14 +356,14 @@ auto test_nested_child_scope_chain(lf::env) -> lf::task // ============================================================ // H. Stoppable receiver / pre-cancelled root. // -// receiver_state::request_stop() before schedule() makes the root -// frame's stop_token immediately satisfied, triggering the goto-cleanup -// fast path in root.cxx so the task body never runs. +// Using root_state + receiver::request_stop() exercises the +// goto-cleanup fast path in root.cxx when stop is requested before the +// worker resumes the task. // ============================================================ template -auto pre_cancelled_root_fn(lf::env, bool *ran) -> lf::task { - *ran = true; +auto pre_cancelled_root_fn(lf::env, std::atomic *ran) -> lf::task { + ran->store(true, std::memory_order_relaxed); co_return; } @@ -439,14 +442,16 @@ void tests(Sch &scheduler) { REQUIRE(std::move(recv).get()); } - SECTION("stoppable receiver: pre-cancelled root task body never executes") { - bool ran = false; - auto state = std::make_shared>(); - state->request_stop(); + SECTION("stoppable receiver: root_state + request_stop completes cleanly") { + std::atomic ran = false; + lf::root_state state; auto recv = lf::schedule(scheduler, std::move(state), pre_cancelled_root_fn, &ran); REQUIRE(recv.valid()); + recv.request_stop(); std::move(recv).get(); - REQUIRE(!ran); + // The task body may or may not have run depending on scheduler timing; + // what matters is that get() completes without error. + (void)ran.load(); } #if LF_COMPILER_EXCEPTIONS From 50f3c9d6437c6aa85ffdf92547c94c6e5e851ba9 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:27:24 +0100 Subject: [PATCH 02/33] todo --- src/core/root.cxx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/root.cxx b/src/core/root.cxx index 403356e6..490d1f54 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -51,8 +51,7 @@ struct root_task { * `std::shared_ptr>` passed to `root_pkg`). */ template - static auto operator new(std::size_t size, - std::shared_ptr> const &recv, + static auto operator new(std::size_t size, std::shared_ptr> const &recv, CoroArgs const &.../*unused*/) noexcept -> void * { LF_ASSUME(recv != nullptr); LF_ASSUME(size <= receiver_state::buffer_size); @@ -216,6 +215,8 @@ root_pkg(std::shared_ptr> recv, Fn fn, Args... args // // We return any exception stashed unconditionally + // TODO: drop exception on cancel + if constexpr (LF_COMPILER_EXCEPTIONS) { if (root->exception_bit) { // The child threw an exception, propagate it to the receiver. From 13ff2f5e19e8548ddc75f25063b433631b1d8e5c Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:33:21 +0100 Subject: [PATCH 03/33] pass 2 --- src/core/receiver.cxx | 153 +++++++++++++----------------------------- src/core/root.cxx | 117 ++++++++++++++------------------ src/core/schedule.cxx | 39 ++++++----- test/src/schedule.cpp | 36 ++++++++++ 4 files changed, 154 insertions(+), 191 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 25c26955..651f681b 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -19,115 +19,56 @@ export struct broken_receiver_error final : libfork_exception { } }; +/** + * @brief Thrown if the root coroutine frame is too large for the embedded buffer. + */ +export struct root_alloc_error final : libfork_exception { + [[nodiscard]] + constexpr auto what() const noexcept -> const char * override { + return "root coroutine frame exceeds receiver_state buffer size"; + } +}; + /** * @brief Shared state between a scheduled task and its receiver handle. * - * This class is internal — users interact with it only via the exported - * `root_state` wrapper (defined in the schedule partition) and the returned - * `receiver` handle. + * Internal — users interact with it only through the exported `root_state` + * wrapper (in schedule.cxx) and the returned `receiver` handle. * - * The class embeds a 1 KiB aligned buffer that the root task's coroutine - * frame is placement-new'd into (see the custom operator new/delete and - * final_suspend awaiter in root.cxx). Because the frame lives inside the - * buffer, `receiver_state` must outlive the frame — arranged by holding a - * type-erased `std::shared_ptr` inside the root promise that is - * hand-over-hand moved out of the frame before the frame is destroyed. + * The aligned `buffer` hosts the root task's coroutine frame via placement + * `operator new`; the state must outlive the frame, which is arranged by the + * root promise taking a copy of the `shared_ptr` parameter. + * + * Two distinct `empty_*` tags are used for the potentially-empty members so + * that `[[no_unique_address]]` can collapse both to the same offset. */ template -class receiver_state { - public: +struct receiver_state { + /// Size of the embedded coroutine-frame buffer (bytes). static constexpr std::size_t buffer_size = 1024; - struct empty {}; - - /// Default construction — return value is default-initialised (or empty for void). - constexpr receiver_state() = default; - - /// In-place construction of the return value from arbitrary args. - template - requires (!std::is_void_v) && std::constructible_from - constexpr explicit receiver_state(Args &&...args) : m_return_value(std::forward(args)...) {} - - /** - * @brief Request that the associated task stop. - * - * Only available when Stoppable=true. Safe to call before scheduling — - * the root frame checks stop_requested() before executing the task body. - */ - constexpr auto request_stop() noexcept -> void - requires Stoppable - { - m_stop.request_stop(); - } - - /** - * @brief Raw pointer to the embedded buffer. - * - * Used by the root task's promise_type::operator new to placement-construct - * the coroutine frame inside this state's storage. - */ - [[nodiscard]] - auto buffer() noexcept -> void * { - return m_buffer; - } - - private: - template - friend class receiver; - - /** - * @brief Internal accessor returned by `get(key_t, receiver_state&)`. - * - * Not reachable by name from outside this translation unit because view - * is a private nested type. Callers use `auto` with the hidden friend. - */ - struct view { - receiver_state *p; - - constexpr void set_exception(std::exception_ptr e) noexcept { p->m_exception = std::move(e); } + struct empty_ret {}; + struct empty_stop {}; - constexpr void notify_ready() noexcept { - p->m_ready.test_and_set(); - p->m_ready.notify_one(); - } + alignas(std::max_align_t) std::byte buffer[buffer_size]{}; - [[nodiscard]] - constexpr auto return_value_address() noexcept -> T * - requires (!std::is_void_v) - { - return std::addressof(p->m_return_value); - } - - [[nodiscard]] - constexpr auto get_stop_token() noexcept -> stop_source::stop_token - requires Stoppable - { - return p->m_stop.token(); - } - }; - - /** - * @brief Hidden friend accessor for internal library use. - * - * Only callable via ADL when a `key_t` is available (i.e. by calling `key()`). - * Returns a `view` proxy to manipulate the state's private members. - */ - [[nodiscard]] - friend constexpr auto get(key_t, receiver_state &self) noexcept -> view { - return {&self}; - } + [[no_unique_address]] + std::conditional_t, empty_ret, T> return_value{}; - // Buffer first — it is the largest member and alignment-sensitive. - alignas(std::max_align_t) std::byte m_buffer[buffer_size]{}; + std::exception_ptr exception; + std::atomic_flag ready; [[no_unique_address]] - std::conditional_t, empty, T> m_return_value{}; - std::exception_ptr m_exception; - std::atomic_flag m_ready; + std::conditional_t stop; - [[no_unique_address]] - std::conditional_t m_stop; + /// Default construction — return value is default-initialised (or empty for void). + constexpr receiver_state() = default; + + /// In-place construction of the return value (used by allocate_shared). + template + requires (!std::is_void_v) && std::constructible_from + constexpr explicit receiver_state(Args &&...args) : return_value(std::forward(args)...) {} }; export template @@ -136,7 +77,7 @@ class receiver { using state_type = receiver_state; public: - constexpr receiver(key_t, std::shared_ptr &&state) : m_state(std::move(state)) {} + constexpr receiver(key_t, std::shared_ptr state) noexcept : m_state(std::move(state)) {} // Move only constexpr receiver(receiver &&) noexcept = default; @@ -154,37 +95,35 @@ class receiver { if (!valid()) { LF_THROW(broken_receiver_error{}); } - return m_state->m_ready.test(); + return m_state->ready.test(); } constexpr void wait() const { if (!valid()) { LF_THROW(broken_receiver_error{}); } - m_state->m_ready.wait(false); + m_state->ready.wait(false); } /** * @brief Returns a stop_token for this task's stop source. * - * Only available when Stoppable=true. The token can be used to observe - * whether the associated task has been cancelled. + * Only available when Stoppable=true. */ [[nodiscard]] - constexpr auto token() noexcept -> stop_source::stop_token + constexpr auto token() const -> stop_source::stop_token requires Stoppable { if (!valid()) { LF_THROW(broken_receiver_error{}); } - return get(key(), *m_state).get_stop_token(); + return m_state->stop.token(); } /** * @brief Request that the associated task stop. * - * Only available when Stoppable=true. Thread-safe; may be called - * concurrently with the task executing on worker threads. + * Only available when Stoppable=true. Thread-safe. */ constexpr auto request_stop() -> void requires Stoppable @@ -192,7 +131,7 @@ class receiver { if (!valid()) { LF_THROW(broken_receiver_error{}); } - m_state->m_stop.request_stop(); + m_state->stop.request_stop(); } [[nodiscard]] @@ -205,12 +144,12 @@ class receiver { LF_ASSUME(state != nullptr); - if (state->m_exception) { - std::rethrow_exception(state->m_exception); + if (state->exception) { + std::rethrow_exception(state->exception); } if constexpr (!std::is_void_v) { - return std::move(state->m_return_value); + return std::move(state->return_value); } } diff --git a/src/core/root.cxx b/src/core/root.cxx index 490d1f54..61d19b6c 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -21,41 +21,50 @@ namespace lf { struct get_frame_t {}; -/** - * @brief Tag awaited inside the root body to install a type-erased shared_ptr - * keep-alive into the promise. - * - * The keep-alive is moved out of the frame by final_awaiter before the frame - * is destroyed, so the receiver_state (which owns the buffer the frame lives - * in) is kept alive until *after* frame teardown completes. - */ -struct set_keep_alive_t { - std::shared_ptr ptr; -}; - template struct root_task { struct promise_type { frame_type frame{Checkpoint{}}; - /// Owns a ref to the receiver_state hosting this coroutine's buffer. - /// Moved out by `final_awaiter::await_suspend` before frame destruction. + /// Owns a ref to the receiver_state hosting this frame's buffer. + /// Moved (via std::exchange) out of the frame by `final_awaiter::await_suspend` + /// before the frame itself is destroyed, so the receiver_state (and its + /// buffer) outlives frame teardown. std::shared_ptr keep_alive; /** - * @brief Placement operator new: place the coroutine frame in the + * @brief Default constructor — used if no coroutine-arg ctor matches. + */ + constexpr promise_type() = default; + + /** + * @brief Coroutine-argument constructor: captures a keep-alive copy of + * the receiver_state shared pointer passed as the coroutine's + * first argument. + */ + template + constexpr explicit promise_type(std::shared_ptr> const &recv, + Rest const &.../*unused*/) noexcept + : keep_alive(recv) {} + + /** + * @brief Placement `operator new`: locate the frame inside the * receiver_state's embedded buffer. * - * Deduces R and Stoppable from the first coroutine argument (the - * `std::shared_ptr>` passed to `root_pkg`). + * Throws `root_alloc_error` if the requested frame size exceeds the + * buffer capacity. Declared non-`noexcept` so the exception propagates + * out to the caller of the scheduled coroutine (the `root_pkg` call in + * `schedule`). */ template static auto operator new(std::size_t size, std::shared_ptr> const &recv, - CoroArgs const &.../*unused*/) noexcept -> void * { + CoroArgs const &.../*unused*/) -> void * { LF_ASSUME(recv != nullptr); - LF_ASSUME(size <= receiver_state::buffer_size); - return recv->buffer(); + if (size > receiver_state::buffer_size) { + LF_THROW(root_alloc_error{}); + } + return recv->buffer; } /// No-op: the buffer is owned by the receiver_state, not the frame. @@ -84,18 +93,6 @@ struct root_task { return {.child = child}; } - struct set_keep_alive_awaitable { - set_keep_alive_t tag; - promise_type *self; - constexpr auto await_ready() const noexcept -> bool { return true; } - constexpr void await_suspend(std::coroutine_handle<>) const noexcept {} - constexpr void await_resume() noexcept { self->keep_alive = std::move(tag.ptr); } - }; - - constexpr auto await_transform(set_keep_alive_t tag) noexcept -> set_keep_alive_awaitable { - return {.tag = std::move(tag), .self = this}; - } - constexpr auto get_return_object() noexcept -> root_task { return {.promise = this}; } constexpr static auto initial_suspend() noexcept -> std::suspend_always { return {}; } @@ -103,33 +100,28 @@ struct root_task { /** * @brief Custom final_suspend. * - * The root task's coroutine frame lives inside the receiver_state's - * embedded buffer. We must ensure the receiver_state (which owns that - * buffer) outlives the frame teardown itself. The strategy: + * The root coroutine frame lives inside the receiver_state's embedded + * buffer, so the receiver_state must outlive the frame teardown. * - * 1. Move the type-erased keep-alive shared_ptr out of the promise - * into a local on the host stack. After this the promise no - * longer holds a ref. - * 2. Call `h.destroy()`. This runs parameter + promise destructors - * and then our no-op `operator delete`. No frame-memory access - * occurs after this point. - * 3. Return from `await_suspend`. As we unwind, the stack-local - * shared_ptr's destructor runs; if its ref was the last, it - * destroys the receiver_state (and releases the buffer memory) - * cleanly — we are no longer executing inside the buffer. + * 1. `std::exchange` the keep-alive shared_ptr into a local on the + * host stack, leaving the promise member null. + * 2. `h.destroy()` — runs parameter + promise destructors (including + * the now-null `keep_alive`) and our no-op `operator delete`. + * No frame-memory access occurs after the handle returns. + * 3. On return, the stack-local `shared_ptr` dies; if its ref + * was the last, it destroys the receiver_state cleanly — we are + * no longer executing inside the buffer. * * Destroying a coroutine from within its own final_awaiter::await_suspend - * is a well-known idiom: by the time await_suspend is called the body - * has completed, so there is nothing else for the frame to do. + * is a well-known idiom: by the time await_suspend runs the body is + * complete so the frame has no further work to do. */ struct final_awaiter { constexpr auto await_ready() const noexcept -> bool { return false; } void await_suspend(std::coroutine_handle h) const noexcept { - // Step 1: move keep-alive onto our stack. - std::shared_ptr local = std::move(h.promise().keep_alive); - // Step 2: destroy the frame (promise + parameter destructors, no-op delete). + std::shared_ptr local = std::exchange(h.promise().keep_alive, nullptr); h.destroy(); - // Step 3: `local` goes out of scope on return — possibly frees receiver_state. + // `local` released here — possibly freeing receiver_state on return. } constexpr void await_resume() const noexcept {} }; @@ -160,17 +152,10 @@ root_pkg(std::shared_ptr> recv, Fn fn, Args... args using checkpoint = checkpoint_t; - // Install the keep-alive shared_ptr into the promise before anything else. - // The type-erased std::shared_ptr shares ownership with `recv`, so - // even after `recv` (the coroutine parameter) is destroyed during frame - // teardown the receiver_state stays alive until final_awaiter drops the - // keep-alive on the host stack. - co_await set_keep_alive_t{std::shared_ptr{recv}}; - - // This is a pointer to the current root_task's frame + // Pointer to this root_task's own frame. frame_type *root = not_null(co_await get_frame_t{}); - // Now we do a manual "call" invocation. + // Manual "call" invocation of the user-supplied task. using result_type = async_result_t; using promise_type = promise_type; @@ -187,7 +172,7 @@ root_pkg(std::shared_ptr> recv, Fn fn, Args... args // Potentially throwing child = get(key(), ctx_invoke_t{}(std::move(fn), std::move(args)...)); } LF_CATCH_ALL { - get(key(), *recv).set_exception(std::current_exception()); + recv->exception = std::current_exception(); goto cleanup; } @@ -200,7 +185,7 @@ root_pkg(std::shared_ptr> recv, Fn fn, Args... args LF_ASSUME(child->frame.kind == category::call); if constexpr (!std::is_void_v>) { - child->return_address = get(key(), *recv).return_value_address(); + child->return_address = std::addressof(recv->return_value); } // Begin normal execution of the child task, it will clean itself @@ -215,19 +200,17 @@ root_pkg(std::shared_ptr> recv, Fn fn, Args... args // // We return any exception stashed unconditionally - // TODO: drop exception on cancel - if constexpr (LF_COMPILER_EXCEPTIONS) { if (root->exception_bit) { // The child threw an exception, propagate it to the receiver. - get(key(), *recv).set_exception(extract_exception(root)); + recv->exception = extract_exception(root); } } cleanup: - // Now do that which we would otherwise do at a final suspend. // Notify the receiver that the task is done. - get(key(), *recv).notify_ready(); + recv->ready.test_and_set(); + recv->ready.notify_one(); LF_ASSUME(root->steals == 0); LF_ASSUME(root->joins == k_u16_max); diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 3752675c..151aa7da 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -50,21 +50,30 @@ using schedule_result_t = receiver>; /** * @brief Lightweight move-only handle owning a pre-allocated root task state. * - * `root_state` is a simple wrapper the caller constructs and then passes - * by value into `schedule`. It has no public methods beyond construction - * and move: all user-visible interaction with the task happens through the - * `receiver` returned from `schedule`. + * `root_state` is a simple wrapper constructed by the caller and passed by + * value into `schedule`. Apart from construction and move-assignment it has + * no public methods — all user-visible interaction with the scheduled task + * happens through the `receiver` returned from `schedule`. * - * Construction allocates a `receiver_state` on the heap; this - * state embeds a 1 KiB buffer into which the root coroutine frame will be - * placement-constructed by `schedule`. + * Construction allocates a `receiver_state` which embeds a + * 1 KiB aligned buffer; the root coroutine frame is placement-constructed + * into that buffer by `schedule`. + * + * Two constructors are provided, mirroring `make_shared` / `allocate_shared`: + * - default-construct: uses `std::make_shared` + * - allocator-aware: uses `std::allocate_shared` with the given allocator */ export template class root_state { public: - /// Allocate the backing receiver_state. + /// Default: allocate via `std::make_shared`. root_state() : m_ptr(std::make_shared>()) {} + /// Allocator-aware: allocate via `std::allocate_shared` with `alloc`. + template + root_state(std::allocator_arg_t /*tag*/, Allocator const &alloc) + : m_ptr(std::allocate_shared>(alloc)) {} + // Move-only. root_state(root_state &&) noexcept = default; auto operator=(root_state &&) noexcept -> root_state & = default; @@ -85,12 +94,8 @@ class root_state { /** * @brief Schedule a function using a caller-provided `root_state`. * - * The root coroutine frame is placement-constructed inside the 1 KiB buffer - * embedded in the state's `receiver_state`. Lifetime of the state (and - * therefore the buffer) is extended past frame destruction by a type-erased - * keep-alive shared_ptr installed into the root promise. - * - * Strongly exception safe. + * Strongly exception safe: if the scheduler's `post()` throws, the root + * frame is destroyed and the exception is rethrown to the caller. */ export template requires schedulable, Args...> && @@ -104,12 +109,12 @@ constexpr auto schedule(Sch &&sch, root_state state, Fn &&fn, Args LF_THROW(schedule_error{}); } - // Grab a copy of the shared_ptr before handing it to root_pkg. std::shared_ptr> sp = get(key(), state); LF_ASSUME(sp != nullptr); - // Package takes shared ownership of the state; fine if this throws. + // root_pkg's operator new may throw root_alloc_error if the frame is + // too large; if so, `sp` goes out of scope and destroys the state. root_task task = root_pkg(sp, std::forward(fn), std::forward(args)...); LF_ASSUME(task.promise != nullptr); @@ -118,7 +123,7 @@ constexpr auto schedule(Sch &&sch, root_state state, Fn &&fn, Args task.promise->frame.parent = nullptr; if constexpr (Stoppable) { - task.promise->frame.stop_token = get(key(), *sp).get_stop_token(); + task.promise->frame.stop_token = sp->stop.token(); } else { task.promise->frame.stop_token = stop_source::stop_token{}; // non-cancellable root } diff --git a/test/src/schedule.cpp b/test/src/schedule.cpp index f4c82aca..114dabc7 100644 --- a/test/src/schedule.cpp +++ b/test/src/schedule.cpp @@ -28,6 +28,14 @@ auto throwing_function(env /*unused*/) -> task { co_return; } +// Task whose argument is a big enough value-type to push the root coroutine +// frame past the 1 KiB embedded buffer in receiver_state. +template +auto big_arg_function(env /*unused*/, std::array /*unused*/) + -> task { + co_return; +} + template void simple_tests(Sch &scheduler) { SECTION("void") { @@ -42,12 +50,40 @@ void simple_tests(Sch &scheduler) { REQUIRE(std::move(recv).get() == true); } + SECTION("explicit root_state") { + lf::root_state state; + auto recv = schedule(scheduler, std::move(state), simple_function>); + REQUIRE(recv.valid()); + REQUIRE(std::move(recv).get() == true); + } + + SECTION("stoppable root_state") { + lf::root_state state; + auto recv = schedule(scheduler, std::move(state), simple_function>); + REQUIRE(recv.valid()); + REQUIRE(std::move(recv).get() == true); + } + + SECTION("root_state with explicit allocator") { + std::allocator alloc; + lf::root_state state{std::allocator_arg, alloc}; + auto recv = schedule(scheduler, std::move(state), simple_function>); + REQUIRE(recv.valid()); + REQUIRE(std::move(recv).get() == true); + } + #if LF_COMPILER_EXCEPTIONS SECTION("throwing") { auto recv = schedule(scheduler, throwing_function>); REQUIRE(recv.valid()); REQUIRE_THROWS_AS(std::move(recv).get(), std::runtime_error); } + + SECTION("frame too large -> root_alloc_error") { + std::array big{}; + REQUIRE_THROWS_AS(schedule(scheduler, big_arg_function>, big), + lf::root_alloc_error); + } #endif } From 3d460bb5e728468c719d921e3fa1daf0c0a1de8f Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:40:27 +0100 Subject: [PATCH 04/33] tidy recv --- src/core/receiver.cxx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 651f681b..aba22643 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -48,27 +48,26 @@ struct receiver_state { /// Size of the embedded coroutine-frame buffer (bytes). static constexpr std::size_t buffer_size = 1024; - struct empty_ret {}; - struct empty_stop {}; + struct empty_1 {}; + struct empty_2 {}; - alignas(std::max_align_t) std::byte buffer[buffer_size]{}; + alignas(k_new_align) std::byte buffer[buffer_size]{}; [[no_unique_address]] - std::conditional_t, empty_ret, T> return_value{}; + std::conditional_t, empty_1, T> return_value{}; std::exception_ptr exception; std::atomic_flag ready; [[no_unique_address]] - std::conditional_t stop; + std::conditional_t stop; - /// Default construction — return value is default-initialised (or empty for void). constexpr receiver_state() = default; - /// In-place construction of the return value (used by allocate_shared). template - requires (!std::is_void_v) && std::constructible_from - constexpr explicit receiver_state(Args &&...args) : return_value(std::forward(args)...) {} + requires (sizeof...(Args) > 0) && std::constructible_from + constexpr explicit(sizeof...(Args) == 1) receiver_state(Args &&...args) + : return_value(std::forward(args)...) {} }; export template From 3b7a532e026cb5e24a049e8a82f6f4e9b63660d2 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:41:23 +0100 Subject: [PATCH 05/33] move --- src/core/receiver.cxx | 44 +++++++++++++++++++++++++++++++++++ src/core/schedule.cxx | 54 ++++--------------------------------------- 2 files changed, 48 insertions(+), 50 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index aba22643..340de760 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -70,6 +70,50 @@ struct receiver_state { : return_value(std::forward(args)...) {} }; +/** + * @brief Lightweight move-only handle owning a pre-allocated root task state. + * + * `root_state` is a simple wrapper constructed by the caller and passed by + * value into `schedule`. Apart from construction and move-assignment it has + * no public methods — all user-visible interaction with the scheduled task + * happens through the `receiver` returned from `schedule`. + * + * Construction allocates a `receiver_state` which embeds a + * 1 KiB aligned buffer; the root coroutine frame is placement-constructed + * into that buffer by `schedule`. + * + * Two constructors are provided, mirroring `make_shared` / `allocate_shared`: + * - default-construct: uses `std::make_shared` + * - allocator-aware: uses `std::allocate_shared` with the given allocator + */ +export template +class root_state { + public: + /// Default: allocate via `std::make_shared`. + root_state() : m_ptr(std::make_shared>()) {} + + /// Allocator-aware: allocate via `std::allocate_shared` with `alloc`. + template + root_state(std::allocator_arg_t /*tag*/, Allocator const &alloc) + : m_ptr(std::allocate_shared>(alloc)) {} + + // Move-only. + root_state(root_state &&) noexcept = default; + auto operator=(root_state &&) noexcept -> root_state & = default; + root_state(root_state const &) = delete; + auto operator=(root_state const &) -> root_state & = delete; + + ~root_state() = default; + + private: + [[nodiscard]] + friend auto get(key_t, root_state &self) noexcept -> std::shared_ptr> & { + return self.m_ptr; + } + + std::shared_ptr> m_ptr; +}; + export template class receiver { diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 151aa7da..141d1e85 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -47,50 +47,6 @@ export template requires schedulable using schedule_result_t = receiver>; -/** - * @brief Lightweight move-only handle owning a pre-allocated root task state. - * - * `root_state` is a simple wrapper constructed by the caller and passed by - * value into `schedule`. Apart from construction and move-assignment it has - * no public methods — all user-visible interaction with the scheduled task - * happens through the `receiver` returned from `schedule`. - * - * Construction allocates a `receiver_state` which embeds a - * 1 KiB aligned buffer; the root coroutine frame is placement-constructed - * into that buffer by `schedule`. - * - * Two constructors are provided, mirroring `make_shared` / `allocate_shared`: - * - default-construct: uses `std::make_shared` - * - allocator-aware: uses `std::allocate_shared` with the given allocator - */ -export template -class root_state { - public: - /// Default: allocate via `std::make_shared`. - root_state() : m_ptr(std::make_shared>()) {} - - /// Allocator-aware: allocate via `std::allocate_shared` with `alloc`. - template - root_state(std::allocator_arg_t /*tag*/, Allocator const &alloc) - : m_ptr(std::allocate_shared>(alloc)) {} - - // Move-only. - root_state(root_state &&) noexcept = default; - auto operator=(root_state &&) noexcept -> root_state & = default; - root_state(root_state const &) = delete; - auto operator=(root_state const &) -> root_state & = delete; - - ~root_state() = default; - - private: - [[nodiscard]] - friend auto get(key_t, root_state &self) noexcept -> std::shared_ptr> & { - return self.m_ptr; - } - - std::shared_ptr> m_ptr; -}; - /** * @brief Schedule a function using a caller-provided `root_state`. * @@ -100,8 +56,8 @@ class root_state { export template requires schedulable, Args...> && std::same_as, Args...>> -constexpr auto schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) - -> receiver { +constexpr auto +schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> receiver { using context_type = context_t; @@ -151,10 +107,8 @@ schedule(Sch &&sch, Fn &&fn, Args &&...args) -> receiver; using R = invoke_decay_result_t; - return schedule(std::forward(sch), - root_state{}, - std::forward(fn), - std::forward(args)...); + return schedule( + std::forward(sch), root_state{}, std::forward(fn), std::forward(args)...); } } // namespace lf From b9a7f5795e60a25a8ad6b873d4857fd5e678959d Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:49:48 +0100 Subject: [PATCH 06/33] clean ups --- src/core/receiver.cxx | 35 ++++++++++++++++++++++++++--------- test/src/schedule.cpp | 8 ++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 340de760..a517a887 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -82,20 +82,37 @@ struct receiver_state { * 1 KiB aligned buffer; the root coroutine frame is placement-constructed * into that buffer by `schedule`. * - * Two constructors are provided, mirroring `make_shared` / `allocate_shared`: - * - default-construct: uses `std::make_shared` - * - allocator-aware: uses `std::allocate_shared` with the given allocator + * Constructors mirror `make_shared` / `allocate_shared`: + * + * root_state s; // default-init return value + * root_state s{v1, v2}; // in-place init: T{v1, v2} + * root_state s{allocator_arg, alloc}; // default-init, custom allocator + * root_state s{allocator_arg, alloc, v1, v2}; // in-place init + custom allocator */ export template class root_state { + using state_type = receiver_state; + public: - /// Default: allocate via `std::make_shared`. - root_state() : m_ptr(std::make_shared>()) {} + /// Default: value-initialise via `std::make_shared`. + root_state() : m_ptr(std::make_shared()) {} - /// Allocator-aware: allocate via `std::allocate_shared` with `alloc`. - template - root_state(std::allocator_arg_t /*tag*/, Allocator const &alloc) - : m_ptr(std::allocate_shared>(alloc)) {} + /// Value-init from args: forwards `args` to `receiver_state`'s constructor + /// (in-place construction of the return value) via `std::make_shared`. + template + requires (sizeof...(Args) > 0) && std::constructible_from + constexpr explicit(sizeof...(Args) == 1) root_state(Args &&...args) + : m_ptr(std::make_shared(std::forward(args)...)) {} + + /// Allocator-aware, default return value: allocate via `std::allocate_shared`. + template + root_state(std::allocator_arg_t, Alloc const &alloc) : m_ptr(std::allocate_shared(alloc)) {} + + /// Allocator-aware with value-init args. + template + requires std::constructible_from + root_state(std::allocator_arg_t, Alloc const &alloc, Args &&...args) + : m_ptr(std::allocate_shared(alloc, std::forward(args)...)) {} // Move-only. root_state(root_state &&) noexcept = default; diff --git a/test/src/schedule.cpp b/test/src/schedule.cpp index 114dabc7..804be57e 100644 --- a/test/src/schedule.cpp +++ b/test/src/schedule.cpp @@ -72,6 +72,14 @@ void simple_tests(Sch &scheduler) { REQUIRE(std::move(recv).get() == true); } + SECTION("root_state with value-init") { + // Pre-initialise the return slot; the task overwrites it. + lf::root_state state{false}; + auto recv = schedule(scheduler, std::move(state), simple_function>); + REQUIRE(recv.valid()); + REQUIRE(std::move(recv).get() == true); + } + #if LF_COMPILER_EXCEPTIONS SECTION("throwing") { auto recv = schedule(scheduler, throwing_function>); From d076f93a00a00aab955bd2dd775414905686b703 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:50:40 +0100 Subject: [PATCH 07/33] move --- src/core/receiver.cxx | 10 ---------- src/core/root.cxx | 10 ++++++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index a517a887..6d7adf72 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -19,16 +19,6 @@ export struct broken_receiver_error final : libfork_exception { } }; -/** - * @brief Thrown if the root coroutine frame is too large for the embedded buffer. - */ -export struct root_alloc_error final : libfork_exception { - [[nodiscard]] - constexpr auto what() const noexcept -> const char * override { - return "root coroutine frame exceeds receiver_state buffer size"; - } -}; - /** * @brief Shared state between a scheduled task and its receiver handle. * diff --git a/src/core/root.cxx b/src/core/root.cxx index 61d19b6c..37d2ee8e 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -19,6 +19,16 @@ namespace lf { // TODO: allocator aware! -> IDEA embed in frame/state +/** + * @brief Thrown if the root coroutine frame is too large for the embedded buffer. + */ +export struct root_alloc_error final : libfork_exception { + [[nodiscard]] + constexpr auto what() const noexcept -> const char * override { + return "root coroutine frame exceeds receiver_state buffer size"; + } +}; + struct get_frame_t {}; template From 7274cfe5e27019c9b124ac5494cda453885379f5 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:50:53 +0100 Subject: [PATCH 08/33] drop todo --- src/core/root.cxx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/root.cxx b/src/core/root.cxx index 37d2ee8e..c7ab5a9c 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -17,8 +17,6 @@ import :task; namespace lf { -// TODO: allocator aware! -> IDEA embed in frame/state - /** * @brief Thrown if the root coroutine frame is too large for the embedded buffer. */ From 8105fe7065b505bb5ce8bff4be66f656e1768696 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 21:57:24 +0100 Subject: [PATCH 09/33] move state around --- src/core/receiver.cxx | 29 ++++++----------------------- src/core/schedule.cxx | 2 +- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 6d7adf72..4652ff19 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -21,16 +21,6 @@ export struct broken_receiver_error final : libfork_exception { /** * @brief Shared state between a scheduled task and its receiver handle. - * - * Internal — users interact with it only through the exported `root_state` - * wrapper (in schedule.cxx) and the returned `receiver` handle. - * - * The aligned `buffer` hosts the root task's coroutine frame via placement - * `operator new`; the state must outlive the frame, which is arranged by the - * root promise taking a copy of the `shared_ptr` parameter. - * - * Two distinct `empty_*` tags are used for the potentially-empty members so - * that `[[no_unique_address]]` can collapse both to the same offset. */ template struct receiver_state { @@ -63,19 +53,14 @@ struct receiver_state { /** * @brief Lightweight move-only handle owning a pre-allocated root task state. * - * `root_state` is a simple wrapper constructed by the caller and passed by - * value into `schedule`. Apart from construction and move-assignment it has - * no public methods — all user-visible interaction with the scheduled task - * happens through the `receiver` returned from `schedule`. - * * Construction allocates a `receiver_state` which embeds a * 1 KiB aligned buffer; the root coroutine frame is placement-constructed * into that buffer by `schedule`. * * Constructors mirror `make_shared` / `allocate_shared`: * - * root_state s; // default-init return value - * root_state s{v1, v2}; // in-place init: T{v1, v2} + * root_state s; // default-init return value + * root_state s{v1, v2}; // in-place init: T{v1, v2} * root_state s{allocator_arg, alloc}; // default-init, custom allocator * root_state s{allocator_arg, alloc, v1, v2}; // in-place init + custom allocator */ @@ -110,12 +95,10 @@ class root_state { root_state(root_state const &) = delete; auto operator=(root_state const &) -> root_state & = delete; - ~root_state() = default; - private: [[nodiscard]] - friend auto get(key_t, root_state &self) noexcept -> std::shared_ptr> & { - return self.m_ptr; + friend auto get(key_t, root_state &&self) noexcept -> std::shared_ptr> { + return std::move(self.m_ptr); } std::shared_ptr> m_ptr; @@ -127,7 +110,7 @@ class receiver { using state_type = receiver_state; public: - constexpr receiver(key_t, std::shared_ptr state) noexcept : m_state(std::move(state)) {} + constexpr receiver(key_t, std::shared_ptr &&state) noexcept : m_state(std::move(state)) {} // Move only constexpr receiver(receiver &&) noexcept = default; @@ -173,7 +156,7 @@ class receiver { /** * @brief Request that the associated task stop. * - * Only available when Stoppable=true. Thread-safe. + * Only available when Stoppable=true. Thread-safe. */ constexpr auto request_stop() -> void requires Stoppable diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 141d1e85..9385579b 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -65,7 +65,7 @@ schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> LF_THROW(schedule_error{}); } - std::shared_ptr> sp = get(key(), state); + std::shared_ptr> sp = get(key(), std::move(state)); LF_ASSUME(sp != nullptr); From df93cc1f822aad192ec33eedef1ebffbc531463d Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 22:01:07 +0100 Subject: [PATCH 10/33] tmp --- src/core/receiver.cxx | 22 ++++++++++++---------- src/core/root.cxx | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 4652ff19..259ba1ea 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -26,12 +26,12 @@ template struct receiver_state { /// Size of the embedded coroutine-frame buffer (bytes). - static constexpr std::size_t buffer_size = 1024; + static constexpr std::size_t k_buffer_size = 1024; struct empty_1 {}; struct empty_2 {}; - alignas(k_new_align) std::byte buffer[buffer_size]{}; + alignas(k_new_align) std::byte buffer[k_buffer_size]{}; [[no_unique_address]] std::conditional_t, empty_1, T> return_value{}; @@ -70,7 +70,7 @@ class root_state { public: /// Default: value-initialise via `std::make_shared`. - root_state() : m_ptr(std::make_shared()) {} + constexpr root_state() : m_ptr(std::make_shared()) {} /// Value-init from args: forwards `args` to `receiver_state`'s constructor /// (in-place construction of the return value) via `std::make_shared`. @@ -81,23 +81,25 @@ class root_state { /// Allocator-aware, default return value: allocate via `std::allocate_shared`. template - root_state(std::allocator_arg_t, Alloc const &alloc) : m_ptr(std::allocate_shared(alloc)) {} + constexpr root_state(std::allocator_arg_t, Alloc const &alloc) + : m_ptr(std::allocate_shared(alloc)) {} /// Allocator-aware with value-init args. template requires std::constructible_from - root_state(std::allocator_arg_t, Alloc const &alloc, Args &&...args) + constexpr root_state(std::allocator_arg_t, Alloc const &alloc, Args &&...args) : m_ptr(std::allocate_shared(alloc, std::forward(args)...)) {} // Move-only. - root_state(root_state &&) noexcept = default; - auto operator=(root_state &&) noexcept -> root_state & = default; - root_state(root_state const &) = delete; - auto operator=(root_state const &) -> root_state & = delete; + constexpr root_state(root_state &&) noexcept = default; + constexpr auto operator=(root_state &&) noexcept -> root_state & = default; + constexpr root_state(root_state const &) = delete; + constexpr auto operator=(root_state const &) -> root_state & = delete; private: [[nodiscard]] - friend auto get(key_t, root_state &&self) noexcept -> std::shared_ptr> { + friend constexpr auto + get(key_t, root_state &&self) noexcept -> std::shared_ptr> { return std::move(self.m_ptr); } diff --git a/src/core/root.cxx b/src/core/root.cxx index c7ab5a9c..c2b925b2 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -69,7 +69,7 @@ struct root_task { static auto operator new(std::size_t size, std::shared_ptr> const &recv, CoroArgs const &.../*unused*/) -> void * { LF_ASSUME(recv != nullptr); - if (size > receiver_state::buffer_size) { + if (size > receiver_state::k_buffer_size) { LF_THROW(root_alloc_error{}); } return recv->buffer; From e974ef3ddb533cba1607c95edf4b224d9f64edb9 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 22:02:09 +0100 Subject: [PATCH 11/33] std::array --- src/core/receiver.cxx | 5 +---- src/core/root.cxx | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 259ba1ea..68b45b61 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -25,13 +25,10 @@ export struct broken_receiver_error final : libfork_exception { template struct receiver_state { - /// Size of the embedded coroutine-frame buffer (bytes). - static constexpr std::size_t k_buffer_size = 1024; - struct empty_1 {}; struct empty_2 {}; - alignas(k_new_align) std::byte buffer[k_buffer_size]{}; + alignas(k_new_align) std::array buffer{}; [[no_unique_address]] std::conditional_t, empty_1, T> return_value{}; diff --git a/src/core/root.cxx b/src/core/root.cxx index c2b925b2..ff4f3160 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -69,10 +69,10 @@ struct root_task { static auto operator new(std::size_t size, std::shared_ptr> const &recv, CoroArgs const &.../*unused*/) -> void * { LF_ASSUME(recv != nullptr); - if (size > receiver_state::k_buffer_size) { + if (size > recv->buffer.size()) { LF_THROW(root_alloc_error{}); } - return recv->buffer; + return recv->buffer.data(); } /// No-op: the buffer is owned by the receiver_state, not the frame. From 704cbd0863cdb65847669a86d8a7f8b5c9e40823 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 22:03:41 +0100 Subject: [PATCH 12/33] comment --- src/core/receiver.cxx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 68b45b61..0035b25e 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -166,6 +166,11 @@ class receiver { m_state->stop.request_stop(); } + /** + * @brief Wait for the associated task to complete and return its result, or rethrow. + * + * This may only be called once; the state is consumed and the receiver becomes invalid. + */ [[nodiscard]] constexpr auto get() && -> T { From 2adc2b97e2f8f36c5c3163b1c8787fcfffb0eae3 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 22:09:19 +0100 Subject: [PATCH 13/33] tmp --- src/core/root.cxx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/core/root.cxx b/src/core/root.cxx index ff4f3160..edfd4cd3 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -36,24 +36,11 @@ struct root_task { frame_type frame{Checkpoint{}}; /// Owns a ref to the receiver_state hosting this frame's buffer. - /// Moved (via std::exchange) out of the frame by `final_awaiter::await_suspend` - /// before the frame itself is destroyed, so the receiver_state (and its - /// buffer) outlives frame teardown. std::shared_ptr keep_alive; - /** - * @brief Default constructor — used if no coroutine-arg ctor matches. - */ - constexpr promise_type() = default; - - /** - * @brief Coroutine-argument constructor: captures a keep-alive copy of - * the receiver_state shared pointer passed as the coroutine's - * first argument. - */ - template + template constexpr explicit promise_type(std::shared_ptr> const &recv, - Rest const &.../*unused*/) noexcept + Args const &...) noexcept : keep_alive(recv) {} /** From 26934c691a593c99ccd17ccd7bd6f7a79c579467 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 22:18:09 +0100 Subject: [PATCH 14/33] clean ups --- src/core/root.cxx | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/core/root.cxx b/src/core/root.cxx index edfd4cd3..4d6bc51c 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -43,22 +43,17 @@ struct root_task { Args const &...) noexcept : keep_alive(recv) {} - /** - * @brief Placement `operator new`: locate the frame inside the - * receiver_state's embedded buffer. - * - * Throws `root_alloc_error` if the requested frame size exceeds the - * buffer capacity. Declared non-`noexcept` so the exception propagates - * out to the caller of the scheduled coroutine (the `root_pkg` call in - * `schedule`). - */ - template - static auto operator new(std::size_t size, std::shared_ptr> const &recv, - CoroArgs const &.../*unused*/) -> void * { + template + static auto + operator new(std::size_t size, std::shared_ptr> const &recv, Args const &...) + -> void * { + LF_ASSUME(recv != nullptr); + if (size > recv->buffer.size()) { LF_THROW(root_alloc_error{}); } + return recv->buffer.data(); } @@ -100,25 +95,20 @@ struct root_task { * * 1. `std::exchange` the keep-alive shared_ptr into a local on the * host stack, leaving the promise member null. - * 2. `h.destroy()` — runs parameter + promise destructors (including + * 2. `handle.destroy()` — runs parameter + promise destructors (including * the now-null `keep_alive`) and our no-op `operator delete`. * No frame-memory access occurs after the handle returns. * 3. On return, the stack-local `shared_ptr` dies; if its ref * was the last, it destroys the receiver_state cleanly — we are * no longer executing inside the buffer. - * - * Destroying a coroutine from within its own final_awaiter::await_suspend - * is a well-known idiom: by the time await_suspend runs the body is - * complete so the frame has no further work to do. */ - struct final_awaiter { - constexpr auto await_ready() const noexcept -> bool { return false; } - void await_suspend(std::coroutine_handle h) const noexcept { - std::shared_ptr local = std::exchange(h.promise().keep_alive, nullptr); - h.destroy(); + struct final_awaiter : std::suspend_always { + void await_suspend(std::coroutine_handle handle) const noexcept { + std::shared_ptr local = std::exchange(handle.promise().keep_alive, nullptr); + LF_ASSUME(local != nullptr); + handle.destroy(); // `local` released here — possibly freeing receiver_state on return. } - constexpr void await_resume() const noexcept {} }; constexpr static auto final_suspend() noexcept -> final_awaiter { return {}; } From c3cbc8b8fec218be7fdd530cc53decb8ea3f649c Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 22:20:31 +0100 Subject: [PATCH 15/33] alias --- src/core/receiver.cxx | 13 ++++++++----- src/core/root.cxx | 8 +++----- src/core/schedule.cxx | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 0035b25e..526f34b3 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -47,6 +47,10 @@ struct receiver_state { : return_value(std::forward(args)...) {} }; +/// Convenience alias — used throughout the core partitions. +template +using state_handle = std::shared_ptr>; + /** * @brief Lightweight move-only handle owning a pre-allocated root task state. * @@ -95,12 +99,11 @@ class root_state { private: [[nodiscard]] - friend constexpr auto - get(key_t, root_state &&self) noexcept -> std::shared_ptr> { + friend constexpr auto get(key_t, root_state &&self) noexcept -> state_handle { return std::move(self.m_ptr); } - std::shared_ptr> m_ptr; + state_handle m_ptr; }; export template @@ -109,7 +112,7 @@ class receiver { using state_type = receiver_state; public: - constexpr receiver(key_t, std::shared_ptr &&state) noexcept : m_state(std::move(state)) {} + constexpr receiver(key_t, state_handle state) noexcept : m_state(std::move(state)) {} // Move only constexpr receiver(receiver &&) noexcept = default; @@ -191,7 +194,7 @@ class receiver { } private: - std::shared_ptr m_state; + state_handle m_state; }; } // namespace lf diff --git a/src/core/root.cxx b/src/core/root.cxx index 4d6bc51c..f3055ec8 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -39,13 +39,11 @@ struct root_task { std::shared_ptr keep_alive; template - constexpr explicit promise_type(std::shared_ptr> const &recv, - Args const &...) noexcept + constexpr explicit promise_type(state_handle const &recv, Args const &...) noexcept : keep_alive(recv) {} template - static auto - operator new(std::size_t size, std::shared_ptr> const &recv, Args const &...) + static auto operator new(std::size_t size, state_handle const &recv, Args const &...) -> void * { LF_ASSUME(recv != nullptr); @@ -129,7 +127,7 @@ template [[nodiscard]] auto // -root_pkg(std::shared_ptr> recv, Fn fn, Args... args) +root_pkg(state_handle recv, Fn fn, Args... args) -> root_task> { // This should be resumed on a valid context. diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 9385579b..051de6c6 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -65,7 +65,7 @@ schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> LF_THROW(schedule_error{}); } - std::shared_ptr> sp = get(key(), std::move(state)); + state_handle sp = get(key(), std::move(state)); LF_ASSUME(sp != nullptr); From c9eba52e615b2490a1dc0ffe8b744cfee5837bd0 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 22:22:13 +0100 Subject: [PATCH 16/33] format --- src/core/root.cxx | 7 +++---- test/src/schedule.cpp | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/core/root.cxx b/src/core/root.cxx index f3055ec8..465f96a3 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -43,8 +43,8 @@ struct root_task { : keep_alive(recv) {} template - static auto operator new(std::size_t size, state_handle const &recv, Args const &...) - -> void * { + static auto + operator new(std::size_t size, state_handle const &recv, Args const &...) -> void * { LF_ASSUME(recv != nullptr); @@ -127,8 +127,7 @@ template [[nodiscard]] auto // -root_pkg(state_handle recv, Fn fn, Args... args) - -> root_task> { +root_pkg(state_handle recv, Fn fn, Args... args) -> root_task> { // This should be resumed on a valid context. LF_ASSUME(thread_local_context != nullptr); diff --git a/test/src/schedule.cpp b/test/src/schedule.cpp index 804be57e..7596ddff 100644 --- a/test/src/schedule.cpp +++ b/test/src/schedule.cpp @@ -89,8 +89,7 @@ void simple_tests(Sch &scheduler) { SECTION("frame too large -> root_alloc_error") { std::array big{}; - REQUIRE_THROWS_AS(schedule(scheduler, big_arg_function>, big), - lf::root_alloc_error); + REQUIRE_THROWS_AS(schedule(scheduler, big_arg_function>, big), lf::root_alloc_error); } #endif } From da60f82a9bf807f183462bcaaef525c373ae5af3 Mon Sep 17 00:00:00 2001 From: Conor Date: Sun, 19 Apr 2026 22:30:32 +0100 Subject: [PATCH 17/33] wip schedulable --- src/core/schedule.cxx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 051de6c6..6db69a90 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -43,6 +43,13 @@ concept schedulable = schedulable_decayed, Context, std::decay_ template using invoke_decay_result_t = async_result_t, Context, std::decay_t...>; +/** + * @brief Subsumes `schedulable` and checks the result type is `R`. + */ +export template +concept schedulable_to = + schedulable && std::same_as>; + export template requires schedulable using schedule_result_t = receiver>; @@ -54,8 +61,7 @@ using schedule_result_t = receiver>; * frame is destroyed and the exception is rethrown to the caller. */ export template - requires schedulable, Args...> && - std::same_as, Args...>> + requires schedulable_to, Args...> constexpr auto schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> receiver { From 74864f658d9b2c887ef1ad3a0d6ca777994a53c1 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 10:10:34 +0100 Subject: [PATCH 18/33] tidy ups --- src/core/schedule.cxx | 51 ++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 6db69a90..aeeb3898 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -20,9 +20,6 @@ import :receiver; namespace lf { -export template -concept schedulable_return = std::is_void_v || (std::default_initializable && std::movable); - export struct schedule_error final : libfork_exception { [[nodiscard]] constexpr auto what() const noexcept -> const char * override { @@ -33,35 +30,18 @@ export struct schedule_error final : libfork_exception { template concept decay_copyable = std::convertible_to>; -template -concept schedulable_decayed = - async_invocable && schedulable_return>; - -export template -concept schedulable = schedulable_decayed, Context, std::decay_t...>; - -template -using invoke_decay_result_t = async_result_t, Context, std::decay_t...>; - -/** - * @brief Subsumes `schedulable` and checks the result type is `R`. - */ -export template -concept schedulable_to = - schedulable && std::same_as>; - -export template - requires schedulable -using schedule_result_t = receiver>; - /** * @brief Schedule a function using a caller-provided `root_state`. * - * Strongly exception safe: if the scheduler's `post()` throws, the root - * frame is destroyed and the exception is rethrown to the caller. + * This will create a root task that stores decayed copies of `Fn` and + * `Args...` in its frame, then post it to the scheduler. The root task must + * then be resumed by a worker which will perform the invocation of `Fn`. + * + * Strongly exception safe. */ export template - requires schedulable_to, Args...> + requires async_invocable_to, R, context_t, std::decay_t...> +[[nodiscard("Fire and forget is an anti-pattern")]] constexpr auto schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> receiver { @@ -102,16 +82,27 @@ schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> return {key(), std::move(sp)}; } +template +concept schedulable_return = std::is_void_v || (std::default_initializable && std::movable); + +template +concept default_schedulable = + async_invocable && schedulable_return>; + +template +using async_decay_result_t = async_result_t, Context, std::decay_t...>; + /** * @brief Convenience overload: default-constructs a non-cancellable root_state. */ export template - requires schedulable, Args...> + requires default_schedulable, context_t, std::decay_t...> +[[nodiscard("Fire and forget is an anti-pattern")]] constexpr auto -schedule(Sch &&sch, Fn &&fn, Args &&...args) -> receiver, Args...>> { +schedule(Sch &&sch, Fn &&fn, Args &&...args) -> receiver, Args...>> { using context_type = context_t; - using R = invoke_decay_result_t; + using R = async_decay_result_t; return schedule( std::forward(sch), root_state{}, std::forward(fn), std::forward(args)...); From 77117dbec74a6adaa47c00b923799b7649afa5b2 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 10:11:14 +0100 Subject: [PATCH 19/33] rename sp -> state_ptr --- src/core/schedule.cxx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index aeeb3898..86cb4b95 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -51,13 +51,13 @@ schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> LF_THROW(schedule_error{}); } - state_handle sp = get(key(), std::move(state)); + state_handle state_ptr = get(key(), std::move(state)); - LF_ASSUME(sp != nullptr); + LF_ASSUME(state_ptr != nullptr); // root_pkg's operator new may throw root_alloc_error if the frame is - // too large; if so, `sp` goes out of scope and destroys the state. - root_task task = root_pkg(sp, std::forward(fn), std::forward(args)...); + // too large; if so, `state_ptr` goes out of scope and destroys the state. + root_task task = root_pkg(state_ptr, std::forward(fn), std::forward(args)...); LF_ASSUME(task.promise != nullptr); @@ -65,7 +65,7 @@ schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> task.promise->frame.parent = nullptr; if constexpr (Stoppable) { - task.promise->frame.stop_token = sp->stop.token(); + task.promise->frame.stop_token = state_ptr->stop.token(); } else { task.promise->frame.stop_token = stop_source::stop_token{}; // non-cancellable root } @@ -79,7 +79,7 @@ schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> LF_RETHROW; } - return {key(), std::move(sp)}; + return {key(), std::move(state_ptr)}; } template From d3e2b0366448da44c088c81d85b6650105999758 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 10:19:40 +0100 Subject: [PATCH 20/33] tidy ups --- src/core/schedule.cxx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 86cb4b95..90d13d46 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -37,6 +37,10 @@ concept decay_copyable = std::convertible_to>; * `Args...` in its frame, then post it to the scheduler. The root task must * then be resumed by a worker which will perform the invocation of `Fn`. * + * The return address/exception and possibly stop token of the root task are + * bound to the provided `root_state` and can be observed by the caller via the + * returned `receiver`. + * * Strongly exception safe. */ export template @@ -94,18 +98,18 @@ using async_decay_result_t = async_result_t, Context, std::deca /** * @brief Convenience overload: default-constructs a non-cancellable root_state. + * + * Uses the default allocator (`make_shared`) for all allocations. */ export template requires default_schedulable, context_t, std::decay_t...> [[nodiscard("Fire and forget is an anti-pattern")]] constexpr auto schedule(Sch &&sch, Fn &&fn, Args &&...args) -> receiver, Args...>> { - - using context_type = context_t; - using R = async_decay_result_t; - + using result_type = async_decay_result_t, Args...>; + root_state state; return schedule( - std::forward(sch), root_state{}, std::forward(fn), std::forward(args)...); + std::forward(sch), std::move(state), std::forward(fn), std::forward(args)...); } } // namespace lf From 2aaaca272a5b357f5bc47c6ce2ee6bc2b10400c4 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 10:28:25 +0100 Subject: [PATCH 21/33] forward schedule --- src/core/concepts/scheduler.cxx | 4 ++-- src/core/schedule.cxx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/concepts/scheduler.cxx b/src/core/concepts/scheduler.cxx index 137f3197..bd081ce6 100644 --- a/src/core/concepts/scheduler.cxx +++ b/src/core/concepts/scheduler.cxx @@ -22,8 +22,8 @@ using context_t = typename std::remove_cvref_t::context_type; */ export template concept scheduler = - has_context_typedef && requires (Sch scheduler, sched_handle> handle) { - { scheduler.post(handle) } -> std::same_as; + has_context_typedef && requires (Sch &&scheduler, sched_handle> handle) { + { static_cast(scheduler).post(handle) } -> std::same_as; }; } // namespace lf diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 90d13d46..3acf2c86 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -75,10 +75,10 @@ schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> } LF_TRY { - // TODO: forward sch + modify concept - sch.post(sched_handle{key(), &task.promise->frame}); + std::forward(sch).post(sched_handle{key(), &task.promise->frame}); // If ^ didn't throw then the root_task will destroy itself at the final suspend. } LF_CATCH_ALL { + // Otherwise, if it did throw, we must clean up task.promise->frame.handle().destroy(); LF_RETHROW; } From 325d2955ec5f421540cfa7c1c2f8a0fd700da1c3 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 10:35:05 +0100 Subject: [PATCH 22/33] renames --- src/core/receiver.cxx | 44 +++++++++++++++++++++---------------------- src/core/root.cxx | 14 +++++++------- src/core/schedule.cxx | 10 +++++----- test/src/cancel.cpp | 8 ++++---- test/src/schedule.cpp | 18 +++++++++--------- 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 526f34b3..e73430a4 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -23,7 +23,7 @@ export struct broken_receiver_error final : libfork_exception { * @brief Shared state between a scheduled task and its receiver handle. */ template -struct receiver_state { +struct hidden_receiver_state { struct empty_1 {}; struct empty_2 {}; @@ -39,67 +39,67 @@ struct receiver_state { [[no_unique_address]] std::conditional_t stop; - constexpr receiver_state() = default; + constexpr hidden_receiver_state() = default; template requires (sizeof...(Args) > 0) && std::constructible_from - constexpr explicit(sizeof...(Args) == 1) receiver_state(Args &&...args) + constexpr explicit(sizeof...(Args) == 1) hidden_receiver_state(Args &&...args) : return_value(std::forward(args)...) {} }; /// Convenience alias — used throughout the core partitions. template -using state_handle = std::shared_ptr>; +using state_handle = std::shared_ptr>; /** * @brief Lightweight move-only handle owning a pre-allocated root task state. * - * Construction allocates a `receiver_state` which embeds a + * Construction allocates a `hidden_receiver_state` which embeds a * 1 KiB aligned buffer; the root coroutine frame is placement-constructed * into that buffer by `schedule`. * * Constructors mirror `make_shared` / `allocate_shared`: * - * root_state s; // default-init return value - * root_state s{v1, v2}; // in-place init: T{v1, v2} - * root_state s{allocator_arg, alloc}; // default-init, custom allocator - * root_state s{allocator_arg, alloc, v1, v2}; // in-place init + custom allocator + * recv_state s; // default-init return value + * recv_state s{v1, v2}; // in-place init: T{v1, v2} + * recv_state s{allocator_arg, alloc}; // default-init, custom allocator + * recv_state s{allocator_arg, alloc, v1, v2}; // in-place init + custom allocator */ export template -class root_state { - using state_type = receiver_state; +class recv_state { + using state_type = hidden_receiver_state; public: /// Default: value-initialise via `std::make_shared`. - constexpr root_state() : m_ptr(std::make_shared()) {} + constexpr recv_state() : m_ptr(std::make_shared()) {} - /// Value-init from args: forwards `args` to `receiver_state`'s constructor + /// Value-init from args: forwards `args` to `hidden_receiver_state`'s constructor /// (in-place construction of the return value) via `std::make_shared`. template requires (sizeof...(Args) > 0) && std::constructible_from - constexpr explicit(sizeof...(Args) == 1) root_state(Args &&...args) + constexpr explicit(sizeof...(Args) == 1) recv_state(Args &&...args) : m_ptr(std::make_shared(std::forward(args)...)) {} /// Allocator-aware, default return value: allocate via `std::allocate_shared`. template - constexpr root_state(std::allocator_arg_t, Alloc const &alloc) + constexpr recv_state(std::allocator_arg_t, Alloc const &alloc) : m_ptr(std::allocate_shared(alloc)) {} /// Allocator-aware with value-init args. template requires std::constructible_from - constexpr root_state(std::allocator_arg_t, Alloc const &alloc, Args &&...args) + constexpr recv_state(std::allocator_arg_t, Alloc const &alloc, Args &&...args) : m_ptr(std::allocate_shared(alloc, std::forward(args)...)) {} // Move-only. - constexpr root_state(root_state &&) noexcept = default; - constexpr auto operator=(root_state &&) noexcept -> root_state & = default; - constexpr root_state(root_state const &) = delete; - constexpr auto operator=(root_state const &) -> root_state & = delete; + constexpr recv_state(recv_state &&) noexcept = default; + constexpr auto operator=(recv_state &&) noexcept -> recv_state & = default; + constexpr recv_state(recv_state const &) = delete; + constexpr auto operator=(recv_state const &) -> recv_state & = delete; private: [[nodiscard]] - friend constexpr auto get(key_t, root_state &&self) noexcept -> state_handle { + friend constexpr auto get(key_t, recv_state &&self) noexcept -> state_handle { return std::move(self.m_ptr); } @@ -109,7 +109,7 @@ class root_state { export template class receiver { - using state_type = receiver_state; + using state_type = hidden_receiver_state; public: constexpr receiver(key_t, state_handle state) noexcept : m_state(std::move(state)) {} diff --git a/src/core/root.cxx b/src/core/root.cxx index 465f96a3..1f2436c8 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -23,7 +23,7 @@ namespace lf { export struct root_alloc_error final : libfork_exception { [[nodiscard]] constexpr auto what() const noexcept -> const char * override { - return "root coroutine frame exceeds receiver_state buffer size"; + return "root coroutine frame exceeds hidden_receiver_state buffer size"; } }; @@ -35,7 +35,7 @@ struct root_task { frame_type frame{Checkpoint{}}; - /// Owns a ref to the receiver_state hosting this frame's buffer. + /// Owns a ref to the hidden_receiver_state hosting this frame's buffer. std::shared_ptr keep_alive; template @@ -55,7 +55,7 @@ struct root_task { return recv->buffer.data(); } - /// No-op: the buffer is owned by the receiver_state, not the frame. + /// No-op: the buffer is owned by the hidden_receiver_state, not the frame. static auto operator delete(void * /*ptr*/, std::size_t /*size*/) noexcept -> void {} struct frame_awaitable : std::suspend_never { @@ -88,8 +88,8 @@ struct root_task { /** * @brief Custom final_suspend. * - * The root coroutine frame lives inside the receiver_state's embedded - * buffer, so the receiver_state must outlive the frame teardown. + * The root coroutine frame lives inside the hidden_receiver_state's embedded + * buffer, so the hidden_receiver_state must outlive the frame teardown. * * 1. `std::exchange` the keep-alive shared_ptr into a local on the * host stack, leaving the promise member null. @@ -97,7 +97,7 @@ struct root_task { * the now-null `keep_alive`) and our no-op `operator delete`. * No frame-memory access occurs after the handle returns. * 3. On return, the stack-local `shared_ptr` dies; if its ref - * was the last, it destroys the receiver_state cleanly — we are + * was the last, it destroys the hidden_receiver_state cleanly — we are * no longer executing inside the buffer. */ struct final_awaiter : std::suspend_always { @@ -105,7 +105,7 @@ struct root_task { std::shared_ptr local = std::exchange(handle.promise().keep_alive, nullptr); LF_ASSUME(local != nullptr); handle.destroy(); - // `local` released here — possibly freeing receiver_state on return. + // `local` released here — possibly freeing hidden_receiver_state on return. } }; diff --git a/src/core/schedule.cxx b/src/core/schedule.cxx index 3acf2c86..8c5e510a 100644 --- a/src/core/schedule.cxx +++ b/src/core/schedule.cxx @@ -31,14 +31,14 @@ template concept decay_copyable = std::convertible_to>; /** - * @brief Schedule a function using a caller-provided `root_state`. + * @brief Schedule a function using a caller-provided `recv_state`. * * This will create a root task that stores decayed copies of `Fn` and * `Args...` in its frame, then post it to the scheduler. The root task must * then be resumed by a worker which will perform the invocation of `Fn`. * * The return address/exception and possibly stop token of the root task are - * bound to the provided `root_state` and can be observed by the caller via the + * bound to the provided `recv_state` and can be observed by the caller via the * returned `receiver`. * * Strongly exception safe. @@ -47,7 +47,7 @@ export template , R, context_t, std::decay_t...> [[nodiscard("Fire and forget is an anti-pattern")]] constexpr auto -schedule(Sch &&sch, root_state state, Fn &&fn, Args &&...args) -> receiver { +schedule(Sch &&sch, recv_state state, Fn &&fn, Args &&...args) -> receiver { using context_type = context_t; @@ -97,7 +97,7 @@ template using async_decay_result_t = async_result_t, Context, std::decay_t...>; /** - * @brief Convenience overload: default-constructs a non-cancellable root_state. + * @brief Convenience overload: default-constructs a non-cancellable recv_state. * * Uses the default allocator (`make_shared`) for all allocations. */ @@ -107,7 +107,7 @@ export template constexpr auto schedule(Sch &&sch, Fn &&fn, Args &&...args) -> receiver, Args...>> { using result_type = async_decay_result_t, Args...>; - root_state state; + recv_state state; return schedule( std::forward(sch), std::move(state), std::forward(fn), std::forward(args)...); } diff --git a/test/src/cancel.cpp b/test/src/cancel.cpp index 8ef9b1d0..85b011b4 100644 --- a/test/src/cancel.cpp +++ b/test/src/cancel.cpp @@ -35,7 +35,7 @@ import libfork; // source propagates through the chain to the inner scope. // // H. Stoppable receiver / pre-cancelled root: -// root_state + receiver::request_stop() immediately after +// recv_state + receiver::request_stop() immediately after // schedule() — covers the goto-cleanup fast path in root.cxx on // schedulers where the task has not yet begun running. Racy in // principle, so the test only asserts completion, not that the body @@ -356,7 +356,7 @@ auto test_nested_child_scope_chain(lf::env) -> lf::task // ============================================================ // H. Stoppable receiver / pre-cancelled root. // -// Using root_state + receiver::request_stop() exercises the +// Using recv_state + receiver::request_stop() exercises the // goto-cleanup fast path in root.cxx when stop is requested before the // worker resumes the task. // ============================================================ @@ -442,9 +442,9 @@ void tests(Sch &scheduler) { REQUIRE(std::move(recv).get()); } - SECTION("stoppable receiver: root_state + request_stop completes cleanly") { + SECTION("stoppable receiver: recv_state + request_stop completes cleanly") { std::atomic ran = false; - lf::root_state state; + lf::recv_state state; auto recv = lf::schedule(scheduler, std::move(state), pre_cancelled_root_fn, &ran); REQUIRE(recv.valid()); recv.request_stop(); diff --git a/test/src/schedule.cpp b/test/src/schedule.cpp index 7596ddff..1f951a56 100644 --- a/test/src/schedule.cpp +++ b/test/src/schedule.cpp @@ -29,7 +29,7 @@ auto throwing_function(env /*unused*/) -> task { } // Task whose argument is a big enough value-type to push the root coroutine -// frame past the 1 KiB embedded buffer in receiver_state. +// frame past the 1 KiB embedded buffer in hidden_receiver_state. template auto big_arg_function(env /*unused*/, std::array /*unused*/) -> task { @@ -50,31 +50,31 @@ void simple_tests(Sch &scheduler) { REQUIRE(std::move(recv).get() == true); } - SECTION("explicit root_state") { - lf::root_state state; + SECTION("explicit recv_state") { + lf::recv_state state; auto recv = schedule(scheduler, std::move(state), simple_function>); REQUIRE(recv.valid()); REQUIRE(std::move(recv).get() == true); } - SECTION("stoppable root_state") { - lf::root_state state; + SECTION("stoppable recv_state") { + lf::recv_state state; auto recv = schedule(scheduler, std::move(state), simple_function>); REQUIRE(recv.valid()); REQUIRE(std::move(recv).get() == true); } - SECTION("root_state with explicit allocator") { + SECTION("recv_state with explicit allocator") { std::allocator alloc; - lf::root_state state{std::allocator_arg, alloc}; + lf::recv_state state{std::allocator_arg, alloc}; auto recv = schedule(scheduler, std::move(state), simple_function>); REQUIRE(recv.valid()); REQUIRE(std::move(recv).get() == true); } - SECTION("root_state with value-init") { + SECTION("recv_state with value-init") { // Pre-initialise the return slot; the task overwrites it. - lf::root_state state{false}; + lf::recv_state state{false}; auto recv = schedule(scheduler, std::move(state), simple_function>); REQUIRE(recv.valid()); REQUIRE(std::move(recv).get() == true); From 29cca6f0f0d920f607fc05ffef1e32a0634967af Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 10:49:56 +0100 Subject: [PATCH 23/33] missing nodiscard --- src/core/stop.cxx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/stop.cxx b/src/core/stop.cxx index 4b3b8c52..3c4a0623 100644 --- a/src/core/stop.cxx +++ b/src/core/stop.cxx @@ -72,7 +72,10 @@ export class stop_source { /** * @brief Get a handle to this stop source. */ - constexpr auto token() const noexcept -> stop_token { return stop_token{this}; } + [[nodiscard]] + constexpr auto token() const noexcept -> stop_token { + return stop_token{this}; + } /** * @brief Returns true if any stop source in the ancestor chain has been stopped. From 98a1645d4cc77c0140c5ce6229ef7b54908d71a4 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 11:05:56 +0100 Subject: [PATCH 24/33] comments --- src/core/promise.cxx | 5 +++++ src/core/root.cxx | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/promise.cxx b/src/core/promise.cxx index 24b1908a..c18634da 100644 --- a/src/core/promise.cxx +++ b/src/core/promise.cxx @@ -421,6 +421,11 @@ struct join_awaitable { LF_ASSUME(self.frame->joins == k_u16_max); // Outside parallel regions so can touch non-atomically. + // + // A task that completes by responding to cancellation will drop any + // exceptions however, a task may still throw exceptions even if cancelled. + // Here we must rethrow even if cancelled becasue we can't re-suspend at + // this point. if constexpr (LF_COMPILER_EXCEPTIONS) { if (self.frame->exception_bit) [[unlikely]] { self.rethrow_exception(); diff --git a/src/core/root.cxx b/src/core/root.cxx index 1f2436c8..f3870477 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -178,9 +178,10 @@ root_pkg(state_handle recv, Fn fn, Args... args) -> root_taskexception_bit) { From 74fdaf9dd05be5c8a2cd1dafd649c4a926a28650 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 11:06:09 +0100 Subject: [PATCH 25/33] receiver API --- src/core/receiver.cxx | 31 ++++++++++++++----------------- test/src/cancel.cpp | 2 +- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index e73430a4..5435183e 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -120,11 +120,17 @@ class receiver { constexpr auto operator=(receiver &&) noexcept -> receiver & = default; constexpr auto operator=(const receiver &) -> receiver & = delete; + /** + * @brief Test if connected to a receiver state. + */ [[nodiscard]] constexpr auto valid() const noexcept -> bool { return m_state != nullptr; } + /** + * @brief Test if the associated task has completed (either successfully or with an exception/cancellation). + */ [[nodiscard]] constexpr auto ready() const -> bool { if (!valid()) { @@ -133,6 +139,11 @@ class receiver { return m_state->ready.test(); } + /** + * @brief Wait for the associated task to complete (either successfully or with an exception/cancellation). + * + * May be called multiple times. + */ constexpr void wait() const { if (!valid()) { LF_THROW(broken_receiver_error{}); @@ -141,32 +152,18 @@ class receiver { } /** - * @brief Returns a stop_token for this task's stop source. + * @brief Get a reference to the stop_source for this task, allowing the caller to request cancellation. * * Only available when Stoppable=true. */ [[nodiscard]] - constexpr auto token() const -> stop_source::stop_token - requires Stoppable - { - if (!valid()) { - LF_THROW(broken_receiver_error{}); - } - return m_state->stop.token(); - } - - /** - * @brief Request that the associated task stop. - * - * Only available when Stoppable=true. Thread-safe. - */ - constexpr auto request_stop() -> void + constexpr auto stop_source() -> stop_source & requires Stoppable { if (!valid()) { LF_THROW(broken_receiver_error{}); } - m_state->stop.request_stop(); + return m_state->stop; } /** diff --git a/test/src/cancel.cpp b/test/src/cancel.cpp index 85b011b4..c38ea5d5 100644 --- a/test/src/cancel.cpp +++ b/test/src/cancel.cpp @@ -447,7 +447,7 @@ void tests(Sch &scheduler) { lf::recv_state state; auto recv = lf::schedule(scheduler, std::move(state), pre_cancelled_root_fn, &ran); REQUIRE(recv.valid()); - recv.request_stop(); + recv.stop_source().request_stop(); std::move(recv).get(); // The task body may or may not have run depending on scheduler timing; // what matters is that get() completes without error. From f7e50991ee7c5b71ad9036b6f6577433ca5c64d1 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 11:12:35 +0100 Subject: [PATCH 26/33] throw exception on get when cancelled --- src/core/receiver.cxx | 15 +++++++++++++++ test/src/cancel.cpp | 10 ++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 5435183e..7b4c9873 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -19,6 +19,13 @@ export struct broken_receiver_error final : libfork_exception { } }; +export struct operation_cancelled_error final : libfork_exception { + [[nodiscard]] + constexpr auto what() const noexcept -> const char * override { + return "operation was cancelled"; + } +}; + /** * @brief Shared state between a scheduled task and its receiver handle. */ @@ -169,6 +176,8 @@ class receiver { /** * @brief Wait for the associated task to complete and return its result, or rethrow. * + * If the reciever was cancelled this will throw an exception. + * * This may only be called once; the state is consumed and the receiver becomes invalid. */ [[nodiscard]] @@ -185,6 +194,12 @@ class receiver { std::rethrow_exception(state->exception); } + if constexpr (Stoppable) { + if (state->stop.stop_requested()) { + LF_THROW(operation_cancelled_error{}); + } + } + if constexpr (!std::is_void_v) { return std::move(state->return_value); } diff --git a/test/src/cancel.cpp b/test/src/cancel.cpp index c38ea5d5..4d91226f 100644 --- a/test/src/cancel.cpp +++ b/test/src/cancel.cpp @@ -448,10 +448,16 @@ void tests(Sch &scheduler) { auto recv = lf::schedule(scheduler, std::move(state), pre_cancelled_root_fn, &ran); REQUIRE(recv.valid()); recv.stop_source().request_stop(); - std::move(recv).get(); + +#if LF_COMPILER_EXCEPTIONS + REQUIRE_THROWS_AS(std::move(recv).get(), lf::operation_cancelled_error); +#else + recv.wait(); +#endif + // The task body may or may not have run depending on scheduler timing; // what matters is that get() completes without error. - (void)ran.load(); + std::ignore = ran.load(); } #if LF_COMPILER_EXCEPTIONS From 2a1f48dc55352973a5184aa9bcde02bf6d2809ce Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 11:50:37 +0100 Subject: [PATCH 27/33] move release to correct location --- src/core/promise.cxx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/core/promise.cxx b/src/core/promise.cxx index c18634da..d1541aac 100644 --- a/src/core/promise.cxx +++ b/src/core/promise.cxx @@ -137,15 +137,15 @@ constexpr auto final_suspend_full(Context &context, frame_t *frame) noe continue; } - if (owner) { - // We were unable to resume the parent and we were its owner, as the - // resuming thread will take ownership of the parent's we must give it up. - context.stack().release(std::move(release_key)); - } - return parent->handle(); } + if (owner) { + // We were unable to resume the parent and we were its owner, as the + // resuming thread will take ownership of the parent's we must give it up. + context.stack().release(std::move(release_key)); + } + // We did not win the join-race, we cannot dereference the parent pointer now // as the frame may now be freed by the winner. Parent has not reached join // or we are not the last child to complete. We are now out of jobs, we must @@ -182,6 +182,7 @@ constexpr auto final_suspend_trailing(Context &context, frame_t *parent } return final_suspend_full(context, parent); } + return parent->handle(); } From 3c19374ac998eb747a7b5b8ff05883890e7aac4c Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 11:54:02 +0100 Subject: [PATCH 28/33] typo --- src/core/promise.cxx | 2 +- src/core/stop.cxx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/promise.cxx b/src/core/promise.cxx index d1541aac..6ce7ff60 100644 --- a/src/core/promise.cxx +++ b/src/core/promise.cxx @@ -425,7 +425,7 @@ struct join_awaitable { // // A task that completes by responding to cancellation will drop any // exceptions however, a task may still throw exceptions even if cancelled. - // Here we must rethrow even if cancelled becasue we can't re-suspend at + // Here we must rethrow even if cancelled because we can't re-suspend at // this point. if constexpr (LF_COMPILER_EXCEPTIONS) { if (self.frame->exception_bit) [[unlikely]] { diff --git a/src/core/stop.cxx b/src/core/stop.cxx index 3c4a0623..6d23d9ca 100644 --- a/src/core/stop.cxx +++ b/src/core/stop.cxx @@ -7,7 +7,7 @@ import libfork.utils; namespace lf { /** - * @brief Similar to a linked-list of std::stop_sorce but with an embedded stop_state. + * @brief Similar to a linked-list of std::stop_source but with an embedded stop_state. */ export class stop_source { public: @@ -79,7 +79,7 @@ export class stop_source { /** * @brief Returns true if any stop source in the ancestor chain has been stopped. - + * * Complexity: O(chain depth). Every task that creates a child_scope adds one * node to the chain, so deeply-nested task hierarchies pay proportionally more * per stop check. From 704669a82b38d2cc687768a429c8935dd8c83d8f Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 11:56:17 +0100 Subject: [PATCH 29/33] codespell --- src/core/receiver.cxx | 2 +- src/core/root.cxx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/receiver.cxx b/src/core/receiver.cxx index 7b4c9873..3e78b9e2 100644 --- a/src/core/receiver.cxx +++ b/src/core/receiver.cxx @@ -176,7 +176,7 @@ class receiver { /** * @brief Wait for the associated task to complete and return its result, or rethrow. * - * If the reciever was cancelled this will throw an exception. + * If the receiver was cancelled this will throw an exception. * * This may only be called once; the state is consumed and the receiver becomes invalid. */ diff --git a/src/core/root.cxx b/src/core/root.cxx index f3870477..fa4b6285 100644 --- a/src/core/root.cxx +++ b/src/core/root.cxx @@ -180,8 +180,8 @@ root_pkg(state_handle recv, Fn fn, Args... args) -> root_taskexception_bit) { From ac226449d05bbf162e05a07fb213feb29955bcd6 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 12:05:02 +0100 Subject: [PATCH 30/33] new tests --- test/src/cancel.cpp | 261 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) diff --git a/test/src/cancel.cpp b/test/src/cancel.cpp index 4d91226f..63ca97f4 100644 --- a/test/src/cancel.cpp +++ b/test/src/cancel.cpp @@ -367,6 +367,162 @@ auto pre_cancelled_root_fn(lf::env, std::atomic *ran) -> lf::task co_return; } +// ============================================================ +// I. Stress tests: concurrent cancellation under contention. +// +// These tests fork many tasks across multiple threads to maximize the +// probability of hitting the concurrent paths in final_suspend_full, +// final_suspend_trailing, and join_awaitable::await_suspend with +// stop_requested() == true. +// ============================================================ + +// --- Leaf task: does a tiny amount of work then returns. +struct leaf_work { + template + static auto operator()(lf::env, std::atomic &count) -> lf::task { + count.fetch_add(1, std::memory_order_relaxed); + co_return; + } +}; + +// --- Fan-out many forks, one sibling cancels the scope mid-flight. +// +// With enough forks and threads, some children will be in-flight when +// stop fires. This exercises: +// - final_suspend_trailing: child completes, wins join race, sees stop +// - final_suspend_full: iterative ancestor climb on stopped frames +// - awaitable::await_suspend: children launched after stop are skipped +// - join_awaitable: stop detected at join with steals > 0 + +struct stress_fan_cancel_inner { + template + static auto operator()(lf::env, lf::stop_source &my_stop, std::atomic &count, int width) + -> lf::task { + auto sc = co_await lf::scope(); + + // Fork width children; the last one cancels this scope. + for (int i = 0; i < width; ++i) { + if (i == width / 2) { + co_await sc.fork_drop(cancel_source{}, my_stop, count); + } else { + co_await sc.fork_drop(leaf_work{}, count); + } + } + co_await sc.join(); + // Should not be reached — cancel_source fired mid-fan. + count.fetch_add(10000, std::memory_order_relaxed); + } +}; + +template +auto stress_fan_cancel(lf::env, int width) -> lf::task { + std::atomic count = 0; + auto outer = co_await lf::child_scope(); + co_await outer.call_drop(stress_fan_cancel_inner{}, outer, count, width); + co_await outer.join(); + // Unreachable: outer scope was cancelled by the inner task. +} + +// --- Deep recursive fork tree with cancellation at a specific depth. +// +// This creates a binary tree of forks. When a node at the target depth +// fires, it cancels the scope. This stresses: +// - final_suspend_full loop: many frames in the ancestor chain may be +// stopped, causing iterative climbing +// - final_suspend_trailing: stolen forks completing concurrently +// - Stack ownership transfer under cancellation + +struct tree_cancel_node { + template + static auto + operator()(lf::env, lf::stop_source &root_stop, std::atomic &count, int depth, int cancel_at) + -> lf::task { + count.fetch_add(1, std::memory_order_relaxed); + + if (depth <= 0) { + co_return; + } + + if (depth == cancel_at) { + root_stop.request_stop(); + co_return; + } + + auto sc = co_await lf::scope(); + co_await sc.fork_drop(tree_cancel_node{}, root_stop, count, depth - 1, cancel_at); + co_await sc.fork_drop(tree_cancel_node{}, root_stop, count, depth - 1, cancel_at); + co_await sc.join(); + } +}; + +template +auto stress_tree_cancel(lf::env, int depth, int cancel_at) -> lf::task { + std::atomic count = 0; + auto outer = co_await lf::child_scope(); + co_await outer.call_drop(tree_cancel_node{}, outer, count, depth, cancel_at); + co_await outer.join(); + // Unreachable: outer scope was cancelled by a tree node. +} + +// --- Repeated schedule + cancel: exercises root.cxx stop path and receiver. +// +// Rapidly schedules tasks and immediately cancels them. The race between +// the worker picking up the task and the cancellation request stresses +// the root_pkg pre-cancelled path and final_suspend from root frames. + +struct busy_leaf { + template + static auto operator()(lf::env) -> lf::task { + co_return; + } +}; + +// --- Many-fork cancel with nested child_scopes at multiple levels. +// +// An outer child_scope forks N tasks. Each inner task creates its own +// child_scope and forks M children. Mid-way, the outer scope is cancelled. +// This tests chain propagation under concurrent fork completion, hitting +// final_suspend_full's iterative climb through multiple nested stopped +// frames. + +struct nested_inner_worker { + template + static auto operator()(lf::env, std::atomic &count, int width) -> lf::task { + auto sc = co_await lf::scope(); + for (int i = 0; i < width; ++i) { + co_await sc.fork_drop(leaf_work{}, count); + } + co_await sc.join(); + } +}; + +struct nested_cancel_orchestrator { + template + static auto operator()(lf::env, lf::stop_source &root_stop, std::atomic &count, int width) + -> lf::task { + auto sc = co_await lf::scope(); + for (int i = 0; i < width; ++i) { + if (i == width / 2) { + // Cancel after forking half the work + root_stop.request_stop(); + } + co_await sc.fork_drop(nested_inner_worker{}, count, width); + } + co_await sc.join(); + // Should not be reached + count.fetch_add(100000, std::memory_order_relaxed); + } +}; + +template +auto stress_nested_cancel(lf::env, int width) -> lf::task { + std::atomic count = 0; + auto outer = co_await lf::child_scope(); + co_await outer.call_drop(nested_cancel_orchestrator{}, outer, count, width); + co_await outer.join(); + // Unreachable: outer scope was cancelled by the orchestrator. +} + // ============================================================ // Run all tests against a given scheduler // ============================================================ @@ -460,6 +616,63 @@ void tests(Sch &scheduler) { std::ignore = ran.load(); } + // --- Stress tests (paths C/D/E under contention) --- + + SECTION("stress: fan-out cancel, width=16") { + auto recv = schedule(scheduler, stress_fan_cancel, 16); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + + SECTION("stress: fan-out cancel, width=64") { + auto recv = schedule(scheduler, stress_fan_cancel, 64); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + + SECTION("stress: tree cancel depth=6, cancel at depth=3") { + auto recv = schedule(scheduler, stress_tree_cancel, 6, 3); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + + SECTION("stress: tree cancel depth=8, cancel at depth=1 (near leaf)") { + auto recv = schedule(scheduler, stress_tree_cancel, 8, 1); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + + SECTION("stress: tree cancel depth=8, cancel at depth=7 (near root)") { + auto recv = schedule(scheduler, stress_tree_cancel, 8, 7); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + + SECTION("stress: nested child_scope cancel, width=8") { + auto recv = schedule(scheduler, stress_nested_cancel, 8); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + + SECTION("stress: nested child_scope cancel, width=32") { + auto recv = schedule(scheduler, stress_nested_cancel, 32); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + + SECTION("stress: rapid schedule + cancel") { + for (int i = 0; i < 50; ++i) { + lf::recv_state state; + auto recv = lf::schedule(scheduler, std::move(state), busy_leaf{}); + recv.stop_source().request_stop(); +#if LF_COMPILER_EXCEPTIONS + REQUIRE_THROWS_AS(std::move(recv).get(), lf::operation_cancelled_error); +#else + recv.wait(); +#endif + } + } + #if LF_COMPILER_EXCEPTIONS SECTION("exception propagates through join when frame is NOT cancelled") { @@ -511,3 +724,51 @@ TEMPLATE_TEST_CASE("Busy cancel", "[cancel]", mono_busy_thread_pool, poly_busy_t } } } + +namespace { + +// Stress tests repeated at higher thread counts to maximize contention. +template +void stress_tests(Sch &scheduler) { + using Ctx = lf::context_t; + + SECTION("stress: repeated fan cancel") { + for (int rep = 0; rep < 20; ++rep) { + auto recv = schedule(scheduler, stress_fan_cancel, 32); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + } + + SECTION("stress: repeated tree cancel") { + for (int rep = 0; rep < 20; ++rep) { + auto recv = schedule(scheduler, stress_tree_cancel, 7, 3); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + } + + SECTION("stress: repeated nested cancel") { + for (int rep = 0; rep < 20; ++rep) { + auto recv = schedule(scheduler, stress_nested_cancel, 16); + REQUIRE(recv.valid()); + std::move(recv).get(); + } + } +} + +} // namespace + +TEMPLATE_TEST_CASE("Busy cancel stress", "[cancel][stress]", mono_busy_thread_pool, poly_busy_thread_pool) { + + STATIC_REQUIRE(lf::scheduler); + + std::size_t max_thr = std::min(8UZ, static_cast(std::thread::hardware_concurrency())); + + for (std::size_t thr = 2; thr <= max_thr; thr *= 2) { + DYNAMIC_SECTION("threads=" << thr) { + TestType scheduler{thr}; + stress_tests(scheduler); + } + } +} From 4932cba26c5db40184c6f1f9bb82dea36597d3c2 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 13:09:32 +0100 Subject: [PATCH 31/33] comments --- test/src/cancel.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/src/cancel.cpp b/test/src/cancel.cpp index 63ca97f4..dfdff441 100644 --- a/test/src/cancel.cpp +++ b/test/src/cancel.cpp @@ -40,6 +40,8 @@ import libfork; // schedulers where the task has not yet begun running. Racy in // principle, so the test only asserts completion, not that the body // was skipped. +// +// I. Stress tests: concurrent cancellation under contention. namespace { From efb3ed8105d419f844188c29e59d3cffefbb78b8 Mon Sep 17 00:00:00 2001 From: Conor Date: Mon, 20 Apr 2026 13:12:35 +0100 Subject: [PATCH 32/33] term --- test/src/cancel.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/src/cancel.cpp b/test/src/cancel.cpp index dfdff441..a1b741f0 100644 --- a/test/src/cancel.cpp +++ b/test/src/cancel.cpp @@ -423,6 +423,7 @@ auto stress_fan_cancel(lf::env, int width) -> lf::task { co_await outer.call_drop(stress_fan_cancel_inner{}, outer, count, width); co_await outer.join(); // Unreachable: outer scope was cancelled by the inner task. + std::terminate(); } // --- Deep recursive fork tree with cancellation at a specific depth. @@ -464,6 +465,7 @@ auto stress_tree_cancel(lf::env, int depth, int cancel_at) -> lf::task< co_await outer.call_drop(tree_cancel_node{}, outer, count, depth, cancel_at); co_await outer.join(); // Unreachable: outer scope was cancelled by a tree node. + std::terminate(); } // --- Repeated schedule + cancel: exercises root.cxx stop path and receiver. @@ -523,6 +525,7 @@ auto stress_nested_cancel(lf::env, int width) -> lf::task Date: Mon, 20 Apr 2026 13:16:17 +0100 Subject: [PATCH 33/33] remove incorrect assumptions --- test/src/cancel.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/src/cancel.cpp b/test/src/cancel.cpp index a1b741f0..386e59ee 100644 --- a/test/src/cancel.cpp +++ b/test/src/cancel.cpp @@ -422,8 +422,6 @@ auto stress_fan_cancel(lf::env, int width) -> lf::task { auto outer = co_await lf::child_scope(); co_await outer.call_drop(stress_fan_cancel_inner{}, outer, count, width); co_await outer.join(); - // Unreachable: outer scope was cancelled by the inner task. - std::terminate(); } // --- Deep recursive fork tree with cancellation at a specific depth. @@ -464,8 +462,6 @@ auto stress_tree_cancel(lf::env, int depth, int cancel_at) -> lf::task< auto outer = co_await lf::child_scope(); co_await outer.call_drop(tree_cancel_node{}, outer, count, depth, cancel_at); co_await outer.join(); - // Unreachable: outer scope was cancelled by a tree node. - std::terminate(); } // --- Repeated schedule + cancel: exercises root.cxx stop path and receiver. @@ -524,8 +520,6 @@ auto stress_nested_cancel(lf::env, int width) -> lf::task