Skip to content

Commit 839d2b3

Browse files
committed
fix(threading): give every thread a distinct ManagedThreadId (#1958, SR-AUD-193)
Thread::CurrentThread().getManagedThreadIdProperty() returned the literal 1 from every thread not created through a System::Threading::Thread object -- the main thread, every raw std::thread, every std::async worker, every pool thread -- so all of them collided with each other and with the main thread. .NET's ManagedThreadId is "a unique identifier for the current managed thread" with no path returning a shared constant, so anything keyed on it was keyed on the single value 1. The id is now a thread_local assigned on first use, drawn from nextManagedId_ -- the SAME counter Thread's constructor uses. No signature, layout, vtable or noexcept change; no consumer rebuild. Landed under SA-5. The main thread keeps 1 by IDENTITY rather than by arriving first: its OS id is captured during static initialisation, which runs on it. "Give 1 to whoever asks first" hands it to a worker whenever one asks before main does, which fails intermittently rather than consistently. One counter, not two. Uniqueness is across all threads, not within each kind; a separate external counter gives each kind internally distinct ids and still lets the two kinds collide. That was mutation M4, and it survived the first three tests. The obvious test for it does not work either: collecting four ids of each kind and asserting eight distinct values PASSES against the mutation, because two counters only collide where their ranges overlap and a fresh one sits far below the shared one. The discriminating assertion is ORDERING -- an external id taken after a Thread object's id must exceed it. Four mutations, all caught. #1958's own description is stale and is corrected rather than copied: SR-AUD-214 and SR-AUD-189 were landed by #1971 on 2026-08-03, and SR-AUD-215 was deliberately excluded there with a measured reason. The ticket stays blocked for SR-AUD-209 (vtable change), SR-AUD-194 (signature change), SR-AUD-196 (removes public constructors) and SR-AUD-220 (storage on a public template). Downstream measured: 0 sites in cna, 0 in mobile-eggbert. Gate: 17,420 run, 17,420 passed, 0 failed, 0 skipped across 38 executables (+4 on 17,416; SharpRuntimeTests_Threading 471 -> 475; no other executable moved).
1 parent 78e520e commit 839d2b3

5 files changed

Lines changed: 283 additions & 6 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 — every thread now has a distinct `ManagedThreadId` (ticket #1958, SR-AUD-193)
5+
6+
*2026-08-19.* `System::Threading::Thread::CurrentThread().getManagedThreadIdProperty()` returned
7+
**1** from every thread that was not created through a `System::Threading::Thread` object. The
8+
main thread reported 1, and so did every raw `std::thread`, every thread-pool worker, and every
9+
thread `std::async` created — all of them the same number, all of them colliding with the main
10+
thread and with each other.
11+
12+
Landed under `docs/StandingApprovals.md` **SA-5** (aligning to the reference is ordinary work).
13+
**No public signature, object layout, vtable or `noexcept` change**, so consumers need no rebuild
14+
and no source edit. The id lives in a `thread_local`.
15+
16+
---
17+
18+
## 1. What changed
19+
20+
| Caller | Was | Is |
21+
|---|---|---|
22+
| the main thread | `1` | `1` — unchanged, but now by **identity** (§3) |
23+
| a raw `std::thread` | `1` | a distinct id, `>= 2` |
24+
| a `std::async` / pool worker | `1` | a distinct id, `>= 2` |
25+
| the same thread, asked twice | `1`, `1` | the same id both times |
26+
| two different external threads | `1`, `1` | two different ids |
27+
| inside a `Thread` object's own body | that object's id | unchanged |
28+
| `someThread.getManagedThreadIdProperty()` | that object's id | unchanged |
29+
30+
Anything that already reported a real id keeps reporting exactly the same one. The change is
31+
confined to threads that used to fall through to the hard-coded `1`.
32+
33+
## 2. Why
34+
35+
.NET's contract is stated in `Thread.cs` and is unconditional:
36+
37+
> `ManagedThreadId` … "a unique identifier for the current managed thread"
38+
39+
and its `ManagedThreadId` is read from the runtime's own per-thread block, which every thread has
40+
— managed, native-entered, or thread-pool. There is no path in .NET that returns a shared
41+
constant. A caller that keys a map, a lock-ownership record, a re-entrancy guard or a log column
42+
on this id was, in this port, keying all of them on the single value `1`.
43+
44+
The old body said as much:
45+
46+
```cpp
47+
return currentThreadState_ ? currentThreadState_->managedThreadId : 1;
48+
```
49+
50+
`currentThreadState_` is only set by this port's own `Thread::Start`, so the `: 1` arm was every
51+
other thread in the process.
52+
53+
## 3. The main thread keeps 1, and how it keeps it is the interesting part
54+
55+
The naive repair — hand out `1` to whichever thread asks first, then `2, 3, …` — is wrong, and
56+
silently so. Nothing guarantees the main thread asks first; a worker started during static
57+
initialisation, or a logging thread that stamps its id before `main` runs, would take `1` and the
58+
main thread would become an ordinary numbered worker. The failure is intermittent, which is worse
59+
than consistent.
60+
61+
The id is therefore assigned by **identity**:
62+
63+
```cpp
64+
inline static const std::thread::id mainThreadOsId_ = std::this_thread::get_id();
65+
```
66+
67+
That initialiser runs during static initialisation, which runs on the main thread, so
68+
`mainThreadOsId_` is the main thread's OS id no matter who asks first afterwards.
69+
70+
## 4. One counter, not two
71+
72+
External threads draw from `nextManagedId_` — the **same** counter `Thread`'s constructor uses.
73+
That is not an implementation detail: uniqueness is across *all* threads, not within each kind. A
74+
separate counter for external threads would give each kind internally distinct ids and still let
75+
the two kinds collide with each other, which is the whole defect in a different costume.
76+
77+
This is the mutation that survived the first three tests (§6) and needed a case of its own.
78+
79+
## 5. Assigned on first use
80+
81+
A thread that never reads the property costs nothing — no counter increment, no allocation. The
82+
`thread_local` starts at `0`, which is not a legal managed id, and is filled in on the first
83+
call. So the id sequence reflects the order in which threads *asked*, not the order in which they
84+
started; .NET's does the same, and no documented behaviour depends on the numeric order.
85+
86+
## 6. Evidence
87+
88+
Four mutations, **all caught**:
89+
90+
| Mutation | Caught by |
91+
|---|---|
92+
| M1 — external threads report `1` again | `Fix1958_193_ExternalThreadsGetDistinctIds` |
93+
| M2 — the id is reassigned on every call | `Fix1958_193_TheIdIsStableWithinAThread` |
94+
| M3 — the main thread loses its `1` | `Fix1958_193_ExternalThreadsGetDistinctIds` |
95+
| M4 — external ids come from a **separate** counter | `Fix1958_193_ExternalAndWrapperIdsShareOneCounter` |
96+
97+
**M4 is worth recording in full, because the obvious test for it does not work.** Collecting four
98+
wrapper ids and four external ids and asserting the set has eight members *passes* against the
99+
mutation: two counters only collide where their ranges overlap, and in a test binary that has
100+
already created dozens of threads a freshly-started second counter sits far below the shared one,
101+
so the ids come out distinct — for the wrong reason. The assertion that discriminates is
102+
**ordering**: take a `Thread` object's id, then an external id assigned after it, and require the
103+
external one to be larger. That holds under one counter and fails under two, on the first run,
104+
with no dependence on how many threads ran before.
105+
106+
Gate: **17,420 run, 17,420 passed, 0 failed, 0 skipped** across 38 executables — `+4` on 17,416,
107+
exactly the four cases added to `SharpRuntimeTests_Threading` (471 → 475). No other executable's
108+
count moved.
109+
110+
## 7. Downstream, measured
111+
112+
Per SA-2 condition 5 (recorded here although SA-2 is not the approval this landed under, since a
113+
behaviour change deserves the same measurement): `getManagedThreadIdProperty` appears in **zero**
114+
places in `cna` and **zero** in `mobile-eggbert`. Neither repository was modified.
115+
116+
Any future consumer that used the id as a *thread-kind* discriminator — treating `1` as "not one
117+
of ours" — must stop; that reading was never what the property meant, and it no longer works.
118+
119+
## 8. What #1958 still covers
120+
121+
Ticket #1958 remains **blocked** for its other members, each of which needs an approval this one
122+
did not: SR-AUD-209 is a vtable/base-class change, SR-AUD-194 a public signature change,
123+
SR-AUD-196 removes public constructors, and SR-AUD-220 needs storage on a public template.
124+
125+
Two members listed in #1958's description are **already closed and the description is stale**:
126+
SR-AUD-214 and SR-AUD-189 were both landed by ticket #1971 on 2026-08-03. SR-AUD-215 was
127+
deliberately excluded by #1971 with a measured reason — `Capture()` returns `nullptr`
128+
unconditionally, so rejecting null would break every reachable call.

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

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,28 @@ namespace System::Threading {
6666
// Thread object itself.
6767
inline static thread_local std::shared_ptr<RunState> currentThreadState_;
6868

69+
// #1958 / SR-AUD-193. A thread NOT started through this class used to report managed id
70+
// 1 -- the main thread's -- so every external thread collided with it and with each
71+
// other, erasing the uniqueness .NET guarantees. Each OS thread now takes an id from the
72+
// same counter the wrapper uses, so no two threads can report the same one.
73+
//
74+
// The main thread keeps 1 by IDENTITY rather than by arriving first: its OS id is
75+
// captured during static initialisation, which runs on it. Assigning "1 to whoever asks
76+
// first" would hand it to a worker whenever a worker happens to ask before main does.
77+
inline static const std::thread::id mainThreadOsId_ = std::this_thread::get_id();
78+
inline static thread_local intcs currentExternalId_ = 0;
79+
80+
/** @brief The managed id of the calling thread, assigned on first use. */
81+
static intcs currentManagedThreadId() {
82+
if (currentThreadState_) return currentThreadState_->managedThreadId;
83+
if (currentExternalId_ == 0) {
84+
currentExternalId_ = (std::this_thread::get_id() == mainThreadOsId_)
85+
? 1
86+
: nextManagedId_.fetch_add(1);
87+
}
88+
return currentExternalId_;
89+
}
90+
6991
std::shared_ptr<RunState> state_ = std::make_shared<RunState>();
7092
std::function<void()> fn_;
7193
std::thread thread_;
@@ -315,13 +337,20 @@ namespace System::Threading {
315337
/**
316338
* @brief Returns the managed thread ID of the calling thread.
317339
*
318-
* Resolves to the same ManagedThreadId the owning Thread object reports, when the
319-
* calling thread was started via Thread::Start(); otherwise (the main thread, or
320-
* any thread not created through this class) returns 1, matching .NET's
321-
* main-thread convention.
340+
* Resolves to the same ManagedThreadId the owning Thread object reports when the
341+
* calling thread was started via Thread::Start(). Ticket #1958 / SR-AUD-193: a
342+
* thread NOT created through this class used to return 1 unconditionally -- the main
343+
* thread's id -- so every external thread collided with it and with every other
344+
* external thread. Each now takes a distinct id from the same counter, which is the
345+
* uniqueness .NET's ManagedThreadId guarantees.
346+
*
347+
* The main thread still reports 1, and now does so by identity rather than by
348+
* arriving first: its OS thread id is captured during static initialisation.
349+
*
350+
* The id is assigned on FIRST USE, so a thread that never asks costs nothing.
322351
*/
323352
[[nodiscard]] intcs getManagedThreadIdProperty() const {
324-
return currentThreadState_ ? currentThreadState_->managedThreadId : 1;
353+
return currentManagedThreadId();
325354
}
326355
/** @brief Returns whether the calling thread's owning Thread object is marked background. */
327356
[[nodiscard]] bool getIsBackgroundProperty() const {

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

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
// ManualResetEventSlim, ReaderWriterLockSlim, SpinWait, ThreadLocal, ThreadPool, Timer,
1010
// and Threading exceptions.
1111
#include <gtest/gtest.h>
12+
#include "System/Threading/Thread.hpp"
13+
#include <vector>
14+
#include <memory>
15+
#include <set>
1216
#include <atomic>
1317
#include <chrono>
1418
#include <future>
@@ -1033,3 +1037,119 @@ THREADING_EXCEPT_SIMPLE(WaitHandleCannotBeOpenedException)
10331037
TEST(WaitHandleCannotBeOpenedExceptionTests, IsA_ApplicationException) {
10341038
EXPECT_THROW(throw WaitHandleCannotBeOpenedException(), System::ApplicationException);
10351039
}
1040+
1041+
// ===========================================================================
1042+
// #1958 / SR-AUD-193 -- CurrentThread().ManagedThreadId is unique per thread.
1043+
//
1044+
// #1958 lists eight members; this is the one whose repair needs no approval
1045+
// boundary -- no signature, no layout, no vtable, and the id lives in a
1046+
// thread_local rather than in any public type. The other seven keep theirs, and
1047+
// two of the eight (SR-AUD-214, SR-AUD-189) were already landed by #1971, which
1048+
// the ticket's own description still describes as live.
1049+
// ===========================================================================
1050+
1051+
TEST(ThreadManagedIdTests, Fix1958_193_ExternalThreadsGetDistinctIds) {
1052+
// The defect: every thread not started through Thread reported 1 -- the main thread's id --
1053+
// so they collided with it and with each other.
1054+
const SharpRuntime::intcs mainId = System::Threading::Thread::CurrentThread().getManagedThreadIdProperty();
1055+
EXPECT_EQ(mainId, 1) << "the main thread keeps .NET's conventional id";
1056+
1057+
constexpr int kThreads = 8;
1058+
std::vector<SharpRuntime::intcs> ids(kThreads, 0);
1059+
std::vector<std::thread> workers;
1060+
for (int i = 0; i < kThreads; ++i) {
1061+
workers.emplace_back([&ids, i] {
1062+
// std::thread, NOT System::Threading::Thread -- these are exactly the "externally
1063+
// created" threads the finding is about.
1064+
ids[static_cast<size_t>(i)] =
1065+
System::Threading::Thread::CurrentThread().getManagedThreadIdProperty();
1066+
});
1067+
}
1068+
for (auto& w : workers) w.join();
1069+
1070+
std::set<SharpRuntime::intcs> distinct(ids.begin(), ids.end());
1071+
EXPECT_EQ(distinct.size(), static_cast<size_t>(kThreads)) << "ids collided";
1072+
EXPECT_EQ(distinct.count(mainId), 0u) << "an external thread took the main thread's id";
1073+
for (auto id : ids) EXPECT_GT(id, 1) << "an external thread reported the main-thread id";
1074+
}
1075+
1076+
TEST(ThreadManagedIdTests, Fix1958_193_TheIdIsStableWithinAThread) {
1077+
// Assigned on FIRST USE and then kept: two reads on one thread must agree, or the id is not
1078+
// an identity at all. The row that fails if the counter is bumped on every call.
1079+
SharpRuntime::intcs a = 0, b = 0;
1080+
std::thread worker([&a, &b] {
1081+
a = System::Threading::Thread::CurrentThread().getManagedThreadIdProperty();
1082+
b = System::Threading::Thread::CurrentThread().getManagedThreadIdProperty();
1083+
});
1084+
worker.join();
1085+
EXPECT_NE(a, 0);
1086+
EXPECT_EQ(a, b);
1087+
1088+
// And the main thread's is stable too.
1089+
EXPECT_EQ(System::Threading::Thread::CurrentThread().getManagedThreadIdProperty(),
1090+
System::Threading::Thread::CurrentThread().getManagedThreadIdProperty());
1091+
}
1092+
1093+
TEST(ThreadManagedIdTests, Fix1958_193_ExternalAndWrapperIdsShareOneCounter) {
1094+
// UNIQUENESS IS ACROSS ALL THREADS, not within each kind. A separate counter for external
1095+
// threads would give each kind distinct ids and still let the two kinds collide -- which is
1096+
// exactly the mutation this case was added for, after it went uncaught with only the
1097+
// per-kind tests above.
1098+
// Thread is non-copyable (one owned OS handle per object), so the objects are held by
1099+
// pointer rather than by value.
1100+
std::vector<SharpRuntime::intcs> wrapperIds;
1101+
std::vector<std::unique_ptr<System::Threading::Thread>> wrappers;
1102+
for (int i = 0; i < 4; ++i) {
1103+
wrappers.push_back(std::make_unique<System::Threading::Thread>([] {}));
1104+
wrapperIds.push_back(wrappers.back()->getManagedThreadIdProperty());
1105+
}
1106+
1107+
std::vector<SharpRuntime::intcs> externalIds(4, 0);
1108+
std::vector<std::thread> workers;
1109+
for (int i = 0; i < 4; ++i)
1110+
workers.emplace_back([&externalIds, i] {
1111+
externalIds[static_cast<size_t>(i)] =
1112+
System::Threading::Thread::CurrentThread().getManagedThreadIdProperty();
1113+
});
1114+
for (auto& w : workers) w.join();
1115+
1116+
std::set<SharpRuntime::intcs> all(wrapperIds.begin(), wrapperIds.end());
1117+
for (auto id : externalIds) {
1118+
EXPECT_EQ(all.count(id), 0u)
1119+
<< "external id " << id << " collided with a Thread object's id";
1120+
all.insert(id);
1121+
}
1122+
EXPECT_EQ(all.size(), 8u);
1123+
1124+
// THE ASSERTION THAT ACTUALLY DISCRIMINATES A SECOND COUNTER, and it was added after the
1125+
// set-distinctness above failed to: two counters only collide where their ranges overlap, and
1126+
// in a binary that has already created many threads a fresh second counter sits far below
1127+
// the shared one, so the ids come out distinct for the wrong reason.
1128+
//
1129+
// Ordering is the invariant a single counter really provides: an id handed out LATER is
1130+
// larger. Take a wrapper id, then an external one after it, and the external must exceed it.
1131+
System::Threading::Thread marker([] {});
1132+
const SharpRuntime::intcs wrapperId = marker.getManagedThreadIdProperty();
1133+
SharpRuntime::intcs afterId = 0;
1134+
std::thread later([&afterId] {
1135+
afterId = System::Threading::Thread::CurrentThread().getManagedThreadIdProperty();
1136+
});
1137+
later.join();
1138+
EXPECT_GT(afterId, wrapperId)
1139+
<< "an external id assigned after a Thread object's id was smaller, so the two kinds are "
1140+
"not sharing one counter";
1141+
}
1142+
1143+
TEST(ThreadManagedIdTests, Fix1958_193_AWrapperThreadStillReportsItsOwnObjectsId) {
1144+
// Unchanged behaviour, and the control: a thread started through Thread reports the id its
1145+
// Thread object was given at construction, not a freshly assigned external one.
1146+
SharpRuntime::intcs seenInside = 0;
1147+
System::Threading::Thread t([&seenInside] {
1148+
seenInside = System::Threading::Thread::CurrentThread().getManagedThreadIdProperty();
1149+
});
1150+
const SharpRuntime::intcs objectId = t.getManagedThreadIdProperty();
1151+
t.Start();
1152+
t.Join();
1153+
EXPECT_EQ(seenInside, objectId);
1154+
EXPECT_GT(objectId, 1);
1155+
}

plan.sqlite3

8 KB
Binary file not shown.

0 commit comments

Comments
 (0)