Fix SGen ref-handling asymmetry; add regression tests

Refactored AcBinarySourceGenerator to use RefAwareEmitPredicate for all ref-handling switch decisions, ensuring child property ref-marker logic is based solely on child compile-time flags. Fixed deserialization drift when parent disables ref-handling but child enables it. Added regression tests and new test models to verify correct round-trip behavior for duplicate child references in collections and dictionaries. Improved XML docs and updated conventions for summary tags. Added SGen string round-trip tests for medium UTF-8/ASCII cases.
This commit is contained in:
Loretta 2026-05-19 08:32:39 +02:00
parent f631fd4b78
commit 3671c70aa1
9 changed files with 331 additions and 43 deletions

View File

@ -109,7 +109,9 @@
"Bash(stat -c '%y %s %n' \\\\ *)", "Bash(stat -c '%y %s %n' \\\\ *)",
"Bash(xargs stat -c '%y %s %n')", "Bash(xargs stat -c '%y %s %n')",
"Bash(xargs -I {} stat -c '%y %s %n' {})", "Bash(xargs -I {} stat -c '%y %s %n' {})",
"Bash(xargs -I {} stat -c '%y %n' {})" "Bash(xargs -I {} stat -c '%y %n' {})",
"Bash(find \"H:/Applications\" -maxdepth 4 -name \"*.sln\" -o -name \"*.slnx\" -o -name \"*.slnf\" 2>/dev/null | head -20)",
"Bash(rm -rf \"H:/Applications/Mango/Source/FruitBankHybridApp/FruitBank.Common/obj\"; dotnet build \"H:/Applications/Mango/Source/FruitBankHybridApp/FruitBank.Common/FruitBank.Common.csproj\" -c Debug -p:EmitCompilerGeneratedFiles=true 2>&1 | tail -15)"
] ]
} }
} }

View File

