diff --git a/docs/IMPLEMENTATION_PLAN_runtime-state.md b/docs/IMPLEMENTATION_PLAN_runtime-state.md index c340305..fe197b4 100644 --- a/docs/IMPLEMENTATION_PLAN_runtime-state.md +++ b/docs/IMPLEMENTATION_PLAN_runtime-state.md @@ -68,12 +68,20 @@ Completed follow-on increment: State-handle creation hooks so external `setStateFnc()`, `setNext()`, and `setEnd()` throw `std::logic_error` after a successful `run()`, even through a `Dmn_State &` view. +- Tie transition authorization to the runtime execution thread so external + client threads remain rejected while a callback is active. - Reserve `runNext()` for manager-only execution by rejecting all external - calls and routing manager-driven stepping through internal runtime-state - helpers. + calls, including recursive calls from a user callback, and routing + manager-driven stepping through internal runtime-state helpers. - Add `Dmn_State::hasStateFncs()` as a public query for whether the client - configured at least one state function, excluding the internal - initialization function. + configured at least one callback, excluding the reserved internal slot. +- Keep internal initialization and finalization invisible to callers: + `runNext()` performs initialization before its first user callback and + finalization after a terminal callback in the same invocation. An empty + `Dmn_State` converts to false, and an explicit `runNext()` initializes and + finalizes it before returning false. +- Reserve state index 0 for internal initialization; client transitions use + 1-based user-state indices. - Do not retain created states in the manager yet. Retention begins only when a later `run()` implementation queues a state. @@ -83,7 +91,8 @@ Phase 2: Terminal-state primitive and lifecycle unit tests (complete) `Dmn_State &` parameter or its `Dmn_Runtime_State &` parameter to call `setNext()` or `setEnd()`, depending on which registration API was used. - Require clients to finish configuring state functions before successful - submission, because configuration is not synchronized with runtime execution. + submission, because inherited `Dmn_State` configuration and inspection are + not synchronized with runtime execution. - Implement the completion promise/shared_future pair, terminal flags, and a single idempotent terminal transition helper. - Implement the selected no-state and cancel-before-run behavior. @@ -104,8 +113,9 @@ Phase 3: Basic runtime enqueue & single-step execution (complete) DmnRuntimeStatePtr> m_pendingStates`. - The job's m_fnc creates a coroutine task (TaskFncType) that: - locks a weak_ptr to the state - - checks isCancelled(); if set, call setEnd() and finalize - - calls runNext() once (in try/catch) + - checks isCancelled(); if set, calls setEnd() and then runNext() to finalize + - calls runNext() once (in try/catch), executing at most one user callback + while folding in pending initialization or finalization - if still active, repost immediately by calling addJob() again - if terminal, set completion promise and erase manager internal shared_ptr - Wire `m_completionPromise` and `m_completionSharedFuture` so getFuture() returns `m_completionSharedFuture`. @@ -113,9 +123,9 @@ Phase 3: Basic runtime enqueue & single-step execution (complete) for runtime-aware logic such as `isCancelled()`, while keeping the inherited `setStateFnc()` path documented for base-API compatibility. -Tests expected to pass after this phase: -- RuntimeState_BasicFlow -- RuntimeState_GetFuture_PreRun_MultipleWaiters (shared_future works) +Coverage implemented in: +- `ExecutesStatesAndReportsStateFailures` +- `RejectsUnconfiguredAndPreRunCancelledStates` Phase 4: Exception capture and onError forwarding (complete) - Wrap runNext() call in try/catch inside the runtime job. @@ -127,9 +137,8 @@ Phase 4: Exception capture and onError forwarding (complete) - invoke onError callback forwarded via job.m_onErrorFnc - Update run() to forward client-provided onError into the runtime job creation -Tests expected to pass: -- RuntimeState_RunOnErrorCallback -- state_exception_marks_failed +Coverage implemented in: +- `ExecutesStatesAndReportsStateFailures` Phase 5: Complete lifecycle and scheduling coverage (complete) - Added focused named Google Test cases for singleton/state creation, @@ -154,7 +163,7 @@ Phase 6: Drain-and-cancel manager shutdown (complete) - Shutdown snapshots the manager-retained handles, requests cooperative cancellation outside the manager mutex, and waits for all captured states to reach terminal cancellation before returning. -- A state step already executing may finish its callback, but its terminal +- A user-state callback already executing may finish, but its terminal outcome is cancellation when shutdown requested it. Queued states finalize without running another user-defined callback. - Shutdown is idempotent and rejects calls from the runtime async thread to @@ -188,11 +197,13 @@ Notes and gotchas - Use the state mutex to set the queued flag and avoid races for multiple concurrent run() calls. - Use std::shared_future to support multiple waiters. -- Be careful to release manager internal shared_ptr only after the completion promise is fulfilled and after finalization is complete. +- Release the manager-owned shared_ptr only after the terminal outcome is + published and its lifecycle hook returns. Failed states and states cancelled + before submission do not necessarily run base-state finalization. - Use runtime's addJob/addTimedJob APIs and forward onError callback using Dmn_Runtime_Job::OnErrorFncType. - The manager exposes one drain-and-cancel shutdown mode and no - concurrency-configuration API; state steps execute in the process-wide - runtime async context. + concurrency-configuration API; user-state callbacks execute in the + process-wide runtime async context. Example commands - Configure & build: cmake -B build -DCMAKE_BUILD_TYPE=Debug diff --git a/docs/specs/runtime-state-machine-plan.md b/docs/specs/runtime-state-machine-plan.md index 7a1a0d5..d589818 100644 --- a/docs/specs/runtime-state-machine-plan.md +++ b/docs/specs/runtime-state-machine-plan.md @@ -12,11 +12,21 @@ The manager follows the same singleton model as `Dmn_Runtime_Manager`. It owns t ### Decision 2: state objects subclass `Dmn_State` Each runtime state object remains a state machine, but adds async lifecycle metadata and `wait()` support. This keeps the base state semantics while adding runtime execution ownership. +`Dmn_State::runNext()` exposes only user-provided states: it performs +initialization before the first user callback and finalization after a terminal +callback in the same invocation. An empty `Dmn_State` converts to false, while +an explicit `runNext()` initializes and finalizes it before returning false. + ### Decision 3: all state execution is serialized -The manager does not allow independent parallel execution of state steps across runtime state objects. It posts work to the runtime scheduler in serialized form. +The manager does not allow independent parallel execution of user-state +callbacks across submitted runtime state objects. It posts work to the +process-wide runtime scheduler, which executes them serially. ### Decision 4: `run()` is async-only -The client never directly executes state logic in its own thread. `run()` only queues runtime tasks. The runtime executes each state step and then re-posts the next task until the machine is complete. +The client never directly executes state logic in its own thread. `run()` only +queues runtime tasks. Each task invokes `runNext()`, executes at most one +user-state callback, folds in pending initialization or finalization, and +reposts when more user work remains. ## 3. Phase 1: Construct the manager singleton (complete) @@ -54,8 +64,11 @@ The client never directly executes state logic in its own thread. `run()` only q - keep the inherited `Dmn_State` configuration API visible, but dynamically reject external `setStateFnc()`, `setNext()`, and `setEnd()` calls after a successful `run()` +- authorize in-callback transitions by runtime thread identity, preventing a + client thread from mutating transitions while a callback is active - reject external `runNext()` through any `Dmn_Runtime_State` or `Dmn_State` - view so only the manager may advance the machine + view, including from inside a user callback, so only the manager may advance + the machine - add public `Dmn_State::hasStateFncs()` to identify whether the client configured at least one state function - retain a manager-owned state handle after successful runtime queueing and @@ -84,10 +97,12 @@ The client never directly executes state logic in its own thread. `run()` only q ### Tasks -- schedule state steps as runtime jobs using `Dmn_Runtime_Manager::addJob()` +- schedule user-state callbacks as runtime jobs using + `Dmn_Runtime_Manager::addJob()` - ensure `run()` posts work to runtime rather than executing directly - serialize state object execution through the runtime queue -- implement continuation loop so each step schedules the next one until terminal state +- implement a continuation loop so each non-terminal user step schedules the + next one ### Deliverables @@ -160,8 +175,8 @@ The client never directly executes state logic in its own thread. `run()` only q - Added API-boundary enforcement so runtime submission freezes external configuration/mutation and reserves `runNext()` for manager-driven execution only. -- Added runtime-aware callback support so cancellation-aware state steps can - use `Dmn_Runtime_State &` directly when needed. +- Added runtime-aware callback support so cancellation-aware user-state + callbacks can use `Dmn_Runtime_State &` directly when needed. ## 10. Risks and Checkpoints @@ -190,7 +205,8 @@ The feature is done when: - the runtime state manager is implemented as a singleton - runtime state objects subclass `Dmn_State` - `run()` schedules async execution through `Dmn_Runtime_Manager` -- all state objects are serialized through the runtime manager +- callbacks from submitted state objects are serialized through the runtime + manager - client `wait()` supports async completion tracking - failure and cancellation paths are verified by tests - the library remains backward compatible with existing runtime/state APIs diff --git a/docs/specs/runtime-state-machine-spec.md b/docs/specs/runtime-state-machine-spec.md index 2634c19..9e3a30f 100644 --- a/docs/specs/runtime-state-machine-spec.md +++ b/docs/specs/runtime-state-machine-spec.md @@ -1,6 +1,6 @@ # Feature Spec: Runtime State Manager -Status: Draft — Phases 1-7 implemented. +Status: Implemented. ## Implementation Status @@ -9,8 +9,8 @@ handles, completion futures, runtime scheduling, manager-held lifetime, one-shot submission, cooperative cancellation, failure capture, runtime error callback forwarding, post-submission external-mutation rejection, and the runtime-aware `setRuntimeStateFnc()` callback API. `run()` supports -priority and an initial delay; later steps are reposted immediately at the -submitted priority. +priority and an initial delay; later user-state callbacks are reposted +immediately at the submitted priority. `run()`, `wait()`, and `wait_for()` reject calls from the runtime async thread. Focused tests cover external-mutation rejection after submission, @@ -19,12 +19,17 @@ manager-retained lifetime, priority ordering, timed initial submission, runtime-thread rejection, drain-and-cancel shutdown, multi-state serialization, failure isolation, and concurrent lifecycle operations. The manager exposes one shutdown mode and no configurable concurrency. The -current runtime architecture serializes all state steps through the +current runtime architecture serializes all user-state callbacks through the process-wide runtime async thread. ## 1. Summary -This feature introduces a new runtime-owned state execution manager that combines the existing `dmn-runtime` scheduler with the `dmn-state` finite-state helper. The result is a singleton runtime service that creates state objects for clients, serializes their execution through the runtime, and lets callers wait for completion without forcing the client thread to execute each state step directly. +This feature introduces a new runtime-owned state execution manager that +combines the existing `dmn-runtime` scheduler with the `dmn-state` finite-state +helper. The result is a singleton runtime service that creates state objects +for clients, serializes their execution through the runtime, and lets callers +wait for completion without forcing the client thread to execute each +user-state callback directly. The new feature preserves the library’s current design philosophy: @@ -39,16 +44,23 @@ The primary behavior is: 2. client configures the state(s) on that object; 3. client calls `statehandle->run()` (optionally with priority / delay / onError handler); 4. `run()` enqueues a runtime task into the runtime manager and returns a boolean indicating whether the enqueue succeeded; -5. the runtime manager continues to repost tasks until the state object reaches its terminal condition; errors occurring in async execution invoke a client-provided onError callback (if supplied) and are captured on the state handle; -6. all state objects created by the manager are executed in serialized order through `Dmn_Runtime_Manager` (subject to the manager's serialization policy); +5. the runtime manager continues to repost tasks while user callbacks remain; + errors occurring in async execution invoke a client-provided onError + callback (if supplied) and are captured on the state handle; +6. callbacks from submitted state objects execute serially through + `Dmn_Runtime_Manager`; 7. client may call `statehandle->wait()` or use the returned shared_future to block or asynchronously observe completion. ## 2. Design Objective -The existing `dmn-state` component is synchronous and client-driven. It calls `runNext()` directly in the caller thread. That is useful for local control flow, but not for runtime-managed workflows. The new runtime state manager changes the ownership model: +The existing `dmn-state` component is synchronous and client-driven. It calls +`runNext()` directly in the caller thread; each call executes at most one user +callback and folds in pending initialization or finalization. That is useful +for local control flow, but not for runtime-managed workflows. The new runtime +state manager changes the ownership model: - the state object remains a state machine definition and execution state, -- the runtime manager owns when the state steps are executed, +- the runtime manager owns when user-state callbacks are executed, - state execution is serialized within the runtime’s async context, - the client receives an async completion signal via `wait()` or a shared_future rather than manually stepping the machine. @@ -73,7 +85,6 @@ This makes the feature a natural fit for handshake flows, retries, startup/teard - persistence or recovery of state machines - automatic consensus protocol orchestration - general-purpose actor model features -- changes to existing `Dmn_State` sync API behavior ## 4. Architectural Context @@ -154,20 +165,34 @@ client chose, to call `setNext()` or `setEnd()` when it needs to select the next transition or terminate the machine. `Dmn_State::hasStateFncs()` is a public query that returns true when at least -one client-defined state function exists. It excludes the internal -initialization function and is used by `Dmn_Runtime_State::run()` to reject an -unconfigured state without terminalizing it. +one client-defined callback exists. It excludes the reserved internal slot and +is used by `Dmn_Runtime_State::run()` to reject an unconfigured state without +terminalizing it. + +For synchronous `Dmn_State`, internal lifecycle work is not exposed as +separate steps. `runNext()` performs initialization before the first +user-provided callback and performs finalization after a callback selects a +terminal transition, all in the same invocation. It executes at most one user +callback per call. An empty state converts to false; an explicit `runNext()` +still initializes and finalizes that empty state before returning false. +State index 0 is reserved for internal initialization and is not a valid +`setNext()` target. Configuration must be complete before a successful `run()` call. After a successful submission, external calls to inherited `setStateFnc()`, `setNext()`, and `setEnd()` MUST throw `std::logic_error`. This freeze applies through any `Dmn_Runtime_State` or `Dmn_State` view of the object. The currently executing runtime-managed callback remains allowed to call -`setNext()` and `setEnd()` to choose transitions. +`setNext()` and `setEnd()` to choose transitions. Authorization is tied to the +runtime execution thread, so a client thread cannot mutate transitions while a +callback is active. External `runNext()` is never part of the runtime-managed contract. Calling `runNext()` on a `Dmn_Runtime_State` through any `Dmn_State` view MUST throw -`std::logic_error`; only the manager may drive state advancement. +`std::logic_error`; only the manager may drive state advancement. This +restriction also applies inside a user callback, which must use `setNext()` or +`setEnd()` and return control to the manager instead of recursively advancing +the machine. ### FR-4: `run()` dispatches work to runtime and error callback forwarding @@ -180,10 +205,14 @@ Behavior: - `run()` returns `true` if the state was successfully queued and `false` when the state is unconfigured, cancelled, terminal, already active, or the manager has shut down. Runtime submission exceptions propagate after the - state clears its queued marker. + state clears its queued marker, allowing a later submission attempt. - `run()` is a one-shot operation for each state handle: subsequent `run()` calls after the first successful enqueue MUST be no-ops and MUST return `false`. - The optional `onError` callback provided to `run()` MUST be forwarded to the underlying `dmn-runtime` job so that asynchronous runtime failures invoke the client callback when the runtime job reports an error. Use `Dmn_Runtime_Job::OnErrorFncType` as the canonical type. -- The runtime work must execute exactly one next state step of the state object per posted job; after the step executes, the manager reposts another job (or finalizes) until terminal. +- Each runtime dispatch executes at most one user-provided callback. + Initialization runs before the first callback, and finalization runs after a + terminal callback in the same dispatch. Cancellation or an end selected + before submission may cause a dispatch to execute no user callback. After a + non-terminal callback executes, the manager reposts another job. - `run()` MUST NOT execute state logic synchronously in the caller thread. Priority and timed variants: @@ -203,15 +232,15 @@ Thread policy for `run()`, `wait()`, and `wait_for()`: - This policy applies in every build configuration, permitting direct, deterministic unit testing without a debug-only death test. -### FR-5: Serialized execution across all state objects +### FR-5: Serialized execution across submitted state objects All state objects created from the runtime state manager must execute in serialized form through the shared runtime scheduler by default. Requirements: -- no two state objects may run their next step concurrently in the same runtime manager (default global serialization) +- no two state objects may run user-state callbacks concurrently in the same runtime manager (default global serialization) - state object execution order must follow runtime job ordering/priority semantics -- state step tasks must be single-threaded relative to manager execution +- user-state callbacks must be single-threaded relative to manager execution - the manager MAY provide configuration for relaxed concurrency (optional extension) but default behavior must be serialized to match the spec ### FR-6: Completion waiting via `wait()` and async alternatives @@ -244,12 +273,33 @@ The object must track: - failed state - cancelled state -A state object is terminal when it has either: +A runtime state object is terminal when it has either: -- reached the end via `setEnd()` or a terminal transition -- failed due to uncaught exception during a state step +- completed a `runNext()` call that observed an end selected by `setEnd()` or + another terminal transition +- failed due to an uncaught exception during a user-state callback - been cancelled +The inherited `Dmn_State::isInitialized()`, `isFinalized()`, and boolean +conversion describe the base state-machine lifecycle. They are not substitutes +for runtime terminal-status queries: failure or cancellation before a runtime +dispatch can publish a terminal runtime outcome without running base +finalization. Clients must use `isCompleted()`, `isFailed()`, `isCancelled()`, +or `getFuture()` for runtime completion. + +Lifecycle hooks have these execution and ordering rules: + +- `onStarted()` normally runs in the runtime thread before `runNext()` and + before the first user callback. If it throws, the state fails. +- `onCompleted()` and `onFailed()` run in the runtime thread after the terminal + result has been published. +- `onCancelled()` runs synchronously in the caller's thread for + pre-submission cancellation and normally in the runtime thread for submitted + work. +- A terminal future may wake a waiter before its terminal hook returns. +- Terminal hook overrides must not throw because an exception can interrupt + manager cleanup without changing the already-published outcome. + ### FR-8: Cancellation and shutdown The runtime state object MUST provide a `cancel()` method that is cooperative in nature. @@ -268,7 +318,8 @@ Semantics: their runtime-state handle and need to early-exit or perform cleanup - calling `cancel()` before `run()` transitions the object to cancelled terminal state immediately, completes its shared future, and causes any later - `run()` call to return false + `run()` call to return false. Its `onCancelled()` hook runs synchronously in + the thread that called `cancel()`. - `Dmn_Runtime_State_Manager::shutdown()` provides the current drain-and-cancel shutdown mode. It rejects new submissions, requests cancellation for all submitted states, and waits for their terminal futures. @@ -292,11 +343,19 @@ If a state callback throws while running inside the runtime-managed async thread ### NFR-1: Singleton and runtime-thread ownership -The runtime state manager must preserve the runtime’s model: client code may call APIs from any thread, but the actual state execution must be marshaled to the runtime async thread. +The runtime state manager must preserve the runtime's execution model. +Runtime-specific lifecycle operations and status queries are synchronized and +may be called from client threads, subject to the runtime-thread restrictions +on `run()`, `wait()`, `wait_for()`, and `shutdown()`. Inherited `Dmn_State` +configuration is single-threaded and must finish before submission. User-state +callbacks execute on the runtime thread. -### NFR-2: Backward compatibility +### NFR-2: Compatibility -This feature must not break the existing `Dmn_Runtime_Manager` or `Dmn_State` APIs. It is additive only. +The runtime manager API remains additive and does not change +`Dmn_Runtime_Manager`. `Dmn_State` intentionally changes execution semantics +so initialization and finalization are no longer user-visible steps, empty +states convert to false, and index 0 is reserved for internal initialization. ### NFR-3: Determinism @@ -349,8 +408,9 @@ public: explicit Dmn_Runtime_State(std::string_view name); // State configuration methods are inherited from Dmn_State for pre-run - // setup. After successful run(), external setStateFnc()/setNext()/setEnd() - // calls throw std::logic_error. External runNext() also throws + // setup. After submission or a terminal outcome, external + // setStateFnc()/setNext()/setEnd() calls throw std::logic_error. + // External runNext() always throws // std::logic_error; only the manager may advance the machine. void setRuntimeStateFnc(RuntimeStateFnc fnc, int index = 0); @@ -362,8 +422,9 @@ public: const std::chrono::steady_clock::duration &delay = std::chrono::steady_clock::duration::zero(), OnErrorFnc onError = {}); - // cancel is cooperative and idempotent. If called before a step runs, the running task will - // observe the cancelled flag, call setEnd() and transition to terminal state instead of executing further steps. + // Cancellation is cooperative and idempotent. A runtime dispatch observes + // the request, selects the end, and terminates without invoking another + // user callback. A callback whose dispatch already started may finish. void cancel(); // wait blocks until terminal state. wait_for(timeout) returns true if it observed terminal state before timeout. @@ -420,15 +481,17 @@ private: enqueue and false when the state is unconfigured, cancelled, terminal, already active, or its manager has shut down. A runtime submission exception propagates after clearing the queued marker. If `delay` is non-zero, the - first job uses `addTimedJob()`; later state steps use `addJob()`. + first runtime dispatch uses `addTimedJob()`; later dispatches use `addJob()`. - `run()` accepts an optional onError callback that uses the runtime's `Dmn_Runtime_Job::OnErrorFncType` signature and is forwarded to the runtime job. - `run()` is one-shot: a successful `run()` prevents subsequent `run()` calls from enqueueing again; such subsequent calls return `false` (no-op). This avoids duplicate enqueues across threads. +- If runtime enqueueing throws, the queued marker is cleared before the + exception propagates and the caller may retry. - `cancel()` is cooperative: it sets a cancelled flag. The runtime job, before calling `runNext()`, must check `isCancelled()` and call `setEnd()` if the - state has been cancelled so that the state finalizes without executing - further steps. -- `setRuntimeStateFnc()` is the preferred callback API when a state step needs - runtime-only methods such as `isCancelled()`. It adapts a + state has been cancelled so that the state finalizes without invoking + another user callback. +- `setRuntimeStateFnc()` is the preferred callback API when a user-state + callback needs runtime-only methods such as `isCancelled()`. It adapts a `Dmn_Runtime_State &` callback into the same underlying state-machine storage. - The inherited `setStateFnc()` remains available for compatibility. A @@ -438,7 +501,7 @@ private: - After successful `run()`, external configuration or transition mutation is frozen. Calls to inherited `setStateFnc()`, `setNext()`, and `setEnd()` throw `std::logic_error` unless they occur from inside the active - runtime-managed callback. External `runNext()` also throws + runtime-managed callback on the runtime thread. External `runNext()` also throws `std::logic_error`, including when attempted through a `Dmn_State &` view. - `wait()` supports a timeout variant and `getFuture()` returns a `std::shared_future` that can be used by multiple waiters. The shared_future is valid immediately after createState() is called and resolves when the state reaches a terminal condition. - Calls to `run()`, `wait()`, or `wait_for()` from inside the runtime async @@ -458,7 +521,7 @@ A runtime state object has the following lifecycle: 4. Queued for runtime execution after `run()` (manager retains shared_ptr and sets `m_queued` while holding the state mutex) 5. Running inside runtime async thread -6. Finalized once terminal condition reached (manager releases internal shared_ptr) +6. Terminal outcome published and manager-held ownership released 7. Client may call `wait()` at any time after submission or use the shared_future returned by `getFuture()` ### 8.2 `run()` exact semantics and mutex-protected queued flag @@ -472,9 +535,14 @@ When `statehandle->run(priority, delay, onError)` is called: internal shared_ptr and submit the runtime job 3. if `m_queued` was already true or the object is terminal, `run()` returns false (no-op) 4. the manager stores a shared_ptr to the object (ensuring lifetime) and schedules a runtime job via `addJob()` or `addTimedJob()` depending on `delay` -5. the scheduled job executes exactly one next state step using the state object -6. before calling `runNext()`, the runtime job MUST check the cancel flag; if cancelled, it MUST call `setEnd()` and finalize instead of running the step -7. after the state step finishes, the runtime manager checks whether another state remains and reposts a new runtime job if appropriate +5. the scheduled job invokes `runNext()`, which executes at most one + user-provided state callback and folds in internal initialization or + finalization when needed +6. before calling `runNext()`, the runtime job checks cancellation; when + cancelled, it selects the end so `runNext()` finalizes without invoking + another user callback +7. after the dispatch finishes, the runtime manager reposts a new job only + when more user work remains 8. if no state remains, the object transitions to completed terminal state, the manager notifies waiters (set promise) and releases its internal shared_ptr 9. if a state callback throws, the exception is captured, the object transitions to failed, waiters are notified, and the optional onError callback is invoked @@ -482,13 +550,15 @@ This loop continues until no more states to run. The runtime manager is responsi ### 8.3 Serialization requirement and optional config -All state object tasks must be executed in serialized form through the runtime queue by default. This strict global serialization simplifies reasoning and matches the project's stated intent. Implementations SHOULD provide configuration for relaxed concurrency (e.g., per-manager worker count) as an optional extension, but that must be explicitly chosen and documented by the caller. +All state object tasks execute serially through the process-wide runtime queue. +The current manager does not provide a relaxed-concurrency mode. ## 9. State Object Contract ### 9.1 Subclassing `Dmn_State` -The new runtime state object must subclass `Dmn_State` and preserve `Dmn_State` semantics while adding runtime lifecycle tracking. +The runtime state object must subclass `Dmn_State` and preserve its +user-visible stepping semantics while adding runtime lifecycle tracking. It must retain: @@ -496,7 +566,8 @@ It must retain: configuration and in-callback transition control - `setRuntimeStateFnc()` as a runtime-aware convenience wrapper for callbacks that need `Dmn_Runtime_State` methods directly -- init/finalize behavior inherited from the base +- internal initialization before the first user callback and finalization + after terminal selection, inherited from the base - default state sequencing model The runtime layer keeps the base configuration methods visible, but adds @@ -524,6 +595,8 @@ inherited `Dmn_State &` API to select the next transition or terminate. `std::runtime_error` as described. - `wait_for(timeout)` returns a bool indicating whether the wait observed terminal completion before the timeout expired. - `getFuture()` returns a `std::shared_future` available immediately after creation and resolves on terminal state. +- `wait()` and `wait_for()` do not rethrow execution failures; + `getFuture().get()` rethrows the captured exception. ## 10. Detailed Behavior and Edge Cases @@ -531,7 +604,9 @@ inherited `Dmn_State &` API to select the next transition or terminate. If no state is defined before `run()`, the manager must not enqueue an invalid task. `run()` returns false, leaves the state unsubmitted and non-terminal, and -does not invoke onError. +does not invoke onError. Its inherited `Dmn_State` boolean conversion is also +false because no user-state callback is configured; this does not mean the +runtime state has reached a terminal outcome. ### 10.2 Repeated `run()` calls @@ -546,7 +621,8 @@ Behavior: ### 10.3 Finalized or cancelled states -Once finalized, failed, or cancelled, no further state step may be scheduled. +Once finalized, failed, or cancelled, no further user-state callback may be +scheduled. ### 10.4 Exception propagation and onError @@ -574,7 +650,9 @@ The runtime main loop must remain active while shutdown drains queued work. ## 11. Serialization and Scheduling Contract -The manager is responsible for ensuring serialized processing across all state instances it creates (by default). Each queued job should trigger exactly one `runNext()` invocation and then repost if required. +The manager serializes submitted state instances through the runtime queue. +Each queued job triggers one `runNext()` invocation, which executes at most one +user callback, and reposts only if more user work remains. Provide clear priority mapping between manager jobs and other runtime jobs. The manager must not starve other runtime jobs; use the runtime's priority scheme and document how manager tasks are enqueued. @@ -586,6 +664,7 @@ Provide clear priority mapping between manager jobs and other runtime jobs. The - `CreatesSingletonManagerAndStateHandle` - `RejectsExternalMutationAfterSubmission` +- `RejectsRecursiveRunNextFromRuntimeCallback` - `RuntimeCallbackCanObserveCancellationDirectly` - `RejectsUnconfiguredAndPreRunCancelledStates` - `ExecutesStatesAndReportsStateFailures` @@ -618,20 +697,23 @@ stress test drains 32 queued states behind an executing callback. The feature is accepted when all of the following are true: - clients can create runtime-managed state objects from a singleton manager using a shared_ptr handle -- state objects subclass `Dmn_State` and retain base state semantics +- state objects subclass `Dmn_State` and retain its documented user-visible + stepping semantics - `statehandle->run(priority, delay, onError)` schedules work into the runtime manager and returns true/false to indicate success - state execution is serialized through the runtime manager by default - `statehandle->wait()` and `wait_for()` block until runtime completion or terminal failure and `getFuture()` is available for async waiting (shared_future) -- `cancel()` cooperatively finalizes execution and prevents future steps; cancel semantics are documented and tested +- `cancel()` cooperatively terminates runtime execution and prevents future + user callbacks; cancellation semantics are documented and tested - exceptions and cancellation leave the runtime in a valid state and optional onError callbacks are invoked - documentation, examples and tests exist for typical runtime state workflow usage and lifetime edge cases ## 14. Risks and Mitigations (updated) ### Risk: re-entrant scheduling loop -Mitigation: `run()` must schedule a single runtime task per state step and stop -when the terminal condition is reached. No unbounded recursive scheduling loop -is allowed. A mutex-protected queued flag prevents duplicate enqueues. +Mitigation: `run()` must schedule a single runtime task per user-state callback +and stop when the terminal condition is reached. Internal initialization and +finalization do not require extra tasks. No unbounded recursive scheduling +loop is allowed. A mutex-protected queued flag prevents duplicate enqueues. ### Risk: wait deadlock Mitigation: do not call `wait()` from inside the runtime async thread. Throw @@ -646,7 +728,9 @@ Mitigation: the runtime manager must keep job postings small, deterministic, and ## 15. Implementation Notes -This feature should be implemented as an additive API layered on top of the existing runtime and state components. +The runtime-state feature is implemented as an API layered on the existing +runtime and state components. It also includes the intentional `Dmn_State` +stepping changes described in FR-3 and NFR-2. Implementation should reuse: @@ -659,7 +743,7 @@ The runtime state manager should primarily add: - state object lifecycle tracking using shared_ptr handles - queueing and serialization logic - `wait()` synchronization through the promise/shared_future pair -- terminal-state finalization and onError callback forwarding +- terminal-outcome publication and onError callback forwarding - cooperative cancel() semantics Sample usage (illustrative): @@ -676,7 +760,7 @@ auto runtime = dmn::Dmn_Runtime_Manager<>::createInstance(); auto manager = dmn::Dmn_Runtime_State_Manager::createInstance(); StatePtr s = manager->createState("example"); -// Prefer the runtime-aware callback API when the step may need +// Prefer the runtime-aware callback API when the callback may need // Dmn_Runtime_State methods such as isCancelled(). s->setRuntimeStateFnc([](dmn::Dmn_Runtime_State &st) { if (st.isCancelled()) { diff --git a/include/dmn-runtime-state.hpp b/include/dmn-runtime-state.hpp index cff5bc9..0c68a08 100644 --- a/include/dmn-runtime-state.hpp +++ b/include/dmn-runtime-state.hpp @@ -2,16 +2,17 @@ * Copyright © 2026 Chee Bin HOH. All rights reserved. * * @file dmn-runtime-state.hpp - * @brief Runtime-scheduled finite-state-machine execution and lifetime - * management. + * @brief Asynchronous state-machine execution and lifetime management. * * @author Chee Bin HOH * @date 2026-08-31 * * Overview * -------- - * This header combines @ref Dmn_State with @ref Dmn_Runtime_Manager to execute - * state-machine steps asynchronously on the process-wide runtime thread. + * This header runs @ref Dmn_State callbacks on the process-wide + * @ref Dmn_Runtime_Manager thread. Internal initialization and finalization + * occur in the same runtime dispatch as the surrounding user-state work and + * are not scheduled separately. * Clients create a @ref Dmn_Runtime_State through * @ref Dmn_Runtime_State_Manager, configure it with the inherited * @ref Dmn_State API or @ref setRuntimeStateFnc(), and submit it with @@ -19,19 +20,25 @@ * * Ownership and Lifetime * ---------------------- - * State handles are shared pointers. Once a state is submitted, the manager - * retains an owning handle until it reaches a terminal outcome, ensuring queued - * and running work cannot access a destroyed state. Implementations should use - * shared_from_this() only after a state is owned by a @c std::shared_ptr. + * State handles are shared pointers. After a successful submission, the + * manager retains an owning handle until the state completes, fails, or is + * cancelled. A client may therefore release its handle without invalidating + * queued or running work. * * Thread Safety * ------------- - * Public lifecycle operations and state inspection are thread-safe. State - * functors and lifecycle hooks execute in the runtime async thread. State - * configuration belongs to the pre-submission phase only: after a successful - * run(), external calls to inherited configuration/transition APIs are - * rejected. Blocking wait and shutdown operations are prohibited from the - * runtime async thread to avoid deadlock. + * Runtime lifecycle operations and runtime-specific status queries are + * synchronized. Configure inherited @ref Dmn_State callbacks and transitions + * from one client thread before submission. During execution, inherited base + * lifecycle queries should only be read from a callback or after the + * completion future is ready. + * + * User-state callbacks, onStarted(), onCompleted(), and onFailed() execute on + * the runtime thread. onCancelled() executes in whichever thread publishes + * cancellation: normally the runtime thread for queued work, but the caller's + * thread when cancellation completes before successful submission. Blocking + * wait and shutdown operations are prohibited from the runtime thread to + * avoid deadlock. */ #ifndef DMN_RUNTIME_STATE_HPP_ @@ -50,6 +57,7 @@ #include #include #include +#include #include namespace dmn { @@ -60,29 +68,35 @@ namespace dmn { * * Dmn_Runtime_State subclasses Dmn_State and adds asynchronous runtime * ownership semantics: a client obtains a shared_ptr handle from the - * manager, configures state functors using the inherited @ref Dmn_State API + * manager, configures state callbacks using the inherited @ref Dmn_State API * or the runtime-aware @ref setRuntimeStateFnc() helper, then calls * run() to schedule execution on the global runtime thread. * * Lifecycle * --------- - * A state is configured, submitted once, and then completes, fails, or is - * cancelled. Cancellation is cooperative: it prevents subsequent state steps - * but does not interrupt a functor already executing. Completion is published - * through a @c std::shared_future, which supports multiple waiters. + * A state is configured, successfully submitted at most once, and then + * completes, fails, or is cancelled. Cancellation is cooperative: it does not + * interrupt a callback whose runtime dispatch has started. Completion is + * published through a @c std::shared_future, which supports multiple + * waiters. * * The inherited @ref Dmn_State configuration API remains the client-facing way - * to install state functors before submission. After a successful @ref run, + * to install state callbacks before submission. After a successful @ref run, * external calls to @ref Dmn_State::setStateFnc, @ref Dmn_State::setNext, and * @ref Dmn_State::setEnd throw @c std::logic_error. Manager-driven execution * still permits state callbacks to call @ref setNext or @ref setEnd from - * inside the currently executing runtime step. External @ref runNext calls are - * rejected; only the manager may advance the machine. + * inside the currently executing user-state callback. External @ref runNext + * calls are rejected; only the manager may advance the machine. * * For callbacks that need runtime-only APIs such as @ref isCancelled(), use * @ref setRuntimeStateFnc(). It adapts a callback that takes * @c Dmn_Runtime_State & into the underlying @ref Dmn_State callback storage * while preserving the base API for compatibility. + * + * The inherited Dmn_State boolean conversion describes whether configured + * base-state work remains; it is not a runtime terminal-status query. Use + * isCompleted(), isFailed(), isCancelled(), or getFuture() to observe the + * runtime lifecycle. */ class Dmn_Runtime_State : public Dmn_State, @@ -91,9 +105,10 @@ class Dmn_Runtime_State public: /** - * @brief Callback invoked by the runtime when a state step throws. + * @brief Callback invoked when a runtime dispatch throws. * - * The callback receives the exception captured by the runtime job. + * The callback receives the exception captured by the runtime job. This + * includes exceptions from onStarted() and user-state callbacks. */ using OnErrorFnc = Dmn_Runtime_Job::OnErrorFncType; @@ -125,16 +140,20 @@ class Dmn_Runtime_State using RuntimeStateFnc = std::function; /** - * @brief Install a runtime-aware state functor. - * @param fnc Callback invoked as the selected state step body with the - * runtime-managed state object. - * @param index If 0 or the next 1-based user-state index, append a new user - * state. If 1..the current highest user-state index, replace - * the existing user state at that slot. + * @brief Add a runtime-aware user-state callback or replace an existing one. + * @param fnc Callback invoked with the runtime-managed state object. + * @param index With N callbacks currently configured, pass 0 (the default) + * or N+1 to append a callback. Pass 1 through N to replace the + * callback at that state. + * @throws std::out_of_range if index is negative or greater than N+1. * * This is a convenience wrapper over the inherited @ref Dmn_State API. It * adapts a callback taking @c Dmn_Runtime_State & into the underlying - * storage used by @ref Dmn_State::setStateFnc(). + * storage used by @ref Dmn_State::setStateFnc() and uses the same indexing + * rules. + * + * @throws std::logic_error if called after successful submission. + * @throws std::out_of_range for an invalid index. */ void setRuntimeStateFnc(RuntimeStateFnc fnc, int index = 0); @@ -145,8 +164,8 @@ class Dmn_Runtime_State * * @param priority Job priority to use when enqueuing (maps to * Dmn_Runtime_Job::Priority). - * @param delay If non-zero, the first job is scheduled via addTimedJob() - * after this delay. Later state steps are posted immediately. + * @param delay If non-zero, the first runtime dispatch is scheduled via + * addTimedJob() after this delay. Later dispatches are posted immediately. * @param onError Optional error callback forwarded to the runtime job. The * type matches Dmn_Runtime_Job::OnErrorFncType. * @return true if the state was successfully queued; false for an already @@ -156,12 +175,16 @@ class Dmn_Runtime_State * Notes: * - run() is one-shot for a given handle: the first successful call enqueues * the state; subsequent calls return false. + * - A failed enqueue does not consume the one allowed successful + * submission, so the caller may retry. * - Calling run() from inside the runtime async thread is disallowed and * throws std::runtime_error. * * @throws std::bad_weak_ptr if this object is not owned by a * @c std::shared_ptr. * @throws std::runtime_error if called from the runtime async thread. + * @throws Any exception raised by the runtime scheduler while enqueuing. + * The state remains eligible for another submission attempt. */ bool run(Dmn_Runtime_Job::Priority priority = Dmn_Runtime_Job::Priority::kMedium, @@ -172,15 +195,15 @@ class Dmn_Runtime_State /** * @brief Request cooperative cancellation of this state. * - * The cancellation is idempotent and thread-safe. It does NOT preempt a - * currently-running functor. A callback that needs cooperative early exit - * should prefer @ref setRuntimeStateFnc() so it can query - * @ref isCancelled() directly on its @c Dmn_Runtime_State & parameter. The - * runtime job must check isCancelled() and call setEnd() prior to invoking - * runNext() if cancellation is set. + * The cancellation is idempotent and thread-safe. It does not preempt a + * user-state callback whose runtime dispatch has started. A callback that + * needs cooperative early exit should prefer @ref setRuntimeStateFnc() so it + * can query @ref isCancelled() directly. * * A state cancelled before submission becomes terminal immediately. A * submitted state becomes terminal when the runtime observes the request. + * Pre-submission cancellation invokes onCancelled() in the calling thread; + * cancellation of submitted work normally invokes it in the runtime thread. */ void cancel(); @@ -194,13 +217,16 @@ class Dmn_Runtime_State * so callers may register waiters before run() is called. * * @return A copyable completion future. Calling @c get() on it rethrows a - * state-step failure. + * user-state callback failure. */ std::shared_future getFuture(); /** * @brief Block until the state reaches a terminal condition. * + * This method does not rethrow a captured execution failure. Call + * getFuture().get() when the failure must be observed. + * * Calling wait() from the runtime async thread is disallowed and throws * std::runtime_error. See getFuture() for async waiting. * @@ -212,6 +238,10 @@ class Dmn_Runtime_State * @brief Block until the state is terminal or the timeout expires. * @param timeout Maximum duration to wait. * @return true if terminal observed before timeout, false otherwise. + * + * This method does not rethrow a captured execution failure. Call + * getFuture().get() when the failure must be observed. + * * @throws std::runtime_error if called from the runtime async thread. */ template @@ -227,10 +257,10 @@ class Dmn_Runtime_State /** @brief Return whether the state completed successfully. */ bool isCompleted() const; - /** @brief Return whether a state step failed. */ + /** @brief Return whether a user-state callback failed. */ bool isFailed() const; - /** @brief Return whether the state is queued or executing a step. */ + /** @brief Return whether the state is queued or has started but not ended. */ bool isRunning() const; protected: @@ -238,27 +268,40 @@ class Dmn_Runtime_State * @brief Lifecycle hooks for derived implementations. * * Subclasses may override these to observe state lifecycle transitions. The - * default implementations are no-ops. + * default implementations are no-ops. Completion is published before a + * terminal hook runs, so a waiting client may resume while that hook is + * executing. Overrides of terminal hooks must not throw; their outcome has + * already been published, and an exception can interrupt manager cleanup. + * An exception from onStarted() is handled as a state failure before a user + * callback runs. */ - /** @brief Called once before the first state step executes. */ + /** @brief Called once when the first runtime dispatch begins. */ virtual void onStarted(); - /** @brief Called after normal terminal completion is published. */ + /** + * @brief Called in the runtime thread after successful completion is + * published. + */ virtual void onCompleted(); /** - * @brief Called after a state-step failure is published. - * @param ep Exception raised by the failed state step. + * @brief Called in the runtime thread after a failure is published. + * @param ep Exception raised by the failed user-state callback. */ virtual void onFailed(std::exception_ptr ep); - /** @brief Called after cancellation is published as terminal. */ + /** + * @brief Called after cancellation is published. + * + * This runs in the caller's thread for cancellation before submission and + * normally in the runtime thread for submitted work. + */ virtual void onCancelled(); private: enum class Terminal_State { kCompleted, kFailed, kCancelled }; - /** @brief Begin a runtime step and invoke @ref onStarted exactly once. */ + /** @brief Begin a runtime dispatch and invoke @ref onStarted exactly once. */ bool beginStep(); /** @@ -274,16 +317,21 @@ class Dmn_Runtime_State * it. */ void resetQueuedAfterSubmission(); - /** @brief Advance the state machine with manager-only execution access. */ + /** + * @brief Execute one user state with manager-only execution access. + * + * The base operation also performs pending initialization and terminal + * finalization in this call. + */ bool runNextManaged(); /** @brief Force terminal selection with manager-only execution access. */ void setEndManaged(); - /** @brief Mark the current thread as executing a manager-authorized step. */ - void enterInternalExecution(); + /** @brief Enter a manager-authorized runtime dispatch. */ + void enterInternalExecution(bool permitRunNext); - /** @brief Clear manager-authorized execution access. */ + /** @brief Leave a manager-authorized runtime dispatch. */ void leaveInternalExecution() noexcept; void beforeSetStateFnc() override; @@ -295,17 +343,20 @@ class Dmn_Runtime_State std::promise m_completionPromise{}; ///< Fulfilled on terminal state. std::shared_future m_completionSharedFuture{}; ///< Copyable completion notification. - std::exception_ptr m_failure{}; ///< Captured state-function failure. + std::exception_ptr m_failure{}; ///< Captured dispatch failure. bool m_queued{}; ///< True after successful submission setup. - bool m_running{}; ///< True after the first state step begins until terminal. - bool m_started{}; ///< Ensures onStarted runs once. + bool m_running{}; ///< True after the first runtime dispatch until terminal. + bool m_started{}; ///< Ensures onStarted runs once. bool m_completed{}; ///< Terminal normal-completion marker. bool m_failed{}; ///< Terminal failure marker. bool m_cancelled{}; ///< Cancellation requested or terminal. bool m_terminal{}; ///< Guards one-time completion publication. unsigned int m_internalExecutionDepth{}; ///< Non-zero only while the manager - ///< drives a step and its + ///< drives a dispatch and its ///< callback-controlled transitions. + std::thread::id + m_internalExecutionThread{}; ///< Thread authorized to change transitions. + bool m_runNextPermitted{}; ///< Consumed when the manager starts one step. }; /** @@ -363,8 +414,8 @@ class Dmn_Runtime_State_Manager * Shutdown is idempotent. It permanently rejects new state submissions: * handles created after shutdown remain configurable, but @ref * Dmn_Runtime_State::run returns false. Submitted states are cancelled - * cooperatively; an executing user step may finish before publishing - * cancellation, while queued steps finalize without running another + * cooperatively; an executing user callback may finish before publishing + * cancellation, while queued states finalize without running another * user-defined callback. * * This method waits without holding the manager mutex until every state @@ -391,7 +442,7 @@ class Dmn_Runtime_State_Manager friend class Dmn_Runtime_State; /** - * @brief Retain and submit a state for its first or subsequent step. + * @brief Retain and submit a state for its first or subsequent dispatch. * * The retained handle guarantees state lifetime until @ref releaseState. * @return true when the job was accepted; false if shutdown rejects an @@ -403,11 +454,14 @@ class Dmn_Runtime_State_Manager Dmn_Runtime_State::OnErrorFnc onError); /** - * @brief Execute one state step and repost or terminally release it. + * @brief Advance a state and repost or terminally release it. * * @param state Non-owning state handle captured by a runtime job. - * @param priority Priority to reuse when posting the next step. - * @param onError Runtime error callback for the next step. + * @param priority Priority to reuse when posting the next dispatch. + * @param onError Runtime error callback for the next dispatch. + * + * A dispatch executes at most one user callback; cancellation or a + * previously selected end may execute none. */ void executeStateStep(std::weak_ptr state, Dmn_Runtime_Job::Priority priority, diff --git a/include/dmn-state.hpp b/include/dmn-state.hpp index 7fcedf7..18e34aa 100644 --- a/include/dmn-state.hpp +++ b/include/dmn-state.hpp @@ -2,12 +2,11 @@ * Copyright © 2026 Chee Bin HOH. All rights reserved. * * @file dmn-state.hpp - * @brief Generic State machine wrapper and API that clients can drive - * the state machine to execute different states. + * @brief A caller-driven state machine composed of callback functions. * - * The Dmn_State class stores a sequence of state functors and provides a - * small API for initializing, advancing, and finalizing a state machine. - * States are represented by functors of type std::function. + * Clients register one or more callbacks and advance the machine by calling + * runNext(). Each callback receives the state machine so it can repeat, + * select another state, advance sequentially, or end execution. */ #ifndef DMN_STATE_HPP_ @@ -25,17 +24,33 @@ namespace dmn { /** * @class Dmn_State - * @brief Compact generic finite-state-machine helper. + * @brief A finite-state machine advanced explicitly by its caller. * - * Each state is a functor callable with the Dmn_State instance; the machine - * stores these functors and uses m_next to select which to run next. + * Register callbacks with setStateFnc(), then call runNext() until it returns + * false. Each call executes at most one callback. A callback remains selected + * for the next call unless it calls setNext(), setNext(int), or setEnd(). + * + * Initialization runs automatically before the first callback. Finalization + * runs automatically as soon as a callback selects the end. Neither lifecycle + * operation requires a separate call from the client. + * + * A machine with no callbacks converts to false. Calling runNext() on an empty + * machine is valid: it initializes and finalizes the machine, then returns + * false without invoking a callback. * * Usage example: * @code - * Dmn_State s("example"); - * s.setStateFnc(step1, 1); - * s.setStateFnc(step2, 2); - * while (s.runNext()) // drive the machine + * Dmn_State state{"example"}; + * state.setStateFnc([](Dmn_State ¤t) { + * // First state work. + * current.setNext(); + * }); + * state.setStateFnc([](Dmn_State ¤t) { + * // Second state work. + * current.setEnd(); + * }); + * + * while (state.runNext()) * ; * @endcode */ @@ -44,15 +59,13 @@ class Dmn_State { public: /** - * @brief Construct a Dmn_State with a human-readable name. - * @param name Human-readable name for diagnostics/logging. + * @brief Construct an empty state machine. + * @param name Human-readable name for diagnostics. */ explicit Dmn_State(std::string_view name); /** - * @brief Virtual destructor to allow clean subclassing. - * - * noexcept to avoid throwing during stack unwinding. + * @brief Destroy the state machine. */ virtual ~Dmn_State() noexcept; @@ -62,116 +75,140 @@ class Dmn_State { Dmn_State &operator=(Dmn_State &&obj) = delete; ///< non-movable /** - * @brief Mark the state machine to end (finalize) after the current step. + * @brief Select the end of the state machine. + * + * When called from a state callback, finalization occurs before the current + * runNext() call returns. Otherwise, the next runNext() call finalizes the + * machine without invoking a user callback. */ void setEnd(); /** - * @brief Set the next state by internal or user-state index. - * @param index 0 selects the internal initialization step and - * 1..m_states.size()-1 select configured user states. - * - * @note Callers must pass a valid configured index. Invalid indices trigger - * the implementation's existing defensive checks. + * @brief Select which user state the next runNext() call will execute. + * @param index With N configured user states, values 1 through N select a + * callback. N+1 selects the end of the machine. Zero is + * reserved for internal initialization. + * @throws std::out_of_range if index is outside 1 through N+1. */ void setNext(int index); /** - * @brief Convenience: set the next state to the next sequential state. + * @brief Select the next sequential user state. * - * Advances m_next by one (subject to bounds and configured states). + * Calling this from the last user state selects the end of the machine. */ void setNext(); /** - * @brief Set the functor for a state slot. - * @param fnc The functor to be called for the state step. - * @param index If 0 or the next 1-based user-state index, append a new user - * state. If 1..the current highest user-state index, replace - * the existing user state at that slot. + * @brief Add a user-state callback or replace an existing one. + * @param fnc Callback to execute when this state is selected. + * @param index With N callbacks currently configured, pass 0 (the default) + * or N+1 to append a callback. Pass 1 through N to replace the + * callback at that state. + * @throws std::out_of_range if index is negative or greater than N+1. + * + * State numbers start at 1. Zero means "append" only in this method and + * cannot be selected with setNext(). + * + * @pre Do not modify callback registration while a callback is executing. */ void setStateFnc(FncType fnc, int index = 0); /** - * @brief Check whether the machine has been initialized. - * @return true if initialization has occurred. + * @brief Report whether internal initialization has run. + * @return true after runNext() initializes the machine before its first + * user-state callback. */ auto isInitialized() -> bool; /** - * @brief Check whether the machine has been finalized. - * @return true if the machine has completed/finalized. + * @brief Report whether internal finalization has run. + * @return true after runNext() reaches the end of the machine. */ auto isFinalized() -> bool; /** - * @brief Return whether the client configured at least one state function. - * - * Excludes the internal initialization function installed during - * construction. - * - * @return true when at least one user-defined state function exists. + * @brief Report whether at least one user-state callback is configured. + * @return true when the machine contains a user-provided callback. */ bool hasStateFncs() const noexcept; /** - * @brief Execute the next state step. - * @return true if the state machine remains active after running the step; - * false when it has finalized/stopped. + * @brief Execute the currently selected user-state callback. + * + * On the first call, initialization runs before the callback. If the + * callback selects the end by calling setEnd() or by advancing past the last + * state, finalization runs before this method returns. + * + * If no callbacks are configured, this method initializes and finalizes the + * machine without invoking a callback. + * + * @return true when another callback can be executed; false after + * finalization. + * @pre The machine must not already be finalized. */ auto runNext() -> bool; - /// conversion to bool: true when NOT finalized - explicit operator bool() const noexcept { return !m_finalized; } + /** + * @brief Report whether the machine contains callbacks and is not finalized. + * + * A false result can mean either that no callback is configured or that the + * machine has finalized. Use isFinalized() to distinguish those cases. + */ + explicit operator bool() const noexcept { + return hasStateFncs() && !m_finalized; + } - /// optional complement for clarity - bool operator!() const noexcept { return m_finalized; } + /** @brief Return the logical complement of operator bool(). */ + bool operator!() const noexcept { return !static_cast(*this); } protected: /** - * @brief Perform internal initialization. Intended for internal use or - * subclasses that need to hook into init behavior. + * @brief Perform the initialization used by runNext(). + * + * Derived classes normally do not need to call this directly. * @param s Reference to the state object being initialized. */ void init(Dmn_State &s); /** - * @brief Perform internal finalization/cleanup. Intended for internal use - * or subclasses that need to hook into finalize behavior. + * @brief Perform the finalization used by runNext(). + * + * Derived classes normally do not need to call this directly. * @param s Reference to the state object being finalized. */ void finalize(Dmn_State &s); /** - * @brief Hook invoked before installing or replacing a state functor. + * @brief Validate an impending setStateFnc() operation. * - * Derived classes may override this to enforce additional lifecycle rules. - * The default implementation permits the operation. + * Derived classes may override this hook to reject configuration changes, + * for example after execution starts. The default implementation permits + * the operation. */ virtual void beforeSetStateFnc(); /** - * @brief Hook invoked before changing the next-state selector. + * @brief Validate an impending setNext() operation. * - * Derived classes may override this to distinguish internal runtime-driven - * transitions from external client mutations. The default implementation - * permits the operation. + * Derived classes may override this hook to restrict who may select a + * transition. The default implementation permits the operation. */ virtual void beforeSetNext(); /** - * @brief Hook invoked before forcing terminal selection with @ref setEnd. + * @brief Validate an impending setEnd() operation. * - * Derived classes may override this to restrict who may end the machine. - * The default implementation permits the operation. + * Derived classes may override this hook to restrict who may end the + * machine. The default implementation permits the operation. */ virtual void beforeSetEnd(); /** - * @brief Hook invoked before advancing the machine with @ref runNext. + * @brief Validate an impending runNext() operation. * - * Derived classes may override this to reserve stepping for a manager or - * execution context. The default implementation permits the operation. + * Derived classes may override this hook to restrict where execution may + * occur. The default implementation permits the operation. */ virtual void beforeRunNext(); @@ -182,17 +219,17 @@ class Dmn_State { * @brief Next state selector. * * Semantics: - * - 0 => initialization step (no user state) - * - <0 => finalize / terminated - * - >0 => 1-based index into m_states (user-provided states) + * - 0 => initialization is pending; not a valid setNext() argument + * - 1..m_states.size()-1 => selected user state + * - m_states.size() => finalization is pending */ int m_next{}; /** * @brief State functors. * - * Slot 0 stores the internal initialization step. User states occupy slots - * 1..m_states.size()-1. + * Slot 0 is a placeholder that keeps user-state indices 1-based. User + * callbacks occupy slots 1..m_states.size()-1. */ std::vector m_states{}; diff --git a/src/dmn-runtime-state.cpp b/src/dmn-runtime-state.cpp index 9a107a9..bcb28d9 100644 --- a/src/dmn-runtime-state.cpp +++ b/src/dmn-runtime-state.cpp @@ -9,9 +9,11 @@ * -------------------- * Lifecycle flags and the completion promise are synchronized by * Dmn_Runtime_State::m_mutex. The manager separately protects its retained - * state handles while jobs are queued or running. Lifecycle hooks always run - * after the lifecycle mutex is released so derived implementations can safely - * inspect state or call other public APIs. + * state handles while jobs are queued or running. Each job executes at most + * one user-state callback; Dmn_State folds internal initialization and + * finalization into that dispatch. Lifecycle hooks always run after the + * lifecycle mutex is released so derived implementations can safely inspect + * state or call other public APIs. */ #include "dmn-runtime-state.hpp" @@ -22,6 +24,7 @@ #include #include #include +#include #include #include @@ -46,52 +49,79 @@ void Dmn_Runtime_State::beforeSetStateFnc() { if (m_queued || m_running || m_terminal) { throw std::logic_error( - "Dmn_Runtime_State::setStateFnc cannot modify configuration after " - "successful run()"); + "Dmn_Runtime_State::setStateFnc cannot modify configuration after the " + "state has been submitted or reached a terminal outcome"); } } void Dmn_Runtime_State::beforeSetNext() { std::lock_guard lock{m_mutex}; - if ((m_queued || m_running || m_terminal) && m_internalExecutionDepth == 0) { + const bool internalCaller = + m_internalExecutionDepth > 0 && + m_internalExecutionThread == std::this_thread::get_id(); + if ((m_queued || m_running || m_terminal) && !internalCaller) { throw std::logic_error( - "Dmn_Runtime_State transition changes are reserved for the active " - "runtime-managed step after successful run()"); + "Dmn_Runtime_State transition changes are reserved for the runtime " + "callback after submission or a terminal outcome"); } } void Dmn_Runtime_State::beforeSetEnd() { std::lock_guard lock{m_mutex}; - if ((m_queued || m_running || m_terminal) && m_internalExecutionDepth == 0) { + const bool internalCaller = + m_internalExecutionDepth > 0 && + m_internalExecutionThread == std::this_thread::get_id(); + if ((m_queued || m_running || m_terminal) && !internalCaller) { throw std::logic_error( - "Dmn_Runtime_State termination is reserved for the active " - "runtime-managed step after successful run()"); + "Dmn_Runtime_State termination is reserved for the runtime callback " + "after submission or a terminal outcome"); } } void Dmn_Runtime_State::beforeRunNext() { std::lock_guard lock{m_mutex}; - if (m_internalExecutionDepth == 0) { + const bool internalCaller = + m_internalExecutionDepth > 0 && + m_internalExecutionThread == std::this_thread::get_id(); + if (!internalCaller || !m_runNextPermitted) { throw std::logic_error( "Dmn_Runtime_State::runNext is reserved for runtime-managed " "execution"); } + + m_runNextPermitted = false; } -void Dmn_Runtime_State::enterInternalExecution() { +void Dmn_Runtime_State::enterInternalExecution(bool permitRunNext) { std::lock_guard lock{m_mutex}; + + if (m_internalExecutionDepth == 0) { + m_internalExecutionThread = std::this_thread::get_id(); + } else { + assert(m_internalExecutionThread == std::this_thread::get_id()); + } + ++m_internalExecutionDepth; + if (permitRunNext) { + assert(!m_runNextPermitted); + m_runNextPermitted = true; + } } void Dmn_Runtime_State::leaveInternalExecution() noexcept { std::lock_guard lock{m_mutex}; assert(m_internalExecutionDepth > 0); + assert(m_internalExecutionThread == std::this_thread::get_id()); --m_internalExecutionDepth; + if (m_internalExecutionDepth == 0) { + m_internalExecutionThread = {}; + m_runNextPermitted = false; + } } bool Dmn_Runtime_State::runNextManaged() { @@ -100,7 +130,7 @@ bool Dmn_Runtime_State::runNextManaged() { ~Execution_Guard() { state->leaveInternalExecution(); } }; - enterInternalExecution(); + enterInternalExecution(true); Execution_Guard guard{this}; return Dmn_State::runNext(); @@ -112,7 +142,7 @@ void Dmn_Runtime_State::setEndManaged() { ~Execution_Guard() { state->leaveInternalExecution(); } }; - enterInternalExecution(); + enterInternalExecution(false); Execution_Guard guard{this}; Dmn_State::setEnd(); } diff --git a/src/dmn-state.cpp b/src/dmn-state.cpp index 9b761c6..cd96ab0 100644 --- a/src/dmn-state.cpp +++ b/src/dmn-state.cpp @@ -5,15 +5,14 @@ * @brief Generic State machine wrapper and API that clients can drive * the state machine to execute different states. * - * The Dmn_State class stores a sequence of state functors and provides a - * small API for initializing, advancing, and finalizing a state machine. - * States are represented by functors of type std::function. + * Each runNext() call executes at most one user-provided state callback. + * Initialization before the first callback and finalization after terminal + * selection are handled internally by the same call. */ #include "dmn-state.hpp" #include -#include #include #include #include @@ -23,8 +22,7 @@ namespace dmn { Dmn_State::Dmn_State(std::string_view name) : m_name{name} { - m_states.emplace_back( - std::bind(&Dmn_State::init, this, std::placeholders::_1)); + m_states.emplace_back(); } Dmn_State::~Dmn_State() {} @@ -32,7 +30,7 @@ Dmn_State::~Dmn_State() {} void Dmn_State::init([[maybe_unused]] Dmn_State &s) { m_initialized = true; - setNext(1); // either end of state or user provided first state + setNext(1); // Select the first user state; runNext() detects an empty list. } void Dmn_State::finalize([[maybe_unused]] Dmn_State &s) { m_finalized = true; } @@ -54,23 +52,40 @@ bool Dmn_State::hasStateFncs() const noexcept { return m_states.size() > 1; } auto Dmn_State::runNext() -> bool { beforeRunNext(); - // preferred assertion: use an explicit cast so it always compiles - assert(static_cast(*this) && "runNext called after finalize"); + assert(!m_finalized && "runNext called after finalize"); - // Preserve a runtime guard because assert() disappears in release builds. - if (!static_cast(*this)) { + if (m_finalized) { return false; } assert(m_next <= static_cast(m_states.size())); - if (m_next < 0) { + // A previously selected terminal state takes precedence over initialization. + // This preserves cancellation behavior for machines that never started. + if (m_next >= static_cast(m_states.size())) { finalize(*this); - } else if (m_next >= static_cast(m_states.size())) { + } else if (!m_initialized) { + init(*this); + } + + if (m_finalized) { + return false; + } + + // Initialization selects the first user state. An empty machine therefore + // proceeds directly to finalization without exposing either internal step. + if (m_next >= static_cast(m_states.size())) { + finalize(*this); + + return false; + } + + assert(m_next > 0 && "state index 0 is reserved for initialization"); + auto &fn = m_states[m_next]; + fn(*this); + + if (m_next >= static_cast(m_states.size())) { finalize(*this); - } else { - auto &fn = m_states[m_next]; - fn(*this); } return static_cast(*this); @@ -83,7 +98,11 @@ void Dmn_State::setEnd() { void Dmn_State::setNext(int index) { beforeSetNext(); - assert(index >= 0 && index <= static_cast(m_states.size())); + + if (index <= 0 || index > static_cast(m_states.size())) { + throw std::out_of_range( + "setNext: index must select a user state or the end"); + } m_next = index; } @@ -103,13 +122,13 @@ void Dmn_State::setStateFnc(FncType fnc, int index) { const int n = static_cast(m_states.size()); if (index == n || index == 0) { - // append the next step (must be exactly the next index) + // The reserved slot makes n the next 1-based user-state index. m_states.emplace_back(std::move(fnc)); } else if (index < n) { - // overwrite an existing (non-zero) step + // Valid nonzero indices below n identify existing user states. m_states[index] = std::move(fnc); } else { - // index > n -> skipping steps is not allowed + // User-state indices must remain contiguous. throw std::out_of_range("setStateFnc: cannot skip steps; index too large"); } } diff --git a/test/dmn-test-runtime-state.cpp b/test/dmn-test-runtime-state.cpp index 315c9c5..d173fb3 100644 --- a/test/dmn-test-runtime-state.cpp +++ b/test/dmn-test-runtime-state.cpp @@ -78,7 +78,7 @@ TEST(DmnRuntimeState, RejectsExternalMutationAfterSubmission) { EXPECT_TRUE(state->run()); EXPECT_THROW(state->setStateFnc([](dmn::Dmn_State &) {}), std::logic_error); - EXPECT_THROW(state->setNext(0), std::logic_error); + EXPECT_THROW(state->setNext(1), std::logic_error); EXPECT_THROW(state->setEnd(), std::logic_error); EXPECT_THROW(base.runNext(), std::logic_error); @@ -110,6 +110,8 @@ TEST(DmnRuntimeState, RuntimeCallbackCanObserveCancellationDirectly) { EXPECT_TRUE(state->run()); Runtime_Main_Loop loop{runtime()}; EXPECT_EQ(callbackStartedFuture.wait_for(5s), std::future_status::ready); + EXPECT_THROW(state->setNext(1), std::logic_error); + EXPECT_THROW(state->setEnd(), std::logic_error); state->cancel(); EXPECT_TRUE(state->wait_for(5s)); loop.stop(); @@ -118,6 +120,41 @@ TEST(DmnRuntimeState, RuntimeCallbackCanObserveCancellationDirectly) { EXPECT_TRUE(state->isCancelled()); } +TEST(DmnRuntimeState, RejectsRecursiveRunNextFromRuntimeCallback) { + using namespace std::chrono_literals; + + std::atomic_bool recursiveRunRejected{}; + std::atomic_int secondStateCount{}; + auto state = stateManager()->createState("recursive-run-next"); + + state->setStateFnc( + [&recursiveRunRejected](dmn::Dmn_State ¤t) { + current.setNext(); + + try { + (void)current.runNext(); + } catch (const std::logic_error &) { + recursiveRunRejected = true; + } + }, + 1); + state->setStateFnc( + [&secondStateCount](dmn::Dmn_State ¤t) { + ++secondStateCount; + current.setEnd(); + }, + 2); + + EXPECT_TRUE(state->run()); + Runtime_Main_Loop loop{runtime()}; + EXPECT_TRUE(state->wait_for(5s)); + loop.stop(); + + EXPECT_TRUE(recursiveRunRejected.load()); + EXPECT_EQ(secondStateCount.load(), 1); + EXPECT_TRUE(state->isCompleted()); +} + TEST(DmnRuntimeState, RejectsUnconfiguredAndPreRunCancelledStates) { using namespace std::chrono_literals; @@ -482,6 +519,8 @@ TEST(DmnRuntimeState, HandlesConcurrentStateLifecycleOperations) { TEST(DmnRuntimeState, ShutdownCancelsPendingStatesAndRejectsNewSubmissions) { using namespace std::chrono_literals; + // Keep this test last: shutdown permanently disables the process-wide + // runtime-state manager singleton. auto manager = stateManager(); std::promise blockingStepStarted; auto blockingStepStartedFuture = blockingStepStarted.get_future(); diff --git a/test/dmn-test-state.cpp b/test/dmn-test-state.cpp index 64e6d44..c2fe91b 100644 --- a/test/dmn-test-state.cpp +++ b/test/dmn-test-state.cpp @@ -1,9 +1,8 @@ /** * Copyright © 2024 - 2025 Chee Bin HOH. All rights reserved. * - * @file dmn-test-io.cpp - * @brief Unit test for Dmn_Pipe and Dmn_Proc I/O operations including - * multi-threaded read/write. + * @file dmn-test-state.cpp + * @brief Unit tests for Dmn_State lifecycle and user-state transitions. */ #include @@ -20,32 +19,36 @@ int main(int argc, char *argv[]) { dmn::Dmn_State s1{"default"}; - EXPECT_TRUE(s1); + EXPECT_FALSE(s1); + EXPECT_TRUE(!s1); EXPECT_TRUE(!s1.isInitialized()); EXPECT_TRUE(!s1.isFinalized()); EXPECT_FALSE(s1.hasStateFncs()); - s1.runNext(); - EXPECT_TRUE(s1); - EXPECT_TRUE(s1.isInitialized()); - EXPECT_TRUE(!s1.isFinalized()); - - s1.runNext(); - EXPECT_TRUE(!s1); + EXPECT_FALSE(s1.runNext()); + EXPECT_FALSE(s1); EXPECT_TRUE(s1.isInitialized()); EXPECT_TRUE(s1.isFinalized()); dmn::Dmn_State s2{"default2"}; + int s2_count = 0; + s2.setStateFnc([&s2_count](dmn::Dmn_State &s) { + ++s2_count; + s.setEnd(); + }); + + EXPECT_TRUE(s2); EXPECT_TRUE(!s2.isInitialized()); EXPECT_TRUE(!s2.isFinalized()); - while (s2) { - s2.runNext(); - } - + EXPECT_FALSE(s2.runNext()); EXPECT_TRUE(!s2); EXPECT_TRUE(s2.isInitialized()); EXPECT_TRUE(s2.isFinalized()); + EXPECT_EQ(s2_count, 1); + EXPECT_THROW(s2.setNext(0), std::out_of_range); + EXPECT_THROW(s2.setNext(-1), std::out_of_range); + EXPECT_THROW(s2.setNext(3), std::out_of_range); int s3_count = 0; dmn::Dmn_State s3{"count up to 3"}; @@ -62,8 +65,12 @@ int main(int argc, char *argv[]) { EXPECT_TRUE(s3.hasStateFncs()); - while (s3) { - s3.runNext(); + EXPECT_TRUE(s3.runNext()); + EXPECT_EQ(s3_count, 1); + EXPECT_TRUE(s3.isInitialized()); + EXPECT_FALSE(s3.isFinalized()); + + while (s3.runNext()) { } EXPECT_TRUE(!s3); @@ -105,6 +112,5 @@ int main(int argc, char *argv[]) { EXPECT_TRUE(10 == s4_count); EXPECT_TRUE(7 == s4_state_count); - // Dmn_Proc and Dmn_Pipe will be destroyed and display statistics return RUN_ALL_TESTS(); }