Add external-completion scheduler policy for co_sm#16
Conversation
Adds utility/external_completion.hpp: completion_source (an atomic flag a worker thread fires via a trampoline compatible with thread_pool_scheduler::try_submit_with_completion) and external_completion_scheduler<SourceCount>, a single-consumer scheduler policy that lets a co_sm dispatch suspend awaiting completions fired by other threads while still returning only when the whole dispatch is done. co_sm gains a structural external_completion_scheduler_contract concept (duck-typed so co_sm.hpp stays independent of the opt-in module) and an external dispatch path: (1) commit sweep - completions that fired between dispatches are delivered as explicit utility::completion events in ascending source order before the trigger event runs; (2) the trigger event dispatches, its actions may arm sources, hand them to workers, and mark them required; (3) the dispatch coroutine awaits each required source (lowest fired index first) and re-enters it as a completion event; the top-level process_event parks on the scheduler's semaphore and resumes the coroutine on the dispatching thread until the root task is ready. Producers interact only through completion_source::fire (atomic store + semaphore release, never a coroutine resumption), so single-consumer dispatch holds structurally. Delivery order is deterministic regardless of physical fire order. Run-to-completion holds: required completions drain before dispatch returns; unrequired background fires persist only as passive flags swept by the next dispatch. Module is opt-in like the thread pool scheduler: requires C++20 coroutines plus <semaphore>, compiles to nothing otherwise.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “external completion” scheduler policy so co_sm can deterministically deliver cross-thread completion events while still preserving run-to-completion semantics for process_event (i.e., it only returns once required completions have been drained).
Changes:
- Introduces
utility::policy::external_completion_scheduler<SourceCount>andcompletion_sourceplus a genericutility::completion{source_index}event. - Extends
co_smwith anexternal_completion_scheduler_contractconcept and a new synchronous dispatch path that performs: pre-trigger sweep → trigger dispatch → required-completion drain (with parking/resume on the dispatch thread). - Adds functional tests and wires them into the FT CMake target list.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/ft/external_completion.cpp | Adds FT coverage for required-drain, ordering, dispatch-thread resumption, background sweep behavior, and async wrapper behavior. |
| test/ft/CMakeLists.txt | Registers test_external_completion and links pthread. |
| include/stateforward/sml/utility/external_completion.hpp | Adds the stateforward include shim for the new opt-in header. |
| include/boost/sml/utility/external_completion.hpp | Implements the external-completion scheduler policy (completion_source, scheduler, and utility::completion event type). |
| include/boost/sml/utility/co_sm.hpp | Adds duck-typed scheduler contract detection and an external-completion dispatch implementation for synchronous process_event. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rigger - completion_source::fire releases a wakeup permit only on the transition into fired (exchange-gated), so a double-firing producer cannot accumulate permits or overflow the semaphore max count. - external_completion_scheduler::reset_dispatch_state clears required bits and any parked handle at the top of every external dispatch: bookkeeping from a dispatch aborted mid-drain (for example a machine action that threw after require()) can no longer block the next dispatch's drain. Added to the scheduler contract and covered by a regression test that aborts after require() and proves the next dispatch completes. - process_event_external_impl takes the trigger event by const reference: the coroutine always completes inside the enclosing process_event, so the caller's event outlives the frame and non-copyable events stay supported. - clang-format over the touched files (fixes the Quality gates CI job).
|
Addressed all review findings in 8968306:
Full suite 37/37 locally. |
…plain loop Bugbot (high): a nested synchronous process_event from a transition action cleared the outer dispatch's required bits via reset_dispatch_state, letting the outer drain exit before its completions arrived. Only the outermost dispatch now owns reset/sweep/drain (depth-guarded); nested calls dispatch their trigger plainly and anything they require is drained by the enclosing dispatch. Regression test covers a nested probe issued after require(). Performance: the synchronous drive needs no coroutine at all - process_event blocks until done by contract, so suspend/park/resume machinery was pure overhead. The external path is now a plain loop (sweep -> trigger -> drain via consume_next_fired_required + wait_any) with identical semantics: RTC, ascending deterministic delivery, dispatch-thread-only execution. The awaitable, parked handle, and per-dispatch coroutine frame are gone, and the scheduler contract shrinks accordingly. Measured (Ryzen 5950X, -O3, 10M dispatches): inline baseline 0.2 ns/op, external fast path (no completions) 2.6 ns/op - one 8-source sweep scan plus a mask check - and a full arm+fire+require+drain round trip 5.7 ns/op. The blocking path is semaphore-wait dominated, i.e. the external work being awaited, not scheduler overhead.
|
Second round addressed in 2b41690:
37/37 locally. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2b41690. Configure here.
Every successful fire() releases one wakeup permit, but the commit sweep consumed fired flags without their permits, so background fires swept between dispatches leaked permits for the scheduler's lifetime (spurious drain wakeups, and in the theoretical extreme a noexcept release() past the semaphore max). consume_fired() now resets the flag and try_acquires the permit in one place, shared by the sweep and the required-drain consume. Fired flags remain the ground truth - the drain blocks only after finding no consumable flag - so permit accounting can never cause a hang. Regression test runs 10k background fire+sweep cycles and proves a worker-fired required dispatch still drains correctly.
|
Round three addressed in 2508a25: Bugbot's permit-leak finding is fixed — |

