diff --git a/CMakeLists.txt b/CMakeLists.txt index 6701ca0..e4f01bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,11 +36,12 @@ libhal_add_tests(async_context cancel exclusive_access proxy - basics_dep_inject on_unblock simple_scheduler clock_adapter run_until_done + async_stacking + context_swapping MODULES tests/util.cppm diff --git a/modules/coroutine.cppm b/modules/coroutine.cppm index 2a52edf..d576e72 100644 --- a/modules/coroutine.cppm +++ b/modules/coroutine.cppm @@ -221,8 +221,13 @@ export struct bad_coroutine_alloc : std::bad_alloc * has been cancelled. It indicates that the operation was explicitly cancelled * before completion. */ -export class operation_cancelled : public std::exception +export struct operation_cancelled : public std::exception { + operation_cancelled(void const* p_future_address) + : future_address(p_future_address) + { + } + /** * @brief Get exception message * @@ -232,6 +237,8 @@ export class operation_cancelled : public std::exception { return "This future has been cancelled!"; } + + void const* future_address = nullptr; }; // ============================================================================= @@ -771,7 +778,8 @@ public: * destroyed before it is removed from this context. * * @param p_listener - the address of the unblock listener to be invoked when - * this context is unblocked. + * this context is unblocked. A nullptr may be passed to this parameter. It + * has the same effect as calling `clear_unblock_listener()`. */ void on_unblock(unblock_listener* p_listener) { @@ -1926,6 +1934,11 @@ public: } } + constexpr bool cancelled() + { + return std::holds_alternative(m_state); + } + constexpr void resume() const { if (std::holds_alternative(m_state)) { @@ -2004,30 +2017,34 @@ public: { future& m_operation; - constexpr explicit awaiter(future& p_operation - [[clang::lifetimebound]]) noexcept + constexpr explicit awaiter(future& p_operation) noexcept : m_operation(p_operation) { } [[nodiscard]] constexpr bool await_ready() const noexcept { - return m_operation.m_state.index() >= 1; + return not std::holds_alternative(m_operation.m_state); } + /** + * @brief Communicates to the awaiter to simply resume this coroutine + * associated with this future. + * + * @tparam U - return type of the promise + * @param p_calling_coroutine - this type is forgotten. The parent calling + * coroutine's handle was captured when the future object was created via + * get_return_object(). + * @return std::coroutine_handle<> - self to continue + */ template std::coroutine_handle<> await_suspend( - std::coroutine_handle> p_calling_coroutine) noexcept + [[maybe_unused]] std::coroutine_handle> + p_calling_coroutine) noexcept { // This will not throw because the discriminate check was performed in - // `await_ready()` via the done() function. `done()` checks if the state - // is `handle_type` and if it is, it returns false, causing the code to - // call await_suspend(). - auto handle = std::get(m_operation.m_state); - std::coroutine_handle>::from_address(handle.address()) - .promise() - .continuation(p_calling_coroutine); - return handle; + // `await_ready()`. + return std::get(m_operation.m_state); } [[nodiscard]] constexpr monostate_or& await_resume() const @@ -2039,9 +2056,21 @@ public: m_operation.m_state)) [[unlikely]] { std::rethrow_exception( std::get(m_operation.m_state)); + } else if (std::holds_alternative(m_operation.m_state)) + [[unlikely]] { + throw operation_cancelled{ &m_operation }; } - throw operation_cancelled{}; + // In the event that this coroutine awaiting this awaitable has + // requested the result of this awaitable before it has finished, then: + // + // - If contracts are enabled, then contract violation handler is called. + // - Otherwise, std::terminate is called. +#if defined(__cpp_contracts) + contract_assert(std::holds_alternative(m_operation.m_state)); +#else + std::terminate(); +#endif } constexpr void await_resume() const @@ -2054,9 +2083,21 @@ public: m_operation.m_state)) [[unlikely]] { std::rethrow_exception( std::get(m_operation.m_state)); + } else if (std::holds_alternative(m_operation.m_state)) + [[unlikely]] { + throw operation_cancelled{ &m_operation }; } - throw operation_cancelled{}; + // In the event that this coroutine awaiting this awaitable has + // requested the result of this awaitable before it has finished, then: + // + // - If contracts are enabled, then contract violation handler is called. + // - Otherwise, std::terminate is called. +#if defined(__cpp_contracts) + contract_assert(std::holds_alternative(m_operation.m_state)); +#else + std::terminate(); +#endif } }; @@ -2069,15 +2110,27 @@ public: * @return awaiter - An awaiter object that handles the suspension and * resumption of coroutines awaiting this future. * + * @pre The coroutine's context and the future's context are the same. * @note The awaiter will suspend the calling coroutine until this future * completes, then resume with either the result value or an exception. The * future will never be cancelled. */ - [[nodiscard]] constexpr awaiter operator co_await() noexcept + [[nodiscard]] constexpr awaiter operator co_await() && noexcept { return awaiter{ *this }; } + /** + * @brief co_awaiting an l-value future is banned + * + * Co-awaiting an l-value future provides a means to accidentally await on a + * future with a different context than the awaiting coroutine. This is unsafe + * and thus, banned. + * + * @return Nothing, deleted implementation + */ + [[nodiscard]] constexpr awaiter operator co_await() & noexcept = delete; + private: friend promise_type; @@ -2127,6 +2180,10 @@ constexpr future promise::get_return_object() noexcept { using future_handle = std::coroutine_handle>; auto handle = future_handle::from_promise(*this); + // Chain: whatever was active becomes our continuation. + // If nothing was active (noop_sentinel), this is a normal top-level + // coroutine. If something WAS active, we implicitly sit on top of it. + m_continuation = m_context->active_handle(); m_context->active_handle(handle); return future{ handle }; } diff --git a/tests/async_stacking.test.cpp b/tests/async_stacking.test.cpp new file mode 100644 index 0000000..914de03 --- /dev/null +++ b/tests/async_stacking.test.cpp @@ -0,0 +1,173 @@ +#include + +#include + +import async_context; +import test_utils; + +// doesn't matter... +// bar runs until completion then foo runs until complete +void async_stacking() +{ + using namespace boost::ut; + + // LIFO stacking: the last routine pushed onto the context is the first + // to execute. When two routines are loaded onto the same context, the + // second one loaded runs first and the first one loaded runs last. + + "two routines lifo order two steps each"_test = []() { + // Setup + async::inplace_context<1024> ctx; + + unsigned step = 0; + + auto routine_a = [&step](async::context&) -> async::future { + // routine_a is loaded first; it runs last (LIFO) + step = 3; + co_await std::suspend_always{}; + step = 4; + co_await std::suspend_always{}; + co_return; + }; + + auto routine_b = [&step](async::context&) -> async::future { + // routine_b is loaded second; it runs first (LIFO) + step = 1; + co_await std::suspend_always{}; + step = 2; + co_await std::suspend_always{}; + co_return; + }; + + // Exercise: load routine_a first, then routine_b + auto future_a = routine_a(ctx); + auto future_b = routine_b(ctx); + + // Verify: neither has started yet + expect(that % not future_a.done()); + expect(that % not future_b.done()); + expect(that % 0 == step); + + // Exercise: first resume — routine_b (last loaded) runs first, hits step=1 + // then suspends + future_b.resume(); + + // Verify: routine_b ran first and suspended at step 1 + expect(that % not future_b.done()); + expect(that % not future_a.done()); + expect(that % 1 == step); + + // Exercise: second resume — routine_b resumes, hits step=2, suspends again + future_b.resume(); + + // Verify: routine_b suspended at step 2; routine_a still waiting + expect(that % not future_b.done()); + expect(that % not future_a.done()); + expect(that % 2 == step); + + // Exercise: third resume — routine_b hits co_return and completes + future_b.resume(); + + // Verify: routine_b is done; routine_a still waiting + expect(that % future_b.done()); + expect(that % not future_a.done()); + expect(that % 3 == step); + + // Exercise: fourth resume — routine_a (first loaded) now runs, hits step=3, + // then suspends + future_a.resume(); + + // Verify: routine_a ran and suspended at step 3 + expect(that % not future_a.done()); + expect(that % 4 == step); + + // Exercise: fifth resume — routine_a resumes, hits step=4, suspends again + future_a.resume(); + + // Verify: routine_a is done; all memory released + expect(that % future_a.done()); + expect(that % 0 == ctx.memory_used()); + expect(that % 4 == step); + }; + + "three routines lifo order"_test = []() { + // Setup + async::inplace_context<2048> ctx; + + unsigned step = 0; + + auto routine_a = [&step](async::context&) -> async::future { + // loaded first — runs last + step = 7; + co_await std::suspend_always{}; + step = 8; + co_await std::suspend_always{}; + co_return; + }; + + auto routine_b = [&step](async::context&) -> async::future { + // loaded second — runs second + step = 4; + co_await std::suspend_always{}; + step = 5; + co_await std::suspend_always{}; + co_return; + }; + + auto routine_c = [&step](async::context&) -> async::future { + // loaded third — runs first (LIFO) + step = 1; + co_await std::suspend_always{}; + step = 2; + co_await std::suspend_always{}; + co_return; + }; + + // Load in order: a, b, c + auto future_a = routine_a(ctx); + auto future_b = routine_b(ctx); + auto future_c = routine_c(ctx); + + expect(that % 0 == step); + + // routine_c runs first + future_c.resume(); + expect(that % 1 == step); + expect(that % not future_c.done()); + + future_c.resume(); + expect(that % 2 == step); + expect(that % not future_c.done()); + + future_c.resume(); + expect(that % 4 == step); + expect(that % future_c.done()); + + // routine_b was already started by routine_c's co_return; resume to step=5 + future_b.resume(); + expect(that % 5 == step); + expect(that % not future_b.done()); + + // routine_b completes and immediately starts routine_a which runs to step=7 + future_b.resume(); + expect(that % 7 == step); + expect(that % future_b.done()); + expect(that % not future_a.done()); + + future_a.resume(); + expect(that % 8 == step); + expect(that % not future_a.done()); + + future_a.resume(); + expect(that % 8 == step); + expect(that % future_a.done()); + + // All memory should be released + expect(that % 0 == ctx.memory_used()); + }; +} + +int main() +{ + async_stacking(); +} diff --git a/tests/basics.test.cpp b/tests/basics.test.cpp index c555673..37e9167 100644 --- a/tests/basics.test.cpp +++ b/tests/basics.test.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include import async_context; import test_utils; @@ -164,9 +166,9 @@ void basics() }; auto co = [&step, &co2](async::context& p_ctx) -> async::future { step = 1; // skipped as the co2 will immediately start - [[maybe_unused]] auto val = co_await co2(p_ctx); + auto const val = co_await co2(p_ctx); step = 4; - co_return expected_return_value; + co_return val; }; // Exercise 1 @@ -198,7 +200,7 @@ void basics() expect(that % 4 == step); }; - "co_await coroutine"_test = []() { + "co_await coroutine sync"_test = []() { // Setup async::inplace_context<1024> ctx; diff --git a/tests/basics_dep_inject.test.cpp b/tests/basics_dep_inject.test.cpp deleted file mode 100644 index 36061f1..0000000 --- a/tests/basics_dep_inject.test.cpp +++ /dev/null @@ -1,134 +0,0 @@ -#include -#include - -#include -#include - -import async_context; - -void basics_dep_inject() -{ - using namespace boost::ut; - - "sync return type void"_test = []() { - // Setup - std::array stack{}; - async::context ctx{ stack }; - - unsigned step = 0; - auto sync_coroutine = [&step](async::context&) -> async::future { - step = 1; - return {}; - }; - - // Exercise - auto future = sync_coroutine(ctx); - - // Verify - expect(that % 0 == ctx.memory_used()); - expect(that % future.done()); - expect(that % future.has_value()); - expect(that % 1 == step); - }; - - "suspend then co_return"_test = []() { - // Setup - std::array stack{}; - async::context ctx{ stack }; - - static constexpr int expected_return_value = 1413; - unsigned step = 0; - auto async_coroutine = - [&step](async::context& p_ctx) -> async::future { - step = 1; - while (step != 2) { - // external set to 2 - co_await p_ctx.block_by_io(); - } - step = 2; - co_return expected_return_value; - }; - - // Exercise 1 - auto future = async_coroutine(ctx); - - // Verify 1 - expect(that % 0 < ctx.memory_used()); - expect(that % not future.done()); - expect(that % not future.has_value()); - expect(that % 0 == step); - - // Exercise 2: start and suspend coroutine - ctx.resume(); - - // Verify 2 - expect(that % 0 < ctx.memory_used()); - expect(that % not future.done()); - expect(that % not future.has_value()); - expect(that % 1 == step); - expect(ctx.state() == async::blocked_by::io); - - // Exercise 3: resume and co_return from coroutine - ctx.unblock(); - step = 2; - ctx.resume(); - - // Verify 3 - expect(that % 0 == ctx.memory_used()); - expect(that % future.done()); - expect(that % future.has_value()); - expect(that % expected_return_value == future.value()); - expect(that % 2 == step); - }; - - "Call handler"_test = []() { - // Setup - std::array stack{}; - async::context ctx{ stack }; - - static constexpr int expected_return_value = 1413; - unsigned step = 0; - auto async_coroutine = - [&step](async::context& p_ctx) -> async::future { - step = 1; - co_await p_ctx.block_by_io(); - step = 2; - co_return expected_return_value; - }; - - // Exercise 1 - auto future = async_coroutine(ctx); - - // Verify 1 - expect(that % 0 < ctx.memory_used()); - expect(that % not future.done()); - expect(that % not future.has_value()); - expect(that % 0 == step); - - // Exercise 2: start and suspend coroutine - ctx.resume(); - - // Verify 2 - expect(that % 0 < ctx.memory_used()); - expect(async::blocked_by::io == ctx.state()); - // expect(that % async::block_by::io == *info); - expect(that % not future.done()); - expect(that % not future.has_value()); - expect(that % 1 == step); - - // Exercise 3: resume and co_return from coroutine - ctx.resume(); - - // Verify 3 - expect(that % 0 == ctx.memory_used()); - expect(that % future.done()); - expect(that % future.has_value()); - expect(that % expected_return_value == future.value()); - expect(that % 2 == step); - }; -}; - -int main() -{ - basics_dep_inject(); -} diff --git a/tests/context_swapping.test.cpp b/tests/context_swapping.test.cpp new file mode 100644 index 0000000..9c219b4 --- /dev/null +++ b/tests/context_swapping.test.cpp @@ -0,0 +1,88 @@ +#include + +#include + +import async_context; +import test_utils; + +void context_swapping() +{ + using namespace boost::ut; + + // Context swapping: one routine runs on an outer context and produces a + // future. A second routine on a separate context co_awaits that future using + // a locally nested context. Awaiting a future whose context was allocated + // inside a coroutine frame is a contract violation and will abort. + + "co_await future from another context"_test = []() { + // Setup + async::inplace_context<1024> ctx; + + unsigned step = 0; + + // routine_a produces a future that routine will await + auto routine_a = [&step](async::context& p_ctx) -> async::future { + step = 1; + co_await std::suspend_always{}; + step = 2; + co_await std::suspend_always{}; + step = 3; + co_return 42; + }; + + // routine captures routine_a and co_awaits it using a nested context + // allocated inside the coroutine frame — this is a contract violation + auto routine = [&step, &routine_a](async::context&) -> async::future { + step = 10; + co_await std::suspend_always{}; + + async::inplace_context<128> nested_ctx; + // This is a bug and a contract violation, this will either call the + // contract violator OR call std::terminate + auto result = co_await routine_a(nested_ctx); + // These should never be reached + step = 11; + co_return result; + }; + + // Exercise: start routine on the outer context + auto future = routine(ctx); + + // Verify: routine has not run yet + expect(that % not future.done()); + expect(that % 0 == step); + // ctx holds routine's frame + expect(that % 0 < ctx.memory_used()); + + // Start routine — it runs to step=10 then suspends at the first + // co_await suspend_always + future.resume(); + expect(that % 10 == step); + expect(that % not future.done()); + + // Resume routine — it allocates nested_ctx and starts routine_a, + // which runs to step=1 then suspends + future.resume(); + expect(that % 1 == step); + +// Resume routine — it hits co_await on a future whose context was +// allocated inside the coroutine frame; this is a contract violation +#if defined(__cpp_contracts) + // TODO(kammce): Add violation handler +#else + // NOTE: aborts() forks a child process, so a std::set_terminate handler + // set here cannot be observed from the parent — terminate_was_called would + // always be false regardless. The aborts() check is the only observable + // assertion available across the process boundary. + expect(aborts([&future]() { future.resume(); })); + expect(that % 1 == step); + expect(that % not future.done()); + // ctx holds routine's suspended frame while the nested context does work +#endif + }; +} + +int main() +{ + context_swapping(); +}