Skip to content

Commit cb12dcb

Browse files
committed
fix(threading): a Barrier post-phase action can read the phase number (#1957, SR-AUD-210)
Rule-14 sweep. FinishPhase() invokes the post-phase action while HOLDING mutex_, and getCurrentPhaseNumberProperty() took that same non-recursive mutex, so a legal call from inside the action self-deadlocked. Not a new discovery: #1955 fixed the sibling property the same way and its comment names this one as the remaining case. .NET's CurrentPhaseNumber is Volatile.Read(ref _currentPhase) (Barrier.cs:184-188), a lock-free read of a plain field, so the reference settles the design. phaseCount_ becomes std::atomic<longcs> and is read without the lock. sizeof(Barrier) is 160 before and after, so nothing rebuilds for layout. The design record named only the deadlock, and the reference exposes a second half: .NET increments the phase in SetResetEvents, called from FinishPhase's finally -- after the action, and on the throwing path too -- while this port incremented first. That was unobservable only because the property that would have seen it hung. Fixing the deadlock alone would have shipped a newly reachable wrong answer in place of a hang, so both land together: inside the action the phase is now the one ENDING, as .NET reports. Nothing outside the action moves, since mutex_ is held throughout. The design record instead proposed releasing mutex_ around the action and reacquiring it. That is not what the reference does and is materially riskier -- it would let other participants observe a half-finished transition. The boundary is unchanged and pinned: only the two read-only properties are callable from the action; the four mutating members still guard before locking and throw. Three mutations, all caught. Two process notes: M2 was invalid as first written (adding the increment without removing the later one is a double increment, not a move) and was reformulated rather than counted; and M3 was first reported "not caught", which was a defect in my mutation harness found by checking rather than believing it -- M3 hangs a pre-existing multi-participant case, so the suite run hit its timeout and emitted no FAILED lines, which a line-grepping harness cannot tell from a pass. Run alone, the new pin fails in 0 ms. #1957 now stays open for SR-AUD-204 alone. Downstream measured: 0 sites in cna, 0 in mobile-eggbert. Gate: 17,462 run, 17,462 passed, 0 failed, 0 skipped across 38 executables (+6 on 17,456; SharpRuntimeTests_Threading 480 -> 486; no other executable moved). Module graph unchanged at 41/93.
1 parent bd2a608 commit cb12dcb

5 files changed

Lines changed: 322 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — a `Barrier` post-phase action can read the phase number (ticket #1957, SR-AUD-210)
5+
6+
*2026-08-19.* `System::Threading::Barrier::getCurrentPhaseNumberProperty()` no longer takes the
7+
barrier's lock, so calling it from inside a post-phase action no longer self-deadlocks. The phase
8+
increment moved to **after** the action, which is where .NET performs it.
9+
10+
Landed under **SA-5**, with SA-3's layout condition discharged as **layout-neutral**:
11+
`sizeof(Barrier)` is **160 before and after**.
12+
13+
---
14+
15+
## 1. What was wrong
16+
17+
`FinishPhase()` invokes the post-phase action while **holding** `mutex_`, and
18+
`getCurrentPhaseNumberProperty()` took that same non-recursive `std::mutex`. So:
19+
20+
```cpp
21+
Barrier barrier(1, [](Barrier& b) {
22+
auto phase = b.getCurrentPhaseNumberProperty(); // deadlocked, permanently
23+
});
24+
barrier.SignalAndWait();
25+
```
26+
27+
This is not a new discovery. Ticket #1955 fixed the sibling property
28+
`getParticipantCountProperty()` in exactly this way and **named this one as the remaining case**,
29+
in a comment that is still in the header.
30+
31+
## 2. What .NET does
32+
33+
`CurrentPhaseNumber` is a lock-free read of a plain field:
34+
35+
```csharp
36+
public long CurrentPhaseNumber
37+
{
38+
// use the new Volatile.Read/Write method because it is cheaper than Interlocked.Read
39+
get { return Volatile.Read(ref _currentPhase); } // Barrier.cs:184-188
40+
```
41+
42+
so the reference settles the design: the property must not take the barrier's lock. `phaseCount_`
43+
becomes a `std::atomic<longcs>` and is read with `memory_order_acquire`, which is the same repair
44+
#1955 applied to `participantCount_`.
45+
46+
## 3. The half the design record did not name
47+
48+
.NET increments the phase in `SetResetEvents`, which `FinishPhase` calls from its `finally`
49+
**after** the action has run, and on the throwing path too (`Barrier.cs:804-812, 834-836`).
50+
51+
This port incremented **first**. That was unobservable only because the property that would have
52+
seen it deadlocked. **Fixing the deadlock alone would have shipped a newly reachable wrong answer
53+
in place of a hang** — the action would have read the phase about to begin, where .NET reports the
54+
one that is ending.
55+
56+
So both changes land together:
57+
58+
| Read from | Was | Is |
59+
|---|---|---|
60+
| inside the post-phase action | **deadlock** | the phase that is **ending** (0, 1, 2, …) |
61+
| after `SignalAndWait()` returns | 1, 2, 3, … | 1, 2, 3, … — **unchanged** |
62+
| after a **throwing** action | advanced | advanced — unchanged |
63+
64+
Nothing outside the action can see the difference: `mutex_` is held for the whole of
65+
`FinishPhase`, so no waiter can run until it is released. The increment is still inside the
66+
critical section, before `notify_all`.
67+
68+
## 4. The boundary that did not move
69+
70+
Only the two **read-only** properties are callable from the action.
71+
`AddParticipant`, `RemoveParticipant`, `SignalAndWait` and `Dispose` all call
72+
`ThrowIfCalledFromPostPhaseAction()` **before** taking the lock, so they throw
73+
`InvalidOperationException` rather than deadlocking — matching .NET's `_actionCallerID` guard.
74+
That is pinned by a test asserting all four still refuse.
75+
76+
## 5. Evidence
77+
78+
Mutations, **all caught**:
79+
80+
| Mutation | Caught by |
81+
|---|---|
82+
| M1 — the property takes the lock again | `Fix1957_ThePostPhaseActionCanReadThePhaseNumber` |
83+
| M2 — the increment moves back before the action | `Fix1957_ThePostPhaseActionCanReadThePhaseNumber`, `Fix1957_ThePhaseAdvancesAfterTheActionNotBefore`, `Fix1957_TheOtherMembersStillRefuseReentrancy` |
84+
| M3 — the phase advances only on the success path | `Fix1957_ThePhaseStillAdvancesWhenTheActionThrows` |
85+
86+
**M2 was invalid as first written and was reformulated rather than counted**: adding the
87+
increment before the action without removing it afterwards is a *double* increment, not a move,
88+
and it broke two pre-existing tests for the wrong reason.
89+
90+
**M3 was first reported "not caught", and that was a defect in the mutation harness, found by
91+
checking rather than by believing it.** M3 makes a *pre-existing* multi-participant `BarrierTests`
92+
case **hang** — waiters block on `phaseCount_ > myPhase` and the phase never advances — so the
93+
whole-suite run hit its timeout and produced no `[ FAILED ]` lines at all, which the harness
94+
read as "no failures". Run on its own, `Fix1957_ThePhaseStillAdvancesWhenTheActionThrows` fails
95+
in 0 ms. The mutation is therefore caught **by name**, and it is also caught as a hang; a harness
96+
that only greps for failure lines cannot tell those two apart from a pass.
97+
98+
Every case that exercises the action runs its work on a **detached** thread behind a bounded
99+
four-second handshake, because the pre-repair behaviour is a **hang**: joining a stuck thread
100+
would take the whole executable down instead of failing one assertion. That is the same shape
101+
#2341 used for `Monitor::Wait`.
102+
103+
`Fix1957_ThePhaseAdvancesAfterTheActionNotBefore` asserts the whole sequence across three phases
104+
(`{0,1,2}` inside, `{1,2,3}` outside) rather than a single value, so an off-by-one cannot pass by
105+
accident.
106+
107+
Gate: **17,462 run, 17,462 passed, 0 failed, 0 skipped** across 38 executables — `+6` on 17,456,
108+
exactly the six new cases (`SharpRuntimeTests_Threading` 480 → 486). No other executable moved.
109+
Module graph unchanged at 41/93.
110+
111+
## 6. Scope
112+
113+
This is the **third** of ticket #1957's four members. SR-AUD-202 (`Monitor::Wait` recursion)
114+
landed as #2341; SR-AUD-201 (`PeriodicTimer` single-consumer) landed earlier today. **SR-AUD-204**
115+
(`ReaderWriterLockSlim` writer preference) remains, and #1957 stays open for it — it introduces
116+
writer preference, a fairness change that alters which workloads block, and the plan's §12
117+
requires a ThreadSanitizer run for it specifically.
118+
119+
## 7. Downstream, measured
120+
121+
`System::Threading::Barrier` appears in **zero** places in `cna` and **zero** in
122+
`mobile-eggbert`. Neither repository was modified.

modules/threading/include/System/Threading/Barrier.hpp

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,22 @@ namespace System::Threading {
5757
// so the field is layout-neutral (build-probe/1955_probe1_layout_{before,after}.log).
5858
std::atomic<intcs> participantCount_;
5959
intcs remainingCount_;
60-
longcs phaseCount_ = 0;
60+
// Ticket #1957 / SR-AUD-210, cause T-E/2. This was a plain `longcs` read under mutex_,
61+
// and FinishPhase() runs the post-phase action while HOLDING mutex_, so a legal
62+
// `barrier.getCurrentPhaseNumberProperty()` inside that action deadlocked on a
63+
// non-recursive std::mutex -- exactly the hazard #1955 recorded here when it fixed the
64+
// sibling property, and it named this one as the remaining case.
65+
//
66+
// .NET's own CurrentPhaseNumber is `Volatile.Read(ref _currentPhase)` (Barrier.cs:184-188)
67+
// -- a lock-free read of a plain field -- so the reference already answers this: the
68+
// property must not take the barrier's lock. std::atomic<longcs> is 8 bytes and 8-aligned,
69+
// the same as longcs, so the change is layout-neutral (sizeof(Barrier) is 160 before and
70+
// after, build-probe/1957_probe2_barrier.cpp) -- the same argument #1955 made for
71+
// participantCount_ and disposed_.
72+
//
73+
// Writers keep writing under mutex_, so every compound invariant with remainingCount_ is
74+
// unaffected.
75+
std::atomic<longcs> phaseCount_{0};
6176
std::function<void(Barrier&)> postPhaseAction_;
6277
mutable std::mutex mutex_;
6378
std::condition_variable cv_;
@@ -106,8 +121,22 @@ namespace System::Threading {
106121
[[nodiscard]] intcs getParticipantCountProperty() const {
107122
return participantCount_.load(std::memory_order_acquire);
108123
}
109-
/** Returns the current phase number. */
110-
[[nodiscard]] longcs getCurrentPhaseNumberProperty() const { std::unique_lock lock(mutex_); return phaseCount_; }
124+
/**
125+
* @brief Returns the current phase number.
126+
*
127+
* Read without taking the barrier's lock, matching .NET's
128+
* `Volatile.Read(ref _currentPhase)` (`Barrier.cs:184-188`) and keeping the property
129+
* callable from inside a post-phase action (#1957, SR-AUD-210). Taking `mutex_` here
130+
* deadlocked, because `FinishPhase()` invokes that action while holding it.
131+
*
132+
* @note **Inside the post-phase action this returns the phase that is ENDING, not the
133+
* one about to begin.** That is .NET's value, not an accident of ordering: .NET
134+
* increments in `SetResetEvents`, which its `FinishPhase` calls from the `finally`
135+
* *after* the action has run (`Barrier.cs:781-816, 834-836`).
136+
*/
137+
[[nodiscard]] longcs getCurrentPhaseNumberProperty() const {
138+
return phaseCount_.load(std::memory_order_acquire);
139+
}
111140

112141
/**
113142
* @brief Signals that a participant has reached the barrier and blocks until all participants have arrived.
@@ -187,7 +216,9 @@ namespace System::Threading {
187216
* lastPostPhaseException_ once they wake.
188217
*/
189218
void FinishPhase(std::unique_lock<std::mutex>& lock) {
190-
++phaseCount_;
219+
// The participant count is reset BEFORE the action, matching .NET, which zeroes the
220+
// arrival count in SetCurrentTotal and only then calls FinishPhase
221+
// (Barrier.cs:454-458).
191222
remainingCount_ = participantCount_;
192223
if (postPhaseAction_) {
193224
actionCallerId_.store(std::this_thread::get_id());
@@ -201,6 +232,19 @@ namespace System::Threading {
201232
} else {
202233
lastPostPhaseException_ = nullptr;
203234
}
235+
236+
// #1957 MOVED THIS, and the move is what makes the now-reachable property answer
237+
// what .NET answers. .NET increments the phase in SetResetEvents, which FinishPhase
238+
// calls from its `finally` -- AFTER the action, and on the throwing path too
239+
// (Barrier.cs:804-812, 834-836). So inside the action the phase is the one ENDING.
240+
//
241+
// The increment used to run first, which was unobservable only because the property
242+
// that would have seen it deadlocked. Fixing the deadlock without moving it would
243+
// have shipped a newly reachable wrong answer instead of a hang.
244+
//
245+
// Nothing outside the action can see the difference: mutex_ is held for this whole
246+
// function, so no waiter can run until it is released.
247+
++phaseCount_;
204248
cv_.notify_all();
205249
(void)lock;
206250
if (lastPostPhaseException_)

modules/threading/tests/System/Threading/ThreadingBoundaryTests.cpp

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,18 @@
2323
#include <chrono>
2424
#include <functional>
2525
#include <memory>
26+
#include <mutex>
2627
#include <thread>
2728
#include <utility>
2829
#include <vector>
2930

3031
#include "System/ArgumentNullException.hpp"
3132
#include "System/ArgumentOutOfRangeException.hpp"
3233
#include "System/Exception.hpp"
34+
#include "System/InvalidOperationException.hpp"
3335
#include "System/NullReferenceException.hpp"
3436
#include "System/OperationCanceledException.hpp"
37+
#include "System/Threading/Barrier.hpp"
3538
#include "System/Threading/CancellationToken.hpp"
3639
#include "System/Threading/CancellationTokenRegistration.hpp"
3740
#include "System/Threading/CancellationTokenSource.hpp"
@@ -842,3 +845,151 @@ TEST(PeriodicTimerSingleConsumerTests, Fix1957_SingleConsumerUseIsCompletelyUnch
842845
timer.Dispose();
843846
EXPECT_FALSE(timer.WaitForNextTick());
844847
}
848+
849+
// =============================================================================================
850+
// Ticket #1957 / SR-AUD-210 — the Barrier's post-phase action can read the barrier.
851+
//
852+
// FinishPhase() invokes the post-phase action while HOLDING mutex_, and
853+
// getCurrentPhaseNumberProperty() took that same non-recursive mutex, so a legal call from
854+
// inside the action self-deadlocked. #1955 fixed the sibling property
855+
// (getParticipantCountProperty) the same way and named this one as the remaining case.
856+
//
857+
// .NET's CurrentPhaseNumber is `Volatile.Read(ref _currentPhase)` (Barrier.cs:184-188) -- a
858+
// lock-free read of a plain field -- so the reference settles the design: the property must not
859+
// take the lock.
860+
//
861+
// THE SECOND HALF, which the design record did not name: .NET increments the phase in
862+
// SetResetEvents, called from FinishPhase's `finally` AFTER the action runs
863+
// (Barrier.cs:804-812, 834-836). This port incremented FIRST, which was unobservable only
864+
// because the property that would have seen it deadlocked. Fixing the deadlock alone would have
865+
// shipped a newly reachable WRONG ANSWER in place of a hang.
866+
//
867+
// Landed under SA-5, with SA-3's layout condition discharged as layout-neutral: sizeof(Barrier)
868+
// is 160 before and after (build-probe/1957_probe2_barrier.cpp).
869+
// =============================================================================================
870+
871+
TEST(BarrierPostPhaseReadabilityTests, Decl1957_TheAtomicPhaseIsLayoutNeutral) {
872+
static_assert(sizeof(System::Threading::Barrier) == 160,
873+
"#1957/SR-AUD-210 must not change Barrier's layout");
874+
static_assert(alignof(System::Threading::Barrier) == 8);
875+
EXPECT_EQ(sizeof(System::Threading::Barrier), 160u);
876+
}
877+
878+
TEST(BarrierPostPhaseReadabilityTests, Fix1957_ThePostPhaseActionCanReadThePhaseNumber) {
879+
// The deadlock itself. Before the repair this test HANGS rather than fails, which is why the
880+
// work runs on a detached thread behind a bounded handshake: a stuck join would take the whole
881+
// executable down instead of failing one assertion.
882+
std::atomic<bool> done{false};
883+
std::atomic<long long> seenInsideAction{-1};
884+
885+
std::thread worker([&] {
886+
System::Threading::Barrier barrier(1, [&](System::Threading::Barrier& b) {
887+
seenInsideAction.store(b.getCurrentPhaseNumberProperty());
888+
});
889+
barrier.SignalAndWait();
890+
done.store(true);
891+
});
892+
worker.detach();
893+
894+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(4);
895+
while (!done.load() && std::chrono::steady_clock::now() < deadline)
896+
std::this_thread::sleep_for(std::chrono::milliseconds(5));
897+
898+
ASSERT_TRUE(done.load())
899+
<< "reading the phase number from the post-phase action deadlocked";
900+
EXPECT_EQ(seenInsideAction.load(), 0)
901+
<< "inside the action the phase is the one ENDING, matching .NET";
902+
}
903+
904+
TEST(BarrierPostPhaseReadabilityTests, Fix1957_ThePhaseAdvancesAfterTheActionNotBefore) {
905+
// The ordering half, asserted across three phases so an off-by-one cannot pass by accident.
906+
std::atomic<bool> done{false};
907+
std::vector<long long> insideAction;
908+
std::vector<long long> afterSignal;
909+
std::mutex recordMutex;
910+
911+
std::thread worker([&] {
912+
System::Threading::Barrier barrier(1, [&](System::Threading::Barrier& b) {
913+
std::lock_guard<std::mutex> g(recordMutex);
914+
insideAction.push_back(b.getCurrentPhaseNumberProperty());
915+
});
916+
for (int i = 0; i < 3; ++i) {
917+
barrier.SignalAndWait();
918+
std::lock_guard<std::mutex> g(recordMutex);
919+
afterSignal.push_back(barrier.getCurrentPhaseNumberProperty());
920+
}
921+
done.store(true);
922+
});
923+
worker.detach();
924+
925+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(4);
926+
while (!done.load() && std::chrono::steady_clock::now() < deadline)
927+
std::this_thread::sleep_for(std::chrono::milliseconds(5));
928+
ASSERT_TRUE(done.load()) << "the barrier deadlocked";
929+
930+
std::lock_guard<std::mutex> g(recordMutex);
931+
ASSERT_EQ(insideAction.size(), 3u);
932+
ASSERT_EQ(afterSignal.size(), 3u);
933+
EXPECT_EQ(insideAction, (std::vector<long long>{0, 1, 2})) << "the phase that is ending";
934+
EXPECT_EQ(afterSignal, (std::vector<long long>{1, 2, 3})) << "the phase that has begun";
935+
}
936+
937+
TEST(BarrierPostPhaseReadabilityTests, Fix1957_ThePhaseStillAdvancesWhenTheActionThrows) {
938+
// .NET increments in the `finally`, so a throwing action still advances the phase. Easy to
939+
// break by moving the increment into the success path only.
940+
System::Threading::Barrier barrier(1, [](System::Threading::Barrier&) {
941+
throw System::InvalidOperationException("boom");
942+
});
943+
EXPECT_EQ(barrier.getCurrentPhaseNumberProperty(), 0);
944+
EXPECT_THROW(barrier.SignalAndWait(), System::Threading::BarrierPostPhaseException);
945+
EXPECT_EQ(barrier.getCurrentPhaseNumberProperty(), 1)
946+
<< "a throwing post-phase action must still advance the phase";
947+
}
948+
949+
TEST(BarrierPostPhaseReadabilityTests, Fix1957_ThePhaseIsUnchangedForOutsideObservers) {
950+
// Nothing a caller outside the action can see moved: the increment is still inside the
951+
// critical section, before notify_all and before the lock is released.
952+
System::Threading::Barrier barrier(1);
953+
EXPECT_EQ(barrier.getCurrentPhaseNumberProperty(), 0);
954+
barrier.SignalAndWait();
955+
EXPECT_EQ(barrier.getCurrentPhaseNumberProperty(), 1);
956+
barrier.SignalAndWait();
957+
EXPECT_EQ(barrier.getCurrentPhaseNumberProperty(), 2);
958+
}
959+
960+
TEST(BarrierPostPhaseReadabilityTests, Fix1957_TheOtherMembersStillRefuseReentrancy) {
961+
// The boundary this ticket must NOT move: the members that mutate still throw rather than
962+
// deadlock, because each guards before taking the lock. Only the two READ-ONLY properties are
963+
// callable from the action.
964+
std::atomic<bool> done{false};
965+
std::atomic<int> threwCount{0};
966+
std::atomic<long long> phase{-1};
967+
std::atomic<int> participants{-1};
968+
969+
std::thread worker([&] {
970+
System::Threading::Barrier barrier(1, [&](System::Threading::Barrier& b) {
971+
phase.store(b.getCurrentPhaseNumberProperty());
972+
participants.store(static_cast<int>(b.getParticipantCountProperty()));
973+
for (auto call : std::vector<std::function<void()>>{
974+
[&] { (void)b.AddParticipant(); },
975+
[&] { b.RemoveParticipant(); },
976+
[&] { b.SignalAndWait(); },
977+
[&] { b.Dispose(); }}) {
978+
try { call(); } catch (const System::InvalidOperationException&) { ++threwCount; }
979+
catch (...) {}
980+
}
981+
});
982+
barrier.SignalAndWait();
983+
done.store(true);
984+
});
985+
worker.detach();
986+
987+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(4);
988+
while (!done.load() && std::chrono::steady_clock::now() < deadline)
989+
std::this_thread::sleep_for(std::chrono::milliseconds(5));
990+
991+
ASSERT_TRUE(done.load()) << "a mutating member deadlocked instead of throwing";
992+
EXPECT_EQ(threwCount.load(), 4) << "all four mutating members must refuse reentrancy";
993+
EXPECT_EQ(phase.load(), 0);
994+
EXPECT_EQ(participants.load(), 1);
995+
}

plan.sqlite3

8 KB
Binary file not shown.

0 commit comments

Comments
 (0)