Skip to content

Fix ModelProvider base-ctor virtual-dispatch anti-pattern#10628

Open
ArcturusZhang wants to merge 5 commits intomicrosoft:mainfrom
ArcturusZhang:fix/modelprovider-no-virtual-call-in-ctor
Open

Fix ModelProvider base-ctor virtual-dispatch anti-pattern#10628
ArcturusZhang wants to merge 5 commits intomicrosoft:mainfrom
ArcturusZhang:fix/modelprovider-no-virtual-call-in-ctor

Conversation

@ArcturusZhang
Copy link
Copy Markdown
Member

@ArcturusZhang ArcturusZhang commented May 8, 2026

Fixes #10626.

Problem

ModelProvider's base constructor was reaching back into derived-class state via virtual dispatch — the classic 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 #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:

// 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:

// 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.

…ddTypeToKeep

ModelProvider.ctor called CodeModelGenerator.AddTypeToKeep(this), which in
turn evaluated this.Type -> this.BaseType -> virtual BuildBaseType(). When a
derived ModelProvider's BuildBaseType reads any field assigned in the derived
constructor body, that field is still null (base ctor runs before derived
field assignment), causing a NullReferenceException deep in framework code.
This is the classic CA2214 anti-pattern and is especially harmful for an
explicit extension point.

Two complementary fixes:

1. Move the AddTypeToKeep(this) registration out of ModelProvider..ctor and
   into TypeFactory.CreateModel, mirroring the existing EnumProvider /
   TypeFactory.CreateEnum lifecycle. Registration now happens after the
   provider is fully constructed and after PreVisitModel runs.

2. Make AddTypeToKeep(TypeProvider) defer FQN resolution by storing the
   TypeProvider reference and resolving .Type.FullyQualifiedName lazily when
   AdditionalRootTypes / NonRootTypes are queried (the keep set is only
   consumed by the post-processor, well after construction). This hardens the
   API against any future ctor-time caller.

Adds a regression test that constructs a derived ModelProvider whose
BuildBaseType reads a derived field, and verifies AddTypeToKeep(TypeProvider)
preserves the FQN once the keep set is materialized.

Resolves microsoft#10626
@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:client:csharp Issue for the C# client emitter: @typespec/http-client-csharp label May 8, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/http-client-csharp@10628

commit: 4c534b4

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

No changes needing a change description found.

… site

ModelProvider..ctor was eagerly computing DiscriminatorValueExpression when the
input model had a non-null BaseModel. EnsureDiscriminatorValueExpression() reads
BaseModelProvider, which calls BuildBaseModelProvider -> get_BaseType ->
virtual BuildBaseType() onto a partially-constructed derived class - the same
anti-pattern as the AddTypeToKeep(this) site, just a different call chain.

Surfaced while validating the Cdn provisioning migration after the keep-set fix
landed: regen still failed with an NRE in
ProvisioningResourceProvider.BuildBaseType reading derived state.

Fix: convert DiscriminatorValueExpression from eager-init property to a
Lazy<ValueExpression?> that materializes on first read. No external callers
write to it (verified across typespec + azure-sdk-for-net repos).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ArcturusZhang ArcturusZhang changed the title Fix ModelProvider base ctor virtually dispatching BuildBaseType via AddTypeToKeep Fix ModelProvider base-ctor virtual-dispatch anti-pattern May 8, 2026
Match the style already used for other deferred fields in ModelProvider
(e.g. DerivedModels). Behavior is identical - the property still computes
on first read instead of in the ctor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ArcturusZhang added a commit to ArcturusZhang/azure-sdk-for-net that referenced this pull request May 8, 2026
…tomization

- Regenerate Azure.Provisioning.Cdn after the Microsoft.TypeSpec.Generator base-ctor
  virtual-dispatch fix (microsoft/typespec#10628) unblocks the migration.
- Add Custom/CdnProfile.cs with [CodeGenType(\"Profile\")] to rename the generated
  Profile resource to CdnProfile, matching the public API of the previous
  reflection-based generator and avoiding AZC0012 (too-generic type name).
- Update API listings (.net8.0 / .net10.0 / netstandard2.0).
- RegenSdkLocal.ps1: add -UseLocalMgmtGenerator and -UseLocalTypeSpec switches
  to overlay locally-built generator dlls on top of the dist/generator output.
  Useful for validating in-flight generator/typespec PRs end-to-end before merge.
- Invoke-SdkRegeneration.ps1: dump full tsp compile output to tsp-compile.log
  so generator-side errors are visible (the worker's error filter previously
  matched JSON payload lines, hiding the real .NET stack trace).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
/// The set of fully qualified type names to keep as non-roots. Resolved lazily; see
/// <see cref="AdditionalRootTypes"/> for rationale.
/// </summary>
internal HashSet<string> NonRootTypes => MaterializeKeepSet(_nonRootTypeNames, _nonRootTypeProviders);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we avoid recomputing this on every access of the properties ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I updated this to cache the materialized keep sets so repeated property reads don't re-enumerate providers or resolve FQNs again.

I also invalidate the cached set when AddTypeToKeep(...) actually adds a new name/provider. That path is not expected in the normal generation lifecycle after the keep sets are consumed, but it keeps the cache behavior correct for any future or test-only caller that reads the set before all registrations have completed, while preserving the main goal of not forcing TypeProvider.Type at registration time.


🤖 arcturus-copilot

ArcturusZhang and others added 2 commits May 11, 2026 10:45
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:client:csharp Issue for the C# client emitter: @typespec/http-client-csharp

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ModelProvider base constructor invokes virtual BuildBaseType (via AddTypeToKeep) before derived ctor runs

2 participants