Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
timeout.hpp
Go to the documentation of this file.
1#pragma once
2
3// timeout / with_timeout — bound a coroutine's execution by a deadline.
4//
5// Usage:
6//
7// auto v = co_await with_timeout(timer, 3s, [](CancellationToken tok) {
8// return http::get(tok, url);
9// });
10//
11// // No-token form: timeout still works, but inner work cannot
12// // cooperatively cancel — it just runs to completion in the background.
13// auto v = co_await with_timeout(timer, 3s, []{ return work(); });
14//
15// // With a parent token; parent-cancel takes priority over timeout.
16// auto v = co_await with_timeout(parent_tok, timer, 3s, factory);
17//
18// // Fail-fast mode: do not wait for inner to unwind; resume parent
19// // immediately with TimeoutError when the deadline expires.
20// auto v = co_await with_timeout(timer, 3s, factory, OnTimeout::Fail);
21//
22// Behaviour matrix:
23//
24// OnTimeout::Cancel (default; cooperative)
25// When `duration` expires, the inner CancellationToken is flipped.
26// The awaiting coroutine resumes only when the inner factory unwinds
27// via OperationCancelled (or completes naturally just before the
28// timeout). If the timer was the cause, the caller observes
29// `TimeoutError`; if the inner work threw something else, that
30// exception is propagated.
31//
32// ⚠️ Footgun — Cancel + no-token factory:
33// The no-token overload (`with_timeout(timer, dur, []{ return work(); })`)
34// has NO way to signal cancellation into the inner work. Combined
35// with OnTimeout::Cancel, this means the timer can only OBSERVE
36// the inner outcome and re-label it as `TimeoutError` AFTER the
37// inner work has finished naturally — the caller does NOT get a
38// prompt timeout. If you need fail-fast behaviour, either pass a
39// factory that takes `CancellationToken` (so cooperative unwind
40// is possible), or use `OnTimeout::Fail`. Mixing no-token + Cancel
41// is a deliberate "best-effort observe" mode and is documented
42// here so reviewers can flag it; for "real" deadlines, prefer
43// one of the other two combinations.
44//
45// OnTimeout::Fail (non-cooperative; fail-fast)
46// When `duration` expires the parent is resumed IMMEDIATELY with
47// TimeoutError. The inner Task is NOT awaited any further — it
48// continues running detached in the background until it naturally
49// completes (its result and any exception are discarded). The
50// inner CancellationToken is still flipped, so cooperative inner
51// work CAN unwind early; non-cooperative inner work simply runs
52// to completion off-stage.
53//
54// ⚠️ Caveat — only safe when the inner work has no observable
55// side effects after the timeout, OR side effects are idempotent.
56// If inner mutates shared state the caller can no longer track,
57// prefer OnTimeout::Cancel.
58//
59// Composition with retry:
60// When wrapped inside retry/retry_with_backoff, each attempt gets its
61// OWN deadline (factory() is re-invoked per attempt). A "global"
62// timeout across all attempts is NOT provided — wrap the outer call
63// in another with_timeout if you need it.
64
65#include "aria/async/task.hpp"
68#include "aria/async/virtual_time_executor.hpp" // for IDelayedScheduler
69#include "aria/async/detail/race_slot.hpp"
70#include "aria/async/detail/race_trace.hpp"
71#include "aria/error.hpp"
72
73#include <atomic>
74#include <chrono>
75#include <coroutine>
76#include <exception>
77#include <memory>
78#include <mutex>
79#include <optional>
80#include <stdexcept>
81#include <type_traits>
82#include <utility>
83#include <variant>
84
85namespace aria::async {
86
87class TimeoutError : public std::runtime_error {
88public:
89 TimeoutError() : std::runtime_error("operation timed out") {}
90};
91
109enum class OnTimeout {
112};
113
114namespace detail {
115
116template<typename Factory>
117concept TimeoutTokenAcceptingFactory =
118 std::invocable<Factory, CancellationToken>;
119
120template<typename Factory>
121concept TimeoutPlainFactory =
122 std::invocable<Factory>;
123
124template<typename Factory>
125struct timeout_factory_value;
126
127template<typename Factory>
128 requires TimeoutTokenAcceptingFactory<Factory>
129struct timeout_factory_value<Factory> {
130 using type = typename std::invoke_result_t<Factory, CancellationToken>::promise_type::value_type;
131};
132
133template<typename Factory>
134 requires (!TimeoutTokenAcceptingFactory<Factory>) && TimeoutPlainFactory<Factory>
135struct timeout_factory_value<Factory> {
136 using type = typename std::invoke_result_t<Factory>::promise_type::value_type;
137};
138
139template<typename Factory>
140using timeout_factory_value_t = typename timeout_factory_value<Factory>::type;
141
143template<typename Factory>
144auto invoke_factory(Factory& f, CancellationToken tok) {
145 if constexpr (TimeoutTokenAcceptingFactory<Factory>) {
146 return f(tok);
147 } else {
148 return f();
149 }
150}
151
152// ── Cancel mode (cooperative) ────────────────────────────────────────────
153
160template<typename Factory>
161auto with_timeout_impl_(std::optional<CancellationToken> parent,
162 IDelayedScheduler& timer,
163 std::chrono::milliseconds duration,
164 Factory factory)
165 -> Task<timeout_factory_value_t<Factory>>
166{
167 using R = timeout_factory_value_t<Factory>;
168
169 // Per-invocation cancellation source for the inner work; flipped by
170 // either the deadline or (if engaged) the parent token.
171 auto inner_src = std::make_shared<CancellationSource>();
172 auto inner_tok = inner_src->token();
173
174 // Race flag: 0 = pending, 1 = inner-won, 2 = timer-won.
175 auto winner = std::make_shared<std::atomic<int>>(0);
176
177 publish_race_trace(race_source::kWithTimeout, race_op::kStart);
178 struct EndTrace {
179 ~EndTrace() {
180 publish_race_trace(race_source::kWithTimeout, race_op::kEnd);
181 }
182 } end_trace;
183
184 // Arm the deadline. The lambda below takes care of cancelling the
185 // inner work iff it wins the race.
186 timer.post_after(duration, [winner, inner_src]() {
187 int expected = 0;
188 if (winner->compare_exchange_strong(expected, 2)) {
189 // D-31.1: the deadline claimed the race. Cancelling the sole
190 // participant IS the timeout here, so no separate
191 // race_loser_cancel is published.
192 publish_race_trace(race_source::kWithTimeout, race_op::kTimeout, 0,
193 ::aria::Error::timeout("with_timeout"));
194 inner_src->cancel();
195 }
196 });
197
198 // If a parent token is engaged, propagate its cancellation to the
199 // inner work synchronously. Parent-cancel always wins over the
200 // deadline (we re-throw the original OperationCancelled below
201 // instead of converting to TimeoutError).
202 if (parent) {
203 parent->on_cancel([inner_src]{ inner_src->cancel(); });
204 }
205
206 auto finish_success = [&] {
207 int expected = 0;
208 const bool inner_won = winner->compare_exchange_strong(expected, 1);
209 if (parent && parent->is_cancelled()) throw OperationCancelled{};
210 // Cancel mode waits for non-cooperative work to settle, but the
211 // elapsed deadline still determines the caller's result.
212 if (!inner_won) throw TimeoutError{};
213 publish_race_trace(race_source::kWithTimeout, race_op::kWon);
214 };
215
216 try {
217 if constexpr (std::is_void_v<R>) {
218 co_await invoke_factory(factory, inner_tok);
219 finish_success();
220 co_return;
221 } else {
222 R value = co_await invoke_factory(factory, inner_tok);
223 finish_success();
224 co_return value;
225 }
226 } catch (const OperationCancelled&) {
227 int expected = 0;
228 winner->compare_exchange_strong(expected, 1);
229 if (parent && parent->is_cancelled()) {
230 publish_race_trace(race_source::kWithTimeout,
231 race_op::kParentCancel, 0,
232 ::aria::Error::cancellation("with_timeout"));
233 throw;
234 }
235 if (winner->load() == 2) throw TimeoutError{};
236 throw;
237 } catch (...) {
238 // A failed inner task has also finished the race. Prevent the
239 // still-queued timer from cancelling it or reporting a later timeout.
240 int expected = 0;
241 winner->compare_exchange_strong(expected, 1);
242 throw;
243 }
244}
245
246// ── Fail mode (non-cooperative, fail-fast) ───────────────────────────────
247
248// Race state and awaiter live in detail/race_slot.hpp (shared with
249// when_any's race-hardening path).
250//
251// Winner codes for with_timeout::Fail:
252// 1 = Inner completed first
253// 2 = Timer fired first
254// 3 = Parent token cancelled (engaged parent only)
255
260template<typename Factory, typename R>
261Task<void> drive_inner_for_fail_(
262 Factory factory,
263 CancellationToken inner_tok,
264 std::shared_ptr<RaceSlot<R>> slot)
265{
266 try {
267 if constexpr (std::is_void_v<R>) {
268 co_await invoke_factory(factory, inner_tok);
269 if (slot->try_claim(/*Inner=*/1)) {
270 slot->store_value_or_exception(); // void success
271 slot->publish(/*Inner=*/1);
272 publish_race_trace(race_source::kWithTimeout, race_op::kWon);
273 slot->notify_winner_resume();
274 }
275 // else: timer or parent already won; result discarded.
276 } else {
277 R value = co_await invoke_factory(factory, inner_tok);
278 if (slot->try_claim(/*Inner=*/1)) {
279 slot->store_value_or_exception(std::move(value));
280 slot->publish(/*Inner=*/1);
281 publish_race_trace(race_source::kWithTimeout, race_op::kWon);
282 slot->notify_winner_resume();
283 }
284 }
285 } catch (...) {
286 if (slot->try_claim(/*Inner=*/1)) {
287 slot->result.template emplace<2>(std::current_exception());
288 slot->publish(/*Inner=*/1);
289 // The inner task still won the race — it just won with a
290 // failure. The exception itself surfaces through
291 // await_resume, not through an arbitration event.
292 publish_race_trace(race_source::kWithTimeout, race_op::kWon);
293 slot->notify_winner_resume();
294 }
295 // else: timer/parent won; exception discarded silently.
296 }
297}
298
302template<typename Factory>
303auto with_timeout_fail_impl_(std::optional<CancellationToken> parent,
304 IDelayedScheduler& timer,
305 std::chrono::milliseconds duration,
306 Factory factory)
307 -> Task<timeout_factory_value_t<Factory>>
308{
309 using R = timeout_factory_value_t<Factory>;
310
311 auto slot = std::make_shared<RaceSlot<R>>();
312 auto inner_src = std::make_shared<CancellationSource>();
313 auto inner_tok = inner_src->token();
314
315 publish_race_trace(race_source::kWithTimeout, race_op::kStart);
316 struct EndTrace {
317 ~EndTrace() {
318 publish_race_trace(race_source::kWithTimeout, race_op::kEnd);
319 }
320 } end_trace;
321
322 // Arm the deadline.
323 timer.post_after(duration, [slot, inner_src]() {
324 if (slot->try_claim(/*Timer=*/2)) {
325 // Mark inner as cancelled so cooperative work CAN unwind
326 // (best-effort; non-cooperative inner just runs in the bg).
327 inner_src->cancel();
328 slot->result.template emplace<2>(
329 std::make_exception_ptr(TimeoutError{}));
330 slot->publish(/*Timer=*/2);
331 publish_race_trace(race_source::kWithTimeout, race_op::kTimeout, 0,
332 ::aria::Error::timeout("with_timeout"));
333 slot->notify_winner_resume();
334 }
335 });
336
337 // Wire parent token (if engaged): parent-cancel beats timeout, same
338 // as Cancel mode. Manifest as OperationCancelled in await_resume.
339 if (parent) {
340 parent->on_cancel([slot, inner_src]{
341 if (slot->try_claim(/*ParentCancel=*/3)) {
342 inner_src->cancel();
343 slot->result.template emplace<2>(
344 std::make_exception_ptr(OperationCancelled{}));
345 slot->publish(/*ParentCancel=*/3);
346 publish_race_trace(race_source::kWithTimeout,
347 race_op::kParentCancel, 0,
348 ::aria::Error::cancellation("with_timeout"));
349 slot->notify_winner_resume();
350 }
351 });
352 }
353
354 // Start the inner driver detached. It owns its own coroutine frame
355 // via Task::start_detached and may outlive this wrapper if the
356 // timer wins the race.
357 drive_inner_for_fail_<Factory, R>(std::move(factory), inner_tok, slot)
358 .start_detached();
359
360 // Park until a winner publishes a result. The guard closes the trace
361 // on both successful decoding and exception unwinding.
362 if constexpr (std::is_void_v<R>) {
363 co_await RaceSlotAwaiter<R>{slot};
364 co_return;
365 } else {
366 auto decoded = co_await RaceSlotAwaiter<R>{slot};
367 co_return decoded;
368 }
369}
370
371} // namespace detail
372
373// ── Public API: 4 thin wrappers, dispatched on OnTimeout ─────────────────
374
375template<typename Factory>
377 std::chrono::milliseconds duration,
378 Factory factory,
379 OnTimeout on_timeout = OnTimeout::Cancel)
381{
382 if (on_timeout == OnTimeout::Fail) {
383 return detail::with_timeout_fail_impl_(
384 std::optional<CancellationToken>{}, timer, duration, std::move(factory));
385 }
386 return detail::with_timeout_impl_(
387 std::optional<CancellationToken>{}, timer, duration, std::move(factory));
388}
389
390template<typename Factory>
392 IDelayedScheduler& timer,
393 std::chrono::milliseconds duration,
394 Factory factory,
395 OnTimeout on_timeout = OnTimeout::Cancel)
397{
398 if (on_timeout == OnTimeout::Fail) {
399 return detail::with_timeout_fail_impl_(
400 std::optional<CancellationToken>{std::move(parent)},
401 timer, duration, std::move(factory));
402 }
403 return detail::with_timeout_impl_(
404 std::optional<CancellationToken>{std::move(parent)},
405 timer, duration, std::move(factory));
406}
407
408} // namespace aria::async
Tiny interface — anything that can post a function to run after a delay.
Definition property_ops.hpp:62
Definition cancellation.hpp:182
Definition task.hpp:78
TimeoutError()
Definition timeout.hpp:89
Definition async_command.hpp:118
OnTimeout
Behaviour when the deadline expires.
Definition timeout.hpp:109
@ Fail
Definition timeout.hpp:111
@ Cancel
Definition timeout.hpp:110
auto with_timeout(IDelayedScheduler &timer, std::chrono::milliseconds duration, Factory factory, OnTimeout on_timeout=OnTimeout::Cancel) -> Task< detail::timeout_factory_value_t< Factory > >
Definition timeout.hpp:376
Definition validation_key.hpp:110
static Error cancellation(std::string source_tag="AsyncCommand")
Cancellation.
Definition error.hpp:192
static Error timeout(std::string source_tag="AsyncCommand")
with_timeout deadline expired.
Definition error.hpp:198