Skip to content

Commit 98b5f53

Browse files
committed
improve cycles tracking
1 parent 0713ed2 commit 98b5f53

3 files changed

Lines changed: 321 additions & 39 deletions

File tree

src/FastCloner.Tests/FailureHypothesisTests.cs

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Concurrent;
2+
using System.Collections.Frozen;
23
using System.Reflection;
34
using System.Runtime.CompilerServices;
45
using System.Threading.Channels;
@@ -98,15 +99,16 @@ public override bool Equals(object? obj)
9899
[Test]
99100
public async Task FastClonerStableHashAttribute_Marks_Type_As_Stable()
100101
{
101-
await Assert.That(global::FastCloner.Code.FastClonerSafeTypes.HasStableHashSemantics(typeof(ProbeUnfriendlyButStableKey)))
102+
await Assert.That(Code.FastClonerSafeTypes.HasStableHashSemantics(typeof(ProbeUnfriendlyButStableKey)))
102103
.IsTrue()
103104
.Because("[FastClonerStableHash] must short-circuit the probe and declare stable semantics, " +
104105
"even when GetHashCode would throw on default-state instances.");
105-
106-
// Unchanged behavior for the attribute-less twin: probe throws on null Tag, conservative rebuild.
107-
await Assert.That(global::FastCloner.Code.FastClonerSafeTypes.HasStableHashSemantics(typeof(ProbeUnfriendlyKeyNoAttribute)))
108-
.IsFalse()
109-
.Because("Without the opt-in, a probe that NREs on default state must fall back to rebuild.");
106+
107+
await Assert.That(Code.FastClonerSafeTypes.HasStableHashSemantics(typeof(ProbeUnfriendlyKeyNoAttribute)))
108+
.IsTrue()
109+
.Because("The smarter probe substitutes string.Empty for stable-hash reference fields, " +
110+
"so an override of Tag.ToUpperInvariant().GetHashCode() no longer NREs and is " +
111+
"correctly classified as stable even without the [FastClonerStableHash] opt-in.");
110112
}
111113

112114
[Test]
@@ -128,6 +130,45 @@ public async Task FastClonerStableHashAttribute_Allows_FastPath_With_Correct_Loo
128130
await Assert.That(clone.Contains(new ProbeUnfriendlyButStableKey { Tag = "alpha" })).IsTrue()
129131
.Because("Hash is value-based on Tag (case-insensitive) and survives the clone unchanged.");
130132
}
133+
134+
private sealed class IdentityNode
135+
{
136+
public string Tag { get; set; } = "";
137+
}
138+
139+
private struct IdentityDelegatingStruct : IEquatable<IdentityDelegatingStruct>
140+
{
141+
public IdentityNode? Node;
142+
143+
public override int GetHashCode() => Node is null ? 0 : Node.GetHashCode();
144+
145+
public bool Equals(IdentityDelegatingStruct other) => ReferenceEquals(Node, other.Node);
146+
147+
public override bool Equals(object? obj)
148+
=> obj is IdentityDelegatingStruct other && Equals(other);
149+
}
150+
151+
[Test]
152+
public async Task HashSet_Of_Struct_With_IdentityHashed_Reference_Field_Should_Be_Lookupable_After_Clone()
153+
{
154+
IdentityNode node = new IdentityNode { Tag = "n" };
155+
IdentityDelegatingStruct entry = new IdentityDelegatingStruct { Node = node };
156+
157+
HashSet<IdentityDelegatingStruct> original = [entry];
158+
HashSet<IdentityDelegatingStruct> clone = original.DeepClone();
159+
160+
await Assert.That(clone).IsNotSameReferenceAs(original);
161+
await Assert.That(clone.Count).IsEqualTo(1);
162+
163+
IdentityDelegatingStruct cloneEntry = clone.Single();
164+
await Assert.That(cloneEntry.Node).IsNotSameReferenceAs(node)
165+
.Because("Reference field inside the struct must be deep-cloned to a fresh instance.");
166+
167+
await Assert.That(clone.Contains(cloneEntry)).IsTrue()
168+
.Because("FastCloner treats every value type as having stable hash semantics, but this struct's " +
169+
"hash delegates to an identity-hashed reference field that gets a new identity on clone, " +
170+
"so the bucket-stored hash no longer matches the element's actual hash and lookup misses.");
171+
}
131172

132173
[Test]
133174
public async Task BufferBlock_Should_Be_Deep_Cloned_Independently()
@@ -349,6 +390,47 @@ public async Task ReaderWriterLockSlim_Should_Be_Deep_Cloned_Independently()
349390
// Ignore cleanup errors
350391
}
351392
}
393+
394+
[Test]
395+
public async Task FrozenSet_Should_Be_Cloned_Without_StackOverflow()
396+
{
397+
FrozenSet<string> original = new[] { "alpha", "beta", "gamma" }.ToFrozenSet();
398+
399+
FrozenSet<string> clone = original.DeepClone();
400+
401+
await Assert.That(clone.Count).IsEqualTo(3)
402+
.Because("All elements must survive the clone.");
403+
await Assert.That(clone.Contains("alpha")).IsTrue();
404+
await Assert.That(clone.Contains("beta")).IsTrue();
405+
await Assert.That(clone.Contains("gamma")).IsTrue();
406+
await Assert.That(clone.Contains("delta")).IsFalse();
407+
}
408+
409+
private sealed class StructWrappedSelfReferenceContainer
410+
{
411+
public List<int> Payload { get; set; } = [1, 2, 3];
412+
public StructBackRef BackRef;
413+
}
414+
415+
private struct StructBackRef
416+
{
417+
public StructWrappedSelfReferenceContainer? Owner;
418+
}
419+
420+
[Test]
421+
public async Task Container_With_StructMediated_SelfReference_Should_Clone_Without_Overflow()
422+
{
423+
StructWrappedSelfReferenceContainer original = new StructWrappedSelfReferenceContainer();
424+
original.BackRef = new StructBackRef { Owner = original };
425+
await Assert.That(original.BackRef.Owner).IsSameReferenceAs(original);
426+
427+
StructWrappedSelfReferenceContainer clone = original.DeepClone();
428+
429+
await Assert.That(clone).IsNotSameReferenceAs(original);
430+
await Assert.That(clone.Payload).IsEquivalentTo(new[] { 1, 2, 3 });
431+
await Assert.That(clone.BackRef.Owner).IsSameReferenceAs(clone)
432+
.Because("The cycle must be rebound to the clone, not left dangling at the original.");
433+
}
352434

