Skip to content

Commit 3aa028a

Browse files
committed
fix(core): a composed Delegate carries its concrete type (#2271, SR-AUD-118)
Combine and Remove accepted operands of unrelated concrete types and always produced a base Delegate. .NET refuses the mismatch with ArgumentException ('Delegates must be of the same type.') and preserves the type. THE FINDING'S TWO HALVES REALLY WERE INSEPARABLE, for the reason it gave: a same-type guard alone breaks Combine(Combine(a, b), c), because step one returns a multicast whose own typeid is Delegate rather than the operands' type, so step two would compare Delegate against C and reject a combination .NET accepts. BUT NO DATA MEMBER WAS NEEDED. The design the ticket priced assumed the type had to be stored; it does not. A multicast delegate's type IS the type of its entries, and Combine itself guarantees they are uniform because it refuses to build a mixed list -- so the type is read from the invocation list, and sizeof(Delegate) is unchanged. This is not an SA-3 change. The null ordering is .NET's and is asserted: it checks types inside CombineImpl, which a null 'a' never reaches, so Combine(nullptr, b) returns b unchecked. RemoveAll inherits the check because it is defined in terms of Remove, here as in .NET. The ticket expected two green fixtures to need rewriting and NONE did -- the gate was green before a single test was added. What the derivation-removing mutation breaks is three pre-existing MulticastDelegateTests, which is the evidence it is load-bearing rather than decorative. Three mutations, all caught. Gate 17,291 run, 0 failed. Downstream: zero Combine/Remove sites in either consumer.
1 parent 2fec9e5 commit 3aa028a

6 files changed

Lines changed: 220 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

audit/AUDIT_FINDINGS_INDEX.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — a composed `Delegate` carries its concrete type (ticket #2271)
5+
6+
*2026-08-18.* `Delegate::Combine` and `Delegate::Remove` accepted operands of unrelated concrete
7+
types and always produced a base `Delegate`. .NET refuses the mismatch and preserves the type.
8+
9+
Landed under SA-5 on the user's decision of the same date. A **narrowing**: two spellings that
10+
compiled and ran now throw. **No signature, layout, vtable or `noexcept` change** — and, notably,
11+
**no data member**: `sizeof(Delegate)` is unchanged.
12+
13+
---
14+
15+
## 1. What changed
16+
17+
| Call | Was | Is |
18+
|---|---|---|
19+
| `Combine(alpha, beta)` — different derived types | accepted, produced a base `Delegate` | **`ArgumentException`**, *"Delegates must be of the same type."* |
20+
| `Remove(combined, foreign)` | returned the source unchanged | **`ArgumentException`** |
21+
| `RemoveAll(combined, foreign)` | returned the source unchanged | **`ArgumentException`** |
22+
| `Combine(Combine(a, b), c)` — all one type | worked | **works** |
23+
| `Combine(nullptr, b)`, `Combine(b, nullptr)` | returned `b` | **unchanged** |
24+
| `Remove(nullptr, v)`, `Remove(s, nullptr)` || **unchanged** |
25+
| anything using plain `Delegate` on both sides || **unchanged** |
26+
27+
The message is `MulticastDelegate.CoreCLR.cs:212-220` and `Delegate.cs:158-169` transcribed;
28+
`Strings.resx:310-312` gives the sentence verbatim.
29+
30+
## 2. Why both halves had to land together
31+
32+
The ticket recorded that a same-type guard **alone** would break the chained form
33+
`Combine(Combine(a, b), c)`: step one returns a multicast whose own `typeid` is `Delegate` rather
34+
than the operands' type, so step two would compare `Delegate` against `C` and reject a
35+
combination .NET accepts. That is why the finding could not be split.
36+
37+
## 3. No data member was needed
38+
39+
A multicast delegate's type **is** the type of its entries — and `Combine` itself guarantees they
40+
are uniform, because it refuses to build a mixed list. So the type can be **read** from the
41+
invocation list rather than stored beside it:
42+
43+
```cpp
44+
const std::type_info& effectiveDelegateType(const Delegate& d, const InvocationList& list) {
45+
return list.empty() ? typeid(d) : typeid(*list.front());
46+
}
47+
```
48+
49+
`sizeof(Delegate)` is unchanged and this is not an SA-3 change. A mutation that makes a multicast
50+
report its own `typeid` instead breaks the chained-form test **and three pre-existing
51+
`MulticastDelegateTests`**, which is what shows the derivation is load-bearing rather than
52+
decorative.
53+
54+
## 4. The null ordering is .NET's, deliberately
55+
56+
.NET checks the types *inside* `CombineImpl`, which a null `a` never reaches — so
57+
`Combine(nullptr, b)` returns `b` **unchecked**. `Remove`'s check likewise runs after both null
58+
tests. Both orderings are asserted so they cannot drift.
59+
60+
## 5. To migrate
61+
62+
If two delegates in your code have different concrete types and you were combining them, that was
63+
never meaningful — the result could only be invoked through the base. Give them a common type, or
64+
keep them in separate delegates. Plain `Delegate` instances all share one type, so the narrowing
65+
bites only across two **different derived** types.
66+
67+
## 6. Downstream, measured
68+
69+
Neither `cna` nor `mobile-eggbert` calls `Delegate::Combine`, `Delegate::Remove` or
70+
`Delegate::RemoveAll` — **zero sites in both**. Neither repository was modified.

modules/core/src/System/Delegate.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include "System/Delegate.hpp"
5+
#include "System/ArgumentException.hpp"
56
#include "System/NotImplementedException.hpp"
67
#include <functional>
8+
#include <typeinfo>
79
#include <optional>
810

911
namespace System {
@@ -122,11 +124,40 @@ std::any Delegate::DynamicInvoke(const std::vector<std::any>&) {
122124
throw NotImplementedException("DynamicInvoke is not supported in sharp-runtime");
123125
}
124126

127+
namespace {
128+
129+
// #2271 / SR-AUD-118. .NET's Delegate carries its own runtime type, and both CombineImpl
130+
// (`MulticastDelegate.CoreCLR.cs:212-220`) and Remove (`Delegate.cs:158-169`) refuse operands
131+
// whose types differ, with ArgumentException(SR.Arg_DlgtTypeMis).
132+
//
133+
// THE TWO HALVES OF THE FINDING ARE INSEPARABLE, and this function is why. A same-type guard
134+
// alone would break the chained form Combine(Combine(a, b), c): step one returns a multicast
135+
// Delegate, whose own `typeid` is `Delegate` and not the operands' type, so step two would
136+
// compare `Delegate` against `C` and reject a combination .NET accepts.
137+
//
138+
// NO DATA MEMBER IS NEEDED TO CARRY THE TYPE. A multicast delegate's type is the type of its
139+
// entries -- which Combine itself guarantees are all the same -- so it can be READ from the list
140+
// rather than stored beside it. A leaf delegate's type is simply its own. `sizeof(Delegate)` is
141+
// unchanged and this is not an SA-3 change.
142+
const std::type_info& effectiveDelegateType(const Delegate& d,
143+
const std::vector<std::shared_ptr<Delegate>>& list) {
144+
return list.empty() ? typeid(d) : typeid(*list.front());
145+
}
146+
147+
} // namespace
148+
125149
std::shared_ptr<Delegate> Delegate::Combine(
126150
std::shared_ptr<Delegate> a, std::shared_ptr<Delegate> b) {
151+
// .NET checks the types inside CombineImpl, which a null `a` never reaches -- so
152+
// Combine(nullptr, b) returns b unchecked, and this ordering is deliberate.
127153
if (!a) return b;
128154
if (!b) return a;
129155

156+
if (effectiveDelegateType(*a, a->invocationList_) !=
157+
effectiveDelegateType(*b, b->invocationList_)) {
158+
throw System::ArgumentException("Delegates must be of the same type.");
159+
}
160+
130161
std::vector<std::shared_ptr<Delegate>> combined;
131162

132163
const auto& la = a->invocationList_;
@@ -152,6 +183,14 @@ std::shared_ptr<Delegate> Delegate::Remove(
152183
if (!source) return nullptr;
153184
if (!value) return source;
154185

186+
// `Delegate.cs:166-167`: the SAME check as Combine's, and it runs AFTER the two null tests.
187+
// RemoveAll inherits it, because it is defined in terms of Remove here exactly as it is in
188+
// .NET (`Delegate.cs:172-183`).
189+
if (effectiveDelegateType(*source, source->invocationList_) !=
190+
effectiveDelegateType(*value, value->invocationList_)) {
191+
throw System::ArgumentException("Delegates must be of the same type.");
192+
}
193+
155194
const auto& sl = source->invocationList_;
156195
if (sl.empty()) {
157196
// Single-target delegate

modules/core/tests/System/DelegateTests.cpp

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include <gtest/gtest.h>
5+
#include "System/ArgumentException.hpp"
56
#include "System/Delegate.hpp"
67
#include "System/NotImplementedException.hpp"
78

@@ -304,3 +305,111 @@ TEST(DelegateTests, GetTargetProperty_AlwaysNull) {
304305
auto d = std::make_shared<Delegate>([]{});
305306
EXPECT_EQ(d->getTargetProperty(), nullptr);
306307
}
308+
309+
// ---------------------------------------------------------------------------
310+
// #2271 / SR-AUD-118 — a composed delegate carries its concrete type
311+
// ---------------------------------------------------------------------------
312+
//
313+
// .NET's CombineImpl (`MulticastDelegate.CoreCLR.cs:212-220`) and Delegate.Remove
314+
// (`Delegate.cs:158-169`) both refuse operands whose runtime types differ, with
315+
// ArgumentException(SR.Arg_DlgtTypeMis) — "Delegates must be of the same type."
316+
//
317+
// THE FINDING'S TWO HALVES ARE INSEPARABLE, and these tests are why. A same-type guard alone
318+
// would break the chained form Combine(Combine(a, b), c): step one returns a multicast Delegate,
319+
// whose own typeid is `Delegate` rather than the operands' type, so step two would compare
320+
// `Delegate` against `C` and reject a combination .NET accepts. The repair reads a multicast's
321+
// type from its ENTRIES — which Combine itself guarantees are uniform — so no data member was
322+
// needed and sizeof(Delegate) is unchanged.
323+
324+
namespace {
325+
326+
class AlphaDelegate : public Delegate {
327+
public:
328+
using Delegate::Delegate;
329+
};
330+
331+
class BetaDelegate : public Delegate {
332+
public:
333+
using Delegate::Delegate;
334+
};
335+
336+
} // namespace
337+
338+
TEST(DelegateTypeIdentityTests, Fix2271_CombiningDifferentConcreteTypesIsRejected) {
339+
auto alpha = std::make_shared<AlphaDelegate>([] {});
340+
auto beta = std::make_shared<BetaDelegate>([] {});
341+
342+
EXPECT_THROW((void)Delegate::Combine(alpha, beta), System::ArgumentException);
343+
EXPECT_THROW((void)Delegate::Combine(beta, alpha), System::ArgumentException);
344+
345+
try {
346+
(void)Delegate::Combine(alpha, beta);
347+
ADD_FAILURE() << "expected ArgumentException";
348+
} catch (const System::ArgumentException& e) {
349+
// .NET's own sentence, transcribed rather than paraphrased.
350+
EXPECT_STREQ(e.what(), "Delegates must be of the same type.");
351+
}
352+
}
353+
354+
TEST(DelegateTypeIdentityTests, Fix2271_TheChainedFormStillWorks) {
355+
// THE ROW THE FINDING SAID COULD NOT BE SATISFIED BY A GUARD ALONE. Step one returns a
356+
// multicast whose own typeid is `Delegate`; if that were the type compared in step two, this
357+
// would throw.
358+
int calls = 0;
359+
auto a = std::make_shared<AlphaDelegate>([&] { ++calls; });
360+
auto b = std::make_shared<AlphaDelegate>([&] { ++calls; });
361+
auto c = std::make_shared<AlphaDelegate>([&] { ++calls; });
362+
363+
std::shared_ptr<Delegate> ab;
364+
ASSERT_NO_THROW(ab = Delegate::Combine(a, b));
365+
std::shared_ptr<Delegate> abc;
366+
ASSERT_NO_THROW(abc = Delegate::Combine(ab, c));
367+
ASSERT_NE(nullptr, abc);
368+
abc->Invoke();
369+
EXPECT_EQ(3, calls);
370+
371+
// ...and the composed delegate really does carry the type, rather than merely tolerating the
372+
// second step: adding a foreign operand to it is still refused.
373+
auto foreign = std::make_shared<BetaDelegate>([] {});
374+
EXPECT_THROW((void)Delegate::Combine(abc, foreign), System::ArgumentException);
375+
EXPECT_THROW((void)Delegate::Combine(foreign, abc), System::ArgumentException);
376+
}
377+
378+
TEST(DelegateTypeIdentityTests, Fix2271_RemoveAndRemoveAllRefuseAForeignType) {
379+
// `Delegate.cs:166-167` applies the SAME check to Remove, and RemoveAll inherits it because
380+
// it is defined in terms of Remove — in .NET and here alike.
381+
auto a = std::make_shared<AlphaDelegate>([] {});
382+
auto b = std::make_shared<AlphaDelegate>([] {});
383+
auto combined = Delegate::Combine(a, b);
384+
auto foreign = std::make_shared<BetaDelegate>([] {});
385+
386+
EXPECT_THROW((void)Delegate::Remove(combined, foreign), System::ArgumentException);
387+
EXPECT_THROW((void)Delegate::RemoveAll(combined, foreign), System::ArgumentException);
388+
389+
// The same-type removal is untouched.
390+
auto afterRemove = Delegate::Remove(combined, b);
391+
ASSERT_NE(nullptr, afterRemove);
392+
EXPECT_TRUE(afterRemove->getHasSingleTargetProperty());
393+
}
394+
395+
TEST(DelegateTypeIdentityTests, Fix2271_TheNullOrderingIsDotNetsAndTheBaseTypeIsUnaffected) {
396+
// .NET checks the types INSIDE CombineImpl, which a null `a` never reaches, so
397+
// Combine(nullptr, b) returns b unchecked. Remove's check likewise runs after both null
398+
// tests. The ordering is deliberate and asserted so it cannot drift.
399+
auto beta = std::make_shared<BetaDelegate>([] {});
400+
EXPECT_EQ(beta, Delegate::Combine(nullptr, beta));
401+
EXPECT_EQ(beta, Delegate::Combine(beta, nullptr));
402+
EXPECT_EQ(nullptr, Delegate::Remove(nullptr, beta));
403+
EXPECT_EQ(beta, Delegate::Remove(beta, nullptr));
404+
405+
// Plain Delegate instances all share one type, so every pre-existing combination keeps
406+
// working. That is the whole of the narrowing: it bites only across two DIFFERENT derived
407+
// types, which is exactly .NET's rule.
408+
int calls = 0;
409+
auto p = std::make_shared<Delegate>([&] { ++calls; });
410+
auto q = std::make_shared<Delegate>([&] { ++calls; });
411+
auto pq = Delegate::Combine(p, q);
412+
ASSERT_NE(nullptr, pq);
413+
pq->Invoke();
414+
EXPECT_EQ(2, calls);
415+
}

plan.sqlite3

4 KB
Binary file not shown.

0 commit comments

Comments
 (0)