@ -59,7 +59,7 @@ public partial class AcBinarySourceGenerator
foreach (var p in ci.Properties) foreach (var p in ci.Properties)
{ {
sb.AppendLine(); sb.AppendLine();
EmitReadProp(sb, p, " ", ci.EnableMetadata, ci.EnableInternString, ci.EnableRefHandling); EmitReadProp(sb, p, " ", ci.EnableMetadata, ci.EnableInternString);
} }
sb.AppendLine(" }"); sb.AppendLine(" }");
@ -85,7 +85,7 @@ public partial class AcBinarySourceGenerator
/// Markered types: read type code byte, then dispatch. /// Markered types: read type code byte, then dispatch.
/// Mirrors the serializer's EmitProp symmetry. /// Mirrors the serializer's EmitProp symmetry.
/// </summary> /// </summary>
private static void EmitReadProp(StringBuilder sb, PropInfo p, string i, bool enableMetadata, bool enableInternString, bool enableRefHandling) private static void EmitReadProp(StringBuilder sb, PropInfo p, string i, bool enableMetadata, bool enableInternString)
{ {
var a = $"obj.{p.Name}"; var a = $"obj.{p.Name}";
@ -152,15 +152,15 @@ public partial class AcBinarySourceGenerator
break; break;
case PropertyTypeKind.Complex: case PropertyTypeKind.Complex:
EmitReadComplex(sb, p, a, tc, i + " ", enableRefHandling); EmitReadComplex(sb, p, a, tc, i + " ");
break; break;
case PropertyTypeKind.Collection: case PropertyTypeKind.Collection:
EmitReadCollection(sb, p, a, tc, i + " ", enableInternString, enableRefHandling); EmitReadCollection(sb, p, a, tc, i + " ", enableInternString);
break; break;
case PropertyTypeKind.Dictionary: case PropertyTypeKind.Dictionary:
EmitReadDictionary(sb, p, a, tc, i + " ", enableInternString, enableRefHandling); EmitReadDictionary(sb, p, a, tc, i + " ", enableInternString);
break; break;
default: default:
@ -274,7 +274,7 @@ public partial class AcBinarySourceGenerator
/// Non-nullable + no ref → ZERO branches (tc consumed but ignored). /// Non-nullable + no ref → ZERO branches (tc consumed but ignored).
/// No SGen → runtime fallback via ReadValueGenerated. /// No SGen → runtime fallback via ReadValueGenerated.
/// </summary> /// </summary>
private static void EmitReadComplex(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableRefHandling) private static void EmitReadComplex(StringBuilder sb, PropInfo p, string a, string tc, string i)
{ {
if (!p.HasGeneratedWriter) if (!p.HasGeneratedWriter)
{ {
@ -299,11 +299,13 @@ public partial class AcBinarySourceGenerator
var reader = p.WriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"); var reader = p.WriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader");
var cast = $"({p.TypeNameForTypeof})"; var cast = $"({p.TypeNameForTypeof})";
// Ref-aware switch ONLY when both (a) the parent type opts into ref handling via EnableRefHandlingFeature // Ref-aware switch decision routed through RefAwareEmitPredicate — single source of truth shared
// (otherwise no Complex property of this type's reader will ever see an ObjectRef* marker — writer never // with the writer-side EmitDirectCollectionWrite + the sibling EmitReadCollectionElement. The
// emits them on this type) AND (b) the child type subtree may emit ref markers (ChildNeedsRefScan). // decision depends EXCLUSIVELY on the child compile-time fact `ChildNeedsRefScan` — the parent
// Either flag false → ZERO-branch path (Object / FixObj only). // EnableRefHandlingFeature flag is NOT a factor here (it governs only the parent's SELF-tracking
if (!enableRefHandling || !p.ChildNeedsRefScan) // emit in the scan pass, not the marker dispatch for child property reads). Asymmetry-bug fix:
// see AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
if (!RefAwareEmitPredicate.ChildEmitsRefMarker(p))
{ {
// Compile-time proven: child never tracked → only Object (+ Null for nullable) in stream // Compile-time proven: child never tracked → only Object (+ Null for nullable) in stream
// Inline: parent creates instance, calls ReadProperties directly (mirrors EmitDirectObjectWrite) // Inline: parent creates instance, calls ReadProperties directly (mirrors EmitDirectObjectWrite)
@ -395,12 +397,12 @@ public partial class AcBinarySourceGenerator
/// Known collection kind + inlineable element → inline Array loop with direct element reads. /// Known collection kind + inlineable element → inline Array loop with direct element reads.
/// Else → runtime fallback via ReadValueGenerated. /// Else → runtime fallback via ReadValueGenerated.
/// </summary> /// </summary>
private static void EmitReadCollection(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString, bool enableRefHandling) private static void EmitReadCollection(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
{ {
// Check if we can inline: known collection shape + inlineable element type // Check if we can inline: known collection shape + inlineable element type
if (p.CollectionKind != null && CanInlineCollectionRead(p)) if (p.CollectionKind != null && CanInlineCollectionRead(p))
{ {
EmitReadCollectionInline(sb, p, a, tc, i, enableInternString, enableRefHandling); EmitReadCollectionInline(sb, p, a, tc, i, enableInternString);
return; return;
} }
@ -426,7 +428,7 @@ public partial class AcBinarySourceGenerator
/// Wire format: [Dictionary][VarUInt count][key₁ value₁ key₂ value₂ ...]. /// Wire format: [Dictionary][VarUInt count][key₁ value₁ key₂ value₂ ...].
/// Keys and values are read inline when their types are known (primitive/string/Complex+SGen). /// Keys and values are read inline when their types are known (primitive/string/Complex+SGen).
/// </summary> /// </summary>
private static void EmitReadDictionary(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString, bool enableRefHandling) private static void EmitReadDictionary(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
{ {
var s = p.Name; var s = p.Name;
var keyType = p.DictKeyTypeName ?? "object"; var keyType = p.DictKeyTypeName ?? "object";
@ -473,11 +475,14 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);"); sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);");
sb.AppendLine($"{i} dv_{s} = rv_{s};"); sb.AppendLine($"{i} dv_{s} = rv_{s};");
sb.AppendLine($"{i} }}"); sb.AppendLine($"{i} }}");
// ObjectRefFirst / ObjectRef cases — only emit when both (a) the parent type opts into // ObjectRefFirst / ObjectRef cases — routed through RefAwareEmitPredicate. Single source of
// ref handling and (b) the dict-value subtree may emit ref markers. Either flag false → // truth shared with EmitReadComplex / EmitReadCollectionElement / EmitDirectCollectionWrite.
// skip these branches (writer never emits them; reader handles unknown markers via the // The decision depends EXCLUSIVELY on the dict-value compile-time fact `DictValueNeedsRefScan`
// fallback ReadValueGenerated path below). ACCORE-BIN-T-K9M3 Phase C step 2. // — the parent EnableRefHandlingFeature flag is NOT a factor here (it governs only the parent's
if (enableRefHandling && p.DictValueNeedsRefScan) // SELF-tracking emit in the scan pass, GenWriter.cs:140). Symmetric with the writer-side
// dict-value emit. Asymmetry-bug fix: see AcBinarySerializerIIdReferenceTests
// .Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
if (RefAwareEmitPredicate.DictValueEmitsRefMarker(p))
{ {
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRefFirst)"); sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRefFirst)");
sb.AppendLine($"{i} {{"); sb.AppendLine($"{i} {{");
@ -613,7 +618,7 @@ public partial class AcBinarySourceGenerator
/// Reads count + loops with direct element reads (Complex with SGen, or primitive/string/enum). /// Reads count + loops with direct element reads (Complex with SGen, or primitive/string/enum).
/// Eliminates per-element: ReadValue dispatch, ReadObjectCore dict lookup, Activator.CreateInstance. /// Eliminates per-element: ReadValue dispatch, ReadObjectCore dict lookup, Activator.CreateInstance.
/// </summary> /// </summary>
private static void EmitReadCollectionInline(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString, bool enableRefHandling) private static void EmitReadCollectionInline(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
{ {
var isComplexElement = p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter; var isComplexElement = p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter;
var elemType = p.ElementFullTypeName!; var elemType = p.ElementFullTypeName!;
@ -640,7 +645,7 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)"); sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{"); sb.AppendLine($"{i} {{");
if (isComplexElement) if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: true, p.ElementNeedsRefScan, enableInternString, enableRefHandling); EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: true, p.ElementNeedsRefScan, enableInternString);
else else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: true, null, enableInternString); EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: true, null, enableInternString);
sb.AppendLine($"{i} }}"); sb.AppendLine($"{i} }}");
@ -655,7 +660,7 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)"); sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{"); sb.AppendLine($"{i} {{");
if (isComplexElement) if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, enableRefHandling, p.CollectionAddMethod); EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, p.CollectionAddMethod);
else else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, p.CollectionAddMethod, enableInternString); EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, p.CollectionAddMethod, enableInternString);
sb.AppendLine($"{i} }}"); sb.AppendLine($"{i} }}");
@ -666,7 +671,7 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)"); sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{"); sb.AppendLine($"{i} {{");
if (isComplexElement) if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, enableRefHandling); EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString);
else else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, null, enableInternString); EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, null, enableInternString);
sb.AppendLine($"{i} }}"); sb.AppendLine($"{i} }}");
@ -681,7 +686,7 @@ public partial class AcBinarySourceGenerator
/// SGen reader = non-metadata mode → no ObjectWithMetadata fallback. /// SGen reader = non-metadata mode → no ObjectWithMetadata fallback.
/// !needsRefScan → only Object/Null possible → 1 branch per element. /// !needsRefScan → only Object/Null possible → 1 branch per element.
/// </summary> /// </summary>
private static void EmitReadCollectionElement(StringBuilder sb, string reader, string elemTypeName, string elemCast, string indexVar, string propSuffix, string i, bool isArray, bool needsRefScan, bool enableInternString, bool enableRefHandling, string? addMethod = null) private static void EmitReadCollectionElement(StringBuilder sb, string reader, string elemTypeName, string elemCast, string indexVar, string propSuffix, string i, bool isArray, bool needsRefScan, bool enableInternString, string? addMethod = null)
{ {
var etc = $"etc_{propSuffix}"; var etc = $"etc_{propSuffix}";
sb.AppendLine($"{i}var {etc} = context.ReadByte();"); sb.AppendLine($"{i}var {etc} = context.ReadByte();");
@ -690,9 +695,12 @@ public partial class AcBinarySourceGenerator
var assignNull = isArray ? $"col_{propSuffix}[{indexVar}] = null!;" : $"col_{propSuffix}.{addCall}(null!);"; var assignNull = isArray ? $"col_{propSuffix}[{indexVar}] = null!;" : $"col_{propSuffix}.{addCall}(null!);";
var assignExpr = isArray ? $"col_{propSuffix}[{indexVar}] = re_{propSuffix};" : $"col_{propSuffix}.{addCall}(re_{propSuffix});"; var assignExpr = isArray ? $"col_{propSuffix}[{indexVar}] = re_{propSuffix};" : $"col_{propSuffix}.{addCall}(re_{propSuffix});";
// Ref-aware switch ONLY when both the parent type opts in (EnableRefHandlingFeature) and the // Ref-aware switch decision routed through RefAwareEmitPredicate — single source of truth shared
// element subtree may emit ref markers (needsRefScan). Either flag false → ZERO-branch path. // with the writer-side EmitDirectCollectionWrite + EmitReadComplex. The decision depends
if (!enableRefHandling || !needsRefScan) // EXCLUSIVELY on the element compile-time fact `needsRefScan` — the parent EnableRefHandlingFeature
// flag is NOT a factor here. Asymmetry-bug fix:
// see AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
if (!RefAwareEmitPredicate.ElementEmitsRefMarker(needsRefScan))
{ {
// No ref tracking → only Object, FixObj or Null in stream — inline ReadProperties // No ref tracking → only Object, FixObj or Null in stream — inline ReadProperties
// FixObj slot: populate slot cache to keep _nextRuntimeSlot in sync. // FixObj slot: populate slot cache to keep _nextRuntimeSlot in sync.

View File

@ -904,18 +904,24 @@ public partial class AcBinarySourceGenerator
var elemRefSuffix = p.ElementIsIId ? "IId" : "All"; var elemRefSuffix = p.ElementIsIId ? "IId" : "All";
if (!p.ElementNeedsRefScan && !p.ElementEnableMetadata) // Ref-aware emit decision routed through RefAwareEmitPredicate — symmetric counterpart to the
// reader-side EmitReadCollectionElement / EmitReadComplex guards. Single source of truth so the
// writer-emit (which marker variants may appear on the wire) and the reader-emit (which marker
// variants are decoded) NEVER drift apart on the same PropInfo.
var elementEmitsRefMarker = RefAwareEmitPredicate.ElementEmitsRefMarker(p);
if (!elementEmitsRefMarker && !p.ElementEnableMetadata)
{ {
// Compile-time proven: no ref, no metadata. Combined check+inc before marker write. // Compile-time proven: no ref, no metadata. Combined check+inc before marker write.
sb.AppendLine($"{i} if (context.TryEnterRecursion(hasTruncatePath: true)) continue;"); sb.AppendLine($"{i} if (context.TryEnterRecursion(hasTruncatePath: true)) continue;");
sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.Object);"); sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.Object);");
sb.AppendLine($"{i} {writer}.Instance.WriteProperties({e}, context);"); sb.AppendLine($"{i} {writer}.Instance.WriteProperties({e}, context);");
} }
else if (p.ElementNeedsRefScan && !p.ElementEnableMetadata) else if (elementEmitsRefMarker && !p.ElementEnableMetadata)
{ {
sb.AppendLine($"{i} if (context.WriteObjectRefMarker{elemRefSuffix}()) {writer}.Instance.WriteProperties({e}, context);"); sb.AppendLine($"{i} if (context.WriteObjectRefMarker{elemRefSuffix}()) {writer}.Instance.WriteProperties({e}, context);");
} }
else if (!p.ElementNeedsRefScan && p.ElementEnableMetadata) else if (!elementEmitsRefMarker && p.ElementEnableMetadata)
{ {
sb.AppendLine($"{i} if (context.WriteObjectMetaMarker({e}, {writer}.s_wrapperSlot)) {writer}.Instance.WriteProperties({e}, context);"); sb.AppendLine($"{i} if (context.WriteObjectMetaMarker({e}, {writer}.s_wrapperSlot)) {writer}.Instance.WriteProperties({e}, context);");
} }

View File

@ -248,3 +248,62 @@ internal enum PropertyTypeKind
NullableBoolean, NullableSingle, NullableDouble, NullableDecimal, NullableDateTime, NullableBoolean, NullableSingle, NullableDouble, NullableDecimal, NullableDateTime,
NullableDateTimeOffset, NullableTimeSpan, NullableGuid, NullableEnum NullableDateTimeOffset, NullableTimeSpan, NullableGuid, NullableEnum
} }
/// <summary>
/// Single source of truth for the compile-time decision: does the SGen-emit need a full ref-aware
/// switch (<c>Object</c> / <c>ObjectRefFirst</c> / <c>Null</c> / <c>ObjectRef</c> / FixObj) for a
/// given Complex property or collection element, OR can it use the zero-branch path
/// (<c>Object</c> / <c>Null</c> / FixObj only)?
///
/// <para><b>Predicate semantics</b>: the decision depends EXCLUSIVELY on whether the child
/// element subtree may emit ref markers — captured by <c>PropInfo.ChildNeedsRefScan</c> /
/// <c>PropInfo.ElementNeedsRefScan</c>. The parent-level <c>EnableRefHandlingFeature</c> flag is
/// <b>NOT</b> a factor here — that flag governs only the parent's SELF-tracking emit in the scan
/// pass (<c>GenWriter.cs</c> line 140), it does NOT suppress marker dispatch for child element
/// properties of THIS type.</para>
///
/// <para><b>Writer / reader symmetry</b> — invoked from BOTH sides so the compile-time decision is
/// identical at every call site:</para>
/// <list type="bullet">
/// <item><c>GenReader.EmitReadComplex</c> — guards zero-branch vs full ref-aware switch.</item>
/// <item><c>GenReader.EmitReadCollectionElement</c> — same guard for collection-element dispatch.</item>
/// <item><c>GenReader.EmitReadDictionary</c> — same guard for dictionary-value dispatch.</item>
/// <item><c>GenWriter.EmitDirectCollectionWrite</c> — guards <c>Object</c>-only vs
/// <c>WriteObjectRefMarker*</c> (runtime decide) emit on the writer side.</item>
/// </list>
///
/// <para><b>Why a generator-only helper, not a runtime helper</b> — the result is inlined into
/// the generated code as either the zero-branch ag or the full-switch ag. The predicate runs
/// once per emit-site at generation time; the runtime code has zero overhead from this abstraction
/// (no method call, no branch on the runtime hot path).</para>
///
/// <para>Regression target: <c>AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction</c>.</para>
/// </summary>
internal static class RefAwareEmitPredicate
{
/// <summary>
/// Reader-side decision for a Complex property (<c>EmitReadComplex</c>) — does the
/// emit need a full ref-aware switch on <c>p.ChildNeedsRefScan</c>?
/// </summary>
internal static bool ChildEmitsRefMarker(PropInfo p) => p.ChildNeedsRefScan;
/// <summary>
/// Reader-side decision for a collection element (<c>EmitReadCollectionElement</c>) and
/// writer-side decision for the same element (<c>EmitDirectCollectionWrite</c>) — keyed on
/// <c>p.ElementNeedsRefScan</c>.
/// </summary>
internal static bool ElementEmitsRefMarker(PropInfo p) => p.ElementNeedsRefScan;
/// <summary>
/// Reader-side overload for <c>EmitReadCollectionElement</c> when only the bool flag is in
/// scope (e.g. when <c>PropInfo</c> is unrolled at the call site). Same semantics — kept as
/// a thin overload so EVERY call site routes through this predicate, not the raw field.
/// </summary>
internal static bool ElementEmitsRefMarker(bool elementNeedsRefScan) => elementNeedsRefScan;
/// <summary>
/// Reader-side decision for a dictionary value (<c>EmitReadDictionary</c>) — keyed on
/// <c>p.DictValueNeedsRefScan</c>. Symmetric with the Complex / Collection-element overloads.
/// </summary>
internal static bool DictValueEmitsRefMarker(PropInfo p) => p.DictValueNeedsRefScan;
}

