Fix config binder source gen for a sole read-only collection ctor param - #131358

Merged
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param
Jul 27, 2026
Merged

Fix config binder source gen for a sole read-only collection ctor param#131358
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param

Conversation

@TemRevil

Copy link
Copy Markdown
Contributor

Fixes#131320

The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.

Root cause: BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGen only registered a type for Initialize-method generation inside the HasBindableMembers(objectSpec) check. But constructor parameters are bound in Initialize independently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type, HasBindableMembers is false, so Initialize never got registered/emitted, while the emitter's EmitBindingLogic/EmitObjectInit still unconditionally calls InitializeXxx(...) for any ParameterizedConstructor type.

Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. BindCore registration stays gated on HasBindableMembers as before, since that part is unrelated.

Verified by running the incremental generator directly against the repro from the issue:

var config = new ConfigurationBuilder().Build();
Options options = config.Get<Options>();
public record Options(IReadOnlyList<string> Values);

Before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103: The name 'InitializeOptions' does not exist in the current context exactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).

Added SoleReadOnlyCollectionConstructorParameterIsBindable next to the existing ReadOnlyCollectionConstructorParameterIsBindable test, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).

The configuration binder source generator emitted a call to an
Initialize method that was never generated for a parameterized-
constructor type whose only member is a non-bindable copy-constructor
collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property).
The reflection binder already handled this shape correctly, so this
was a parity gap that broke the build with CS0103 instead.
The registration pass only registered a type for Initialize-method
generation inside the HasBindableMembers check, but constructor
parameters are bound in Initialize independently of whether the type
has any other bindable property. Register the Initialize method (and
walk the constructor-parameter properties needed to bind it) whenever
the type has a parameterized constructor, regardless of
HasBindableMembers.
Verified by running the incremental generator directly against the
repro from the issue (record with a single IReadOnlyList<string>
constructor parameter): before this change the generated source calls
InitializeOptions but never defines it, reproducing CS0103 exactly;
after this change the method is generated and binds the parameter
correctly. Confirmed for all four affected collection interfaces.
Fixesdotnet#131320
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jul 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.

… fix
Compiling proved the Initialize method gets emitted, but never asserted
the generated code binds the right values. Extend the existing theory to
populate real config and check the bound collection contents for all
four interface shapes (including IEnumerable, which still routes through
the same CopyConstructor/HasBindableMembers=false path as the others).
Add a sibling test for a complex (non-string) element type, the other
gap called out in review.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Added functional coverage for both gaps, pushed as 16c022b5. SoleReadOnlyCollectionConstructorParameterIsBindable now populates real config, invokes the compiled assembly, and asserts the bound collection contents for all four interface shapes. Added a sibling test, SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable, for the complex-element case: record Options(IReadOnlyList<Child> Values) with no other member. The existing ComplexReadOnlyListConstructorParameterIsBindable always pairs the collection with a bindable Name, so it never actually hit this code path.

On whether IEnumerable<string> exercises the fix: it does. I reverted to the parent commit and ran all four shapes through the generator directly. Pre-fix, IEnumerable<string> fails with the identical CS0103: The name 'InitializeOptions' does not exist as the other three, since it also resolves to CollectionInstantiationStrategy.CopyConstructor in the parser (same as IReadOnlyList/IReadOnlyCollection/IReadOnlySet), so IsCollectionAndCannotOverride excludes it from HasBindableMembers the same way. Post-fix, all four compile and bind correctly.

The nested case turned up something real, though not what either of us expected. Binding Outer.Nested (an Inner whose sole member is a read-only collection ctor param) silently produces null instead of the actual value, no exception. Traced it to CoreBindingHelpers.cs's EmitBindImplForMember, the early return around line 961: !HasBindableMembers(complexType) && ... && InstantiationStrategy == ParameterizedConstructor skips emitting the property assignment entirely, even though InitializeInner is generated correctly and CanInstantiate is true for that exact type. git diff confirms that block is untouched by this PR, present verbatim at the parent commit too, so it's a pre-existing gap in the nested/BindCore path rather than something this fix introduced.

Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer.

@tarekgh

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up.

The functional assertions and the complex-element sole-member test look good, and confirming that IEnumerable<string> hits the same pre-fix failure and now exercises the fixed path resolves my earlier concern. Including all four interface shapes as theory cases is exactly what I was after.

On the nested-usage gap: since the null-binding you found for Outer.Nested is a separate, pre-existing bug that this PR does not touch, let us not fold a fix into this one. Please open a dedicated issue with the minimal repro (the record Options(IReadOnlyList<Child> Values) nested under an outer type) and reference it here so we can track it independently. This PR can merge on its own once the nested case is captured in that issue.

Two small things before I sign off:

  1. Can you add a short code comment (or test comment) noting that nested binding of these read-only collection members is tracked separately, so the gap is discoverable from the test file?
  2. Please make sure the new theory cases are actually running in CI (check the test count in the log), since source-gen tests can silently skip if the baseline is not built.

Every generator test project shares the same assembly name ('test',
from RoslynTestUtils.CreateTestProject), and LoadAndInvokeMain loads
into AssemblyLoadContext.Default, which never unloads. When more than
one theory case in this test run reaches LoadAndInvokeMain, the second
load collides with the first under the identical assembly identity
(FileLoadException: a different copy of assembly 'test.dll' is already
loaded). Rename the compilation to a fresh unique name right before
emitting so concurrent/sequential loads in the same process don't
collide.
Also note in a test comment that nested binding of this same shape is
tracked separately in dotnet#131399, filed per review
discussion on this PR.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Pushed ab00bf5: each compilation now gets renamed to a unique name right before Emit in LoadAndInvokeMain, so the loads stop colliding.

Couldn't run the real suite locally (Arcade still isn't tractable in this environment), so I reproduced the exact failure standalone instead: two Roslyn compilations sharing the test name, loaded into AssemblyLoadContext.Default, throw the same FileLoadException you saw in CI. With a unique name per load, four in a row succeed cleanly.

Filed #131399 for the nested-binding gap with the repro and root cause, and added a note in GeneratorTests.cs pointing at it.

Will watch the CI run and confirm the theory cases actually execute this time, not just compile.

The test project multitargets NetCoreAppCurrent and NetFrameworkCurrent.
System.Runtime.Loader.AssemblyLoadContext does not exist on .NET Framework,
so the net481 leg failed to compile with CS0234, even though the calling
theories are already gated to NetCore at runtime via
PlatformDetection.IsNetCore. Wrapped the using directive and the method
body in #if NET, matching the same split already used a few lines up in
this file for baseline paths.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

One more thing turned up once CI ran on the last push: LoadAndInvokeMain uses AssemblyLoadContext, but this project also builds against net481, and System.Runtime.Loader doesn't exist there. The test methods are gated to NetCore only, but the compiler still has to make the helper compile on every target framework, so net481 failed with CS0234 regardless of the runtime gate.

Wrapped the using directive and the method body in #if NET / #else, same split the file already uses a bit further up for baseline paths. The #else branch just throws PlatformNotSupportedException since nothing reaches it.

Couldn't run this through Arcade either, so I checked it with a throwaway project instead: same #if NET structure on net10.0 and net481 builds clean, and reverting the guard reproduces the exact CS0234 on net481. Pushed as 438c01d.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks @TemRevil for providing the changes!

@tarekgh

Copy link
Copy Markdown
Member

@rosebyte@svick could you please have a quick look and merge if you don't have more feedback? Thanks!

@tarekghtarekgh added this to the 11.0.0 milestone Jul 27, 2026
@tarekghtarekgh added the source-generator Indicates an issue with a source generator feature label Jul 27, 2026
@svick
svick merged commit 2aadf33 into dotnet:mainJul 27, 2026
90 of 92 checks passed
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Thanks for shepherding this one through, @tarekgh. Your test-hardening notes made the regression test noticeably better than what I first pushed, and the pointer about the net481 target saved me a round trip.

I enjoyed digging around the configuration binder source generator, so if there's anything else in that area you'd like a hand with, I'm happy to pick one up.

@dotnet-milestone-botdotnet-milestone-botBot modified the milestones: 11.0.0, 11.0-rc1Jul 28, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 27, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Extensions-Configurationcommunity-contributionIndicates that the PR has been added by a community membersource-generatorIndicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration SG: type whose only member is a non-bindable constructor parameter emits CS0103

3 participants

@TemRevil@tarekgh@svick
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Fix config binder source gen for a sole read-only collection ctor param - #131358

Merged
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param
Jul 27, 2026
Merged

Fix config binder source gen for a sole read-only collection ctor param#131358
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param

Conversation

@TemRevil

Copy link
Copy Markdown
Contributor

Fixes#131320