353435
[Test]
354436
public async Task Task_Should_Not_Be_Deep_Cloned()

src/FastCloner/Code/FastClonerGenerator.cs

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,7 @@ private static FastClonerCache.TypeCloneMetadata BuildTypeMetadata(Type type)
270270

271271
if (cacheEntry.Cloner is not null)
272272
{
273-
return cacheEntry.CanUseNoTrackingState ?
274-
cacheEntry.Cloner(obj, FastCloneState.GetSimpleState()) :
275-
CloneRootWithTrackedState(obj, cacheEntry.Cloner, cacheEntry.Metadata!);
273+
return CloneRootWithTrackedState(obj, cacheEntry.Cloner, cacheEntry.Metadata!);
276274
}
277275
}
278276

@@ -371,12 +369,8 @@ private static bool TryCloneSafeArrayRoot<T>(T obj, Type runtimeType, out T? clo
371369
{
372370
return obj;
373371
}
374-
375-
if (metadata.CanSkipReferenceTracking ||
376-
(metadata is { CyclePolicy: FastClonerCache.CyclePolicy.None, HasBehaviorSensitiveMembers: false } && !rootType.IsValueType))
377-
return cloner(obj, FastCloneState.GetSimpleState());
378-
379-
return CloneRootWithTrackedState(obj, cloner, metadata);
372+
373+
return metadata.CanSkipReferenceTracking ? cloner(obj, FastCloneState.GetSimpleState()) : CloneRootWithTrackedState(obj, cloner, metadata);
380374
}
381375

382376
private static T CloneRootWithTrackedState<T>(T obj, Func<T, FastCloneState, T> cloner, FastClonerCache.TypeCloneMetadata metadata)
@@ -469,11 +463,14 @@ private static bool CalculateCanHaveCycles(Type type)
469463
Type? payloadType = GetCollectionPayloadType(type);
470464
if (payloadType is not null)
471465
{
472-
if (FastClonerSafeTypes.CanReturnSameObject(payloadType))
473-
return false;
466+
bool payloadTriviallyAcyclic =
467+
FastClonerSafeTypes.CanReturnSameObject(payloadType) ||
468+
(payloadType.IsValueType && !ValueTypeContainsReferenceFieldsCached(payloadType));
474469

475-
if (payloadType.IsValueType && !ValueTypeContainsReferenceFieldsCached(payloadType))
476-
return false;
470+
if (payloadTriviallyAcyclic)
471+
{
472+
return ContainerHasStructMediatedSelfReference(type);
473+
}
477474
}
478475

479476
Type[] fieldTypes = GetCycleFieldTypes(type);
@@ -492,6 +489,61 @@ private static bool CalculateCanHaveCycles(Type type)
492489

493490
return false;
494491
}
492+
493+
private static bool ContainerHasStructMediatedSelfReference(Type rootType)
494+
{
495+
HashSet<Type> visited = [];
496+
return HasStructMediatedSelfReferenceCore(rootType, rootType, visited);
497+
}
498+
499+
private static bool HasStructMediatedSelfReferenceCore(Type current, Type rootType, HashSet<Type> visited)
500+
{
501+
if (!visited.Add(current))
502+
return false;
503+
504+
Type[] fieldTypes = GetCycleFieldTypes(current);
505+
for (int i = 0; i < fieldTypes.Length; i++)
506+
{
507+
Type fieldType = fieldTypes[i];
508+
509+
if (FastClonerSafeTypes.CanReturnSameObject(fieldType))
510+
continue;
511+
512+
if (fieldType.IsValueType)
513+
{
514+
if (HasStructMediatedSelfReferenceCore(fieldType, rootType, visited))
515+
return true;
516+
continue;
517+
}
518+
519+
if (FieldTypeCouldReferenceRoot(fieldType, rootType))
520+
return true;
521+
}
522+
523+
return false;
524+
}
525+
526+
private static bool FieldTypeCouldReferenceRoot(Type fieldType, Type rootType)
527+
{
528+
if (fieldType == rootType ||
529+
fieldType.IsAssignableFrom(rootType) ||
530+
rootType.IsAssignableFrom(fieldType))
531+
{
532+
return true;
533+
}
534+
535+
if (fieldType.IsArray)
536+
{
537+
Type? element = fieldType.GetElementType();
538+
if (element is not null &&
539+
(element == rootType || element.IsAssignableFrom(rootType) || rootType.IsAssignableFrom(element)))
540+
{
541+
return true;
542+
}
543+
}
544+
545+
return false;
546+
}
495547

496548
private static bool ValueTypeContainsReferenceFields(Type valueType, HashSet<Type> visited)
497549
{

0 commit comments

Comments
 (0)