View File

@ -561,4 +561,93 @@ public class AcBinarySerializerIIdReferenceTests
} }
#endregion #endregion
#region SGen-emit writer/reader ref-handling asymmetry regression target
/// <summary>
/// Target test for the SGen-emit writer/reader asymmetry hypothesis — covers BOTH
/// collection-element AND dictionary-value ref-marker paths in a single graph.
/// <para>
/// Setup:
/// <list type="bullet">
/// <item><c>TestRefAsymParent</c> [AcBinarySerializable(false)] — parent EnableRefHandlingFeature=false.</item>
/// <item><c>TestRefAsymChild</c> [AcBinarySerializable(true)] — child IId&lt;int&gt;, all features ON.</item>
/// <item>Same child instance referenced twice in the parent's <c>Children</c> list
/// AND twice as VALUES in the parent's <c>ChildrenMap</c> dictionary.</item>
/// <item>Runtime <c>ReferenceHandling=All</c> + <c>Interning=All</c> (via Default options).</item>
/// <item><c>MarkerDecimal</c> property AFTER the list — drift detection slot (decimal = 16 fixed bytes).</item>
/// <item><c>MarkerDecimal2</c> property AFTER the dictionary — second drift detection slot,
/// catches the symmetric dict-value emit asymmetry (EmitReadDictionary:482).</item>
/// </list>
/// </para>
/// <para>
/// Expected if the asymmetry-hypothesis holds: the writer (runtime via
/// WriteObjectGenerated bridge) emits ObjectRefFirst+ObjectRef for the duplicates; the SGen
/// reader-emit's zero-branch path (parent flag false guarding out the ref-aware switch)
/// misreads the VarUInt cacheIdx as a property-marker byte → DECIMAL_DRIFT exception or
/// value-mismatch on MarkerDecimal / MarkerDecimal2.
/// </para>
/// <para>
/// Expected if the hypothesis is WRONG: the test passes — different fix direction needed.
/// </para>
/// </summary>
[TestMethod]
public void Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction()
{
var sharedChild = new TestRefAsymChild { Id = 1, Name = "Shared" };
var parent = new TestRefAsymParent
{
Id = 100,
Children = new List<TestRefAsymChild> { sharedChild, sharedChild },
MarkerDecimal = 999.99m,
ChildrenMap = new Dictionary<int, TestRefAsymChild>
{
{ 10, sharedChild },
{ 20, sharedChild },
},
MarkerDecimal2 = 888.88m,
};
var options = AcBinarySerializerOptions.Default; // RefHandling=All, Interning=All
options.UseGeneratedCode = true;
var bytes = AcBinarySerializer.Serialize(parent, options);
// Sanity check: did the writer actually emit an ObjectRef marker for the duplicates?
var objectRefCount = CountObjectRefs(bytes, writeBinaryToConsole: false);
Console.WriteLine($"Wire size: {bytes.Length}, ObjectRef occurrences: {objectRefCount}");
var result = AcBinaryDeserializer.Deserialize<TestRefAsymParent>(bytes, options);
Assert.IsNotNull(result, "Deserialize returned null — wire corruption");
Assert.AreEqual(parent.Id, result.Id, "Parent.Id mismatch — possible drift before the list");
// --- Collection-element path (EmitReadCollectionElement) ---
Assert.IsNotNull(result.Children, "Children list was null after round-trip");
Assert.AreEqual(2, result.Children.Count, "Children count mismatch");
Assert.IsNotNull(result.Children[0]);
Assert.IsNotNull(result.Children[1]);
Assert.AreEqual(sharedChild.Id, result.Children[0].Id, "Children[0].Id mismatch");
Assert.AreEqual(sharedChild.Name, result.Children[0].Name, "Children[0].Name mismatch");
Assert.AreEqual(sharedChild.Id, result.Children[1].Id, "Children[1].Id mismatch — drift on the duplicate");
Assert.AreEqual(sharedChild.Name, result.Children[1].Name, "Children[1].Name mismatch — drift on the duplicate");
Assert.AreEqual(parent.MarkerDecimal, result.MarkerDecimal,
"MarkerDecimal drift — wire-position desync after the Children list (smoking gun for collection-element SGen-emit asymmetry)");
// --- Dictionary-value path (EmitReadDictionary) ---
Assert.IsNotNull(result.ChildrenMap, "ChildrenMap was null after round-trip");
Assert.AreEqual(2, result.ChildrenMap.Count, "ChildrenMap count mismatch");
Assert.IsTrue(result.ChildrenMap.ContainsKey(10), "ChildrenMap missing key=10");
Assert.IsTrue(result.ChildrenMap.ContainsKey(20), "ChildrenMap missing key=20");
Assert.IsNotNull(result.ChildrenMap[10]);
Assert.IsNotNull(result.ChildrenMap[20]);
Assert.AreEqual(sharedChild.Id, result.ChildrenMap[10].Id, "ChildrenMap[10].Id mismatch");
Assert.AreEqual(sharedChild.Name, result.ChildrenMap[10].Name, "ChildrenMap[10].Name mismatch");
Assert.AreEqual(sharedChild.Id, result.ChildrenMap[20].Id, "ChildrenMap[20].Id mismatch — drift on the dict-value duplicate");
Assert.AreEqual(sharedChild.Name, result.ChildrenMap[20].Name, "ChildrenMap[20].Name mismatch — drift on the dict-value duplicate");
Assert.AreEqual(parent.MarkerDecimal2, result.MarkerDecimal2,
"MarkerDecimal2 drift — wire-position desync after the ChildrenMap dictionary (smoking gun for dict-value SGen-emit asymmetry)");
}
#endregion
} }