The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.

Root cause: BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGen only registered a type for Initialize-method generation inside the HasBindableMembers(objectSpec) check. But constructor parameters are bound in Initialize independently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type, HasBindableMembers is false, so Initialize never got registered/emitted, while the emitter's EmitBindingLogic/EmitObjectInit still unconditionally calls InitializeXxx(...) for any ParameterizedConstructor type.

Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. BindCore registration stays gated on HasBindableMembers as before, since that part is unrelated.

Verified by running the incremental generator directly against the repro from the issue:

var config = new ConfigurationBuilder().Build();
Options options = config.Get<Options>();
public record Options(IReadOnlyList<string> Values);

Before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103: The name 'InitializeOptions' does not exist in the current context exactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).

Added SoleReadOnlyCollectionConstructorParameterIsBindable next to the existing ReadOnlyCollectionConstructorParameterIsBindable test, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).

The configuration binder source generator emitted a call to an
Initialize method that was never generated for a parameterized-
constructor type whose only member is a non-bindable copy-constructor
collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property).
The reflection binder already handled this shape correctly, so this
was a parity gap that broke the build with CS0103 instead.
The registration pass only registered a type for Initialize-method
generation inside the HasBindableMembers check, but constructor
parameters are bound in Initialize independently of whether the type
has any other bindable property. Register the Initialize method (and
walk the constructor-parameter properties needed to bind it) whenever
the type has a parameterized constructor, regardless of
HasBindableMembers.
Verified by running the incremental generator directly against the
repro from the issue (record with a single IReadOnlyList<string>
constructor parameter): before this change the generated source calls
InitializeOptions but never defines it, reproducing CS0103 exactly;
after this change the method is generated and binds the parameter
correctly. Confirmed for all four affected collection interfaces.
Fixesdotnet#131320
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jul 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.

… fix
Compiling proved the Initialize method gets emitted, but never asserted
the generated code binds the right values. Extend the existing theory to
populate real config and check the bound collection contents for all
four interface shapes (including IEnumerable, which still routes through
the same CopyConstructor/HasBindableMembers=false path as the others).
Add a sibling test for a complex (non-string) element type, the other
gap called out in review.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Added functional coverage for both gaps, pushed as 16c022b5. SoleReadOnlyCollectionConstructorParameterIsBindable now populates real config, invokes the compiled assembly, and asserts the bound collection contents for all four interface shapes. Added a sibling test, SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable, for the complex-element case: record Options(IReadOnlyList<Child> Values) with no other member. The existing ComplexReadOnlyListConstructorParameterIsBindable always pairs the collection with a bindable Name, so it never actually hit this code path.

On whether IEnumerable<string> exercises the fix: it does. I reverted to the parent commit and ran all four shapes through the generator directly. Pre-fix, IEnumerable<string> fails with the identical CS0103: The name 'InitializeOptions' does not exist as the other three, since it also resolves to CollectionInstantiationStrategy.CopyConstructor in the parser (same as IReadOnlyList/IReadOnlyCollection/IReadOnlySet), so IsCollectionAndCannotOverride excludes it from HasBindableMembers the same way. Post-fix, all four compile and bind correctly.

The nested case turned up something real, though not what either of us expected. Binding Outer.Nested (an Inner whose sole member is a read-only collection ctor param) silently produces null instead of the actual value, no exception. Traced it to CoreBindingHelpers.cs's EmitBindImplForMember, the early return around line 961: !HasBindableMembers(complexType) && ... && InstantiationStrategy == ParameterizedConstructor skips emitting the property assignment entirely, even though InitializeInner is generated correctly and CanInstantiate is true for that exact type. git diff confirms that block is untouched by this PR, present verbatim at the parent commit too, so it's a pre-existing gap in the nested/BindCore path rather than something this fix introduced.

Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer.

@tarekgh

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up.

The functional assertions and the complex-element sole-member test look good, and confirming that IEnumerable<string> hits the same pre-fix failure and now exercises the fixed path resolves my earlier concern. Including all four interface shapes as theory cases is exactly what I was after.

On the nested-usage gap: since the null-binding you found for Outer.Nested is a separate, pre-existing bug that this PR does not touch, let us not fold a fix into this one. Please open a dedicated issue with the minimal repro (the record Options(IReadOnlyList<Child> Values) nested under an outer type) and reference it here so we can track it independently. This PR can merge on its own once the nested case is captured in that issue.

Two small things before I sign off:

  1. Can you add a short code comment (or test comment) noting that nested binding of these read-only collection members is tracked separately, so the gap is discoverable from the test file?
  2. Please make sure the new theory cases are actually running in CI (check the test count in the log), since source-gen tests can silently skip if the baseline is not built.

Every generator test project shares the same assembly name ('test',
from RoslynTestUtils.CreateTestProject), and LoadAndInvokeMain loads
into AssemblyLoadContext.Default, which never unloads. When more than
one theory case in this test run reaches LoadAndInvokeMain, the second
load collides with the first under the identical assembly identity
(FileLoadException: a different copy of assembly 'test.dll' is already
loaded). Rename the compilation to a fresh unique name right before
emitting so concurrent/sequential loads in the same process don't
collide.
Also note in a test comment that nested binding of this same shape is
tracked separately in dotnet#131399, filed per review
discussion on this PR.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Pushed ab00bf5: each compilation now gets renamed to a unique name right before Emit in LoadAndInvokeMain, so the loads stop colliding.

Couldn't run the real suite locally (Arcade still isn't tractable in this environment), so I reproduced the exact failure standalone instead: two Roslyn compilations sharing the test name, loaded into AssemblyLoadContext.Default, throw the same FileLoadException you saw in CI. With a unique name per load, four in a row succeed cleanly.

Filed #131399 for the nested-binding gap with the repro and root cause, and added a note in GeneratorTests.cs pointing at it.

Will watch the CI run and confirm the theory cases actually execute this time, not just compile.

The test project multitargets NetCoreAppCurrent and NetFrameworkCurrent.
System.Runtime.Loader.AssemblyLoadContext does not exist on .NET Framework,
so the net481 leg failed to compile with CS0234, even though the calling
theories are already gated to NetCore at runtime via
PlatformDetection.IsNetCore. Wrapped the using directive and the method
body in #if NET, matching the same split already used a few lines up in
this file for baseline paths.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

One more thing turned up once CI ran on the last push: LoadAndInvokeMain uses AssemblyLoadContext, but this project also builds against net481, and System.Runtime.Loader doesn't exist there. The test methods are gated to NetCore only, but the compiler still has to make the helper compile on every target framework, so net481 failed with CS0234 regardless of the runtime gate.

Wrapped the using directive and the method body in #if NET / #else, same split the file already uses a bit further up for baseline paths. The #else branch just throws PlatformNotSupportedException since nothing reaches it.

Couldn't run this through Arcade either, so I checked it with a throwaway project instead: same #if NET structure on net10.0 and net481 builds clean, and reverting the guard reproduces the exact CS0234 on net481. Pushed as 438c01d.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks @TemRevil for providing the changes!

@tarekgh

Copy link
Copy Markdown
Member

@rosebyte@svick could you please have a quick look and merge if you don't have more feedback? Thanks!

@tarekghtarekgh added this to the 11.0.0 milestone Jul 27, 2026
@tarekghtarekgh added the source-generator Indicates an issue with a source generator feature label Jul 27, 2026
@svick
svick merged commit 2aadf33 into dotnet:mainJul 27, 2026
90 of 92 checks passed
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Thanks for shepherding this one through, @tarekgh. Your test-hardening notes made the regression test noticeably better than what I first pushed, and the pointer about the net481 target saved me a round trip.

I enjoyed digging around the configuration binder source generator, so if there's anything else in that area you'd like a hand with, I'm happy to pick one up.

@dotnet-milestone-botdotnet-milestone-botBot modified the milestones: 11.0.0, 11.0-rc1Jul 28, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 27, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Extensions-Configurationcommunity-contributionIndicates that the PR has been added by a community membersource-generatorIndicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration SG: type whose only member is a non-bindable constructor parameter emits CS0103

3 participants

@TemRevil@tarekgh@svick
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix config binder source gen for a sole read-only collection ctor param - #131358

Merged
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param
Jul 27, 2026
Merged

Fix config binder source gen for a sole read-only collection ctor param#131358
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param

Conversation

@TemRevil

Copy link
Copy Markdown
Contributor

Fixes#131320

The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.

Root cause: BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGen only registered a type for Initialize-method generation inside the HasBindableMembers(objectSpec) check. But constructor parameters are bound in Initialize independently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type, HasBindableMembers is false, so Initialize never got registered/emitted, while the emitter's EmitBindingLogic/EmitObjectInit still unconditionally calls InitializeXxx(...) for any ParameterizedConstructor type.

Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. BindCore registration stays gated on HasBindableMembers as before, since that part is unrelated.

