Skip to content

Commit bd2a608

Browse files
committed
fix(threading): PeriodicTimer::WaitForNextTick is single-consumer (#1957, SR-AUD-201)
Rule-14 sweep, with two premise corrections that together unblock one of #1957's four members. First, this ticket's recorded gate is "approval question 2" -- growing sizeof on inline header-only types. The design record is dated 2026-08-03 and SA-3 was granted 2026-08-17: a private data member with a pinned sizeof, no vtable/signature/noexcept change, a migration note and the full gate is exactly SA-3's grant. Second, the design carried "[unverified: whether .NET throws or blocks the second consumer must be confirmed against the reference before landing]". The reference says throw: `private bool _activeWait` (PeriodicTimer.cs:192) and `if (_activeWait) ThrowHelper.ThrowInvalidOperationException()` (PeriodicTimer.cs:199-203), commenting "Failing to do so is an error." Two concurrent waiters used to both return true for one tick (the audit measured concurrent=1,1), so a caller that accidentally shared a timer got twice the intended work rate with no diagnostic at all. The guard runs first, as .NET has it -- before the cancellation short-circuit and the signalled fast path -- so a second consumer is refused even on a disposed timer: it is the concurrent use that is the error, not the timer's state. The flag clears on every exit via RAII, which is the mistake in the other direction, since a flag never cleared makes the first wait lock the timer out for ever. sizeof(PeriodicTimer) is 128 before and after -- the bool fits existing padding -- so SA-3's pin records no growth and nothing rebuilds for layout. Three mutations, two caught. M3 is UNCAUGHT and cannot be caught deterministically: moving the guard below the disposed check differs only in the race window between Dispose() releasing the mutex and the parked consumer reacquiring it. A test would be flaky, and this session has twice repaired flaky tests rather than written one. The ordering is kept because it is .NET's, and the site says so. #1957 stays open for SR-AUD-204 (writer preference -- a fairness change needing its own TSan run) and SR-AUD-210. SR-AUD-202 landed earlier as #2341. Downstream measured: 0 sites in cna, 0 in mobile-eggbert. Gate: 17,456 run, 17,456 passed, 0 failed, 0 skipped across 38 executables (+5 on 17,451; SharpRuntimeTests_Threading 475 -> 480; no other executable moved). Module graph unchanged at 41/93.
1 parent 81beb19 commit bd2a608

5 files changed

Lines changed: 272 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `PeriodicTimer::WaitForNextTick` is single-consumer (ticket #1957, SR-AUD-201)
5+
6+
*2026-08-19.* A second **concurrent** call to
7+
`System::Threading::PeriodicTimer::WaitForNextTick()` now throws
8+
`System::InvalidOperationException` instead of being served silently.
9+
10+
Landed under `docs/StandingApprovals.md` **SA-3** (a private data member, `sizeof` pinned) and
11+
**SA-5** (the behaviour is derived from the reference). **`sizeof(PeriodicTimer)` is 128 before
12+
and after** — the flag fits in padding the type already had — so no consumer needs a rebuild for
13+
layout.
14+
15+
---
16+
17+
## 1. What was wrong
18+
19+
`PeriodicTimer` had no in-flight-consumer state, so two threads calling `WaitForNextTick()`
20+
concurrently **both returned `true` for one tick**. The audit's probe measured `concurrent=1,1`.
21+
22+
A caller that accidentally shared a timer therefore got twice the intended work rate, with no
23+
diagnostic anywhere. That is the worst shape of concurrency bug: the wrong answer is a plausible
24+
one.
25+
26+
## 2. What .NET does, and the `[unverified]` flag this resolves
27+
28+
The design record for this ticket (`docs/ThreadingNamespaceReviewPlan.md` §20.2, item 4) proposed
29+
throwing, and marked it:
30+
31+
> **[unverified: whether .NET throws or blocks the second consumer must be confirmed against the
32+
> reference before landing]**
33+
34+
The reference confirms **throwing**:
35+
36+
```csharp
37+
private bool _activeWait; // PeriodicTimer.cs:192
38+
...
39+
lock (this)
40+
{
41+
if (_activeWait)
42+
{
43+
// WaitForNextTickAsync should only be used by one consumer at a time.
44+
// Failing to do so is an error.
45+
ThrowHelper.ThrowInvalidOperationException(); // PeriodicTimer.cs:199-203
46+
}
47+
```
48+
49+
and the type's own summary says the same: *"This timer is intended to be used only by a single
50+
consumer at a time: only one call to `WaitForNextTickAsync` may be in flight at any given
51+
moment"* (`PeriodicTimer.cs:13-14`).
52+
53+
## 3. The guard runs first, and that is load-bearing
54+
55+
.NET tests `_activeWait` **before** the cancellation short-circuit and **before** the
56+
already-signalled fast path (`PeriodicTimer.cs:197-213`). This port therefore tests it before the
57+
disposed check.
58+
59+
The consequence is deliberate: a second consumer arriving while the first is waiting is refused
60+
**even if the timer has since been disposed**. It is the *concurrent use* that is the error, not
61+
the timer's state. A mutation that moves the guard below the disposed check is caught.
62+
63+
## 4. The flag is cleared on every exit
64+
65+
By an RAII guard, so it survives the ordinary return, the disposed return, and any exception. .NET
66+
clears it in the completion path (`PeriodicTimer.cs:296`).
67+
68+
This is the half a naive implementation gets wrong in the opposite direction: a flag that is set
69+
but never cleared makes the **first** wait lock the timer out for ever, so ordinary sequential use
70+
breaks. Two separate tests pin itone for repeated successful ticks, one for repeated
71+
disposed returns.
72+
73+
## 5. What did not change
74+
75+
Single-consumer usewhich is every correct useis **completely unchanged**: same ticks, same
76+
timing, same `false` after `Dispose()`. All 475 pre-existing `SharpRuntimeTests_Threading` cases
77+
passed unchanged before the new ones were added.
78+
79+
## 6. To migrate
80+
81+
If you were sharing one `PeriodicTimer` across threads, you were consuming ticks twice. Give each
82+
consumer its own timer, or serialise the waits behind your own lock. .NET has never supported the
83+
shared pattern; this port simply failed to say so.
84+
85+
## 7. Scope
86+
87+
This is **one of the four members** of ticket #1957. SR-AUD-202 (`Monitor::Wait` recursion) landed
88+
earlier as #2341 under the same section's item-1 carve-out. **SR-AUD-204**
89+
(`ReaderWriterLockSlim` writer preference) and **SR-AUD-210** (`Barrier` post-phase action
90+
deadlock) are untouched and #1957 stays open for themSR-AUD-204 in particular introduces writer
91+
preference, which is a fairness change that alters which workloads block, and it deserves its own
92+
landing with its own TSan run.
93+
94+
## 8. Evidence
95+
96+
Three mutations, **all caught**:
97+
98+
| Mutation | Caught by |
99+
|---|---|
100+
| M1the guard is removed | `Fix1957_ASecondConcurrentConsumerThrows` (the second consumer blocked for the full 3-second period instead of throwing) |
101+
| M2the flag is never cleared | `Fix1957_TheFlagIsClearedSoSequentialWaitsStillWork`, `Fix1957_TheFlagIsClearedAfterADisposedReturn` |
102+
| M3the guard runs after the disposed check | **not caughtand it cannot be, deterministically. See below.** |
103+
104+
**M3 is reported uncaught rather than papered over.** Moving the guard below the disposed check
105+
changes the answer only when `activeWait_` is true *and* the timer is already disposed — that is,
106+
a second consumer must arrive after `Dispose()` has set the flag but before the parked first
107+
consumer has reacquired the mutex and cleared it. That window is a genuine race: `Dispose()`
108+
releases the mutex before `notify_all`, so whether the second caller or the waking first consumer
109+
acquires it next is unspecified. A test for it would pass sometimes, and this session has twice
110+
*repaired* flaky tests rather than written one (#2352, #2166) — a gate that is intermittently
111+
green is not evidence.
112+
113+
The ordering is kept because it is .NET's, not because a test forces it, and the comment at the
114+
site says exactly that.
115+
116+
Every concurrency case is written with a bounded handshake and a detached-then-disposed first
117+
consumer, because a regression here is a **hang** rather than a wrong value, and joining a stuck
118+
thread would take the whole executable down instead of failing one assertion.
119+
120+
Gate: **17,456 run, 17,456 passed, 0 failed, 0 skipped** across 38 executables — `+5` on 17,451,
121+
exactly the five new cases (`SharpRuntimeTests_Threading` 475480). No other executable moved.
122+
Module graph unchanged at 41/93.
123+
124+
## 9. Downstream, measured
125+
126+
`PeriodicTimer` appears in **zero** places in `cna` and **zero** in `mobile-eggbert`. Neither
127+
repository was modified.

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include <thread>
1111
#include "System/ArgumentOutOfRangeException.hpp"
1212
#include "System/IDisposable.hpp"
13+
#include "System/InvalidOperationException.hpp"
1314
#include "System/Threading/Timeout.hpp"
1415
#include "System/TimeSpan.hpp"
1516

@@ -27,6 +28,17 @@ namespace System::Threading {
2728
std::condition_variable cv_;
2829
std::atomic<bool> disposed_{false};
2930

31+
// Ticket #1957 / SR-AUD-201. .NET's State carries `private bool _activeWait`
32+
// (PeriodicTimer.cs:192) for exactly this, and its WaitForNextTickAsync opens by
33+
// testing it and throwing (PeriodicTimer.cs:199-203) under the comment
34+
// "WaitForNextTickAsync should only be used by one consumer at a time. Failing to do
35+
// so is an error."
36+
//
37+
// Without it, two concurrent waiters both returned true for ONE tick -- the audit's
38+
// probe measured `concurrent=1,1` -- so a caller that accidentally shared a timer got
39+
// twice the intended work rate with no diagnostic at all.
40+
bool activeWait_ = false;
41+
3042
public:
3143
/**
3244
* @brief Constructs a PeriodicTimer with the specified period.
@@ -78,7 +90,36 @@ namespace System::Threading {
7890
*/
7991
bool WaitForNextTick() {
8092
std::unique_lock<std::mutex> lock(mtx_);
93+
94+
// FIRST, before every other test, because that is where .NET puts it: the
95+
// _activeWait check precedes both the cancellation short-circuit and the
96+
// already-signalled fast path (PeriodicTimer.cs:197-213). So a second consumer
97+
// arriving while the first waits is refused even if the timer has since been
98+
// disposed -- it is the CONCURRENT USE that is the error, not the timer's state.
99+
//
100+
// NO TEST FORCES THIS ORDERING, and that is recorded rather than hidden: moving the
101+
// check below the disposed test changes the answer only when a second consumer
102+
// arrives after Dispose() but before the parked first consumer reacquires the mutex
103+
// and clears the flag. Dispose() releases the mutex before notify_all, so which of
104+
// the two acquires it next is unspecified -- a test for it would be flaky, and a
105+
// gate that is intermittently green is not evidence (#2352, #2166). The ordering is
106+
// here because it is .NET's.
107+
if (activeWait_) {
108+
throw System::InvalidOperationException(
109+
"WaitForNextTick should only be used by one consumer at a time.");
110+
}
111+
81112
if (disposed_.load()) return false;
113+
114+
// Cleared on EVERY exit -- the ordinary returns, the disposed return, and any
115+
// exception -- so a caller that abandons a wait does not lock the timer out
116+
// permanently. .NET clears it in the completion path (PeriodicTimer.cs:296).
117+
activeWait_ = true;
118+
struct ActiveWaitGuard {
119+
bool& flag;
120+
~ActiveWaitGuard() { flag = false; }
121+
} guard{activeWait_};
122+
82123
if (infinite_) {
83124
cv_.wait(lock, [this] { return disposed_.load(); });
84125
return false;

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,3 +739,106 @@ TEST(ThreadingArgumentDomainTests, PeriodicTimer_CeilingIsTestedAfterTruncation)
739739
EXPECT_THROW(PeriodicTimer(System::TimeSpan::FromMilliseconds(kMax + 1.0)),
740740
System::ArgumentOutOfRangeException);
741741
}
742+
743+
// =============================================================================================
744+
// Ticket #1957 / SR-AUD-201 — PeriodicTimer::WaitForNextTick is single-consumer.
745+
//
746+
// Two concurrent waiters both returned true for ONE tick (the audit's probe measured
747+
// `concurrent=1,1`), so a caller that accidentally shared a timer got twice the intended work
748+
// rate with no diagnostic at all.
749+
//
750+
// .NET carries `private bool _activeWait` (PeriodicTimer.cs:192) and opens
751+
// WaitForNextTickAsync by testing it and throwing (PeriodicTimer.cs:199-203), under the comment
752+
// "WaitForNextTickAsync should only be used by one consumer at a time. Failing to do so is an
753+
// error." The type's own summary says the same: "This timer is intended to be used only by a
754+
// single consumer at a time" (PeriodicTimer.cs:13-14).
755+
//
756+
// This resolves the design record's one [unverified] flag, which asked "whether .NET throws or
757+
// blocks the second consumer must be confirmed against the reference before landing"
758+
// (docs/ThreadingNamespaceReviewPlan.md section 20.2 item 4). It throws.
759+
//
760+
// Landed under SA-3 (a private data member, sizeof pinned) + SA-5 (the behaviour is derived).
761+
// =============================================================================================
762+
763+
TEST(PeriodicTimerSingleConsumerTests, Decl1957_TheGuardCostsNoLayout) {
764+
// SA-3's pinned measurement. The new bool fits in padding the type already had, so the size
765+
// is unchanged and no consumer needs a rebuild for layout -- measured 128 before and 128
766+
// after (build-probe/1957_probe1_layout.cpp).
767+
static_assert(sizeof(System::Threading::PeriodicTimer) == 128,
768+
"#1957/SR-AUD-201 must not grow PeriodicTimer");
769+
static_assert(alignof(System::Threading::PeriodicTimer) == 8);
770+
EXPECT_EQ(sizeof(System::Threading::PeriodicTimer), 128u);
771+
}
772+
773+
TEST(PeriodicTimerSingleConsumerTests, Fix1957_ASecondConcurrentConsumerThrows) {
774+
System::Threading::PeriodicTimer timer(System::TimeSpan::FromMilliseconds(3000));
775+
776+
std::atomic<bool> firstIsWaiting{false};
777+
std::atomic<bool> secondThrew{false};
778+
std::atomic<bool> secondReturned{false};
779+
780+
// The first consumer parks in a long wait. Detached with a bounded handshake, because a
781+
// regression here is a HANG rather than a wrong value, and joining a stuck thread would take
782+
// the whole executable down instead of failing one assertion.
783+
std::thread first([&] {
784+
firstIsWaiting.store(true);
785+
(void)timer.WaitForNextTick();
786+
});
787+
788+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(4);
789+
while (!firstIsWaiting.load() && std::chrono::steady_clock::now() < deadline)
790+
std::this_thread::yield();
791+
ASSERT_TRUE(firstIsWaiting.load()) << "the first consumer never started";
792+
// Give the first consumer time to reach the wait and publish activeWait_ under the mutex.
793+
std::this_thread::sleep_for(std::chrono::milliseconds(150));
794+
795+
std::thread second([&] {
796+
try {
797+
(void)timer.WaitForNextTick();
798+
secondReturned.store(true);
799+
} catch (const System::InvalidOperationException&) {
800+
secondThrew.store(true);
801+
} catch (...) {
802+
}
803+
});
804+
second.join();
805+
806+
EXPECT_TRUE(secondThrew.load())
807+
<< "a second concurrent consumer must be refused, not silently served";
808+
EXPECT_FALSE(secondReturned.load())
809+
<< "two waiters must not both consume the same tick";
810+
811+
timer.Dispose();
812+
first.join();
813+
}
814+
815+
TEST(PeriodicTimerSingleConsumerTests, Fix1957_TheFlagIsClearedSoSequentialWaitsStillWork) {
816+
// The half a naive guard gets wrong: if the flag is not cleared on every exit, the FIRST
817+
// wait locks the timer out for ever and ordinary sequential use breaks.
818+
System::Threading::PeriodicTimer timer(System::TimeSpan::FromMilliseconds(1));
819+
EXPECT_TRUE(timer.WaitForNextTick());
820+
EXPECT_TRUE(timer.WaitForNextTick());
821+
EXPECT_TRUE(timer.WaitForNextTick());
822+
}
823+
824+
TEST(PeriodicTimerSingleConsumerTests, Fix1957_TheFlagIsClearedAfterADisposedReturn) {
825+
// The exit path that returns false rather than a tick must clear the flag too, or a disposed
826+
// timer would start throwing instead of returning false to subsequent callers.
827+
System::Threading::PeriodicTimer timer(System::TimeSpan::FromMilliseconds(1));
828+
timer.Dispose();
829+
EXPECT_FALSE(timer.WaitForNextTick());
830+
EXPECT_FALSE(timer.WaitForNextTick()) << "a disposed timer keeps returning false, not throwing";
831+
}
832+
833+
TEST(PeriodicTimerSingleConsumerTests, Fix1957_SingleConsumerUseIsCompletelyUnchanged) {
834+
// The contract everybody actually uses: one consumer, ticks delivered, disposal ends it.
835+
System::Threading::PeriodicTimer timer(System::TimeSpan::FromMilliseconds(1));
836+
int ticks = 0;
837+
for (int i = 0; i < 5; ++i) {
838+
ASSERT_TRUE(timer.WaitForNextTick());
839+
++ticks;
840+
}
841+
EXPECT_EQ(ticks, 5);
842+
timer.Dispose();
843+
EXPECT_FALSE(timer.WaitForNextTick());
844+
}

plan.sqlite3

8 KB
Binary file not shown.

0 commit comments

Comments
 (0)