Cancel safe.
This is just not great. You did not consider the case when the timer was not delayed. Then you cancel it once, but it just starts a new asynchronous call after calling the completion handler.
Listed below are my detailed steps for tracking the issue.
SUMMARY TL; DR
Canceling time only cancels asynchronous operations in flight.
If you want to disable the asynchronous call chain, you will have to use additional logic for this. An example is given below.
Handler Tracking
Turn on with
#define BOOST_ASIO_ENABLE_HANDLER_TRACKING 1
This creates output that can be visualized using boost/libs/asio/tools/handlerviz.pl :
Successful trace

As you can see, async_wait is in flight when cancellation occurs.
Bad trace
(truncated because it will work endlessly)

Notice how the completion handler sees cc=system:0 , not cc=system:125 (for operation_aborted ). This is a sign of the fact that the published cancellation did not actually “take”. The only logical explanation (not visible in the diagram) is that the timer has already expired before the cancel request is called.
Compare unpaved tracks¹

¹ removing noisy difference
Detection
So we have an advantage. Can we find him?
timer.get_io_service().post([](){ std::cerr << "tid: " << std::this_thread::get_id() << ", cancelling in post\n"; if (timer.expires_from_now() >= std::chrono::steady_clock::duration(0)) { timer.cancel(); } else { std::cout << "PANIC\n"; timer.cancel(); } });
Print
tid: 140113177143232, i: 0, waiting for thread to join() tid: 140113177143232, i: 1, waiting for thread to join() tid: 140113177143232, i: 2, waiting for thread to join() tid: 140113177143232, i: 3, waiting for thread to join() tid: 140113177143232, i: 4, waiting for thread to join() tid: 140113177143232, i: 5, waiting for thread to join() tid: 140113177143232, i: 6, waiting for thread to join() tid: 140113177143232, i: 7, waiting for thread to join() tid: 140113177143232, i: 8, waiting for thread to join() tid: 140113177143232, i: 9, waiting for thread to join() tid: 140113177143232, i: 10, waiting for thread to join() tid: 140113177143232, i: 11, waiting for thread to join() tid: 140113177143232, i: 12, waiting for thread to join() tid: 140113177143232, i: 13, waiting for thread to join() tid: 140113177143232, i: 14, waiting for thread to join() tid: 140113177143232, i: 15, waiting for thread to join() tid: 140113177143232, i: 16, waiting for thread to join() tid: 140113177143232, i: 17, waiting for thread to join() tid: 140113177143232, i: 18, waiting for thread to join() tid: 140113177143232, i: 19, waiting for thread to join() tid: 140113177143232, i: 20, waiting for thread to join() tid: 140113177143232, i: 21, waiting for thread to join() tid: 140113177143232, i: 22, waiting for thread to join() tid: 140113177143232, i: 23, waiting for thread to join() tid: 140113177143232, i: 24, waiting for thread to join() tid: 140113177143232, i: 25, waiting for thread to join() tid: 140113177143232, i: 26, waiting for thread to join() PANIC
Can we communicate super-cancellation in a different, clearer way? We have ... only a timer object to work with, of course:
Alarm off
The timer object does not have many properties to work with. There are no close() or similar ones, for example, in a socket, which can be used to send a timer to some invalid state.
However, there is an expiration point, and we can use a special value domain for the signal "invalid" for our application:
timer.get_io_service().post([](){ std::cerr << "tid: " << std::this_thread::get_id() << ", cancelling in post\n"; // also cancels: timer.expires_at(Timer::clock_type::time_point::min()); });
This "special value" is easily handled in the completion handler:
void handle_timeout(const boost::system::error_code& ec) { if (!ec) { started = true; if (timer.expires_at() != Timer::time_point::min()) { timer.expires_from_now(std::chrono::milliseconds(10)); timer.async_wait(&handle_timeout); } else { std::cerr << "handle_timeout: detected shutdown\n"; } } else if (ec != boost::asio::error::operation_aborted) { std::cerr << "tid: " << std::this_thread::get_id() << ", handle_timeout error " << ec.message() << "\n"; } }