From 4b7051d4f6084d72d5be08d07ff6f92c32d38928 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 16 Jun 2026 00:39:26 +0200 Subject: [PATCH] fix(ex): terminate on an unhandled run_async exception The run_async trampoline's unhandled_exception swallowed every escaping exception, so an unhandled task error vanished silently and the documented "exceptions are rethrown" behavior never happened. Make the trampoline call std::terminate instead: an exception reaches it only by escaping a handler (a handler that threw, or the default handler on an otherwise-unhandled task error), which is a genuine fault with no owner to receive it. Failing fast is loud and leak-free (the process ends), and needs no executor cooperation. To observe an error instead, pass an error handler (it receives the exception_ptr); to catch one, co_await the work inside a coroutine. Cooperative cancellation must not be treated as a fault: the default handler discards a stop_requested_exception (quitter's stop sentinel) so a stopped quitter completes silently rather than terminating. The result-only handler delegates to the same logic. Document the new behavior and exclude the terminate lines from coverage. Add a POSIX fork-based death test covering both terminate paths (no handler, and a rethrowing handler); it is guarded out on Windows. --- .../ROOT/pages/4.coroutines/4b.launching.adoc | 9 ++- .../ROOT/pages/9.design/9l.RunApi.adoc | 2 +- include/boost/capy/detail/run_callbacks.hpp | 15 ++++- include/boost/capy/ex/run_async.hpp | 24 +++++--- test/unit/ex/run_async.cpp | 59 +++++++++++++++++-- 5 files changed, 92 insertions(+), 17 deletions(-) diff --git a/doc/modules/ROOT/pages/4.coroutines/4b.launching.adoc b/doc/modules/ROOT/pages/4.coroutines/4b.launching.adoc index cbcd2eab0..0e088cac3 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4b.launching.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4b.launching.adoc @@ -72,7 +72,7 @@ Always use the two-call pattern in a single expression. [source,cpp] ---- -// Result handler only (exceptions rethrown) +// Result handler only (an unhandled exception calls std::terminate) run_async(ex, [](int result) { std::cout << "Got: " << result << "\n"; })(compute()); @@ -89,7 +89,12 @@ run_async(ex, )(compute()); ---- -When no handlers are provided, results are discarded and exceptions are rethrown (causing `std::terminate` if uncaught). +When no result handler is provided, the result is discarded. An exception +that goes unhandled (no error handler was supplied, or a handler let one +escape) calls `std::terminate`. To react to an error, pass an error handler; +it receives the `std::exception_ptr` and should handle it in place rather +than rethrowing. To catch an error, `co_await` the work inside a coroutine +and use `try`/`catch` rather than launching it fire-and-forget. == run: Executor Hopping Within Coroutines diff --git a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc index a29495f22..414c370c7 100644 --- a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc +++ b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc @@ -8,7 +8,7 @@ This document explains the naming conventions and call syntax of the two launche === `run_async` -- Fire-and-Forget Launch -`run_async` launches any _IoRunnable_ from non-coroutine code: `main()`, callback handlers, event loops. `task` is the most common conforming type, but any user-defined type satisfying the concept works. The function does not return a value to the caller. Handlers receive the task's result or exception after completion. +`run_async` launches any _IoRunnable_ from non-coroutine code: `main()`, callback handlers, event loops. `task` is the most common conforming type, but any user-defined type satisfying the concept works. The function does not return a value to the caller. Handlers receive the task's result or exception after completion, as data; they should not throw. An exception that no handler consumes (none was supplied, or a handler let one escape) calls `std::terminate`; it is never silently discarded. To catch an error instead, `co_await` the work inside a coroutine. [source,cpp] ---- diff --git a/include/boost/capy/detail/run_callbacks.hpp b/include/boost/capy/detail/run_callbacks.hpp index 64526ac08..db8206135 100644 --- a/include/boost/capy/detail/run_callbacks.hpp +++ b/include/boost/capy/detail/run_callbacks.hpp @@ -11,6 +11,7 @@ #define BOOST_CAPY_DETAIL_RUN_CALLBACKS_HPP #include +#include #include #include @@ -34,8 +35,18 @@ struct default_handler void operator()(std::exception_ptr ep) const { - if(ep) + if(!ep) + return; + try + { std::rethrow_exception(ep); + } + catch(stop_requested_exception const&) + { + // Cancellation is a normal completion, not an error. + } + // A real unhandled exception propagates to the trampoline's + // unhandled_exception, which terminates. } }; @@ -92,7 +103,7 @@ struct handler_pair if constexpr(std::invocable) h1_(ep); else - std::rethrow_exception(ep); + default_handler{}(ep); } }; diff --git a/include/boost/capy/ex/run_async.hpp b/include/boost/capy/ex/run_async.hpp index 09268219f..83ab93211 100644 --- a/include/boost/capy/ex/run_async.hpp +++ b/include/boost/capy/ex/run_async.hpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -167,7 +168,12 @@ struct BOOST_CAPY_CORO_DESTROY_WHEN_COMPLETE run_async_trampoline { } - void unhandled_exception() noexcept {} // LCOV_EXCL_LINE unsupported: throwing task with no error handler + // An exception reaches here only by escaping a handler: a handler + // that threw, or the default handler rethrowing an otherwise + // unhandled task exception. Cancellation is filtered out earlier + // by default_handler, so this is always a genuine error with no + // owner to receive it: fail fast. + void unhandled_exception() noexcept { std::terminate(); } // LCOV_EXCL_LINE }; std::coroutine_handle h_; @@ -261,9 +267,8 @@ struct BOOST_CAPY_CORO_DESTROY_WHEN_COMPLETE { } - void unhandled_exception() noexcept - { - } + // See primary template: an escaping handler exception is fatal. + void unhandled_exception() noexcept { std::terminate(); } // LCOV_EXCL_LINE }; std::coroutine_handle h_; @@ -427,7 +432,10 @@ class [[nodiscard]] run_async_wrapper storing the wrapper and calling it later violates LIFO ordering. Uses the default recycling frame allocator for coroutine frames. - With no handlers, the result is discarded and exceptions are rethrown. + With no handlers, the result is discarded. An unhandled exception + thrown by the task calls `std::terminate`; pass an error handler to + receive it as an `exception_ptr`, or `co_await` the work inside a + coroutine if you want to catch it. @par Thread Safety The wrapper and handlers may be called from any thread where the @@ -461,7 +469,7 @@ run_async(Ex ex) The handler `h1` is called with the task's result on success. If `h1` is also invocable with `std::exception_ptr`, it handles exceptions too. - Otherwise, exceptions are rethrown. + Otherwise, an unhandled exception calls `std::terminate`. @par Thread Safety The handler may be called from any thread where the executor @@ -549,8 +557,8 @@ run_async(Ex ex, H1 h1, H2 h2) /** Asynchronously launch a lazy task with stop token support. The stop token is propagated to the task, enabling cooperative - cancellation. With no handlers, the result is discarded and - exceptions are rethrown. + cancellation. With no handlers, the result is discarded and an + unhandled exception calls `std::terminate`. @par Thread Safety The wrapper may be called from any thread where the executor diff --git a/test/unit/ex/run_async.cpp b/test/unit/ex/run_async.cpp index 61ebeb153..e54e26f15 100644 --- a/test/unit/ex/run_async.cpp +++ b/test/unit/ex/run_async.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,11 @@ #include #include +#if !defined(_WIN32) +#include +#include +#endif + /* Implementation Notes for run_async ================================== @@ -305,10 +311,52 @@ struct run_async_test co_return; } - // Note: testDefaultRethrow removed - if no error handler is provided - // and the task throws, the exception goes to unhandled_exception which - // is undefined behavior. Users must provide an error handler if they - // want to handle exceptions. + // Note: a task that throws with no error handler calls std::terminate + // (the trampoline's unhandled_exception). That path is fatal and not + // unit-testable here; pass an error handler to observe exceptions. + +#if !defined(_WIN32) + // Death test: an exception escaping a handler must call std::terminate. + // Run each scenario in a forked child (the child aborts); the parent + // verifies the child did not exit normally. POSIX-only. + void + testTerminateOnUnhandled() + { + auto terminates = [](auto fn) { + ::pid_t pid = ::fork(); + BOOST_TEST(pid >= 0); + if(pid == 0) + { + // Hush the abort message; the binding satisfies freopen's + // warn_unused_result. + [[maybe_unused]] auto* f = + std::freopen("/dev/null", "w", stderr); + fn(); + _exit(0); // reached only if no terminate happened + } + int status = 0; + ::waitpid(pid, &status, 0); + return !(WIFEXITED(status) && WEXITSTATUS(status) == 0); + }; + + // No handler + throwing task: default handler rethrows -> terminate. + BOOST_TEST(terminates([] { + int dc = 0; + sync_executor d(dc); + run_async(d)(throws_exception()); + })); + + // Error handler rethrows -> escapes the handler -> terminate. + BOOST_TEST(terminates([] { + int dc = 0; + sync_executor d(dc); + run_async(d, + [](int) {}, + [](std::exception_ptr ep) { std::rethrow_exception(ep); } + )(throws_exception()); + })); + } +#endif void testErrorHandlerReceivesException() @@ -714,6 +762,9 @@ struct run_async_test testOverloadedHandler(); // Exception Handling +#if !defined(_WIN32) + testTerminateOnUnhandled(); +#endif testErrorHandlerReceivesException(); testOverloadedHandlerException();