Verified by running the incremental generator directly against the repro from the issue:

var config = new ConfigurationBuilder().Build();
Options options = config.Get<Options>();
public record Options(IReadOnlyList<string> Values);

Before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103: The name 'InitializeOptions' does not exist in the current context exactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).

Added SoleReadOnlyCollectionConstructorParameterIsBindable next to the existing ReadOnlyCollectionConstructorParameterIsBindable test, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).

The configuration binder source generator emitted a call to an
Initialize method that was never generated for a parameterized-
constructor type whose only member is a non-bindable copy-constructor
collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property).
The reflection binder already handled this shape correctly, so this
was a parity gap that broke the build with CS0103 instead.
The registration pass only registered a type for Initialize-method
generation inside the HasBindableMembers check, but constructor
parameters are bound in Initialize independently of whether the type
has any other bindable property. Register the Initialize method (and
walk the constructor-parameter properties needed to bind it) whenever
the type has a parameterized constructor, regardless of
HasBindableMembers.
Verified by running the incremental generator directly against the
repro from the issue (record with a single IReadOnlyList<string>
constructor parameter): before this change the generated source calls
InitializeOptions but never defines it, reproducing CS0103 exactly;
after this change the method is generated and binds the parameter
correctly. Confirmed for all four affected collection interfaces.
Fixesdotnet#131320
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jul 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.

… fix
Compiling proved the Initialize method gets emitted, but never asserted
the generated code binds the right values. Extend the existing theory to
populate real config and check the bound collection contents for all
four interface shapes (including IEnumerable, which still routes through
the same CopyConstructor/HasBindableMembers=false path as the others).
Add a sibling test for a complex (non-string) element type, the other
gap called out in review.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Added functional coverage for both gaps, pushed as 16c022b5. SoleReadOnlyCollectionConstructorParameterIsBindable now populates real config, invokes the compiled assembly, and asserts the bound collection contents for all four interface shapes. Added a sibling test, SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable, for the complex-element case: record Options(IReadOnlyList<Child> Values) with no other member. The existing ComplexReadOnlyListConstructorParameterIsBindable always pairs the collection with a bindable Name, so it never actually hit this code path.

On whether IEnumerable<string> exercises the fix: it does. I reverted to the parent commit and ran all four shapes through the generator directly. Pre-fix, IEnumerable<string> fails with the identical CS0103: The name 'InitializeOptions' does not exist as the other three, since it also resolves to CollectionInstantiationStrategy.CopyConstructor in the parser (same as IReadOnlyList/IReadOnlyCollection/IReadOnlySet), so IsCollectionAndCannotOverride excludes it from HasBindableMembers the same way. Post-fix, all four compile and bind correctly.

The nested case turned up something real, though not what either of us expected. Binding Outer.Nested (an Inner whose sole member is a read-only collection ctor param) silently produces null instead of the actual value, no exception. Traced it to CoreBindingHelpers.cs's EmitBindImplForMember, the early return around line 961: !HasBindableMembers(complexType) && ... && InstantiationStrategy == ParameterizedConstructor skips emitting the property assignment entirely, even though InitializeInner is generated correctly and CanInstantiate is true for that exact type. git diff confirms that block is untouched by this PR, present verbatim at the parent commit too, so it's a pre-existing gap in the nested/BindCore path rather than something this fix introduced.

Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer.

@tarekgh

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up.

The functional assertions and the complex-element sole-member test look good, and confirming that IEnumerable<string> hits the same pre-fix failure and now exercises the fixed path resolves my earlier concern. Including all four interface shapes as theory cases is exactly what I was after.

On the nested-usage gap: since the null-binding you found for Outer.Nested is a separate, pre-existing bug that this PR does not touch, let us not fold a fix into this one. Please open a dedicated issue with the minimal repro (the record Options(IReadOnlyList<Child> Values) nested under an outer type) and reference it here so we can track it independently. This PR can merge on its own once the nested case is captured in that issue.

Two small things before I sign off:

  1. Can you add a short code comment (or test comment) noting that nested binding of these read-only collection members is tracked separately, so the gap is discoverable from the test file?
  2. Please make sure the new theory cases are actually running in CI (check the test count in the log), since source-gen tests can silently skip if the baseline is not built.

Every generator test project shares the same assembly name ('test',
from RoslynTestUtils.CreateTestProject), and LoadAndInvokeMain loads
into AssemblyLoadContext.Default, which never unloads. When more than
one theory case in this test run reaches LoadAndInvokeMain, the second
load collides with the first under the identical assembly identity
(FileLoadException: a different copy of assembly 'test.dll' is already
loaded). Rename the compilation to a fresh unique name right before
emitting so concurrent/sequential loads in the same process don't
collide.
Also note in a test comment that nested binding of this same shape is
tracked separately in dotnet#131399, filed per review
discussion on this PR.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Pushed ab00bf5: each compilation now gets renamed to a unique name right before Emit in LoadAndInvokeMain, so the loads stop colliding.

Couldn't run the real suite locally (Arcade still isn't tractable in this environment), so I reproduced the exact failure standalone instead: two Roslyn compilations sharing the test name, loaded into AssemblyLoadContext.Default, throw the same FileLoadException you saw in CI. With a unique name per load, four in a row succeed cleanly.

Filed #131399 for the nested-binding gap with the repro and root cause, and added a note in GeneratorTests.cs pointing at it.

Will watch the CI run and confirm the theory cases actually execute this time, not just compile.

The test project multitargets NetCoreAppCurrent and NetFrameworkCurrent.
System.Runtime.Loader.AssemblyLoadContext does not exist on .NET Framework,
so the net481 leg failed to compile with CS0234, even though the calling
theories are already gated to NetCore at runtime via
PlatformDetection.IsNetCore. Wrapped the using directive and the method
body in #if NET, matching the same split already used a few lines up in
this file for baseline paths.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

One more thing turned up once CI ran on the last push: LoadAndInvokeMain uses AssemblyLoadContext, but this project also builds against net481, and System.Runtime.Loader doesn't exist there. The test methods are gated to NetCore only, but the compiler still has to make the helper compile on every target framework, so net481 failed with CS0234 regardless of the runtime gate.

Wrapped the using directive and the method body in #if NET / #else, same split the file already uses a bit further up for baseline paths. The #else branch just throws PlatformNotSupportedException since nothing reaches it.

Couldn't run this through Arcade either, so I checked it with a throwaway project instead: same #if NET structure on net10.0 and net481 builds clean, and reverting the guard reproduces the exact CS0234 on net481. Pushed as 438c01d.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks @TemRevil for providing the changes!

@tarekgh

Copy link
Copy Markdown
Member

@rosebyte@svick could you please have a quick look and merge if you don't have more feedback? Thanks!

@tarekghtarekgh added this to the 11.0.0 milestone Jul 27, 2026
@tarekghtarekgh added the source-generator Indicates an issue with a source generator feature label Jul 27, 2026
@svick
svick merged commit 2aadf33 into dotnet:mainJul 27, 2026
90 of 92 checks passed
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Thanks for shepherding this one through, @tarekgh. Your test-hardening notes made the regression test noticeably better than what I first pushed, and the pointer about the net481 target saved me a round trip.

I enjoyed digging around the configuration binder source generator, so if there's anything else in that area you'd like a hand with, I'm happy to pick one up.

@dotnet-milestone-botdotnet-milestone-botBot modified the milestones: 11.0.0, 11.0-rc1Jul 28, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 27, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Extensions-Configurationcommunity-contributionIndicates that the PR has been added by a community membersource-generatorIndicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration SG: type whose only member is a non-bindable constructor parameter emits CS0103

3 participants

@TemRevil@tarekgh@svick
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix config binder source gen for a sole read-only collection ctor param - #131358

Merged
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param
Jul 27, 2026
Merged

Fix config binder source gen for a sole read-only collection ctor param#131358
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param

Conversation

@TemRevil

Copy link
Copy Markdown
Contributor

Fixes#131320

The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.

Root cause: BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGen only registered a type for Initialize-method generation inside the HasBindableMembers(objectSpec) check. But constructor parameters are bound in Initialize independently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type, HasBindableMembers is false, so Initialize never got registered/emitted, while the emitter's EmitBindingLogic/EmitObjectInit still unconditionally calls InitializeXxx(...) for any ParameterizedConstructor type.

Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. BindCore registration stays gated on HasBindableMembers as before, since that part is unrelated.

Verified by running the incremental generator directly against the repro from the issue:

