Skip to content

Commit 8c5fa96

Browse files
committed
New primitive - canary - for defense against opstate destruction in start()
1 parent 1e1d8cb commit 8c5fa96

3 files changed

Lines changed: 472 additions & 0 deletions

File tree

doc/api_reference.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
* [`at_coroutine_exit`](#at_coroutine_exit)
9797
* [Other](#other)
9898
* [`async_scope`](#async_scope)
99+
* [`canary`](#canary)
99100
* [`variant_sender`](#variant_sender)
100101

101102
# Receiver Queries
@@ -1728,6 +1729,42 @@ namespace unifex
17281729
}
17291730
```
17301731

1732+
### `canary`
1733+
1734+
Defends against premature destruction of an operation state during `start()`.
1735+
Solves the problem where an async operation launched from `start()` completes
1736+
early - synchronously or on another thread - before `start()` finishes
1737+
modifying the operation state.
1738+
1739+
Three objects, three roles:
1740+
1741+
* **`canary`** — lives in the operation state.
1742+
* **`canary::watcher`** — stack-local in `start()`, created via
1743+
`canary.watch()` before making calls that may complete the sender.
1744+
* **`canary::guard`** — returned by `watcher.alive()`. Truthy if the canary
1745+
is still alive; while the guard exists, the canary's destructor is blocked
1746+
by spinlock.
1747+
1748+
```c++
1749+
return [..., canary{unifex::canary{}}](auto event, auto* self) mutable {
1750+
if constexpr (event.is_start) {
1751+
auto watcher = canary.watch();
1752+
auto cancel_token = start_async_op(
1753+
[&receiver](Result r) {
1754+
set_value(std::move(receiver), r);
1755+
});
1756+
if (auto guard = watcher.alive()) {
1757+
saved_token = cancel_token; // safe: op state still exists
1758+
}
1759+
// guard released here — canary destructor unblocked
1760+
}
1761+
};
1762+
```
1763+
1764+
Only one watcher may be active on a canary at a time (asserted). Sequential
1765+
`watch()` / destroy cycles on the same canary are permitted. When no watcher
1766+
is attached, the canary's destructor is a no-op.
1767+
17311768
### `variant_sender`
17321769

17331770
Non-type erased sender that is parameterized on multiple sender types.

include/unifex/canary.hpp

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
#pragma once
17+
18+
#include <unifex/config.hpp>
19+
20+
#include <atomic>
21+
#include <cassert>
22+
#include <cstdint>
23+
#include <utility>
24+
25+
namespace unifex {
26+
namespace _canary {
27+
28+
// Two synchronization mechanisms:
29+
//
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.
38+
//
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]
44+
45+
class canary {
46+
static constexpr uint8_t _alive = 0;
47+
static constexpr uint8_t _guarded = 1;
48+
static constexpr uint8_t _dead = 2;
49+
static constexpr uint8_t _done = 3;
50+
51+
public:
52+
class watcher;
53+
54+
// Returned by watcher::alive(). Truthy if the canary is still alive.
55+
// While the guard exists, the canary's destructor is blocked.
56+
class guard {
57+
public:
58+
guard(guard&& other) noexcept
59+
: state_(std::exchange(other.state_, nullptr)) {}
60+
guard& operator=(guard&&) = delete;
61+
62+
explicit operator bool() const noexcept { return state_ != nullptr; }
63+
64+
~guard() noexcept {
65+
if (state_) {
66+
state_->store(_done, std::memory_order_release);
67+
}
68+
}
69+
70+
private:
71+
friend class watcher;
72+
explicit guard(std::atomic<uint8_t>* state) noexcept : state_(state) {}
73+
std::atomic<uint8_t>* state_;
74+
};
75+
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.
79+
class watcher {
80+
public:
81+
explicit watcher(canary& c) noexcept : canary_(&c) {
82+
c.watcher_.store(this, std::memory_order_release);
83+
}
84+
85+
watcher(watcher&&) = delete;
86+
watcher& operator=(watcher&&) = delete;
87+
88+
~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+
}
99+
}
100+
}
101+
}
102+
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.
107+
[[nodiscard]] guard alive() noexcept {
108+
uint8_t expected = _alive;
109+
if (state_.compare_exchange_strong(
110+
expected, _guarded, std::memory_order_acq_rel)) {
111+
return guard{&state_};
112+
}
113+
return guard{nullptr};
114+
}
115+
116+
private:
117+
friend class canary;
118+
std::atomic<canary*> canary_;
119+
std::atomic<uint8_t> state_{_alive};
120+
};
121+
122+
canary() noexcept = default;
123+
canary(canary&&) = delete;
124+
canary& operator=(canary&&) = delete;
125+
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).
129+
[[nodiscard]] watcher watch() noexcept {
130+
UNIFEX_ASSERT(watcher_.load(std::memory_order_relaxed) == nullptr);
131+
return watcher{*this};
132+
}
133+
134+
~canary() noexcept {
135+
auto* w = watcher_.load(std::memory_order_acquire);
136+
if (!w) {
137+
return;
138+
}
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.
143+
return;
144+
}
145+
// We own w. The watcher destructor will spin on canary_ until
146+
// we signal completion.
147+
auto old = w->state_.exchange(_dead, std::memory_order_acq_rel);
148+
if (old == _guarded) {
149+
// Guard is held — spin until it is released.
150+
while (w->state_.load(std::memory_order_acquire) != _done) {
151+
}
152+
}
153+
// Signal the watcher destructor that we're done with its members.
154+
w->canary_.store(nullptr, std::memory_order_release);
155+
}
156+
157+
private:
158+
friend class watcher;
159+
std::atomic<watcher*> watcher_{nullptr};
160+
};
161+
162+
} // namespace _canary
163+
164+
using _canary::canary;
165+
166+
} // namespace unifex

0 commit comments

Comments
 (0)