Skip to content

Commit 37ac9ca

Browse files
authored
🐛 Add support for operation stacking (#89)
Operation stacking is when you load a context with multiple coroutines. Before, this resulted in a memory leak, due to the active_handle being overwritten and lost. This also results in lifetime violation if the context stack, prior to the new coroutine being loaded, had objects with lifetimes that need to be destroyed. This change enables operation stacking so it is no longer erroneous behavior. Now the last coroutine loaded is the first one to be executed (LIFO) and up the chain until the first loaded coroutine is reached. ## Summary - Enables multiple coroutines to be loaded onto the same `context` in LIFO order — the last routine pushed runs first, allowing natural "stacking" of async work on a single context - Bans co-awaiting an l-value `future` (deleted `operator co_await() &`) to prevent accidentally awaiting a future from a different context; co-awaiting a future whose context was allocated inside a coroutine frame is now a contract violation (`contract_assert`) or `std::terminate` - Removes `[[clang::lifetimebound]]` from `awaiter` constructor - Simplify `await_suspend` to just return the stored handle and drop the explicit continuation chain. Continuation is now captured at `get_return_object()` time via `m_context->active_handle()` to enable operation stacking. - Exceptions now keep a `void*` for the offending future or context. ## Test plan - [x] `tests/async_stacking.test.cpp` — LIFO ordering with two and three routines on the same context - [x] `tests/context_swapping.test.cpp` — verifies that co-awaiting a future from a mismatched nested context triggers `std::terminate` (or contract violation when contracts are enabled) - [x] Existing `tests/basics.test.cpp` continues to pass with the updated `await_ready` logic - [x] All CI checks pass - [x] New/updated tests cover the changes - [x] Tested locally with `conan create .`
1 parent 0c8af22 commit 37ac9ca

6 files changed

Lines changed: 342 additions & 155 deletions

File tree

CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,12 @@ libhal_add_tests(async_context
3636
cancel
3737
exclusive_access
3838
proxy
39-
basics_dep_inject
4039
on_unblock
4140
simple_scheduler
4241
clock_adapter
4342
run_until_done
43+
async_stacking
44+
context_swapping
4445

4546
MODULES
4647
tests/util.cppm

modules/coroutine.cppm

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,13 @@ export struct bad_coroutine_alloc : std::bad_alloc
221221
* has been cancelled. It indicates that the operation was explicitly cancelled
222222
* before completion.
223223
*/
224-
export class operation_cancelled : public std::exception
224+
export struct operation_cancelled : public std::exception
225225
{
226+
operation_cancelled(void const* p_future_address)
227+
: future_address(p_future_address)
228+
{
229+
}
230+
226231
/**
227232
* @brief Get exception message
228233
*
@@ -232,6 +237,8 @@ export class operation_cancelled : public std::exception
232237
{
233238
return "This future has been cancelled!";
234239
}
240+
241+
void const* future_address = nullptr;
235242
};
236243

237244
// =============================================================================
@@ -771,7 +778,8 @@ public:
771778
* destroyed before it is removed from this context.
772779
*
773780
* @param p_listener - the address of the unblock listener to be invoked when
774-
* this context is unblocked.
781+
* this context is unblocked. A nullptr may be passed to this parameter. It
782+
* has the same effect as calling `clear_unblock_listener()`.
775783
*/
776784
void on_unblock(unblock_listener* p_listener)
777785
{
@@ -1926,6 +1934,11 @@ public:
19261934
}
19271935
}
19281936

1937+
constexpr bool cancelled()
1938+
{
1939+
return std::holds_alternative<cancelled_state>(m_state);
1940+
}
1941+
19291942
constexpr void resume() const
19301943
{
19311944
if (std::holds_alternative<handle_type>(m_state)) {
@@ -2004,30 +2017,34 @@ public:
20042017
{
20052018
future<T>& m_operation;
20062019

2007-
constexpr explicit awaiter(future<T>& p_operation
2008-
[[clang::lifetimebound]]) noexcept
2020+
constexpr explicit awaiter(future<T>& p_operation) noexcept
20092021
: m_operation(p_operation)
20102022
{
20112023
}
20122024

20132025
[[nodiscard]] constexpr bool await_ready() const noexcept
20142026
{
2015-
return m_operation.m_state.index() >= 1;
2027+
return not std::holds_alternative<handle_type>(m_operation.m_state);
20162028
}
20172029

2030+
/**
2031+
* @brief Communicates to the awaiter to simply resume this coroutine
2032+
* associated with this future.
2033+
*
2034+
* @tparam U - return type of the promise
2035+
* @param p_calling_coroutine - this type is forgotten. The parent calling
2036+
* coroutine's handle was captured when the future object was created via
2037+
* get_return_object().
2038+
* @return std::coroutine_handle<> - self to continue
2039+
*/
20182040
template<typename U>
20192041
std::coroutine_handle<> await_suspend(
2020-
std::coroutine_handle<promise<U>> p_calling_coroutine) noexcept
2042+
[[maybe_unused]] std::coroutine_handle<promise<U>>
2043+
p_calling_coroutine) noexcept
20212044
{
20222045
// This will not throw because the discriminate check was performed in
2023-
// `await_ready()` via the done() function. `done()` checks if the state
2024-
// is `handle_type` and if it is, it returns false, causing the code to
2025-
// call await_suspend().
2026-
auto handle = std::get<handle_type>(m_operation.m_state);
2027-
std::coroutine_handle<promise<U>>::from_address(handle.address())
2028-
.promise()
2029-
.continuation(p_calling_coroutine);
2030-
return handle;
2046+
// `await_ready()`.
2047+
return std::get<handle_type>(m_operation.m_state);
20312048
}
20322049

20332050
[[nodiscard]] constexpr monostate_or<T>& await_resume() const
@@ -2039,9 +2056,21 @@ public:
20392056
m_operation.m_state)) [[unlikely]] {
20402057
std::rethrow_exception(
20412058
std::get<std::exception_ptr>(m_operation.m_state));
2059+
} else if (std::holds_alternative<cancelled_state>(m_operation.m_state))
2060+
[[unlikely]] {
2061+
throw operation_cancelled{ &m_operation };
20422062
}
20432063

2044-
throw operation_cancelled{};
2064+
// In the event that this coroutine awaiting this awaitable has
2065+
// requested the result of this awaitable before it has finished, then:
2066+
//
2067+
// - If contracts are enabled, then contract violation handler is called.
2068+
// - Otherwise, std::terminate is called.
2069+
#if defined(__cpp_contracts)
2070+
contract_assert(std::holds_alternative<handle_type>(m_operation.m_state));
2071+
#else
2072+
std::terminate();
2073+
#endif
20452074
}
20462075

20472076
constexpr void await_resume() const
@@ -2054,9 +2083,21 @@ public:
20542083
m_operation.m_state)) [[unlikely]] {
20552084
std::rethrow_exception(
20562085
std::get<std::exception_ptr>(m_operation.m_state));
2086+
} else if (std::holds_alternative<cancelled_state>(m_operation.m_state))
2087+
[[unlikely]] {
2088+
throw operation_cancelled{ &m_operation };
20572089
}
20582090

