morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
timeout_scheduler.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <atomic>
5#include <chrono>
6#include <core/net/EventLoop.hpp>
7#include <core/platform/Clock.hpp>
8#include <cstdint>
9#include <exception>
10#include <functional>
11#include <memory>
12#include <unordered_map>
13#include <utility>
14
15#include "../attributes.hpp"
17#include "io_loop.hpp"
18#include "logger.hpp"
19
20/// @file
21/// `TimeoutScheduler` — "run this callback once, in N milliseconds, unless
22/// cancelled first" — as timers on an `exec::IoLoop`.
23///
24/// @par One owner
25/// Every pending callback, and the loop timer that fires it, lives on the
26/// loop and is touched only in the loop's own tasks. `schedule()` and
27/// `cancel()` post to the loop and return; callbacks run on the loop's thread
28/// (the main thread under single-threaded WebAssembly, where the browser's
29/// timer pumps the loop). A callback therefore runs concurrently with any
30/// caller that is not on the loop, and must not block the loop: every socket
31/// and every other timer built on the same `IoLoop` waits while it runs.
32///
33/// @par What a caller can rely on
34/// - `cancel()` stops a callback that has not started. The callback, and
35/// everything it captured, is released on the loop, shortly after `cancel()`
36/// returns — not before.
37/// - `cancel()` does not wait for a callback that has already started.
38/// - The destructor drops every pending callback without firing it, and
39/// returns only once no callback of this scheduler is running.
40/// - A callback that throws is logged through `morph::log` and swallowed.
41
42namespace morph::async::detail {
43
44/// @brief Invokes a callback once after a delay, unless cancelled first.
45///
46/// Neither `Bridge` nor `RemoteServer` is bound to a specific `IExecutor`
47/// with a delayed-post primitive, so each owns one of these. Used by
48/// `RemoteServer` to enforce `LimitPolicy::executeTimeout` (server-side —
49/// see `docs/spec/core/backend.md`) and by `Bridge::setExecuteDeadline`
50/// (client-side — see `docs/spec/core/completion.md`).
51///
52/// Cross-thread surface: `schedule()` and `cancel()`, which post to the loop,
53/// and the destructor, which runs its close on the loop and waits for it. The
54/// handle `schedule()` returns is drawn from an atomic counter on the calling
55/// thread, so it is known before the loop has armed anything.
56class TimeoutScheduler {
57public:
58 /// @brief Opaque identifier for one scheduled callback. Never `0`, so a
59 /// holder may keep `0` for "nothing scheduled" and cancel it safely.
60 using Handle = std::uint64_t;
61
62 /// @brief Builds the scheduler on @p loop, whose thread runs every callback.
63 /// @param loop The application's loop. Borrowed: it must outlive this
64 /// scheduler.
65 explicit TimeoutScheduler(::morph::exec::IoLoop& loop MORPH_LIFETIMEBOUND)
66 : _loop{&loop}, _state{std::make_shared<State>(loop)} {}
67
68 /// @brief Builds the scheduler on a loop of its own, for a caller with no
69 /// `IoLoop` to share. Natively that loop owns one thread.
70 TimeoutScheduler()
71 : _ownedLoop{std::make_unique<::morph::exec::IoLoop>()},
72 _loop{_ownedLoop.get()},
73 _state{std::make_shared<State>(*_ownedLoop)} {}
74
75 /// @brief Drops every pending callback without firing it.
76 ///
77 /// The close runs on the loop — inline when this runs on the loop's own
78 /// thread, posted and waited for otherwise — so no callback is running
79 /// once it returns. The dropped callbacks are destroyed here, afterwards,
80 /// with the scheduler still whole: a capture whose destructor calls
81 /// `cancel()` on it finds it usable.
82 ~TimeoutScheduler() {
83 Pending dropped;
84 _loop->runAndWait([state = _state, &dropped] { dropped = state->close(); });
85 }
86
87 TimeoutScheduler(const TimeoutScheduler&) = delete;
88 TimeoutScheduler& operator=(const TimeoutScheduler&) = delete;
89 TimeoutScheduler(TimeoutScheduler&&) = delete;
90 TimeoutScheduler& operator=(TimeoutScheduler&&) = delete;
91
92 /// @brief Schedules @p callback to run after @p delay, unless cancelled
93 /// first via `cancel()`.
94 /// @param delay Time to wait before firing, measured from this call.
95 /// @param callback Invoked on the loop's thread if not cancelled in time.
96 /// Exceptions it throws are logged and swallowed.
97 /// @return Handle usable with `cancel()`.
98 Handle schedule(std::chrono::milliseconds delay, std::function<void()> callback) {
99 auto const deadline = _loop->loop().clock().now() + delay;
100 Handle const handle = _nextHandle.fetch_add(1, std::memory_order_relaxed) + 1;
101 _loop->post([state = _state, handle, deadline, callback = std::move(callback)]() mutable {
102 // Exchanged, not moved: a moved-from `std::function` may keep a
103 // copy of its target, and with it the captures `cancel()` is
104 // meant to release.
105 state->arm(handle, deadline, std::exchange(callback, nullptr));
106 });
107 return handle;
108 }
109
110 /// @brief Cancels a previously scheduled callback: stops one that has not
111 /// started, and returns without waiting for one that has.
112 ///
113 /// Posted, like `schedule()`, and applied in the order the two were
114 /// called: a `cancel()` of a handle whose arming has not reached the loop
115 /// yet still finds it, because the loop runs posts in order.
116 ///
117 /// - **@p handle has not started.** Its timer is retired and its callback
118 /// never runs. The callback and its captures are released on the loop,
119 /// after this returns.
120 /// - **@p handle is already running.** The loop took it out of the pending
121 /// set before invoking it, so this finds nothing; it is neither
122 /// interrupted nor waited for.
123 ///
124 /// So `cancel()` returning does **not** mean "no callback is in flight".
125 /// The only thing in this class that means that is `~TimeoutScheduler`. A
126 /// caller must keep every scheduled callback safe to run *after* its
127 /// `cancel()`: both callbacks in this repository (`Bridge::executeVia`'s
128 /// deadline and `RemoteServer`'s `LimitPolicy::executeTimeout`) capture a
129 /// `shared_ptr` to the state they settle and settle it write-once, so a late
130 /// run is an ignored duplicate rather than a use-after-free.
131 ///
132 /// Waiting here for a running callback would be the wrong contract rather
133 /// than a missing feature: a callback that posts back to the cancelling
134 /// thread would deadlock it — the hazard
135 /// `docs/spec/concurrency_and_lifetimes.md` names, and the same reason
136 /// `CallbackScope` deliberately offers no block-until-drained.
137 ///
138 /// A no-op if @p handle already fired, is firing, or was already cancelled.
139 /// @param handle Handle returned by a prior `schedule()` call.
140 void cancel(Handle handle) {
141 _loop->post([state = _state, handle] { state->disarm(handle); });
142 }
143
144private:
145 struct State;
146
147 /// One armed callback. Held in `State::pending`, whose nodes do not move,
148 /// so its address is the loop timer's state for as long as it is armed.
149 struct Entry {
150 State* owner{nullptr};
151 Handle handle{0};
152 ::core::net::TimerId id{};
153 std::function<void()> callback;
154 };
155
156 using Pending = std::unordered_map<Handle, Entry>;
157
158 /// Everything the loop owns. Touched only on the loop's thread.
159 struct State {
160 explicit State(::morph::exec::IoLoop& ioLoop) : loop{ioLoop} {}
161
162 /// Arms @p handle's timer, unless the scheduler has closed.
163 void arm(Handle handle, ::core::platform::SteadyTimePoint deadline, std::function<void()> callback) {
164 ::morph::exec::detail::noteOwner("TimeoutScheduler::schedule", loop.loop(), loop.runningHere());
165 if (closed) {
166 return;
167 }
168 auto [slot, inserted] = pending.try_emplace(
169 handle, Entry{.owner = this, .handle = handle, .id = {}, .callback = std::move(callback)});
170 if (inserted) {
171 slot->second.id = loop.loop().addTimer(deadline, &State::fire, &slot->second);
172 }
173 }
174
175 /// Retires @p handle's timer and releases its callback, if still pending.
176 void disarm(Handle handle) {
177 ::morph::exec::detail::noteOwner("TimeoutScheduler::cancel", loop.loop(), loop.runningHere());
178 auto node = pending.extract(handle);
179 if (node.empty()) {
180 return;
181 }
182 static_cast<void>(loop.loop().cancelTimer(node.mapped().id));
183 }
184
185 /// Retires every timer and hands back what was pending.
186 Pending close() {
187 closed = true;
188 for (auto const& entry : pending) {
189 static_cast<void>(loop.loop().cancelTimer(entry.second.id));
190 }
191 return std::exchange(pending, {});
192 }
193
194 /// The loop's timer callback.
195 /// @param entryPtr The `Entry` the timer was armed with.
196 static void fire(void* entryPtr) {
197 auto const& entry = *static_cast<Entry const*>(entryPtr);
198 State& self = *entry.owner;
199 // Out of the pending set before it runs, so a `cancel()` from
200 // inside the callback, or from anywhere once it has started, finds
201 // nothing. Nothing here touches `self` after the call: the
202 // callback may destroy the scheduler.
203 auto node = self.pending.extract(entry.handle);
204 if (node.empty()) {
205 return;
206 }
207 std::function<void()> const callback = std::move(node.mapped().callback);
208 node = {};
209 try {
210 callback();
211 } catch (const std::exception& exc) {
212 ::morph::log::logError("[timeout-scheduler] callback threw: {}", exc.what());
213 } catch (...) {
214 ::morph::log::logError("[timeout-scheduler] callback threw unknown exception");
215 }
216 }
217
218 // NOLINTNEXTLINE(cppcoreguidelines-avoid-const-or-ref-data-members): bound to one loop at construction and never rebound; the state is neither copied nor assigned.
219 ::morph::exec::IoLoop& loop;
220 Pending pending;
221 bool closed{false};
222 };
223
224 /// Present only for the owning constructor. First, so it is destroyed
225 /// last, after the destructor has closed the state on it.
226 std::unique_ptr<::morph::exec::IoLoop> _ownedLoop;
227 ::morph::exec::IoLoop* _loop;
228 std::atomic<Handle> _nextHandle{0};
229 std::shared_ptr<State> _state;
230};
231
232} // namespace morph::async::detail
constexpr Self self
The dialog or drawer a command list runs in.
Definition author.hpp:1295
DelayAwaiter delay(detail::TimeoutScheduler &scheduler, std::chrono::milliseconds duration)
Suspends the awaiting coroutine for at least duration.
@ Pending
{"pending": "q"}: whether a query or mutation is in flight.
The check every loop-owned body makes on entry: "am I on my owner?".
SlotBinding< std::decay_t< F > > slot(SlotId slotId, F &&read)
A slot with an id.
Definition view.hpp:63