Skip to content

Commit 6207363

Browse files
committed
feat(threading): ThreadLocal's trackAllValues means something, and Values exists (#1958, SR-AUD-220)
Rule-14 sweep. trackAllValues was accepted and never read, and the type had no Values property at all -- two halves of one finding, inseparable because the flag is only observable through the property it gates. A caller who asked for tracking got a silent no-op with no way to notice. Values is transcribed from ThreadLocal.cs:421-434, message included, and the tracking check precedes the disposed check, so a disposed untracked instance reports InvalidOperationException. The lifetime question decided the design and rules out the cheap answer: GetValuesAsList walks the ThreadLocal's OWN LinkedSlot list, so a value survives its thread exiting. The registry therefore holds strong references and per-thread storage moved unique_ptr -> shared_ptr; a weak_ptr registry would silently drop a dead thread's value, which .NET does not do. Co-ownership rather than copying is also what makes an update reflected rather than duplicated. sizeof(ThreadLocal<int>) 56 -> 128; consumers must rebuild. Six mutations, all caught -- two only after the tests meant to catch them were found vacuous, and both are recorded: * the check-order mutation CANNOT be asserted with EXPECT_THROW, because ObjectDisposedException derives from InvalidOperationException (the #2152 trap), so the derived type must be caught first; * Dispose's registry release is unreachable through the public surface -- after Dispose, Values throws either way -- so its only observable is WHEN values are destroyed, tested by letting the owning thread exit and counting destructor calls. Downstream measured: 0 sites in cna, 0 in mobile-eggbert. #1958 stays open for SR-AUD-209 (vtable/base-class change), SR-AUD-194 (signature change, landable under SA-10 but separate work) and SR-AUD-196. Gate: 17,490 run, 17,490 passed, 0 failed, 0 skipped across 38 executables (+8 on 17,482; SharpRuntimeTests_Threading 506 -> 514; no other executable moved). Module graph unchanged at 41/93.
1 parent 17079bd commit 6207363

6 files changed

Lines changed: 360 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `ThreadLocal`'s `trackAllValues` now means something, and `Values` exists (ticket #1958, SR-AUD-220)
5+
6+
*2026-08-19.* `System::Threading::ThreadLocal<T>` gains `getValuesProperty()`, and the
7+
`trackAllValues` constructor flag — previously accepted and **never read** — now controls it.
8+
9+
**`sizeof(ThreadLocal<int>)` grows 56 → 128, so consumers must be recompiled.** Landed under
10+
**SA-5** (the behaviour is derived; the property is additive) with **SA-3**'s layout condition
11+
discharged.
12+
13+
---
14+
15+
## 1. What was wrong
16+
17+
Two halves of one finding, and they are inseparable: the flag was **accepted and never read**, and
18+
the type exposed **no `Values` property at all**. A caller who asked for tracking got a silent
19+
no-op, and had no way to notice — the flag is only observable through the property it gates.
20+
21+
## 2. What .NET does
22+
23+
```csharp
24+
public IList<T> Values
25+
{
26+
get
27+
{
28+
if (!_trackAllValues)
29+
{
30+
throw new InvalidOperationException(SR.ThreadLocal_ValuesNotAvailable);
31+
}
32+
33+
List<T>? list = GetValuesAsList(); // returns null if disposed
34+
ObjectDisposedException.ThrowIf(list is null, this);
35+
return list;
36+
}
37+
} // ThreadLocal.cs:421-434
38+
```
39+
40+
The message is transcribed verbatim from `Strings.resx`.
41+
42+
**The tracking check comes first, and that is observable**: a disposed instance built *without*
43+
tracking reports `InvalidOperationException`, not `ObjectDisposedException`. The order is .NET's
44+
and a test pins both directions.
45+
46+
## 3. Values outlive their threads — and that decided the design
47+
48+
`GetValuesAsList` walks the `ThreadLocal`'s **own** linked list of `LinkedSlot`s
49+
(`ThreadLocal.cs:437-456, 584-598`), so a value survives the thread that created it and is
50+
released when the `ThreadLocal` is disposed.
51+
52+
That rules out the plausible cheap design. The registry holds **strong** references, not
53+
`weak_ptr` — a weak registry would silently drop a dead thread's value, which .NET does not do.
54+
The per-thread storage therefore moved from `unique_ptr<T>` to `shared_ptr<T>`, so one value is
55+
co-owned by the owning thread's map and the instance-wide registry.
56+
57+
**Co-ownership, not copying**, is also why an update is reflected rather than duplicated: the
58+
setter writes through the shared object, so `Values` sees the new value without a second entry. A
59+
registry that stored a copy at creation time would fail both of those — and that is mutation M5.
60+
61+
## 4. What changes
62+
63+
| | Was | Is |
64+
|---|---|---|
65+
| `getValuesProperty()` | **absent** | returns `std::vector<T>`, a snapshot |
66+
| …without `trackAllValues` || `InvalidOperationException` |
67+
| …when disposed **and** tracking || `ObjectDisposedException` |
68+
| …when disposed **and not** tracking || `InvalidOperationException` — tracking is checked first |
69+
| `trackAllValues` | accepted, ignored | controls the above |
70+
| per-thread storage | `unique_ptr<T>` | `shared_ptr<T>` — internal, no API effect |
71+
| `Dispose()` | cleared the thread's slot | also releases the registry, as .NET unlinks its slots |
72+
| `sizeof(ThreadLocal<int>)` | **56** | **128** |
73+
74+
An instance built **without** tracking pays a mutex it never locks and nothing else — the
75+
registry is only populated when the flag is set.
76+
77+
`std::vector<T>` by value is the return shape because .NET's `IList<T>` has no counterpart here,
78+
and `GetValuesAsList` builds a fresh list on every call anyway; mutating the result cannot affect
79+
the instance, which is true of .NET's copy too.
80+
81+
## 5. Evidence
82+
83+
Six mutations, **all caught** — but two only after the tests that were supposed to catch them
84+
were found to be vacuous, and both defects are worth recording.
85+
86+
| Mutation | Caught by |
87+
|---|---|
88+
| M1 — the tracking check is removed | `Fix1958_ValuesThrowsWhenNotTracking` |
89+
| M2 — the check order is inverted | `Decl1958_TheTrackingCheckPrecedesTheDisposedCheck`**after repair**, below |
90+
| M3 — the factory path is not tracked | `Fix1958_TheFactoryPathIsTrackedToo` |
91+
| M4 — the setter path is not tracked | four cases |
92+
| M5 — the registry stores a copy, not the pointer | `Fix1958_AnUpdatedValueIsReflectedNotDuplicated` |
93+
| M6 — `Dispose` does not release the registry | `Fix1958_DisposeReleasesTheTrackedValues`**after repair**, below |
94+
95+
**M2 could not be asserted with `EXPECT_THROW` at all.** `ObjectDisposedException` **derives
96+
from** `InvalidOperationException`, here as in .NET, so `EXPECT_THROW(…, InvalidOperationException)`
97+
passes whichever check fired and the inverted order went uncaught. The test now catches the
98+
derived type first and requires the base one — the same trap #2152 recorded for the compression
99+
streams.
100+
101+
**M6's release is unreachable through the public surface.** After `Dispose`, `Values` throws
102+
whether or not the registry was cleared, so asserting that it throws proves nothing. The only
103+
observable is *when* the values are destroyed — so the test creates the value on a worker thread
104+
which then **exits**, leaving the registry as the sole owner, and counts destructor calls across
105+
`Dispose`.
106+
107+
The factory and setter paths are tested separately because they are **two different creation
108+
sites** — tracking one and not the other is the easy half-repair.
109+
110+
Gate: **17,490 run, 17,490 passed, 0 failed, 0 skipped** across 38 executables — `+8` on 17,482,
111+
exactly the eight new cases (`SharpRuntimeTests_Threading` 506 → 514). No other executable moved.
112+
All 506 pre-existing cases passed unchanged before the new ones were added. Module graph
113+
unchanged at 41/93.
114+
115+
## 6. Downstream, measured
116+
117+
`ThreadLocal` appears in **zero** places in `cna` and **zero** in `mobile-eggbert`, so the rebuild
118+
requirement is recorded here for future consumers rather than acted on. Neither repository was
119+
modified.
120+
121+
## 7. Scope
122+
123+
This is one of #1958's eight findings. SR-AUD-193 (`ManagedThreadId` uniqueness) landed earlier
124+
today; SR-AUD-189 and SR-AUD-214 landed as #1971; SR-AUD-215 was excluded there with a measured
125+
reason. **SR-AUD-209** (making the two events derive from `WaitHandle` — a vtable and base-class
126+
change SA-3 excludes), **SR-AUD-194** (`Thread::Start(void*)` discards its parameter — a public
127+
signature change) and **SR-AUD-196** (`ThreadStartException` publishes constructors .NET makes
128+
internal) remain, and #1958 stays open for them.

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

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
#include <cstdint>
77
#include <functional>
88
#include <memory>
9+
#include <mutex>
10+
#include <vector>
911
#include <unordered_map>
1012
#include <unordered_set>
1113
#include "System/ArgumentNullException.hpp"
@@ -41,8 +43,15 @@ namespace System::Threading {
4143
}
4244
std::uint64_t id_ = nextId().fetch_add(1, std::memory_order_relaxed);
4345

44-
static std::unordered_map<std::uint64_t, std::unique_ptr<T>>& storageMap() {
45-
static thread_local std::unordered_map<std::uint64_t, std::unique_ptr<T>> map;
46+
// Ticket #1958 / SR-AUD-220. This was unique_ptr<T>. A tracked value must be reachable
47+
// from BOTH the owning thread's map and the instance-wide registry, and it must OUTLIVE
48+
// the owning thread -- .NET's LinkedSlot hangs off the ThreadLocal's own linked list and
49+
// GetValuesAsList walks that list (ThreadLocal.cs:437-456, 584-598), so a value survives
50+
// its thread exiting and is released when the ThreadLocal is. shared_ptr is the direct
51+
// counterpart; a weak_ptr registry would silently drop dead threads' values, which .NET
52+
// does not do.
53+
static std::unordered_map<std::uint64_t, std::shared_ptr<T>>& storageMap() {
54+
static thread_local std::unordered_map<std::uint64_t, std::shared_ptr<T>> map;
4655
return map;
4756
}
4857

@@ -60,6 +69,20 @@ namespace System::Threading {
6069

6170
std::function<T()> factory_;
6271
bool trackAllValues_ = false;
72+
// Ticket #1958 / SR-AUD-220. trackAllValues_ was ACCEPTED AND NEVER READ, and the type
73+
// exposed no Values property at all -- so a caller who asked for tracking got a silent
74+
// no-op and had no way to notice. These two members are what makes the flag mean
75+
// something. They are populated ONLY when trackAllValues_ is set, so an untracking
76+
// instance pays a mutex it never locks and nothing else.
77+
mutable std::mutex trackedMutex_;
78+
std::vector<std::shared_ptr<T>> trackedValues_;
79+
80+
/// Registers a newly created value with the instance-wide registry, when tracking.
81+
void trackIfRequested(const std::shared_ptr<T>& value) {
82+
if (!trackAllValues_) return;
83+
std::lock_guard<std::mutex> lk(trackedMutex_);
84+
trackedValues_.push_back(value);
85+
}
6386
// Ticket #1955 / cause T-A of docs/ThreadingNamespaceReviewPlan.md. This was an
6487
// ordinary `bool`, written by Dispose() and read by the guard below with no
6588
// synchronisation between them. Mixing synchronised and unsynchronised access to the
@@ -148,14 +171,15 @@ namespace System::Threading {
148171
if (!active.insert(id_).second)
149172
throw System::InvalidOperationException(
150173
"ValueFactory attempted to access the Value property of this instance.");
151-
std::unique_ptr<T> value;
174+
std::shared_ptr<T> value;
152175
try {
153-
value = std::make_unique<T>(factory_ ? factory_() : T{});
176+
value = std::make_shared<T>(factory_ ? factory_() : T{});
154177
} catch (...) {
155178
active.erase(id_);
156179
throw;
157180
}
158181
active.erase(id_);
182+
trackIfRequested(value);
159183
it = map.emplace(id_, std::move(value)).first;
160184
}
161185
return *it->second;
@@ -169,17 +193,67 @@ namespace System::Threading {
169193
ThrowIfDisposed();
170194
auto& map = storageMap();
171195
auto it = map.find(id_);
172-
if (it == map.end()) map.emplace(id_, std::make_unique<T>(v));
173-
else *it->second = v;
196+
if (it == map.end()) {
197+
auto value = std::make_shared<T>(v);
198+
trackIfRequested(value);
199+
map.emplace(id_, std::move(value));
200+
} else {
201+
// An existing value is UPDATED IN PLACE, so the registry -- which co-owns the
202+
// same object -- sees the new value without a second entry. That is why the
203+
// registry holds the pointer rather than a copy.
204+
*it->second = v;
205+
}
174206
}
175207

176208
/** Returns the value for the current thread (alias for getValueProperty). */
177209
[[nodiscard]] T& Value() { return getValueProperty(); }
178210

211+
/**
212+
* @brief Returns the values held for every thread that has one, as a snapshot.
213+
* @throws System::InvalidOperationException if this instance was not constructed with
214+
* `trackAllValues = true`.
215+
* @throws System::ObjectDisposedException if this instance has been disposed.
216+
*
217+
* Ticket #1958 / SR-AUD-220. Transcribed from `ThreadLocal<T>.Values`
218+
* (`ThreadLocal.cs:421-434`):
219+
* @code
220+
* if (!_trackAllValues) throw new InvalidOperationException(SR.ThreadLocal_ValuesNotAvailable);
221+
* List<T>? list = GetValuesAsList(); // returns null if disposed
222+
* ObjectDisposedException.ThrowIf(list is null, this);
223+
* @endcode
224+
*
225+
* @note **The tracking check comes FIRST, before the disposed check, and that is
226+
* observable**: a disposed instance built WITHOUT tracking reports
227+
* `InvalidOperationException`, not `ObjectDisposedException`. The order is .NET's and a
228+
* test pins it.
229+
*
230+
* @note Returns `std::vector<T>` by value -- a snapshot, as .NET's `GetValuesAsList`
231+
* builds a fresh `List<T>` on every call. .NET's declared return type is `IList<T>`,
232+
* which this port has no counterpart for; the vector is the closest faithful shape and
233+
* mutating it cannot affect the instance, which is also true of .NET's copy.
234+
*/
235+
[[nodiscard]] std::vector<T> getValuesProperty() const {
236+
if (!trackAllValues_) {
237+
throw System::InvalidOperationException(
238+
"The ThreadLocal object is not tracking values. To use the Values property, "
239+
"use a ThreadLocal constructor that accepts the trackAllValues parameter and "
240+
"set the parameter to true.");
241+
}
242+
ThrowIfDisposed();
243+
std::lock_guard<std::mutex> lk(trackedMutex_);
244+
std::vector<T> snapshot;
245+
snapshot.reserve(trackedValues_.size());
246+
for (const auto& v : trackedValues_) snapshot.push_back(*v);
247+
return snapshot;
248+
}
249+
179250
/** Releases resources for the current thread's value. */
180251
void Dispose() override {
181252
disposed_.store(true, std::memory_order_release);
182253
storageMap().erase(id_);
254+
// .NET's Dispose unlinks every LinkedSlot, releasing the tracked values with it.
255+
std::lock_guard<std::mutex> lk(trackedMutex_);
256+
trackedValues_.clear();
183257
}
184258
};
185259

0 commit comments

Comments
 (0)