2059-
throw operation_cancelled{};
2091+
// In the event that this coroutine awaiting this awaitable has
2092+
// requested the result of this awaitable before it has finished, then:
2093+
//
2094+
// - If contracts are enabled, then contract violation handler is called.
2095+
// - Otherwise, std::terminate is called.
2096+
#if defined(__cpp_contracts)
2097+
contract_assert(std::holds_alternative<handle_type>(m_operation.m_state));
2098+
#else
2099+
std::terminate();
2100+
#endif
20602101
}
20612102
};
20622103

@@ -2069,15 +2110,27 @@ public:
20692110
* @return awaiter - An awaiter object that handles the suspension and
20702111
* resumption of coroutines awaiting this future.
20712112
*
2113+
* @pre The coroutine's context and the future's context are the same.
20722114
* @note The awaiter will suspend the calling coroutine until this future
20732115
* completes, then resume with either the result value or an exception. The
20742116
* future will never be cancelled.
20752117
*/
2076-
[[nodiscard]] constexpr awaiter operator co_await() noexcept
2118+
[[nodiscard]] constexpr awaiter operator co_await() && noexcept
20772119
{
20782120
return awaiter{ *this };
20792121
}
20802122

2123+
/**
2124+
* @brief co_awaiting an l-value future is banned
2125+
*
2126+
* Co-awaiting an l-value future provides a means to accidentally await on a
2127+
* future with a different context than the awaiting coroutine. This is unsafe
2128+
* and thus, banned.
2129+
*
2130+
* @return Nothing, deleted implementation
2131+
*/
2132+
[[nodiscard]] constexpr awaiter operator co_await() & noexcept = delete;
2133+
20812134
private:
20822135
friend promise_type;
20832136

