From 471b2ad2768fb27fe414432dfd151cd61391afdd Mon Sep 17 00:00:00 2001 From: Michael Vandeberg Date: Tue, 21 Jul 2026 15:48:34 -0600 Subject: [PATCH] test(fuse): let armed run on any execution context fuse::armed/inert only ever drove iterations through run_blocking (the built-in blocking_context), so they could not exercise operations that require a different execution context -- e.g. corosio::timeout, which needs an io_context. That left the test drivers out of step with the executor-generic run_async execution model. Add armed(run_one, fn): the caller supplies a runner that drives each iteration's task on a context it owns and returns any escaped exception as a std::exception_ptr (armed rethrows it from its own synchronous code). Returning rather than rethrowing is required because an exception escaping a run_async completion handler calls std::terminate. fuse stays io-agnostic -- the caller owns the drive loop and the concrete context. --- .../ROOT/pages/7.testing/7a.drivers.adoc | 41 ++++ include/boost/capy/test/fuse.hpp | 188 +++++++++++++----- test/unit/test/fuse.cpp | 119 +++++++++++ 3 files changed, 296 insertions(+), 52 deletions(-) diff --git a/doc/modules/ROOT/pages/7.testing/7a.drivers.adoc b/doc/modules/ROOT/pages/7.testing/7a.drivers.adoc index e6ec0c91e..98505e0ce 100644 --- a/doc/modules/ROOT/pages/7.testing/7a.drivers.adoc +++ b/doc/modules/ROOT/pages/7.testing/7a.drivers.adoc @@ -352,6 +352,14 @@ BOOST_TEST(r.success); at successive `maybe_fail()` sites. Accepts plain lambdas and coroutine lambdas returning `IoRunnable`. +| `armed(run_one, fn) -> result` +| Like `armed(fn)` for coroutine lambdas, but drives each iteration + through the caller-supplied `run_one` instead of `run_blocking`, so the + task runs on any execution context the caller chooses (for example an + `io_context`, required by operations built on `corosio::timeout`). + `run_one` runs the task to completion and returns any escaped exception + as a `std::exception_ptr` (null on success); `armed` rethrows it. + | `inert(fn) -> result` | Run `fn` once with no injection. `maybe_fail()` always returns `{}`. Accepts plain lambdas and coroutine lambdas returning `IoRunnable`. @@ -460,6 +468,39 @@ void test_add() } ---- +When the operation under test needs a specific execution context -- for +example an `io_context`, which operations built on `corosio::timeout` or +`corosio::delay` require and which `run_blocking` does not provide -- use the +`armed(run_one, fn)` overload. The caller supplies `run_one`, which drives +each iteration's task on a context it owns and returns any exception the task +raised as a `std::exception_ptr` (null on success); `armed` rethrows it. +`fuse` never learns about the context -- the caller owns the drive loop: + +[source,cpp] +---- +fuse f; +auto io_runner = [](task t) -> std::exception_ptr { + corosio::io_context ioc; + std::exception_ptr ep; + run_async(ioc.get_executor(), + [](auto&&...){}, [&ep](std::exception_ptr e){ ep = e; })(std::move(t)); + ioc.run(); + return ep; +}; +auto r = f.armed( + io_runner, + [&](fuse&) -> task { + co_await corosio::timeout(some_op(), 5s); + }); +BOOST_TEST(r.success); +---- + +`run_one` must *return* the exception rather than rethrow it: an exception +escaping a `run_async` completion handler calls `std::terminate`, so the +handler captures it and the runner hands it back once the run loop is done. +A fresh `io_context` per iteration (as above) needs no `restart()`; reuse one +across iterations only if you call `restart()` between rounds. + === Shared State Across Copies `fuse` is a value type backed by a `std::shared_ptr`. Every copy diff --git a/include/boost/capy/test/fuse.hpp b/include/boost/capy/test/fuse.hpp index 13fbd062e..763a06149 100644 --- a/include/boost/capy/test/fuse.hpp +++ b/include/boost/capy/test/fuse.hpp @@ -1,5 +1,6 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Michael Vandeberg // // 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) @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -503,46 +505,18 @@ class fuse p_->stopped = true; } - /** Run a test function with systematic failure injection. - - Repeatedly invokes the provided function, failing at - successive points until the function completes without - encountering a failure. First runs the complete loop - using error codes, then runs using exceptions. - - @par Example - - @code - fuse f; - auto r = f.armed([](fuse& f) { - auto ec = f.maybe_fail(); - if(ec) - return; - - ec = f.maybe_fail(); - if(ec) - return; - }); - - if(!r) - { - std::cerr << "Failure at " - << r.loc.file_name() << ":" - << r.loc.line() << "\n"; - } - @endcode - - @param fn The test function to invoke. It receives - a reference to the fuse and should call @ref maybe_fail - at each potential failure point. +private: + /* Drive the two-phase armed loop, invoking `do_iter` once per round. - @return A @ref result indicating success or failure. - On failure, `result::loc` contains the source location - of the last @ref maybe_fail or @ref fail call. + Phase 1 delivers injected failures as error codes; phase 2 as + exceptions. Shared by the two coroutine `armed` overloads: each + supplies a nullary `do_iter` that runs one iteration — via + @ref run_blocking, or via a caller-supplied runner — so the round + sequence and failure handling stay identical across them. */ - template + template result - armed(F&& fn) + run_phases(DoIter&& do_iter) { result r; @@ -554,7 +528,7 @@ class fuse { try { - fn(*this); + do_iter(); } catch(...) { @@ -583,7 +557,7 @@ class fuse { try { - fn(*this); + do_iter(); } catch(std::system_error const& ex) { @@ -617,29 +591,26 @@ class fuse return r; } - /** Run a coroutine test function with systematic failure injection. +public: + /** Run a test function with systematic failure injection. - Repeatedly invokes the provided coroutine function, failing at + Repeatedly invokes the provided function, failing at successive points until the function completes without encountering a failure. First runs the complete loop using error codes, then runs using exceptions. - This overload handles lambdas that return an @ref IoRunnable - (such as `task`), executing them synchronously via - @ref run_blocking. - @par Example @code fuse f; - auto r = f.armed([&](fuse&) -> task { + auto r = f.armed([](fuse& f) { auto ec = f.maybe_fail(); if(ec) - co_return; + return; ec = f.maybe_fail(); if(ec) - co_return; + return; }); if(!r) @@ -650,7 +621,7 @@ class fuse } @endcode - @param fn The coroutine test function to invoke. It receives + @param fn The test function to invoke. It receives a reference to the fuse and should call @ref maybe_fail at each potential failure point. @@ -659,7 +630,6 @@ class fuse of the last @ref maybe_fail or @ref fail call. */ template - requires IoRunnable> result armed(F&& fn) { @@ -673,7 +643,7 @@ class fuse { try { - run_blocking()(fn(*this)); + fn(*this); } catch(...) { @@ -702,7 +672,7 @@ class fuse { try { - run_blocking()(fn(*this)); + fn(*this); } catch(std::system_error const& ex) { @@ -736,6 +706,120 @@ class fuse return r; } + /** Run a coroutine test function with systematic failure injection. + + Repeatedly invokes the provided coroutine function, failing at + successive points until the function completes without + encountering a failure. First runs the complete loop + using error codes, then runs using exceptions. + + This overload handles lambdas that return an @ref IoRunnable + (such as `task`), executing them synchronously via + @ref run_blocking. + + @par Example + + @code + fuse f; + auto r = f.armed([&](fuse&) -> task { + auto ec = f.maybe_fail(); + if(ec) + co_return; + + ec = f.maybe_fail(); + if(ec) + co_return; + }); + + if(!r) + { + std::cerr << "Failure at " + << r.loc.file_name() << ":" + << r.loc.line() << "\n"; + } + @endcode + + @param fn The coroutine test function to invoke. It receives + a reference to the fuse and should call @ref maybe_fail + at each potential failure point. + + @return A @ref result indicating success or failure. + On failure, `result::loc` contains the source location + of the last @ref maybe_fail or @ref fail call. + */ + template + requires IoRunnable> + result + armed(F&& fn) + { + return run_phases([&]{ run_blocking()(fn(*this)); }); + } + + /** Run a coroutine test function on a caller-supplied runner. + + Behaves like the @ref IoRunnable overload of @ref armed, but + instead of driving each iteration through @ref run_blocking, it + hands the coroutine to `run_one`. This lets a caller run each + iteration on any execution context it chooses — in particular an + `io_context`, which operations built on `corosio::timeout` or + `corosio::delay` require, since those abort on a + non-`io_context` executor. `fuse` never learns about the context; + the caller owns the drive loop. + + @par Runner contract + `run_one` is invoked once per round with the @ref IoRunnable + produced by `fn`. It must run that task to completion + synchronously and *return* any exception the task raised as a + `std::exception_ptr` (null on success). It must not rethrow: + `armed` rethrows the returned pointer from its own synchronous + code so the exception phase observes injected failures, whereas + an exception escaping a `run_async` completion handler would call + `std::terminate`. Capture the exception in the error handler and + return it once the run loop is done. + + @par Example + @code + // Drive each iteration on a fresh io_context. + auto io_runner = [](capy::task<> t) -> std::exception_ptr + { + corosio::io_context ioc; + std::exception_ptr ep; + capy::run_async(ioc.get_executor(), + [](auto&&...){}, + [&ep](std::exception_ptr e){ ep = e; } + )(std::move(t)); + ioc.run(); + return ep; + }; + auto r = f.armed(io_runner, + [&](capy::test::fuse&) -> capy::task<> + { + co_await corosio::timeout(some_op(), 5s); + }); + @endcode + + @param run_one A callable invoked with each iteration's task; it + runs the task to completion and returns any escaped exception + (null on success) without rethrowing. + + @param fn The coroutine test function to invoke. + + @return A @ref result indicating success or failure. + */ + template + requires IoRunnable> + && std::same_as< + std::invoke_result_t>, + std::exception_ptr> + result + armed(Runner&& run_one, F&& fn) + { + return run_phases([&]{ + if(auto ep = run_one(fn(*this))) + std::rethrow_exception(ep); + }); + } + /** Alias for @ref armed. Allows the fuse to be invoked directly as a function diff --git a/test/unit/test/fuse.cpp b/test/unit/test/fuse.cpp index 7ad0bbaf1..8aab2759b 100644 --- a/test/unit/test/fuse.cpp +++ b/test/unit/test/fuse.cpp @@ -11,9 +11,12 @@ #include #include +#include #include #include +#include #include +#include #include "test_suite.hpp" @@ -21,6 +24,27 @@ namespace boost { namespace capy { namespace test { +namespace { + +// A conforming runner (runs the task to completion and returns the escaped +// exception, never rethrowing), built on run_blocking so the runner +// overload can be exercised without a real io_context. +inline std::exception_ptr +run_blocking_runner(task<> t) +{ + try + { + run_blocking()(std::move(t)); + return nullptr; + } + catch(...) + { + return std::current_exception(); + } +} + +} // (anonymous) + class fuse_test { public: @@ -668,6 +692,98 @@ class fuse_test BOOST_TEST(svc.work_count == 5); } + void + testRunnerOverload() + { + // armed(run_one, fn): the caller supplies how each iteration's + // task is driven to completion. run_one runs the task fully and + // *returns* any escaped exception (null on success) without + // rethrowing; armed rethrows it. Here run_blocking_runner stands + // in for burl's io_context-driven runner. + fuse f; + int iterations = 0; + int runner_calls = 0; + + auto r = f.armed( + [&](task<> t) -> std::exception_ptr + { + ++runner_calls; + return run_blocking_runner(std::move(t)); + }, + [&](fuse& fu) -> task<> + { + ++iterations; + auto ec = fu.maybe_fail(); + if(ec) + co_return; + }); + + BOOST_TEST(r.success); + // One maybe_fail: phase 1 = 3 rounds (n=0,1 trigger, n=2 completes), + // phase 2 = 3 rounds. The runner is invoked once per round. + BOOST_TEST(iterations == 6); + BOOST_TEST(runner_calls == 6); + } + + void + testRunnerStrayException() + { + // A stray (non-injected) exception the runner returns must be + // rethrown by armed and fail the result. + fuse f; + + auto r = f.armed( + &run_blocking_runner, + [](fuse& fu) -> task<> + { + auto ec = fu.maybe_fail(); + if(ec) + co_return; + throw std::runtime_error("stray"); + }); + + BOOST_TEST(!r.success); + } + + void + testRunnerMatchesRunBlocking() + { + // The runner overload must drive the same round sequence as the + // built-in run_blocking coroutine overload. + fuse f1; + fuse f2; + int iterations1 = 0; + int iterations2 = 0; + + auto r1 = f1.armed([&](fuse& fu) -> task<> + { + ++iterations1; + auto ec = fu.maybe_fail(); + if(ec) + co_return; + ec = fu.maybe_fail(); + if(ec) + co_return; + }); + + auto r2 = f2.armed( + &run_blocking_runner, + [&](fuse& fu) -> task<> + { + ++iterations2; + auto ec = fu.maybe_fail(); + if(ec) + co_return; + ec = fu.maybe_fail(); + if(ec) + co_return; + }); + + BOOST_TEST(r1.success); + BOOST_TEST(r2.success); + BOOST_TEST(iterations1 == iterations2); + } + void run() { @@ -697,6 +813,9 @@ class fuse_test testStandaloneAfterArmed(); testStandaloneAfterInert(); testDependencyInjectionPattern(); + testRunnerOverload(); + testRunnerStrayException(); + testRunnerMatchesRunBlocking(); } };