CultElementIds: a keyed type is read by key before it is read as a sequence

Classify tested IEnumerable<T> before [MessagePackObject], so a keyed class that
enumerates itself recursed until the stack overflowed during registration (s4,
keyed-enumerable-misclassified), and a keyed class that merely enumerates had its
keyed lists neither judged nor minted. Keyed first: MessagePack writes both by key.
VisitMemberType also stops at a type already on its own through chain, which ends
an unkeyed Node : IEnumerable<Node>.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 02:49:18 +02:00
commit 9fd5ae1f09
2 changed files with 69 additions and 4 deletions

View file

@ -148,6 +148,10 @@ namespace GameCult.Caching
private static void VisitMemberType(Type documentType, Type owner, MemberInfo member, Type memberType, Type[] through, bool hidden, HashSet<Type> seen, Survey survey, Graph graph)
{
memberType = Nullable.GetUnderlyingType(memberType) ?? memberType;
// A sequence or map whose element is itself (an unkeyed Node : IEnumerable<Node>) ends the type walk here; without
// this it would recurse until the stack overflows.
if (Array.IndexOf(through, memberType) >= 0)
return;
var (kind, inner) = Classify(memberType);
switch (kind)
{
@ -565,8 +569,10 @@ namespace GameCult.Caching
private static readonly ConcurrentDictionary<Type, (Kind Kind, Type Inner)> Classes = new();
// The one recognition of what MessagePack writes for a declared type: registration, Plan, Holds and the codec's id path
// all go through it. Inner is a map's value type, a sequence's element type, or the type itself for an owner.
// The walk's model of a declared type: registration, Plan, Holds and the codec's id path all go through it. A
// [MessagePackObject] or [Union] type is read by key even when it is also IEnumerable<T>; the codec checks, for every type
// on the id path, that MessagePack writes it the way this model reads it, and refuses the document when it does not.
// Inner is a map's value type, a sequence's element type, or the type itself for an owner.
// Leaf a value written whole that holds no element list: strings (an IEnumerable<char> MessagePack writes whole),
// a struct, class or interface with no [MessagePackObject] or [Union] (primitives, enums, float2, DateTime,
// and object, which is untyped on disk).
@ -579,13 +585,15 @@ namespace GameCult.Caching
type = Nullable.GetUnderlyingType(type) ?? type;
if (type == typeof(string))
return (Kind.Leaf, type);
if (IsKeyed(type))
return (Kind.Owner, type);
if (type.IsValueType)
return IsKeyed(type) ? (Kind.Owner, type) : (Kind.Leaf, type);
return (Kind.Leaf, type);
if (MapTypes(type) is { } map)
return (Kind.Map, map.Value);
if (ElementTypeOf(type) is { } element)
return (Kind.Sequence, element);
return IsKeyed(type) ? (Kind.Owner, type) : (Kind.Leaf, type);
return (Kind.Leaf, type);
});
// A type MessagePack serializes by key: [MessagePackObject] or [Union].

View file

@ -485,7 +485,64 @@ namespace GameCult.Caching.Tests
Assert.That(CultElementIds.Inspect(typeof(HHiddenList)).IdPath, Is.Empty);
}
// MessagePack writes a [MessagePackObject] class that is only IEnumerable<T> by its keys, so the walk reads it by key. A
// keyed tree node that enumerates its own children is the shape that overflowed the stack when enumeration came first.
[Test]
public void ATypeThatEnumeratesItselfIsWalkedByKeyAndTerminates()
{
var survey = CultElementIds.Inspect(typeof(HKNode));
Assert.That(survey.Problems, Is.Empty);
Assert.That(survey.IdPath, Is.EquivalentTo(new[] { typeof(HKNode), typeof(List<KNode>), typeof(KNode) }));
var root = new HKNode { Nodes = { new KNode { Children = { new KNode { Children = { new KNode() } } } } } };
Assert.That(Assign(root, "r", deterministic: true), Is.EqualTo(3));
Assert.That(root.Nodes[0].Id, Has.Length.EqualTo(12));
Assert.That(root.Nodes[0].Children[0].Children[0].Id, Has.Length.EqualTo(12));
// An unkeyed type that enumerates itself holds no element list and ends the type walk.
var unkeyed = CultElementIds.Inspect(typeof(HUNode));
Assert.That(unkeyed.Problems, Is.Empty);
Assert.That(unkeyed.IdPath, Is.Empty);
// A keyed class that only enumerates is read by its keyed lists, so both are judged and minted.
Assert.That(Problems(typeof(KBag)).Single(), Does.Contain("EBare"));
var bag = new KBagIded { Items = { new EIded(), new EIded() } };
Assert.That(Assign(bag, "r", deterministic: false), Is.EqualTo(2));
Assert.That(CultElementIds.Holds(typeof(KBagIded), bag), Is.True);
}
// ---- shapes ----
[MessagePackObject]
public class KNode : IEnumerable<KNode>
{
[Key(0)] [CultElementId] public string? Id { get; set; }
[Key(1)] public List<KNode> Children { get; set; } = new();
public IEnumerator<KNode> GetEnumerator() => Children.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[MessagePackObject] public class HKNode { [Key(0)] public List<KNode> Nodes { get; set; } = new(); }
public class UNode : IEnumerable<UNode>
{
public IEnumerator<UNode> GetEnumerator() { yield break; }
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[MessagePackObject] public class HUNode { [Key(0)] public UNode? N { get; set; } }
[MessagePackObject]
public class KBag : IEnumerable<EIded>
{
[Key(0)] public List<EIded> Items { get; set; } = new();
[Key(1)] public List<EBare> Other { get; set; } = new();
public IEnumerator<EIded> GetEnumerator() { yield break; }
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[MessagePackObject]
public class KBagIded : IEnumerable<EIded>
{
[Key(0)] public List<EIded> Items { get; set; } = new();
public IEnumerator<EIded> GetEnumerator() { yield break; }
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[MessagePackObject] public class EBare { [Key(0)] public string X { get; set; } = ""; }
[MessagePackObject] public class EIded { [Key(0)] public string X { get; set; } = ""; [Key(1)] [CultElementId] public string Id { get; set; } = ""; }
[MessagePackObject] public class ENest { [Key(0)] [CultElementId] public string Id { get; set; } = ""; [Key(1)] public List<EIded> Sub { get; set; } = new(); }