Skip to content

Commit fe259ee

Browse files
authored
fix(circular): release references beyond initial set on Reset (#55)
* test(circular): add failing test for Reset tail retention Reset copies initialElements over the first N slots but leaves the rest of the backing array populated with whatever Offer wrote there. For pointer T the extras stay reachable and are never GC'd. Runtime-finalizer test that fails on current main and demonstrates the leak. Refs #44 * fix(circular): zero slots past initial set on Reset copy(q.elems, q.initialElements) only overwrites the first N slots. For pointer T the remainder of the backing array stayed populated, keeping popped elements reachable until a later Offer happened to write over each slot. Zero [len(initialElements), len(elems)) after the copy. Fixes #44
1 parent 7e86732 commit fe259ee

2 files changed

Lines changed: 61 additions & 0 deletions

File tree

circular.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ func (q *Circular[T]) Reset() {
107107

108108
copy(q.elems, q.initialElements)
109109

110+
// Drop references in any slot past the initial set; otherwise pointer
111+
// T stays reachable via the backing array until a future Offer
112+
// overwrites each slot.
113+
var zero T
114+
115+
for i := len(q.initialElements); i < len(q.elems); i++ {
116+
q.elems[i] = zero
117+
}
118+
110119
q.head = 0
111120
q.tail = 0
112121
q.size = len(q.initialElements)

circular_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"encoding/json"
66
"errors"
77
"reflect"
8+
"runtime"
89
"testing"
10+
"time"
911

1012
"github.com/adrianbrad/queue"
1113
)
@@ -25,6 +27,56 @@ func TestCircular(t *testing.T) {
2527
t.Run("Iterator", testCircularIterator)
2628
t.Run("MarshalJSON", testCircularMarshalJSON)
2729
t.Run("NonPositiveCapacity", testCircularNonPositiveCapacity)
30+
t.Run("ResetReleasesBeyondInitial", testCircularResetReleasesBeyondInitial)
31+
}
32+
33+
func testCircularResetReleasesBeyondInitial(t *testing.T) {
34+
t.Parallel()
35+
36+
type payload struct{ id int }
37+
38+
var cq *queue.Circular[*payload]
39+
40+
finalized := make(chan struct{}, 3)
41+
42+
func() {
43+
// Capacity 5, 2 initial elems, then offer 3 more. Reset must
44+
// release the 3 extras; on unfixed code, slots 2..4 still hold
45+
// them via the backing array.
46+
cq = queue.NewCircular([]*payload{{id: 1}, {id: 2}}, 5)
47+
48+
for i := 3; i <= 5; i++ {
49+
p := &payload{id: i}
50+
runtime.SetFinalizer(p, func(*payload) {
51+
finalized <- struct{}{}
52+
})
53+
54+
if err := cq.Offer(p); err != nil {
55+
t.Fatalf("offer: %v", err)
56+
}
57+
}
58+
59+
cq.Reset()
60+
}()
61+
62+
deadline := time.After(time.Second)
63+
64+
count := 0
65+
for count < 3 {
66+
runtime.GC() //nolint:revive // explicit GC needed to drive finalizer
67+
68+
select {
69+
case <-finalized:
70+
count++
71+
case <-deadline:
72+
runtime.KeepAlive(cq)
73+
t.Fatalf("only %d/3 extra payloads finalized after Reset", count)
74+
default:
75+
time.Sleep(10 * time.Millisecond)
76+
}
77+
}
78+
79+
runtime.KeepAlive(cq)
2880
}
2981

3082
func testCircularNonPositiveCapacity(t *testing.T) {

0 commit comments

Comments
 (0)