Timeout with Cancellation
Racing a slow operation against a deadline, and cancelling work directly with a stop token.
What You Will Learn
-
Where timed operations belong in the layering
-
Using
async_wakeras the escape hatch for external timing -
Racing a fetch against a deadline with
when_any -
Checking
stop_requested()in coroutines -
Direct cancellation with
std::stop_source
Prerequisites
-
Completed Type-Erased Echo
-
Understanding of stop tokens from Cancellation
Source Code
//
// Copyright (c) 2026 Mungo Gill
// Copyright (c) 2026 Steve Gerbino
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
//
// Official repository: https://github.com/cppalliance/capy
//
#include <boost/capy.hpp>
#include <boost/capy/ex/this_coro.hpp>
#include <boost/capy/test/stream.hpp>
#include <boost/capy/test/run_blocking.hpp>
#include <atomic>
#include <chrono>
#include <iostream>
#include <latch>
#include <thread>
namespace capy = boost::capy;
// A slow operation that respects cancellation
capy::task<std::string> slow_fetch(int steps)
{
auto token = co_await capy::this_coro::stop_token; // std::stop_token
std::string result;
for (int i = 0; i < steps; ++i)
{
// Check cancellation before each step
if (token.stop_requested())
{
std::cout << " Cancelled at step " << i << std::endl;
throw std::system_error(
make_error_code(std::errc::operation_canceled));
}
result += "step" + std::to_string(i) + " ";
// Simulate slow work (in real code, this would be I/O)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::cout << " Completed step " << i << std::endl;
// Yield to allow stop request to be processed before next check
// Extra 5ms ensures print completes before main thread prints
std::this_thread::sleep_for(std::chrono::milliseconds(15));
}
co_return result;
}
// Shared between the fetch worker thread and the coroutine side of
// the race in demo_timeout below.
struct fetch_channel
{
capy::async_waker fetch_ready;
std::atomic<bool> cancelled{false};
std::string result;
};
// One side of the race: completes when the fetch worker thread
// wakes fetch_ready. If the deadline wins first, when_any's stop
// request cancels the wait; flag the worker so it stops early.
capy::io_task<std::string> await_fetch(fetch_channel& ch)
{
auto [ec] = co_await ch.fetch_ready.wait();
if (ec)
{
ch.cancelled.store(true);
co_return capy::io_result<std::string>{ec, {}};
}
co_return capy::io_result<std::string>{{}, std::move(ch.result)};
}
// The other side of the race: completes once a user thread wakes
// the waker. This is the escape hatch: the user supplies the
// thread and the clock.
capy::io_task<> deadline(capy::async_waker& waker)
{
auto [ec] = co_await waker.wait();
co_return capy::io_result<>{ec};
}
// Wraps slow_fetch, translating a cancellation exception into
// std::nullopt for the manual stop_token demo below.
capy::task<std::optional<std::string>> fetch_or_cancelled()
{
try
{
auto result = co_await slow_fetch(5); // std::string
co_return result;
}
catch (std::system_error const& e)
{
if (e.code() == std::errc::operation_canceled)
co_return std::nullopt;
throw;
}
}
void demo_timeout()
{
std::cout << "Demo: Fetch races a deadline\n";
// Both when_any children are plain waker waits, so the whole
// race runs on one thread, as async_waker requires. The
// blocking work happens on user threads that wake them.
capy::thread_pool pool(1);
std::latch done(1);
fetch_channel fetch_ch;
capy::async_waker deadline_waker;
// Worker thread does the slow fetch and wakes fetch_ready on
// completion, checking the cancel flag between steps so a lost race stops
// the work promptly. Mirrors slow_fetch's step loop, kept
// separate since one polls an atomic and the other a stop token.
std::thread fetch_thread([&fetch_ch] {
for (int i = 0; i < 5; ++i)
{
if (fetch_ch.cancelled.load())
{
std::cout << " Cancelled at step " << i << std::endl;
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
fetch_ch.result += "step" + std::to_string(i) + " ";
std::cout << " Completed step " << i << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(15));
}
fetch_ch.fetch_ready.wake();
});
// Deadline thread: a user thread plays the clock, waking the
// waker after the allotted time.
std::thread deadline_thread([&deadline_waker] {
std::this_thread::sleep_for(std::chrono::milliseconds(30));
deadline_waker.wake();
});
// Index 1 = fetch won, index 2 = deadline won, index 0 = error.
std::size_t winner = 0;
std::string fetch_result;
auto race = [&]() -> capy::task<>
{
auto result = co_await capy::when_any(
await_fetch(fetch_ch),
deadline(deadline_waker));
winner = result.index();
if (result.index() == 1)
fetch_result = std::get<1>(std::move(result));
};
capy::run_async(pool.get_executor(),
[&done]() { done.count_down(); },
[&done](std::exception_ptr) { done.count_down(); }
)(race());
done.wait(); // Block until the race completes
fetch_thread.join();
deadline_thread.join();
// Report after the joins so the worker's final line lands
// before the verdict.
if (winner == 1)
// This demo's timing always favors the deadline; this branch
// shows how a fetch win would be consumed if it ever happened.
std::cout << "Result: " << fetch_result << "\n";
else if (winner == 2)
std::cout << "Timed out waiting for fetch\n";
else
std::cout << "Error\n";
}
void demo_cancellation()
{
std::cout << "\nDemo: Cancellation after 2 steps\n";
capy::thread_pool pool;
std::stop_source source;
std::latch done(1); // std::latch - wait for 1 task
// Launch the task
capy::run_async(pool.get_executor(), source.get_token(),
[&done](std::optional<std::string> result) {
if (result)
std::cout << "Result: " << *result << "\n";
else
std::cout << "Cancelled (returned nullopt)\n";
done.count_down();
},
[&done](std::exception_ptr) { done.count_down(); }
)(fetch_or_cancelled());
// Simulate timeout: cancel after 2 steps complete
// Timing: each step is 10ms work + 15ms yield = 25ms total
// Step 1 prints at 35ms, step 2 check at 50ms
// Stop at 42ms: after step 1 print, before step 2 check
std::this_thread::sleep_for(std::chrono::milliseconds(42));
std::cout << " Requesting stop..." << std::endl;
source.request_stop();
done.wait(); // Block until task completes (after cancellation)
}
// Example: Manual stop token checking
capy::task<int> process_items(std::vector<int> const& items)
{
auto token = co_await capy::this_coro::stop_token; // std::stop_token
int sum = 0;
for (auto item : items) // int
{
if (token.stop_requested())
{
std::cout << "Processing cancelled, partial sum: " << sum << "\n";
co_return sum; // Return partial result
}
sum += item;
}
co_return sum;
}
int main()
{
demo_timeout();
demo_cancellation();
return 0;
}
Build
add_executable(timeout_cancellation timeout_cancellation.cpp)
target_link_libraries(timeout_cancellation PRIVATE capy)
Walkthrough
Where Timing Lives
Capy is the coroutine layer: no reactor, no clock, no hidden threads.
Every thread Capy touches is one you handed it, a thread_pool worker,
or the thread that calls run. Timing needs a clock and something to
sleep on it, and that belongs to the I/O layer, where a reactor and
platform clock already exist for other reasons: Corosio provides
delay() and timeout() built on its event loop.
In pure Capy, with no Corosio in the picture, the user’s own thread
plays the clock, and async_waker gives it a suspension point to wake.
The Escape Hatch: async_waker
async_waker is Capy’s answer to "how do I wait for something that isn’t
a coroutine?" It hands a single wakeup from any thread to one waiting
coroutine: one coroutine suspends in wait(); any thread, including one
you spun up yourself to play the role of a clock, wakes it with wake():
struct fetch_channel
{
capy::async_waker fetch_ready;
std::atomic<bool> cancelled{false};
std::string result;
};
wait() returns an io_result<> that is empty on wakeup and carries
cond::canceled if the environment’s stop token fires first. A wakeup
with no waiter present is latched as one pending token, so the
wake-before-wait race is benign; extra wakes collapse into that single
token rather than queuing up. Because wake() is the only operation
callable from a foreign thread, the pattern generalizes to anything
external: a timer, a hardware interrupt, a completion callback from
another library.
Racing a Fetch Against a Deadline
demo_timeout builds two io_task children, each just a waker wait, and
races them with when_any:
auto result = co_await capy::when_any(
await_fetch(fetch_ch),
deadline(deadline_waker));
await_fetch completes when the fetch worker thread finishes and wakes
fetch_ch.fetch_ready. deadline completes when a second user thread,
playing the clock, sleeps for the allotted duration and wakes
deadline_waker. Whichever wakes first wins;
when_any requests stop on the loser, which is exactly how await_fetch
learns to set cancelled and let the fetch worker thread bail out early.
async_waker::wait() must only be awaited on a single-threaded
executor, so both children run on thread_pool(1). The slow, blocking
work itself still happens off that thread, on the two std::thread
objects the demo owns directly.
Getting the Stop Token
auto token = co_await capy::this_coro::stop_token; // std::stop_token
Inside a task, this_coro::stop_token retrieves the stop token propagated from the caller. You can also access it through the full environment via co_await this_coro::environment.
Checking for Cancellation
if (token.stop_requested())
{
throw std::system_error(make_error_code(std::errc::operation_canceled));
}
Check stop_requested() at appropriate points—typically before expensive operations or at loop iterations.
Output
Demo: Fetch races a deadline
Completed step 0
Completed step 1
Cancelled at step 2
Timed out waiting for fetch
Demo: Cancellation after 2 steps
Completed step 0
Completed step 1
Requesting stop...
Cancelled at step 2
Cancelled (returned nullopt)
Exercises
-
Change the deadline to 60ms and observe the fetch winning the race instead
-
Add cancellation support to the echo session from the previous example
-
Create a task that cancels itself after processing N items
Next Steps
-
Parallel Fetch — Concurrent operations with when_all