Commit 7ff22d0
Fix ModelProvider base-ctor virtual-dispatch anti-pattern (microsoft#10628)
Fixes microsoft#10626.
## Problem
`ModelProvider`'s base constructor was reaching back into derived-class
state via virtual dispatch — the classic
[CA2214](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2214)
anti-pattern. C# guarantees derived ctor bodies run only after
`base(...)` returns, so any code reachable from `ModelProvider..ctor`
that overrides `BuildBaseType()` (or other virtuals) and reads a
derived-class field gets a `NullReferenceException` deep in framework
code, with no obvious link back to "your derived ctor body has not run
yet."
This is especially harmful for explicit extension points like
`BuildBaseType` — extension authors discover the contract only via
runtime NREs.
Two distinct call chains in `ModelProvider..ctor` triggered this
anti-pattern:
### Site 1 — `AddTypeToKeep(this)` (filed in microsoft#10626)
```
ModelProvider..ctor
└─ CodeModelGenerator.AddTypeToKeep(this)
└─ this.Type
└─ this.BaseType
└─ virtual BuildBaseType() ← dispatches onto partially-constructed derived class
```
### Site 2 — `EnsureDiscriminatorValueExpression()`
Surfaced while validating the fix end-to-end against the
`Azure.Provisioning.Cdn` migration. After fixing site 1 above, regen
still NRE'd:
```
ModelProvider..ctor
└─ if (_inputModel.BaseModel is not null) DiscriminatorValueExpression = EnsureDiscriminatorValueExpression()
└─ BaseModelProvider
└─ BuildBaseModelProvider
└─ get_BaseType
└─ virtual BuildBaseType() ← same anti-pattern, different path
```
Real stack trace from a `ProvisioningResourceProvider` derived from
`ProvisioningModelProvider : ModelProvider`:
```
at Azure.Generator.Provisioning.Providers.ProvisioningResourceProvider.BuildBaseType()
at TypeProvider.get_BaseType()
at ModelProvider.BuildBaseModelProvider()
at ModelProvider.get_BaseModelProvider()
at ModelProvider.EnsureDiscriminatorValueExpression()
at ModelProvider..ctor(InputModelType inputModel)
```
## Fix
Three complementary changes that together remove every ctor-time
reach-into-derived-state in `ModelProvider`:
### 1. Move keep-set registration out of the constructor (site 1, root
cause)
Removed the `AddTypeToKeep(this)` call from `ModelProvider..ctor`.
`TypeFactory.CreateModel` now performs the registration **after**
`CreateModelCore` and `PreVisitModel` have completed:
```csharp
// TypeFactory.cs
modelProvider = CreateModelCore(model);
foreach (var visitor in Visitors)
modelProvider = visitor.PreVisitModel(model, modelProvider);
InputTypeToModelProvider[model] = modelProvider;
if (modelProvider != null)
{
if (model.Access == "public")
CodeModelGenerator.Instance.AddTypeToKeep(modelProvider); // ← moved here
...
}
```
This mirrors the existing `EnumProvider` / `TypeFactory.CreateEnum`
lifecycle, where the same `if (Access == "public") AddTypeToKeep(...)`
registration is already performed post-construction.
### 2. Defer FQN resolution in `AddTypeToKeep(TypeProvider)` (defense in
depth)
`AddTypeToKeep(TypeProvider)` previously called
`type.Type.FullyQualifiedName` immediately, which is what triggered the
virtual `BuildBaseType` dispatch. Now it stores the `TypeProvider`
reference and resolves the FQN lazily when `AdditionalRootTypes` /
`NonRootTypes` is enumerated. The keep sets are only consumed by
`GeneratedCodeWorkspace.PostProcessAsync` (and unit tests), well after
every provider has been fully constructed.
This hardens the API against any future ctor-time caller — including
third-party extensions that subclass other `TypeProvider` types and call
`AddTypeToKeep` from their own constructors.
The public API surface of `AddTypeToKeep(string)` and
`AddTypeToKeep(TypeProvider)` is unchanged; the storage is split
internally and unioned at read time.
### 3. Defer `DiscriminatorValueExpression` evaluation (site 2)
Converted `DiscriminatorValueExpression` from an eager-init property
assigned in the ctor to a `Lazy<ValueExpression?>` materialized on first
read:
```csharp
// before
public ValueExpression? DiscriminatorValueExpression { get; init; }
if (_inputModel.BaseModel is not null)
DiscriminatorValueExpression = EnsureDiscriminatorValueExpression(); // virtual dispatch via BaseModelProvider
// after
private readonly Lazy<ValueExpression?> _discriminatorValueExpression;
public ValueExpression? DiscriminatorValueExpression => _discriminatorValueExpression.Value;
_discriminatorValueExpression = new Lazy<ValueExpression?>(() =>
_inputModel.BaseModel is not null ? EnsureDiscriminatorValueExpression() : null);
```
All `DiscriminatorValueExpression` consumers (`ModelProvider` ctor body
for non-primary constructors, `ModelFactoryProvider`) read it during
emission/serialization, far after construction. No external callers
write to the property — verified across `microsoft/typespec` and
`Azure/azure-sdk-for-net`.
## Tests
Two regression tests in `ModelProviderTests`, both using a
`DerivedModelProviderReadingOwnField` fixture whose `BuildBaseType()`
override reads a derived-class field — would NRE before the fix:
- `DerivedModelProviderConstructionDoesNotForceTypeEvaluation` — covers
sites 1 & 2 (registration + lazy FQN). Constructs the derived provider,
calls `AddTypeToKeep(TypeProvider)` explicitly, verifies FQN appears in
`AdditionalRootTypes`.
- `DerivedModelProviderConstructionDoesNotForceDiscriminatorEvaluation`
— covers site 3. Constructs a derived provider for a model with a base +
discriminator value, asserts no NRE during ctor and that
`DiscriminatorValueExpression` is still computable on demand.
Existing `PublicModelsAreIncludedInAdditionalRootTypes` /
`InternalModelsAreNotIncludedInAdditionalRootTypes` tests continue to
pass — they query the keep set after
`MockHelpers.LoadMockGenerator(...)` builds the full output library,
which goes through the new `TypeFactory.CreateModel` registration path.
Test results:
- `Microsoft.TypeSpec.Generator.Tests`: **1452 / 1452 passing**
- `Microsoft.TypeSpec.Generator.ClientModel.Tests`: **1323 / 1323
passing**
End-to-end validation: re-ran the `Azure.Provisioning.Cdn` regen (which
initially exposed both NRE sites) with this PR's binaries overlaid.
Generation now completes cleanly.
## Behavioral notes
- Anyone instantiating `ModelProvider` directly via `new
ModelProvider(...)` instead of `TypeFactory.CreateModel(...)` will no
longer get the auto-keep behavior. This matches `EnumProvider`'s
existing contract and is consistent with the framework's intended
factory entry point. None of the in-tree generators (mgmt / provisioning
/ azure / scm) rely on the bypass path; the only direct `new
ModelProvider(...)` calls are in unit tests, which assert behavior that
does not depend on keep-set membership.
- `DiscriminatorValueExpression` is now computed on first access. Result
is identical for all current call sites; the only observable difference
is timing, which is irrelevant because all readers run during emission.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>1 parent f1d33ef commit 7ff22d0
4 files changed
Lines changed: 177 additions & 16 deletions
File tree
- packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator
- src
- Providers
- test/Providers/ModelProviders
Lines changed: 66 additions & 5 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
163 | | - | |
| 163 | + | |
164 | 164 | | |
165 | | - | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
166 | 198 | | |
167 | 199 | | |
168 | 200 | | |
| |||
174 | 206 | | |
175 | 207 | | |
176 | 208 | | |
177 | | - | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
178 | 213 | | |
179 | 214 | | |
180 | 215 | | |
181 | | - | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
182 | 220 | | |
183 | 221 | | |
184 | 222 | | |
185 | 223 | | |
186 | 224 | | |
187 | 225 | | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
188 | 233 | | |
189 | 234 | | |
190 | 235 | | |
191 | | - | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
192 | 253 | | |
193 | 254 | | |
194 | 255 | | |
| |||
Lines changed: 6 additions & 11 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
85 | 75 | | |
86 | 76 | | |
87 | 77 | | |
88 | 78 | | |
89 | 79 | | |
90 | | - | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
91 | 86 | | |
92 | 87 | | |
93 | 88 | | |
| |||
Lines changed: 5 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
191 | 191 | | |
192 | 192 | | |
193 | 193 | | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
194 | 199 | | |
195 | 200 | | |
196 | 201 | | |
| |||
Lines changed: 100 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1480 | 1480 | | |
1481 | 1481 | | |
1482 | 1482 | | |
| 1483 | + | |
| 1484 | + | |
| 1485 | + | |
| 1486 | + | |
| 1487 | + | |
| 1488 | + | |
| 1489 | + | |
| 1490 | + | |
| 1491 | + | |
| 1492 | + | |
| 1493 | + | |
| 1494 | + | |
| 1495 | + | |
| 1496 | + | |
| 1497 | + | |
| 1498 | + | |
| 1499 | + | |
| 1500 | + | |
| 1501 | + | |
| 1502 | + | |
| 1503 | + | |
| 1504 | + | |
| 1505 | + | |
| 1506 | + | |
| 1507 | + | |
| 1508 | + | |
| 1509 | + | |
| 1510 | + | |
| 1511 | + | |
| 1512 | + | |
| 1513 | + | |
| 1514 | + | |
| 1515 | + | |
| 1516 | + | |
| 1517 | + | |
| 1518 | + | |
| 1519 | + | |
| 1520 | + | |
| 1521 | + | |
| 1522 | + | |
| 1523 | + | |
| 1524 | + | |
| 1525 | + | |
| 1526 | + | |
| 1527 | + | |
| 1528 | + | |
| 1529 | + | |
| 1530 | + | |
| 1531 | + | |
| 1532 | + | |
| 1533 | + | |
| 1534 | + | |
| 1535 | + | |
| 1536 | + | |
| 1537 | + | |
| 1538 | + | |
| 1539 | + | |
| 1540 | + | |
| 1541 | + | |
| 1542 | + | |
| 1543 | + | |
| 1544 | + | |
| 1545 | + | |
| 1546 | + | |
| 1547 | + | |
| 1548 | + | |
| 1549 | + | |
| 1550 | + | |
| 1551 | + | |
| 1552 | + | |
| 1553 | + | |
| 1554 | + | |
| 1555 | + | |
| 1556 | + | |
| 1557 | + | |
| 1558 | + | |
| 1559 | + | |
| 1560 | + | |
| 1561 | + | |
| 1562 | + | |
| 1563 | + | |
| 1564 | + | |
| 1565 | + | |
| 1566 | + | |
| 1567 | + | |
| 1568 | + | |
| 1569 | + | |
| 1570 | + | |
| 1571 | + | |
| 1572 | + | |
| 1573 | + | |
| 1574 | + | |
| 1575 | + | |
| 1576 | + | |
| 1577 | + | |
| 1578 | + | |
| 1579 | + | |
| 1580 | + | |
| 1581 | + | |
| 1582 | + | |
1483 | 1583 | | |
1484 | 1584 | | |
1485 | 1585 | | |
| |||
0 commit comments