@@ -2127,6 +2180,10 @@ constexpr future<T> promise<T>::get_return_object() noexcept
21272180
{
21282181
using future_handle = std::coroutine_handle<promise<T>>;
21292182
auto handle = future_handle::from_promise(*this);
2183+
// Chain: whatever was active becomes our continuation.
2184+
// If nothing was active (noop_sentinel), this is a normal top-level
2185+
// coroutine. If something WAS active, we implicitly sit on top of it.
2186+
m_continuation = m_context->active_handle();
21302187
m_context->active_handle(handle);
21312188
return future<T>{ handle };
21322189
}

tests/async_stacking.test.cpp

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#include <coroutine>
2+
3+
#include <boost/ut.hpp>
4+
5+
import async_context;
6+
import test_utils;
7+
8+
// doesn't matter...
9+
// bar runs until completion then foo runs until complete
10+
void async_stacking()
11+
{
12+
using namespace boost::ut;
13+
14+
// LIFO stacking: the last routine pushed onto the context is the first
15+
// to execute. When two routines are loaded onto the same context, the
16+
// second one loaded runs first and the first one loaded runs last.
17+
18+
"two routines lifo order two steps each"_test = []() {
19+
// Setup
20+
async::inplace_context<1024> ctx;
21+
22+
unsigned step = 0;
23+
24+
auto routine_a = [&step](async::context&) -> async::future<void> {
25+
// routine_a is loaded first; it runs last (LIFO)
26+
step = 3;
27+
co_await std::suspend_always{};
28+
step = 4;
29+
co_await std::suspend_always{};
30+
co_return;
31+
};
32+
33+
auto routine_b = [&step](async::context&) -> async::future<void> {
34+
// routine_b is loaded second; it runs first (LIFO)
35+
step = 1;
36+
co_await std::suspend_always{};
37+
step = 2;
38+
co_await std::suspend_always{};
39+
co_return;
40+
};
41+
42+
// Exercise: load routine_a first, then routine_b
43+
auto future_a = routine_a(ctx);
44+
auto future_b = routine_b(ctx);
45+
46+
// Verify: neither has started yet
47+
expect(that % not future_a.done());
48+
expect(that % not future_b.done());
49+
expect(that % 0 == step);
50+
51+
// Exercise: first resume — routine_b (last loaded) runs first, hits step=1
52+
// then suspends
53+
future_b.resume();
54+
55+
// Verify: routine_b ran first and suspended at step 1
56+
expect(that % not future_b.done());
57+
expect(that % not future_a.done());
58+
expect(that % 1 == step);
59+
60+
// Exercise: second resume — routine_b resumes, hits step=2, suspends again
61+
future_b.resume();
62+
63+
// Verify: routine_b suspended at step 2; routine_a still waiting
64+
expect(that % not future_b.done());
65+
expect(that % not future_a.done());
66+
expect(that % 2 == step);
67+
68+
// Exercise: third resume — routine_b hits co_return and completes
69+
future_b.resume();
70+
71+
// Verify: routine_b is done; routine_a still waiting
72+
expect(that % future_b.done());
73+
expect(that % not future_a.done());
74+
expect(that % 3 == step);
75+
76+
// Exercise: fourth resume — routine_a (first loaded) now runs, hits step=3,
77+
// then suspends
78+
future_a.resume();
79+
80+
// Verify: routine_a ran and suspended at step 3
81+
expect(that % not future_a.done());
82+
expect(that % 4 == step);
83+
84+
// Exercise: fifth resume — routine_a resumes, hits step=4, suspends again
85+
future_a.resume();
86+
87+
// Verify: routine_a is done; all memory released
88+
expect(that % future_a.done());
89+
expect(that % 0 == ctx.memory_used());
90+
expect(that % 4 == step);
91+
};
92+
93+
"three routines lifo order"_test = []() {
94+
// Setup
95+
async::inplace_context<2048> ctx;
96+
97+
unsigned step = 0;
98+
99+
auto routine_a = [&step](async::context&) -> async::future<void> {
100+
// loaded first — runs last
101+
step = 7;
102+
co_await std::suspend_always{};
103+
step = 8;
104+
co_await std::suspend_always{};
105+
co_return;
106+
};
107+
108+
auto routine_b = [&step](async::context&) -> async::future<void> {
109+
// loaded second — runs second
110+
step = 4;
111+
co_await std::suspend_always{};
112+
step = 5;
113+
co_await std::suspend_always{};
114+
co_return;
115+
};
116+
117+
auto routine_c = [&step](async::context&) -> async::future<void> {
118+
// loaded third — runs first (LIFO)
119+
step = 1;
120+
co_await std::suspend_always{};
121+
step = 2;
122+
co_await std::suspend_always{};
123+
co_return;
124+
};
125+
126+
// Load in order: a, b, c
127+
auto future_a = routine_a(ctx);
128+
auto future_b = routine_b(ctx);
129+
auto future_c = routine_c(ctx);
130+
131+
expect(that % 0 == step);
132+
133+
// routine_c runs first
134+
future_c.resume();
135+
expect(that % 1 == step);
136+
expect(that % not future_c.done());
137+
138+
future_c.resume();
139+
expect(that % 2 == step);
140+
expect(that % not future_c.done());
141+
142+
future_c.resume();
143+
expect(that % 4 == step);
144+
expect(that % future_c.done());
145+
146+
// routine_b was already started by routine_c's co_return; resume to step=5
147+
future_b.resume();
148+
expect(that % 5 == step);
149+
expect(that % not future_b.done());
150+
151+
// routine_b completes and immediately starts routine_a which runs to step=7
152+
future_b.resume();
153+
expect(that % 7 == step);
154+
expect(that % future_b.done());
155+
expect(that % not future_a.done());
156+
157+
future_a.resume();
158+
expect(that % 8 == step);
159+
expect(that % not future_a.done());
160+
161+
future_a.resume();
162+
expect(that % 8 == step);
163+
expect(that % future_a.done());
164+
165+
// All memory should be released
166+
expect(that % 0 == ctx.memory_used());
167+
};
168+
}
169+
170+
int main()
171+
{
172+
async_stacking();
173+
}

tests/basics.test.cpp

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#include <coroutine>
22

33
#include <boost/ut.hpp>
4+
#include <print>
5+
#include <variant>
46

57
import async_context;
68
import test_utils;
@@ -164,9 +166,9 @@ void basics()
164166
};
165167
auto co = [&step, &co2](async::context& p_ctx) -> async::future<int> {
166168
step = 1; // skipped as the co2 will immediately start
167-
[[maybe_unused]] auto val = co_await co2(p_ctx);
169+
auto const val = co_await co2(p_ctx);
168170
step = 4;
169-
co_return expected_return_value;
171+
co_return val;
170172
};
171173

172174
// Exercise 1
@@ -198,7 +200,7 @@ void basics()
198200
expect(that % 4 == step);
199201
};
200202

201-
"co_await coroutine"_test = []() {
203+
"co_await coroutine sync"_test = []() {
202204
// Setup
203205
async::inplace_context<1024> ctx;
204206

0 commit comments

Comments
 (0)