Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions doc/modules/ROOT/pages/7.testing/7a.drivers.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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<void> 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<void> {
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<state>`. Every copy
Expand Down
188 changes: 136 additions & 52 deletions include/boost/capy/test/fuse.hpp
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -15,6 +16,7 @@
#include <boost/capy/error.hpp>
#include <boost/capy/test/run_blocking.hpp>
#include <system_error>
#include <concepts>
#include <cstddef>
#include <exception>
#include <limits>
Expand Down Expand Up @@ -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<class F>
template<class DoIter>
result
armed(F&& fn)
run_phases(DoIter&& do_iter)
{
result r;

Expand All @@ -554,7 +528,7 @@ class fuse
{
try
{
fn(*this);
do_iter();
}
catch(...)
{
Expand Down Expand Up @@ -583,7 +557,7 @@ class fuse
{
try
{
fn(*this);
do_iter();
}
catch(std::system_error const& ex)
{
Expand Down Expand Up @@ -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<void>`), executing them synchronously via
@ref run_blocking.

@par Example

@code
fuse f;
auto r = f.armed([&](fuse&) -> task<void> {
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)
Expand All @@ -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.

Expand All @@ -659,7 +630,6 @@ class fuse
of the last @ref maybe_fail or @ref fail call.
*/
template<class F>
requires IoRunnable<std::invoke_result_t<F, fuse&>>
result
armed(F&& fn)
{
Expand All @@ -673,7 +643,7 @@ class fuse
{
try
{
run_blocking()(fn(*this));
fn(*this);
}
catch(...)
{
Expand Down Expand Up @@ -702,7 +672,7 @@ class fuse
{
try
{
run_blocking()(fn(*this));
fn(*this);
}
catch(std::system_error const& ex)
{
Expand Down Expand Up @@ -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<void>`), executing them synchronously via
@ref run_blocking.

@par Example

@code
fuse f;
auto r = f.armed([&](fuse&) -> task<void> {
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<class F>
requires IoRunnable<std::invoke_result_t<F, fuse&>>
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<class Runner, class F>
requires IoRunnable<std::invoke_result_t<F, fuse&>>
&& std::same_as<
std::invoke_result_t<Runner&, std::invoke_result_t<F, fuse&>>,
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
Expand Down
Loading
Loading