[Repo Assist] fix: CircularBuffer.GetEnumerator infinite-recursion and destructive-enumeration#257
Conversation
…enumeration The IEnumerable<'T> and IEnumerable interface implementations each called themselves via (this :> Interface).GetEnumerator(), causing a stack overflow whenever the buffer was enumerated (e.g. Seq.toList, for-in loops). Additionally, the old member GetEnumerator() was destructive: it consumed elements via Dequeue, so iterating the buffer would empty it and could loop forever on an empty buffer. Fix: replace with a non-destructive index-based implementation that reads directly from the internal buffer array using (tail + i) % bufferSize. Also adds 6 new tests covering: - Seq.toList is non-destructive - for-in loop enumeration - Wrapped-around buffer enumeration order - Empty buffer enumeration - IReadOnlyCollection.Count via interface - Enqueue(array, offset) 2-arg overload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes CircularBuffer<'T> enumeration so it no longer stack-overflows (due to explicit-interface infinite recursion) and no longer mutates/empties the buffer during iteration, bringing behavior in line with IReadOnlyCollection<'T> expectations.
Changes:
- Replaced destructive, recursive
GetEnumeratorimplementation with an index-based enumerator over the internal ring buffer. - Updated explicit
IEnumerable<'T>/IEnumerableimplementations to delegate to the concreteGetEnumerator(avoiding self-recursion). - Added tests covering non-destructive enumeration, FIFO ordering (including wrap-around), empty enumeration, and the
Enqueue(array, offset)overload.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/FSharpx.Collections/CircularBuffer.fs | Reworks enumerator implementation to be non-destructive and fixes explicit-interface recursion. |
| tests/FSharpx.Collections.Tests/CircularBufferTests.fs | Adds regression tests for enumeration behavior and Enqueue(array, offset). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| loop().GetEnumerator() | ||
| member this.GetEnumerator() : IEnumerator<'T> = |
There was a problem hiding this comment.
GetEnumerator builds a seq that reads the mutable tail field on each iteration (buffer.[(tail + i) % bufferSize]). If the buffer is modified during enumeration, this can shift the effective start index mid-enumeration and produce duplicated/skipped elements. Consider snapshotting tail (and length) into local let bindings before constructing the sequence so the enumerator is stable for the duration of enumeration.
| member this.GetEnumerator() : IEnumerator<'T> = | |
| member this.GetEnumerator() : IEnumerator<'T> = | |
| let tail = tail | |
| let length = length |
| result <- result @ [ x ] | ||
|
|
There was a problem hiding this comment.
Building result via result <- result @ [ x ] inside the loop repeatedly appends to the end of a list (O(n^2) behavior). Prefer accumulating with x :: result and reversing once at the end, or using a ResizeArray/System.Collections.Generic.List and converting to a list at the end.
| result <- result @ [ x ] | |
| result <- x :: result | |
| let result = List.rev result |
🤖 This PR was created by Repo Assist, an automated AI assistant.
Root Cause
Two bugs existed in
CircularBuffer<'T>.GetEnumerator:Bug 1 — Infinite recursion in interface implementations
Casting
thisto the interface and callingGetEnumerator()dispatches to the explicit interface implementation — the same method — causing a stack overflow on anyfor x in buffer,Seq.toList, orSeq.itercall.Bug 2 — Destructive enumeration
The
member this.GetEnumerator()loop consumed elements viathis.Dequeue(1), so iterating the buffer would empty it. For an empty buffer the loop would never terminate (it kept callingloop()after yielding nothing, producing an infinite empty sequence). This violates theIReadOnlyCollection<'T>contract.Fix
Replace both bugs with a single, non-destructive, index-based enumerator:
The interface implementations now delegate to the concrete
member(not through the interface), eliminating the recursion. The concrete member reads directly from the internalbufferarray using(tail + i) % bufferSize— no elements are removed.Trade-offs
for x in bufNew Tests (6)
Enqueue(array, offset)2-arg overload (was untested)Seq.toListis non-destructive — count unchanged after enumerationfor-inloop returns elements in FIFO orderSeq.toListIReadOnlyCollection.Countvia interface after wrapTest Status
✅ 901 tests passed, 0 failed (2 pre-existing pending skips)
✅ Fantomas format check passed