Fix ModelProvider base-ctor virtual-dispatch anti-pattern#10628
Fix ModelProvider base-ctor virtual-dispatch anti-pattern#10628ArcturusZhang wants to merge 5 commits intomicrosoft:mainfrom
Conversation
…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
commit: |
|
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>
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>
…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); |
There was a problem hiding this comment.
Could we avoid recomputing this on every access of the properties ?
There was a problem hiding this comment.
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
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…virtual-call-in-ctor
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 afterbase(...)returns, so any code reachable fromModelProvider..ctorthat overridesBuildBaseType()(or other virtuals) and reads a derived-class field gets aNullReferenceExceptiondeep 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..ctortriggered this anti-pattern:Site 1 —
AddTypeToKeep(this)(filed in #10626)Site 2 —
EnsureDiscriminatorValueExpression()Surfaced while validating the fix end-to-end against the
Azure.Provisioning.Cdnmigration. After fixing site 1 above, regen still NRE'd:Real stack trace from a
ProvisioningResourceProviderderived fromProvisioningModelProvider : ModelProvider: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 fromModelProvider..ctor.TypeFactory.CreateModelnow performs the registration afterCreateModelCoreandPreVisitModelhave completed:This mirrors the existing
EnumProvider/TypeFactory.CreateEnumlifecycle, where the sameif (Access == "public") AddTypeToKeep(...)registration is already performed post-construction.2. Defer FQN resolution in
AddTypeToKeep(TypeProvider)(defense in depth)AddTypeToKeep(TypeProvider)previously calledtype.Type.FullyQualifiedNameimmediately, which is what triggered the virtualBuildBaseTypedispatch. Now it stores theTypeProviderreference and resolves the FQN lazily whenAdditionalRootTypes/NonRootTypesis enumerated. The keep sets are only consumed byGeneratedCodeWorkspace.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
TypeProvidertypes and callAddTypeToKeepfrom their own constructors.The public API surface of
AddTypeToKeep(string)andAddTypeToKeep(TypeProvider)is unchanged; the storage is split internally and unioned at read time.3. Defer
DiscriminatorValueExpressionevaluation (site 2)Converted
DiscriminatorValueExpressionfrom an eager-init property assigned in the ctor to aLazy<ValueExpression?>materialized on first read:All
DiscriminatorValueExpressionconsumers (ModelProviderctor body for non-primary constructors,ModelFactoryProvider) read it during emission/serialization, far after construction. No external callers write to the property — verified acrossmicrosoft/typespecandAzure/azure-sdk-for-net.Tests
Two regression tests in
ModelProviderTests, both using aDerivedModelProviderReadingOwnFieldfixture whoseBuildBaseType()override reads a derived-class field — would NRE before the fix:DerivedModelProviderConstructionDoesNotForceTypeEvaluation— covers sites 1 & 2 (registration + lazy FQN). Constructs the derived provider, callsAddTypeToKeep(TypeProvider)explicitly, verifies FQN appears inAdditionalRootTypes.DerivedModelProviderConstructionDoesNotForceDiscriminatorEvaluation— covers site 3. Constructs a derived provider for a model with a base + discriminator value, asserts no NRE during ctor and thatDiscriminatorValueExpressionis still computable on demand.Existing
PublicModelsAreIncludedInAdditionalRootTypes/InternalModelsAreNotIncludedInAdditionalRootTypestests continue to pass — they query the keep set afterMockHelpers.LoadMockGenerator(...)builds the full output library, which goes through the newTypeFactory.CreateModelregistration path.Test results:
Microsoft.TypeSpec.Generator.Tests: 1452 / 1452 passingMicrosoft.TypeSpec.Generator.ClientModel.Tests: 1323 / 1323 passingEnd-to-end validation: re-ran the
Azure.Provisioning.Cdnregen (which initially exposed both NRE sites) with this PR's binaries overlaid. Generation now completes cleanly.Behavioral notes
ModelProviderdirectly vianew ModelProvider(...)instead ofTypeFactory.CreateModel(...)will no longer get the auto-keep behavior. This matchesEnumProvider'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 directnew ModelProvider(...)calls are in unit tests, which assert behavior that does not depend on keep-set membership.DiscriminatorValueExpressionis 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.