src/internal/input_binding_lifecycle.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_INTERNAL_INPUT_BINDING_LIFECYCLE_HPP | ||
| 2 | #define DETOURMODKIT_INTERNAL_INPUT_BINDING_LIFECYCLE_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file input_binding_lifecycle.hpp | ||
| 6 | * @brief Per-registration callback admission state shared by an input binding's engine entries and teardown gate. | ||
| 7 | * @details The control block is allocated with the registration and keeps reshape-time retirement allocation-free. | ||
| 8 | * Staged callbacks use its generation and in-flight counters to synchronize with remove, clear, and rebind. | ||
| 9 | * Not installed. | ||
| 10 | */ | ||
| 11 | |||
| 12 | #include "drain_backoff.hpp" | ||
| 13 | |||
| 14 | #include <array> | ||
| 15 | #include <atomic> | ||
| 16 | #include <chrono> | ||
| 17 | #include <cstddef> | ||
| 18 | #include <cstdint> | ||
| 19 | #include <memory> | ||
| 20 | #include <thread> | ||
| 21 | #include <utility> | ||
| 22 | |||
| 23 | namespace DetourModKit::detail | ||
| 24 | { | ||
| 25 | namespace input_callback_lifecycle | ||
| 26 | { | ||
| 27 | inline constexpr std::uint32_t ADMISSION_OPEN = 1U; | ||
| 28 | inline constexpr std::uint32_t DRAIN_PENDING = 1U << 1U; | ||
| 29 | |||
| 30 | inline std::atomic<std::uint32_t> s_state{ADMISSION_OPEN}; | ||
| 31 | inline std::atomic<std::uint32_t> s_staged_count{0}; | ||
| 32 | } // namespace input_callback_lifecycle | ||
| 33 | |||
| 34 | /// Returns whether process-wide staging admission is open. | ||
| 35 | 3921 | [[nodiscard]] inline bool input_callback_admission_open() noexcept | |
| 36 | { | ||
| 37 | 3922 | return (input_callback_lifecycle::s_state.load(std::memory_order_seq_cst) & | |
| 38 | 3922 | input_callback_lifecycle::ADMISSION_OPEN) != 0; | |
| 39 | } | ||
| 40 | |||
| 41 | /** | ||
| 42 | * @brief Opens process-wide admission for staged input callback storage unless a drain remains unresolved. | ||
| 43 | * @return true when admission is open; false when a pending drain kept it closed. | ||
| 44 | */ | ||
| 45 | 359 | [[nodiscard]] inline bool open_input_callback_admission() noexcept | |
| 46 | { | ||
| 47 | 359 | std::uint32_t state = input_callback_lifecycle::s_state.load(std::memory_order_seq_cst); | |
| 48 |
1/2✓ Branch 22 → 10 taken 359 times.
✗ Branch 22 → 23 not taken.
|
359 | while ((state & input_callback_lifecycle::DRAIN_PENDING) == 0) |
| 49 | { | ||
| 50 |
2/2✓ Branch 10 → 11 taken 228 times.
✓ Branch 10 → 12 taken 131 times.
|
359 | if ((state & input_callback_lifecycle::ADMISSION_OPEN) != 0) |
| 51 | { | ||
| 52 | 228 | return true; | |
| 53 | } | ||
| 54 | 131 | const std::uint32_t desired = state | input_callback_lifecycle::ADMISSION_OPEN; | |
| 55 |
1/2✓ Branch 20 → 21 taken 131 times.
✗ Branch 20 → 22 not taken.
|
131 | if (input_callback_lifecycle::s_state.compare_exchange_weak(state, desired, std::memory_order_seq_cst)) |
| 56 | { | ||
| 57 | 131 | return true; | |
| 58 | } | ||
| 59 | } | ||
| 60 | ✗ | return false; | |
| 61 | } | ||
| 62 | |||
| 63 | /// Closes process-wide admission for staged input callback storage. | ||
| 64 | ✗ | inline void close_input_callback_admission() noexcept | |
| 65 | { | ||
| 66 | input_callback_lifecycle::s_state.fetch_and( | ||
| 67 | ~input_callback_lifecycle::ADMISSION_OPEN, | ||
| 68 | std::memory_order_seq_cst | ||
| 69 | ); | ||
| 70 | ✗ | } | |
| 71 | |||
| 72 | /// Marks input callback rundown as unresolved and atomically closes staging admission. | ||
| 73 | 150 | inline void mark_input_callback_drain_pending() noexcept | |
| 74 | { | ||
| 75 | 150 | std::uint32_t state = input_callback_lifecycle::s_state.load(std::memory_order_seq_cst); | |
| 76 | for (;;) | ||
| 77 | { | ||
| 78 | 150 | const std::uint32_t desired = | |
| 79 | 150 | (state | input_callback_lifecycle::DRAIN_PENDING) & ~input_callback_lifecycle::ADMISSION_OPEN; | |
| 80 |
1/2✓ Branch 18 → 19 taken 150 times.
✗ Branch 18 → 20 not taken.
|
150 | if (input_callback_lifecycle::s_state.compare_exchange_weak(state, desired, std::memory_order_seq_cst)) |
| 81 | { | ||
| 82 | 150 | return; | |
| 83 | } | ||
| 84 | ✗ | } | |
| 85 | } | ||
| 86 | |||
| 87 | /// Marks the current input callback rundown as complete. | ||
| 88 | 156 | inline void resolve_input_callback_drain() noexcept | |
| 89 | { | ||
| 90 | input_callback_lifecycle::s_state.fetch_and( | ||
| 91 | ~input_callback_lifecycle::DRAIN_PENDING, | ||
| 92 | std::memory_order_seq_cst | ||
| 93 | ); | ||
| 94 | 156 | } | |
| 95 | |||
| 96 | /// Returns whether a failed or active callback rundown still needs completion. | ||
| 97 | 3619 | [[nodiscard]] inline bool input_callback_drain_pending() noexcept | |
| 98 | { | ||
| 99 | 3617 | return (input_callback_lifecycle::s_state.load(std::memory_order_seq_cst) & | |
| 100 | 3617 | input_callback_lifecycle::DRAIN_PENDING) != 0; | |
| 101 | } | ||
| 102 | |||
| 103 | /// Returns the number of staged callback records whose callable storage is still alive. | ||
| 104 | 362 | [[nodiscard]] inline std::uint32_t staged_input_callback_count() noexcept | |
| 105 | { | ||
| 106 | 362 | return input_callback_lifecycle::s_staged_count.load(std::memory_order_seq_cst); | |
| 107 | } | ||
| 108 | |||
| 109 | /** | ||
| 110 | * @brief Converts a caller timeout into an absolute rundown deadline, saturating instead of overflowing. | ||
| 111 | * @details One owner for the clamp so the input and config halves of an unload transaction cannot drift apart on | ||
| 112 | * it. A non-positive timeout yields "now" (poll once, never wait), and a timeout that would run past the | ||
| 113 | * clock's range yields time_point::max() rather than wrapping into an already-expired deadline. | ||
| 114 | */ | ||
| 115 | [[nodiscard]] inline std::chrono::steady_clock::time_point | ||
| 116 | 280 | drain_deadline(std::chrono::milliseconds timeout) noexcept | |
| 117 | { | ||
| 118 | 280 | const auto now = std::chrono::steady_clock::now(); | |
| 119 |
1/2✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 280 times.
|
280 | if (timeout <= std::chrono::milliseconds{0}) |
| 120 | { | ||
| 121 | ✗ | return now; | |
| 122 | } | ||
| 123 | const auto remaining = | ||
| 124 | 280 | std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::time_point::max() - now); | |
| 125 |
1/2✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 280 times.
|
280 | if (timeout >= remaining) |
| 126 | { | ||
| 127 | ✗ | return std::chrono::steady_clock::time_point::max(); | |
| 128 | } | ||
| 129 | 280 | return now + std::chrono::duration_cast<std::chrono::steady_clock::duration>(timeout); | |
| 130 | } | ||
| 131 | |||
| 132 | /** | ||
| 133 | * @brief Waits until every staged input callback record has been destroyed or @p deadline is reached. | ||
| 134 | * @return true when no staged record remains; false on timeout. | ||
| 135 | */ | ||
| 136 | 141 | [[nodiscard]] inline bool await_staged_input_callbacks(std::chrono::steady_clock::time_point deadline) noexcept | |
| 137 | { | ||
| 138 | 499 | return drain_until_zero([]() noexcept { return staged_input_callback_count(); }, deadline); | |
| 139 | } | ||
| 140 | |||
| 141 | /** | ||
| 142 | * @brief Generation, tombstone, and in-flight counts for one input registration. | ||
| 143 | * @details Invocation admission uses increment-then-recheck with sequentially consistent atomics. A reshape first | ||
| 144 | * advances the generation or publishes the one-way tombstone, then drains in-flight callbacks. An advanced | ||
| 145 | * (surviving) registration drains only the retired parity slot so it never waits on live new-generation | ||
| 146 | * work; a tombstone drains BOTH slots, because it admits nothing further and a caller relies on the drain | ||
| 147 | * to see out an admit-across release edge that a prior advance may have left in the other parity slot. | ||
| 148 | * Presses and held(true) edges are refused across any advance, so only a benign, gate-serialized release | ||
| 149 | * edge can ever straddle two generations. | ||
| 150 | */ | ||
| 151 | class BindingLifecycle | ||
| 152 | { | ||
| 153 | public: | ||
| 154 | 1723 | explicit BindingLifecycle(std::uint64_t initial_generation) noexcept : m_generation(initial_generation) {} | |
| 155 | |||
| 156 | /// Returns the generation to carry with a staged callback. | ||
| 157 | 7604 | [[nodiscard]] std::uint64_t generation() const noexcept { return m_generation.load(std::memory_order_acquire); } | |
| 158 | |||
| 159 | /// Returns whether removal permanently retired this registration. | ||
| 160 | 5811 | [[nodiscard]] bool tombstoned() const noexcept { return m_tombstoned.load(std::memory_order_acquire); } | |
| 161 | |||
| 162 | /** | ||
| 163 | * @brief Advances to the next generation and returns the retired generation. | ||
| 164 | * @note Serialized by the poller's binding writer lock. | ||
| 165 | */ | ||
| 166 | 2011 | [[nodiscard]] std::uint64_t advance_generation() noexcept | |
| 167 | { | ||
| 168 | 4022 | return m_generation.fetch_add(1, std::memory_order_seq_cst); | |
| 169 | } | ||
| 170 | |||
| 171 | /** | ||
| 172 | * @brief Permanently retires this registration and returns the generation to drain. | ||
| 173 | * @note Serialized by the poller's binding writer lock. | ||
| 174 | */ | ||
| 175 | 531 | [[nodiscard]] std::uint64_t tombstone() noexcept | |
| 176 | { | ||
| 177 | 531 | const std::uint64_t retired_generation = m_generation.load(std::memory_order_seq_cst); | |
| 178 | 531 | m_tombstoned.store(true, std::memory_order_seq_cst); | |
| 179 | 531 | return retired_generation; | |
| 180 | } | ||
| 181 | |||
| 182 | /** | ||
| 183 | * @brief Attempts to admit a callback staged from @p expected_generation. | ||
| 184 | * @param admit_across_generation When true, admit even if the generation advanced since staging, provided the | ||
| 185 | * registration was not tombstoned. Set only for a terminal hold-release (false) edge: it can only end a | ||
| 186 | * held state and never fires a stale activation, so an in-place rebind that merely advanced the | ||
| 187 | * generation must still deliver it, or the gate's held count desyncs from the poller and the consumer | ||
| 188 | * is stranded holding a released binding. A tombstone (remove / clear) still refuses it, because that | ||
| 189 | * path publishes its own balancing false and must not race a post-return delivery against state the | ||
| 190 | * caller is destroying. | ||
| 191 | * @return true when the callback is counted and may begin; false when its registration was reshaped. | ||
| 192 | */ | ||
| 193 | 468 | [[nodiscard]] bool try_enter(std::uint64_t expected_generation, bool admit_across_generation) noexcept | |
| 194 | { | ||
| 195 |
4/4✓ Branch 3 → 4 taken 463 times.
✓ Branch 3 → 13 taken 5 times.
✓ Branch 15 → 16 taken 6 times.
✓ Branch 15 → 17 taken 462 times.
|
931 | if (m_tombstoned.load(std::memory_order_acquire) || |
| 196 |
4/4✓ Branch 4 → 5 taken 462 times.
✓ Branch 4 → 14 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 461 times.
|
925 | (!admit_across_generation && m_generation.load(std::memory_order_acquire) != expected_generation)) |
| 197 | { | ||
| 198 | 6 | return false; | |
| 199 | } | ||
| 200 | |||
| 201 | 462 | auto &counter = m_in_flight[slot(expected_generation)]; | |
| 202 | 462 | counter.fetch_add(1, std::memory_order_seq_cst); | |
| 203 |
2/4✓ Branch 22 → 23 taken 462 times.
✗ Branch 22 → 32 not taken.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 38 taken 462 times.
|
924 | if (m_tombstoned.load(std::memory_order_seq_cst) || |
| 204 |
3/4✓ Branch 23 → 24 taken 461 times.
✓ Branch 23 → 33 taken 1 time.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 461 times.
|
923 | (!admit_across_generation && m_generation.load(std::memory_order_seq_cst) != expected_generation)) |
| 205 | { | ||
| 206 | ✗ | counter.fetch_sub(1, std::memory_order_seq_cst); | |
| 207 | ✗ | return false; | |
| 208 | } | ||
| 209 | 462 | return true; | |
| 210 | } | ||
| 211 | |||
| 212 | /// Releases one callback admitted for @p entered_generation. | ||
| 213 | 462 | void leave(std::uint64_t entered_generation) noexcept | |
| 214 | { | ||
| 215 | 462 | m_in_flight[slot(entered_generation)].fetch_sub(1, std::memory_order_seq_cst); | |
| 216 | 462 | } | |
| 217 | |||
| 218 | /// Returns callbacks still running from @p retired_generation. | ||
| 219 | 2076 | [[nodiscard]] std::uint32_t in_flight(std::uint64_t retired_generation) const noexcept | |
| 220 | { | ||
| 221 | 4152 | return m_in_flight[slot(retired_generation)].load(std::memory_order_seq_cst); | |
| 222 | } | ||
| 223 | |||
| 224 | /** | ||
| 225 | * @brief Returns callbacks still running from either generation slot. | ||
| 226 | * @details A tombstone drains on this so an admit-across release edge left in a prior advance's parity slot | ||
| 227 | * cannot outlive the reshape that retired the binding. | ||
| 228 | */ | ||
| 229 | 416 | [[nodiscard]] std::uint32_t in_flight_total() const noexcept | |
| 230 | { | ||
| 231 | 1248 | return m_in_flight[0].load(std::memory_order_seq_cst) + m_in_flight[1].load(std::memory_order_seq_cst); | |
| 232 | } | ||
| 233 | |||
| 234 | private: | ||
| 235 | 3000 | [[nodiscard]] static constexpr std::size_t slot(std::uint64_t generation) noexcept | |
| 236 | { | ||
| 237 | 3000 | return static_cast<std::size_t>(generation & 1U); | |
| 238 | } | ||
| 239 | |||
| 240 | std::atomic<std::uint64_t> m_generation; | ||
| 241 | std::atomic<bool> m_tombstoned{false}; | ||
| 242 | std::array<std::atomic<std::uint32_t>, 2> m_in_flight{}; | ||
| 243 | }; | ||
| 244 | |||
| 245 | /** | ||
| 246 | * @brief RAII lease spanning a staged callback's callable copies, dispatch, and destruction. | ||
| 247 | * @details Counts process-wide staged callable storage so an unload drain can wait, to its own deadline, until | ||
| 248 | * every copied callable and capture manager is gone. The lease must be declared before callable-storage | ||
| 249 | * members so reverse member destruction releases it only after that storage has been destroyed. | ||
| 250 | * | ||
| 251 | * It deliberately does NOT hold a BindingLifecycle in-flight slot. That slot is the per-registration | ||
| 252 | * reshape rundown, which waits with no deadline; pinning it from staging until the whole poll cycle's | ||
| 253 | * staged storage is destroyed would make one binding's reshape or removal block on every other binding | ||
| 254 | * dispatched in the same cycle, and a control thread holding a lock one of those callbacks wants would | ||
| 255 | * deadlock. BindingInvocation scopes that slot to the callback body instead. | ||
| 256 | */ | ||
| 257 | class StagedCallbackLease | ||
| 258 | { | ||
| 259 | public: | ||
| 260 | 472 | StagedCallbackLease(std::shared_ptr<BindingLifecycle> lifecycle, std::uint64_t staged_generation) noexcept | |
| 261 | 944 | : m_lifecycle(std::move(lifecycle)), m_generation(staged_generation) | |
| 262 | { | ||
| 263 |
1/2✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 472 times.
|
472 | if (!input_callback_admission_open()) |
| 264 | { | ||
| 265 | ✗ | return; | |
| 266 | } | ||
| 267 | |||
| 268 | input_callback_lifecycle::s_staged_count.fetch_add(1, std::memory_order_seq_cst); | ||
| 269 |
1/2✗ Branch 11 → 12 not taken.
✓ Branch 11 → 15 taken 472 times.
|
472 | if (!input_callback_admission_open()) |
| 270 | { | ||
| 271 | input_callback_lifecycle::s_staged_count.fetch_sub(1, std::memory_order_seq_cst); | ||
| 272 | ✗ | return; | |
| 273 | } | ||
| 274 | 472 | m_engaged = true; | |
| 275 | } | ||
| 276 | |||
| 277 | 1410 | ~StagedCallbackLease() noexcept | |
| 278 | { | ||
| 279 |
2/2✓ Branch 2 → 3 taken 472 times.
✓ Branch 2 → 6 taken 938 times.
|
1410 | if (m_engaged) |
| 280 | { | ||
| 281 | input_callback_lifecycle::s_staged_count.fetch_sub(1, std::memory_order_seq_cst); | ||
| 282 | } | ||
| 283 | 1410 | } | |
| 284 | |||
| 285 | StagedCallbackLease(const StagedCallbackLease &) = delete; | ||
| 286 | StagedCallbackLease &operator=(const StagedCallbackLease &) = delete; | ||
| 287 | StagedCallbackLease &operator=(StagedCallbackLease &&) = delete; | ||
| 288 | |||
| 289 | // Move construction is what lets a lease reach the staged record it guards, both into the record and through a | ||
| 290 | // vector reallocation of the poll cycle's staged storage. | ||
| 291 | 938 | StagedCallbackLease(StagedCallbackLease &&other) noexcept | |
| 292 | 1876 | : m_lifecycle(std::move(other.m_lifecycle)), m_generation(other.m_generation), | |
| 293 | 938 | m_engaged(std::exchange(other.m_engaged, false)) | |
| 294 | { | ||
| 295 | 938 | } | |
| 296 | |||
| 297 | /// Returns whether the callback was admitted into staged storage. | ||
| 298 | 472 | [[nodiscard]] bool engaged() const noexcept { return m_engaged; } | |
| 299 | |||
| 300 | /// The registration this callback was staged from, for the dispatch-time BindingInvocation. | ||
| 301 | 468 | [[nodiscard]] BindingLifecycle *lifecycle() const noexcept { return m_lifecycle.get(); } | |
| 302 | |||
| 303 | /// The generation this callback was staged at. | ||
| 304 | 468 | [[nodiscard]] std::uint64_t generation() const noexcept { return m_generation; } | |
| 305 | |||
| 306 | private: | ||
| 307 | std::shared_ptr<BindingLifecycle> m_lifecycle; | ||
| 308 | std::uint64_t m_generation{0}; | ||
| 309 | bool m_engaged{false}; | ||
| 310 | }; | ||
| 311 | |||
| 312 | /** | ||
| 313 | * @brief RAII admission for one staged callback's dispatch. | ||
| 314 | * @details Holds the registration's in-flight slot for exactly the callback body, so a reshape or removal of this | ||
| 315 | * binding waits only on its own callback and never on an unrelated binding dispatched in the same cycle. | ||
| 316 | */ | ||
| 317 | class BindingInvocation | ||
| 318 | { | ||
| 319 | public: | ||
| 320 | 468 | BindingInvocation( | |
| 321 | BindingLifecycle *lifecycle, | ||
| 322 | std::uint64_t staged_generation, | ||
| 323 | bool admit_across_generation | ||
| 324 | ) noexcept | ||
| 325 | 468 | : m_lifecycle(lifecycle), m_generation(staged_generation), | |
| 326 |
3/4✓ Branch 2 → 3 taken 468 times.
✗ Branch 2 → 5 not taken.
✓ Branch 4 → 5 taken 462 times.
✓ Branch 4 → 6 taken 6 times.
|
468 | m_admitted(lifecycle == nullptr || lifecycle->try_enter(staged_generation, admit_across_generation)) |
| 327 | { | ||
| 328 | 468 | } | |
| 329 | |||
| 330 | 468 | ~BindingInvocation() noexcept | |
| 331 | { | ||
| 332 |
3/4✓ Branch 2 → 3 taken 468 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 462 times.
✓ Branch 3 → 5 taken 6 times.
|
468 | if (m_lifecycle != nullptr && m_admitted) |
| 333 | { | ||
| 334 | 462 | m_lifecycle->leave(m_generation); | |
| 335 | } | ||
| 336 | 468 | } | |
| 337 | |||
| 338 | BindingInvocation(const BindingInvocation &) = delete; | ||
| 339 | BindingInvocation &operator=(const BindingInvocation &) = delete; | ||
| 340 | BindingInvocation(BindingInvocation &&) = delete; | ||
| 341 | BindingInvocation &operator=(BindingInvocation &&) = delete; | ||
| 342 | |||
| 343 | /// Returns whether this callback may begin. | ||
| 344 | 468 | [[nodiscard]] bool admitted() const noexcept { return m_admitted; } | |
| 345 | |||
| 346 | private: | ||
| 347 | BindingLifecycle *m_lifecycle; | ||
| 348 | std::uint64_t m_generation; | ||
| 349 | bool m_admitted; | ||
| 350 | }; | ||
| 351 | } // namespace DetourModKit::detail | ||
| 352 | |||
| 353 | #endif // DETOURMODKIT_INTERNAL_INPUT_BINDING_LIFECYCLE_HPP | ||
| 354 |