Skip to content

Commit 3a417d6

Browse files
committed
fix(core,net-http,text-json): a default-constructed exception carries .NET's fallback message (#2323)
System::Exception{} reported an empty message. .NET's is `_message ?? SR.Format(SR.Exception_WasThrown, GetClassName())` (Exception.cs:61,65) -- "Exception of type '{0}' was thrown." (Strings.resx:2333) -- with {0} the runtime type name. BOTH RECORDED BLOCKERS ARE RESOLVED, and the second dissolves rather than being worked around. 1. The exact resource text was unreadable with /rv absent. It is readable now. 2. {0} is GetType(), which is reflection this port permanently lacks. But .NET computes the fallback LAZILY only so that _message can stay null for serialization; the observable is identical if the constructor just stores it, which is what a hundred subclasses in this repository already do. So {0} is resolved STATICALLY, at each site, by the one entity that knows the answer: the type itself. No reflection, no new virtual, no layout change, no signature change, no optional. Hard-coding the base string and letting subclasses inherit it was the option the review rejected, and rightly: a message naming the WRONG type is a lie, where an empty one is merely an absence. THE REVIEW'S PREMISE MEASUREMENT WAS WRONG. It recorded that "18 subclasses supply a NON-EMPTY default message and exactly ONE type does not -- System::Exception itself", so the blast radius was the base constructed directly. Re-measured across all 103 exception subclasses: THREE reach the base fallback. `= default` on a derived exception default-constructs its base, and two types spell it that way -- HttpRequestException (.NET's is `{ }`, HttpRequestException.cs:10-11) and JsonException (`: base() { }`, JsonException.cs:78, with Message overridden as `_message ?? base.Message`). Both are given the message .NET produces for them, naming themselves. An EXPLICITLY empty message stays empty, which is also .NET: its fallback fires on a null message, and `new Exception("")` has Message == "". THE GUARD. The cost of static resolution is that a future subclass written as `= default` would silently report System.Exception. ExceptionFallbackMessageTests.NoOtherExceptionInheritsTheBaseFallback asserts that a representative set carries neither the base string nor an empty message, so such a subclass is caught here rather than discovered downstream. The two out-of-module rows live in modules/net-http/tests and modules/text-json/tests, because Core.Base depends on neither and a test is not a reason to add a public component edge (#2354's rule). The module graph is unchanged at 41/92. DOWNSTREAM IS NOT EMPTY, which is the point of measuring it. mobile-eggbert: zero sites. cna: FIVE exception types whose default constructors chain to System::Exception() -- GameUpdateRequiredException, GuideAlreadyVisibleException, GamerPrivilegeException, GamerServicesNotAvailableException and NetworkException, each at modules/gamer-services/src/Xna/<Type>.cpp lines 7 and 25 -- whose default message now names the WRONG type, plus SIX tests asserting EXPECT_STREQ("", ex.what()) that will fail. cna may be read but not edited without a per-action instruction, so this is recorded as ticket #2377, not performed. The correct downstream repair is the one applied here: give each of the six its own message. Three pins inverted (two in Core.Base, one in the integration suite). Three mutations, all caught. Gate: 17,318 run, 17,318 passed, 0 failed, 0 skipped across 38 executables, GREEN. docs/Migration-ExceptionFallbackMessage.md
1 parent 2297b7c commit 3a417d6

11 files changed

