Skip to content

Commit c6e5af9

Browse files
committed
[fix] TOCTOU race between destructors of canary and watcher
1 parent 8c5fa96 commit c6e5af9

2 files changed

Lines changed: 243 additions & 44 deletions

File tree

examples/canary_stress.cpp

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* Licensed under the Apache License Version 2.0 with LLVM Exceptions
5+
* (the "License"); you may not use this file except in compliance with
6+
* the License. You may obtain a copy of the License at
7+
*
8+
* https://llvm.org/LICENSE.txt
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#if __cplusplus >= 201911L
18+
19+
# include <unifex/async_scope.hpp>
20+
# include <unifex/canary.hpp>
21+
# include <unifex/create_raw_sender.hpp>
22+
# include <unifex/on.hpp>
23+
# include <unifex/single_thread_context.hpp>
24+
# include <unifex/sync_wait.hpp>
25+
26+
# include <atomic>
27+
# include <cassert>
28+
# include <iostream>
29+
30+
using namespace unifex;
31+
32+
// Stress tests for unifex::canary with create_raw_sender.
33+
//
34+
// A sender is run on start_ctx (via on()). Inside start(), async
35+
// work is dispatched to completion_ctx which completes the receiver —
36+
// potentially destroying the op state before start() returns. The
37+
// canary detects this race.
38+
//
39+
// Two tests:
40+
// 1. Race test: alive() races against async completion.
41+
// Both "alive" and "dead" outcomes are valid.
42+
// 2. Guard test: guard is acquired before the async call,
43+
// blocking ~canary() until the guard is released.
44+
45+
static constexpr int iterations = 10000;
46+
47+
void stress_race() {
48+
single_thread_context start_ctx;
49+
single_thread_context completion_ctx;
50+
async_scope scope;
51+
52+
std::atomic<int> guard_alive{0};
53+
54+
for (int i = 0; i < iterations; ++i) {
55+
sync_wait(on(
56+
start_ctx.get_scheduler(), create_raw_sender<int>([&](auto&& receiver) {
57+
// The factory returns a plain callable (operator() = start).
58+
return [receiver = std::forward<decltype(receiver)>(receiver),
59+
&scope,
60+
&completion_ctx,
61+
&guard_alive,
62+
c = canary{}]() mutable noexcept {
63+
auto watcher = c.watch();
64+
65+
// Launch async completion on a different thread.
66+
scope.detached_spawn_call_on(
67+
completion_ctx.get_scheduler(),
68+
[&receiver]() noexcept { set_value(std::move(receiver), 42); });
69+
70+
// Race: did completion destroy us?
71+
// IMPORTANT: only access captures (which live in the
72+
// op state) inside the guard. When alive() returns
73+
// false, the op state is destroyed — touching any
74+
// capture would be UAF.
75+
if (auto guard = watcher.alive()) {
76+
guard_alive.fetch_add(1, std::memory_order_relaxed);
77+
}
78+
};
79+
})));
80+
}
81+
82+
sync_wait(scope.cleanup());
83+
84+
int alive = guard_alive.load(std::memory_order_relaxed);
85+
int dead = iterations - alive;
86+
std::cout << "stress_race: " << iterations << " iterations, " << alive
87+
<< " alive, " << dead << " dead\n";
88+
}
89+
90+
void stress_guard() {
91+
single_thread_context start_ctx;
92+
single_thread_context completion_ctx;
93+
async_scope scope;
94+
95+
std::atomic<int> post_guard_writes{0};
96+
97+
for (int i = 0; i < iterations; ++i) {
98+
sync_wait(on(
99+
start_ctx.get_scheduler(), create_raw_sender<int>([&](auto&& receiver) {
100+
return [receiver = std::forward<decltype(receiver)>(receiver),
101+
&scope,
102+
&completion_ctx,
103+
&post_guard_writes,
104+
c = canary{}]() mutable noexcept {
105+
auto watcher = c.watch();
106+
107+
// Acquire guard BEFORE launching async work.
108+
auto guard = watcher.alive();
109+
110+
scope.detached_spawn_call_on(
111+
completion_ctx.get_scheduler(),
112+
[&receiver]() noexcept { set_value(std::move(receiver), 42); });
113+
114+
// Guard blocks ~canary(). Safe to write.
115+
if (guard) {
116+
post_guard_writes.fetch_add(1, std::memory_order_relaxed);
117+
}
118+
};
119+
})));
120+
}
121+
122+
sync_wait(scope.cleanup());
123+
124+
int writes = post_guard_writes.load(std::memory_order_relaxed);
125+
assert(writes == iterations);
126+
std::cout << "stress_guard: " << iterations << " iterations, " << writes
127+
<< " guarded writes\n";
128+
}
129+
130+
int main() {
131+
stress_race();
132+
stress_guard();
133+
std::cout << "PASSED\n";
134+
}
135+
136+
#else
137+
138+
int main() {
139+
}
140+
141+
#endif