Adds the external-completion module anticipated by the thread-pool scheduler work (#15): a scheduler policy that lets a
co_smdispatch suspend awaiting completions fired by other threads while still returning only when the whole dispatch is done.What
utility/external_completion.hpp(opt-in like the thread pool: C++20 coroutines +<semaphore>, compiles to nothing otherwise):completion_source— atomic idle/pending/fired flag;fire(void*)trampoline is compatible withthread_pool_scheduler::try_submit_with_completionand does exactly two things: release-store the flag and release the scheduler's semaphore. It never resumes a coroutine and never touches the machine, so single-consumer dispatch holds structurally no matter which thread fires.external_completion_scheduler<SourceCount>— fixed source array, counting semaphore, required-mask, one parked handle;utility::completion{source_index}is the generic event machines add rows for.co_sm.hpp:external_completion_scheduler_contractconcept (duck-typed; the core header stays independent of the opt-in module),process_eventparks on the semaphore and resumes on the dispatching thread until the root task is ready).Guarantees
process_eventreturns; the async wrapper returns an already-completed task.Tests
test/ft/external_completion.cpp: required-drain with a real 1-worker thread pool forcing a park, dispatching-thread delivery, ascending delivery of pre-fired sources without suspension, background-fire sweep ordering, no-requires-no-park, async wrapper inline completion, source reuse across dispatches, contract static_asserts. Full suite: 37/37 pass.Note
Medium Risk
New concurrency and blocking semantics on the dispatch thread (semaphore waits, required-source contract); mistakes can hang or mis-order completions, though behavior is heavily covered by functional tests.
Overview
Introduces
utility/external_completion.hpp(C++20 +<semaphore>, otherwise empty):completion_sourcewith afire(void*)hook forthread_pool_scheduler::try_submit_with_completion,external_completion_scheduler<N>with a required-source mask and counting semaphore, andutility::completion{source_index}as the generic machine event.co_smgains duck-typedexternal_completion_scheduler_contractand, for matching schedulers, replaces plainprocess_eventwith a synchronous commit sweep → trigger → required drain loop (blocking on the semaphore on the dispatching thread). Nestedprocess_eventfrom actions skips reset/sweep/drain viaexternal_dispatch_depth_;process_event_asyncreturns an immediately-ready task that runs that same path.reset_dispatch_stateclears stale required bits after aborted dispatches.Adds
test/ft/external_completion.cpp(thread pool, ordering, nested dispatch, permit leaks, exceptions) and a stateforward include shim.Reviewed by Cursor Bugbot for commit 2508a25. Bugbot is set up for automated code reviews on this repo. Configure here.