Lines changed: 290 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — a default-constructed exception carries .NET's fallback message (ticket #2323)
5+
6+
*2026-08-18.* `System::Exception{}.getMessageProperty()` is now
7+
`"Exception of type 'System.Exception' was thrown."` It used to be empty.
8+
9+
**This changes a message downstream code may assert on.** `cna` has six such assertions — see §6.
10+
Landed under `docs/StandingApprovals.md` SA-5. No signature, layout, vtable or `noexcept` change.
11+
12+
---
13+
14+
## 1. What changed
15+
16+
| Expression | Was | Is |
17+
|---|---|---|
18+
| `System::Exception{}.getMessageProperty()` | `""` | `"Exception of type 'System.Exception' was thrown."` |
19+
| `System::Exception{}.what()` | `""` | the same string |
20+
| `HttpRequestException{}` | `""` | `"Exception of type 'System.Net.Http.HttpRequestException' was thrown."` |
21+
| `JsonException{}` | `""` | `"Exception of type 'System.Text.Json.JsonException' was thrown."` |
22+
| `System::Exception("")` | `""` | `""`**unchanged** |
23+
| every other exception type's default | its own message | **unchanged** |
24+
25+
An **explicitly empty** message stays empty, and that is .NET too: its fallback fires on a
26+
**null** message, and `new Exception("")` has `Message == ""`.
27+
28+
## 2. The reference
29+
30+
```csharp
31+
public virtual string Message => _message ?? SR.Format(SR.Exception_WasThrown, GetClassName());
32+
private string GetClassName() => GetType().ToString();
33+
```
34+
*(`Exception.cs:61,65`; `Exception_WasThrown` is `"Exception of type '{0}' was thrown."`,
35+
`Strings.resx:2333`.)*
36+
37+
The review recorded two blockers. The first — the exact resource text — is simply readable now.
38+
The second is real and permanent: `{0}` is `GetType()`, which is reflection this port does not
39+
have.
40+
41+
## 3. What dissolves the second blocker
42+
43+
.NET computes the fallback **lazily** only so `_message` can stay null for serialization; the
44+
observable is identical if the constructor just stores it — which is what a hundred subclasses in
45+
this repository already do.
46+
47+
So `{0}` is resolved **statically, at each site, by the one entity that knows the answer**: the
48+
type itself. No reflection, no new virtual, no layout change, no signature change, no `optional`.
49+
50+
Hard-coding the base's string and letting subclasses inherit it was the option the review
51+
rejected, and rightly: **a message naming the wrong type is a lie, where an empty one is merely an
52+
absence.** The subclasses that reach the base fallback are given their own.
53+
54+
## 4. The review's premise measurement was wrong
55+
56+
It recorded that *"18 subclasses supply a NON-EMPTY default message and exactly ONE type does
57+
not — `System::Exception` itself"*, concluding the blast radius was the base constructed directly.
58+
59+
Re-measured across all **103** exception subclasses in the repository: **three** types reach the
60+
base fallback, not one. `= default` on a derived exception default-constructs its base, and two
61+
types spell it that way — `HttpRequestException` and `JsonException`. Both are given their own
62+
message, matching what .NET produces for each.
63+
64+
## 5. The guard
65+
66+
The cost of static resolution is that a **future** subclass written as `= default` would silently
67+
report `System.Exception`. `ExceptionFallbackMessageTests.NoOtherExceptionInheritsTheBaseFallback`
68+
asserts that a representative set of types does not carry the base string and does not carry an
69+
empty message, so such a subclass is caught rather than discovered downstream.
70+
71+
The two out-of-module rows live in `modules/net-http/tests` and `modules/text-json/tests`, because
72+
`Core.Base` does not depend on either and **a test is not a reason to add a public component
73+
edge** (#2354's rule). The module graph is unchanged at 41/92.
74+
75+
## 6. Downstream, measured — and it is not empty
76+
77+
Per SA-2 condition 5. `mobile-eggbert`: **zero** sites.
78+
79+
`cna`: **five** exception types whose default constructors chain to `System::Exception()`
80+
`GameUpdateRequiredException`, `GuideAlreadyVisibleException`, `GamerPrivilegeException`,
81+
`GamerServicesNotAvailableException` and `NetworkException`, each at
82+
`modules/gamer-services/src/Xna/<Type>.cpp` lines 7 and 25. Their default message changes from
83+
`""` to the base string, **which names the wrong type**, and `cna` has **six** tests asserting
84+
`EXPECT_STREQ("", ex.what())` that will fail.
85+
86+
`cna` may be read but not edited without a per-action instruction, so this is recorded rather
87+
than performed: ticket **#2377**. The correct downstream repair is the same one applied here to
88+
`HttpRequestException` and `JsonException` — give each of the six its own message naming itself,
89+
which is what FNA's and .NET's counterparts produce.
90+
91+
## 7. To migrate
92+
93+
If you assert on a default-constructed exception's message, assert the new string, or construct
94+
with an explicit `""` if you genuinely want an empty one. If you derive from `System::Exception`
95+
with `= default`, supply your own default message instead:
96+
97+
```cpp
98+
// before
99+
MyException() = default; // now reports "System.Exception"
100+
101+
// after
102+
MyException() : System::Exception("Exception of type 'My.Namespace.MyException' was thrown.") {}
103+
```
104+
105+
## 8. Evidence
106+
107+
| Mutation | Caught |
108+
|---|---|
109+
| The base goes back to an empty message | ✅ |
110+
| `HttpRequestException` inherits the base string (`= default`) | ✅ (2 tests, one of them the integration suite) |
111+
| `JsonException` inherits the base string (`= default`) | ✅ |

modules/core/src/System/Exception.cpp

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,24 @@
99

1010
namespace System {
1111

12+
// Ticket #2323 (SR-AUD-092, 2026-08-18). This used to leave the message EMPTY, where .NET's
13+
// is `_message ?? SR.Format(SR.Exception_WasThrown, GetClassName())` (Exception.cs:61,65) --
14+
// "Exception of type '{0}' was thrown.", Strings.resx:2333, with {0} the RUNTIME TYPE NAME.
15+
//
16+
// The review recorded two blockers. The first, the exact resource text, is simply readable
17+
// now. The second is real and permanent: {0} is reflection, which this port does not have.
18+
//
19+
// WHAT DISSOLVES IT is that .NET computes the fallback LAZILY only so that `_message` can
20+
// stay null for serialization; the observable is identical if the constructor just stores it,
21+
// which is what a hundred subclasses in this repository already do. So {0} is resolved
22+
// STATICALLY, at each site, by the one entity that knows the answer -- the type itself. No
23+
// reflection, no new virtual, no layout change, no signature change.
24+
//
25+
// Hard-coding this string into the base and letting subclasses inherit it was the option the
26+
// review rejected, and rightly: a message naming the WRONG type is a lie, where an empty one
27+
// is merely an absence. The two subclasses that reach here are given their own (#2323).
1228
Exception::Exception()
13-
: message_("") {
29+
: message_("Exception of type 'System.Exception' was thrown.") {
1430
}
1531

1632
Exception::Exception(const char* msg)

modules/core/tests/System/ExceptionNewTests.cpp

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
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/IO/IOException.hpp"
6+
#include "System/SystemException.hpp"
7+
#include "System/InvalidOperationException.hpp"
8+
#include "System/IndexOutOfRangeException.hpp"
9+
#include "System/FormatException.hpp"
10+
#include "System/NotSupportedException.hpp"
11+
#include "System/NotImplementedException.hpp"
512
#include "System/Exception.hpp"
613
#include "System/ArgumentNullException.hpp"
714
#include "System/ObjectDisposedException.hpp"
@@ -14,9 +21,12 @@ TEST(ExceptionNewTests, CStringCtor_MessageMatches) {
1421
EXPECT_EQ(e.getMessageProperty(), "hello");
1522
}
1623

17-
TEST(ExceptionNewTests, DefaultCtor_MessageEmpty) {
24+
// FLIPPED by #2323 (2026-08-18). The empty message was not .NET's; .NET's is
25+
// `_message ?? SR.Format(SR.Exception_WasThrown, GetClassName())` (Exception.cs:61,65).
26+
TEST(ExceptionNewTests, DefaultCtor_MessageIsDotNetsFallback) {
1827
System::Exception e;
19-
EXPECT_TRUE(e.getMessageProperty().empty());
28+
EXPECT_EQ(e.getMessageProperty(), "Exception of type 'System.Exception' was thrown.");
29+
EXPECT_STREQ(e.what(), "Exception of type 'System.Exception' was thrown.");
2030
}
2131

2232
TEST(ExceptionNewTests, InnerExceptionPtr_StoredAndRetrievable) {
@@ -63,3 +73,57 @@ TEST(ObjectDisposedExceptionNewTests, ObjectName_Stored) {
6373
System::ObjectDisposedException e("myResource");
6474
EXPECT_EQ(e.getObjectNameProperty(), "myResource");
6575
}
76+
77+
// ===========================================================================
78+
// #2323 — the base fallback, and the guard that stops it spreading
79+
// ===========================================================================
80+
//
81+
// .NET's Exception.Message is `_message ?? SR.Format(SR.Exception_WasThrown, GetClassName())`
82+
// (Exception.cs:61,65), i.e. "Exception of type '{0}' was thrown." (Strings.resx:2333) with {0}
83+
// the RUNTIME TYPE NAME. {0} is reflection, which this port permanently does not have, so it is
84+
// resolved STATICALLY at each site by the one entity that knows the answer: the type itself.
85+
//
86+
// THE REVIEW'S PREMISE MEASUREMENT WAS WRONG, and that is why this guard exists. It recorded
87+
// that "18 subclasses supply a NON-EMPTY default message and exactly ONE type does not --
88+
// System::Exception itself", so the blast radius was "the base type constructed directly".
89+
// Re-measured over all 103 subclasses: THREE reach the base fallback, not one. `= default` on a
90+
// derived exception default-constructs its base, and two types spell it that way.
91+
//
92+
// The cost of the static resolution is that a FUTURE subclass written as `= default` would
93+
// silently report `System.Exception` -- a message naming the wrong type, which is a lie where an
94+
// empty one was merely an absence. That is what this test is for.
95+
TEST(ExceptionFallbackMessageTests, TheBaseNamesItself) {
96+
EXPECT_EQ(System::Exception{}.getMessageProperty(),
97+
"Exception of type 'System.Exception' was thrown.");
98+
// The other two types that reach this fallback live in modules that Core.Base does not
99+
// depend on, so their rows are in their own suites -- modules/net-http and modules/text-json.
100+
// A refactor is not a reason to add a public component edge (#2354's rule), and neither is a
101+
// test.
102+
}
103+
104+
TEST(ExceptionFallbackMessageTests, NoOtherExceptionInheritsTheBaseFallback) {
105+
// The guard. Every one of these has its own default message, so none may report the base's
106+
// string; if one starts to, a subclass has been added or changed to reach the fallback and
107+
// must be given its own name in the same change.
108+
const std::string baseFallback = "Exception of type 'System.Exception' was thrown.";
109+
const auto notTheBase = [&baseFallback](const std::string& message, const char* what) {
110+
EXPECT_NE(message, baseFallback) << what << " now inherits the base fallback";
111+
EXPECT_FALSE(message.empty()) << what << " has no default message at all";
112+
};
113+
notTheBase(System::SystemException{}.getMessageProperty(), "SystemException");
114+
notTheBase(System::ArgumentException{}.getMessageProperty(), "ArgumentException");
115+
notTheBase(System::ArgumentNullException{}.getMessageProperty(), "ArgumentNullException");
116+
notTheBase(System::InvalidOperationException{}.getMessageProperty(), "InvalidOperationException");
117+
notTheBase(System::NotSupportedException{}.getMessageProperty(), "NotSupportedException");
118+
notTheBase(System::NotImplementedException{}.getMessageProperty(), "NotImplementedException");
119+
notTheBase(System::FormatException{}.getMessageProperty(), "FormatException");
120+
notTheBase(System::IndexOutOfRangeException{}.getMessageProperty(), "IndexOutOfRangeException");
121+
notTheBase(System::IO::IOException{}.getMessageProperty(), "IOException");
122+
}
123+
124+
TEST(ExceptionFallbackMessageTests, AnExplicitlyEmptyMessageStaysEmpty) {
125+
// .NET's fallback fires on a NULL message, not an empty one: `new Exception("")` has
126+
// `Message == ""`. This port cannot distinguish null from empty in a std::string, and does
127+
// not need to -- the fallback lives in the constructor that was given no message at all.
128+
EXPECT_TRUE(System::Exception("").getMessageProperty().empty());
129+
}

modules/core/tests/System/ExceptionTests.cpp

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,14 @@ using System::ObjectDisposedException;
4545
// Exception (base)
4646
// ---------------------------------------------------------------------------
4747

48-
TEST(ExceptionTests, DefaultCtorEmptyMessage) {
48+
// FLIPPED by #2323 (2026-08-18) -- see ExceptionNewTests for the reference lines. An
49+
// EXPLICITLY empty message is still empty, which is also .NET: its fallback fires on a NULL
50+
// message, and `new Exception("")` has `Message == ""`.
51+
TEST(ExceptionTests, DefaultCtorCarriesTheDotNetFallbackButAnExplicitEmptyStringDoesNot) {
4952
Exception e;
50-
EXPECT_TRUE(e.getMessageProperty().empty() || e.getMessageProperty() == "");
53+
EXPECT_EQ(e.getMessageProperty(), "Exception of type 'System.Exception' was thrown.");
54+
Exception explicitlyEmpty("");
55+
EXPECT_TRUE(explicitlyEmpty.getMessageProperty().empty());
5156
}
5257

5358
TEST(ExceptionTests, MessageFromCString) {

modules/net-http/include/System/Net/Http/HttpRequestException.hpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,19 @@ namespace System::Net::Http {
2323

2424
public:
2525
/** Creates a new instance with no message. */
26-
HttpRequestException() = default;
26+
/**
27+
* @brief Constructs an exception with .NET's fallback message for this type.
28+
*
29+
* Ticket #2323. .NET's `HttpRequestException()` is `{ }` (HttpRequestException.cs:10-11),
30+
* so `Message` falls through to `Exception`'s
31+
* `SR.Format(SR.Exception_WasThrown, GetClassName())` and names THIS type. `{0}` is
32+
* reflection, which this port does not have, so it is resolved statically here -- by the
33+
* one entity that knows the answer. Inheriting the base's string would have named
34+
* `System.Exception`, which is a lie rather than an absence.
35+
*/
36+
HttpRequestException()
37+
: System::Exception(
38+
"Exception of type 'System.Net.Http.HttpRequestException' was thrown.") {}
2739

2840
/** Creates a new instance with the specified message. */
2941
explicit HttpRequestException(const std::string& message) : System::Exception(message) {}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) Robert Vokac and contributors
3+
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
4+
//
5+
// Ticket #2323 (SR-AUD-092). The companion of
6+
// modules/core/tests/System/ExceptionNewTests.cpp's ExceptionFallbackMessageTests, in its own
7+
// module because Core.Base does not depend on Net.Http and a test is not a reason to add a
8+
// public component edge (#2354's rule).
9+
//
10+
// .NET's Exception.Message is `_message ?? SR.Format(SR.Exception_WasThrown, GetClassName())`
11+
// (Exception.cs:61,65) -- "Exception of type '{0}' was thrown." (Strings.resx:2333) -- and .NET's
12+
// HttpRequestException() is `{ }` (HttpRequestException.cs:10-11), so it reaches that fallback
13+
// and {0} names THIS type. {0} is reflection, which this port permanently lacks, so it is
14+
// resolved statically here rather than inherited from the base: a message naming the wrong type
15+
// would be a lie, where the empty message this replaced was merely an absence.
16+
#include <gtest/gtest.h>
17+
#include <string>
18+
#include "System/Net/Http/HttpRequestException.hpp"
19+
20+
TEST(HttpRequestExceptionFallbackTests, DefaultCtorNamesThisTypeNotTheBase) {
21+
const System::Net::Http::HttpRequestException e;
22+
EXPECT_EQ(e.getMessageProperty(),
23+
"Exception of type 'System.Net.Http.HttpRequestException' was thrown.");
24+
EXPECT_NE(e.getMessageProperty(), "Exception of type 'System.Exception' was thrown.")
25+
<< "inheriting the base's string would name the wrong type";
26+
}
27+
28+
TEST(HttpRequestExceptionFallbackTests, AnExplicitlyEmptyMessageStaysEmpty) {
29+
// .NET's fallback fires on a NULL message, not an empty one.
30+
EXPECT_TRUE(System::Net::Http::HttpRequestException("").getMessageProperty().empty());
31+
EXPECT_EQ(System::Net::Http::HttpRequestException("boom").getMessageProperty(), "boom");
32+
}

modules/text-json/include/System/Text/Json/JsonException.hpp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,17 @@ namespace System::Text::Json {
2323
std::optional<std::string> path_;
2424

2525
public:
26-
JsonException() = default;
26+
/**
27+
* @brief Constructs an exception with .NET's fallback message for this type.
28+
*
29+
* Ticket #2323. .NET's `JsonException()` is `: base() { }` (JsonException.cs:78) and its
30+
* `Message` override is `_message ?? base.Message` (:141-147), so it reaches
31+
* `Exception`'s `SR.Format(SR.Exception_WasThrown, GetClassName())` and names THIS type.
32+
* See HttpRequestException for why `{0}` is resolved statically.
33+
*/
34+
JsonException()
35+
: System::Exception(
36+
"Exception of type 'System.Text.Json.JsonException' was thrown.") {}
2737
explicit JsonException(const std::string& message) : System::Exception(message) {}
2838
JsonException(const std::string& message, std::exception_ptr innerException)
2939
: System::Exception(message, innerException) {}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) Robert Vokac and contributors
3+
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
4+
//
5+
// Ticket #2323 (SR-AUD-092). See HttpRequestExceptionFallbackTests for the shared rationale;
6+
// this is the second of the two subclasses that reach `System::Exception`'s fallback.
7+
//
8+
// .NET's JsonException() is `: base() { }` (JsonException.cs:78) and its Message override is
9+
// `_message ?? base.Message` (:141-147), so it reaches Exception's formatted default and names
10+
// THIS type.
11+
#include <gtest/gtest.h>
12+
#include <string>
13+
#include "System/Text/Json/JsonException.hpp"
14+
15+
TEST(JsonExceptionFallbackTests, DefaultCtorNamesThisTypeNotTheBase) {
16+
const System::Text::Json::JsonException e;
17+
EXPECT_EQ(e.getMessageProperty(),
18+
"Exception of type 'System.Text.Json.JsonException' was thrown.");
19+
EXPECT_NE(e.getMessageProperty(), "Exception of type 'System.Exception' was thrown.")
20+
<< "inheriting the base's string would name the wrong type";
21+
}
22+
23+
TEST(JsonExceptionFallbackTests, AnExplicitlyEmptyMessageStaysEmpty) {
24+
EXPECT_TRUE(System::Text::Json::JsonException("").getMessageProperty().empty());
25+
EXPECT_EQ(System::Text::Json::JsonException("boom").getMessageProperty(), "boom");
26+
}

plan.sqlite3

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)