morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
manual_scheduler.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <chrono>
6#include <cstddef>
7#include <cstdint>
8#include <functional>
9#include <memory>
10#include <stdexcept>
11#include <utility>
12#include <vector>
13
14#include "../scheduler.hpp"
15
16/// @file
17/// @brief `morph::reactive::testing::ManualScheduler`: a scheduler whose time moves only when a test says so.
18
19namespace morph::reactive::testing {
20
21/// @brief A deterministic `Scheduler`: its clock moves only in `advance()`, which fires what falls due on the
22/// calling thread.
23///
24/// `advance(delta)` models time passing while the owner loop runs and is never blocked: the clock stops at each
25/// deadline in turn, so `every()` fires once per elapsed period, and a timer a callback creates fires in the same
26/// advance when its deadline falls within it. Timers due at the same instant fire in the order they were created
27/// (a periodic timer keeps the place its first scheduling gave it). One exception keeps a self-re-arming
28/// zero-delay timer from looping: a timer a callback creates with a non-positive delay is due at the next
29/// `advance()`, not at the current instant.
30///
31/// It keeps the `Scheduler` contract: a timer cancelled by an earlier callback does not fire, a callback may
32/// cancel any timer, its own included, and handles may outlive the scheduler. Not thread-safe.
33class ManualScheduler final : public Scheduler {
34public:
35 ManualScheduler() = default;
36 ~ManualScheduler() override = default;
37 ManualScheduler(ManualScheduler const&) = delete;
38 ManualScheduler& operator=(ManualScheduler const&) = delete;
39 ManualScheduler(ManualScheduler&&) = delete;
40 ManualScheduler& operator=(ManualScheduler&&) = delete;
41
42 /// @brief Runs @p fn once, @p delay after the current time.
43 /// @param delay How long to wait; a negative delay counts as zero, which is due at the next `advance()`,
44 /// `advance(0ms)` included.
45 /// @param fn The callback.
46 /// @return The handle; destroying it before the deadline cancels the call.
47 [[nodiscard]] TimerHandle after(std::chrono::milliseconds delay, std::function<void()> fn) override {
48 return add(std::max(delay, std::chrono::milliseconds{0}), std::chrono::milliseconds{0}, std::move(fn));
49 }
50
51 /// @brief Runs @p fn every @p period, starting one period after the current time.
52 /// @param period The interval; must be positive.
53 /// @param fn The callback.
54 /// @return The handle; destroying it stops the repetition.
55 /// @throws std::invalid_argument when @p period is not positive.
56 [[nodiscard]] TimerHandle every(std::chrono::milliseconds period, std::function<void()> fn) override {
57 if (period.count() <= 0) {
58 throw std::invalid_argument{"ManualScheduler::every: the period must be positive"};
59 }
60 return add(period, period, std::move(fn));
61 }
62
63 /// @brief Moves the clock forward by @p delta, stopping at each deadline on the way to fire what is due
64 /// there.
65 ///
66 /// A callback sees the clock at its own deadline. A periodic timer is next due one period after the deadline
67 /// it fired for. A callback that throws ends the advance: the exception propagates, the clock stays at that
68 /// deadline, the throwing timer is rescheduled (periodic) or gone (one-shot) as if it had returned, and the
69 /// rest fire at the next advance.
70 /// @param delta How far to move; must not be negative.
71 /// @throws std::invalid_argument when @p delta is negative.
72 /// @throws std::logic_error when called from a timer callback.
73 void advance(std::chrono::milliseconds delta) {
74 if (delta.count() < 0) {
75 throw std::invalid_argument{"ManualScheduler::advance: the delta must not be negative"};
76 }
77 if (_state->advancing) {
78 throw std::logic_error{"ManualScheduler::advance: called from a timer callback"};
79 }
80 // What callbacks deferred during an earlier advance is due now, at the current time rather than at the
81 // earlier deadline it was created at, so the clock never runs backwards.
82 for (Timer& timer : _state->timers) {
83 if (timer.deferred) {
84 timer.deadline = _state->now;
85 timer.deferred = false;
86 }
87 }
88 _state->advancing = true;
89 try {
90 fireUntil(_state->now + delta);
91 } catch (...) {
92 _state->advancing = false;
93 throw;
94 }
95 _state->advancing = false;
96 }
97
98 /// @brief The scheduler's clock: the sum of every `advance()` so far, or, inside a callback, that callback's
99 /// deadline. It never decreases.
100 /// @return The current time.
101 [[nodiscard]] std::chrono::milliseconds now() const { return _state->now; }
102
103 /// @brief How many timers are scheduled: periodic ones until cancelled, one-shots until they fire or are
104 /// cancelled.
105 /// @return The count.
106 [[nodiscard]] std::size_t pendingTimers() const { return _state->timers.size(); }
107
108private:
109 struct Timer {
110 std::uint64_t id;
111 std::chrono::milliseconds deadline;
112 std::chrono::milliseconds period; // zero for a one-shot
113 std::shared_ptr<std::function<void()> const> fn;
114 // Created by a callback with a deadline at the current instant: waits for the next advance.
115 bool deferred;
116 };
117
118 // Shared with every handle's cancel function, weakly, so a handle that outlives the scheduler finds it
119 // gone instead of touching freed memory.
120 struct State {
121 std::chrono::milliseconds now{0};
122 std::uint64_t nextId = 0;
123 bool advancing = false;
124 std::vector<Timer> timers;
125 };
126
127 void fireUntil(std::chrono::milliseconds target) {
128 auto& timers = _state->timers;
129 for (;;) {
130 // Ids grow with creation order, which breaks deadline ties.
131 auto due = timers.end();
132 for (auto timer = timers.begin(); timer != timers.end(); ++timer) {
133 if (!timer->deferred && timer->deadline <= target &&
134 (due == timers.end() ||
135 std::pair{timer->deadline, timer->id} < std::pair{due->deadline, due->id})) {
136 due = timer;
137 }
138 }
139 if (due == timers.end()) {
140 break;
141 }
142 _state->now = due->deadline;
143 // Held here: the callback may cancel its own timer, which destroys the stored one.
144 std::shared_ptr<std::function<void()> const> const fire = due->fn;
145 if (due->period.count() > 0) {
146 due->deadline += due->period;
147 } else {
148 timers.erase(due);
149 }
150 (*fire)();
151 }
152 _state->now = target;
153 }
154
155 TimerHandle add(std::chrono::milliseconds delay, std::chrono::milliseconds period, std::function<void()> fn) {
156 auto const timerId = ++_state->nextId;
157 _state->timers.push_back(Timer{.id = timerId,
158 .deadline = _state->now + delay,
159 .period = period,
160 .fn = std::make_shared<std::function<void()> const>(std::move(fn)),
161 .deferred = _state->advancing && delay.count() <= 0});
162 auto const isTimer = [timerId](Timer const& timer) { return timer.id == timerId; };
163 return TimerHandle{[weak = std::weak_ptr<State>{_state}, isTimer] {
164 if (auto const state = weak.lock()) {
165 std::erase_if(state->timers, isTimer);
166 }
167 },
168 [weak = std::weak_ptr<State>{_state}, isTimer] {
169 auto const state = weak.lock();
170 return state != nullptr && std::ranges::any_of(state->timers, isTimer);
171 }};
172 }
173
174 std::shared_ptr<State> _state = std::make_shared<State>();
175};
176
177} // namespace morph::reactive::testing
Owns one scheduled timer and cancels it when destroyed. Move-only.
Definition scheduler.hpp:19
void advance(std::chrono::milliseconds delta)
Moves the clock forward by delta, stopping at each deadline on the way to fire what is due there.
TimerHandle after(std::chrono::milliseconds delay, std::function< void()> fn) override
Runs fn once, delay after the current time.
std::size_t pendingTimers() const
How many timers are scheduled: periodic ones until cancelled, one-shots until they fire or are cancel...
TimerHandle every(std::chrono::milliseconds period, std::function< void()> fn) override
Runs fn every period, starting one period after the current time.
std::chrono::milliseconds now() const
The scheduler's clock: the sum of every advance() so far, or, inside a callback, that callback's dead...
DelayAwaiter delay(detail::TimeoutScheduler &scheduler, std::chrono::milliseconds duration)
Suspends the awaiting coroutine for at least duration.
morph::reactive::Scheduler: timers a frontend runs on the runtime's owner executor.
reactive::TimerHandle TimerHandle
Owns one scheduled timer and cancels it when destroyed; see reactive::TimerHandle.
Definition frontend.hpp:44