View File

@ -66,6 +66,74 @@ public class AcBinarySerializerSGenRuntimeCompatibilityTests
} }
} }
/// <summary>
/// Regression test: SGen ↔ SGen round-trip with non-ASCII multi-byte ProductName above the
/// StringSmall threshold (utf8Len &gt; 255 byte). Engages the StringMedium tier (marker 94,
/// fixed-width header [marker:1][charLen:16][utf8Len:16][bytes]). After ProductName in
/// TestOrderItemBase come Quantity (int) + UnitPrice (decimal) — any writer/reader byte-count
/// asymmetry in the StringMedium path surfaces as a UnitPrice corruption (DECIMAL_DRIFT) or
/// Quantity skew. The [AcStringIntern(true)] attribute on ProductName means the first occurrence
/// emits StringInternFirstMedium (marker 105) for the InternFirst tier.
/// </summary>
[TestMethod]
public void Serialize_MediumStringUtf8_OnProductName_SGenRoundTrip()
{
// 300 chars × 2 byte (Hungarian 'á' = 2 byte UTF-8) = 600 byte UTF-8 → StringMedium (or
// StringInternFirstMedium for the first occurrence under interning).
var mediumUtf8 = new string('á', 300);
foreach (var optionFactory in GetOptionFactories())
{
var options = optionFactory();
options.UseGeneratedCode = true;
var order = BenchmarkTestDataProvider
.CreateTestDataSets()
.Cast<TestDataSet<TestOrder_All_True>>()
.First(x => x.Name.StartsWith("Small")).Order;
foreach (var item in order.Items) item.ProductName = mediumUtf8;
var bytes = AcBinarySerializer.Serialize(order, options);
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, options);
AssertOrderEquivalent(order, roundTrip,
$"WireMode={options.WireMode}, Refs={options.ReferenceHandling}, Interning={options.UseStringInterning}");
}
}
/// <summary>
/// Regression test: SGen ↔ SGen round-trip with pure ASCII ProductName above the FixStrAscii inline
/// limit (&gt;31 chars). Engages StringAscii (marker 167) — writer detects ASCII via
/// bytesWritten == charLength post-encode, reader byte→char widens directly without UTF-8 decode.
/// Same drift-surface as the UTF-8 variant: UnitPrice / Quantity after ProductName in TestOrderItemBase.
/// </summary>
[TestMethod]
public void Serialize_MediumStringAscii_OnProductName_SGenRoundTrip()
{
// 500 chars × 1 byte = 500 byte ASCII → StringAscii (167) tier.
var mediumAscii = new string('X', 500);
foreach (var optionFactory in GetOptionFactories())
{
var options = optionFactory();
options.UseGeneratedCode = true;
var order = BenchmarkTestDataProvider
.CreateTestDataSets()
.Cast<TestDataSet<TestOrder_All_True>>()
.First(x => x.Name.StartsWith("Small")).Order;
foreach (var item in order.Items) item.ProductName = mediumAscii;
var bytes = AcBinarySerializer.Serialize(order, options);
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, options);
AssertOrderEquivalent(order, roundTrip,
$"WireMode={options.WireMode}, Refs={options.ReferenceHandling}, Interning={options.UseStringInterning}");
}
}
private static IEnumerable<TestDataSet<TestOrder_All_True>> GetTargetDataSets() private static IEnumerable<TestDataSet<TestOrder_All_True>> GetTargetDataSets()
{ {
// SGen↔Runtime compatibility test depends on TestOrder_All_True graphs (the AssertOrderEquivalent // SGen↔Runtime compatibility test depends on TestOrder_All_True graphs (the AssertOrderEquivalent

View File

@ -194,3 +194,62 @@ public sealed partial class TestMeasurementPoint_All_False
UserPreferences_All_False> UserPreferences_All_False>
{ {
} }
// ============================================================================
// MIXED family — drift reproduction (SGen-emit asymmetry check).
// Mirrors the FruitBank ProductDto / OrderDto / GenericAttributeDto attribute:
// [AcBinarySerializable(false, true, false, true, false, false)]
// meta=false, IdTracking=true, RefHandling=FALSE, Intern=true, Filter=false, Poly=false
//
// Parent: EnableRefHandlingFeature=FALSE ◀ the asymmetry trigger
// Child: EnableRefHandlingFeature=true (IId<int>, all features ON)
//
// Hypothesis (confirmed): the SGen reader-emit guard for collection-element / Complex /
// dictionary-value dispatch (EmitReadCollectionElement / EmitReadComplex / EmitReadDictionary)
// used to check the PARENT-level enableRefHandling flag. The writer-emit only depends on
// CHILD-level flags (ElementNeedsRefScan / DictValueNeedsRefScan / runtime
// UseTypeReferenceHandling). With runtime ReferenceHandling=All + duplicate child instances,
// the writer runtime emits ObjectRefFirst / ObjectRef, but the reader's zero-branch path
// couldn't decode them → DECIMAL_DRIFT on MarkerDecimal after the Children list or
// ChildrenMap dictionary.
//
// Fix: parent-flag removed from reader guards; routing through RefAwareEmitPredicate
// (single source of truth shared with writer-side EmitDirectCollectionWrite).
//
// The existing _All_True family tests don't exercise this path because
// EnableRefHandlingFeature=true on the parent → reader emitted the full
// ref-aware switch → never hit the zero-branch bug.
// ============================================================================
[MemoryPackable]
[AcBinarySerializable(false, true, false, true, false, false)]
[MessagePackObject]
public sealed partial class TestRefAsymParent : AyCode.Core.Interfaces.IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public System.Collections.Generic.List<TestRefAsymChild>? Children { get; set; }
[Key(2)]
public decimal MarkerDecimal { get; set; }
[Key(3)]
public System.Collections.Generic.Dictionary<int, TestRefAsymChild>? ChildrenMap { get; set; }
[Key(4)]
public decimal MarkerDecimal2 { get; set; }
}
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class TestRefAsymChild : AyCode.Core.Interfaces.IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Name { get; set; } = "";
}

