mirror of
https://github.com/XRPLF/clio.git
synced 2025-11-04 11:55:51 +00:00
chore: Cancellable coroutines (#2180)
Add a wrap for `boost::asio::yield_context` to make async operations cancellable by default. The API could be a bit adjusted when we start switching our code to use it.
This commit is contained in:
@@ -4,6 +4,7 @@ target_sources(
|
||||
clio_util
|
||||
PRIVATE Assert.cpp
|
||||
build/Build.cpp
|
||||
Coroutine.cpp
|
||||
CoroutineGroup.cpp
|
||||
log/Logger.cpp
|
||||
prometheus/Http.cpp
|
||||
|
||||
90
src/util/Coroutine.cpp
Normal file
90
src/util/Coroutine.cpp
Normal file
@@ -0,0 +1,90 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of clio: https://github.com/XRPLF/clio
|
||||
Copyright (c) 2025, the clio developers.
|
||||
|
||||
Permission to use, copy, modify, and distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#include "util/Coroutine.hpp"
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/bind_cancellation_slot.hpp>
|
||||
#include <boost/asio/cancellation_type.hpp>
|
||||
#include <boost/asio/error.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/spawn.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace util {
|
||||
|
||||
Coroutine::Coroutine(boost::asio::yield_context&& yield, std::shared_ptr<FamilyCancellationSignal> signal)
|
||||
: yield_(std::move(yield))
|
||||
, cyield_(boost::asio::bind_cancellation_slot(cancellationSignal_.slot(), yield_[error_]))
|
||||
, familySignal_{std::move(signal)}
|
||||
, connection_{familySignal_->connect([this](boost::asio::cancellation_type_t cancellationType) {
|
||||
cancellationSignal_.emit(cancellationType);
|
||||
isCancelled_ = true;
|
||||
})}
|
||||
|
||||
{
|
||||
}
|
||||
|
||||
Coroutine::~Coroutine()
|
||||
{
|
||||
connection_.disconnect();
|
||||
}
|
||||
|
||||
boost::system::error_code
|
||||
Coroutine::error() const
|
||||
{
|
||||
return error_;
|
||||
}
|
||||
|
||||
void
|
||||
Coroutine::cancelAll(boost::asio::cancellation_type_t cancellationType)
|
||||
{
|
||||
if (isCancelled())
|
||||
return;
|
||||
familySignal_->operator()(cancellationType);
|
||||
}
|
||||
|
||||
bool
|
||||
Coroutine::isCancelled() const
|
||||
{
|
||||
return error_ == boost::asio::error::operation_aborted || isCancelled_;
|
||||
}
|
||||
|
||||
Coroutine::cancellable_yield_context_type
|
||||
Coroutine::yieldContext() const
|
||||
{
|
||||
return cyield_;
|
||||
}
|
||||
|
||||
boost::asio::any_io_executor
|
||||
Coroutine::executor() const
|
||||
{
|
||||
return cyield_.get().get_executor();
|
||||
}
|
||||
|
||||
void
|
||||
Coroutine::yield() const
|
||||
{
|
||||
boost::asio::post(yield_);
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
191
src/util/Coroutine.hpp
Normal file
191
src/util/Coroutine.hpp
Normal file
@@ -0,0 +1,191 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of clio: https://github.com/XRPLF/clio
|
||||
Copyright (c) 2025, the clio developers.
|
||||
|
||||
Permission to use, copy, modify, and distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/bind_cancellation_slot.hpp>
|
||||
#include <boost/asio/cancellation_signal.hpp>
|
||||
#include <boost/asio/cancellation_type.hpp>
|
||||
#include <boost/asio/error.hpp>
|
||||
#include <boost/asio/spawn.hpp>
|
||||
#include <boost/signals2/connection.hpp>
|
||||
#include <boost/signals2/signal.hpp>
|
||||
#include <boost/signals2/variadic_signal.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <concepts>
|
||||
#include <csignal>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace util {
|
||||
|
||||
class Coroutine;
|
||||
|
||||
/**
|
||||
* @brief Concept for functions that can be used as coroutine bodies.
|
||||
* Such functions must be invocable with a `Coroutine&` argument.
|
||||
* @tparam Fn The function type to check.
|
||||
*/
|
||||
template <typename Fn>
|
||||
concept CoroutineFunction = std::invocable<Fn, Coroutine&> and not std::is_reference_v<Fn>;
|
||||
|
||||
/**
|
||||
* @brief Manages a coroutine execution context, allowing for cooperative multitasking
|
||||
* and cancellation.
|
||||
*
|
||||
* The Coroutine class wraps a Boost.Asio yield_context and provides mechanisms
|
||||
* for spawning new coroutines, child coroutines, and managing their lifecycle,
|
||||
* including cancellation. It integrates with a signal system to propagate
|
||||
* cancellation requests across related coroutines.
|
||||
*/
|
||||
class Coroutine {
|
||||
public:
|
||||
/**
|
||||
* @brief Type alias for a yield_context that is bound to a cancellation slot.
|
||||
* This allows asynchronous operations initiated with this context to be cancelled.
|
||||
*/
|
||||
using cancellable_yield_context_type =
|
||||
boost::asio::cancellation_slot_binder<boost::asio::yield_context, boost::asio::cancellation_slot>;
|
||||
|
||||
private:
|
||||
boost::asio::yield_context yield_;
|
||||
boost::system::error_code error_;
|
||||
boost::asio::cancellation_signal cancellationSignal_;
|
||||
cancellable_yield_context_type cyield_;
|
||||
std::atomic_bool isCancelled_{false};
|
||||
|
||||
using FamilyCancellationSignal = boost::signals2::signal<void(boost::asio::cancellation_type_t)>;
|
||||
std::shared_ptr<FamilyCancellationSignal> familySignal_;
|
||||
boost::signals2::connection connection_;
|
||||
|
||||
/**
|
||||
* @brief Private constructor to create a Coroutine instance.
|
||||
* @param yield The Boost.Asio yield_context for this coroutine.
|
||||
* @param signal A shared signal used for propagating cancellation requests among related coroutines.
|
||||
*/
|
||||
explicit Coroutine(
|
||||
boost::asio::yield_context&& yield,
|
||||
std::shared_ptr<FamilyCancellationSignal> signal = std::make_shared<FamilyCancellationSignal>()
|
||||
);
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Destructor for the Coroutine.
|
||||
* Handles cleanup, such as disconnecting from the cancellation signal.
|
||||
*/
|
||||
~Coroutine();
|
||||
|
||||
Coroutine(Coroutine const&) = delete;
|
||||
Coroutine(Coroutine&&) = delete;
|
||||
|
||||
Coroutine&
|
||||
operator==(Coroutine&&) = delete;
|
||||
|
||||
Coroutine&
|
||||
operator==(Coroutine const&) = delete;
|
||||
|
||||
/**
|
||||
* @brief Spawns a new top-level coroutine.
|
||||
* @tparam ExecutionContext The type of the I/O execution context (e.g., boost::asio::io_context).
|
||||
* @tparam Fn The type of the invocable function that represents the coroutine body.
|
||||
* @param ioContext The I/O execution context on which to spawn the coroutine.
|
||||
* @param fn The function to be executed as the coroutine. It will receive a Coroutine& argument.
|
||||
*/
|
||||
template <typename ExecutionContext, CoroutineFunction Fn>
|
||||
static void
|
||||
spawnNew(ExecutionContext& ioContext, Fn fn)
|
||||
{
|
||||
boost::asio::spawn(ioContext, [fn = std::move(fn)](boost::asio::yield_context yield) {
|
||||
Coroutine thisCoroutine{std::move(yield)};
|
||||
fn(thisCoroutine);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Spawns a child coroutine from this coroutine.
|
||||
* The child coroutine shares the same cancellation signal.
|
||||
* @tparam Fn The type of the invocable function that represents the child coroutine body.
|
||||
* @param fn The function to be executed as the child coroutine. It will receive a Coroutine& argument.
|
||||
*/
|
||||
template <CoroutineFunction Fn>
|
||||
void
|
||||
spawnChild(Fn fn)
|
||||
{
|
||||
if (isCancelled_)
|
||||
return;
|
||||
|
||||
boost::asio::spawn(
|
||||
yield_,
|
||||
[signal = familySignal_, fn = std::move(fn)](boost::asio::yield_context yield) mutable {
|
||||
Coroutine coroutine(std::move(yield), std::move(signal));
|
||||
fn(coroutine);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the error code, if any, associated with the last operation in this coroutine.
|
||||
* @return A boost::system::error_code indicating the status.
|
||||
*/
|
||||
[[nodiscard]] boost::system::error_code
|
||||
error() const;
|
||||
|
||||
/**
|
||||
* @brief Cancels all coroutines sharing the same root cancellation signal.
|
||||
* @param cancellationType The type of cancellation to perform.
|
||||
* Defaults to boost::asio::cancellation_type::terminal.
|
||||
*/
|
||||
void
|
||||
cancelAll(boost::asio::cancellation_type_t cancellationType = boost::asio::cancellation_type::terminal);
|
||||
|
||||
/**
|
||||
* @brief Checks if this coroutine has been cancelled.
|
||||
* @return True if the coroutine is cancelled, false otherwise.
|
||||
*/
|
||||
[[nodiscard]] bool
|
||||
isCancelled() const;
|
||||
|
||||
/**
|
||||
* @brief Returns the cancellable yield context associated with this coroutine.
|
||||
* This context should be used for Boost.Asio asynchronous operations within the coroutine
|
||||
* to enable cancellation.
|
||||
* @return A cancellable_yield_context_type object.
|
||||
*/
|
||||
[[nodiscard]] cancellable_yield_context_type
|
||||
yieldContext() const;
|
||||
|
||||
/**
|
||||
* @brief Returns the executor associated with this coroutine's yield context.
|
||||
* @return The executor.
|
||||
*/
|
||||
[[nodiscard]] boost::asio::any_io_executor
|
||||
executor() const;
|
||||
|
||||
/**
|
||||
* @brief Explicitly yields execution back to the scheduler.
|
||||
* This can be used to allow other tasks to run.
|
||||
*/
|
||||
void
|
||||
yield() const;
|
||||
};
|
||||
|
||||
} // namespace util
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "util/Coroutine.hpp"
|
||||
#include "util/LoggerFixtures.hpp"
|
||||
|
||||
#include <boost/asio/executor_work_guard.hpp>
|
||||
@@ -79,6 +80,22 @@ private:
|
||||
* This is meant to be used as a base for other fixtures.
|
||||
*/
|
||||
struct SyncAsioContextTest : virtual public NoLoggerFixture {
|
||||
template <util::CoroutineFunction F>
|
||||
void
|
||||
runCoroutine(F&& f, bool allowMockLeak = false)
|
||||
{
|
||||
testing::MockFunction<void()> call;
|
||||
if (allowMockLeak)
|
||||
testing::Mock::AllowLeak(&call);
|
||||
|
||||
util::Coroutine::spawnNew(ctx_, [&, _ = boost::asio::make_work_guard(ctx_)](util::Coroutine& coroutine) {
|
||||
f(coroutine);
|
||||
call.Call();
|
||||
});
|
||||
EXPECT_CALL(call, Call());
|
||||
runContext();
|
||||
}
|
||||
|
||||
template <typename F>
|
||||
void
|
||||
runSpawn(F&& f, bool allowMockLeak = false)
|
||||
|
||||
@@ -166,6 +166,7 @@ target_sources(
|
||||
# Common utils
|
||||
util/AccountUtilsTests.cpp
|
||||
util/AssertTests.cpp
|
||||
util/CoroutineTest.cpp
|
||||
util/MoveTrackerTests.cpp
|
||||
util/RandomTests.cpp
|
||||
util/RetryTests.cpp
|
||||
|
||||
191
tests/unit/util/CoroutineTest.cpp
Normal file
191
tests/unit/util/CoroutineTest.cpp
Normal file
@@ -0,0 +1,191 @@
|
||||
//------------------------------------------------------------------------------
|
||||
/*
|
||||
This file is part of clio: https://github.com/XRPLF/clio
|
||||
Copyright (c) 2025, the clio developers.
|
||||
|
||||
Permission to use, copy, modify, and distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
//==============================================================================
|
||||
|
||||
#include "util/AsioContextTestFixture.hpp"
|
||||
#include "util/Coroutine.hpp"
|
||||
#include "util/Profiler.hpp"
|
||||
|
||||
#include <boost/asio/buffer.hpp>
|
||||
#include <boost/asio/error.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/read.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
#include <boost/system/system_error.hpp>
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <ranges>
|
||||
#include <string>
|
||||
|
||||
using namespace util;
|
||||
|
||||
class CoroutineTest : public SyncAsioContextTest {
|
||||
protected:
|
||||
testing::StrictMock<testing::MockFunction<void(Coroutine&)>> fnMock_;
|
||||
|
||||
static void
|
||||
asyncOperation(Coroutine::cancellable_yield_context_type yield, std::chrono::steady_clock::duration duration)
|
||||
{
|
||||
boost::asio::steady_timer timer(yield.get().get_executor(), duration);
|
||||
timer.async_wait(yield);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(CoroutineTest, SpawnNew)
|
||||
{
|
||||
EXPECT_CALL(fnMock_, Call);
|
||||
|
||||
Coroutine::spawnNew(ctx_, fnMock_.AsStdFunction());
|
||||
ctx_.run();
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, SpawnChild)
|
||||
{
|
||||
EXPECT_CALL(fnMock_, Call);
|
||||
runCoroutine([this](Coroutine& coroutine) { coroutine.spawnChild(fnMock_.AsStdFunction()); });
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, SpawnChildDoesNothingWhenTheCoroutineIsCancelled)
|
||||
{
|
||||
runCoroutine([this](Coroutine& coroutine) {
|
||||
coroutine.cancelAll();
|
||||
coroutine.spawnChild(fnMock_.AsStdFunction());
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, ErrorReturnsDefaultWhenNoError)
|
||||
{
|
||||
runCoroutine([](Coroutine& coroutine) { EXPECT_EQ(coroutine.error(), boost::system::error_code{}); });
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, ErrorReturnsDefaultAfterSuccessfulOperation)
|
||||
{
|
||||
runCoroutine([](Coroutine& coroutine) {
|
||||
boost::asio::steady_timer timer(coroutine.executor());
|
||||
timer.expires_after(std::chrono::milliseconds(1));
|
||||
timer.async_wait(coroutine.yieldContext());
|
||||
EXPECT_EQ(coroutine.error(), boost::system::error_code{});
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, ErrorReturnsErrorOfLastOperation)
|
||||
{
|
||||
runCoroutine([](Coroutine& coroutine) {
|
||||
boost::asio::ip::tcp::socket socket{coroutine.executor()};
|
||||
std::string buffer;
|
||||
socket.async_read_some(boost::asio::buffer(buffer), coroutine.yieldContext());
|
||||
EXPECT_EQ(coroutine.error(), boost::system::errc::bad_file_descriptor);
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, CancelAllCancelsChildren)
|
||||
{
|
||||
runCoroutine([&](Coroutine& coroutine) {
|
||||
coroutine.spawnChild([](Coroutine& childCoroutine) {
|
||||
auto const duration = util::timed([&childCoroutine]() {
|
||||
asyncOperation(childCoroutine.yieldContext(), std::chrono::seconds{5});
|
||||
});
|
||||
EXPECT_TRUE(childCoroutine.isCancelled());
|
||||
EXPECT_LT(duration, 1000);
|
||||
});
|
||||
coroutine.cancelAll();
|
||||
EXPECT_TRUE(coroutine.isCancelled());
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, CancelAllCancelsParent)
|
||||
{
|
||||
runCoroutine([&](Coroutine& coroutine) {
|
||||
coroutine.spawnChild([](Coroutine& childCoroutine) {
|
||||
childCoroutine.yield();
|
||||
childCoroutine.cancelAll();
|
||||
EXPECT_TRUE(childCoroutine.isCancelled());
|
||||
});
|
||||
|
||||
auto const duration =
|
||||
util::timed([&coroutine]() { asyncOperation(coroutine.yieldContext(), std::chrono::seconds{5}); });
|
||||
EXPECT_TRUE(coroutine.isCancelled());
|
||||
EXPECT_LT(duration, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, CancelAllCalledMultipleTimes)
|
||||
{
|
||||
runCoroutine([&](Coroutine& coroutine) {
|
||||
coroutine.spawnChild([](Coroutine& childCoroutine) {
|
||||
childCoroutine.yield();
|
||||
for ([[maybe_unused]] auto const i : std::ranges::iota_view(0, 10)) {
|
||||
childCoroutine.cancelAll();
|
||||
}
|
||||
EXPECT_TRUE(childCoroutine.isCancelled());
|
||||
});
|
||||
|
||||
auto const duration =
|
||||
util::timed([&coroutine]() { asyncOperation(coroutine.yieldContext(), std::chrono::seconds{5}); });
|
||||
EXPECT_TRUE(coroutine.isCancelled());
|
||||
EXPECT_LT(duration, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, CancelAllCancelsSiblingsAndParent)
|
||||
{
|
||||
EXPECT_CALL(fnMock_, Call).Times(2);
|
||||
runCoroutine([&](Coroutine& parentCoroutine) {
|
||||
parentCoroutine.spawnChild([&](Coroutine& child1Coroutine) {
|
||||
auto duration = util::timed([&child1Coroutine]() {
|
||||
asyncOperation(child1Coroutine.yieldContext(), std::chrono::seconds(5));
|
||||
});
|
||||
EXPECT_TRUE(child1Coroutine.isCancelled());
|
||||
EXPECT_LT(duration, 2000);
|
||||
EXPECT_EQ(child1Coroutine.error(), boost::asio::error::operation_aborted);
|
||||
fnMock_.Call(child1Coroutine);
|
||||
});
|
||||
parentCoroutine.spawnChild([&](Coroutine& child2Coroutine) {
|
||||
child2Coroutine.yield();
|
||||
child2Coroutine.cancelAll();
|
||||
fnMock_.Call(child2Coroutine);
|
||||
});
|
||||
|
||||
auto parentDuration = util::timed([&parentCoroutine]() {
|
||||
asyncOperation(parentCoroutine.yieldContext(), std::chrono::seconds(5));
|
||||
});
|
||||
EXPECT_TRUE(parentCoroutine.isCancelled());
|
||||
EXPECT_LT(parentDuration, 2000);
|
||||
EXPECT_EQ(parentCoroutine.error(), boost::asio::error::operation_aborted);
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(CoroutineTest, Yield)
|
||||
{
|
||||
testing::StrictMock<testing::MockFunction<void()>> anotherFnMock;
|
||||
testing::Sequence sequence;
|
||||
EXPECT_CALL(fnMock_, Call).InSequence(sequence);
|
||||
EXPECT_CALL(anotherFnMock, Call).InSequence(sequence);
|
||||
|
||||
runCoroutine([&](Coroutine& coroutine) {
|
||||
coroutine.spawnChild([&anotherFnMock](Coroutine& childCoroutine) {
|
||||
childCoroutine.yield();
|
||||
anotherFnMock.Call();
|
||||
});
|
||||
fnMock_.Call(coroutine);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user