Skip to content

Support deserializing read-only collections in XmlSerializer via [CollectionBuilder] - #132356

Draft
StephenMolloy wants to merge 3 commits into
mainfrom
stephenmolloy-xmlserializer-collection-builder
Draft

Support deserializing read-only collections in XmlSerializer via [CollectionBuilder]#132356
StephenMolloy wants to merge 3 commits into
mainfrom
stephenmolloy-xmlserializer-collection-builder

Conversation

@StephenMolloy

@StephenMolloy StephenMolloy commented Aug 15, 2026

Copy link
Copy Markdown
Member

Fixes: #66264

Supersedes #130455, which took a narrower approach. This branch is a fresh implementation.

Problem

XmlSerializer populates a collection by constructing it and then calling Add() (or this[int]) once per element. Read-only and immutable collections cannot be populated that way, so today they fall into one of three buckets:

Bucket Types Symptom
Throw at import ImmutableHashSet<T>, FrozenSet<T>, ReadOnlySet<T>, ReadOnlyCollection<T>, ImmutableStack<T>, ImmutableQueue<T> InvalidOperationException, "...does not implement Add(System.Object)" / "...does not have a default accessor"
Silently lose data ImmutableList<T>, ImmutableSortedSet<T> Add returns a new instance which is discarded, so the member always deserializes empty
Throw at runtime ImmutableArray<T> NotSupportedException from IList.Add, or an NRE for a nil root

Approach

This adds a secondary construction path driven by System.Runtime.CompilerServices.CollectionBuilderAttribute. When a collection type carries the attribute and cannot be constructed or populated the normal way, the readers accumulate elements into an ephemeral T[], exactly like the existing array path, and at end-of-collection hydrate the real instance by calling the builder factory method.

This is deliberately not an "Immutable-namespace" special case. It is a generic fallback for any type that opts in via the attribute, including user-defined ones.

Gating

The builder path is used only when both hold:

  1. The type has [CollectionBuilder] and a usable factory method resolves; and
  2. The type fails today's requirements, meaning TypeDesc.CannotNew, or no usable this[int] indexer, or no matching Add.

Types that work today are untouched: with no attribute present, behavior is byte-identical to before.

Attribute discovery uses GetCustomAttributesData() rather than GetCustomAttribute<T>(), so no attribute is instantiated and no extra types or assemblies are loaded during import.

Factory resolution and invocation

[CollectionBuilder] requires a Create(ReadOnlySpan<T>) overload and permits a T[] overload, so CollectionBuilderInfo requires the span overload and treats the array overload as optional. The ILGen and sgen backends always emit the span call directly.

Because MethodInfo.Invoke cannot pass a ReadOnlySpan<T>, the reflection reader binds the closed MethodInfo to a private delegate type via MethodInfo.CreateDelegate (no expression trees). When !RuntimeFeature.IsDynamicCodeSupported it cannot close that generic delegate, so it falls back to the T[] overload with a plain Invoke. Note that ReadOnlyCollection.CreateCollection<T>, ReadOnlyCollection.CreateSet<T> and FrozenSet.Create<T> are span-only, so there is no fallback for those three.

Types that begin working

ImmutableArray<T>, ImmutableList<T>, ImmutableHashSet<T>, ImmutableSortedSet<T>, ImmutableStack<T>, ImmutableQueue<T>, FrozenSet<T>, ReadOnlyCollection<T>, ReadOnlySet<T>.

Behavioral changes

These are the observable differences for code that compiles today:

  • ImmutableList<T> and ImmutableSortedSet<T> members previously deserialized silently empty and now round-trip with their content. Anyone relying on the empty result was relying on data loss, but it is a behavior change nonetheless.
  • ImmutableArray<T> previously threw NotSupportedException during deserialization and now succeeds.

Everything else here turns a thrown exception into working serialization, which is additive.