var config = new ConfigurationBuilder().Build();
Options options = config.Get<Options>();
public record Options(IReadOnlyList<string> Values);

Before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103: The name 'InitializeOptions' does not exist in the current context exactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).

Added SoleReadOnlyCollectionConstructorParameterIsBindable next to the existing ReadOnlyCollectionConstructorParameterIsBindable test, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).

The configuration binder source generator emitted a call to an
Initialize method that was never generated for a parameterized-
constructor type whose only member is a non-bindable copy-constructor
collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property).
The reflection binder already handled this shape correctly, so this
was a parity gap that broke the build with CS0103 instead.
The registration pass only registered a type for Initialize-method
generation inside the HasBindableMembers check, but constructor
parameters are bound in Initialize independently of whether the type
has any other bindable property. Register the Initialize method (and
walk the constructor-parameter properties needed to bind it) whenever
the type has a parameterized constructor, regardless of
HasBindableMembers.
Verified by running the incremental generator directly against the
repro from the issue (record with a single IReadOnlyList<string>
constructor parameter): before this change the generated source calls
InitializeOptions but never defines it, reproducing CS0103 exactly;
after this change the method is generated and binds the parameter
correctly. Confirmed for all four affected collection interfaces.
Fixesdotnet#131320
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jul 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.

… fix
Compiling proved the Initialize method gets emitted, but never asserted
the generated code binds the right values. Extend the existing theory to
populate real config and check the bound collection contents for all
four interface shapes (including IEnumerable, which still routes through
the same CopyConstructor/HasBindableMembers=false path as the others).
Add a sibling test for a complex (non-string) element type, the other
gap called out in review.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Added functional coverage for both gaps, pushed as 16c022b5. SoleReadOnlyCollectionConstructorParameterIsBindable now populates real config, invokes the compiled assembly, and asserts the bound collection contents for all four interface shapes. Added a sibling test, SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable, for the complex-element case: record Options(IReadOnlyList<Child> Values) with no other member. The existing ComplexReadOnlyListConstructorParameterIsBindable always pairs the collection with a bindable Name, so it never actually hit this code path.

On whether IEnumerable<string> exercises the fix: it does. I reverted to the parent commit and ran all four shapes through the generator directly. Pre-fix, IEnumerable<string> fails with the identical CS0103: The name 'InitializeOptions' does not exist as the other three, since it also resolves to CollectionInstantiationStrategy.CopyConstructor in the parser (same as IReadOnlyList/IReadOnlyCollection/IReadOnlySet), so IsCollectionAndCannotOverride excludes it from HasBindableMembers the same way. Post-fix, all four compile and bind correctly.

The nested case turned up something real, though not what either of us expected. Binding Outer.Nested (an Inner whose sole member is a read-only collection ctor param) silently produces null instead of the actual value, no exception. Traced it to CoreBindingHelpers.cs's EmitBindImplForMember, the early return around line 961: !HasBindableMembers(complexType) && ... && InstantiationStrategy == ParameterizedConstructor skips emitting the property assignment entirely, even though InitializeInner is generated correctly and CanInstantiate is true for that exact type. git diff confirms that block is untouched by this PR, present verbatim at the parent commit too, so it's a pre-existing gap in the nested/BindCore path rather than something this fix introduced.

Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer.

@tarekgh

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up.

The functional assertions and the complex-element sole-member test look good, and confirming that IEnumerable<string> hits the same pre-fix failure and now exercises the fixed path resolves my earlier concern. Including all four interface shapes as theory cases is exactly what I was after.

On the nested-usage gap: since the null-binding you found for Outer.Nested is a separate, pre-existing bug that this PR does not touch, let us not fold a fix into this one. Please open a dedicated issue with the minimal repro (the record Options(IReadOnlyList<Child> Values) nested under an outer type) and reference it here so we can track it independently. This PR can merge on its own once the nested case is captured in that issue.

Two small things before I sign off:

  1. Can you add a short code comment (or test comment) noting that nested binding of these read-only collection members is tracked separately, so the gap is discoverable from the test file?
  2. Please make sure the new theory cases are actually running in CI (check the test count in the log), since source-gen tests can silently skip if the baseline is not built.

Every generator test project shares the same assembly name ('test',
from RoslynTestUtils.CreateTestProject), and LoadAndInvokeMain loads
into AssemblyLoadContext.Default, which never unloads. When more than
one theory case in this test run reaches LoadAndInvokeMain, the second
load collides with the first under the identical assembly identity
(FileLoadException: a different copy of assembly 'test.dll' is already
loaded). Rename the compilation to a fresh unique name right before
emitting so concurrent/sequential loads in the same process don't
collide.
Also note in a test comment that nested binding of this same shape is
tracked separately in dotnet#131399, filed per review
discussion on this PR.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Pushed ab00bf5: each compilation now gets renamed to a unique name right before Emit in LoadAndInvokeMain, so the loads stop colliding.

Couldn't run the real suite locally (Arcade still isn't tractable in this environment), so I reproduced the exact failure standalone instead: two Roslyn compilations sharing the test name, loaded into AssemblyLoadContext.Default, throw the same FileLoadException you saw in CI. With a unique name per load, four in a row succeed cleanly.

Filed #131399 for the nested-binding gap with the repro and root cause, and added a note in GeneratorTests.cs pointing at it.

Will watch the CI run and confirm the theory cases actually execute this time, not just compile.

The test project multitargets NetCoreAppCurrent and NetFrameworkCurrent.
System.Runtime.Loader.AssemblyLoadContext does not exist on .NET Framework,
so the net481 leg failed to compile with CS0234, even though the calling
theories are already gated to NetCore at runtime via
PlatformDetection.IsNetCore. Wrapped the using directive and the method
body in #if NET, matching the same split already used a few lines up in
this file for baseline paths.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

One more thing turned up once CI ran on the last push: LoadAndInvokeMain uses AssemblyLoadContext, but this project also builds against net481, and System.Runtime.Loader doesn't exist there. The test methods are gated to NetCore only, but the compiler still has to make the helper compile on every target framework, so net481 failed with CS0234 regardless of the runtime gate.

Wrapped the using directive and the method body in #if NET / #else, same split the file already uses a bit further up for baseline paths. The #else branch just throws PlatformNotSupportedException since nothing reaches it.

Couldn't run this through Arcade either, so I checked it with a throwaway project instead: same #if NET structure on net10.0 and net481 builds clean, and reverting the guard reproduces the exact CS0234 on net481. Pushed as 438c01d.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks @TemRevil for providing the changes!

@tarekgh

Copy link
Copy Markdown
Member

@rosebyte@svick could you please have a quick look and merge if you don't have more feedback? Thanks!

@tarekghtarekgh added this to the 11.0.0 milestone Jul 27, 2026
@tarekghtarekgh added the source-generator Indicates an issue with a source generator feature label Jul 27, 2026
@svick
svick merged commit 2aadf33 into dotnet:mainJul 27, 2026
90 of 92 checks passed
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Thanks for shepherding this one through, @tarekgh. Your test-hardening notes made the regression test noticeably better than what I first pushed, and the pointer about the net481 target saved me a round trip.

I enjoyed digging around the configuration binder source generator, so if there's anything else in that area you'd like a hand with, I'm happy to pick one up.

@dotnet-milestone-botdotnet-milestone-botBot modified the milestones: 11.0.0, 11.0-rc1Jul 28, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 27, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Extensions-Configurationcommunity-contributionIndicates that the PR has been added by a community membersource-generatorIndicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration SG: type whose only member is a non-bindable constructor parameter emits CS0103

3 participants

@TemRevil@tarekgh@svick
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Fix config binder source gen for a sole read-only collection ctor param - #131358

Merged
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param
Jul 27, 2026
Merged

Fix config binder source gen for a sole read-only collection ctor param#131358
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param

Conversation

@TemRevil

Copy link
Copy Markdown
Contributor

Fixes#131320

The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.

Root cause: BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGen only registered a type for Initialize-method generation inside the HasBindableMembers(objectSpec) check. But constructor parameters are bound in Initialize independently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type, HasBindableMembers is false, so Initialize never got registered/emitted, while the emitter's EmitBindingLogic/EmitObjectInit still unconditionally calls InitializeXxx(...) for any ParameterizedConstructor type.

Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. BindCore registration stays gated on HasBindableMembers as before, since that part is unrelated.

Verified by running the incremental generator directly against the repro from the issue:

var config = new ConfigurationBuilder().Build();
Options options = config.Get<Options>();
public record Options(IReadOnlyList<string> Values);

Before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103: The name 'InitializeOptions' does not exist in the current context exactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).

Added SoleReadOnlyCollectionConstructorParameterIsBindable next to the existing ReadOnlyCollectionConstructorParameterIsBindable test, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).

