Skip to content

Commit 17079bd

Browse files
committed
fix(threading): Dispose also refuses a ReaderWriterLockSlim with waiters (#2389)
Completes what #1956 half-landed. .NET's Dispose performs TWO checks (ReaderWriterLockSlim.cs:1250-1258) and #1956's design record named only the held-mode one, so Dispose accepted a lock other threads were waiting for. waitingReaders_ and waitingUpgraders_ join SR-AUD-204's waitingWriters_, and both checks now run in .NET's order. The two new counters feed no admission predicate -- only writer-waiting does -- so their guards take notifyOnLast=false and cannot perturb wake-up ordering. sizeof 120 -> 128: SR-AUD-204's single counter fit existing padding and these two did not, so consumers must rebuild. The pre-existing layout gate was updated from 120 to 128. Four mutations, all caught -- but only after two defects were found, and those are the substance of this ticket: 1. The first run reported all four "caught" by a test that was ALREADY FAILING: the layout gate asserts 120 and was failing on the unmutated build too, so no verdict meant anything. A mutation verdict is only evidence against a baseline that passes. 2. Re-run green, M1 and M2 came back NOT CAUGHT -- a real defect in my tests, which disposed from a thread that also held a mode, so the held-mode check fired and the waiter check was never exercised. The disposer must hold nothing. 3. The corrected tests then flaked inside the gate: a fixed 150 ms settle does not guarantee the waiter has been counted. Tuning the sleep would have been the wrong repair. They now rebuild the scenario over up to six attempts and pass the moment Dispose refuses -- sound rather than tolerant, since only a genuinely firing check can pass, and every mutation still fails all six. That layout gate doing its job is also the evidence 128 is a real change rather than drift: it stayed green through SR-AUD-204's earlier counter. Downstream measured: 0 sites in cna, 0 in mobile-eggbert. Gate: 17,482 run, 17,482 passed, 0 failed, 0 skipped across 38 executables (+6 on 17,476; SharpRuntimeTests_Threading 500 -> 506; no other executable moved). Module graph unchanged at 41/93.
1 parent 9f3114c commit 17079bd

6 files changed

Lines changed: 334 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `ReaderWriterLockSlim::Dispose` also refuses a lock with waiters (ticket #2389)
5+
6+
*2026-08-19.* `Dispose()` now throws `SynchronizationLockException` when any thread is **waiting**
7+
to acquire the lock, not only when the calling thread **holds** a mode. Both checks are .NET's,
8+
in .NET's order.
9+
10+
**`sizeof(ReaderWriterLockSlim)` grows 120 → 128, so consumers must be recompiled.** Landed under
11+
**SA-3** (private data members, `sizeof` pinned) and **SA-5** (behaviour derived from the
12+
reference).
13+
14+
---
15+
16+
## 1. What this completes
17+
18+
.NET's `Dispose(bool)` performs **two** checks (`ReaderWriterLockSlim.cs:1250-1258`):
19+
20+
```csharp
21+
if (WaitingReadCount > 0 || WaitingUpgradeCount > 0 || WaitingWriteCount > 0)
22+
throw new SynchronizationLockException(SR.SynchronizationLockException_IncorrectDispose);
23+
24+
if (IsReadLockHeld || IsUpgradeableReadLockHeld || IsWriteLockHeld)
25+
throw new SynchronizationLockException(SR.SynchronizationLockException_IncorrectDispose);
26+
```
27+
28+
Ticket #1956 landed the **second** — its design record named only that one, so the omission was a
29+
gap in the record rather than a shortcut taken while implementing it. The first needed per-mode
30+
waiter counts. #1957/SR-AUD-204 added `waitingWriters_` for a different purpose (writer
31+
preference); this ticket adds `waitingReaders_` and `waitingUpgraders_` and wires all three into
32+
`Dispose`.
33+
34+
## 2. What changes
35+
36+
| `Dispose()` called while… | Was | Is |
37+
|---|---|---|
38+
| nothing held, nobody waiting | succeeds | succeeds — unchanged, still idempotent |
39+
| the calling thread holds a mode | throws (#1956) | throws — unchanged |
40+
| **another thread is waiting for a read lock** | **succeeded** | `SynchronizationLockException` |
41+
| **another thread is waiting for the write lock** | **succeeded** | `SynchronizationLockException` |
42+
| **another thread is waiting to upgrade** | **succeeded** | `SynchronizationLockException` |
43+
| a waiter that has since **timed out** || succeeds; the counters come back down |
44+
45+
## 3. The new counters affect no admission decision
46+
47+
Only *writer*-waiting influences who may enter, which is SR-AUD-204's rule and .NET's.
48+
`waitingReaders_` and `waitingUpgraders_` are consulted by `Dispose` **and nothing else** — their
49+
guards are constructed with `notifyOnLast = false` precisely so they cannot perturb wake-up
50+
ordering. That is stated at the site.
51+
52+
All three use the same RAII guard, so a waiter that **times out or throws** stops being counted.
53+
Without that, a lock that ever had a waiter could never be disposed again — a permanent failure,
54+
not a transient one, and a test pins it.
55+
56+
## 4. The order is transcribed, not chosen
57+
58+
.NET tests **waiters first**. Both arms raise the same message in this port, so which one fires
59+
is currently *unobservable* — the ordering is nonetheless .NET's, and a test constructs the case
60+
where both conditions hold so that if the messages ever diverge the ordering is already covered
61+
rather than newly at risk.
62+
63+
## 5. Layout
64+
65+
| | Was | Is |
66+
|---|---|---|
67+
| `sizeof(ReaderWriterLockSlim)` | **120** | **128** |
68+
| `alignof` | 8 | 8 |
69+
70+
SR-AUD-204's single counter was layout-neutral — it landed in padding the type already had. These
71+
two did not fit, so this is a real object-layout change: **every consumer must be recompiled.**
72+
No source change is needed. The pin was updated in place rather than duplicated, and it now names
73+
both tickets so a third counter cannot arrive unnoticed.
74+
75+
## 6. Evidence
76+
77+
Four mutations, **all caught**:
78+
79+
| Mutation | Caught by |
80+
|---|---|
81+
| M1 — the waiter check is removed | all three waiting-kind tests |
82+
| M2 — only writers are counted | `Fix2389_DisposingWithAWaitingReaderThrows`, `Fix2389_DisposingWithAWaitingUpgraderThrows` |
83+
| M3 — the reader guard never decrements | `Fix2389_OnceTheWaiterGivesUpDisposalSucceeds`, `Decl2389_TheHeldModeCheckStillFiresOnItsOwn`, and #1956's held-mode test |
84+
| M4 — the upgrader is not counted | `Fix2389_DisposingWithAWaitingUpgraderThrows` |
85+
86+
**A second process note, and it is the more useful one.** With the layout gate fixed and the
87+
baseline green, M1 and M2 came back **NOT CAUGHT** — a real defect in the tests, not in the
88+
repair. Every case had the **disposing thread also holding a mode**, so the *held-mode* check
89+
fired and the waiter check was never the one that threw. The cases now use a third thread to hold
90+
the lock, a second to wait for it, and dispose from a thread holding **nothing** — which is the
91+
only shape where the waiter check can be observed at all. All four mutations are caught against
92+
that.
93+
94+
**One process note, and it is the reason these verdicts are trustworthy.** The first mutation run
95+
reported all four as "caught" — by `ThreadingSharedStateTests.RepairedTypes_LayoutUnchanged`,
96+
a **pre-existing** layout gate that asserts `sizeof(ReaderWriterLockSlim) == 120`. That gate was
97+
failing on the **unmutated** build too, because of the 120 → 128 growth, so every run had a
98+
failure regardless of the mutation and none of the four results meant anything. The gate was
99+
updated to 128 first, a green baseline confirmed (506 cases), and only then were the mutations
100+
re-run. A mutation verdict is only evidence against a baseline that passes.
101+
102+
That gate doing its job is also the evidence that 128 is a **real** change rather than drift: it
103+
stayed green through SR-AUD-204's earlier counter, which landed in existing padding.
104+
105+
**A third process note: the first robust-looking version still flaked.** With the tests
106+
restructured so the disposer holds nothing, they passed in isolation and then **failed inside the
107+
full gate** — a fixed 150 ms settle is not a guarantee that the waiting thread has reached the
108+
wait and been counted, and under gate load it had not. Tuning the sleep upward would have been
109+
the wrong repair; this repository has twice *repaired* such a test rather than tuned it (#2352,
110+
#2166).
111+
112+
The cases now rebuild the whole scenario on each of up to six attempts, with a growing settle,
113+
and pass the moment `Dispose` refuses. That is **sound rather than merely tolerant**: the case can
114+
only pass if the waiter check genuinely fires, so every mutation that removes or narrows it fails
115+
all six attempts and is still caught by name. Verified green three consecutive times in isolation
116+
and once through the full gate.
117+
118+
Gate: **17,482 run, 17,482 passed, 0 failed, 0 skipped** across 38 executables — `+6` on 17,476,
119+
exactly the six new cases (`SharpRuntimeTests_Threading` 500 → 506). No other executable moved.
120+
Module graph unchanged at 41/93.
121+
122+
## 7. Downstream, measured
123+
124+
`ReaderWriterLockSlim` appears in **zero** places in `cna` and **zero** in `mobile-eggbert`, so
125+
the rebuild requirement is recorded here for future consumers rather than acted on. Neither
126+
repository was modified.

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

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ namespace System::Threading {
106106
// (ReaderWriterLockSlim.cs:1039-1042), so a writer that TIMES OUT stops blocking readers;
107107
// the RAII guard in TryEnterWriteLock does the same here.
108108
intcs waitingWriters_ = 0;
109+
// Ticket #2389. .NET's Dispose refuses a lock that has WAITERS as well as one with a
110+
// held mode, and it counts all three kinds (ReaderWriterLockSlim.cs:1254). #1956
111+
// implemented the held-mode check only, because these two counters did not exist; they
112+
// do now, on the same RAII pattern waitingWriters_ uses.
113+
//
114+
// These are NOT consulted by any admission predicate -- only writer-waiting affects
115+
// admission, which is #1957/SR-AUD-204's rule and .NET's. They exist for Dispose.
116+
intcs waitingReaders_ = 0;
117+
intcs waitingUpgraders_ = 0;
118+
119+
/// Increments a waiter count for the duration of a wait and restores it on every exit.
120+
struct WaiterCountGuard {
121+
intcs& count;
122+
std::condition_variable& cv;
123+
bool notifyOnLast;
124+
~WaiterCountGuard() {
125+
if (--count == 0 && notifyOnLast) cv.notify_all();
126+
}
127+
};
109128
bool writerActive_ = false;
110129
bool upgradeableActive_ = false;
111130
// Ticket #1955 / cause T-A of docs/ThreadingNamespaceReviewPlan.md. This was an
@@ -236,6 +255,9 @@ namespace System::Threading {
236255
// holds the read, write or upgrade lock never reaches here -- every one of those
237256
// cases returned above -- so writer preference can only delay a genuinely NEW
238257
// reader, which is precisely .NET's contract and cannot deadlock a recursive one.
258+
// #2389: counted for Dispose's benefit only -- a waiting reader blocks nothing.
259+
++waitingReaders_;
260+
WaiterCountGuard readerWaitGuard{waitingReaders_, cv_, /*notifyOnLast=*/false};
239261
if (!waitFor(lk, millisecondsTimeout,
240262
[&] { return !writerActive_ && waitingWriters_ == 0; }))
241263
return false;
@@ -304,13 +326,7 @@ namespace System::Threading {
304326
// Declared after `lk` so it is destroyed BEFORE the lock is released: the decrement
305327
// and the notify both happen under the mutex.
306328
++waitingWriters_;
307-
struct WaitingWriterGuard {
308-
intcs& count;
309-
std::condition_variable& cv;
310-
~WaitingWriterGuard() {
311-
if (--count == 0) cv.notify_all();
312-
}
313-
} waitingWriterGuard{waitingWriters_, cv_};
329+
WaiterCountGuard waitingWriterGuard{waitingWriters_, cv_, /*notifyOnLast=*/true};
314330

315331
bool acquired = upgradingToWrite
316332
? waitFor(lk, millisecondsTimeout, [&] { return readers_ == 0; })
@@ -384,6 +400,9 @@ namespace System::Threading {
384400
throw LockRecursionException("Upgradeable lock may not be acquired with read lock held.");
385401

386402
std::unique_lock<std::mutex> lk(stateMtx_);
403+
// #2389: counted for Dispose's benefit only.
404+
++waitingUpgraders_;
405+
WaiterCountGuard upgraderWaitGuard{waitingUpgraders_, cv_, /*notifyOnLast=*/false};
387406
if (!waitFor(lk, millisecondsTimeout, [&] { return !writerActive_ && !upgradeableActive_; })) return false;
388407
upgradeableActive_ = true;
389408
++counts.upgrade;
@@ -409,19 +428,29 @@ namespace System::Threading {
409428
* owning a mode on a disposed object. .NET refuses
410429
* (`ReaderWriterLockSlim.cs:1250-1258`).
411430
*
412-
* @note **.NET performs TWO checks here and this port performs one**, which is stated
413-
* rather than glossed. The reference tests, in this order:
431+
* @throws System::Threading::SynchronizationLockException if any thread is WAITING to
432+
* acquire the lock.
433+
*
434+
* Both checks are .NET's, in .NET's order (`ReaderWriterLockSlim.cs:1250-1258`):
414435
* @code
415436
* if (WaitingReadCount > 0 || WaitingUpgradeCount > 0 || WaitingWriteCount > 0) throw ...;
416437
* if (IsReadLockHeld || IsUpgradeableReadLockHeld || IsWriteLockHeld) throw ...;
417438
* @endcode
418-
* The second is implemented here. The first needs per-mode WAITER counts, of which this
419-
* port has only `waitingWriters_` (added by SR-AUD-204); counting waiting readers and
420-
* upgraders is additional state on three more paths and is ticket **#2389**. The
421-
* narrowing is therefore a strict subset of .NET's -- this port never refuses a disposal
422-
* .NET would accept.
439+
* #1956 landed the second; ticket **#2389** added the waiter counts and the first.
440+
*
441+
* @note The order is transcribed rather than chosen. Both arms raise the same message
442+
* here, so which one fires is currently unobservable -- but the ordering is .NET's and a
443+
* test pins it, so it cannot be inverted casually if the messages ever diverge.
423444
*/
424445
void Dispose() override {
446+
std::unique_lock<std::mutex> lk(stateMtx_);
447+
if (waitingReaders_ > 0 || waitingUpgraders_ > 0 || waitingWriters_ > 0) {
448+
throw System::Threading::SynchronizationLockException(
449+
"The lock is being disposed while still being used. It either is being held "
450+
"by a thread and/or has active waiters waiting to acquire the lock.");
451+
}
452+
lk.unlock();
453+
425454
auto& map = threadCounts();
426455
auto it = map.find(id_);
427456
if (it != map.end() &&

0 commit comments

Comments
 (0)