Skip to content

fix(coro): captured_awaitable tag, steal payload by value, no frame leak, spawn_task returns to the caller - #22

Merged
codethinki merged 1 commit into
masterfrom
fix/captured-awaitable
Oct 24, 2026
Merged

codethinki merged 1 commit into
masterfrom
fix/captured-awaitable

Conversation

@codethinki

Copy link
Copy Markdown
Owner

Correctness fixes in cth_coro, found while designing the strand for jvk.

captured_awaitable was always false

  • The concept checked capture_awaiter_base, which was only declared. The capture awaiters derived from a differently named dev::capture_promise_awaiter_base, which nothing checked.
  • Now capture_awaiter_base is the public opt-in tag, and capture_promise_awaiter derives from it. dev::capture_promise_awaiter_base is gone.

steal of a foreign awaitable crashed

  • The coroutine overload took Pyld const&. The callers pass a temporary, so the reference in the frame dangled before the task ran.
  • The payload is now taken by value (C++ Core Guidelines CP.53).

Coroutine frames leaked on co_await of an rvalue task

  • task_base::operator co_await() && extracted the handle into an awaiter that does not own it. LeakSanitizer found leaks in 38 of 66 coro tests, among them every spawn.
  • The rvalue overload is removed. The task stays the owner: a temporary lives until the end of the full expression.
  • The steal overloads for this_coro awaitables return a non-owning awaiter: documented, co_await it in the same expression.

spawn returns to the caller

  • scheduled_task -> spawn_task. Before, spawn_impl stole back to the target, and the caller only returned to its own scheduler because the broken concept sent scheduled_task through the foreign path.
  • spawn_task is now a this_coro awaitable: the caller's scheduler is injected, and at the end the caller continues on its own scheduler (direct transfer if already there, else one post). A caller without a this_coro payload continues where the child ended.

constexpr

  • Marked where a function can run at compile time, removed where it never can.

Tests: classification static_asserts, spawn behavior (A to B and back, A to A, void, exceptions, nested, a child that ends elsewhere, a caller without payload), steal of a foreign awaitable. 285/285 on gcc and clang, debug and release. ASan: 0 leaks across all coro tests.

…eak on rvalue co_await, spawn_task returns to the caller

