fix: make the submit gate a single atomic
9c5ce5festablished "a node never sleeps with a wake outstanding" and implemented it as two independent atomics: queued_ for "a firing is in flight", wake_pending_ for "a wake arrived during one". Two variables cannot express that invariant, because the release side has to read and write both and a wake can land in between: producer (try_submit) worker (release_and_recheck) ------------------------ ---------------------------- CAS reads queued_ == true, fails queued_.store(false) wake_pending_.exchange(false) -> false wake_pending_.store(true) queued_ false, wake_pending_ true, nothing running and nothing scheduled — exactly the state the invariant forbids. This is not a memory-ordering subtlety; the interleaving holds under seq_cst. SubmitGate replaces both with one atomic over three states, so "idle" and "wake outstanding" are the same variable and no interleaving can produce both. A release that finds a recorded wake keeps the claim and hands it to the next firing, so the node is never momentarily idle while a submission for it is in flight. What this does not do is fix a reproducible hang. Every current call site follows release_and_recheck() with a level re-check — on_input_ready(), or outputs_have_space() on the parked path — which rediscovers the state a lost wake would have signalled. The bug is masked, and I could not write a node-level test that fails before and passes after; claiming otherwise would be dishonest. The masking is a property of the call sites, not the mechanism: any future early return that forgets its re-check reintroduces a silent hang, and the pipeline has already been round that loop twice (28e0667, then9c5ce5f, each of which moved the stall rather than removing it). So the tests are structural. The state machine is pinned by contract tests, and the defect it replaces is pinned by demonstration: LegacyGate in the test file is the old protocol with a seam between the failed CAS and the wake record, which makes the loss deterministic rather than something to wait for. It also keeps the defect on record now that the code implementing it is gone. Also ignores build-*/ so a sanitizer build tree cannot be committed by accident, which this commit did on its first attempt.
This commit is contained in:
@@ -28,3 +28,5 @@ Thumbs.db
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
include/kpn/ort_cache/
|
||||
build-tsan/
|
||||
build-*/
|
||||
|
||||
+51
-58
@@ -5,6 +5,7 @@
|
||||
#include "inode.hpp"
|
||||
#include "port.hpp"
|
||||
#include "scheduler.hpp"
|
||||
#include "submit_gate.hpp"
|
||||
#include "traits.hpp"
|
||||
|
||||
#include <array>
|
||||
@@ -31,7 +32,7 @@ namespace kpn {
|
||||
// Reactive alternative to Node<>. Instead of owning a blocked thread, the node
|
||||
// is submitted to a shared IScheduler whenever all its input channels become
|
||||
// non-empty. A single fire_once() call pops all inputs, executes the function,
|
||||
// and pushes outputs. At most one fire_once() runs at a time (queued_ flag).
|
||||
// and pushes outputs. At most one fire_once() runs at a time (see SubmitGate).
|
||||
//
|
||||
// Source nodes (input_count == 0) submit themselves immediately on start() and
|
||||
// resubmit after each fire_once().
|
||||
@@ -83,7 +84,7 @@ public:
|
||||
void start() override {
|
||||
enable_inputs(std::make_index_sequence<input_count>{});
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_relaxed);
|
||||
gate_.force_idle();
|
||||
register_callbacks(std::make_index_sequence<input_count>{});
|
||||
if constexpr (input_count == 0)
|
||||
try_submit(0.5f);
|
||||
@@ -145,8 +146,8 @@ public:
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||
qwait_ms,
|
||||
stats_.total_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
queued_.load(std::memory_order_relaxed),
|
||||
wake_pending_.load(std::memory_order_relaxed),
|
||||
gate_.queued(),
|
||||
gate_.wake_pending(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,7 +259,7 @@ private:
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
// Plain store, not release_and_recheck(): this node is stopping, and
|
||||
// honouring a pending wake here would resubmit a dead node.
|
||||
queued_.store(false, std::memory_order_release);
|
||||
gate_.force_idle();
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
@@ -346,39 +347,35 @@ private:
|
||||
: 0.5f), ...);
|
||||
}
|
||||
|
||||
/// Submit unless already queued. A wake that arrives while this node is
|
||||
/// queued or running is *recorded*, never dropped.
|
||||
/// Submit unless a firing is already in flight. A wake that arrives while
|
||||
/// one is is *recorded* against it, never dropped.
|
||||
///
|
||||
/// Wakes are edge-triggered: a channel fires its space callback on the
|
||||
/// transition, once. If that lands while queued_ is up, the CAS below fails
|
||||
/// and — before wake_pending_ — the wake was gone. A node could then park a
|
||||
/// transition, once. A dropped one never returns, so a node could park a
|
||||
/// value, release its worker, and sleep forever holding output its consumer
|
||||
/// was waiting for, with every worker idle in cond_wait and nothing left to
|
||||
/// re-trigger it. Recording the drop turns the signal level-triggered: the
|
||||
/// invariant is that a node never sleeps with a wake outstanding, enforced
|
||||
/// by release_and_recheck() at every point that releases the node.
|
||||
/// re-trigger it. SubmitGate makes "idle" and "wake outstanding" the same
|
||||
/// variable, so the two cannot both be true — see submit_gate.hpp.
|
||||
void try_submit(float priority) {
|
||||
bool expected = false;
|
||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||
if (gate_.claim())
|
||||
scheduler_->submit([this] { fire_once(); }, priority);
|
||||
else
|
||||
wake_pending_.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
/// Clear queued_, then honour any wake that was dropped while it was up.
|
||||
/// Every path that finishes or parks a firing must release the node through
|
||||
/// here rather than storing queued_ directly.
|
||||
/// End this firing, honouring any wake recorded during it. Every path that
|
||||
/// finishes or parks a firing must release the node through here rather
|
||||
/// than touching the gate directly. When a wake was recorded the gate stays
|
||||
/// claimed and is handed to the next firing, so the node is never
|
||||
/// momentarily idle with work outstanding.
|
||||
void release_and_recheck(float priority = 0.5f) {
|
||||
queued_.store(false, std::memory_order_release);
|
||||
if (wake_pending_.exchange(false, std::memory_order_acq_rel))
|
||||
try_submit(priority);
|
||||
if (gate_.release())
|
||||
scheduler_->submit([this] { fire_once(); }, priority);
|
||||
}
|
||||
|
||||
// ── Execution ─────────────────────────────────────────────────────────────
|
||||
|
||||
void fire_once() {
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||
queued_.store(false, std::memory_order_release);
|
||||
gate_.force_idle();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -397,7 +394,7 @@ private:
|
||||
release_and_recheck();
|
||||
if (pending_) {
|
||||
// Close the lost-wakeup race: a space_callback that fired
|
||||
// between the failed push and clearing queued_ was
|
||||
// between the failed push and releasing the gate was
|
||||
// swallowed, and nothing else will wake this node. Re-check
|
||||
// now that the flag is down.
|
||||
if (outputs_have_space(std::make_index_sequence<output_count>{}))
|
||||
@@ -475,8 +472,8 @@ private:
|
||||
|
||||
// Parked by the push above. Same situation as the retry path at the top
|
||||
// of fire_once — and the same lost-wakeup race, which that path closes
|
||||
// and this one did not. A space callback that fired while queued_ was
|
||||
// still up got swallowed by try_submit's CAS, and the resubmit below
|
||||
// and this one did not. A space callback that fired while the gate was
|
||||
// still claimed is recorded there, and the resubmit below
|
||||
// cannot cover it: this firing consumed its input, so inputs are empty
|
||||
// and on_input_ready() will not resubmit. The node would then hold its
|
||||
// value forever while its consumer waits for exactly that value and its
|
||||
@@ -499,7 +496,7 @@ private:
|
||||
}
|
||||
|
||||
// Pop all inputs — safe because we're the sole consumer and fire_once
|
||||
// is guarded by queued_ (only one fire_once runs at a time).
|
||||
// is guarded by the submit gate (only one fire_once runs at a time).
|
||||
template<std::size_t... Is>
|
||||
args_tuple pop_inputs(std::index_sequence<Is...>) {
|
||||
return {pop_one<Is>()...};
|
||||
@@ -584,9 +581,9 @@ private:
|
||||
input_channels_t input_channels_;
|
||||
output_channels_t output_channels_{};
|
||||
std::atomic<bool> stop_flag_{true};
|
||||
std::atomic<bool> queued_{false};
|
||||
/// A wake that arrived while queued_ was up. See try_submit.
|
||||
std::atomic<bool> wake_pending_{false};
|
||||
/// Serialises firings and records wakes that arrive during one. See
|
||||
/// submit_gate.hpp for why this cannot be two separate flags.
|
||||
SubmitGate gate_;
|
||||
|
||||
/// The hidden one-slot output buffer (see push_outputs). Holding the value
|
||||
/// here is what lets a node stop running without dropping it or occupying a
|
||||
@@ -652,7 +649,7 @@ public:
|
||||
void start() override {
|
||||
enable_inputs(std::make_index_sequence<input_count>{});
|
||||
stop_flag_.store(false, std::memory_order_relaxed);
|
||||
queued_.store(false, std::memory_order_relaxed);
|
||||
gate_.force_idle();
|
||||
register_callbacks(std::make_index_sequence<input_count>{});
|
||||
if constexpr (input_count == 0)
|
||||
try_submit(0.5f);
|
||||
@@ -694,8 +691,8 @@ public:
|
||||
total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0,
|
||||
qwait_ms,
|
||||
stats_.total_exec_us.load(std::memory_order_relaxed) / 1000.0,
|
||||
queued_.load(std::memory_order_relaxed),
|
||||
wake_pending_.load(std::memory_order_relaxed),
|
||||
gate_.queued(),
|
||||
gate_.wake_pending(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -774,7 +771,7 @@ private:
|
||||
stats_.exec_start_us.store(0, std::memory_order_relaxed);
|
||||
// Plain store, not release_and_recheck(): this node is stopping, and
|
||||
// honouring a pending wake here would resubmit a dead node.
|
||||
queued_.store(false, std::memory_order_release);
|
||||
gate_.force_idle();
|
||||
stop_flag_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
@@ -855,37 +852,33 @@ private:
|
||||
: 0.5f), ...);
|
||||
}
|
||||
|
||||
/// Submit unless already queued. A wake that arrives while this node is
|
||||
/// queued or running is *recorded*, never dropped.
|
||||
/// Submit unless a firing is already in flight. A wake that arrives while
|
||||
/// one is is *recorded* against it, never dropped.
|
||||
///
|
||||
/// Wakes are edge-triggered: a channel fires its space callback on the
|
||||
/// transition, once. If that lands while queued_ is up, the CAS below fails
|
||||
/// and — before wake_pending_ — the wake was gone. A node could then park a
|
||||
/// transition, once. A dropped one never returns, so a node could park a
|
||||
/// value, release its worker, and sleep forever holding output its consumer
|
||||
/// was waiting for, with every worker idle in cond_wait and nothing left to
|
||||
/// re-trigger it. Recording the drop turns the signal level-triggered: the
|
||||
/// invariant is that a node never sleeps with a wake outstanding, enforced
|
||||
/// by release_and_recheck() at every point that releases the node.
|
||||
/// re-trigger it. SubmitGate makes "idle" and "wake outstanding" the same
|
||||
/// variable, so the two cannot both be true — see submit_gate.hpp.
|
||||
void try_submit(float priority) {
|
||||
bool expected = false;
|
||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||
if (gate_.claim())
|
||||
scheduler_->submit([this] { fire_once(); }, priority);
|
||||
else
|
||||
wake_pending_.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
/// Clear queued_, then honour any wake that was dropped while it was up.
|
||||
/// Every path that finishes or parks a firing must release the node through
|
||||
/// here rather than storing queued_ directly.
|
||||
/// End this firing, honouring any wake recorded during it. Every path that
|
||||
/// finishes or parks a firing must release the node through here rather
|
||||
/// than touching the gate directly. When a wake was recorded the gate stays
|
||||
/// claimed and is handed to the next firing, so the node is never
|
||||
/// momentarily idle with work outstanding.
|
||||
void release_and_recheck(float priority = 0.5f) {
|
||||
queued_.store(false, std::memory_order_release);
|
||||
if (wake_pending_.exchange(false, std::memory_order_acq_rel))
|
||||
try_submit(priority);
|
||||
if (gate_.release())
|
||||
scheduler_->submit([this] { fire_once(); }, priority);
|
||||
}
|
||||
|
||||
void fire_once() {
|
||||
if (stop_flag_.load(std::memory_order_relaxed)) {
|
||||
queued_.store(false, std::memory_order_release);
|
||||
gate_.force_idle();
|
||||
return;
|
||||
}
|
||||
auto t0 = clock_t::now();
|
||||
@@ -902,7 +895,7 @@ private:
|
||||
release_and_recheck();
|
||||
if (pending_) {
|
||||
// Close the lost-wakeup race: a space_callback that fired
|
||||
// between the failed push and clearing queued_ was
|
||||
// between the failed push and releasing the gate was
|
||||
// swallowed, and nothing else will wake this node. Re-check
|
||||
// now that the flag is down.
|
||||
if (outputs_have_space(std::make_index_sequence<output_count>{}))
|
||||
@@ -974,8 +967,8 @@ private:
|
||||
|
||||
// Parked by the push above. Same situation as the retry path at the top
|
||||
// of fire_once — and the same lost-wakeup race, which that path closes
|
||||
// and this one did not. A space callback that fired while queued_ was
|
||||
// still up got swallowed by try_submit's CAS, and the resubmit below
|
||||
// and this one did not. A space callback that fired while the gate was
|
||||
// still claimed is recorded there, and the resubmit below
|
||||
// cannot cover it: this firing consumed its input, so inputs are empty
|
||||
// and on_input_ready() will not resubmit. The node would then hold its
|
||||
// value forever while its consumer waits for exactly that value and its
|
||||
@@ -1054,9 +1047,9 @@ private:
|
||||
input_channels_t input_channels_;
|
||||
output_channels_t output_channels_{};
|
||||
std::atomic<bool> stop_flag_{true};
|
||||
std::atomic<bool> queued_{false};
|
||||
/// A wake that arrived while queued_ was up. See try_submit.
|
||||
std::atomic<bool> wake_pending_{false};
|
||||
/// Serialises firings and records wakes that arrive during one. See
|
||||
/// submit_gate.hpp for why this cannot be two separate flags.
|
||||
SubmitGate gate_;
|
||||
|
||||
/// The hidden one-slot output buffer (see push_outputs). Holding the value
|
||||
/// here is what lets a node stop running without dropping it or occupying a
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
|
||||
namespace kpn {
|
||||
|
||||
// ── SubmitGate ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Decides, for one node, whether a wake must turn into a scheduler submission.
|
||||
// Exactly one firing of a node may be in flight at a time, and a wake that
|
||||
// arrives while one is already in flight must not be lost — it has to be
|
||||
// honoured when that firing finishes, or the node sleeps holding work.
|
||||
//
|
||||
// 9c5ce5f wrote this as two independent atomics: queued_ said a firing was in
|
||||
// flight, wake_pending_ recorded a wake that arrived during one. That cannot be
|
||||
// made correct, because the release side has to read and write both, and a wake
|
||||
// can land between the two operations:
|
||||
//
|
||||
// producer (try_submit) worker (release_and_recheck)
|
||||
// ------------------------ ----------------------------
|
||||
// CAS reads queued_ == true, fails
|
||||
// queued_.store(false)
|
||||
// wake_pending_.exchange(false) -> false
|
||||
// wake_pending_.store(true)
|
||||
//
|
||||
// End state: queued_ false, wake_pending_ true, nothing running and nothing
|
||||
// scheduled. The node sleeps with a wake outstanding, which is precisely the
|
||||
// invariant that commit set out to establish. It is not a memory-ordering
|
||||
// subtlety — the interleaving above holds under seq_cst.
|
||||
//
|
||||
// It survived because every caller happened to follow release_and_recheck()
|
||||
// with a level re-check (on_input_ready(), or outputs_have_space() on the
|
||||
// parked path), which rediscovers the state a lost wake would have signalled.
|
||||
// That is a property of the call sites, not of the mechanism, and any new early
|
||||
// return that forgets the re-check turns it back into a hang.
|
||||
//
|
||||
// One atomic with three states makes the race unrepresentable: "idle" and "wake
|
||||
// outstanding" are the same variable, so no interleaving can produce both.
|
||||
//
|
||||
// Idle nothing in flight
|
||||
// Queued a firing is in flight or queued; no wake since it was claimed
|
||||
// QueuedWake a firing is in flight or queued, and a wake arrived meanwhile
|
||||
//
|
||||
class SubmitGate {
|
||||
public:
|
||||
/// Register a wake. Returns true when the caller must submit the node;
|
||||
/// false when a firing is already in flight and the wake has been recorded
|
||||
/// against it instead.
|
||||
bool claim() noexcept {
|
||||
int cur = state_.load(std::memory_order_acquire);
|
||||
for (;;) {
|
||||
if (cur == kIdle) {
|
||||
if (state_.compare_exchange_weak(cur, kQueued,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return true;
|
||||
} else if (cur == kQueued) {
|
||||
if (state_.compare_exchange_weak(cur, kQueuedWake,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return false;
|
||||
} else {
|
||||
return false; // a wake is already recorded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End the in-flight firing. Returns true when a wake arrived during it and
|
||||
/// the caller must submit again — in which case the gate stays claimed, so
|
||||
/// the node is handed straight from one firing to the next and is never
|
||||
/// momentarily idle with work outstanding. Returns false when the node is
|
||||
/// now idle.
|
||||
bool release() noexcept {
|
||||
int cur = state_.load(std::memory_order_acquire);
|
||||
for (;;) {
|
||||
if (cur == kQueuedWake) {
|
||||
if (state_.compare_exchange_weak(cur, kQueued,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return true;
|
||||
} else {
|
||||
// kQueued, or kIdle if a stop already forced the gate down.
|
||||
if (state_.compare_exchange_weak(cur, kIdle,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the claim and any recorded wake. For stop paths only: honouring a
|
||||
/// wake there would resubmit a dead node.
|
||||
void force_idle() noexcept { state_.store(kIdle, std::memory_order_release); }
|
||||
|
||||
bool queued() const noexcept { return state_.load(std::memory_order_relaxed) != kIdle; }
|
||||
bool wake_pending() const noexcept { return state_.load(std::memory_order_relaxed) == kQueuedWake; }
|
||||
|
||||
private:
|
||||
static constexpr int kIdle = 0;
|
||||
static constexpr int kQueued = 1;
|
||||
static constexpr int kQueuedWake = 2;
|
||||
|
||||
std::atomic<int> state_{kIdle};
|
||||
};
|
||||
|
||||
} // namespace kpn
|
||||
@@ -36,6 +36,7 @@ add_executable(kpn_tests
|
||||
test_pool_node.cpp
|
||||
test_backpressure_deadlock.cpp
|
||||
test_scheduler.cpp
|
||||
test_submit_gate.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(kpn_tests PRIVATE
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Regression: a node must never end up idle with a wake outstanding.
|
||||
//
|
||||
// 9c5ce5f established that invariant and implemented it as two independent
|
||||
// atomics — queued_ for "a firing is in flight", wake_pending_ for "a wake
|
||||
// arrived during one". Two variables cannot express it, because the release
|
||||
// side has to read and write both and a wake can land in between:
|
||||
//
|
||||
// producer (try_submit) worker (release_and_recheck)
|
||||
// ------------------------ ----------------------------
|
||||
// CAS reads queued_ == true, fails
|
||||
// queued_.store(false)
|
||||
// wake_pending_.exchange(false) -> false
|
||||
// wake_pending_.store(true)
|
||||
//
|
||||
// queued_ false, wake_pending_ true, nothing running and nothing scheduled.
|
||||
// Not a memory-ordering subtlety: the interleaving holds under seq_cst.
|
||||
//
|
||||
// LegacyGate below is that protocol verbatim, with a hook between the failed
|
||||
// CAS and the wake_pending_ store so the interleaving can be forced rather than
|
||||
// waited for. That makes the loss deterministic and the test non-flaky, and it
|
||||
// keeps the defect on record now that the code implementing it is gone.
|
||||
//
|
||||
// A note on what is NOT tested here, because it would be misleading to imply
|
||||
// otherwise: there is no black-box, node-level test that fails before this fix
|
||||
// and passes after. Every call site of release_and_recheck() happens to follow
|
||||
// it with a level re-check — on_input_ready(), or outputs_have_space() on the
|
||||
// parked path — which rediscovers the state a lost wake would have signalled.
|
||||
// That masking is a property of the call sites, not of the mechanism, and the
|
||||
// point of the fix is that a future early return that forgets the re-check no
|
||||
// longer reintroduces a hang. The value is structural, so the tests are
|
||||
// structural: the state machine is pinned by contract, and the defect it
|
||||
// replaces is pinned by demonstration.
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <kpn/submit_gate.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <thread>
|
||||
|
||||
using namespace kpn;
|
||||
|
||||
namespace {
|
||||
|
||||
// The pre-fix protocol, with a seam at the point where the race lives.
|
||||
class LegacyGate {
|
||||
public:
|
||||
std::function<void()> before_recording_wake;
|
||||
|
||||
bool claim() noexcept {
|
||||
bool expected = false;
|
||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||
return true;
|
||||
if (before_recording_wake) before_recording_wake();
|
||||
wake_pending_.store(true, std::memory_order_release);
|
||||
return false;
|
||||
}
|
||||
bool release() noexcept {
|
||||
queued_.store(false, std::memory_order_release);
|
||||
if (wake_pending_.exchange(false, std::memory_order_acq_rel)) {
|
||||
bool expected = false;
|
||||
if (queued_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool queued() const noexcept { return queued_.load(std::memory_order_relaxed); }
|
||||
bool wake_pending() const noexcept { return wake_pending_.load(std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
std::atomic<bool> queued_{false};
|
||||
std::atomic<bool> wake_pending_{false};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("the two-atomic gate loses a wake, deterministically", "[submit_gate]") {
|
||||
LegacyGate gate;
|
||||
bool resubmitted = true;
|
||||
|
||||
REQUIRE(gate.claim()); // a firing is now in flight
|
||||
|
||||
// Force the interleaving: the firing completes in the window between the
|
||||
// second wake's failed CAS and its record of that wake.
|
||||
gate.before_recording_wake = [&] { resubmitted = gate.release(); };
|
||||
|
||||
const bool submitted = gate.claim();
|
||||
|
||||
// The wake was neither submitted by the producer nor honoured by the
|
||||
// release. Nothing is scheduled, and nothing else will re-trigger it.
|
||||
CHECK_FALSE(submitted);
|
||||
CHECK_FALSE(resubmitted);
|
||||
CHECK_FALSE(gate.queued());
|
||||
CHECK(gate.wake_pending()); // recorded, and never to be consumed
|
||||
}
|
||||
|
||||
TEST_CASE("submit gate: a wake during a firing is honoured", "[submit_gate]") {
|
||||
SubmitGate gate;
|
||||
|
||||
REQUIRE(gate.claim()); // idle -> queued, caller submits
|
||||
REQUIRE(gate.queued());
|
||||
REQUIRE_FALSE(gate.wake_pending());
|
||||
|
||||
REQUIRE_FALSE(gate.claim()); // second wake is recorded, not submitted
|
||||
REQUIRE(gate.wake_pending());
|
||||
|
||||
REQUIRE(gate.release()); // and honoured when the firing ends
|
||||
// The gate stays claimed across the handover, so the node is never
|
||||
// momentarily idle while a submission for it is in flight. This is the
|
||||
// state the legacy gate could not represent.
|
||||
REQUIRE(gate.queued());
|
||||
REQUIRE_FALSE(gate.wake_pending());
|
||||
|
||||
REQUIRE_FALSE(gate.release()); // no further wake: now idle
|
||||
REQUIRE_FALSE(gate.queued());
|
||||
}
|
||||
|
||||
TEST_CASE("submit gate: repeated wakes collapse to one resubmission", "[submit_gate]") {
|
||||
// Collapsing is deliberate. A firing consumes one item and its caller then
|
||||
// re-checks the input level, so the gate only has to guarantee that at
|
||||
// least one more firing follows a wake, not one per wake.
|
||||
SubmitGate gate;
|
||||
REQUIRE(gate.claim());
|
||||
for (int i = 0; i < 10; ++i) REQUIRE_FALSE(gate.claim());
|
||||
REQUIRE(gate.release());
|
||||
REQUIRE_FALSE(gate.release());
|
||||
}
|
||||
|
||||
TEST_CASE("submit gate: force_idle drops a recorded wake", "[submit_gate]") {
|
||||
// Stop paths use this deliberately — honouring a wake there would resubmit
|
||||
// a node that has already been told to stop.
|
||||
SubmitGate gate;
|
||||
REQUIRE(gate.claim());
|
||||
REQUIRE_FALSE(gate.claim());
|
||||
REQUIRE(gate.wake_pending());
|
||||
|
||||
gate.force_idle();
|
||||
REQUIRE_FALSE(gate.queued());
|
||||
REQUIRE_FALSE(gate.wake_pending());
|
||||
REQUIRE(gate.claim()); // and the gate is reusable afterwards
|
||||
}
|
||||
|
||||
TEST_CASE("submit gate: concurrent claim and release stay consistent", "[submit_gate]") {
|
||||
// Not a lost-wake test — see the header note. This is a TSan target and a
|
||||
// check that the CAS loops always terminate and always leave the gate in a
|
||||
// reachable state: exactly one party may hold the claim at a time, so the
|
||||
// count of claims granted must equal the count of releases that ended idle.
|
||||
SubmitGate gate;
|
||||
std::atomic<long> granted{0}, ended_idle{0};
|
||||
std::atomic<bool> stop{false};
|
||||
|
||||
std::thread waker([&] {
|
||||
while (!stop.load(std::memory_order_relaxed))
|
||||
if (gate.claim()) granted.fetch_add(1, std::memory_order_relaxed);
|
||||
});
|
||||
std::thread worker([&] {
|
||||
while (!stop.load(std::memory_order_relaxed))
|
||||
if (gate.queued() && !gate.release())
|
||||
ended_idle.fetch_add(1, std::memory_order_relaxed);
|
||||
});
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
stop.store(true, std::memory_order_relaxed);
|
||||
waker.join();
|
||||
worker.join();
|
||||
|
||||
// Drain whatever claim is outstanding so the two counts can be compared.
|
||||
while (gate.queued())
|
||||
if (!gate.release()) ended_idle.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
INFO("granted " << granted.load() << " ended idle " << ended_idle.load());
|
||||
REQUIRE(granted.load() > 0);
|
||||
CHECK(granted.load() == ended_idle.load());
|
||||
}
|
||||
Reference in New Issue
Block a user