Skip to content

Commit 17063c8

Browse files
committed
fix(runtime): the interop metadata values match .NET (#1980, group G-2)
Rule-14 sweep. P/Invoke is a declared permanent deviation, so these types exist to preserve the managed metadata values rather than to produce a native effect -- getting the numbers right is the whole of their contract. The LPStruct finding was understated. The plan recorded "48 -> 43", a wrong number; 48 is LPUTF8Str's value, so the two enumerators were INDISTINGUISHABLE -- LPStruct == LPUTF8Str was true, and a switch over UnmanagedType could not carry both arms. A test asserts the inequality, not just the two numbers. Two more divergences the plan never named were found by measuring the reference alongside the four it did: BOTH CharSet fields. .NET declares them as plain fields with no initializer, so the default is 0 -- which is not a declared enumerator (None is 1). Reproducing an unnamed default is deliberate and follows from the types' purpose; a test states the reasoning so it is not later "corrected" back. Also repaired: Currency and IDispatch were absent; Pack was 8 where .NET's plain `public int Pack;` gives 0; PreserveSig and BestFitMapping were true where .NET's plain `public bool` gives false -- and the port had already got the other three booleans right, which is what makes those two a divergence rather than a policy. Every other UnmanagedType value already matched, asserted across a spread so the three repairs cannot be mistaken for a renumbering. Five mutations, all caught; M2 at compile time, since removing an enumerator breaks the test naming it and that is the only way C++ reports it. The plan predicted the one pin that had to be inverted (DefaultPack_IsEight), and it was the only pre-existing test this group touched. Downstream measured: 0 sites in cna, 0 in mobile-eggbert. #1980 stays open for G-3 (vtable), G-4 (mandatory migration) and G-5. Gate: 17,503 run, 17,503 passed, 0 failed, 0 skipped across 38 executables (+5 on 17,498; SharpRuntimeTests_Runtime 181 -> 186; no other executable moved). Module graph unchanged at 41/93.
1 parent 9a971d8 commit 17063c8

5 files changed

Lines changed: 229 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — the interop metadata values match .NET (ticket #1980, group G-2)
5+
6+
*2026-08-19.* `System::Runtime::InteropServices`' `UnmanagedType`, `StructLayoutAttribute` and
7+
`DllImportAttribute` carried wrong constants and wrong defaults. All of them now match the
8+
reference.
9+
10+
Landed under **SA-5**. No layout, vtable, signature or `noexcept` change — only *values*.
11+
12+
---
13+
14+
## 1. Why the numbers are the whole contract
15+
16+
P/Invoke and interop are a **declared permanent deviation**: these types exist so ported
17+
declarations keep compiling and so the **managed metadata values are preserved**, not to produce
18+
a native effect. The header says so itself. Getting the numbers right is therefore not cosmetic —
19+
it is the entire purpose of the types.
20+
21+
## 2. What changed
22+
23+
| Declaration | Was | Is | Reference |
24+
|---|---|---|---|
25+
| `UnmanagedType::LPStruct` | **48** | **43** | `UnmanagedType.cs:55` (`0x2b`) |
26+
| `UnmanagedType::Currency` | **absent** | **15** | `UnmanagedType.cs:22` (`0xf`) |
27+
| `UnmanagedType::IDispatch` | **absent** | **26** | `UnmanagedType.cs:30` (`0x1a`) |
28+
| `StructLayoutAttribute::Pack` | **8** | **0** | `StructLayoutAttribute.cs:21` |
29+
| `StructLayoutAttribute::CharSet` | `Ansi` (2) | **0** | `StructLayoutAttribute.cs:23` |
30+
| `DllImportAttribute::CharSet` | `None` (1) | **0** | plain field, no initializer |
31+
| `DllImportAttribute::PreserveSig` | **true** | **false** | `DllImportAttribute.cs:22` |
32+
| `DllImportAttribute::BestFitMapping` | **true** | **false** | `DllImportAttribute.cs:21` |
33+
34+
## 3. The `LPStruct` finding was understated
35+
36+
The plan recorded *"`LPStruct` 48 → 43"* — a wrong number. Measured, it is worse than that: **48
37+
is `LPUTF8Str`'s value**, so the two enumerators were **indistinguishable**.
38+
`UnmanagedType::LPStruct == UnmanagedType::LPUTF8Str` was **true**, and a `switch` over
39+
`UnmanagedType` could not carry both arms — it would not compile.
40+
41+
A test now asserts the inequality, not just the two numbers.
42+
43+
## 4. Two divergences the plan did not name
44+
45+
G-2's list named four items. Measuring the reference alongside them found a fifth and sixth: both
46+
`CharSet` fields.
47+
48+
.NET declares `public CharSet CharSet;` on both attributes **with no initializer**, so the default
49+
is `0` — and `CharSet` has **no enumerator with that value** (`None` is 1). This port defaulted
50+
them to named values instead.
51+
52+
**Reproducing an unnamed default is deliberate**, and it follows directly from §1: the header
53+
exists to preserve the managed metadata, and .NET's metadata really does carry an unset `CharSet`
54+
here. A test states the reasoning so it is not later "corrected" back to a named value.
55+
56+
## 5. What did *not* change, and why that matters
57+
58+
Every other `UnmanagedType` value already matched .NET exactly — `Bool`, the whole `I1``U8`
59+
block, `BStr`, `IUnknown`, `Struct`, `LPArray`, `CustomMarshaler`, `HString` and the rest. A test
60+
asserts a representative spread of them, so the three repairs above cannot be mistaken for a
61+
wholesale renumbering.
62+
63+
The same applies to `DllImportAttribute`'s booleans: `SetLastError`, `ExactSpelling` and
64+
`ThrowOnUnmappableChar` were **already** `false`. That the port got three right and two wrong is
65+
what makes `PreserveSig`/`BestFitMapping` a divergence rather than a deliberate policy.
66+
67+
## 6. Evidence
68+
69+
Five mutations, **all caught** — one per repaired value, plus the `CharSet` default:
70+
71+
| Mutation | Caught by |
72+
|---|---|
73+
| M1 — `LPStruct` back to 48 | `Fix1980G2_LPStructNoLongerCollidesWithLPUTF8Str` |
74+
| M2 — `Currency` removed | **at compile time**`error: 'Currency' is not a member of 'UnmanagedType'`, which is the only way C++ can report a missing enumerator |
75+
| M3 — `Pack` back to 8 | `Fix1980G2_DefaultPackIsZeroNotEight` |
76+
| M4 — `PreserveSig` back to true | `Fix1980G2_DllImportBoolDefaultsAreAllFalse` |
77+
| M5 — `CharSet` named again | `Decl1980G2_BothCharSetDefaultsAreUnsetNotNamed` |
78+
79+
**One pre-existing test was inverted, and the plan predicted exactly that**:
80+
`StructLayoutAttributeTests.DefaultPack_IsEight` pinned the wrong value, and the plan's own G-2
81+
row said it *"would have to be rewritten"*. It is the only pre-existing test this group touches.
82+
83+
Gate: **17,503 run, 17,503 passed, 0 failed, 0 skipped** across 38 executables — `+5` on 17,498
84+
(`SharpRuntimeTests_Runtime` 181 → 186; one pin inverted in place, five cases added). No other
85+
executable moved. Module graph unchanged at 41/93.
86+
87+
## 7. Downstream, measured
88+
89+
`UnmanagedType`, `StructLayoutAttribute`, `DllImportAttribute` and `MarshalAsAttribute` appear in
90+
**zero** places in `cna` and **zero** in `mobile-eggbert`. Neither repository was modified.
91+
92+
## 8. Scope
93+
94+
This closes **G-2** of #1980. Remaining: **G-3** (reparenting and sealing — a vtable *and* layout
95+
change SA-3 excludes), **G-4** (removing `setIsOptionalProperty`, moving `Url` to a settable
96+
property — mandatory migration), and **G-5** / SR-AUD-167 (retyping `MarshalAs` fields, adding
97+
`ComInterfaceType`/`ClassInterfaceType`). G-1 landed earlier the same day.

modules/runtime/include/System/Runtime/InteropServices/InteropAttributes.hpp

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,15 @@ namespace System::Runtime::InteropServices {
3434
* Stated explicitly because the audit found this header to be the one interop-adjacent file
3535
* that described effects it cannot produce **without saying so**, unlike the compiler-service
3636
* marker headers alongside it (ticket #1978, the disclosure half of SR-AUD-168). The
37-
* remaining divergences in the *values* this header declares — `UnmanagedType::LPStruct`,
38-
* `StructLayoutAttribute::Pack`, `DllImportAttribute::PreserveSig`/`BestFitMapping`, the
39-
* omitted `MarshalAsAttribute` fields and the absent `ComInterfaceType`/`ClassInterfaceType`
40-
* enums — are SR-AUD-165/166/167 and are tracked separately by the approval-gated ticket
41-
* #1980, because changing them changes declarations a consumer can already name.
37+
* The *value* divergences this header used to declare — `UnmanagedType::LPStruct`, the
38+
* missing `Currency`/`IDispatch`, `StructLayoutAttribute::Pack`, both `CharSet` defaults and
39+
* `DllImportAttribute::PreserveSig`/`BestFitMapping` — were **#1980 group G-2** and are now
40+
* fixed; see docs/Migration-InteropMetadataValues.md. What remains of #1980 here is
41+
* **SR-AUD-167** (group G-5): the omitted `MarshalAsAttribute` fields and the absent
42+
* `ComInterfaceType`/`ClassInterfaceType` enums.
43+
*
44+
* Because these types exist to preserve managed metadata rather than to produce an effect,
45+
* getting the *numbers* right is the whole of their contract.
4246
*/
4347

4448
/** Specifies the memory layout of a managed class or struct. */
@@ -69,6 +73,10 @@ namespace System::Runtime::InteropServices {
6973
U8 = 10,
7074
R4 = 11,
7175
R8 = 12,
76+
// #1980 G-2 / SR-AUD-165: Currency and IDispatch were absent. .NET declares
77+
// `Currency = 0xf` and `IDispatch = 0x1a` (UnmanagedType.cs:22,30).
78+
Currency = 15,
79+
IDispatch = 26,
7280
LPStr = 20,
7381
LPWStr = 21,
7482
LPTStr = 22,
@@ -88,7 +96,12 @@ namespace System::Runtime::InteropServices {
8896
FunctionPtr= 38,
8997
AsAny = 40,
9098
LPArray = 42,
91-
LPStruct = 48,
99+
// #1980 G-2 / SR-AUD-165. This was 48, which is not merely the wrong number: 48 is
100+
// LPUTF8Str's value, so the two enumerators COLLIDED -- `LPStruct == LPUTF8Str` was true,
101+
// and a switch over UnmanagedType could not carry both arms. .NET declares
102+
// `LPStruct = 0x2b` (UnmanagedType.cs:55). The plan recorded this as a wrong value; the
103+
// collision is what measurement added.
104+
LPStruct = 43,
92105
CustomMarshaler = 44,
93106
Error = 45,
94107
IInspectable = 46,
@@ -109,9 +122,28 @@ namespace System::Runtime::InteropServices {
109122
class StructLayoutAttribute : public System::Attribute {
110123
public:
111124
LayoutKind Value; ///< The layout kind.
112-
SharpRuntime::intcs Pack = 8; ///< Packing alignment in bytes.
125+
/**
126+
* Packing alignment in bytes.
127+
*
128+
* #1980 G-2 / SR-AUD-166: this defaulted to **8**. .NET declares `public int Pack;` with
129+
* no initializer (`StructLayoutAttribute.cs:21`), so a default-constructed attribute
130+
* reports **0** -- which in the metadata means "use the runtime's default packing", a
131+
* different statement from "pack to 8".
132+
*/
133+
SharpRuntime::intcs Pack = 0;
113134
SharpRuntime::intcs Size = 0; ///< Minimum size in bytes (0 = no minimum).
114-
::System::Runtime::InteropServices::CharSet CharSet = ::System::Runtime::InteropServices::CharSet::Ansi; ///< Character set used for embedded strings.
135+
/**
136+
* Character set used for embedded strings.
137+
*
138+
* #1980 G-2: this defaulted to `CharSet::Ansi`. .NET declares `public CharSet CharSet;`
139+
* with no initializer (`StructLayoutAttribute.cs:23`), so the default is **0** -- and
140+
* `CharSet` has no enumerator with that value (`None` is 1). **That is deliberate, not a
141+
* slip**: this header exists to preserve the managed metadata values exactly, and .NET's
142+
* metadata really does carry an unset CharSet here. The plan's G-2 list did not name this
143+
* field; it was found by measuring the reference alongside the four it did name.
144+
*/
145+
::System::Runtime::InteropServices::CharSet CharSet =
146+
static_cast<::System::Runtime::InteropServices::CharSet>(0);
115147

116148
/** @param layout The desired memory layout kind. */
117149
explicit StructLayoutAttribute(LayoutKind layout) : Value(layout) {}
@@ -152,12 +184,34 @@ namespace System::Runtime::InteropServices {
152184
public:
153185
std::string Value; ///< The name of the DLL to import from.
154186
std::string EntryPoint; ///< Name of the exported function; empty means use the method name.
155-
::System::Runtime::InteropServices::CharSet CharSet = ::System::Runtime::InteropServices::CharSet::None; ///< String marshalling character set.
187+
/**
188+
* String marshalling character set.
189+
*
190+
* #1980 G-2: this defaulted to `CharSet::None` (1). .NET declares
191+
* `public CharSet CharSet;` with no initializer, so the default is **0**, which is not a
192+
* declared enumerator. Same reasoning as `StructLayoutAttribute::CharSet` above.
193+
*/
194+
::System::Runtime::InteropServices::CharSet CharSet =
195+
static_cast<::System::Runtime::InteropServices::CharSet>(0);
156196
::System::Runtime::InteropServices::CallingConvention CallingConvention = ::System::Runtime::InteropServices::CallingConvention::Winapi; ///< Calling convention.
157197
bool SetLastError = false; ///< Capture GetLastError after the call.
158198
bool ExactSpelling = false; ///< Disable automatic A/W suffix probing.
159-
bool PreserveSig = true; ///< Preserve the signature (no HRESULT transformation).
160-
bool BestFitMapping = true; ///< Enable best-fit character mapping.
199+
/**
200+
* Preserve the signature (no HRESULT transformation).
201+
*
202+
* #1980 G-2 / SR-AUD-166: this defaulted to **true**. .NET declares `public bool
203+
* PreserveSig;` with no initializer (`DllImportAttribute.cs:22`), so the field reads
204+
* **false** on a default-constructed attribute -- the same as every other bool on the
205+
* type, none of which this port had got wrong.
206+
*/
207+
bool PreserveSig = false;
208+
/**
209+
* Enable best-fit character mapping.
210+
*
211+
* #1980 G-2 / SR-AUD-166: this defaulted to **true**; .NET's is a plain
212+
* `public bool BestFitMapping;` (`DllImportAttribute.cs:21`), i.e. **false**.
213+
*/
214+
bool BestFitMapping = false;
161215
bool ThrowOnUnmappableChar = false; ///< Throw on unmappable Unicode characters.
162216

163217
/** @param dllName The name of the native DLL. */

modules/runtime/tests/System/Runtime/RuntimeTests.cpp

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,9 +496,13 @@ TEST(StructLayoutAttributeTests, Constructor_StoresLayout) {
496496
EXPECT_EQ(attr.Value, LayoutKind::Sequential);
497497
}
498498

499-
TEST(StructLayoutAttributeTests, DefaultPack_IsEight) {
499+
TEST(StructLayoutAttributeTests, Fix1980G2_DefaultPackIsZeroNotEight) {
500+
// INVERTED by #1980 group G-2. The plan predicted this exact pin would have to be rewritten,
501+
// and it is the only pre-existing test the group touches. .NET declares `public int Pack;`
502+
// with no initializer (StructLayoutAttribute.cs:21), so the default is 0 -- which in the
503+
// metadata means "use the runtime's default packing", a different statement from "pack to 8".
500504
StructLayoutAttribute attr(LayoutKind::Explicit);
501-
EXPECT_EQ(attr.Pack, 8);
505+
EXPECT_EQ(attr.Pack, 0);
502506
}
503507

504508
TEST(StructLayoutAttributeTests, DefaultSize_IsZero) {
@@ -1036,3 +1040,63 @@ TEST(ConditionalWeakTableEnumeratorTests, Decl1981_TheTablesOwnLayoutIsUnchanged
10361040
static_assert(alignof(WeakTable) == 8);
10371041
EXPECT_EQ(sizeof(WeakTable), 72u);
10381042
}
1043+
1044+
// =============================================================================================
1045+
// Ticket #1980 group G-2 (SR-AUD-165/166) — the interop metadata VALUES.
1046+
//
1047+
// These types exist to preserve managed metadata, not to produce an effect (P/Invoke and interop
1048+
// are a declared permanent deviation), so getting the numbers right is the whole of their
1049+
// contract. Landed under SA-5: every value below is transcribed from the reference.
1050+
// =============================================================================================
1051+
1052+
TEST(InteropMetadataValueTests, Fix1980G2_LPStructNoLongerCollidesWithLPUTF8Str) {
1053+
// THE FINDING WAS UNDERSTATED. LPStruct was 48 -- which is LPUTF8Str's value, so the two
1054+
// enumerators were INDISTINGUISHABLE: the comparison below was true, and a switch over
1055+
// UnmanagedType could not carry both arms. .NET: LPStruct = 0x2b (UnmanagedType.cs:55).
1056+
EXPECT_EQ(static_cast<int>(UnmanagedType::LPStruct), 43);
1057+
EXPECT_EQ(static_cast<int>(UnmanagedType::LPUTF8Str), 48);
1058+
EXPECT_NE(UnmanagedType::LPStruct, UnmanagedType::LPUTF8Str);
1059+
}
1060+
1061+
TEST(InteropMetadataValueTests, Fix1980G2_CurrencyAndIDispatchExist) {
1062+
// Both were absent. .NET: Currency = 0xf, IDispatch = 0x1a (UnmanagedType.cs:22,30).
1063+
EXPECT_EQ(static_cast<int>(UnmanagedType::Currency), 15);
1064+
EXPECT_EQ(static_cast<int>(UnmanagedType::IDispatch), 26);
1065+
}
1066+
1067+
TEST(InteropMetadataValueTests, Decl1980G2_EveryOtherUnmanagedTypeValueWasAlreadyRight) {
1068+
// Asserted so the two repairs above are not mistaken for a wholesale renumbering: the rest of
1069+
// the enum already matched .NET exactly, which is why only these three rows moved.
1070+
EXPECT_EQ(static_cast<int>(UnmanagedType::Bool), 2);
1071+
EXPECT_EQ(static_cast<int>(UnmanagedType::U8), 10);
1072+
EXPECT_EQ(static_cast<int>(UnmanagedType::BStr), 19);
1073+
EXPECT_EQ(static_cast<int>(UnmanagedType::IUnknown), 25);
1074+
EXPECT_EQ(static_cast<int>(UnmanagedType::Struct), 27);
1075+
EXPECT_EQ(static_cast<int>(UnmanagedType::LPArray), 42);
1076+
EXPECT_EQ(static_cast<int>(UnmanagedType::CustomMarshaler), 44);
1077+
EXPECT_EQ(static_cast<int>(UnmanagedType::HString), 47);
1078+
}
1079+
1080+
TEST(InteropMetadataValueTests, Fix1980G2_DllImportBoolDefaultsAreAllFalse) {
1081+
// PreserveSig and BestFitMapping defaulted to TRUE. .NET declares every one of these as a
1082+
// plain `public bool` with no initializer (DllImportAttribute.cs:18-23), so all five read
1083+
// false -- and the port had already got the other three right, which is what made the two
1084+
// outliers a divergence rather than a policy.
1085+
DllImportAttribute attr("lib");
1086+
EXPECT_FALSE(attr.PreserveSig);
1087+
EXPECT_FALSE(attr.BestFitMapping);
1088+
EXPECT_FALSE(attr.SetLastError);
1089+
EXPECT_FALSE(attr.ExactSpelling);
1090+
EXPECT_FALSE(attr.ThrowOnUnmappableChar);
1091+
}
1092+
1093+
TEST(InteropMetadataValueTests, Decl1980G2_BothCharSetDefaultsAreUnsetNotNamed) {
1094+
// A divergence the plan's G-2 list did NOT name, found by measuring the reference alongside
1095+
// the four it did. .NET declares `public CharSet CharSet;` on both attributes with no
1096+
// initializer, so the default is 0 -- and CharSet has NO enumerator with that value (None is
1097+
// 1). Reproducing it is deliberate: the header's stated purpose is to preserve the managed
1098+
// metadata values, and .NET's metadata really does carry an unset CharSet here.
1099+
EXPECT_EQ(static_cast<int>(CharSet::None), 1) << "None is 1, so 0 is genuinely unnamed";
1100+
EXPECT_EQ(static_cast<int>(StructLayoutAttribute(LayoutKind::Sequential).CharSet), 0);
1101+
EXPECT_EQ(static_cast<int>(DllImportAttribute("lib").CharSet), 0);
1102+
}

plan.sqlite3

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)