The configuration binder source generator emitted a call to an
Initialize method that was never generated for a parameterized-
constructor type whose only member is a non-bindable copy-constructor
collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property).
The reflection binder already handled this shape correctly, so this
was a parity gap that broke the build with CS0103 instead.
The registration pass only registered a type for Initialize-method
generation inside the HasBindableMembers check, but constructor
parameters are bound in Initialize independently of whether the type
has any other bindable property. Register the Initialize method (and
walk the constructor-parameter properties needed to bind it) whenever
the type has a parameterized constructor, regardless of
HasBindableMembers.
Verified by running the incremental generator directly against the
repro from the issue (record with a single IReadOnlyList<string>
constructor parameter): before this change the generated source calls
InitializeOptions but never defines it, reproducing CS0103 exactly;
after this change the method is generated and binds the parameter
correctly. Confirmed for all four affected collection interfaces.
Fixesdotnet#131320
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jul 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.

… fix
Compiling proved the Initialize method gets emitted, but never asserted
the generated code binds the right values. Extend the existing theory to
populate real config and check the bound collection contents for all
four interface shapes (including IEnumerable, which still routes through
the same CopyConstructor/HasBindableMembers=false path as the others).
Add a sibling test for a complex (non-string) element type, the other
gap called out in review.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Added functional coverage for both gaps, pushed as 16c022b5. SoleReadOnlyCollectionConstructorParameterIsBindable now populates real config, invokes the compiled assembly, and asserts the bound collection contents for all four interface shapes. Added a sibling test, SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable, for the complex-element case: record Options(IReadOnlyList<Child> Values) with no other member. The existing ComplexReadOnlyListConstructorParameterIsBindable always pairs the collection with a bindable Name, so it never actually hit this code path.

On whether IEnumerable<string> exercises the fix: it does. I reverted to the parent commit and ran all four shapes through the generator directly. Pre-fix, IEnumerable<string> fails with the identical CS0103: The name 'InitializeOptions' does not exist as the other three, since it also resolves to CollectionInstantiationStrategy.CopyConstructor in the parser (same as IReadOnlyList/IReadOnlyCollection/IReadOnlySet), so IsCollectionAndCannotOverride excludes it from HasBindableMembers the same way. Post-fix, all four compile and bind correctly.

The nested case turned up something real, though not what either of us expected. Binding Outer.Nested (an Inner whose sole member is a read-only collection ctor param) silently produces null instead of the actual value, no exception. Traced it to CoreBindingHelpers.cs's EmitBindImplForMember, the early return around line 961: !HasBindableMembers(complexType) && ... && InstantiationStrategy == ParameterizedConstructor skips emitting the property assignment entirely, even though InitializeInner is generated correctly and CanInstantiate is true for that exact type. git diff confirms that block is untouched by this PR, present verbatim at the parent commit too, so it's a pre-existing gap in the nested/BindCore path rather than something this fix introduced.

Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer.

@tarekgh

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up.

The functional assertions and the complex-element sole-member test look good, and confirming that IEnumerable<string> hits the same pre-fix failure and now exercises the fixed path resolves my earlier concern. Including all four interface shapes as theory cases is exactly what I was after.

On the nested-usage gap: since the null-binding you found for Outer.Nested is a separate, pre-existing bug that this PR does not touch, let us not fold a fix into this one. Please open a dedicated issue with the minimal repro (the record Options(IReadOnlyList<Child> Values) nested under an outer type) and reference it here so we can track it independently. This PR can merge on its own once the nested case is captured in that issue.

Two small things before I sign off:

  1. Can you add a short code comment (or test comment) noting that nested binding of these read-only collection members is tracked separately, so the gap is discoverable from the test file?
  2. Please make sure the new theory cases are actually running in CI (check the test count in the log), since source-gen tests can silently skip if the baseline is not built.

Every generator test project shares the same assembly name ('test',
from RoslynTestUtils.CreateTestProject), and LoadAndInvokeMain loads
into AssemblyLoadContext.Default, which never unloads. When more than
one theory case in this test run reaches LoadAndInvokeMain, the second
load collides with the first under the identical assembly identity
(FileLoadException: a different copy of assembly 'test.dll' is already
loaded). Rename the compilation to a fresh unique name right before
emitting so concurrent/sequential loads in the same process don't
collide.
Also note in a test comment that nested binding of this same shape is
tracked separately in dotnet#131399, filed per review
discussion on this PR.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Pushed ab00bf5: each compilation now gets renamed to a unique name right before Emit in LoadAndInvokeMain, so the loads stop colliding.

Couldn't run the real suite locally (Arcade still isn't tractable in this environment), so I reproduced the exact failure standalone instead: two Roslyn compilations sharing the test name, loaded into AssemblyLoadContext.Default, throw the same FileLoadException you saw in CI. With a unique name per load, four in a row succeed cleanly.

Filed #131399 for the nested-binding gap with the repro and root cause, and added a note in GeneratorTests.cs pointing at it.

Will watch the CI run and confirm the theory cases actually execute this time, not just compile.

The test project multitargets NetCoreAppCurrent and NetFrameworkCurrent.
System.Runtime.Loader.AssemblyLoadContext does not exist on .NET Framework,
so the net481 leg failed to compile with CS0234, even though the calling
theories are already gated to NetCore at runtime via
PlatformDetection.IsNetCore. Wrapped the using directive and the method
body in #if NET, matching the same split already used a few lines up in
this file for baseline paths.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

One more thing turned up once CI ran on the last push: LoadAndInvokeMain uses AssemblyLoadContext, but this project also builds against net481, and System.Runtime.Loader doesn't exist there. The test methods are gated to NetCore only, but the compiler still has to make the helper compile on every target framework, so net481 failed with CS0234 regardless of the runtime gate.

Wrapped the using directive and the method body in #if NET / #else, same split the file already uses a bit further up for baseline paths. The #else branch just throws PlatformNotSupportedException since nothing reaches it.

Couldn't run this through Arcade either, so I checked it with a throwaway project instead: same #if NET structure on net10.0 and net481 builds clean, and reverting the guard reproduces the exact CS0234 on net481. Pushed as 438c01d.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks @TemRevil for providing the changes!

@tarekgh

Copy link
Copy Markdown
Member

@rosebyte@svick could you please have a quick look and merge if you don't have more feedback? Thanks!

@tarekghtarekgh added this to the 11.0.0 milestone Jul 27, 2026
@tarekghtarekgh added the source-generator Indicates an issue with a source generator feature label Jul 27, 2026
@svick
svick merged commit 2aadf33 into dotnet:mainJul 27, 2026
90 of 92 checks passed
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Thanks for shepherding this one through, @tarekgh. Your test-hardening notes made the regression test noticeably better than what I first pushed, and the pointer about the net481 target saved me a round trip.

I enjoyed digging around the configuration binder source generator, so if there's anything else in that area you'd like a hand with, I'm happy to pick one up.

@dotnet-milestone-botdotnet-milestone-botBot modified the milestones: 11.0.0, 11.0-rc1Jul 28, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 27, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Extensions-Configurationcommunity-contributionIndicates that the PR has been added by a community membersource-generatorIndicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration SG: type whose only member is a non-bindable constructor parameter emits CS0103

3 participants

@TemRevil@tarekgh@svick
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix config binder source gen for a sole read-only collection ctor param - #131358

Merged
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param
Jul 27, 2026
Merged

Fix config binder source gen for a sole read-only collection ctor param#131358
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param

Conversation

@TemRevil

Copy link
Copy Markdown
Contributor

Fixes#131320

The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.

Root cause: BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGen only registered a type for Initialize-method generation inside the HasBindableMembers(objectSpec) check. But constructor parameters are bound in Initialize independently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type, HasBindableMembers is false, so Initialize never got registered/emitted, while the emitter's EmitBindingLogic/EmitObjectInit still unconditionally calls InitializeXxx(...) for any ParameterizedConstructor type.

Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. BindCore registration stays gated on HasBindableMembers as before, since that part is unrelated.

Verified by running the incremental generator directly against the repro from the issue:

var config = new ConfigurationBuilder().Build();
Options options = config.Get<Options>();
public record Options(IReadOnlyList<string> Values);

Before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103: The name 'InitializeOptions' does not exist in the current context exactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).

Added SoleReadOnlyCollectionConstructorParameterIsBindable next to the existing ReadOnlyCollectionConstructorParameterIsBindable test, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).

