Skip to content

Fix xsi:list round-tripping with space-separated serialization#130428

Open
StephenMolloy wants to merge 5 commits into
dotnet:mainfrom
StephenMolloy:115837-Fix-xsi-list-serialization-in-XmlText-and-XmlAttribute
Open

Fix xsi:list round-tripping with space-separated serialization#130428
StephenMolloy wants to merge 5 commits into
dotnet:mainfrom
StephenMolloy:115837-Fix-xsi-list-serialization-in-XmlText-and-XmlAttribute

Conversation

@StephenMolloy

Copy link
Copy Markdown
Member

Fixes #115837

An array-like member marked with [XmlText] (e.g. [XmlText] string[]) did not round-trip: on serialization the items were concatenated into the element's text content with no separator, so deserialization read them back as a single value.

Per the XML spec (https://www.w3.org/TR/xml/#sec-common-syn) whitespace is one of only four characters (0x20, 0x09, 0x0D, 0x0A), and per the XSD spec a list type is a whitespace-separated sequence of tokens. This change makes [XmlText] on array-like members serialize as a space-separated list and split on the four XML whitespace characters when reading, mirroring the long-standing [XmlAttribute] list behavior. [XmlAttribute] list deserialization is also aligned to split on exactly those four XML whitespace characters (rather than the broader char.IsWhiteSpace set) so that other whitespace can survive inside list items.

The new behavior is applied across all serialization engines: the reflection-based reader/writer, the IL-generated reader/writer, and the Microsoft.XmlSerializer.Generator pre-generated output.

Because the previous concatenation behavior was broken for round-tripping, the new behavior is on by default. Callers depending on the old behavior can opt out via the AppContext switch Switch.System.Xml.Serialization.UseLegacyXmlListSeparation.

Notes:

  • Whitespace is normalized when reading a list, so a null/empty middle item that serializes to a double space is collapsed and dropped on read (empty/null items are not expected to survive a round trip). Callers are responsible for not embedding XML whitespace inside individual list items.

Tests:

  • Added coverage for [XmlText] on arrays, List, and mixed element/text content, plus [XmlAttribute] arrays, verifying exact serialized output and round-tripping.
  • Added tests for whitespace normalization / null-item collapsing edge cases.
  • Added tests with the legacy AppContext switch enabled to verify the old concatenation behavior, and a test pinning the switch's default to false.
  • New runtime-only test types were placed in SerializationTypes.RuntimeOnly.cs so they are excluded from the pre-generated serializer test projects.

Fixes #115837

Fixes dotnet#115837

An array-like member marked with [XmlText] (e.g. `[XmlText] string[]`) did not
round-trip: on serialization the items were concatenated into the element's text
content with no separator, so deserialization read them back as a single value.

Per the XML spec (https://www.w3.org/TR/xml/#sec-common-syn) whitespace is one of
only four characters (0x20, 0x09, 0x0D, 0x0A), and per the XSD spec a list type
is a whitespace-separated sequence of tokens. This change makes [XmlText] on
array-like members serialize as a space-separated list and split on the four XML
whitespace characters when reading, mirroring the long-standing [XmlAttribute]
list behavior. [XmlAttribute] list deserialization is also aligned to split on
exactly those four XML whitespace characters (rather than the broader
char.IsWhiteSpace set) so that other whitespace can survive inside list items.

The new behavior is applied across all serialization engines: the reflection-based
reader/writer, the IL-generated reader/writer, and the Microsoft.XmlSerializer.Generator
pre-generated output.

Because the previous concatenation behavior was broken for round-tripping, the new
behavior is on by default. Callers depending on the old behavior can opt out via the
AppContext switch `Switch.System.Xml.Serialization.UseLegacyXmlListSeparation`.

Notes:
- Whitespace is normalized when reading a list, so a null/empty middle item that
  serializes to a double space is collapsed and dropped on read (empty/null items are
  not expected to survive a round trip). Callers are responsible for not embedding XML
  whitespace inside individual list items.

Tests:
- Added coverage for [XmlText] on arrays, List<string>, and mixed element/text content,
  plus [XmlAttribute] arrays, verifying exact serialized output and round-tripping.
- Added tests for whitespace normalization / null-item collapsing edge cases.
- Added tests with the legacy AppContext switch enabled to verify the old
  concatenation behavior, and a test pinning the switch's default to false.
- New runtime-only test types were placed in SerializationTypes.RuntimeOnly.cs so they
  are excluded from the pre-generated serializer test projects.
@StephenMolloy StephenMolloy added this to the 11.0.0 milestone Jul 9, 2026
@StephenMolloy StephenMolloy self-assigned this Jul 9, 2026
Copilot AI review requested due to automatic review settings July 9, 2026 19:20

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 changes XmlSerializer list handling so that array-like members serialized as text ([XmlText] string[], List<string>, etc.) round-trip by writing a space-separated list and reading it back by splitting on the four XML whitespace characters (' ', '\t', '\n', '\r'). It also introduces an AppContext opt-out switch (Switch.System.Xml.Serialization.UseLegacyXmlListSeparation) and updates generator/expected outputs plus tests accordingly.

Changes:

  • Emit space separators when serializing “pure text” array-like [XmlText] members (and avoid applying list semantics for mixed text+element content).
  • Update deserialization to split list text (and attribute list values) on XML’s four whitespace characters, with an opt-out legacy path.
  • Add tests covering round-tripping, whitespace normalization edge cases, and the legacy switch behavior.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/libraries/System.Runtime.Serialization.Xml/tests/SerializationTypes.RuntimeOnly.cs Adds runtime-only test types used by serializer tests.
src/libraries/System.Private.Xml/tests/XmlSerializer/XmlSerializerTests.cs Adds/updates tests for new list behavior + legacy switch coverage.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/XmlSerializationWriterILGen.cs IL-gen writer emits separators for list-style [XmlText] array-like members.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/XmlSerializationWriter.cs Source-gen writer emits separators for list-style [XmlText] array-like members.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/XmlSerializationReaderILGen.cs IL-gen reader adds XML-whitespace splitting for text lists and attribute lists (with legacy switch).
src/libraries/System.Private.Xml/src/System/Xml/Serialization/XmlSerializationReader.cs Source-gen reader adds XML-whitespace splitting for text lists and attribute lists (with legacy switch).
src/libraries/System.Private.Xml/src/System/Xml/Serialization/XmlReflectionImporter.cs Controls when [XmlText] array-like members are treated as list text vs. legacy concatenation/mixed content.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/ReflectionXmlSerializationWriter.cs Reflection writer emits separators for list-style [XmlText] array-like members.
src/libraries/System.Private.Xml/src/System/Xml/Serialization/ReflectionXmlSerializationReader.cs Reflection reader splits list text/attributes using XML whitespace (with legacy switch).
src/libraries/System.Private.Xml/src/System/Xml/Serialization/Mappings.cs Adds TextAccessor.IsList to carry per-accessor list semantics for [XmlText].
src/libraries/System.Private.Xml/src/System/Xml/Core/LocalAppContextSwitches.cs Adds cached switch accessor for UseLegacyXmlListSeparation.
src/libraries/Microsoft.XmlSerializer.Generator/tests/Expected.SerializableAssembly.XmlSerializers.cs Updates expected generated serializer output to reflect new behavior.

Copilot AI review requested due to automatic review settings July 15, 2026 01:09

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

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

@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Workflow state for the Holistic Review Orchestrator.

{
  "version": 5,
  "last_dispatched_commit": "35301936033455c31cf21db11761094b91b45b08",
  "last_dispatched_base_ref": "main",
  "last_dispatched_base_sha": "8dd1f60f48798789aea5967a7df682e7803468e7",
  "last_reviewed_commit": "35301936033455c31cf21db11761094b91b45b08",
  "last_reviewed_base_ref": "main",
  "last_reviewed_base_sha": "8dd1f60f48798789aea5967a7df682e7803468e7",
  "last_recorded_worker_run_id": "29766987730",
  "review_attempt_commit": "",
  "review_attempt_base_ref": "",
  "review_attempt_count": 0,
  "max_review_attempts": 5,
  "review_history_format": "holistic-review-disclosure-v1",
  "review_history": [
    {
      "commit": "5e91788a1ca21eccb4ef9c407433be2335ec077a",
      "review_id": 4730638129
    },
    {
      "commit": "35301936033455c31cf21db11761094b91b45b08",
      "review_id": 4737841507
    }
  ]
}

@github-actions github-actions Bot 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.

Holistic Review

Motivation: The problem is real and well-evidenced (issue #115837): an array-like [XmlText] member (e.g. [XmlText] string[]) concatenated its items with no separator on write, so the value could never round-trip — deserialization read it back as a single item. Aligning with the long-standing [XmlAttribute] list behavior and the XSD xs:list model is a reasonable, spec-grounded fix.

Approach: The fix threads a per-accessor TextAccessor.IsList flag (a strict refinement of Mapping.IsList, narrowed by pure-text/no-mixed-content checks and the opt-out switch) and applies the space-separated write / whitespace-split read consistently across all four engines (reflection reader/writer, IL-gen reader/writer) plus the pre-generated Expected.* output. Splitting on exactly the four XML whitespace characters (#x20 #x9 #xA #xD) rather than char.IsWhiteSpace() is a deliberate, well-justified refinement, and the legacy behavior is preserved behind the opt-out Switch.System.Xml.Serialization.UseLegacyXmlListSeparation. The code is careful and internally consistent.

Summary: ⚠️ Needs Human Review. I found no correctness bugs in the changed code; the multi-engine implementation and tests are thorough and mutually consistent. The escalation is because this is an intentional default-on behavioral/compat change to serialized output (val1val2val1 val2) and to attribute list splitting semantics, which requires maintainer sign-off on the compat process (breaking-change doc, opt-out polarity) and confirmation of the documented lossy round-trip for empty/null items. A human should focus on those policy decisions rather than the mechanics.


Detailed Findings

⚠️ Compatibility — Default-on behavior change to serialized output and attribute splitting

The new list behavior is enabled by default (opt-out switch), so two observable behaviors change for existing consumers without any code change:

  1. [XmlText] array/list members now emit space-separated content (val1 val2) instead of concatenated content (val1val2). Any consumer that today relies on the (admittedly broken) concatenation, or that parses the produced XML with external tooling, will see different output.
  2. [XmlAttribute] list deserialization now splits on only the four XML whitespace characters instead of char.IsWhiteSpace(). Previously an item separated by e.g. NBSP would be split; now it is preserved. This is a read-side semantic change for attribute lists even though attribute serialization is unchanged.

Both are defensible and gated by the switch, but the polarity (opt-out, new behavior by default) and the fact that #2 changes attribute list reading — not just the [XmlText] bug being fixed — are decisions that warrant explicit maintainer confirmation and a breaking-change doc. The UseLegacyXmlListSeparationSwitch_DefaultsToFalse test correctly pins the polarity, which is good.

⚠️ Round-trip lossiness for empty/null items is asymmetric by design

The writer emits a positional separator even when an item serializes to empty (null/empty string), producing a double space, while the reader normalizes runs and uses RemoveEmptyEntries, so empty/null middle items are silently dropped. This is clearly documented in comments and covered by tests (...NullOrEmptyItemNormalizesOnDeserialize), and matches the XSD list model, so it is acceptable — but it is a genuine data-loss edge for callers who put empty entries in a text list. Worth a human confirming this is the intended contract (it mirrors attribute-list behavior, which supports the decision).

✅ Cross-engine consistency verified

I checked that the flag and the split/join logic are applied consistently in ReflectionXmlSerializationReader/Writer, XmlSerializationReader(ILGen)/Writer(ILGen), and that the regenerated Expected.SerializableAssembly.XmlSerializers.cs matches the IL-gen/source-gen output (space separator on write, split on the four XML whitespace chars with RemoveEmptyEntries on read). The text.IsList gate (Mapping.IsList && XmlElements.Count == 0 && XmlAnyElements.Count == 0 && !switch) and the writer's elements.Length == 0 guard are mutually consistent, so mixed element/text content correctly retains the legacy per-run behavior (validated by XmlTextArray_WithMixedElementContent_PreservesTextRuns).

TextAccessor.IsList scoping is sound

The flag is only set in XmlReflectionImporter (attribute-driven mapping). The schema path (XmlSchemaImporter) leaves it false, so schema-imported mixed-content string[] text keeps its existing run-based behavior. That is the correct conservative choice and avoids changing xsd-driven scenarios.

💡 Minor: runtime allocation of the separator array in IL-gen path

The non-legacy IL-gen paths emit " \t\n\r".ToCharArray() inline, allocating a 4-char array on every deserialize of the member. This is negligible in practice and not worth blocking, but a static cached char[] (as the reflection reader already uses via s_xmlListWhitespace) would avoid the per-call allocation if a follow-up wants parity.

✅ Test quality

Tests are strong: exact serialized-output assertions plus round-trip checks, [Theory]/[MemberData]/[InlineData] for whitespace-normalization and null/empty edge cases, List<string> coverage (not just arrays), legacy-switch coverage for both [XmlText] (concatenation) and [XmlAttribute] (broad splitting), a default-polarity pin, and correct placement of new runtime-only types in SerializationTypes.RuntimeOnly.cs to exclude them from the pre-generated serializer projects.

Note

This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.

Generated by Holistic Review · 166.9 AIC · ⌖ 11.2 AIC · ⊞ 10K

The ILGen-emitted deserializer split list values (for [XmlText] and
[XmlAttribute] array-like members) by building the four-char XML whitespace
separator array at runtime via " \t\n\r".ToCharArray(), allocating a fresh
char[] on every deserialization.

Cache the separators in a private static field (s_xmlListWhitespace) defined
on the generated reader type, lazily initialized on first use, so the array is
allocated at most once per reader type. The field lives on the generated
subclass (not the public XmlSerializationReader base) to avoid adding public
API surface. The lazy-init race is benign since the array is only ever read.

The legacy (opt-out) path is unchanged behaviorally: it still passes a null
separator to String.Split (broader char.IsWhiteSpace splitting) and now loads
null directly at the call site with no local round-trip.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 93c39ba6-3968-432e-97ee-713c2252ed42
Copilot AI review requested due to automatic review settings July 20, 2026 18:01

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

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

@github-actions github-actions Bot 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.

Holistic Review

Motivation: Unchanged from the prior review. The base problem (issue #115837) is real and well-evidenced: an array-like [XmlText] member (e.g. [XmlText] string[]) concatenated its items with no separator on write, so the value could never round-trip. The single new commit in this update is a pure performance refinement of the already-accepted fix and does not alter the motivation.

Approach: The new commit (35301936) implements the exact optimization flagged as a minor follow-up in the previous holistic review: instead of emitting " \t\n\r".ToCharArray() inline (allocating a fresh 4-char array on every deserialize), the IL-gen reader now caches the four XML whitespace separators in a lazily-initialized private static field (s_xmlListWhitespace) on the generated reader, via the new EmitXmlListSeparators helper. Both the [XmlAttribute] list path (WriteAttribute) and the [XmlText] list path (WriteText) share the helper. For the legacy switch the helper returns null and the caller loads null directly, preserving the broad char.IsWhiteSpace() split. This brings the IL-gen path to parity with the reflection reader's existing s_xmlListWhitespace cache.

Summary: ⚠️ Needs Human Review (unchanged verdict). The incremental commit is a clean, correct, allocation-avoiding refinement with no new correctness concerns: the emitted IL loads the static field (Ldsfld), branches on null, populates and stores the array once, and the lazy-init race is benign because the array is only ever read after publication. The escalation is unchanged and rooted in the cumulative PR, not this commit: this remains an intentional default-on behavioral/compat change (space-separated serialized output and attribute-list splitting semantics) that warrants maintainer sign-off on compat policy (breaking-change doc, opt-out polarity) and confirmation of the documented lossy round-trip for empty/null items. Those decisions belong to a human, and no code in this increment changes them.


Assessment History

  • review 4730638129 reviewed commit 5e91788a, verdict ⚠️ Needs Human Review. Current verdict: ⚠️ Needs Human Review — unchanged. Motivation, approach, and risk assessment are all unchanged; the sole new commit (35301936) implements the "💡 Minor: runtime allocation of the separator array in IL-gen path" follow-up from that review by caching the separators in a static field, and introduces no new compat or correctness risk.

Detailed Findings

No new actionable findings. The incremental change (EmitXmlListSeparators and its two call sites in XmlSerializationReaderILGen.cs) is correct and addresses the prior review's minor allocation note. All cumulative compatibility observations from the prior review still stand but concern code unchanged in this increment, so they are not re-raised as new findings.

Note

This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.

Generated by Holistic Review · 104.5 AIC · ⌖ 10.4 AIC · ⊞ 10K

Replace the per-engine list-splitting logic (reflection, IL, and C#
pre-gen) with a single protected static helper,
XmlSerializationReader.SplitXmlListValues(string, bool), so all three
serializer engines share one implementation and one cached separator
array.

This supersedes the prior per-generated-type static char[] caching in the
ILGen reader: instead of emitting a lazily-initialized static field per
generated serializer, the ILGen and C# codegen paths now emit a call to
the already-compiled framework helper (mirroring the existing ToChar/ToEnum
callbacks). This removes the per-call char[] allocation in every engine and
sidesteps the safe-publication/race-condition concern raised on the lazy
static field.

The UseLegacyXmlListSeparation switch is now evaluated at read time inside
the helper rather than baked in at serializer-generation time. This fixes a
latent bug in pre-generated (sgen) serializers, which previously captured
the build machine's switch value, leaving end users unable to opt into the
legacy behavior. It also makes the ILGen path robust against serializer
caching and test ordering.

Adds the protected static SplitXmlListValues member to the System.Xml
ref assembly and regenerates the Microsoft.XmlSerializer.Generator golden
file to emit the helper call. The new protected API requires API-review
sign-off.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 93c39ba6-3968-432e-97ee-713c2252ed42
Copilot AI review requested due to automatic review settings July 20, 2026 21:25

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

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

… API

The previous commit routed all three serializer engines through a new
`protected static string[] XmlSerializationReader.SplitXmlListValues(string, bool)`
helper. That added permanent public (protected) API surface to a long-standing
public base class, which requires formal API review and is a lasting
compatibility obligation.

On reflection the shared helper is a bolt-on either way:

- The apparent kinship with the existing `ToChar`/`ToEnum` protected-static
  family is superficial. Those are scalar lexical->value parsers with real
  conversion logic; list splitting is a trivial tokenizer that every engine
  emitted inline before this work.
- XmlSerializer is by design three parallel engines (reflection, IL-emit, and
  C# pre-gen) that each duplicate serialization semantics. A single shared
  helper cuts against that grain rather than with it.
- The one thing that genuinely must happen at runtime -- honoring the
  UseLegacyXmlListSeparation opt-out in the two compiled engines -- does NOT
  require the helper. The switch is read via the public
  `AppContext.TryGetSwitch(string, out bool)`, which both compiled engines can
  invoke directly.

So this reverts the shared-helper design and instead has each engine own its
separator set while still evaluating the switch at runtime (once per process),
with zero new API:

- Reflection reader: restores a private `static readonly char[]
  s_xmlListWhitespace` and inlines the `UseLegacyXmlListSeparation` check at
  both the attribute and text list sites.
- IL-gen reader: emits a private `static char[] s_xmlListSeparators` field on
  the generated reader plus a static constructor that initializes it from
  `AppContext.TryGetSwitch`. The switch is therefore read when the generated
  type loads rather than baked in at generation time, so a caller's opt-out is
  honored -- fixing the latent pre-gen bug where the build machine's switch
  value was captured. The two list sites load the field and call
  `string.Split` directly.
- C# pre-gen (codegen) reader: emits an equivalent per-class
  `static readonly char[] xmlListSeparators = AppContext.TryGetSwitch(...) ? ...`
  field, emitted only when a list member is generated, and both sites call
  `listValues.Split(xmlListSeparators[, RemoveEmptyEntries])`.
- Removes the `SplitXmlListValues` entry from the XmlSerializer ref assembly and
  regenerates the golden pre-gen serializer to match the inline `.Split` output.

Behavior is unchanged from the helper version and verified across all four
engine x switch combinations (IL-gen and reflection, default and legacy):
space-separated text by default with the four XML whitespace characters,
NBSP preserved by default and split under legacy, double-space normalization,
and legacy concatenation for [XmlText] arrays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 93c39ba6-3968-432e-97ee-713c2252ed42
Copilot AI review requested due to automatic review settings July 21, 2026 23:36

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

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

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.

XML serialization of xs:list elements doesn't respect white-space separation

2 participants