include/unifex/canary.hpp

Lines changed: 102 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -25,29 +25,45 @@
2525
namespace unifex {
2626
namespace _canary {
2727

28-
// Two synchronization mechanisms:
28+
// Destructor arbitration uses tagged pointers (LSB = locked) on
29+
// both canary::watcher_ and watcher::canary_. Each destructor
30+
// locks its own pointer first, then accesses the other object.
31+
// Locking prevents the other destructor from completing (its CAS
32+
// on our pointer fails), ensuring the other object stays alive
33+
// while we access it.
2934
//
30-
// 1. CAS on canary::watcher_ (ownership arbitration):
31-
// Both the canary destructor and watcher destructor try to CAS
32-
// watcher_ from &watcher to nullptr. Exactly one wins:
33-
// - Canary wins: it "owns" the watcher pointer and may access
34-
// watcher members. The watcher destructor spins on canary_
35-
// until the canary destructor signals completion.
36-
// - Watcher wins: it clears the pointer. The canary destructor
37-
// sees nullptr and does nothing.
35+
// Deadlock (both pointers locked) is resolved in favor of watcher:
36+
// canary unlocks watcher_ and spins until the watcher clears it.
3837
//
39-
// 2. Exchange on watcher::state_ (guard coordination):
40-
// alive(0) → guarded(1) by watcher::alive() [CAS]
41-
// alive(0) → dead(2) by canary::~canary() [exchange]
42-
// guarded(1) → dead(2) by canary::~canary() [exchange, then spinloop]
43-
// dead(2) → done(3) by guard::~guard() [store, unblocks spinloop]
38+
// Guard coordination uses watcher::state_ (unchanged):
39+
// alive(0) → guarded(1) by watcher::alive() [CAS]
40+
// alive(0) → dead(2) by canary::~canary() [exchange]
41+
// guarded(1) → dead(2) by canary::~canary() [exchange, then spinloop]
42+
// dead(2) → done(3) by guard::~guard() [store, unblocks spinloop]
4443

4544
class canary {
4645
static constexpr uint8_t _alive = 0;
4746
static constexpr uint8_t _guarded = 1;
4847
static constexpr uint8_t _dead = 2;
4948
static constexpr uint8_t _done = 3;
5049

50+
static constexpr uintptr_t _lock_bit = 1;
51+
52+
template <typename T>
53+
static T* _lock(T* p) noexcept {
54+
return reinterpret_cast<T*>(reinterpret_cast<uintptr_t>(p) | _lock_bit);
55+
}
56+
57+
template <typename T>
58+
static T* _unlock(T* p) noexcept {
59+
return reinterpret_cast<T*>(reinterpret_cast<uintptr_t>(p) & ~_lock_bit);
60+
}
61+
62+
template <typename T>
63+
static bool _is_locked(T* p) noexcept {
64+
return reinterpret_cast<uintptr_t>(p) & _lock_bit;
65+
}
66+
5167
public:
5268
class watcher;
5369

@@ -73,9 +89,6 @@ class canary {
7389
std::atomic<uint8_t>* state_;
7490
};
7591

76-
// Stack-local object that registers with the canary before a
77-
// potentially-destroying call. Call alive() after the call to
78-
// check whether the canary survived.
7992
class watcher {
8093
public:
8194
explicit watcher(canary& c) noexcept : canary_(&c) {
@@ -86,24 +99,41 @@ class canary {
8699
watcher& operator=(watcher&&) = delete;
87100

88101
~watcher() noexcept {
89-
if (auto* c = canary_.load(std::memory_order_acquire)) {
90-
watcher* expected = this;
91-
if (c->watcher_.compare_exchange_strong(
92-
expected, nullptr, std::memory_order_acq_rel)) {
93-
// We cleared the pointer. Canary destructor will see nullptr.
94-
} else {
95-
// Canary destructor won the CAS — it holds a reference to
96-
// us and will store nullptr to canary_ when done. Spin.
97-
while (canary_.load(std::memory_order_acquire) != nullptr) {
98-
}
102+
// Step 1: lock our own pointer (canary_).
103+
auto* c = canary_.load(std::memory_order_relaxed);
104+
if (!c) {
105+
return;
106+
}
107+
if (_is_locked(c) ||
108+
!canary_.compare_exchange_strong(
109+
c, _lock(c), std::memory_order_acq_rel)) {
110+
// Canary destructor locked or cleared canary_. It will
111+
// store nullptr when done. Spin.
112+
while (canary_.load(std::memory_order_acquire) != nullptr) {
113+
}
114+
return;
115+
}
116+
// canary_ is now locked. Canary destructor can't complete.
117+
118+
// Step 2: clear canary's watcher_ pointer.
119+
// Canary is alive (its destructor can't complete while our
120+
// canary_ is locked — its CAS on canary_ will fail).
121+
watcher* expected = this;
122+
while (!c->watcher_.compare_exchange_weak(
123+
expected, nullptr, std::memory_order_acq_rel)) {
124+
if (expected == nullptr) {
125+
break; // shouldn't happen, but handle gracefully
99126
}
127+
// watcher_ is locked (this|1) by canary destructor.
128+
// Canary will detect deadlock and unlock watcher_.
129+
// Spin-retry.
130+
expected = this;
100131
}
132+
133+
// Step 3: unlock and clear canary_.
134+
canary_.store(nullptr, std::memory_order_release);
101135
}
102136

103-
// If the canary is still alive, atomically transitions to guarded
104-
// state and returns a truthy guard that blocks the canary's
105-
// destructor. If the canary has been destroyed, returns a falsy
106-
// guard.
107137
[[nodiscard]] guard alive() noexcept {
108138
uint8_t expected = _alive;
109139
if (state_.compare_exchange_strong(
@@ -119,46 +149,74 @@ class canary {
119149
std::atomic<uint8_t> state_{_alive};
120150
};
121151

152+
static_assert(
153+
alignof(watcher) >= 2,
154+
"watcher must be at least 2-byte aligned for LSB tagging");
155+
122156
canary() noexcept = default;
123157
canary(canary&&) = delete;
124158
canary& operator=(canary&&) = delete;
125159

126-
// Creates a watcher registered with this canary. The watcher is
127-
// non-moveable and must be used as a stack-local variable.
128-
// Returns a prvalue (C++17 guaranteed copy elision).
129160
[[nodiscard]] watcher watch() noexcept {
130161
UNIFEX_ASSERT(watcher_.load(std::memory_order_relaxed) == nullptr);
131162
return watcher{*this};
132163
}
133164

134165
~canary() noexcept {
135-
auto* w = watcher_.load(std::memory_order_acquire);
166+
// Step 1: lock our own pointer (watcher_).
167+
auto* w = watcher_.load(std::memory_order_relaxed);
136168
if (!w) {
137169
return;
138170
}
139-
// Try to claim ownership of the watcher pointer.
140-
if (!watcher_.compare_exchange_strong(
141-
w, nullptr, std::memory_order_acq_rel)) {
142-
// Watcher destructor won — it cleared the pointer. Done.
171+
if (_is_locked(w) ||
172+
!watcher_.compare_exchange_strong(
173+
w, _lock(w), std::memory_order_acq_rel)) {
174+
// Watcher destructor locked or cleared watcher_. Done.
143175
return;
144176
}
145-
// We own w. The watcher destructor will spin on canary_ until
146-
// we signal completion.
177+
// watcher_ is now locked. Watcher destructor can't complete.
178+
179+
// Step 2: lock watcher's canary_ pointer.
180+
// Watcher is alive (its destructor can't complete while our
181+
// watcher_ is locked — its CAS on watcher_ will fail).
182+
canary* expected = this;
183+
if (!w->canary_.compare_exchange_strong(
184+
expected, _lock(this), std::memory_order_acq_rel)) {
185+
// canary_ is locked (this|1) by watcher destructor. Deadlock.
186+
// Canary yields: unlock watcher_ and let the watcher proceed.
187+
watcher_.store(w, std::memory_order_release); // unlock
188+
// Watcher will clear watcher_ to nullptr. Spin until done.
189+
while (watcher_.load(std::memory_order_acquire) != nullptr) {
190+
}
191+
return;
192+
}
193+
// Both pointers locked. We fully own the watcher.
194+
195+
// Step 3: guard coordination via state_.
147196
auto old = w->state_.exchange(_dead, std::memory_order_acq_rel);
148197
if (old == _guarded) {
149-
// Guard is held — spin until it is released.
150-
while (w->state_.load(std::memory_order_acquire) != _done) {
198+
// Guard is held — spin until released. The guard destructor
199+
// overwrites _dead with _done; we spin while state_ remains
200+
// _dead (the value we wrote) rather than waiting for a
201+
// specific successor value.
202+
while (w->state_.load(std::memory_order_acquire) == _dead) {
151203
}
152204
}
153-
// Signal the watcher destructor that we're done with its members.
205+
206+
// Step 4: signal completion and unlock.
154207
w->canary_.store(nullptr, std::memory_order_release);
208+
watcher_.store(nullptr, std::memory_order_release);
155209
}
156210

157211
private:
158212
friend class watcher;
159213
std::atomic<watcher*> watcher_{nullptr};
160214
};
161215

216+
static_assert(
217+
alignof(canary) >= 2,
218+
"canary must be at least 2-byte aligned for LSB tagging");
219+
162220
} // namespace _canary
163221

164222
using _canary::canary;

0 commit comments

Comments
 (0)