The configuration binder source generator emitted a call to an
Initialize method that was never generated for a parameterized-
constructor type whose only member is a non-bindable copy-constructor
collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property).
The reflection binder already handled this shape correctly, so this
was a parity gap that broke the build with CS0103 instead.
The registration pass only registered a type for Initialize-method
generation inside the HasBindableMembers check, but constructor
parameters are bound in Initialize independently of whether the type
has any other bindable property. Register the Initialize method (and
walk the constructor-parameter properties needed to bind it) whenever
the type has a parameterized constructor, regardless of
HasBindableMembers.
Verified by running the incremental generator directly against the
repro from the issue (record with a single IReadOnlyList<string>
constructor parameter): before this change the generated source calls
InitializeOptions but never defines it, reproducing CS0103 exactly;
after this change the method is generated and binds the parameter
correctly. Confirmed for all four affected collection interfaces.
Fixesdotnet#131320
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jul 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.

… fix
Compiling proved the Initialize method gets emitted, but never asserted
the generated code binds the right values. Extend the existing theory to
populate real config and check the bound collection contents for all
four interface shapes (including IEnumerable, which still routes through
the same CopyConstructor/HasBindableMembers=false path as the others).
Add a sibling test for a complex (non-string) element type, the other
gap called out in review.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Added functional coverage for both gaps, pushed as 16c022b5. SoleReadOnlyCollectionConstructorParameterIsBindable now populates real config, invokes the compiled assembly, and asserts the bound collection contents for all four interface shapes. Added a sibling test, SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable, for the complex-element case: record Options(IReadOnlyList<Child> Values) with no other member. The existing ComplexReadOnlyListConstructorParameterIsBindable always pairs the collection with a bindable Name, so it never actually hit this code path.

On whether IEnumerable<string> exercises the fix: it does. I reverted to the parent commit and ran all four shapes through the generator directly. Pre-fix, IEnumerable<string> fails with the identical CS0103: The name 'InitializeOptions' does not exist as the other three, since it also resolves to CollectionInstantiationStrategy.CopyConstructor in the parser (same as IReadOnlyList/IReadOnlyCollection/IReadOnlySet), so IsCollectionAndCannotOverride excludes it from HasBindableMembers the same way. Post-fix, all four compile and bind correctly.

The nested case turned up something real, though not what either of us expected. Binding Outer.Nested (an Inner whose sole member is a read-only collection ctor param) silently produces null instead of the actual value, no exception. Traced it to CoreBindingHelpers.cs's EmitBindImplForMember, the early return around line 961: !HasBindableMembers(complexType) && ... && InstantiationStrategy == ParameterizedConstructor skips emitting the property assignment entirely, even though InitializeInner is generated correctly and CanInstantiate is true for that exact type. git diff confirms that block is untouched by this PR, present verbatim at the parent commit too, so it's a pre-existing gap in the nested/BindCore path rather than something this fix introduced.

Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer.

@tarekgh

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up.

The functional assertions and the complex-element sole-member test look good, and confirming that IEnumerable<string> hits the same pre-fix failure and now exercises the fixed path resolves my earlier concern. Including all four interface shapes as theory cases is exactly what I was after.

On the nested-usage gap: since the null-binding you found for Outer.Nested is a separate, pre-existing bug that this PR does not touch, let us not fold a fix into this one. Please open a dedicated issue with the minimal repro (the record Options(IReadOnlyList<Child> Values) nested under an outer type) and reference it here so we can track it independently. This PR can merge on its own once the nested case is captured in that issue.

Two small things before I sign off:

  1. Can you add a short code comment (or test comment) noting that nested binding of these read-only collection members is tracked separately, so the gap is discoverable from the test file?
  2. Please make sure the new theory cases are actually running in CI (check the test count in the log), since source-gen tests can silently skip if the baseline is not built.

Every generator test project shares the same assembly name ('test',
from RoslynTestUtils.CreateTestProject), and LoadAndInvokeMain loads
into AssemblyLoadContext.Default, which never unloads. When more than
one theory case in this test run reaches LoadAndInvokeMain, the second
load collides with the first under the identical assembly identity
(FileLoadException: a different copy of assembly 'test.dll' is already
loaded). Rename the compilation to a fresh unique name right before
emitting so concurrent/sequential loads in the same process don't
collide.
Also note in a test comment that nested binding of this same shape is
tracked separately in dotnet#131399, filed per review
discussion on this PR.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Pushed ab00bf5: each compilation now gets renamed to a unique name right before Emit in LoadAndInvokeMain, so the loads stop colliding.

Couldn't run the real suite locally (Arcade still isn't tractable in this environment), so I reproduced the exact failure standalone instead: two Roslyn compilations sharing the test name, loaded into AssemblyLoadContext.Default, throw the same FileLoadException you saw in CI. With a unique name per load, four in a row succeed cleanly.

Filed #131399 for the nested-binding gap with the repro and root cause, and added a note in GeneratorTests.cs pointing at it.

Will watch the CI run and confirm the theory cases actually execute this time, not just compile.

The test project multitargets NetCoreAppCurrent and NetFrameworkCurrent.
System.Runtime.Loader.AssemblyLoadContext does not exist on .NET Framework,
so the net481 leg failed to compile with CS0234, even though the calling
theories are already gated to NetCore at runtime via
PlatformDetection.IsNetCore. Wrapped the using directive and the method
body in #if NET, matching the same split already used a few lines up in
this file for baseline paths.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

One more thing turned up once CI ran on the last push: LoadAndInvokeMain uses AssemblyLoadContext, but this project also builds against net481, and System.Runtime.Loader doesn't exist there. The test methods are gated to NetCore only, but the compiler still has to make the helper compile on every target framework, so net481 failed with CS0234 regardless of the runtime gate.

Wrapped the using directive and the method body in #if NET / #else, same split the file already uses a bit further up for baseline paths. The #else branch just throws PlatformNotSupportedException since nothing reaches it.

Couldn't run this through Arcade either, so I checked it with a throwaway project instead: same #if NET structure on net10.0 and net481 builds clean, and reverting the guard reproduces the exact CS0234 on net481. Pushed as 438c01d.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks @TemRevil for providing the changes!

@tarekgh

Copy link
Copy Markdown
Member

@rosebyte@svick could you please have a quick look and merge if you don't have more feedback? Thanks!

@tarekghtarekgh added this to the 11.0.0 milestone Jul 27, 2026
@tarekghtarekgh added the source-generator Indicates an issue with a source generator feature label Jul 27, 2026
@svick
svick merged commit 2aadf33 into dotnet:mainJul 27, 2026
90 of 92 checks passed
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Thanks for shepherding this one through, @tarekgh. Your test-hardening notes made the regression test noticeably better than what I first pushed, and the pointer about the net481 target saved me a round trip.

I enjoyed digging around the configuration binder source generator, so if there's anything else in that area you'd like a hand with, I'm happy to pick one up.

@dotnet-milestone-botdotnet-milestone-botBot modified the milestones: 11.0.0, 11.0-rc1Jul 28, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 27, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Extensions-Configurationcommunity-contributionIndicates that the PR has been added by a community membersource-generatorIndicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration SG: type whose only member is a non-bindable constructor parameter emits CS0103

3 participants

@TemRevil@tarekgh@svick
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix config binder source gen for a sole read-only collection ctor param - #131358

Merged
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param
Jul 27, 2026
Merged

Fix config binder source gen for a sole read-only collection ctor param#131358
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param

Conversation

@TemRevil

Copy link
Copy Markdown
Contributor

Fixes#131320

The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.

Root cause: BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGen only registered a type for Initialize-method generation inside the HasBindableMembers(objectSpec) check. But constructor parameters are bound in Initialize independently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type, HasBindableMembers is false, so Initialize never got registered/emitted, while the emitter's EmitBindingLogic/EmitObjectInit still unconditionally calls InitializeXxx(...) for any ParameterizedConstructor type.

Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. BindCore registration stays gated on HasBindableMembers as before, since that part is unrelated.

Verified by running the incremental generator directly against the repro from the issue:

var config = new ConfigurationBuilder().Build();
Options options = config.Get<Options>();
public record Options(IReadOnlyList<string> Values);

Before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103: The name 'InitializeOptions' does not exist in the current context exactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).

Added SoleReadOnlyCollectionConstructorParameterIsBindable next to the existing ReadOnlyCollectionConstructorParameterIsBindable test, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).

The configuration binder source generator emitted a call to an
Initialize method that was never generated for a parameterized-
constructor type whose only member is a non-bindable copy-constructor
collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property).
The reflection binder already handled this shape correctly, so this
was a parity gap that broke the build with CS0103 instead.
The registration pass only registered a type for Initialize-method
generation inside the HasBindableMembers check, but constructor
parameters are bound in Initialize independently of whether the type
has any other bindable property. Register the Initialize method (and
walk the constructor-parameter properties needed to bind it) whenever
the type has a parameterized constructor, regardless of
HasBindableMembers.
Verified by running the incremental generator directly against the
repro from the issue (record with a single IReadOnlyList<string>
constructor parameter): before this change the generated source calls
InitializeOptions but never defines it, reproducing CS0103 exactly;
after this change the method is generated and binds the parameter
correctly. Confirmed for all four affected collection interfaces.
Fixesdotnet#131320
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jul 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.

