Skip to content

fix: timeout callbacks hold their session, not this - #44

Merged
pgit merged 1 commit into
masterfrom
cancellation
Nov 9, 2026
Merged

pgit merged 1 commit into
masterfrom
cancellation

Conversation

@pgit

@pgit pgit commented Nov 9, 2026

Copy link
Copy Markdown
Owner

Fixes #43, but not the way the issue proposed: Timer keeps Asio's contract, and the callbacks that relied on more are fixed instead.

Cancelling an asynchronous operation means it completes soon, cancelled or successfully. A wait whose delay has expired already completes successfully, so its callback still runs once after arm(), cancel() or the destructor. A stop token per wait would hide that, at the cost of an allocation on every arm(), which runs after every packet on QUIC. The Corosio runtime gives no more than Asio either: the extra check is only in anyhttp's Corosio Timer wrapper, see the issue.

Changes

  • Timer (asio/runtime.hpp): the code is unchanged. The doc comment no longer promises that a cancelled or replaced callback is "never called". It says the callback may run once late, so it must hold what it uses and do no harm.
  • h2 and h2v2 shutdown(): the timeout no longer captures this, but [weak = weak_from_this()], and cancels through self once weak.lock() succeeds.
  • io::drain(): takes the owner of its stream as a std::weak_ptr<const void> and does nothing once it is gone. The callers in h1, h2 and h2v2 pass weak_from_this().
  • Tests: the Timer re-arm/cancel test moves from test_corosio.cpp to test_runtime.cpp, so it runs on both runtimes.

Testing

Full suites pass in build/ (ASIO), build-corosio/ and build-h2v2/. The timeouts still fire through the weak pointers:

  • TlsConnectionEnd.WHEN_client_never_answers_the_close_notify_THEN_server_ends_anyway: about 2.0 s.
  • ClientTlsEnd.WHEN_write_is_stuck_THEN_shutdown_interrupts_it_and_says_so/HTTP2: about 2.7 s.

The GCC and ASAN trees have not been run on this version.

🤖 Generated with Claude Code

Timer promised that a callback that has been replaced or cancelled is never
called. On ASIO, that does not hold for a wait whose delay has expired
already: its handler is queued with success, and arm(), cancel() or the
destructor cannot take it back -- the contract of every asynchronous
operation. Timer now says so, and leaves lifetime to the callback.

Three callbacks referred to what they cancel, [this] and [&stream], and
could run after the coroutine that armed them had moved on, against a
session that may be gone. The h2 and h2v2 shutdowns now capture
weak_from_this() and cancel through it; io::drain() takes the owner of its
stream and does nothing once the owner is gone.

The timer test of test_corosio.cpp moves to test_runtime.cpp, to run on
both runtimes.

Closes #43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pgit
pgit merged commit bc985a0 into master Nov 9, 2026
11 checks passed
@pgit
pgit deleted the cancellation branch November 13, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Timer (Asio): a cancelled or replaced callback still runs once its wait has expired

1 participant