- capture_awaiter_base is the public opt-in tag for captured awaitables, capture_promise_awaiter derives from it
- steal takes its payload by value: a reference parameter of a coroutine dangles
- task_base has no rvalue operator co_await: it extracted the handle into a non-owning awaiter and leaked the frame
- scheduled_task -> spawn_task: a this_coro awaitable, the caller continues on its own scheduler
- constexpr where it can run at compile time
@codethinki
codethinki merged commit 48805f0 into master Oct 24, 2026
6 checks passed
@codethinki
codethinki deleted the fix/captured-awaitable branch October 24, 2026 14:01
codethinki added a commit that referenced this pull request Oct 25, 2026
… and strand (#25)

* Revert "fix(coro): captured_awaitable tag, steal payload by value, no frame leak on rvalue co_await, spawn_task returns to the caller (#22)"

This reverts commit 48805f0.

* fix(coro): task_base has no rvalue operator co_await

- the rvalue overload extracted the handle into a non-owning awaiter, the frame leaked
- an rvalue task binds to the const& overload, the temporary lives until the end of the full expression
- the deleted copy assignment returns task_base&

* fix(coro): every co_await returns the caller to its scheduler

- the scheduler lives in the promise, the payload is user data only (payload_base)
- task awaiters read the caller's promise in await_suspend, no inject step
- affine final awaiter: a task continues its parent on the parent's scheduler, also after an exception
- one marker for awaiters that resume the caller themselves (this_coro_awaiter_base), await_transform passes them through
- steal is dev::steal, it captures a foreign awaitable, the other overloads and executor::steal are removed
- scheduled_task -> spawn_task with its own awaiter, the spawned task gets a fresh payload
- tags receive the scheduler and the payload
- native_handle_awaiter_base and osfence_awaiter_base are not templates
- sync_executor_task is removed
- a this_coro task awaited by a caller without this_coro is a compile error

* feat(coro): executor::sync_wait spawns a task and blocks until it ends

- CTH_CRITICAL if called on a thread of its own scheduler, it would block a worker
- the tests use it instead of their local helpers, the one in executor_test.cpp dropped exceptions of void tasks

* feat(meta): nothrow_constructible and nothrow_copy_constructible concepts

* refactor(coro): constexpr pass, copies are conditionally noexcept

- constexpr where a function can run at compile time, removed where it reads the clock or returns noop_coroutine()
- payload copies (promise constructor, inherit, await_suspend of the task awaiter), raw_promise::return_value and data_awaiter::await_resume are noexcept if the copy is: a throwing copy reaches the caller instead of terminating

* docs: README coro section, every co_await returns the caller, sync_wait example

* fix(win): keybd_event_awaiter derives the non-template osfence_awaiter_base

* feat(coro): context interface, scheduler is a context

- abstract context: post, await (handle, time point), owns_thread
- co::current_context(): the context whose job runs on the calling thread, exact for strand jobs
- promise, awaiters, tags and executor work on context const&, this_coro::scheduler -> this_coro::context
- the affine final awaiter and schedule() compare with current_context()
- sync_wait has no CTH_CRITICAL, it documents the self wait

* feat(coro): strand, a serial queue on a scheduler

- strand is a context, its jobs run one at a time and in post order on the workers of its scheduler
- strand jobs set current_context() to the strand while they run
- shared asio helpers post_on and await_on, scheduler and strand implement their overrides with them

* refactor(coro): reuse promise and awaiter bases, data_awaiter moves its value

- dev::affine_promise<T> holds the affine final awaiter and parentContext together, they cannot be split anymore
- the task awaiters set their own parts and call basic_promise_awaiter::await_suspend
- raw_promise_base holds exception, continuation and unhandled_exception for both raw_promise versions
- data_awaiter moves its value out in await_resume

* refactor(coro): one awaiter for all tasks, the promise attaches to the caller

- this_coro_promise_awaiter is the awaiter of executor, spawn and capture tasks
- the per task part is the promise's attach(): affine default sets the parent context, executor also inherits, spawn falls back to its own context
- dev::nothrow_attachable gives the noexcept of await_suspend
- capture_promise_awaiter, spawn_promise_awaiter and the unused basic_final_promise_awaiter are removed

* refactor(coro): executor_promise::attach takes its noexcept from a trait pack, no lambda trick

* perf(meta): trait packs are type level, type_pack

- unpack_trait_t, first_pack_t and conditional_pack_t use partial specializations instead of consteval functions
- about 2.6x faster on gcc and 15% on clang for 2000 instantiations
- the consteval functions first_pack(), unpack_trait() and conditional_pack() are removed, the _t aliases stay
- type_pack<T>: a pack that always unpacks to T

* refactor(coro): caller_payload_t uses type_pack

* fix(coro): start() on an active scheduler is a CTH_CRITICAL, callerContext, cleanups

- start() on an active scheduler deadlocked: the jthread assignment joined a running worker
- affine_promise::parentContext -> callerContext, caller is the term in the docs
- unused includes, forward declaration and namespace removed, wait_awaiter uses chrono::time_point_t

* fix(coro): a failed wait throws in the awaiting coroutine

- context::await takes an await_func, the callback gets the error code of the wait
- the wait handlers pass the error code instead of throwing on a worker, which terminated the program
- timer_pool releases its timer before the callback, a failed wait leaked it
- wait_awaiter_base keeps the error code until the caller resumes, its await_resume throws coro_exception
- wait_awaiter -> time_point_awaiter, it and raw_native_handle_awaiter derive wait_awaiter_base
- the awaiters of cth_net and cth_win call the base await_resume first

* refactor(coro): CTH_CORO_WARN, CTH_CORO_STABLE_ERR, CTH_CORO_STABLE_THROW like the other modules

* refactor(coro): context::current() instead of the free function current_context()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant