diff --git a/src/RESPite/Buffers/CycleBuffer.cs b/src/RESPite/Buffers/CycleBuffer.cs index adc15c60e..a56c0a4b2 100644 --- a/src/RESPite/Buffers/CycleBuffer.cs +++ b/src/RESPite/Buffers/CycleBuffer.cs @@ -571,6 +571,14 @@ public void Recycle() Memory = default; RunningIndex = 0; _flags = Flags.None; + + // This object becomes available - via the SHARED, STATIC _spare slot below - to any CycleBuffer in the + // process, handed out by Segment.Create as a pristine segment, so StartTrimCount must not survive it. + // Some callers recycle without Untrim first (Release; AppendOrRecycle's search-exhausted path), and Init + // never touches it, so a stale trim reached a "new" segment: Debug.Assert(leasedStart == 0) in Debug, + // and in Release a later Untrim expanding backwards by a trim that never happened, corrupting the + // chain's running indexes. Found under PR #3251's read/parse stress test; absorbed into v4 with the split. + StartTrimCount = 0; Interlocked.Exchange(ref _spare, this); DebugAssertValidChain(); } diff --git a/tests/RESPite.Tests/CycleBufferTests.cs b/tests/RESPite.Tests/CycleBufferTests.cs index df45420c6..883d8b42d 100644 --- a/tests/RESPite.Tests/CycleBufferTests.cs +++ b/tests/RESPite.Tests/CycleBufferTests.cs @@ -128,4 +128,75 @@ public void CanDiscardSafely(Timing timing) Assert.Equal(0, buffer.GetCommittedLength()); } + + /// + /// A segment recycled while start-trimmed must not carry its trim into the next buffer that is handed it. + /// + /// + /// Recycled segments go to a process-wide spare slot and come back out of Segment.Create as pristine. + /// Recycle did not reset StartTrimCount, and some paths - , and + /// AppendOrRecycle's search-exhausted path - recycle without Untrim first, so the next buffer's + /// "new" segment wrote at a stale offset and read its committed bytes from the wrong place. Found under #3251's + /// read/parse stress test as Debug.Assert(leasedStart == 0); absorbed into v4 with the split. + /// + [Fact] + public void ARecycledTrimmedSegmentStartsCleanInTheNextBuffer() + { + // the spare slot is process-wide and the suite is parallel, so another buffer can take the segment first; + // repeat the scenario rather than assert on a race nobody controls + for (var attempt = 0; attempt < 50; attempt++) + { + var first = CycleBuffer.Create(); + first.Write("0123456789"u8); + first.DiscardCommitted(4); // start-trimmed: StartTrimCount is now 4 + first.Release(); // recycled WITHOUT Untrim + + var second = CycleBuffer.Create(); + + second.Write("hello"u8); + + // read directly, because in Release - where the Debug.Assert is compiled out - nothing visible fails + // at once: the stale trim only corrupts the chain arithmetic of a later Untrim + Assert.Equal(0, StartTrimCountOfFirstSegment(second)); + Assert.True(second.TryGetCommitted(out var committed)); + Assert.Equal("hello", System.Text.Encoding.ASCII.GetString(committed.ToArray())); + second.Release(); + } + } + + /// + /// The observable consequence of a stale start trim: a discard that empties the buffer while a write lease is + /// outstanding (the outbound copy-out loop draining while the writer holds GetMemory) must still keep the + /// bytes that were written into the lease. + /// + [Fact] + public void DiscardDuringWriteKeepsLeasedBytesAfterARecycledTrimmedSegment() + { + for (var attempt = 0; attempt < 50; attempt++) + { + var first = CycleBuffer.Create(); + first.Write("0123456789"u8); + first.DiscardCommitted(4); // start-trimmed + first.Release(); // recycled WITHOUT Untrim + + var second = CycleBuffer.Create(); + second.Write("abc"u8); + + var lease = second.GetUncommittedMemory(); + "XYZ"u8.CopyTo(lease.Span); + second.DiscardCommitted(3); // drains everything committed while the lease is open + second.Commit(3); + + Assert.True(second.TryGetCommitted(out var committed)); + Assert.Equal("XYZ", System.Text.Encoding.ASCII.GetString(committed.ToArray())); + second.Release(); + } + } + + private static int StartTrimCountOfFirstSegment(CycleBuffer buffer) + { + const System.Reflection.BindingFlags Any = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; + var segment = typeof(CycleBuffer).GetField("startSegment", Any)!.GetValue(buffer)!; + return (int)segment.GetType().GetProperty("StartTrimCount", Any)!.GetValue(segment)!; + } }