Known limitations

  • ImmutableStack<T> and ImmutableQueue<T> reverse element order on round-trip. Their builders match C# collection-expression semantics (ImmutableStack.Create(a, b, c) enumerates as c, b, a). This is documented in the tests rather than worked around, so the behavior stays consistent with the language. Reversing on hydration is a reasonable follow-up.
  • Dictionaries remain unsupported. XmlSerializer rejects IDictionary up front and that guard is unchanged, so ImmutableDictionary<,> and FrozenDictionary<,> still throw. ImmutableSortedDictionary<,> has no [CollectionBuilder] at all.
  • Interfaces such as IImmutableList<T> are still rejected earlier as interfaces.
  • Get-only collection properties do not round-trip. A collection created from its complete contents cannot be added to, and a member with no setter has nowhere to store the new instance, so the elements read are dropped and the getter's value survives. This is the same thing that happens today for a get-only ImmutableList<T>, and it is why the fix skips the assignment rather than throwing: such a property imports, serializes, and deserializes (as empty) today, so throwing at deserialize would regress it, and throwing at import would break serialize-only scenarios since import is shared by both directions. All three readers agree on this, including for a get-only ImmutableArray<T> whose getter returns an uninitialized struct.
  • SOAP / encoded mode is out of scope. Its CollectionFixup / WriteAddCollectionFixup model defers population until after the object graph is read, which is fundamentally incompatible with hydrate-once construction. Builder-backed types are explicitly guarded there so they throw a clear error rather than producing garbage.

Notes for reviewers

  • UsesCollectionBuilder is not a TypeFlags bit. It is derived from whether the resolved CollectionBuilderInfo is non-null. GetNullableTypeDesc and CreateArrayTypeDesc copy _flags but not the builder object, so a flag bit would leak the capability onto derived TypeDescs that have no builder.
  • CannotNew is not redefined. A builder has no bearing on whether a type can be new-ed; every downstream site that cares checks UsesCollectionBuilder explicitly. CheckNeedConstructor() is itself builder-aware rather than each call site being patched.
  • Nil and absent semantics are preserved exactly. The legacy quirk is that a non-array collection always comes back empty, never null, whether the XML says xsi:nil="true" or omits the element entirely, while arrays come back null. That "absent means empty" behavior comes from WriteMemberBegin pre-creating the collection, not from WriteMemberEnd, so builder-backed members do both: create empty up front and hydrate at the end. ShrinkArray is called with isNullable: false so a nil collection yields an empty array and therefore an empty hydrated collection. Xml_CollectionAsMember_NilOrAbsentBecomesEmpty pins this against a mutable control type.
  • There are two independent element-type resolution paths, TypeScope.ImportTypeDesc for classification and TypeScope.GetArrayElementType -> GetCollectionElementType used by ArrayModel.Element and XmlReflectionImporter.ImportAccessorMapping. Both had to be taught the same rule or they disagree and the second one re-throws.
  • [UnconditionalSuppressMessage] rather than [RequiresDynamicCode] on the MakeGenericMethod choke point. Propagating RequiresDynamicCode would cascade across every caller of TypeScope.GetTypeDesc, and every XmlSerializer public entry point is already annotated [RequiresDynamicCode(XmlSerializer.AotSerializationWarning)].
  • Expected.SerializableAssembly.XmlSerializers.cs is machine-generated and was regenerated from sgen output. Adding a type to SerializationTypes.cs renumbers every Write##_/Read##_ method, which is why that part of the diff is large. The %%ParentAssemblyId%% placeholder is preserved.
  • No new resource strings; this reuses SR.XmlReadOnlyCollection.

Testing

New tests live in XmlSerializerTests.cs so they run in all three flavors. They cover root-level and member-level usage, empty and nil collections, ImmutableArray<ConsoleColor?> (the original AV repro from the issue), and sequence-equality comparisons since ReadOnlyCollection<T> does not override Equals. Member-level coverage includes a field as well as properties, since the readers assign fields through a different path. Unsupported shapes are covered by negative tests asserting they still throw.