… fix
Compiling proved the Initialize method gets emitted, but never asserted
the generated code binds the right values. Extend the existing theory to
populate real config and check the bound collection contents for all
four interface shapes (including IEnumerable, which still routes through
the same CopyConstructor/HasBindableMembers=false path as the others).
Add a sibling test for a complex (non-string) element type, the other
gap called out in review.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Added functional coverage for both gaps, pushed as 16c022b5. SoleReadOnlyCollectionConstructorParameterIsBindable now populates real config, invokes the compiled assembly, and asserts the bound collection contents for all four interface shapes. Added a sibling test, SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable, for the complex-element case: record Options(IReadOnlyList<Child> Values) with no other member. The existing ComplexReadOnlyListConstructorParameterIsBindable always pairs the collection with a bindable Name, so it never actually hit this code path.

On whether IEnumerable<string> exercises the fix: it does. I reverted to the parent commit and ran all four shapes through the generator directly. Pre-fix, IEnumerable<string> fails with the identical CS0103: The name 'InitializeOptions' does not exist as the other three, since it also resolves to CollectionInstantiationStrategy.CopyConstructor in the parser (same as IReadOnlyList/IReadOnlyCollection/IReadOnlySet), so IsCollectionAndCannotOverride excludes it from HasBindableMembers the same way. Post-fix, all four compile and bind correctly.

The nested case turned up something real, though not what either of us expected. Binding Outer.Nested (an Inner whose sole member is a read-only collection ctor param) silently produces null instead of the actual value, no exception. Traced it to CoreBindingHelpers.cs's EmitBindImplForMember, the early return around line 961: !HasBindableMembers(complexType) && ... && InstantiationStrategy == ParameterizedConstructor skips emitting the property assignment entirely, even though InitializeInner is generated correctly and CanInstantiate is true for that exact type. git diff confirms that block is untouched by this PR, present verbatim at the parent commit too, so it's a pre-existing gap in the nested/BindCore path rather than something this fix introduced.

Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer.

@tarekgh

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up.

The functional assertions and the complex-element sole-member test look good, and confirming that IEnumerable<string> hits the same pre-fix failure and now exercises the fixed path resolves my earlier concern. Including all four interface shapes as theory cases is exactly what I was after.

On the nested-usage gap: since the null-binding you found for Outer.Nested is a separate, pre-existing bug that this PR does not touch, let us not fold a fix into this one. Please open a dedicated issue with the minimal repro (the record Options(IReadOnlyList<Child> Values) nested under an outer type) and reference it here so we can track it independently. This PR can merge on its own once the nested case is captured in that issue.

Two small things before I sign off:

  1. Can you add a short code comment (or test comment) noting that nested binding of these read-only collection members is tracked separately, so the gap is discoverable from the test file?
  2. Please make sure the new theory cases are actually running in CI (check the test count in the log), since source-gen tests can silently skip if the baseline is not built.

Every generator test project shares the same assembly name ('test',
from RoslynTestUtils.CreateTestProject), and LoadAndInvokeMain loads
into AssemblyLoadContext.Default, which never unloads. When more than
one theory case in this test run reaches LoadAndInvokeMain, the second
load collides with the first under the identical assembly identity
(FileLoadException: a different copy of assembly 'test.dll' is already
loaded). Rename the compilation to a fresh unique name right before
emitting so concurrent/sequential loads in the same process don't
collide.
Also note in a test comment that nested binding of this same shape is
tracked separately in dotnet#131399, filed per review
discussion on this PR.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Pushed ab00bf5: each compilation now gets renamed to a unique name right before Emit in LoadAndInvokeMain, so the loads stop colliding.

Couldn't run the real suite locally (Arcade still isn't tractable in this environment), so I reproduced the exact failure standalone instead: two Roslyn compilations sharing the test name, loaded into AssemblyLoadContext.Default, throw the same FileLoadException you saw in CI. With a unique name per load, four in a row succeed cleanly.

Filed #131399 for the nested-binding gap with the repro and root cause, and added a note in GeneratorTests.cs pointing at it.

Will watch the CI run and confirm the theory cases actually execute this time, not just compile.

The test project multitargets NetCoreAppCurrent and NetFrameworkCurrent.
System.Runtime.Loader.AssemblyLoadContext does not exist on .NET Framework,
so the net481 leg failed to compile with CS0234, even though the calling
theories are already gated to NetCore at runtime via
PlatformDetection.IsNetCore. Wrapped the using directive and the method
body in #if NET, matching the same split already used a few lines up in
this file for baseline paths.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

One more thing turned up once CI ran on the last push: LoadAndInvokeMain uses AssemblyLoadContext, but this project also builds against net481, and System.Runtime.Loader doesn't exist there. The test methods are gated to NetCore only, but the compiler still has to make the helper compile on every target framework, so net481 failed with CS0234 regardless of the runtime gate.

Wrapped the using directive and the method body in #if NET / #else, same split the file already uses a bit further up for baseline paths. The #else branch just throws PlatformNotSupportedException since nothing reaches it.

Couldn't run this through Arcade either, so I checked it with a throwaway project instead: same #if NET structure on net10.0 and net481 builds clean, and reverting the guard reproduces the exact CS0234 on net481. Pushed as 438c01d.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks @TemRevil for providing the changes!

@tarekgh

Copy link
Copy Markdown
Member

@rosebyte@svick could you please have a quick look and merge if you don't have more feedback? Thanks!

@tarekghtarekgh added this to the 11.0.0 milestone Jul 27, 2026
@tarekghtarekgh added the source-generator Indicates an issue with a source generator feature label Jul 27, 2026
@svick
svick merged commit 2aadf33 into dotnet:mainJul 27, 2026
90 of 92 checks passed
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Thanks for shepherding this one through, @tarekgh. Your test-hardening notes made the regression test noticeably better than what I first pushed, and the pointer about the net481 target saved me a round trip.

I enjoyed digging around the configuration binder source generator, so if there's anything else in that area you'd like a hand with, I'm happy to pick one up.

@dotnet-milestone-botdotnet-milestone-botBot modified the milestones: 11.0.0, 11.0-rc1Jul 28, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 27, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Extensions-Configurationcommunity-contributionIndicates that the PR has been added by a community membersource-generatorIndicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration SG: type whose only member is a non-bindable constructor parameter emits CS0103

3 participants

@TemRevil@tarekgh@svick
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Fix config binder source gen for a sole read-only collection ctor param - #131358

Merged
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param
Jul 27, 2026
Merged

Fix config binder source gen for a sole read-only collection ctor param#131358
svick merged 4 commits into
dotnet:mainfrom
TemRevil:fix/config-binder-solo-readonly-collection-ctor-param

Conversation

@TemRevil

Copy link
Copy Markdown
Contributor

Fixes#131320

The configuration binder source generator emitted an uncompilable call to an Initialize method that was never generated, for a parameterized-constructor type whose only member is a non-bindable copy-constructor collection parameter (a positional record with a single IReadOnlyList, IReadOnlyCollection, IReadOnlySet, or IEnumerable parameter and no other bindable property). The reflection binder handles this shape correctly, so this was a parity regression that broke the build instead.

Root cause: BindingHelperInfo.Builder.TryRegisterTransitiveTypesForMethodGen only registered a type for Initialize-method generation inside the HasBindableMembers(objectSpec) check. But constructor parameters are bound in Initialize independently of whether the type has any other bindable property, per the comment already on the ctor-param property loop a few lines above. For a type where the only "property" is a ctor param backed by a non-bindable read-only collection type, HasBindableMembers is false, so Initialize never got registered/emitted, while the emitter's EmitBindingLogic/EmitObjectInit still unconditionally calls InitializeXxx(...) for any ParameterizedConstructor type.

Fix: register the Initialize method (and walk the constructor-parameter properties needed to bind it) whenever the type has a parameterized constructor, regardless of HasBindableMembers. BindCore registration stays gated on HasBindableMembers as before, since that part is unrelated.

Verified by running the incremental generator directly against the repro from the issue:

var config = new ConfigurationBuilder().Build();
Options options = config.Get<Options>();
public record Options(IReadOnlyList<string> Values);

