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)!;
+ }
}