View File

@ -51,17 +51,10 @@ public sealed class AcBinarySerializableAttribute : Attribute
public bool EnableIdTrackingFeature { get; } public bool EnableIdTrackingFeature { get; }
/// <summary> /// <summary>
/// When <c>true</c> (default): the SGen-emitted code emits non-IId reference tracking /// Controls emit of this type's non-IId hash-based reference tracking (self-registration in the
/// (<c>wrapper.TryTrackInt32(GetHashCode(...))</c> in the scan pass when <c>ReferenceHandling /// scan pass, paired with runtime <c>ReferenceHandling</c>). Scope: ONLY this type's self-tracking
/// = All</c>) AND the reader-side <c>ObjectRef</c> / <c>ObjectRefFirst</c> / <c>ObjectWithMetadataRefFirst</c> /// — does NOT govern marker dispatch for child properties (child marker emit follows the child
/// case-emit on every Complex / Collection-element / Dictionary-value property of this type. /// type's own facts, symmetric writer/reader). See class remarks for the general flag semantics.
/// <para>When <c>false</c>: both emit blocks are omitted. <b>Significantly reduces scan-pass
/// cost</b> — the per-instance hash-track lookup is eliminated; combined with
/// <c>EnableIdTrackingFeature = false</c> the scan pass for this type degenerates to a primitive-property
/// iteration only. Reader-side switch-dispatch shrinks by 2-3 cases per Complex/Collection/Dict
/// property (smaller jump table, better branch predictor, smaller IL). The runtime
/// <c>ReferenceHandling</c> option is silently ignored for instances of this type. Use only when
/// the type is never reference-shared across the serialized graph.</para>
/// </summary> /// </summary>
public bool EnableRefHandlingFeature { get; } public bool EnableRefHandlingFeature { get; }

View File

@ -11,6 +11,10 @@
> **Workspace-wide note:** the framework-only **class prefix mandate** (`Ac` for AyCode.*, `Mg` for Mango.Nop.*; product/consumer repos un-prefixed) is an architectural rule — see `ARCHITECTURE.md#class-prefix--framework-only-mandate`. The first bullet above is the AyCode.Core-specific instance of that rule. > **Workspace-wide note:** the framework-only **class prefix mandate** (`Ac` for AyCode.*, `Mg` for Mango.Nop.*; product/consumer repos un-prefixed) is an architectural rule — see `ARCHITECTURE.md#class-prefix--framework-only-mandate`. The first bullet above is the AyCode.Core-specific instance of that rule.
## XML Documentation
`<summary>` — brief, developer-facing, readable in VS IntelliSense tooltip. NO implementation details, NO wire-format / byte-level / perf specifics — those live in `docs/TOPIC/*.md`. Add `<example>` only when usage is non-obvious; otherwise omit.
## Patterns ## Patterns
- **Extension methods over instance methods** for CRUD — clean interfaces, composable impls. - **Extension methods over instance methods** for CRUD — clean interfaces, composable impls.