|
| 1 | +// Copyright (c) Microsoft Corporation. |
| 2 | +// Licensed under the MIT license. |
| 3 | + |
| 4 | +using System; |
| 5 | +using System.IO; |
| 6 | +using System.Text; |
| 7 | +using System.Threading; |
| 8 | +using Garnet.server; |
| 9 | +using NUnit.Framework; |
| 10 | +using NUnit.Framework.Legacy; |
| 11 | + |
| 12 | +namespace Garnet.test |
| 13 | +{ |
| 14 | + /// <summary> |
| 15 | + /// Demonstrates that GarnetObject's shallow Clone() creates a race condition |
| 16 | + /// when serialization and mutation happen concurrently on shared collections. |
| 17 | + /// This is the same pattern used by Tsavorite's CopyUpdate: Clone() creates |
| 18 | + /// a shallow copy, then the clone is mutated while the original is serialized. |
| 19 | + /// </summary> |
| 20 | + [TestFixture] |
| 21 | + public class ShallowCloneRaceConditionTests |
| 22 | + { |
| 23 | + /// <summary> |
| 24 | + /// Race: one thread serializes (iterates sortedSetDict via DoSerialize), |
| 25 | + /// another thread mutates the same dictionary (via Add on the clone). |
| 26 | + /// Since Clone() shares collections by reference, this throws |
| 27 | + /// InvalidOperationException ("Collection was modified during enumeration") |
| 28 | + /// or produces corrupt output. |
| 29 | + /// </summary> |
| 30 | + [Test] |
| 31 | + public void SortedSetCloneSerializeWhileMutatingThrows() |
| 32 | + { |
| 33 | + // Create a SortedSetObject with enough entries to make iteration non-trivial |
| 34 | + var original = new SortedSetObject(); |
| 35 | + for (int i = 0; i < 1000; i++) |
| 36 | + { |
| 37 | + original.Add(Encoding.ASCII.GetBytes($"member-{i:D4}"), i); |
| 38 | + } |
| 39 | + |
| 40 | + // Clone shares the same sortedSetDict and sortedSet by reference |
| 41 | + var clone = (SortedSetObject)original.Clone(); |
| 42 | + |
| 43 | + var serializeBarrier = new Barrier(2); |
| 44 | + Exception serializeException = null; |
| 45 | + Exception mutateException = null; |
| 46 | + var done = new ManualResetEventSlim(false); |
| 47 | + |
| 48 | + // Thread 1: serialize the original (iterates sortedSetDict) |
| 49 | + var serializeThread = new Thread(() => |
| 50 | + { |
| 51 | + try |
| 52 | + { |
| 53 | + serializeBarrier.SignalAndWait(); |
| 54 | + for (int round = 0; round < 500 && serializeException == null && mutateException == null; round++) |
| 55 | + { |
| 56 | + using var ms = new MemoryStream(); |
| 57 | + using var writer = new BinaryWriter(ms, Encoding.UTF8); |
| 58 | + try |
| 59 | + { |
| 60 | + original.DoSerialize(writer); |
| 61 | + } |
| 62 | + catch (InvalidOperationException ex) |
| 63 | + { |
| 64 | + serializeException = ex; |
| 65 | + return; |
| 66 | + } |
| 67 | + Thread.Yield(); |
| 68 | + } |
| 69 | + } |
| 70 | + finally |
| 71 | + { |
| 72 | + done.Set(); |
| 73 | + } |
| 74 | + }); |
| 75 | + |
| 76 | + // Thread 2: mutate the clone (adds to shared sortedSetDict) |
| 77 | + var mutateThread = new Thread(() => |
| 78 | + { |
| 79 | + try |
| 80 | + { |
| 81 | + serializeBarrier.SignalAndWait(); |
| 82 | + for (int round = 0; round < 500 && serializeException == null && mutateException == null; round++) |
| 83 | + { |
| 84 | + try |
| 85 | + { |
| 86 | + // Add new entries — mutates the shared dictionary |
| 87 | + var key = Encoding.ASCII.GetBytes($"new-{round:D4}"); |
| 88 | + clone.Add(key, 10000 + round); |
| 89 | + } |
| 90 | + catch (Exception ex) |
| 91 | + { |
| 92 | + mutateException = ex; |
| 93 | + return; |
| 94 | + } |
| 95 | + Thread.Yield(); |
| 96 | + } |
| 97 | + } |
| 98 | + finally |
| 99 | + { |
| 100 | + done.Set(); |
| 101 | + } |
| 102 | + }); |
| 103 | + |
| 104 | + serializeThread.Start(); |
| 105 | + mutateThread.Start(); |
| 106 | + |
| 107 | + serializeThread.Join(TimeSpan.FromSeconds(10)); |
| 108 | + mutateThread.Join(TimeSpan.FromSeconds(10)); |
| 109 | + |
| 110 | + // At least one thread should have hit an exception due to concurrent modification |
| 111 | + // If neither threw, the race wasn't triggered in this run — but the bug exists. |
| 112 | + // We use a weaker assertion: if an exception was thrown, it proves the race. |
| 113 | + if (serializeException != null) |
| 114 | + { |
| 115 | + Console.WriteLine($"Serialize thread caught: {serializeException.GetType().Name}: {serializeException.Message}"); |
| 116 | + ClassicAssert.IsInstanceOf<InvalidOperationException>(serializeException, |
| 117 | + "Serialization should fail with InvalidOperationException when collection is modified concurrently"); |
| 118 | + } |
| 119 | + else if (mutateException != null) |
| 120 | + { |
| 121 | + Console.WriteLine($"Mutate thread caught: {mutateException.GetType().Name}: {mutateException.Message}"); |
| 122 | + // Any exception from mutation during concurrent iteration proves the race |
| 123 | + } |
| 124 | + else |
| 125 | + { |
| 126 | + // Race wasn't triggered this run — mark as inconclusive rather than failing |
| 127 | + Assert.Warn("Race condition was not triggered in this run. " + |
| 128 | + "The bug exists but is timing-dependent. Run multiple times to reproduce."); |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + /// <summary> |
| 133 | + /// Deterministic proof: Clone shares collections, so mutations via the |
| 134 | + /// clone are visible through the original. This is not thread-safe but |
| 135 | + /// demonstrates the shared-state problem even without concurrency. |
| 136 | + /// </summary> |
| 137 | + [Test] |
| 138 | + public void SortedSetCloneSharesCollections() |
| 139 | + { |
| 140 | + var original = new SortedSetObject(); |
| 141 | + original.Add(Encoding.ASCII.GetBytes("a"), 1.0); |
| 142 | + original.Add(Encoding.ASCII.GetBytes("b"), 2.0); |
| 143 | + |
| 144 | + var clone = (SortedSetObject)original.Clone(); |
| 145 | + |
| 146 | + // Clone and original share the same Dictionary |
| 147 | + ClassicAssert.AreEqual(2, original.Dictionary.Count); |
| 148 | + ClassicAssert.AreEqual(2, clone.Dictionary.Count); |
| 149 | + |
| 150 | + // Mutate via clone |
| 151 | + clone.Add(Encoding.ASCII.GetBytes("c"), 3.0); |
| 152 | + |
| 153 | + // Original sees the mutation — proves shared reference |
| 154 | + ClassicAssert.AreEqual(3, original.Dictionary.Count, |
| 155 | + "Original should see clone's mutation because they share the same Dictionary"); |
| 156 | + } |
| 157 | + |
| 158 | + /// <summary> |
| 159 | + /// Same issue applies to any GarnetObject with shallow Clone(). |
| 160 | + /// HashObject's Clone() also shares the hash dictionary by reference. |
| 161 | + /// Mutations through one reference are visible through the other. |
| 162 | + /// </summary> |
| 163 | + [Test] |
| 164 | + public void HashObjectCloneSharesMutableState() |
| 165 | + { |
| 166 | + // Create HashObject via deserialization with known entries |
| 167 | + using var ms = new MemoryStream(); |
| 168 | + using var writer = new BinaryWriter(ms, Encoding.UTF8); |
| 169 | + writer.Write((byte)GarnetObjectType.Hash); // type |
| 170 | + writer.Write(0L); // expiration |
| 171 | + writer.Write(2); // count |
| 172 | + var f1 = Encoding.ASCII.GetBytes("field1"); |
| 173 | + var v1 = Encoding.ASCII.GetBytes("value1"); |
| 174 | + writer.Write(f1.Length); writer.Write(f1); |
| 175 | + writer.Write(v1.Length); writer.Write(v1); |
| 176 | + var f2 = Encoding.ASCII.GetBytes("field2"); |
| 177 | + var v2 = Encoding.ASCII.GetBytes("value2"); |
| 178 | + writer.Write(f2.Length); writer.Write(f2); |
| 179 | + writer.Write(v2.Length); writer.Write(v2); |
| 180 | + |
| 181 | + ms.Position = 0; |
| 182 | + using var reader = new BinaryReader(ms, Encoding.UTF8); |
| 183 | + reader.ReadByte(); // type |
| 184 | + var original = new HashObject(reader); |
| 185 | + var clone = (HashObject)original.Clone(); |
| 186 | + |
| 187 | + // Both point to the same internal hash dictionary |
| 188 | + // This is the fundamental issue: shallow clone = shared mutable state |
| 189 | + ClassicAssert.IsNotNull(clone); |
| 190 | + ClassicAssert.IsNotNull(original); |
| 191 | + } |
| 192 | + } |
| 193 | +} |
0 commit comments