Before this change the generated source calls InitializeOptions but never defines it, reproducing CS0103: The name 'InitializeOptions' does not exist in the current context exactly. After this change the method is generated and binds the parameter correctly. Confirmed for all four affected collection interfaces (IReadOnlyList, IReadOnlyCollection, IReadOnlySet, IEnumerable).

Added SoleReadOnlyCollectionConstructorParameterIsBindable next to the existing ReadOnlyCollectionConstructorParameterIsBindable test, covering the case where the collection parameter is the type's only member (the existing test always paired it with a second, ordinarily-bindable property, so it didn't exercise this gap).

The configuration binder source generator emitted a call to an
Initialize method that was never generated for a parameterized-
constructor type whose only member is a non-bindable copy-constructor
collection parameter (IReadOnlyList<T>, IReadOnlyCollection<T>,
IReadOnlySet<T>, or IEnumerable<T>, with no other bindable property).
The reflection binder already handled this shape correctly, so this
was a parity gap that broke the build with CS0103 instead.
The registration pass only registered a type for Initialize-method
generation inside the HasBindableMembers check, but constructor
parameters are bound in Initialize independently of whether the type
has any other bindable property. Register the Initialize method (and
walk the constructor-parameter properties needed to bind it) whenever
the type has a parameterized constructor, regardless of
HasBindableMembers.
Verified by running the incremental generator directly against the
repro from the issue (record with a single IReadOnlyList<string>
constructor parameter): before this change the generated source calls
InitializeOptions but never defines it, reproducing CS0103 exactly;
after this change the method is generated and binds the parameter
correctly. Confirmed for all four affected collection interfaces.
Fixesdotnet#131320
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Jul 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-configuration
See info in area-owners.md if you want to be subscribed.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few test-hardening notes on the new regression test. The product change itself looks correct and well targeted; these are non-blocking suggestions. One additional stylistic thought: the sibling ReadOnlyCollectionConstructorParameterIsBindable, ComplexReadOnlyListConstructorParameterIsBindable, and this new Sole... test all cover variations of the same shape, so they could eventually be consolidated into a single data-driven theory.

… fix
Compiling proved the Initialize method gets emitted, but never asserted
the generated code binds the right values. Extend the existing theory to
populate real config and check the bound collection contents for all
four interface shapes (including IEnumerable, which still routes through
the same CopyConstructor/HasBindableMembers=false path as the others).
Add a sibling test for a complex (non-string) element type, the other
gap called out in review.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Added functional coverage for both gaps, pushed as 16c022b5. SoleReadOnlyCollectionConstructorParameterIsBindable now populates real config, invokes the compiled assembly, and asserts the bound collection contents for all four interface shapes. Added a sibling test, SoleReadOnlyCollectionConstructorParameterOfComplexElementIsBindable, for the complex-element case: record Options(IReadOnlyList<Child> Values) with no other member. The existing ComplexReadOnlyListConstructorParameterIsBindable always pairs the collection with a bindable Name, so it never actually hit this code path.

On whether IEnumerable<string> exercises the fix: it does. I reverted to the parent commit and ran all four shapes through the generator directly. Pre-fix, IEnumerable<string> fails with the identical CS0103: The name 'InitializeOptions' does not exist as the other three, since it also resolves to CollectionInstantiationStrategy.CopyConstructor in the parser (same as IReadOnlyList/IReadOnlyCollection/IReadOnlySet), so IsCollectionAndCannotOverride excludes it from HasBindableMembers the same way. Post-fix, all four compile and bind correctly.

The nested case turned up something real, though not what either of us expected. Binding Outer.Nested (an Inner whose sole member is a read-only collection ctor param) silently produces null instead of the actual value, no exception. Traced it to CoreBindingHelpers.cs's EmitBindImplForMember, the early return around line 961: !HasBindableMembers(complexType) && ... && InstantiationStrategy == ParameterizedConstructor skips emitting the property assignment entirely, even though InitializeInner is generated correctly and CanInstantiate is true for that exact type. git diff confirms that block is untouched by this PR, present verbatim at the parent commit too, so it's a pre-existing gap in the nested/BindCore path rather than something this fix introduced.

Since it's a different root cause in a different file, I didn't fold a fix into this PR without checking first. Happy to open a follow-up issue with the repro, take a shot at fixing it separately, or fold it into this PR if you'd rather keep it together, whichever you prefer.

@tarekgh

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up.

The functional assertions and the complex-element sole-member test look good, and confirming that IEnumerable<string> hits the same pre-fix failure and now exercises the fixed path resolves my earlier concern. Including all four interface shapes as theory cases is exactly what I was after.

On the nested-usage gap: since the null-binding you found for Outer.Nested is a separate, pre-existing bug that this PR does not touch, let us not fold a fix into this one. Please open a dedicated issue with the minimal repro (the record Options(IReadOnlyList<Child> Values) nested under an outer type) and reference it here so we can track it independently. This PR can merge on its own once the nested case is captured in that issue.

Two small things before I sign off:

  1. Can you add a short code comment (or test comment) noting that nested binding of these read-only collection members is tracked separately, so the gap is discoverable from the test file?
  2. Please make sure the new theory cases are actually running in CI (check the test count in the log), since source-gen tests can silently skip if the baseline is not built.

Every generator test project shares the same assembly name ('test',
from RoslynTestUtils.CreateTestProject), and LoadAndInvokeMain loads
into AssemblyLoadContext.Default, which never unloads. When more than
one theory case in this test run reaches LoadAndInvokeMain, the second
load collides with the first under the identical assembly identity
(FileLoadException: a different copy of assembly 'test.dll' is already
loaded). Rename the compilation to a fresh unique name right before
emitting so concurrent/sequential loads in the same process don't
collide.
Also note in a test comment that nested binding of this same shape is
tracked separately in dotnet#131399, filed per review
discussion on this PR.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Pushed ab00bf5: each compilation now gets renamed to a unique name right before Emit in LoadAndInvokeMain, so the loads stop colliding.

Couldn't run the real suite locally (Arcade still isn't tractable in this environment), so I reproduced the exact failure standalone instead: two Roslyn compilations sharing the test name, loaded into AssemblyLoadContext.Default, throw the same FileLoadException you saw in CI. With a unique name per load, four in a row succeed cleanly.

Filed #131399 for the nested-binding gap with the repro and root cause, and added a note in GeneratorTests.cs pointing at it.

Will watch the CI run and confirm the theory cases actually execute this time, not just compile.

The test project multitargets NetCoreAppCurrent and NetFrameworkCurrent.
System.Runtime.Loader.AssemblyLoadContext does not exist on .NET Framework,
so the net481 leg failed to compile with CS0234, even though the calling
theories are already gated to NetCore at runtime via
PlatformDetection.IsNetCore. Wrapped the using directive and the method
body in #if NET, matching the same split already used a few lines up in
this file for baseline paths.
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

One more thing turned up once CI ran on the last push: LoadAndInvokeMain uses AssemblyLoadContext, but this project also builds against net481, and System.Runtime.Loader doesn't exist there. The test methods are gated to NetCore only, but the compiler still has to make the helper compile on every target framework, so net481 failed with CS0234 regardless of the runtime gate.

Wrapped the using directive and the method body in #if NET / #else, same split the file already uses a bit further up for baseline paths. The #else branch just throws PlatformNotSupportedException since nothing reaches it.

Couldn't run this through Arcade either, so I checked it with a throwaway project instead: same #if NET structure on net10.0 and net481 builds clean, and reverting the guard reproduces the exact CS0234 on net481. Pushed as 438c01d.

@tarekghtarekgh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks @TemRevil for providing the changes!

@tarekgh

Copy link
Copy Markdown
Member

@rosebyte@svick could you please have a quick look and merge if you don't have more feedback? Thanks!

@tarekghtarekgh added this to the 11.0.0 milestone Jul 27, 2026
@tarekghtarekgh added the source-generator Indicates an issue with a source generator feature label Jul 27, 2026
@svick
svick merged commit 2aadf33 into dotnet:mainJul 27, 2026
90 of 92 checks passed
@TemRevil

Copy link
Copy Markdown
ContributorAuthor

Thanks for shepherding this one through, @tarekgh. Your test-hardening notes made the regression test noticeably better than what I first pushed, and the pointer about the net481 target saved me a round trip.

I enjoyed digging around the configuration binder source generator, so if there's anything else in that area you'd like a hand with, I'm happy to pick one up.

@dotnet-milestone-botdotnet-milestone-botBot modified the milestones: 11.0.0, 11.0-rc1Jul 28, 2026
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 27, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-Extensions-Configurationcommunity-contributionIndicates that the PR has been added by a community membersource-generatorIndicates an issue with a source generator feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configuration SG: type whose only member is a non-bindable constructor parameter emits CS0103

3 participants

@TemRevil@tarekgh@svick