An AOT-safe, source-generated DataContractSerializer - #1766
Draft
g7ed6e wants to merge 39 commits into
Draft
Conversation
…tSerializer
DataContractSerializer discovers types by reflection and is annotated
[RequiresDynamicCode] + [RequiresUnreferencedCode], so CoreWCF services cannot
publish cleanly with PublishAot or PublishTrimmed. The fix is a Roslyn generator
emitting reflection-free serializers, plugged in via the existing virtual
DataContractSerializerOperationBehavior.CreateSerializer seam.
This commit builds no generator code. It builds the oracle first: contract
instances whose exact serialized bytes are recorded from the real reflection-based
serializer, so the generator can later be diffed byte for byte against them.
A round-trip test would only prove self-consistency, not wire compatibility.
Adds three areas, following the src/<Area>/src + src/<Area>/tests layout of
CoreWCF.BuildTools:
CoreWCF.DataContractSerialization the package (netstandard2.0),
empty, packs the generator as an
analyzer under roslyn4.8/cs
CoreWCF.DataContractSerialization.Generator the generator, empty
CoreWCF.DataContractSerialization.TestCorpus contract corpus and instance
catalog; pure BCL so it stays
publishable ahead-of-time
Corpus: 10 CoreWCF-owned sanity types covering the capability surface, plus
dotnet/runtime's SerializationTestTypes/Primitives.cs pinned at bbfaee3b.
Primitives.cs is not self-contained; rather than import 244 KB of out-of-scope
files, the two symbols it references are extracted into _ImportSupport.cs with
per-symbol provenance. See SerializationTestTypes/UPSTREAM.md.
Fixtures are byte-exact: UTF-8, no BOM, no XML declaration, no indentation, one
line. Fixtures/.gitattributes marks them -text because the repository root sets
* text=auto, which would rewrite a raw newline inside a serialized string value
on Windows checkout - passing on Linux CI and failing on Windows.
Framework divergence is real and runs in both directions from the net8.0
baseline, so overrides are recorded only where bytes genuinely differ and are
pruned when they stop differing:
AllTypes, AllTypes2 net472 double.Epsilon renders 4.94065645841247E-324
on .NET Framework, 5E-324 on .NET Core 3.0+
DateTimeOnlyWrapper net10.0 .NET 10 made DateOnly/TimeOnly native
serializer primitives (dotnet/runtime#119835)
Regeneration is opt-in via COREWCF_REGENERATE_DCS_FIXTURES, writes to the source
tree, refuses to run on a build agent, and always fails the run - a run that
authors baselines must never be green, or leaving the variable set in CI would
silently rewrite the oracle it is meant to check.
All three src projects set IncludeCommonCode=false: without a Resources/Strings.resx
resources.props omits SR generation, which the shared src/Common sources require.
Verified: 70 tests on net8.0/net9.0/net10.0 and 69 on net472, all green; 3
generator scaffold tests; full solution Release build clean; dotnet pack yields
exactly one package with the analyzer inside. Corrupting a fixture by hand fails
exactly one test and reverts clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…itance cases
Importing a corpus file was a manual sequence of download, header injection,
break-fixing and registration. import.ps1 collapses the mechanical part into one
command: it downloads at a pinned commit, verifies the MIT header (which also
catches a moved path silently returning a 404 page), injects the provenance
block, writes CRLF/UTF-8-no-BOM, and reports any local CoreWCF-marked edit an
overwrite would discard - leaving a .bak, since it cannot re-apply them. -WhatIf
does a dry run.
Uses it to import InheritanceCases.cs and InheritanceObjectRef.cs, taking the
corpus from 39 to 75 cases. The value is the interaction between IsReference and
inheritance, where the emitted XML is genuinely non-obvious:
- IsReference inherits down a chain of [DataContract] declarations that do not
restate it, so DerivedNoIsRef5 still emits z:Id on the root.
- When only a nested member is reference-preserving, xmlns:z is redeclared on
each element instead of once on the root. Prefix scoping differs from the
root-is-IsReference case, which a generator would plausibly get wrong.
- i:type appears only where the declared type differs from the runtime type.
InheritanceObjectRef.cs is imported wholesale: despite its name it contains no
IObjectReference or ISerializable implementation, only inheritance hierarchies of
plain data contracts. The earlier assessment deferring it was based on the
filename and was wrong. ObjRefSample.cs stays out - it declares SerIser, a real
ISerializable type - so SimpleDC and SimpleDCWithRef join the extracted
declarations in _ImportSupport.cs.
Two things the import surfaced:
- DerivedWithIsRefTrue is an upstream negative test: IsReference = true under a
base that leaves it false is an invalid contract and throws
InvalidDataContractException rather than producing XML. Skipped with that
reason; rejecting it is a generator diagnostic test, not a fixture.
- Regeneration aborted on the first such case, leaving the fixture set half
rewritten and hiding every other problem behind one stack trace. It now
collects per-case failures and reports them all, so a bad import yields the
full list of cases needing a fix or a Skip in one run.
Verified: 106 tests on net8.0/net9.0/net10.0 and 105 on net472, all green; full
solution Release build clean. No new framework divergence - the three existing
overrides are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… switch
Groundwork for the source generator. Adds the extension point generated
serializers will plug into, plus the switch that selects them - and nothing else.
With the switch unset, which is the default outside Native AOT, CoreWCF behaves
exactly as before: CreateAotSerializer returns null and the operation formatter
takes the reflection path it always took.
Why a new abstraction rather than XmlObjectSerializer: all sixteen of its members
carry [RequiresDynamicCode] and [RequiresUnreferencedCode], and IL2046/IL3051
require an override's annotations to match the member it overrides. Callers reach
it through the base-typed reference anyway, so a reflection-free subclass would
make serialization work under AOT without silencing a single warning - the
annotation lives in the abstraction, not the implementation. The remedy is the one
System.Text.Json used: leave the reflection API annotated and add a second,
attribute-free one that takes the compile-time contract as a parameter.
AotXmlObjectSerializer is that second API, narrowed to what the operation
formatter actually calls, and carrying no attributes of its own - so it needs no
polyfills on netstandard2.0, where those attributes do not exist.
Read support is opt-in via CanReadObject. The first generator slice emits
write-only serializers, and the two directions are independent, so CoreWCF keeps
deserializing with the reflection-based serializer until a generated one claims it
can read.
The switch mirrors OperationInvokerBehavior, which gates the other generator the
same way: explicit AppContext setting wins, otherwise on only where
RuntimeFeature.IsDynamicCodeSupported is false, because under Native AOT the
reflection path is the broken one. An ordinary application must not change
serializer implementation merely because it upgraded CoreWCF.
The decision is a pure function of its two inputs, separate from the AppContext
reads, so it can be tested without mutating process-global state.
IsDynamicCodeSupported is read into static readonly fields elsewhere in CoreWCF
(Dispatcher/InvokerUtil, Dispatcher/DispatchOperationRuntimeHelpers), so a test
that set it could silently change unrelated behaviour for the rest of a
concurrently-running suite.
Also records the design and risk register in
Documentation/DesignDocs/aot-datacontractserializer-risks.md, including the write
algorithm as it actually is - read from dotnet/runtime rather than inferred. Two
findings worth having up front:
- Member ordering is Order ascending then string.CompareOrdinal on the contract
name, and DataMemberAttribute.Order defaults to -1 with negatives rejected, so
unordered members always precede ordered ones including Order = 0. The corpus
alone could not distinguish -1 from 0.
- Namespace prefixes are allocated by XmlDictionaryWriter, not by the
serializer, which only declares namespaces explicitly at the root and per
member. Emitting the same writer calls in the same order reproduces prefixes
for free - removing most of the byte-exactness risk.
Verified inert: Primitives 153, Metadata 26, Http 391 and the serialization
harness 103, all green with the switch unset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the package's public surface and a working source generator pipeline. The
generator discovers contexts, resolves contract names and namespaces, and emits a
compiling partial - but every GetSerializer still returns null, so CoreWCF takes
the reflection path exactly as before. Emitting real serializer bodies is next.
Package surface:
- DataContractSerializableAttribute, modelled on JsonSerializableAttribute:
declaring a context is the opt-in, and the resulting list of types is rooted,
which is what makes the output trimmable.
- DataContractSerializerContext.GetSerializer is virtual returning null rather
than abstract. The generator is gated by target framework, so on net472 it
never runs and a user's partial context is never completed - an abstract
member would leave that class uncompilable. Returning null means the same
source compiles everywhere and contributes nothing where generation did not
happen, with no conditional compilation in user code. Verified: the test
project builds on net8.0/9.0/10.0 with generation, and on net472 without.
- GeneratedDataContractSerializerOperationBehavior, overriding
CreateAotSerializer to consult a context.
- CoreWCF.DataContractSerialization.props/.targets, packed to
build;buildTransitive, defaulting the generator on for .NET 8 and later. That
gate is what lets emitted code use a modern language version rather than the
oldest one any consumer might compile with.
Generator, following the CoreWCF.BuildTools layout (pipeline / Parser / Emitter /
specs) with one deliberate divergence: parsing happens inside the pipeline
transform and the specs hold only values, so Roslyn can actually cache the step.
BuildTools' generators build their specs from live symbols inside
RegisterSourceOutput, which re-runs on every keystroke. That is also why
diagnostics travel as DiagnosticInfo rather than Diagnostic - a Location roots a
whole Compilation - and why EquatableArray exists, since arrays compare by
reference and would defeat the cache on their own.
Member ordering is implemented from ClassDataContract.DataMemberComparer in
dotnet/runtime rather than inferred: Order ascending, then string.CompareOrdinal
on the contract name. DataMemberAttribute.Order defaults to -1 and rejects
negatives, so unordered members always precede ordered ones including Order = 0.
Diagnostics take the free COREWCF_04XX band. They fire only for input that is
definitely wrong - a context that cannot be generated into, or a type that is not
a data contract. A shape the generator merely does not support yet is left to the
reflection serializer, because falling back is a correct outcome rather than an
error.
Two traps worth recording:
- The shipped .targets cannot simply be imported from a project body. NuGet
injects it after the SDK targets, where TargetFrameworkIdentifier is
populated; an import in the body is evaluated before that, silently sees an
empty value and disables the generator. The test project mirrors the gate
conditioned on TargetFramework instead, which is set that early.
- EmitCompilerGeneratedFiles writes under the project directory, where the
default glob compiles it as ordinary source - so the next build sees every
generated member declared twice. The test project now excludes generated/**
so inspecting output stays read-only.
Verified: serialization harness 103 on net8.0 and 102 on net472, generator tests
3, all four target frameworks building.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator now writes real serializers, and their output is byte-identical to
the reflection-based DataContractSerializer for the contracts in scope - verified
against fixtures the generator had no part in producing.
Three corpus cases match exactly on net8.0/9.0/10.0; the other 72 report a reason
and skip. That skip list is the coverage report, and it comes from the generator
itself: TryGetUnsupportedReason asks the generated context whether a serializer
exists, so it cannot drift from what was actually emitted.
In scope: primitives, DateTime, Guid, TimeSpan, byte[], strings and nullable value
types; member ordering; Name, Order, IsRequired and EmitDefaultValue; contract
Name and Namespace; i:nil. Out of scope for now - and detected in the parser, so
the contract simply gets no serializer and falls back: inheritance, non-public
members, and any member type not in the list above.
Reproducing the bytes came from reading dotnet/runtime rather than guessing, and
two findings did most of the work:
- Namespace prefixes are allocated by XmlDictionaryWriter, not by the
serializer. ReflectionWriteStartElement passes no prefix at all; a: and i:
fall out of declaration order. The serializer declares namespaces explicitly
in only two places, and the emitted code makes the same calls in the same
order - so prefixes match for free rather than being reimplemented.
- Primitive formatting is just writer.WriteValue, with three exceptions worth
knowing: char is written as its numeric value, Guid and TimeSpan go through
WriteRaw because XmlWriter has no WriteValue overload for them, and byte[] is
base64. Those three are exactly what a from-scratch implementation would get
wrong.
EmitDefaultValue = false combined with IsRequired throws rather than silently
omitting, matching ReflectionXmlFormatWriter.ReflectionWriteMembers.
Harness change: SerializerProvider.Capture is now virtual. The two producers reach
their serializer through different contracts - XmlObjectSerializer for the
reflection one, the attribute-free AotXmlObjectSerializer for the generated one -
but both use an identically configured writer, so the bytes stay comparable. The
generated provider deliberately does not set CanProduceFixtures: a serializer that
recorded its own expected output would make the corpus a tautology.
On net472 the generator does not run, GetSerializer returns null, and all 74 cases
report unsupported and skip. Verified explicitly rather than assumed - a run that
passes because nothing executed looks identical to one that passes because
everything matched.
Verified: 106 passing on net8.0/9.0/10.0 and 102 on net472, no failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ll checks
Adds 17 unit tests for the generator itself, covering what the golden-record
corpus cannot: diagnostics, the target framework gate, and the decisions that
leave a contract on the reflection path.
Drives CSharpGeneratorDriver directly rather than using
Microsoft.CodeAnalysis.Testing as CoreWCF.BuildTools does. That harness verifies
generated sources by exact text match, which for a serializer body means pinning
sixty lines of emitted code per test and rewriting them all whenever formatting
shifts. Here the emitted text is asserted where it carries meaning - member order,
wire names, the fallback comments - and the result compilation is checked for
errors, so invalid generated code still fails loudly. Failures print the compiler
diagnostics, without which "collection was not empty" says nothing about what
broke.
Writing them found two things worth keeping:
- A context carrying no [DataContractSerializable] is never discovered at all,
so nothing is emitted and the base GetSerializer keeps returning null. Three
tests were originally written expecting a diagnostic there; the attribute is
the opt-in, and its absence is not an error.
- The build property name is spelled out in the harness rather than referencing
the generator's constant. It is the name a consumer's build actually supplies,
so a rename should break these tests rather than sail through them and break
consumers instead.
Also removes a redundant null test from the emitted code: for a member with
EmitDefaultValue = false, reaching the write branch already proves the value is
not at its default, and for the nullable kinds "default" is exactly "null" - so
the inner check was dead code.
Verified the change is still inert with the switch unset: Primitives 153, Metadata
26, Http 391, the serialization harness 106 on net8.0 and 102 on net472, and the
full solution builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…two soundness bugs
Coverage was 3 of 75 not because the generator could only handle three cases, but
because the context listed only two types. It now lists every contract in the
corpus and lets the generator decide, which is the honest measurement: 17 of 75
byte-match, and the skip reasons are the generator's own.
Making that measurement immediately found two ways the generator produced output
that was wrong rather than absent - the failure mode the corpus exists to catch,
since both looked entirely plausible:
- IsReference was never checked. SimpleDC is IsReference = true, so the real
serializer emits z:Id for object identity; the generated one emitted a
well-formed element without it.
- A contract in another assembly may have data members the generator cannot
see. BaseDCNoIsRef's only [DataMember] is a private field, and compiled from
metadata rather than source it did not appear at all - so the generator
emitted a serializer that silently dropped it. Their absence is
indistinguishable from their not existing, so the only sound answer is to
decline any contract declared outside the context's own assembly.
That second fix is a real constraint on users, not a test artifact: a context in a
different assembly from its contracts would otherwise serialize them incorrectly
and silently. It also forces the corpus context to move out of the test project
and in alongside the types, which milestone 1 anticipated - "the trade reverses
the moment an in-slice case needs private access". BaseDCNoIsRef is that case.
The cost of that move is the coupling it was placed to avoid: generated code that
fails to compile now breaks the corpus build and the reflection oracle with it.
That is a far smaller risk than it was, with 19 generator unit tests now proving
the emitter produces compiling code, and the fixtures already committed.
CorpusDoesNotReferenceCoreWcf becomes CorpusDoesNotReferenceTheHostingStack. The
corpus now references CoreWCF.DataContractSerialization deliberately; that package
is netstandard2.0 and reflection-free, so it does not compromise what the test
protects. Dragging in the hosting stack still would.
Where the remaining 58 sit, from the generator's own output: 24 blocked on a
member type it cannot write (6 object, 6 collections, 6 nested contracts, the rest
[Serializable] or DateOnly), 20 on inheritance, 12 on IsReference, 1 on a
non-public member. Note IsReference is inherited, so implementing inheritance will
need to walk the base chain for it rather than reading one attribute.
Verified: 120 passing on net8.0/9.0/10.0 and 102 on net472, 19 generator tests, no
failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage goes from 17 of 75 to 26. Both capabilities fall out of one
restructure: each contract now emits a static content writer that writes only its
members, and a serializer class that wraps one in a root element.
Splitting them is what makes both work. A contract-typed member has no second
wrapping element on the wire, so it is written by calling the nested contract's
content writer inline. A derived contract writes its base's members first by
calling the base content writer, which also gets the ordering right: members are
sorted within their own contract rather than merged across the hierarchy, so a
base member named Zulu still precedes a derived member named Alpha.
Nested contracts are pulled in transitively, so a user lists the types they
serialize rather than the closure of everything those types reach. Only the
declared ones become GetSerializer entries. A container whose nested contract
cannot be written is itself declined, computed to a fixed point since the
dependency can be several deep.
Members whose type lives in a different contract namespace declare it on the
member element rather than at the root, which is what produces xmlns:b there.
Mirrors ClassDataContract.GetChildNamespaceToDeclare, including its exclusions:
built-in contracts, enums and IXmlSerializable declare nothing.
Three soundness bugs found by the corpus, all of which produced plausible but
wrong XML rather than falling back:
- Overriding data members were written twice. SerializationTestTypes.Derived
overrides A and B and both the base and the override carry [DataMember], so
the base content writer and the derived one each emitted them. An override
contributes no new element - only a different getter.
- IsReference was read from one attribute, but it is inherited. A derived
contract that says nothing still gets it from its base, and would have been
emitted without z:Id.
- A contract-typed member whose declared type admits derived instances needs an
i:type attribute and the derived contract's members. Writing the declared
type's members instead is wrong, which is what SanityKnownTypeHolder caught.
Declined now on two independent signals: [KnownType] anywhere in the declared
type's hierarchy, detected at compile time, and a non-empty knownTypes list
passed to CreateAotSerializer, which is how CoreWCF supplies them from an
operation contract where no attribute would reveal them.
Note the closed-world assumption this rests on: without a known type there is
nothing to be polymorphic with, because DataContractSerializer itself throws on an
unexpected runtime type rather than guessing. Declared and runtime type must
therefore agree, which is what makes writing a nested contract inline safe.
Generated output is now sorted by contract name so the emitted file is byte-stable
across builds; dictionary ordering is an implementation detail and generated
source that shuffles shows up as a spurious diff.
Verified: 129 passing on net8.0/9.0/10.0 and 102 on net472, 24 generator tests, no
failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 26 -> 27. Enums are written as their wire names, from a per-enum value/name table driven by one shared copy of the algorithm rather than a switch emitted per type. Mirrors EnumDataContract.WriteEnumValue exactly, including two things a from-scratch implementation would get wrong: an exact value match wins even for a flags enum, so a combination that happens to equal a declared member writes that member's name rather than decomposing; and a flags value that cannot be fully decomposed is an error rather than a partial write. Members are kept in declaration order because the decomposition consumes them in that order. Wire names follow EnumDataContract.ImportDataMembers: when the enum itself carries [DataContract] only fields with [EnumMember] participate and an explicitly set Value replaces the field name; otherwise every public constant participates under its own name. Only +1 case, which is worth recording: the other enum-bearing contracts are blocked on object members or IsReference, not on enums. IsReference is now the single largest blocker at 26 contracts - up from 12, because inheritance support made the inherited-IsReference detection correctly decline the whole BaseWithIsRefTrue hierarchy rather than silently emitting it without z:Id. Verified: 130 passing on net8.0 and 102 on net472, 24 generator tests.
Corpus coverage 27 -> 29 of 76. Arrays and lists of primitives are written as
per-item elements in the serialization Arrays namespace, which is declared on the
member element rather than at the root.
The item element names are XSD-derived and several are not what the CLR type name
suggests, so they were recorded from the real serializer rather than assumed. A
new SanityPrimitiveArrays corpus type carries one array per supported primitive
plus a List<int> and a null element, and its fixture is now the specification for
this table. Two things it corrected that a from-scratch implementation would very
likely get wrong:
- sbyte is written as "byte" and byte as "unsignedByte" - the reverse of the
obvious guess.
- byte[] is not a collection at all. It is a primitive written as base64 in the
containing contract's namespace, with no child namespace and no per-item
elements. Treating it as an array of bytes would have produced a completely
different document.
Also confirmed by that fixture: List<T> and T[] produce identical output, an empty
collection writes the member element with no children rather than nil, and a null
item inside a collection is written as an element carrying i:nil.
Nested collections, dictionaries, ArrayList and collections of contracts or enums
stay on the reflection path - each needs more than an item name to write.
Two generator tests had used List<int> as their example of an unsupported type,
which it no longer is; they now use Dictionary, and two new tests pin the item
naming and the byte[] distinction.
Verified: 133 passing on net8.0/9.0/10.0 and 103 on net472, 26 generator tests, no
failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 29 -> 49 of 76, the largest single jump of this milestone.
IsReference was blocking 28 contracts, more than every other cause combined.
The algorithm was read from XmlObjectSerializerWriteContext.OnHandleIsReference and
ObjectToIdCache before any code was written, and four of its details are ones a
from-scratch implementation is unlikely to guess:
- Ids come from a per-call cache whose counter starts at 1, and only objects whose
contract is IsReference consume one. That is why a reference-preserving member of
a plain root is i1 rather than i2.
- Lookup is by reference, never Equals. A contract that overrides equality still
gets one id per instance, and two equal-but-distinct instances must not collapse
into a single z:Ref.
- A repeat sight writes z:Ref and nothing else. That, not a visited-set check, is
what terminates a cycle - the return value means "already written".
- The decision belongs to the element wrapping the object, so it is taken once at
the root or on the member element, never once per level of a base chain.
The z prefix is not declared anywhere in the generated code. Writing an attribute in
the serialization namespace makes the writer declare it where it first comes into
scope, which reproduces both fixture shapes for free: xmlns:z on the root when the
root contract is IsReference, and on the member element when only a nested contract
is. The ordering that puts z:Id ahead of the xmlns declarations falls out the same
way, from writing it where OnHandleIsReference sits relative to HandleGraphAtTopLevel.
Two contracts that DataContractSerializer rejects are now declined explicitly rather
than served: one that contradicts its base about IsReference, and IsReference on a
value type. Both throw InvalidDataContractException on the reflection path, so
declining preserves that instead of accepting a contract the real serializer refuses.
Not closed: a cycle in a graph with no IsReference contract overflows the stack where
DataContractSerializer throws CannotSerializeObjectWithCycles past 512 levels. Only
reachable on graphs the real serializer also refuses, so no valid document is
affected, but the failure mode differs. Recorded in the risk register as 4a.
All 20 newly covered cases byte-matched the recorded fixtures on the first run, with
no fixture regenerated.
Verified: 153 passing on net8.0/9.0/10.0 and 103 on net472, 29 generator tests, no
failures, full solution builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 49 -> 58 of 76. A member declared as one contract but holding a
derived one now picks its writer by runtime type and announces the choice with
i:type, instead of leaving the whole contract to reflection.
Read from DataContract.ImportKnownTypeAttributes and
XmlObjectSerializerWriteContext.WriteTypeInfo first. Three details shaped the design:
- The known-type closure is not a read of one type's attributes. The base chain is
walked, so a derived contract inherits what its base declared, and the closure is
transitive, so a known type's own [KnownType]s join it.
- The attribute may sit on either end - the contract holding the member, or the
member's own declared type. The serializer has both in scope as it descends.
SanityKnownTypeHolder declares nothing and its member type declares everything,
so reading only the holder generated a serializer that silently wrote the base
contract's members for a derived instance.
- i:type is written only when the runtime contract differs from the declared one,
and is written by declaring the value's namespace first and then the qualified
name into the attribute. Declaring first is what lets an already-bound prefix be
reused, which is why i:type on a contract in the root's namespace reads
"a:Derived" and adds no declaration of its own.
Branching is on exact type equality rather than a type pattern, so the emitted code
is order-independent: a pattern would let the base branch swallow a derived instance
depending on the order the candidates happen to be in. A runtime type outside the set
throws, as the reflection path does - falling back is not available once the element
is open.
CoreWCF also supplies known types from the operation description, which no attribute
reveals to the generator. The behavior used to decline whenever any were present,
which is what kept the TestInheritance family on the reflection path even once their
contracts were writable. It now asks the context whether the serializer already
resolves them, answered from the closure rolled up across the whole reachable graph.
Subset, not equality: an operation supplying fewer types than the attributes declare
is still covered.
Three shapes are declined rather than served, each because the real serializer would
reject or resolve something this cannot see: [KnownType] naming a method, which only
exists at run time; a member declared as an abstract contract with nothing to resolve
it to; and the service-level known-type attributes.
All 9 newly covered cases byte-matched the recorded fixtures, no fixture regenerated.
Verified: 162 passing on net8.0/9.0/10.0 and 103 on net472, 34 generator tests, no
failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…overed
Corpus coverage 58 -> 61 of 77. A member declared as object now writes its runtime
type as i:type, resolved against the boxed primitives plus whatever [KnownType]
names.
As with the collection item names, the type table was recorded from the real
serializer rather than assumed. A new SanityBoxedPrimitives corpus type carries one
object member per primitive plus the three shapes that are not a primitive at all,
and its fixture is the specification. What it settles:
- Most xsi type names come from XML Schema, but char, Guid and TimeSpan are named
in the serialization namespace because XSD has no equivalent for them. Nothing
but a byte-exact comparison would surface that split.
- The sbyte/byte swap is the same as for collection items: sbyte is "byte" and byte
is "unsignedByte".
- A bare object is anyType and writes an empty element with no i:type at all; null
is i:nil with no i:type; a data contract writes i:type and its members.
Registering the two new Sanity types exposed two problems that had nothing to do with
object members and everything to do with trusting a green suite.
SanityPrimitiveArrays was added with the collections work and its fixture recorded,
but the type was never listed on GeneratedCorpusContext. GetSerializer returned null,
the generated half of the harness skipped it, and the skip reason read exactly like a
legitimately unsupported case. The collections work looked verified and was not. A new
integrity test now asserts that every catalogued case's contract type is listed in the
context, so this cannot recur silently.
Registering it then broke the build in a way worth recording: csc exited 1 with no
diagnostic at all, and the same compilation succeeded under
-p:EmitCompilerGeneratedFiles=true, which made it look like a compiler crash. Dumping
the generated file and compiling it as an ordinary source gave the real error: CS0121
on writer.WriteValue(item) for a ulong. XmlWriter has no WriteValue(ulong) - ulong
converts implicitly to float, double and decimal and to none of them better than the
others, so the call is ambiguous rather than missing. Upstream avoids it in
XmlWriterDelegator.WriteUnsignedLong by going through WriteRaw(XmlConvert.ToString),
a fourth WriteRaw case alongside Guid and TimeSpan, and so does the generator now.
Not closed: DataContractSerializer allows arrays and collections in an object member
without them being declared, and the generated switch has no branch for those, so it
throws where reflection succeeds. It cannot fall back - the member element is already
open by the time the runtime type is known. Recorded in the risk register as 4b, along
with 1a for the two verification lessons above.
Verified: 167 passing on net8.0/9.0/10.0 and 105 on net472, 37 generator tests, no
failures, full solution builds clean. No existing fixture was regenerated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 61 -> 65 of 77, closing the TestInheritance cases whose members are
[Serializable] rather than [DataContract].
Read from the else branch of hasDataContract in ClassDataContract.ImportDataMembers.
Three rules, none of which follow from the attribute's name:
- [DataContract] wins when a type carries both. BaseSerializable in the corpus is
exactly that shape, and reading its fields instead of its [DataMember]s would
have written three members where the wire has three different ones.
- Every instance field takes part, public and non-public, except [NonSerialized].
Properties never do - not even ones that look like data.
- Fields sort by the same comparer as data members, but with Order 0 rather than
the -1 an unspecified [DataMember] gets. Within one contract they are all equal
either way, so the sort reduces to ordinal by name.
This is not the implicit no-attribute contract that v1 excludes: [Serializable] is an
explicit opt-in with a defined member set, where a bare POCO is inferred.
Two restrictions on top of upstream, both to avoid claiming more than can be
delivered. Only types declared in source are treated this way - [Serializable] is
everywhere in the framework, and Uri, ArrayList and Dictionary carry it while having
wire formats that are nothing like their field layout, so a metadata type keeps
whatever answer it had before. And a type implementing ISerializable is declined
outright: that takes over serialization entirely, so its fields are not what would go
on the wire. Non-public fields still decline for the reason they always did, since
generated code cannot reach another type's privates however close by they compile.
All 4 newly covered cases byte-matched the recorded fixtures, no fixture regenerated,
and no previously-supported contract changed its classification - ArrayList and
Dictionary still decline by name rather than being mistaken for contracts.
Verified: 171 passing on net8.0/9.0/10.0 and 105 on net472, 40 generator tests, no
failures, full solution builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two capabilities off the AllTypes list. Coverage stays at 65 of 77: AllTypes and AllTypes2 need six more before either can pass, so this moves the blocker rather than the count. An enum in an object member is announced with i:type like any other contract and then written from its own value/name table. An enum item in a collection is the more surprising of the two: unlike every built-in type, it is named after its own contract and stays in its own namespace rather than going into the Arrays namespace, which is why AllTypes.enumArrayData writes <a:MyEnum1> beside its containing contract with no xmlns declaration on the member element at all. That required the item namespace to become per-member rather than a constant, and the enum write to be shared between the member, collection-item and boxed-candidate paths. Correcting the record: I previously reported AllTypes as blocked on one remaining capability. It is blocked on eight. ParseContract records only the first reason a contract is declined and stops describing it, so a wide contract reads like a single blocker when it is one of many. The full list is now in the risk register as 4c - XmlQualifiedName, Uri, System.ValueType and System.Enum declared members, System.Array declared members, and DateTimeOffset - along with the observation that a report naming one cause per contract will understate the work whenever the contract is wide. Verified: 171 passing on net8.0/9.0/10.0 and 105 on net472, 41 generator tests, no failures. One generator test had an obsolete premise - an enum known type on an object member was the example of something unsupported - and now pins the behaviour instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 65 of 77 -> 66 of 78. Two more capabilities off the AllTypes list,
and the two on it that turn up in ordinary service contracts rather than only in a
deliberately pathological test type.
Neither is what it looks like on the wire, so a new SanityUriAndOffset corpus type
carries both in every position - bare, null, nullable, and in a collection - and its
fixture is the specification:
- A Uri is written from GetComponents(SerializationInfoString, UriEscaped), not
ToString. That normalises it: "http://corewcf.example" goes out with a trailing
slash.
- A DateTimeOffset is not a value at all. DataContractSerializer swaps in
DateTimeOffsetAdapter, a contract with a DateTime and an OffsetMinutes member
living in http://schemas.datacontract.org/2004/07/System - a namespace neither
type mentions. The DateTime written is the UTC one, so the offset is recorded
once rather than baked into both.
- In a collection the two diverge. A Uri item is an XSD name in the Arrays
namespace like any other built-in; a DateTimeOffset item is named after its
contract and stays in the System namespace, the same rule enums follow.
- A nil nullable DateTimeOffset still declares the System namespace on the member
element, because the child namespace is written before the value is inspected.
The boxed name came from the oracle too: adding a Uri to SanityBoxedPrimitives had the
generated path throw "Type 'System.Uri' cannot be written into an object member",
which is the intended failure - a clear exception rather than plausible wrong XML -
and the regenerated fixture then supplied "anyURI" in the XML Schema namespace.
AllTypes and AllTypes2 remain blocked. Four of their eight capabilities are now done;
the four left - XmlQualifiedName's own element prefix, System.ValueType and
System.Enum declared members, and System.Array declared members - are shapes that
appear nowhere else in the corpus. The table in the risk register tracks which is
which.
Verified: 173 passing on net8.0/9.0/10.0 and 106 on net472, 43 generator tests, no
failures, full solution builds clean. No existing fixture changed except
SanityBoxedPrimitives, which gained the new member.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… base Corpus coverage 66 -> 68 of 78. AllTypes and AllTypes2 now byte-match, closing the last four of the eight capabilities they needed. Members declared as System.ValueType, System.Enum or System.Array are the same shape as an object member - a switch on the runtime type, announced with i:type - over a narrower candidate set. The narrowing is not cosmetic: casting a ValueType to string is a compile error, so an unfiltered table would emit generated code that does not build. The parser filters the known types the same way, since an Enum member admits only enums and a ValueType member only value types. XmlQualifiedName is the one member type whose element carries a prefix of its own instead of reusing what the writer has bound, which leaves a second prefix on the contract's namespace beside the one already in scope. It looks redundant and it is what the serializer does - see NeedsPrefix in ReflectionXmlFormatWriter, which forces Globals.ElementPrefix for this type alone and only when the namespace is non-empty. The value follows XmlWriterDelegator.WriteQName: the empty name writes nothing at all rather than an empty string. A System.Array member is the narrowest of the four by design. It writes object[] as a sequence of anyType items, and the visible difference from a typed collection is where the namespace is declared: the member element declares nothing, so each item binds the Arrays namespace as a default xmlns of its own rather than carrying a prefix. The item writer covers the primitives, a bare object and null, and throws otherwise, because a contract or enum inside an untyped array would need the containing contract's known types and an item writer shared across the whole context does not have them. Verified: 175 passing on net8.0/9.0/10.0 and 106 on net472, 45 generator tests, no failures, full solution builds clean. No fixture was regenerated - all four landed byte-exact against the recorded output on the first run. Ten cases still skip: Dictionary and ArrayList, a jagged array, DateOnly, three contracts whose [KnownType] names a method, one with a non-public data member, and one with no [DataContract] at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stead of overflowing Two gaps the risk register had open, neither of which moves coverage - both are about the generated path failing honestly. ParseContract recorded the first reason a contract could not be written and stopped, so a wide contract read like a single blocker when it was one of many. That is not a cosmetic problem: it is how AllTypes was reported as one capability away when it needed eight. Reasons are now collected into a list and the emitted report prints one line per reason. DateTimeOnlyWrapper went from one line to four the moment it was turned on. A cycle in a graph with no IsReference contract used to recurse until the stack ran out. DataContractSerializer counts nesting depth and, past 512 levels, checks whether the object is already on the path and throws CannotSerializeObjectWithCycles - see XmlObjectSerializerWriteContext.OnHandleReference. The per-call scope that already tracks z:Id now carries the same counter, and every by-value contract write is wrapped in EnterByValue/ExitByValue. Reference-preserving contracts are deliberately left unguarded: the second sight of an instance is a z:Ref with no content, so they cannot recurse forever. The golden-record corpus cannot cover this, because the real serializer refuses such a graph and there is no output to record. CyclicGraphTests covers it directly instead: both paths must throw SerializationException for the same cyclic graph, and a 600-deep chain must still come out byte-identical - which is what stops the guard mistaking depth for a cycle. Both assertions matter; the second is the one that would have caught an off-by-one in the depth bookkeeping. One difference from upstream worth knowing: the 512 counts nested contracts here and XML writer depth there, so the exact level at which the throw happens can differ. Not observable in a valid document, where neither throws at all. Verified: 178 passing on net8.0/9.0/10.0 and 107 on net472, 48 generator tests, no failures, full solution builds clean. No fixture changed - coverage stays at 68 of 78. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 68 -> 71 of 78, closing SanityCollections, DictContainer and ArrayContainer. A dictionary entry is named after both type arguments: KeyValueOf followed by the XSD name of each, so Dictionary<string, string> writes KeyValueOfstringstring and Dictionary<byte[], byte[]> writes KeyValueOfbase64Binarybase64Binary. Both are pinned by existing fixtures. Every element involved - the entry, its Key and its Value - sits in the Arrays namespace, declared once on the member. ArrayList holds anything, so each item is an anyType element announcing its own runtime type - the same shape as an object member, once per item. The anyType writer added for System.Array members already did exactly this, so it is reused rather than duplicated. The two differ in one visible way, and the fixtures show why: an ArrayList member declares the Arrays namespace on the member element, so its items carry a prefix, where a System.Array member declares nothing and each item binds a default xmlns of its own. Only built-in key and value types are supported. A contract argument would contribute its own contract name to the entry name and, when itself generic, a hash - neither worth guessing at, and both would produce a plausible wrong element name rather than a fallback. The XSD name table is now one function serving three callers - collection item names, i:type local names, and half a dictionary entry name - rather than a switch inlined in the collection path. Three generator tests had used ArrayList or Dictionary<string, string> as their example of an unsupported type, which neither is any more; they now use Dictionary<string, Order>, whose value is a contract. Verified: 181 passing on net8.0/9.0/10.0 and 107 on net472, 51 generator tests, no failures, full solution builds clean. No fixture regenerated - all three cases matched the recorded bytes on the first run. Seven cases still skip: a jagged array, DateOnly/TimeOnly, three contracts whose [KnownType] names a method, one with a non-public data member, and one with no [DataContract] at all. Only the first two are capabilities rather than deliberate v1 exclusions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 71 -> 72 of 78, closing Array3. Each outer item of a jagged collection is an array in its own right, written as an element named ArrayOf plus the XSD name of the innermost type - ArrayOfint for int[][] - holding the items themselves in the same Arrays namespace. A null outer array is i:nil like any other missing reference; an empty one is an empty element, which is what the Array3 fixture pins. byte[][] is deliberately not this shape and stays as it was. CollectionElementTypeOf declines byte[], because the serializer treats it as a primitive written as base64 rather than as an array of bytes, so a byte[][] remains a flat collection of base64Binary items with no ArrayOf wrapper. The generator test asserts both halves of that distinction, since getting it wrong would silently restructure a document. Only one level of nesting is supported: the innermost type must be a built-in. Deeper jagging, or an inner array of contracts, still declines. One generator test had used int[][] as its example of an unsupported type, which it no longer is; it now uses List<Order>, whose element is a contract. Verified: 182 passing on net8.0/9.0/10.0 and 107 on net472, 52 generator tests, no failures, full solution builds clean. No fixture regenerated. Six cases still skip, and only one of them is a capability: DateOnly/TimeOnly. The rest are deliberate v1 exclusions or things a generator cannot reach - three contracts whose [KnownType] names a method resolved at run time, one with a non-public data member, and one with no [DataContract] at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 72 of 78 -> 74 of 80. Every case that still skips is now a deliberate
v1 exclusion rather than missing work, so WriteObject is feature-complete for the
corpus.
These two are unlike everything else in the generator: their wire format is a property
of the runtime, not of the contract.
- Up to .NET 9 DataContractSerializer does not recognise them and writes a contract
with no members - an empty element that drops the value entirely, with the System
namespace declared on it.
- .NET 10 writes them as primitives, with no namespace declaration at all.
That it is runtime-determined and not target-framework-determined was verified rather
than assumed: running the net8.0 test assembly under DOTNET_ROLL_FORWARD=LatestMajor
makes the reflection serializer produce the .NET 10 format. So the generated code
tests Environment.Version.Major at run time instead of the generator deciding at
compile time.
The distinction has teeth. With a compile-time decision a net8.0 assembly rolled
forward onto .NET 10 would have emitted the old format while reflection emitted the
new one, and the harness would not have caught it - the generated provider would have
gone on matching its own stale baseline. With the runtime test, that same roll-forward
run fails both providers on exactly the same two cases, which is the fixtures being
keyed to the compile-time framework and not a defect.
The formats come from XmlWriterDelegator: "yyyy-MM-dd" for a date and
"HH:mm:ss.FFFFFFF" for a time, the optional fractional digits being what omits
trailing zeros and the dot rather than padding them.
The upstream DateTimeOnlyWrapper only ever holds default values, so it cannot tell a
dropped value from a zero one. A new SanityDateAndTimeOnly carries real ones, which is
what makes the pre-.NET 10 data loss visible: a DateOnly of 2020-01-02 records as
<a:Date/>. Its fixture is recorded on both net8.0 and net10.0.
Verified: 185 passing on net8.0/9.0/10.0 and 107 on net472, 53 generator tests, no
failures, full solution builds clean. The net8.0 and net10.0 runs exercise the two
branches of the runtime test against their own recorded bytes.
Six cases still skip: three whose [KnownType] names a method resolved at run time, one
with a non-public data member, one with no [DataContract] at all, and SeasonsEnumContainer
which is the same. None is a capability.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ReadObject is the next milestone, not an increment, and the write side succeeded because the oracle came first. This is that oracle, with no generated reader behind it yet. The write side had an obvious reference: the exact bytes the real serializer produced. The read side has no equivalent. Comparing two object graphs would mean an equality rule for all eighty corpus types, and a wrong rule reports success exactly as readily as a right one. So the fixture is the oracle here too, used the other way round: read it with the serializer under test, write the result back out with the reflection serializer, and compare those bytes to the fixture. That composition only reproduces the fixture if the graph that came out of the read is the graph that went into the recording, and it reuses a byte comparison that is already trusted rather than inventing a second notion of correctness. Deliberately not a round trip through the generated writer. A mistake shared by the generated reader and the generated writer - a member skipped by both, a name misspelled the same way twice - would cancel out and pass. Writing back through reflection measures the read against the real implementation instead. The reflection-reads-its-own-fixture theory is not a tautology either: it establishes that every fixture is round-trippable at all, so a later failure on the generated side is a defect in the generator rather than a case whose value the real serializer never recorded. All 79 pass today, including DateTimeOnlyWrapper, whose value the pre-.NET 10 serializer drops on both sides of the trip. Every generated case skips for now, because CanReadObject is still false everywhere. That is the honest starting position: the measurement exists before the thing it measures. Verified: 264 passing on net8.0 and 184 on net472, no failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first ReadObject slice. 14 of the 79 corpus cases now read back through generated code and reproduce their fixture; the rest keep CanReadObject false and fall back. The element walk mirrors ReflectionXmlFormatReader.ReflectionReadMembers, which scans forward only: a member matches an element only if it comes after the last one matched, and an element matching nothing is skipped rather than being an error. That is what makes an absent member keep its default and an unknown element harmless instead of fatal. Value parsing is the inverse of WriteValueStatement and has to stay that way. Two places where it is not symmetrical by accident: char went out as its numeric value so it comes back through an int, and DateTime needs RoundtripKind or the Utc flag the writer recorded is silently dropped on the way in. Readable is deliberately narrower than writable - flat contracts of built-in members, nothing else. Inheritance, nested contracts, collections, polymorphism and IsReference each need the read counterpart of machinery the write side already has, and every one of them is a way to quietly produce a graph that is not the one recorded. A contract outside the slice never claims to read, which is GetSerializer returning null one level down. Two constraints the reflection implementation does not have: a contract needs an accessible parameterless constructor, because generated code cannot allocate without running one, and each member has to be settable. Struct contracts caught a real bug on the first run. VT and PublicDCStruct read the whole document and then discarded it member by member, failing nowhere, because the content reader took the contract by value. Their readers now take it by ref. The oracle is what surfaced this: a round trip through the generated writer would have compared one empty struct against another and passed. Verified: 278 passing on net8.0/9.0/10.0 and 184 on net472, 53 generator tests, no failures, full solution builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second ReadObject slice. 20 of the 79 corpus cases now read back through generated code and reproduce their fixture, up from 14. A nested contract member is the mirror of how it is written: the wire format has no second wrapping element, so the member element is the nested contract's element. Open it, hand the reader to that contract's content reader, close it. A collection accumulates into a List before assigning, and turns it into an array only where the member is one. An empty collection and a null one are different documents and stay different: an empty element yields an empty collection, and only i:nil yields null. Getting that backwards is invisible until something round-trips, which is the kind of mistake this harness exists to catch. Readability is now a graph question rather than a per-member one, so it is computed with memoisation. A contract that reaches itself - a linked list being the ordinary case - is assumed readable while its own answer is being computed, which is the right answer because the recursion terminates at run time on a nil or empty element rather than statically. Polymorphic members stay unreadable on purpose. Resolving an i:type back to a type is a different problem from announcing one, and a member that may hold more than its declared contract would otherwise read as its declared type and silently lose the derived members. Verified: 284 passing on net8.0/9.0/10.0 and 184 on net472, 53 generator tests, no failures, full solution builds clean. Still unreadable: inheritance, IsReference, enums, dictionaries, boxed and polymorphic members, and the format-specific kinds - DateTimeOffset, QName, DateOnly, TimeOnly. IsReference is the one that needs more than an inverse: a z:Ref can point at an object the reader has not reached yet, so it needs a fixup pass rather than a straight parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third ReadObject slice. 29 of the 79 corpus cases now read back through generated code and reproduce their fixture, up from 20. Reading a base chain is not the writer run backwards. The writer recurses, one call per level, because writing is unconditional. A reader cannot: it is a single loop over a single monotonically advancing index, and a per-level loop would let the base's own loop reach a derived member, fail to recognise it and skip it in silence - the members would vanish and the read would report success. Upstream draws the same distinction, so the generator flattens the chain base-first the way ReflectionGetMembers does. An inherited member keeps the namespace of the contract that declares it rather than the derived contract's. That is not an inference from the fixtures: ClassDataContract builds MemberNamespaces by Array.Copy from the base before appending its own entries. A contract that names a descendant in its [KnownType] closure stays unreadable. A member typed as it declines already through its Candidates, but the root of a document has no member to carry that, and reading a derived instance through the base's reader would drop every member the derived contract adds. Merely having a descendant is not enough to decline: one the contract never names is one the reflection reader would refuse outright, so declining for it would cost coverage and buy no safety. The generator's own tests no longer target net472. They compile the emitted code, and the emitter is entitled to DateOnly, TimeOnly and ReferenceEqualityComparer because CoreWCF.DataContractSerialization.targets gates the generator off below net8.0 - so compiling its output against net472 reference assemblies was testing a combination that cannot occur, and 44 of the 53 failed there. That the generator contributes nothing on net472 is verified where it is observable: CoreWCF.DataContractSerialization.Tests still runs there and reports every case unsupported. Verified: 293 passing on net8.0/9.0/10.0 and 184 on net472, 55 generator tests, no failures, full solution builds clean. Still unreadable: IsReference, enums, dictionaries, boxed and polymorphic members, and the format-specific kinds - DateTimeOffset, QName, DateOnly, TimeOnly. IsReference is the one that needs more than an inverse: a z:Ref can point at an object the reader has not reached yet, so it needs a fixup pass rather than a straight parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fourth ReadObject slice. 37 of the 81 corpus cases now read back through generated code and reproduce their fixture, up from 29 of 79. An enum comes back through its own name table rather than Enum.Parse, which would accept names the contract never declared and numbers it never wrote. Mirroring EnumDataContract.ReadEnumValue: a flags enum is a space-separated list and an empty one is legal, since that is how a zero value is written when no member names it, while for a non-flags enum the empty string is an error rather than zero. A name has to match in full - comparing only the first count characters would accept a truncated name and return the wrong member - and an unrecognised one always throws. A dictionary entry is an ordinary two-member contract on the wire, so it is matched the way every other one is: forward only, each part at most once, against the entry and part names the writer already proved against a fixture. Two corpus cases were added rather than trusting the code. One populated Dictionary<string, string> reaches none of the branches that matter, so SanityDictionaries pins an empty map, a missing one, an entry whose Value carries i:nil, a base64 value and a non-string key in one document; SanityEnumCollections pins a flags enum inside a collection, which writes "Beta Gamma" and has to come back as both members. A branch no fixture exercises is a branch that is not verified, however carefully it was written. Also closes a gap that predates this slice: a byte[] member declined as unreadable even though the reader has handled base64 all along, because the readability test asked for a text expression and base64 has none. That branch was dead code. SanityPrimitives and SanityPrimitiveArrays read as a result. Verified: 307 passing on net8.0/9.0/10.0 and 188 on net472, 57 generator tests, no failures, full solution builds clean. On the write side 76 of 81 cases byte-match and the five skips are the same deliberate exclusions as before. Still unreadable: IsReference, boxed and polymorphic members, and DateTimeOffset, QName, DateOnly and TimeOnly. IsReference remains the one that needs more than an inverse: a z:Ref can point at an object the reader has not reached yet, so it needs a fixup pass rather than a straight parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fifth ReadObject slice. 39 of the 82 corpus cases now read back through generated code and reproduce their fixture, up from 37 of 81. Neither of these is read from text. A DateTimeOffset is a two-member contract rather than a value, and its inverse follows DateTimeOffsetAdapter.GetDateTimeOffset: an Unspecified DateTime is paired with the offset, anything else is converted to it. The writer recorded UtcDateTime and the offset separately rather than a local time, so treating both cases alike would shift every value that carries an offset by that offset. A QName has to resolve a prefix against an element that is still open. The prefix is declared on the member element and nowhere else, so reading the text and the end tag in one call - ReadElementContentAsString - pops the scope that defines it and leaves nothing to resolve against. XmlReaderDelegator.ReadElementContentAsQName splits the read into start, content and end for exactly that reason, and the generated reader splits it the same way. The empty name is a shape of its own again: the writer emits no content for it, so it comes back as an empty element rather than an empty string that parses the same way. SanityQualifiedNames was added because the only QName in the corpus lives in AllTypes, which is unreadable for unrelated reasons - and it immediately failed on the write side, which is the useful kind of failure. A non-null XmlQualifiedName member element carries a prefix of its own, as NeedsPrefix in ReflectionXmlFormatWriter says, but a null one does not: the prefix belongs to the path that writes a value, and a null member is written by WriteNull, which opens the element with whatever prefix is already bound. The generator had been applying it unconditionally, and AllTypes never caught it because its QName is non-null. The two halves of the rule are now pinned by two different fixtures. The three read paths that differed only in how one element yields one value - a member, a collection item, half a dictionary entry - are now one method, since this slice would have added a fourth and fifth copy. That also fixes a latent bug in the collection path, which built its assignment as a conditional expression: in x = c ? default : e the conditional takes its type from e, so a nil element in a List<int?> would have come back as 0 rather than null. Verified: 312 passing on net8.0/9.0/10.0 and 190 on net472, 60 generator tests, no failures, full solution builds clean. On the write side 77 of 82 cases byte-match and the five skips are the same deliberate exclusions as before. Still unreadable: IsReference, boxed and polymorphic members, and DateOnly/TimeOnly. IsReference remains the one that needs more than an inverse: a z:Ref can point at an object the reader has not reached yet, so it needs a fixup pass rather than a straight parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sixth ReadObject slice. 41 of the 82 corpus cases now read back through generated code and reproduce their fixture, up from 39. What remains unreadable is object identity and polymorphism, and nothing else. These are the one format decided by the runtime rather than by the contract, so the reader carries the same Environment.Version.Major >= 10 test the writer does. Before .NET 10 the serializer does not know what these types are and writes a contract with no members - an empty element that drops the value. Reading default there is not a fallback: it is what the recorded document says, and it is what the reflection-based reader produces from the same bytes, which is what keeps the round-trip exact. The two parses are not symmetric, and following upstream closely matters. DateOnly goes through ParseExact with AllowLeadingWhite | AllowTrailingWhite. TimeOnly does not: ReadElementContentAsTimeOnly goes through XmlConvert.ToDateTimeOffset and takes TimeOfDay from the result. XmlReaderDelegator also defines a ParseTimeOnly that mirrors the DateOnly one and never calls it - copying that helper instead of the code actually on the path would have narrowed what this reader accepts, rejecting a Z or an offset the real one takes. Because the branch is a run-time test rather than a compile-time one, one test verifies both halves: SanityDateAndTimeOnly round-trips on net8.0 and net9.0 against the lost-value fixture and on net10.0 against the primitive one. Verified: 314 passing on net8.0/9.0/10.0 and 190 on net472, 61 generator tests, no failures, full solution builds clean. Still unreadable: IsReference, and boxed and polymorphic members. Both need machinery rather than an inverse - resolving an i:type back to a type without reflection, and a fixup pass for a z:Ref that points at an object the reader has not reached yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the root The seventh ReadObject slice, and it turned up a write-side bug. 45 of the 85 corpus cases now read back through generated code and reproduce their fixture, up from 41 of 82. Announcing an i:type is a switch on the runtime type. Resolving one is a lookup by name, and the candidate set is the compile-time known-types closure - so what the serializer does through reflection is a chain of string comparisons here. Following XmlObjectSerializerReadContext: no i:type means the declared contract, so the absent name and the declared name share one branch; and a name that resolves to nothing throws rather than falling back, because falling back would produce an instance missing every member the derived contract adds and report success. The attribute is read while the reader still sits on the element, since the prefix it uses is declared there - the same constraint a QName has. A root has no member to carry that decision, so it makes it itself. Adding the read side exposed that the write side never had one: WriteObject cast the graph to the declared contract, so a service returning a derived instance through a base-typed contract wrote only the base members, with no i:type and no error. That is the silent-data-loss failure this project exists to prevent, and it was on the write path independently of any of the read work. Three corpus cases pin it, all byte-exact on the first run. SanityBase.derived-instance records what a root actually emits - i:type ahead of the namespace declarations, reusing the prefix already bound to the contract namespace rather than declaring a second one, which is where a root differs from a member element. SanityPolymorphic covers the member side with all four shapes at once, because a single derived instance cannot tell "resolved the name" from "took the only branch": a declared instance carrying no i:type, two different derived types carrying different ones, and a null carrying none. The readability rule changes shape as a result. A contract that names a descendant used to decline outright; now it reads, provided every descendant it names is itself readable. Boxed members stay write-only. Their candidates are not all contracts - a primitive in an object member is announced by an XSD name with no contract behind it - so resolving one needs a second table keyed by those names, the inverse of the BoxedPrimitives table the writer uses. Verified: 327 passing on net8.0/9.0/10.0 and 196 on net472, 62 generator tests, no failures, full solution builds clean. On the write side 80 of 85 cases byte-match and the five skips are the same deliberate exclusions as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The eighth ReadObject slice. 49 of the 85 corpus cases now read back through generated code and reproduce their fixture, up from 45 - including AllTypes and AllTypes2, the two widest contracts in the corpus. A polymorphic member's candidates are all contracts, so resolving a name means finding a contract. A boxed member's may be a primitive announced by an XSD name with no contract behind it, so it needs the inverse of the writer's BoxedPrimitives table. Three things that table settles and the inverse has to preserve: "byte" is sbyte while "unsignedByte" is byte, the pair most likely to be quietly corrected into agreeing with the CLR names; char, Guid and TimeSpan are named in the serialization namespace because XML Schema has nothing to call them; and a bare object carries neither i:type nor content, so there is nothing to recover but the fact that something was there. What the declared type buys is a check. A member declared ValueType cannot hold a string, so an i:type naming one is refused with a SerializationException rather than surfacing later as an InvalidCastException out of generated code. Enum and Array members admit no primitive at all, so an unmatched name there never reaches the XSD table. Array is the one shape that is not a value: the only array the writer emits is object[], and it announces no i:type - each item carries its own. The untyped item reader that serves it is the inverse of WriteAnyType, and it is the same one a collection of anyType items uses. Two gaps remain that are neither boxed nor object identity, and both are collection containers rather than values. A jagged int[][] reads its items through the ordinary collection path, which has no case for an item that is itself a collection. An ArrayList's items read - they are untyped values like any other - but the container does not, because the collection reader accumulates into a List<T> and assigns it or its ToArray(), and an ArrayList is neither. Verified: 331 passing on net8.0/9.0/10.0 and 196 on net472, 64 generator tests, no failures, full solution builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ninth ReadObject slice, and the last capability before object identity. 51 of the 86
corpus cases now read back through generated code and reproduce their fixture, up from 49
of 85.
Reading a sequence is the same loop every time - fill a container from the children of an
element, then assign it - and the containers are what differ: an array accumulates into a
List<T> and calls ToArray, a List<T> is assigned as it stands, and an ArrayList is its own
container. That last one is the only one a read cannot infer from the items, which is why
the spec now records it: writing one needs nothing but foreach, so the write side never had
to know. The three read paths that had grown separate copies of that loop are now one
method.
A jagged collection is the same loop nested. The ArrayOf element is the inner collection's
own element - there is no second wrapper - so an item read is that loop one level down over
the innermost items. A null inner row is i:nil like any other missing reference, which makes
{ {1,2}, {}, null } three different documents rather than two.
SanityUntypedCollections exists because the upstream cases covering these two containers are
exactly the two that cannot be read at all: ArrayContainer declares ArrayContainer(bool) and
so suppresses the implicit parameterless constructor, and Array3 has no null inner row.
Without a case of its own the container code would have shipped with no fixture exercising
it, which is the mistake this harness is here to prevent.
That leaves the one place where the generated path is strictly less capable than reflection.
DataContractSerializer allocates without running a constructor; generated code cannot, so
ArrayContainer and PrivateCstor are written byte-exactly and never read. It fails by
declining rather than by producing a wrong graph, so the caller keeps the reflection-based
serializer and nothing is lost but the optimization.
Verified: 336 passing on net8.0/9.0/10.0 and 198 on net472, 66 generator tests, no failures,
full solution builds clean. Every remaining read skip is IsReference or one of those two
constructors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last read capability. 79 of the 86 corpus cases now read back through generated code and reproduce their fixture, up from 51. Five of the seven that skip have no generated serializer at all - the write-side exclusions - and the other two have no accessible parameterless constructor. Read coverage is now write coverage minus exactly those two. I had this wrong in five previous commit messages, so it is worth stating plainly: a z:Ref never points forward. The writer assigns an id the first time it writes an instance, so the element carrying z:Id is written, and therefore read, before any reference to it. There is nothing to defer and no fixup pass to run. Upstream is unambiguous - GetExistingObject throws DeserializedObjectWithIdNotFound on an id it has not seen rather than recording it for later, and the comment beside it says so: BinaryFormatter supports fixing up such references later, these XmlObjectSerializer implementations do not, hence we throw. What makes a cycle work is not patching up afterwards but when the instance is recorded. It goes into the table between construction and the first member read, because an instance inside its own graph is referred to while it is still being filled in - that is what a cycle is - so recording it after its members would turn that reference into a lookup that fails. Upstream calls AddNewObject before deserializing members for the same reason. So the reader's scope is much the smaller half of the pair: one Dictionary<string, object>, allocated on first use, one per ReadObject call so nothing survives between documents. The writer's has to assign ids, track by reference identity and guard by-value recursion; this one only has to remember. SanityReferenceNode.cycle proves it byte-for-byte, since writing the recovered graph back out reproduces the same z:Id and z:Ref pattern only if the identities match. A byte comparison standing in for an object-identity claim is worth backing up once, so CyclicGraphTests now also asserts it directly - including the reference that could only come from the table, where the second node points back at the first while the first is still being read. Verified: 365 passing on net8.0/9.0/10.0 and 198 on net472, 67 generator tests, no failures, full solution builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both halves, because migrating a codebase contract by contract needs both: the fallback still happens, and it is no longer silent. Nothing about the fallback changes. GetSerializer still returns null, CanReadObject stays false, CoreWCF still uses the reflection-based DataContractSerializer, and a build that would have worked still works. The warnings are diagnostics and nothing else - severity Warning rather than Error, so NoWarn and EditorConfig work as usual for anyone who has looked at one and accepted it. Reporting them at all is a change of mind, and the file that recorded the old decision now records why. While the generated path was purely an optimization, silence was right: nothing was lost but speed. It is not only an optimization under Native AOT, where the switch defaults on precisely because the reflection path is the broken one - there a silent fallback is a build that looks clean and throws at run time. COREWCF_0403 fires when no serializer was generated for a listed contract. COREWCF_0404 fires when one is written by generated code but not read by it, which is worth its own id because it is half a fallback: a service that only returns the contract is unaffected while one that accepts it is not. Both are reported on the type the user listed rather than on every contract reachable from it. A nested contract that cannot be written makes its container unsupported too, so reporting both would bury the one line they can act on under a cascade of consequences. The reason text carries the cause instead, nested as far down as it goes. That reason is why IsReadable became a function returning a string rather than a bool: "it cannot be read" is not something anyone can act on, and the answer is now shown to a user rather than only to the emitter. It is computed by the emitter rather than the parser because readability is a property of the whole contract graph. The corpus reports seven of them, which is exactly its skip list: five contracts with no generated serializer and two whose constructor generated code cannot call. Verified: 365 passing on net8.0/9.0/10.0 and 198 on net472, 69 generator tests, no failures, full solution builds clean with 0 errors. While checking how CanReadObject is consumed I found that PartInfo.ReadObject never asks for the generated serializer - it goes straight to Serializer, so every read runs through reflection today whatever the generator produced. The read half is built and verified against the corpus but is not reachable from CoreWCF yet. Recorded as risk 5a; closing it is one branch in PartInfo mirroring the one WriteObject already has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the gap found while wiring the fallback warnings: PartInfo preferred the generated serializer for writing but never for reading, so every read ran through reflection whatever the generator produced. Two call paths needed it rather than one. ReadObject was the obvious half. IsStartObject was not: it was being asked of part.Serializer at three call sites, and that property constructs the reflection-based serializer, which is the thing the generated path exists to avoid. Both now resolve through a single ReadingSerializer property, and that is not tidiness - the two have to agree. A part recognised by one serializer and then read by the other is a part read by something that did not recognise it. Reading stays gated on CanReadObject, so the two directions remain independent and a contract can be written by generated code while still being read by reflection. That is the same shape the COREWCF_0404 warning reports, and it is what makes migrating one contract at a time possible. The two-argument ReadObject overload is left as it is. It ignores its serializer parameter and uses the field, which reads like a bug, but it predates this work - it came over with the WCF port - and it is unreachable within the repo, so changing it belongs to whoever looks at it deliberately rather than to this commit. The seam had no test in either direction before this. The switch defaults off, so nothing in the suite had ever taken the generated branch, and both the write wiring and now the read wiring went in unexercised. PartInfoSerializerSelectionTests covers the choice by planting a recording serializer on the part rather than by turning the switch on: the switch resolves into a process-wide Lazy that would leak into a concurrently running suite, and its own resolution order is already covered by GeneratedSerializerSwitchTests. Verified: 155 passing in CoreWCF.Primitives.Tests with the switch never set, which is the gate that matters - the default path has to be untouched. Plus 365 on net8.0/9.0/10.0 and 198 on net472 in the serialization tests, and the full solution builds with 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nproven AOT premise The diagnostic question is answered by COREWCF_0403 and 0404: both, not either. The larger one is not answered at all. Everything in the document is verified under a normal runtime, byte for byte against fixtures - and the premise the work exists for, that this runs under Native AOT, has never been executed. There is no PublishAot or trimming infrastructure anywhere in the repository to execute it with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ll it Everything in this area was verified under a normal runtime, where dynamic code is available and nothing is trimmed. The premise the work exists for was not verified at all, and there was no AOT or trimming infrastructure anywhere in the repository to verify it with. This publishes a real service with PublishAot and calls it over HTTP. It passes. The generated serializer writes and reads the graph with IsDynamicCodeSupported false, and the service answers over HTTP with contents that match. The stages are separate on purpose. Serialization is the last link in a long chain, and a failure in the host or the dispatcher would otherwise read as "AOT does not work" without saying what does. The client is a raw HttpClient posting a hand-written envelope, because System.ServiceModel does not support AOT either and a failure there would say nothing about the service. Three findings that were not visible from the unit tests. The serializer this replaces loses data silently rather than loudly. Given the same contract in the same binary, the reflection-based DataContractSerializer fails - and how it fails is the finding. Before the contract types were rooted it did not throw at all: it wrote a document a quarter of the size, a graph missing most of its members, returned as though nothing were wrong. With the types rooted it throws NullReferenceException instead. Silent truncation is the worse of the two and it is what an AOT app would have got, which is the concrete form of the argument for warning on a fallback rather than falling back quietly. CoreWCF needs the contract rooted before the trimmer will keep it. TypeLoader finds operations by reflecting over the contract interface, and nothing calls those methods statically - the whole point of a dispatcher is that the call is dynamic. Without a [DynamicDependency] the interface arrives with zero operations and the host refuses to start. That is an annotation gap in CoreWCF rather than something an application should have to know; it is worked around here so the stages after it can be reached. A warning-free publish is a long way off: 339 trim and AOT warnings, essentially all from CoreWCF rather than from this area - 207 IL3050, 72 IL2026, 56 assorted reflection-pattern warnings, and 4 IL3054 for generic recursion aborted in the message filter tables, which throws if that path is ever reached. They come from the security stack, the channel proxy and the dispatcher. So a service runs under AOT for this shape without the guarantees a clean publish would give. Serialization is one of those warnings addressed; the rest are risk 5. One thing the app itself taught: an earlier stage read GeneratedSerializerSwitch by name and got a TypeLoadException, because ILC keeps the code but not the reflection metadata for an internal type nothing looks up by name. Correct behaviour, and a fair warning about probing internals in a trimmed app - so the stage now measures the consequence instead. Not part of dotnet test: it proves something only once published, which is a separate command. The README beside it says how, including that Windows needs vswhere.exe on PATH for the ILCompiler to find the MSVC linker. Verified: the published binary exits 0 with all five stages passing, and the full solution still builds with 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Generators The emitted code was asserted a line at a time and never shown. That is right for pinning a rule and wrong for seeing the shape of the whole thing: nobody reviewing a change to this generator can hold six hundred lines of output in their head from a diff of the emitter. Five snapshots put the generated code in the repository, where it can be read and where a change to it turns up in the diff of the pull request that caused it. The assertions stay. They answer different questions and neither substitutes for the other. An assertion says why a rule exists - that a QName resolves its prefix before the element closes - and survives reformatting. A snapshot says what was actually emitted, in full. A snapshot that changed tells you something moved but not whether it should have; an assertion passing tells you one line is right and nothing about the other six hundred. And neither says the output is correct: only the golden-record corpus does that, by comparing bytes against the real serializer. Five cases rather than one per feature. Each snapshot carries the shared helpers as well as its own contract's code, so they overlap heavily and a sixth would mostly repeat the first. These cover the structurally distinct shapes: a flat contract, inheritance with i:type in both directions, the container shapes, an IsReference graph, and two contracts that fall back - the last one because Verify renders diagnostics beside sources, which makes it the one place the wording of COREWCF_0403 and COREWCF_0404 is visible as a user reads it. One snapshot serves all three target frameworks. Verify names the received file per framework so concurrent runs cannot collide, but they are compared against the same verified file, which is the right arrangement: the generator's output is a function of the contract it is given rather than of the runtime the test host is on. Confirmed by diffing net8.0 against net10.0 before relying on it, and now re-checked on every run - three frameworks disagreeing about a snapshot is exactly the signal worth having. Two pieces of housekeeping that snapshots need to survive in a repository. The verified files are pinned to LF in .gitattributes: left to the `text=auto` default a checkout would rewrite their line endings and every one of them would fail on the machine that did it. And *.received.* is ignored, since it is the candidate rather than the record. Verify pulls a newer Microsoft.Bcl.AsyncInterfaces than the repository pins centrally, overridden in this test project rather than raised for everyone: the pin governs what the shipping libraries carry. Verified: 74 passing on net8.0/9.0/10.0, up from 69, no failures, full solution builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
✅ Test & Coverage Report
Line coverage: 35.9% · Branch coverage: 36.4% 📋 Detailed test report · 📈 Coverage report Updated for commit |
…sts in the matrix Two CI failures on PR CoreWCF#1766, both from target frameworks I never ran locally. net8.0, net9.0 and net10.0 were green on Windows and Linux throughout. net11.0 failed because two fixtures were missing, not because anything was wrong. The tell is that ReflectionGoldenRecordTests failed too - the oracle itself. On net11.0 the serializer writes DateOnly and TimeOnly as primitives, as it does on net10.0, while the baseline fixture holds the pre-.NET-10 shape that drops the value. Recorded on a net11.0 SDK: exactly two cases diverge, SanityDateAndTimeOnly and DateTimeOnlyWrapper, and both match the net10.0 shape. The csproj already carried the Fixtures\net11.0 slot; it was empty. Nothing in the generator needed changing for it. The DateOnly branch is a run-time test of Environment.Version rather than a compile-time one, which is what makes it right on a runtime that did not exist when it was written. net472 failed for a reason that had nothing to do with the tests: every one of them passed. CI runs `dotnet test <project> -f <tfm>` for every CoreWCF.*.Tests.csproj in each matrix framework, with no per-project filter, so a project that does not produce a net472 assembly fails the job with "The test source file ... was not found". The generator tests stopped producing one when they dropped net472, which they did because the emitted code is entitled to net6+ types and cannot be compiled against net472 reference assemblies. CoreWCF.Kafka.Tests already solves this, and its DummyTest.cs even describes the failure mode: build the project for every framework, but on net472 compile nothing except a single skipped test, because xunit.v3 fails a run whose assembly contains none. Same pattern here, with the reason spelled out where the next person will hit it. The net472 assembly is back and reports one skipped test. Verified: 365 passing on net8.0/9.0/10.0/net11.0 and 198 on net472 in the serialization tests; 74 on net8.0/9.0/10.0 in the generator tests with net472 reporting its dummy; full solution builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An AOT-safe, source-generated DataContractSerializer
Draft. Not ready to merge — see Open decisions for the calls that need a maintainer.
DataContractSerializercannot run underPublishAot: it generates IL at run time and reflects overmembers a trimmer has no reason to keep. Every CoreWCF service that carries a data contract inherits
that. This branch adds a Roslyn generator that reads
[DataContract]/[DataMember]at compile timeand emits a reflection-free serializer per contract, plus the seam CoreWCF needs to use one.
It is an optimization with a fallback, not a replacement. With the switch off, not one byte of
CoreWCF's behaviour changes:
GetSerializerreturns null, the formatter uses the reflection-basedserializer, and the 155 existing
CoreWCF.Primitivestests pass unchanged. Contracts migrate one ata time.
The premise is executed rather than assumed: a CoreWCF service published with Native AOT answers over
HTTP, and the serializer it replaces fails on the same contract in the same binary.
Contents
Scope
In: the operation body path. A service's parameters and return values are serialized and
deserialized by generated code when a context covers them.
Out, deliberately: everything else that constructs a
DataContractSerializerdirectly —PrimitiveOperationFormatter,FaultContractInfo,Message/MessageFault/MessageHeader(s)/AddressHeader, and the WS-Trust and secure-conversation serializers. Those are listed underStill to do; none of them is blocked by anything here.
Out, permanently: contracts with no accessible parameterless constructor.
DataContractSerializerallocates without running one and generated code cannot. These are writtenbyte-exactly and never read — the one place the generated path is strictly less capable than
reflection. It declines rather than producing a wrong graph.
What works
WriteObjectReadObjectThe five write-side skips are deliberate exclusions: three contracts whose
[KnownType]names amethod resolved at run time, one with a non-public data member, one with no
[DataContract]at all.Capabilities covered, each verified byte-for-byte:
OrderEmitDefaultValue,IsRequiredXmlWriterDelegatorhandlesi:nil,Nullable<T>[Flags]and[EnumMember]List<T>, jagged arrays,ArrayListDictionary<K,V>IsReference—z:Id/z:Ref, cycles[KnownType]andi:typepolymorphismobject,ValueType,Enum,Array)[Serializable],Uri,DateTimeOffset,XmlQualifiedNameDateOnly/TimeOnly, whose format the runtime decidesVerification
CoreWCF.DataContractSerialization.TestsCoreWCF.DataContractSerialization.Generator.TestsCoreWCF.Primitives.Tests(switch never set)CoreWCF.slnnet472runs the same suite and reports every case unsupported rather than silently passing, whichis a distinct outcome worth asserting: a pass because nothing ran looks identical to a pass because
everything matched.
How the generator is built
The pipeline
Parseris the only code that touches anISymbol, and it runs insidetransform— beforeCollect()— so its result is a plain value Roslyn can compare and cache.Emitternever sees asymbol or a
Compilation.This is a deliberate divergence from
CoreWCF.BuildTools, noted in a comment where it matters.Those generators hold raw
ISymbolreferences in their specs and build them insideRegisterSourceOutput, which defeats incremental caching and re-runs on every keystroke. The cost ofgetting this wrong is build time, silently — it fails no test.
Two consequences of that rule shape the code:
EquatableArray<T>wraps every collection on a spec. Arrays compare by reference, so a specholding a bare
T[]never equals its predecessor and the cache never hits.DiagnosticInfo/LocationInfocarry a diagnostic as values. ADiagnosticholds aLocation, which holds aSyntaxTree, which roots an entireCompilation; putting one in a cachedmodel would both defeat caching and keep compilations alive. The real diagnostic is built at the
point of reporting.
Layout
Five files mirroring the house style of
CoreWCF.BuildTools, ~5,400 lines:…Generator.Emitter.csStringBuilder→AddSource…Generator.Parser.cs…Generator.ContractSpec.csMemberKind…Generator.csEquatableArray.cs,DiagnosticInfo.cs,DiagnosticDescriptors.cs,Indentor.csWhat gets emitted
One partial fill-in for the user's context class, containing:
WriteNil,IsNil,ReadText,ReadXsiType,ReadQName,WriteEnum/ReadEnum, theDateTimeOffsetadapter, the boxed-primitive table and its inverse;__ReferenceScope(object → id, plus the by-value cycleguard) and
__ReadScope(id → object), one instance perWriteObject/ReadObjectcall so nothingsurvives between documents;
the mirror of how the wire format nests;
AotXmlObjectSerializer.Three levels of falling back, all returning null or false
GetSerializerreturns nullCreateAotSerializerreturns nullCanReadObjectis falseNone of them throws, and each is checked before use, so a contract the generator does not handle is
simply served by the reflection-based serializer. That is what makes step-by-step migration possible.
Since falling back under AOT means a runtime failure rather than a lost optimization, each one is
also a build warning —
COREWCF_0403(nothing generated) andCOREWCF_0404(written but not read),both
Warningseverity and suppressible. They carry the reason, nested as far as it goes:Reported on the type the user listed, not on every contract reachable from it — a nested contract
that cannot be written makes its container unsupported too, and reporting both buries the actionable
line under its consequences.
The generated code is checked in
Five snapshots, taken with
Verify.SourceGenerators, put ~4,600 lines of emitted code in therepository under
…Generator/tests/Snapshots/. Nobody reviewing a change to this generator can holdsix hundred lines of output in their head from a diff of the emitter; here they read the output, and
a change to it turns up in the diff of the pull request that caused it.
The line-by-line assertions stay, because the two answer different questions. An assertion says why
a rule exists — that a QName resolves its prefix before the element closes — and survives
reformatting. A snapshot says what was actually emitted, in full. A changed snapshot tells you
something moved but not whether it should have; a passing assertion tells you one line is right and
nothing about the other six hundred. Neither says the output is correct — only the corpus does
that, by comparing bytes against the real serializer.
Five cases rather than one per feature: each snapshot carries the shared helpers as well as its own
contract's code, so they overlap heavily and a sixth would mostly repeat the first. One of them is a
pair of contracts that fall back, because Verify renders diagnostics beside sources — making it the
one place the wording of
COREWCF_0403/0404is visible as a user reads it.One snapshot serves all three target frameworks. Verify names the
.received.file per framework soconcurrent runs cannot collide, but they are compared against the same
.verified.file, which isthe right arrangement: the generator's output is a function of the contract it is given rather than
of the runtime the test host is on. Every run re-checks that claim.
Gated by target framework
CoreWCF.DataContractSerialization.props/.targetsship underbuild/buildTransitiveand enablethe generator on net8.0+ only, mirroring how
CoreWCF.Primitives.targetsgates the OperationInvokergenerator. Two things follow: emitted code may assume a modern language version rather than the
oldest any consumer compiles with, and
GetSerializerisvirtualreturning null rather thanabstract, so a user'spartial class MyContext : DataContractSerializerContextstill compiles onnet472 where the generator never runs — no
#ifin user code.The seam
XmlObjectSerializercannot be subclassed cleanly: all 16 members carry[RequiresDynamicCode]and[RequiresUnreferencedCode], IL2046/IL3051 are symmetric so an override must repeat them, and CoreWCFcalls through the base-typed reference anyway. A reflection-free subclass would make serialization
work under AOT without silencing one warning.
The System.Text.Json answer applies: STJ never un-annotated
Serialize<T>(T), it addedSerialize(T, JsonTypeInfo<T>)alongside. So this branch addsAotXmlObjectSerializer— anattribute-free abstraction in
CoreWCF.Primitivescarrying only the four members the formatter uses —and
DataContractSerializerOperationBehavior.CreateAotSerializerbesideCreateSerializer.PartInfoprefers it forWriteObject, and forIsStartObject/ReadObjectwhenCanReadObject.Suppressing on CoreWCF's call sites was rejected: at that point CoreWCF cannot know whether the
instance is generated or a real
DataContractSerializer, so the suppression would be unsound andwould turn build warnings into runtime failures in someone else's app.
How it was verified
Two disciplines did most of the work, and both are worth keeping.
An oracle, compared byte for byte
86 corpus cases — 75 imported from the WCF test corpus plus 11 written for shapes it does not
reach — each with its exact serialized bytes recorded from the real
DataContractSerializer.Generated output is compared to those bytes, never semantically.
That matters because the hard part is not the element tree, it is the namespace machinery.
xmlns:zsits on the root when the root contract is
IsReferencebut is redeclared on every element when onlya nested member is;
z:Idprecedes thexmlnsdeclarations in attribute order; a collection memberdeclares
xmlns:bon the member element rather than the root. A semantic XML diff calls all of thoseequal.
Per-TFM overrides record where runtimes genuinely differ —
DateOnlywrites an empty element before.NET 10 and a value from .NET 10 on, so one test verifies both halves by running on both.
Fixtures are never regenerated to make the generator pass. A mismatch means the generator is
wrong.
The read side has its own oracle: read with generated code, write back with the reflection
serializer, compare to the fixture. A read bug shows up as a byte difference. It caught struct
contracts being read into a copy —
refwas missing — which failed nowhere and lost every member.Read upstream before implementing
System.Private.DataContractSerializationis MIT-licensed source. Reading it first, and citing thefile wherever a non-obvious rule is implemented, repeatedly beat inference:
ReadElementContentAsTimeOnlygoes throughXmlConvert.ToDateTimeOffset, not theParseTimeOnlyhelper defined next to it — which is dead code. Copying the helper would havenarrowed what the reader accepts.
z:Refnever points forward, soIsReferenceneeds an id table and not a fixup pass. Upstreamsays so outright: "BinaryFormatter supports this by fixing up such references later. These
XmlObjectSerializer implementations do not currently support fix-ups. Hence we throw." What makes
a cycle work is recording the instance before reading its members.
Order = 0, comparedordinally — settled by
ClassDataContract.DataMemberComparerrather than by guessing.What that combination caught
A sample, all of which passed casual inspection:
sbyteis"byte"on the wire whilebyteis"unsignedByte";char,GuidandTimeSpanarenamed in the serialization namespace because XML Schema has nothing to call them.
writer.WriteValue(ulong)is ambiguous, not missing — CS0121 — so it surfaced as a compilererror in generated code rather than as wrong output.
XmlQualifiedNamemember element does not get theq:prefix a non-null one does,because the prefix belongs to the path that writes a value. This was a live write-side bug found by
a fixture added for the read side.
derived member with no
i:typeand no error — a silent-data-loss bug on the write path, found bybuilding the read side.
What the AOT smoke test found
src/CoreWCF.DataContractSerialization.AotSmokeTestpublishes a real service withPublishAotandcalls it with a raw
HttpClientover a hand-written envelope.System.ServiceModelis not usable asthe client — it does not support AOT either, so a failure there would say nothing about the service.
It is not part of
dotnet test; its README says how to run it.Three findings the unit tests could not have shown.
The serializer this replaces loses data silently, not loudly. Same contract, same binary. Before
the contract types were rooted with
[DynamicDependency],DataContractSerializerdid not throw — itwrote 95 bytes where the generated one wrote 436: a graph missing most of its members, returned as
though nothing were wrong. That is the concrete argument for warning on a fallback.
CoreWCF needs contracts rooted for the trimmer.
TypeLoaderfinds operations by reflecting overthe contract interface and nothing calls those methods statically, so without
[DynamicDependency]the interface arrives with zero operations and the host refuses to start. An annotation gap in
CoreWCF, not something an application should have to know.
A warning-free publish is far off: 339 trim/AOT warnings, essentially all from CoreWCF — 207
IL3050, 72 IL2026, 56 assorted reflection patterns, and 4 IL3054 for generic recursion aborted in the
message filter tables, which throws if reached. They come from the security stack, the channel proxy
and the dispatcher. A service runs under AOT for this shape; it does so without the guarantees a
clean publish would give.
Still to do
The rest of the seam.
CreateSerializeris not a complete injection point. Even with a perfectgenerator, CoreWCF still constructs a reflection-based serializer directly in
PrimitiveOperationFormatter(selected instead of the DataContract formatter for simple contracts,so the simplest services never reach any of this),
FaultContractInfo,DeserializeHeaderContents,the
Message/MessageFault/MessageHeader(s)/AddressHeaderfamily, and the WS-Trust and SCT tokenserializers.
CoreWCF's own trim annotations. The 339 warnings above, and the
TypeLoaderrooting gap.Two behavioural differences from reflection, both recorded in the risk register:
objectmember holding a collection throws where reflection succeeds — it cannot be a fallback,because by the time the runtime type is known the element is already open;
Two things nothing verifies. Incremental caching — the whole spec design exists to keep it
working and there is no test; it fails by costing build time. And
CoversKnownTypesis exercised by agenerator test but not through
GeneratedDataContractSerializerOperationBehavior.Open decisions
These want a maintainer's call, not more code.
Public API names.
AotXmlObjectSerializer,DataContractSerializerContext,DataContractSerializableAttributebecome public API on first release. There is no public-APIbaseline in this repo, so nothing stops a rename now and nothing forgives one later.
Aotin atype name may not age well — the property is "needs no dynamic code", which is also true under
plain trimming.
Where the user's context lives. Today it sits in the test project, so a generator bug cannot
break the corpus build that the reflection oracle depends on. That trade reverses the moment an
in-slice case needs private member access.
Warning severity and noise.
COREWCF_0403/0404fire once per listed contract that fallsback. On a large existing service that could be a lot of warnings on first adoption. Warning is
the right default; whether to ship an opt-in escalation to error, or an opt-out, is a product call.
Should this be CoreWCF-specific at all? The generator reads
System.Runtime.Serializationattributes and mirrors
System.Private.DataContractSerialization— nothing about the hard part isCoreWCF. Three type names couple it. Splitting those into a neutral package would make it usable
by
System.ServiceModelclients and by plainDataContractSerializerusers. Out of scope here,but the API decision in (1) is easier to make with an answer to this.
Reviewing this
178 files, +21,300/−9. Most of it is corpus, fixtures and generated-code snapshots. A reading
order that makes it tractable:
Documentation/DesignDocs/aot-datacontractserializer-risks.md— the design record: what the wireformat actually does, every decision and why, the risk register, and what each upstream source
settled. Written first and kept current; the best single entry point.
src/CoreWCF.Primitives/src/CoreWCF/Runtime/Serialization/and thePartInfochanges inDataContractSerializerOperationFormatter.cs— the seam, and the only changes to existing CoreWCFbehaviour.
…Generator/src/DataContractSerializerGenerator{,.Parser,.Emitter}.cs— the generator.…Generator/tests/Snapshots/— the generated code itself, if you would rather read theoutput than the emitter.
src/CoreWCF.DataContractSerialization/tests/— the harness.GoldenRecordTests,ReadObjectTestsandFixtureStoreare how everything above is held to the recorded bytes.src/CoreWCF.DataContractSerialization.AotSmokeTest/— the end-to-end proof.The commits are ordered by capability and each states what it verified, so
git log --reversereadsas the development narrative if that is more useful than the diff.