You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
XmlSerializer's reflection-based reader (ReflectionXmlSerializationReader) throws when a serializable type declares a collection member as a readonlyfield. The IL-emitting reader handles the same type correctly, so this is a parity gap between the two backends rather than an intended restriction.
This is not specific to immutable or read-only collections. It reproduces with a plain readonly List<int> field, and it fails whether the corresponding element is present in the XML or absent entirely, which makes such a type wholly undeserializable on that backend.
Deserializing <Holder><Items><int>1</int></Items></Holder> with the reflection-based reader throws:
System.ArgumentException: Expression must be writeable (Parameter 'left')
The IL-emitting reader returns Items populated with [1], and returns it as an empty list when the element is absent.
Measured matrix (same document, same type shape, both backends):
Field
XML
ILGen
Reflection
readonly List<int> F = new()
present
[1]
throws
readonly List<int> F = new()
absent
[]
throws
readonly List<int> F (no initializer)
present
null
throws
readonly ImmutableList<string> F
present
null
throws
Root cause
ReflectionXmlSerializationReader.GetSetMemberValueDelegate builds a setter delegate for every member. When the declaring type is a reference type and dynamic code is available, it takes the expression-tree branch, which eagerly constructs an assignment for the member:
Expression.Assign rejects an initonly field outright, so the delegate fails to build at all. Note that the failure happens while constructing the setter, before anything decides whether the member actually needs to be assigned, which is why an absent element fails too.
The IL-emitting reader never hits this because it does not assign read-only collection members at all. It populates them in place through the getter, which is sufficient: Models.cs only admits a read-only member when its kind is Collection or Enumerable, so any read-only member that reaches a mapping is by construction a collection that can be populated in place.
Suggested fix
The same method already has a FieldInfo.SetValue branch, used when the declaring type is a value type or dynamic code is unavailable. FieldInfo.SetValue succeeds on an initonly instance field (verified on .NET 10), so routing read-only fields to that branch looks like a small fix.
Whether that is the right behavior is worth a moment's thought, since it would make the reflection reader assign where the IL reader only populates in place. Matching the IL reader more literally, by populating through the getter and skipping the assignment, may be the better parity target.
Notes
This does not reproduce under NativeAOT. Mode returns ReflectionOnly when !RuntimeFeature.IsDynamicCodeSupported, but that same condition sends GetSetMemberValueDelegate down the SetValue branch, which works.
It likewise does not reproduce when the declaring type is a struct, for the same reason.
Description
XmlSerializer's reflection-based reader (ReflectionXmlSerializationReader) throws when a serializable type declares a collection member as areadonlyfield. The IL-emitting reader handles the same type correctly, so this is a parity gap between the two backends rather than an intended restriction.This is not specific to immutable or read-only collections. It reproduces with a plain
readonly List<int>field, and it fails whether the corresponding element is present in the XML or absent entirely, which makes such a type wholly undeserializable on that backend.Repro
Deserializing
<Holder><Items><int>1</int></Items></Holder>with the reflection-based reader throws:The IL-emitting reader returns
Itemspopulated with[1], and returns it as an empty list when the element is absent.Measured matrix (same document, same type shape, both backends):
readonly List<int> F = new()[1]readonly List<int> F = new()[]readonly List<int> F(no initializer)nullreadonly ImmutableList<string> FnullRoot cause
ReflectionXmlSerializationReader.GetSetMemberValueDelegatebuilds a setter delegate for every member. When the declaring type is a reference type and dynamic code is available, it takes the expression-tree branch, which eagerly constructs an assignment for the member:https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.Xml/src/System/Xml/Serialization/ReflectionXmlSerializationReader.cs#L656-L672
Expression.Assignrejects aninitonlyfield outright, so the delegate fails to build at all. Note that the failure happens while constructing the setter, before anything decides whether the member actually needs to be assigned, which is why an absent element fails too.The IL-emitting reader never hits this because it does not assign read-only collection members at all. It populates them in place through the getter, which is sufficient:
Models.csonly admits a read-only member when its kind isCollectionorEnumerable, so any read-only member that reaches a mapping is by construction a collection that can be populated in place.Suggested fix
The same method already has a
FieldInfo.SetValuebranch, used when the declaring type is a value type or dynamic code is unavailable.FieldInfo.SetValuesucceeds on aninitonlyinstance field (verified on .NET 10), so routing read-only fields to that branch looks like a small fix.Whether that is the right behavior is worth a moment's thought, since it would make the reflection reader assign where the IL reader only populates in place. Matching the IL reader more literally, by populating through the getter and skipping the assignment, may be the better parity target.
Notes
ModereturnsReflectionOnlywhen!RuntimeFeature.IsDynamicCodeSupported, but that same condition sendsGetSetMemberValueDelegatedown theSetValuebranch, which works.Note
This issue was drafted with GitHub Copilot.