Suite Result
System.Private.Xml.Tests (ILGen) 49,058 passed, 0 failed, 0 skipped
System.Xml.XmlSerializer.ReflectionOnly.Tests 366 passed, 0 failed
Microsoft.XmlSerializer.Generator.Tests (sgen) 139 passed, 0 failed
System.Runtime.Serialization.Xml.Tests (shares SerializationTypes.cs) 361 passed, 0 failed

Three divergences between the backends were found by tests rather than by inspection, and are worth knowing about when reviewing this area:

  1. Xml_GetOnlyReadOnlyCollectionAsMember_KeepsGetterValue failed without the fix in two distinct ways. The ILGen reader hit Debug.Assert(setMethod != null) in CodeGenerator.StoreMember and took the test host down, because Member._isArray is now IsArray || UsesCollectionBuilder and get-only collection properties are admitted at import (Models.cs:238) where get-only array properties are not. The reflection reader failed separately with a NullReferenceException: for the [XmlArray]-wrapped shape it never takes the member.Collection path and instead installed a reflective Add delegate that called ImmutableArray<T>.Add on an uninitialized struct. The regenerated sgen output was inspected directly to confirm the read method for that type contains zero member assignments.

  2. Adding a field to TypeWithReadOnlyCollections exposed the reflection reader's EnsureCollection finalizer, the thing that guarantees a collection member is never left uninitialized, testing GetMemberValue(...) == null. A struct collection such as ImmutableArray<T> boxes to a non-null default, so an absent member kept its default value where the other two readers produced an empty collection. EnsureCollection now also treats a boxed default as uninitialized.

  3. The reflection reader's flat [XmlElement] shape reaches AddObjectsIntoTargetCollection and its IList.Add, which throws NotSupportedException for a builder-backed type. The guard that skips it was verified by adding a repeated-element get-only member and confirming the test fails when the guard is removed.

The !RuntimeFeature.IsDynamicCodeSupported array fallback in CollectionBuilderInfo.CreateBuilder is not covered by an executed test. It was verified by inspection only, as the libraries test suites all run with dynamic code available.

Unrelated pre-existing behavior worth knowing: the reflection reader cannot deserialize into a readonly field of any collection type, failing with ArgumentException: Expression must be writeable from the Expression.Assign it builds. This reproduces identically with a plain readonly List<int> field, so it is not caused by this change and is not addressed here. Filed separately as #132357.

Note

This pull request description was drafted with GitHub Copilot.

StephenMolloy and others added 3 commits August 14, 2026 13:34
XmlSerializer could not deserialize collections that cannot be created empty
and then added to one element at a time. Types like ImmutableArray<T>,
ImmutableList<T>, ReadOnlyCollection<T> and FrozenSet<T> either threw at
import or silently deserialized as empty.

Add a secondary "accumulate then hydrate" path for these. When a collection
cannot be constructed and populated the usual way, but carries a
[CollectionBuilder] attribute, elements are accumulated into an ephemeral
array and the real instance is created once at the end of the collection by
calling the builder factory method. Collections without the attribute keep
their existing behavior exactly.

The path is implemented in all three readers: the RefEmit reader, the
reflection reader, and the pregenerated source generator. Detection uses
GetCustomAttributesData so no extra types or assemblies are loaded, and the
span-based builder overload is preferred, with the T[] overload as a fallback
where dynamic code is unavailable.

Nil and absent members keep the long-standing behavior of producing an empty
collection rather than null, which required pre-creating the collection in
WriteMemberBegin as well as hydrating in WriteMemberEnd.

Get-only collection members are left alone rather than assigned, since a
collection built from its complete contents has nowhere to be stored without
a setter. SOAP encoding is out of scope and guarded so builder-backed types
report a clear error, and IDictionary remains unsupported.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f550422-5e7b-45fe-9467-28d84726d702
Member level coverage only used properties, so add a field to
TypeWithReadOnlyCollections. Fields are assigned through a different path in
all three readers.

