Skip to content

Commit 22e99ca

Browse files
committed
feat(threading): Thread::Start(void*) forwards its parameter (#1958, SR-AUD-194)
Rule-14 sweep. Start(void*) captured its argument and discarded it with a literal (void)parameter; while its own doc-comment said the value was "forwarded to the thread function" -- no way to receive it, no diagnostic, because the only accepted callback shape had no parameter slot. THE RECORDED COST ESTIMATE WAS WRONG. #1958 listed this as a public signature change, routing it through SA-10 and SA-2. No existing signature changes: the repair is an additive second constructor -- .NET's Thread(ParameterizedThreadStart) (Thread.cs:152) -- plus an SA-5 behaviour change. It therefore landed as ordinary work with a pinned layout. The one spelling that would become ambiguous, Thread(nullptr), exists in zero places across modules/, test/ and both consumers. No shape flag was needed: exactly one of the two callables is ever set, and which one IS .NET's `startHelper._start is ThreadStart` test. sizeof(Thread) 104 -> 136; consumers rebuild. Two asymmetries are .NET's and both are pinned: Start() does NOT reject a parameterized thread (it passes null), and the shape check applies only before the first start, because .NET wraps it in `if (startHelper != null)` and says so in a comment. That second pin was written asserting the opposite and FAILED -- the reference showed the test was wrong rather than the code. Five mutations, all caught. Two only after repair: M1 was a SEGV because the test dereferenced a null the mutation supplies, and M3 was a SIGABRT because my mutation threw from a thread body, which is not a realistic regression. M2 is caught only as a crash and that is inherent -- removing the guard hands an empty std::function to a new thread and reaches std::terminate with no handler, which no assertion can observe (#2215). Downstream measured: 0 sites in cna, 0 in mobile-eggbert. #1958 now has two findings left: SR-AUD-209 and SR-AUD-196. Gate: 17,497 run, 17,497 passed, 0 failed, 0 skipped across 38 executables (+7 on 17,490; SharpRuntimeTests_Threading 514 -> 521; no other executable moved). Module graph unchanged at 41/93.
1 parent 6207363 commit 22e99ca

5 files changed

Lines changed: 287 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `Thread::Start(void*)` forwards its parameter (ticket #1958, SR-AUD-194)
5+
6+
*2026-08-19.* `System::Threading::Thread` gains a constructor taking
7+
`std::function<void(void*)>`, and `Start(void*)` now delivers its argument to it — or throws if
8+
the thread was built with the parameterless shape.
9+
10+
**`sizeof(Thread)` grows 104 → 136, so consumers must be recompiled.** Landed under **SA-5** with
11+
**SA-3**'s layout condition discharged.
12+
13+
---
14+
15+
## 1. What was wrong
16+
17+
`Start(void*)` captured its argument and then discarded it with a literal `(void)parameter;`,
18+
while the member's own doc-comment said the value was *"forwarded to the thread function"*. There
19+
was no way for a caller to receive it and no diagnostic saying so — because the only accepted
20+
callback shape had **no parameter slot**.
21+
22+
## 2. The recorded cost estimate was wrong
23+
24+
#1958's record listed SR-AUD-194 as *"a signature change"*, which would route it through SA-10
25+
and SA-2's five conditions. **Measured, no existing signature changes.** The repair is:
26+
27+
* **additive** — a second constructor, the counterpart of .NET's
28+
`Thread(ParameterizedThreadStart start)` (`Thread.cs:152`);
29+
* **a behaviour change** to `Start(void*)`, which is SA-5's ordinary territory.
30+
31+
So it landed as ordinary work with a pinned layout, not as a source break. Nothing that compiled
32+
before compiles differently: a no-argument lambda converts only to `std::function<void()>`, a
33+
`void*`-taking lambda only to `std::function<void(void*)>`. The one spelling that would become
34+
ambiguous is `Thread(nullptr)`, and it exists in **zero** places across `modules/`, `test/`, `cna`
35+
and `mobile-eggbert`.
36+
37+
## 3. What changes
38+
39+
| | Was | Is |
40+
|---|---|---|
41+
| `Thread(std::function<void(void*)>)` | **absent** | present; empty callable → `ArgumentNullException` |
42+
| `Start(p)` on a **parameterized** thread || the body receives `p` |
43+
| `Start(p)` on a **parameterless** thread | silently ignored `p`, ran the body | `InvalidOperationException` |
44+
| `Start()` on a **parameterized** thread || runs with `nullptr`, **no exception** (§4) |
45+
| `Start()` on a parameterless thread | unchanged | unchanged |
46+
| `sizeof(Thread)` | **104** | **136** |
47+
48+
The message is .NET's verbatim: *"The thread was created with a ThreadStart delegate that does not
49+
accept a parameter."*
50+
51+
No separate shape flag was needed — exactly one of the two callables is ever set, and which one
52+
**is** the shape record. That is precisely .NET's `startHelper._start is ThreadStart` test.
53+
54+
## 4. Two asymmetries, both .NET's, both pinned
55+
56+
**`Start()` does not reject a parameterized thread.** .NET's private `Start(bool)` sets
57+
`startHelper._startArg = null` and performs **no** delegate-shape check at all
58+
(`Thread.cs:239-253`) — only `Start(parameter)` guards. "Reject it for symmetry" is the plausible
59+
wrong answer, so a test pins the permissive behaviour.
60+
61+
**The shape check applies only before the first start.** .NET wraps it in
62+
`if (startHelper != null)` (`Thread.cs:204-214`), and the comment above says why: *"In the case of
63+
a null startHelper (second call to start on same thread) StartCore method will take care of the
64+
error reporting."* So a **second** `Start(void*)` reports the **restart** error, not the
65+
wrong-shape error.
66+
67+
**That second pin was written asserting the opposite, and it failed.** The reference then showed
68+
that the *test* was wrong rather than the code: this port gets .NET's rule from the same fact,
69+
because `fn_` is moved from on the first successful start and is empty afterwards, exactly as
70+
.NET's `startHelper` becomes null. The test now asserts both halves — shape error before the
71+
first start, restart error after it.
72+
73+
## 5. Evidence
74+
75+
Five mutations, all caught:
76+
77+
| Mutation | Caught by |
78+
|---|---|
79+
| M1 — the parameter is discarded again | `Fix1958_TheParameterActuallyReachesTheBody`**by name, after repair** |
80+
| M2 — the shape guard is removed | **as a crash only** — see below |
81+
| M3 — `Start()` rejects a parameterized thread | `Decl1958_ParameterlessStartOnAParameterizedThreadPassesNull`**by name, after reformulation** |
82+
| M4 — an empty parameterized callable is accepted | `Fix1958_AnEmptyParameterizedCallableIsRejectedAtConstruction` |
83+
| M5 — the new constructor consumes no managed id | `Fix1958_BothShapesGetDistinctManagedThreadIds` |
84+
85+
M5 matters because SR-AUD-193's uniqueness contract covers **every** `Thread` object regardless of
86+
shape.
87+
88+
**M1 was caught only as a SEGV at first**, because the test dereferenced the pointer and the
89+
mutation passes `nullptr`. The assertion is now null-safe, so it fails by name.
90+
91+
**M3 was caught only as a SIGABRT at first**, because my mutation threw from inside the *thread
92+
body*, where no handler exists. That is not a realistic regression; reformulated as the plausible
93+
one — rejecting at the call site "for symmetry" — it is caught by name.
94+
95+
**M2 is caught only as a crash, and that is inherent rather than a test defect.** Removing the
96+
guard lets `Start(void*)` on a parameterless thread hand an **empty** `std::function` to a new OS
97+
thread, whose call to it raises `std::bad_function_call` with no handler and reaches
98+
`std::terminate` — exactly the failure mode SR-AUD-192 documented for the constructor. The
99+
`EXPECT_THROW` does record its failure first, but the process aborts before gtest prints its
100+
summary. No assertion can observe a `std::terminate` on another thread, which is the same
101+
inherent limit #2215 recorded.
102+
103+
Gate: **17,497 run, 17,497 passed, 0 failed, 0 skipped** across 38 executables — `+7` on 17,490,
104+
exactly the seven new cases (`SharpRuntimeTests_Threading` 514 → 521). No other executable moved.
105+
All 514 pre-existing cases passed unchanged before the new ones were added. Module graph
106+
unchanged at 41/93.
107+
108+
## 6. Downstream, measured
109+
110+
`System::Threading::Thread` appears in **zero** places in `cna` and **zero** in `mobile-eggbert`,
111+
so the rebuild requirement is recorded here for future consumers rather than acted on. Neither
112+
repository was modified.
113+
114+
## 7. Scope
115+
116+
#1958 now has **two** findings left: **SR-AUD-209** (make `AutoResetEvent`/`ManualResetEvent`
117+
derive from `WaitHandle` — a vtable *and* base-class change, which SA-3 explicitly excludes) and
118+
**SR-AUD-196** (`ThreadStartException` publishes constructors .NET makes internal).

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

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#include "System/LocalDataStoreSlot.hpp"
1717
#include "SharpRuntime/SharpRuntimeHelper.hpp"
1818
#include "System/ArgumentNullException.hpp"
19+
#include "System/InvalidOperationException.hpp"
1920
#include "System/ArgumentOutOfRangeException.hpp"
2021
#include "System/Threading/ApartmentState.hpp"
2122
#include "System/Threading/ThreadPriority.hpp"
@@ -90,6 +91,11 @@ namespace System::Threading {
9091

9192
std::shared_ptr<RunState> state_ = std::make_shared<RunState>();
9293
std::function<void()> fn_;
94+
// Ticket #1958 / SR-AUD-194. Exactly ONE of these two is ever set, and which one records
95+
// the delegate shape -- so no separate flag is needed. `fn_` non-empty means the thread
96+
// was built with the parameterless shape, which is precisely .NET's
97+
// `startHelper._start is ThreadStart` test.
98+
std::function<void(void*)> paramFn_;
9399
std::thread thread_;
94100
std::string name_;
95101
bool isThreadPoolThread_ = false;
@@ -120,6 +126,31 @@ namespace System::Threading {
120126
state_->managedThreadId = nextManagedId_.fetch_add(1);
121127
}
122128

129+
/**
130+
* @brief Constructs a Thread whose body ACCEPTS the parameter `Start(void*)` supplies.
131+
* @param start Function to execute on the new thread, receiving `Start`'s argument.
132+
* @throws System::ArgumentNullException if @p start is an empty std::function.
133+
*
134+
* C++ counterpart of .NET's `Thread(ParameterizedThreadStart start)`
135+
* (`Thread.cs:152`). Ticket #1958 / SR-AUD-194.
136+
*
137+
* **This constructor is what makes `Start(void*)` mean anything.** Before it, the only
138+
* accepted callback shape had no parameter slot, so `Start(void*)` captured its argument
139+
* and then discarded it with a literal `(void)parameter;` while its own doc-comment said
140+
* the value was "forwarded to the thread function". There was no way for a caller to
141+
* receive it and no diagnostic saying so.
142+
*
143+
* The same `ArgumentNullException` guard applies for the same reason as the parameterless
144+
* constructor's (SR-AUD-192): deferring it means `std::bad_function_call` on a thread with
145+
* no handler, i.e. `std::terminate`.
146+
*/
147+
explicit Thread(std::function<void(void*)> start)
148+
: paramFn_(std::move(start))
149+
{
150+
if (!paramFn_) throw System::ArgumentNullException("start");
151+
state_->managedThreadId = nextManagedId_.fetch_add(1);
152+
}
153+
123154
~Thread() {
124155
if (thread_.joinable()) thread_.detach();
125156
}
@@ -138,9 +169,15 @@ namespace System::Threading {
138169
void Start() {
139170
if (started_.exchange(true))
140171
throw System::Threading::ThreadStateException("Thread is running or terminated; it cannot restart.");
141-
thread_ = std::thread([state = state_, fn = std::move(fn_)]() mutable {
172+
// A PARAMETERIZED thread started this way runs with a null argument and NO exception.
173+
// That asymmetry is .NET's: its private Start(bool) sets `startHelper._startArg = null`
174+
// and performs no delegate-shape check at all (Thread.cs:239-253) -- only
175+
// Start(parameter) guards. Pinned by a test, because "reject it for symmetry" is the
176+
// plausible wrong answer.
177+
thread_ = std::thread([state = state_, fn = std::move(fn_),
178+
paramFn = std::move(paramFn_)]() mutable {
142179
currentThreadState_ = state;
143-
fn();
180+
if (fn) fn(); else paramFn(nullptr);
144181
state->finished.store(true);
145182
});
146183
}
@@ -151,12 +188,29 @@ namespace System::Threading {
151188
* @throws System::Threading::ThreadStateException if Start() has already been called.
152189
*/
153190
void Start(void* parameter) {
191+
// THE SHAPE CHECK COMES FIRST -- but only while the thread has NOT been started, and
192+
// that qualification is .NET's rather than an accident here. Its private
193+
// Start(object, bool) wraps the whole check in `if (startHelper != null)`
194+
// (Thread.cs:204-214), and the comment two lines above says why: "In the case of a
195+
// null startHelper (second call to start on same thread) StartCore method will take
196+
// care of the error reporting."
197+
//
198+
// So a SECOND Start(void*) reports the RESTART error, not the wrong-shape error. This
199+
// port gets the same rule from the same fact: fn_ is MOVED FROM into the thread body
200+
// on the first successful start, so it is empty afterwards and the guard falls
201+
// through -- exactly as .NET's startHelper becomes null. A pin asserts both halves,
202+
// and it was written asserting the opposite first: the test failed, and the reference
203+
// showed the test was wrong rather than the code.
204+
if (fn_) {
205+
throw System::InvalidOperationException(
206+
"The thread was created with a ThreadStart delegate that does not accept a "
207+
"parameter.");
208+
}
154209
if (started_.exchange(true))
155210
throw System::Threading::ThreadStateException("Thread is running or terminated; it cannot restart.");
156-
thread_ = std::thread([state = state_, fn = std::move(fn_), parameter]() mutable {
211+
thread_ = std::thread([state = state_, paramFn = std::move(paramFn_), parameter]() mutable {
157212
currentThreadState_ = state;
158-
(void)parameter;
159-
fn();
213+
paramFn(parameter);
160214
state->finished.store(true);
161215
});
162216
}

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,3 +1587,112 @@ TEST(ThreadLocalValuesTests, Fix1958_DisposeReleasesTheTrackedValues) {
15871587

15881588
EXPECT_THROW((void)local.getValuesProperty(), System::ObjectDisposedException);
15891589
}
1590+
1591+
// =============================================================================================
1592+
// Ticket #1958 / SR-AUD-194 — Thread::Start(void*) actually forwards its parameter.
1593+
//
1594+
// The member captured its argument and then discarded it with a literal `(void)parameter;`,
1595+
// while its own doc-comment said the value was "forwarded to the thread function". There was no
1596+
// way for a caller to receive it and no diagnostic saying so, because the ONLY accepted callback
1597+
// shape had no parameter slot.
1598+
//
1599+
// The repair is therefore ADDITIVE, not a signature change: a second constructor taking
1600+
// std::function<void(void*)> -- .NET's Thread(ParameterizedThreadStart) (Thread.cs:152) -- plus a
1601+
// guard on Start(void*) matching .NET's `startHelper._start is ThreadStart` test
1602+
// (Thread.cs:206-210), with .NET's verbatim message.
1603+
//
1604+
// Landed under SA-5 with SA-3's layout condition discharged: sizeof(Thread) grows 104 -> 136.
1605+
// =============================================================================================
1606+
1607+
TEST(ThreadParameterizedStartTests, Decl1958_TheSecondCallableGrowsTheType) {
1608+
static_assert(sizeof(System::Threading::Thread) == 136,
1609+
"#1958/SR-AUD-194 grew Thread 104 -> 136; a further change needs its own pin");
1610+
EXPECT_EQ(sizeof(System::Threading::Thread), 136u);
1611+
}
1612+
1613+
TEST(ThreadParameterizedStartTests, Fix1958_TheParameterActuallyReachesTheBody) {
1614+
// THE DEFECT: before the repair there was no callable shape that could receive this at all.
1615+
int payload = 4242;
1616+
std::atomic<int> seen{0};
1617+
// Null-SAFE on purpose: the mutation that discards the parameter again passes nullptr, and a
1618+
// dereference there would SEGV -- caught, but as a crash rather than by name.
1619+
System::Threading::Thread t(std::function<void(void*)>(
1620+
[&seen](void* p) { seen.store(p ? *static_cast<int*>(p) : -1); }));
1621+
t.Start(&payload);
1622+
t.Join();
1623+
EXPECT_EQ(seen.load(), 4242);
1624+
}
1625+
1626+
TEST(ThreadParameterizedStartTests, Fix1958_StartWithAParameterOnAParameterlessThreadThrows) {
1627+
// .NET: `if (startHelper._start is ThreadStart) throw new InvalidOperationException(
1628+
// SR.InvalidOperation_ThreadWrongThreadStart);` (Thread.cs:206-210).
1629+
std::atomic<bool> ran{false};
1630+
System::Threading::Thread t(std::function<void()>([&ran] { ran.store(true); }));
1631+
int payload = 1;
1632+
EXPECT_THROW(t.Start(&payload), System::InvalidOperationException);
1633+
EXPECT_FALSE(ran.load()) << "the rejected Start must not have started anything";
1634+
1635+
// ...and the thread is still usable through the door that matches its shape.
1636+
EXPECT_NO_THROW(t.Start());
1637+
t.Join();
1638+
EXPECT_TRUE(ran.load());
1639+
}
1640+
1641+
TEST(ThreadParameterizedStartTests, Decl1958_ParameterlessStartOnAParameterizedThreadPassesNull) {
1642+
// THE ASYMMETRY, pinned because "reject it for symmetry" is the plausible wrong answer.
1643+
// .NET's private Start(bool) sets `startHelper._startArg = null` and performs NO delegate
1644+
// shape check at all (Thread.cs:239-253) -- only Start(parameter) guards.
1645+
std::atomic<bool> ran{false};
1646+
std::atomic<bool> gotNull{false};
1647+
System::Threading::Thread t(std::function<void(void*)>([&](void* p) {
1648+
gotNull.store(p == nullptr);
1649+
ran.store(true);
1650+
}));
1651+
EXPECT_NO_THROW(t.Start()) << "a parameterized thread may be started with no parameter";
1652+
t.Join();
1653+
EXPECT_TRUE(ran.load());
1654+
EXPECT_TRUE(gotNull.load()) << "it receives null, as .NET's _startArg = null gives";
1655+
}
1656+
1657+
TEST(ThreadParameterizedStartTests, Decl1958_TheShapeCheckAppliesOnlyBeforeTheFirstStart) {
1658+
// THIS TEST WAS WRITTEN ASSERTING THE OPPOSITE AND FAILED -- and the reference showed the
1659+
// TEST was wrong, not the code. .NET wraps the whole shape check in
1660+
// `if (startHelper != null)` (Thread.cs:204-214), and the comment above it says why: "In the
1661+
// case of a null startHelper (second call to start on same thread) StartCore method will take
1662+
// care of the error reporting."
1663+
//
1664+
// So the wrong-shape error is reported only while the thread has not been started; a SECOND
1665+
// Start(void*) reports the RESTART error. This port gets the same rule from the same fact --
1666+
// fn_ is moved from on the first successful start, exactly as .NET's startHelper is nulled.
1667+
std::atomic<bool> ran{false};
1668+
System::Threading::Thread t(std::function<void()>([&ran] { ran.store(true); }));
1669+
1670+
// Before any start: the shape check fires.
1671+
int payload = 1;
1672+
EXPECT_THROW(t.Start(&payload), System::InvalidOperationException);
1673+
1674+
t.Start();
1675+
t.Join();
1676+
1677+
// After starting: the restart check fires, through EITHER door.
1678+
EXPECT_THROW(t.Start(&payload), System::Threading::ThreadStateException)
1679+
<< "a second Start(parameter) reports the restart, as .NET's null startHelper does";
1680+
EXPECT_THROW(t.Start(), System::Threading::ThreadStateException);
1681+
}
1682+
1683+
TEST(ThreadParameterizedStartTests, Fix1958_AnEmptyParameterizedCallableIsRejectedAtConstruction) {
1684+
// The same SR-AUD-192 reasoning as the parameterless constructor: deferring it means
1685+
// std::bad_function_call on a thread with no handler, i.e. std::terminate.
1686+
EXPECT_THROW(System::Threading::Thread(std::function<void(void*)>()),
1687+
System::ArgumentNullException);
1688+
}
1689+
1690+
TEST(ThreadParameterizedStartTests, Fix1958_BothShapesGetDistinctManagedThreadIds) {
1691+
// The new constructor must consume an id like the old one -- SR-AUD-193's uniqueness contract
1692+
// covers every Thread object regardless of shape.
1693+
System::Threading::Thread a(std::function<void()>([] {}));
1694+
System::Threading::Thread b(std::function<void(void*)>([](void*) {}));
1695+
EXPECT_NE(a.getManagedThreadIdProperty(), b.getManagedThreadIdProperty());
1696+
EXPECT_GT(a.getManagedThreadIdProperty(), 1);
1697+
EXPECT_GT(b.getManagedThreadIdProperty(), 1);
1698+
}

plan.sqlite3

8 KB
Binary file not shown.

0 commit comments

Comments
 (0)