That exposed a divergence for struct collections. The reflection reader
guarantees a collection member is never left uninitialized by checking
whether it is null at the end of the read, but ImmutableArray<T> boxes to a
non-null default, so an absent member kept its default value where the IL
generating and pregenerated readers produced an empty collection. Treat a
boxed default as uninitialized as well.

The existing member test missed this because it exercised a nil element
rather than an absent one, and every other member was a reference type.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f550422-5e7b-45fe-9467-28d84726d702
CollectionBuilderAttribute's contract requires a Create(ReadOnlySpan<T>)
overload, so CollectionBuilderInfo now requires one and drops the Factory
and FactoryTakesSpan members that let callers ask which shape they got.
The generating readers always call the span overload; the array overload
is kept only as the reflection-based reader's fallback for when dynamic
code is unavailable and it cannot close a generic type over the element
type. The two invoker classes collapse into a single Func<Array, object>,
and the doc comments are trimmed to match an internal helper.

Fold the encoded-serialization read-only and collection builder throws in
WriteAddCollectionFixup together, since they raise the same exception.

Combine the member-level nil and absent tests into one theory covering
both cases, and cover a get-only collection declared as repeated elements
rather than a wrapped array. That shape reaches a different path in the
reflection-based reader, which throws NotSupportedException without the
guard that skips it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f550422-5e7b-45fe-9467-28d84726d702
@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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR extends System.Xml.Serialization.XmlSerializer to support deserializing read-only / immutable collections that opt into construction via System.Runtime.CompilerServices.CollectionBuilderAttribute, by accumulating elements into an array and hydrating the final collection via the builder factory when in-place population (new + Add/indexer) isn’t possible.

Changes:

  • Add CollectionBuilderInfo resolution and plumb TypeDesc.UsesCollectionBuilder through type classification to enable an alternate “accumulate then build” read path.
  • Update the reflection reader and both codegen backends (ILGen + sgen) to deserialize builder-backed collections using the existing array accumulation machinery, then build the final instance.
  • Add tests and new serialization test types covering root/member scenarios, nil/absent semantics, get-only members, and negative cases (e.g., dictionaries).

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/libraries/System.Runtime.Serialization.Xml/tests/SerializationTypes.cs Adds new test types with immutable/read-only collection members to exercise builder-backed deserialization scenarios.
src/libraries/System.Private.Xml/tests/XmlSerializer/XmlSerializerTests.cs Adds/updates test coverage for root and member deserialization of builder-backed collections, nil/absent semantics, and unsupported shapes.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/XmlSerializationReaderILGen.cs Treats builder-backed collections as array-like during read, and emits hydration via the resolved builder at end-of-collection.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/XmlSerializationReader.cs Updates the sgen code generator to emit builder-backed collection hydration and to guard unsupported encoded/fixup paths.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/Types.cs Introduces TypeDesc.CollectionBuilder/UsesCollectionBuilder and updates import/element-type resolution to use the builder fallback when needed.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/ReflectionXmlSerializationReader.cs Implements reflection-path accumulation + builder hydration; updates “ensure collection” semantics for struct-backed collections (e.g., ImmutableArray<T>).
src/libraries/System.Private.Xml/src/System/Xml/Serialization/CollectionBuilderInfo.cs New helper for discovering and invoking [CollectionBuilder] factories (span-first, optional array fallback).
src/libraries/System.Private.Xml/src/System.Private.Xml.csproj Includes the new CollectionBuilderInfo.cs in the build.

Comment on lines +131 to +139
// Only keep the array overload when it agrees with the span overload, so the two paths cannot disagree
// about what the collection contains.
if (arrayFactory != null && arrayFactory.GetParameters()[0].ParameterType.GetElementType() != elementType)
{
arrayFactory = null;
}

return new CollectionBuilderInfo(elementType, spanFactory, spanConversion, arrayFactory);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Add xml serialization support for immutable arrays

2 participants