@@ -4,16 +4,20 @@ import (
44 "context"
55 "errors"
66 "math/rand"
7+ "reflect"
8+ "runtime"
79 "sync"
10+ "sync/atomic"
811 "testing"
912 "time"
1013
1114 "github.com/stretchr/testify/require"
1215)
1316
1417func TestPipeliner (t * testing.T ) {
15- v , err := testPipeliner (false , false )
18+ v , launched , err := testPipeliner (false , false )
1619 require .NoError (t , err )
20+ require .Equal (t , len (v ), launched )
1721 for i , e := range v {
1822 require .Equal (t , i , e )
1923 }
@@ -24,21 +28,25 @@ func TestPipelinerError(t *testing.T) {
2428}
2529
2630func testPipelinerError (t * testing.T ) {
27- v , err := testPipeliner (true , false )
31+ v , launched , err := testPipeliner (true , false )
2832 require .Error (t , err )
2933 if err != nil {
3034 require .Equal (t , err .Error (), "errored out" )
3135 }
32- for _ , e := range v [28 :] {
36+ // Indices below `launched` were handed to a goroutine and may have
37+ // written a value; how many of those race ahead of the error being
38+ // noticed varies run to run. Only indices at or past `launched` were
39+ // never launched at all, so they must be untouched.
40+ for _ , e := range v [launched :] {
3341 require .Equal (t , 0 , e )
3442 }
3543}
3644
3745func TestPipelinerCancel (t * testing.T ) {
38- v , err := testPipeliner (false , true )
46+ v , launched , err := testPipeliner (false , true )
3947 require .Error (t , err )
4048 require .Equal (t , err .Error (), "context canceled" )
41- for _ , e := range v [28 :] {
49+ for _ , e := range v [launched :] {
4250 require .Equal (t , 0 , e )
4351 }
4452}
@@ -49,8 +57,121 @@ func TestPipelinerErrorStress(t *testing.T) {
4957 }
5058}
5159
52- func testPipeliner (doError bool , doCancel bool ) ([]int , error ) {
53- v := make ([]int , 100 )
60+ // TestPipelinerNoGoroutineLeakOnError is a regression test: callers that
61+ // follow the documented pattern of returning immediately when WaitForRoom
62+ // errors out must not leak the goroutines that were still in flight.
63+ func TestPipelinerNoGoroutineLeakOnError (t * testing.T ) {
64+ runtime .GC ()
65+ before := runtime .NumGoroutine ()
66+
67+ for range 200 {
68+ testPipelinerError (t )
69+ }
70+
71+ // Give any leaked goroutines a chance to be scheduled and block, so
72+ // they show up in the goroutine count rather than racing this check.
73+ time .Sleep (50 * time .Millisecond )
74+ runtime .GC ()
75+ after := runtime .NumGoroutine ()
76+
77+ require .LessOrEqual (t , after , before + 2 ,
78+ "goroutine count grew from %d to %d, suggesting a leak" , before , after )
79+ }
80+
81+ // TestTryReserveConcurrentNeverExceedsWindow hammers tryReserve from many
82+ // goroutines to guard the check-and-reserve race. It calls tryReserve
83+ // directly rather than through WaitForRoom: concurrent WaitForRoom callers
84+ // can race over CompleteOne's wakeups, a separate hazard this library
85+ // doesn't promise to handle.
86+ func TestTryReserveConcurrentNeverExceedsWindow (t * testing.T ) {
87+ const window = 4
88+ const attempts = 5000
89+
90+ p := NewPipeliner (window )
91+
92+ var wg sync.WaitGroup
93+ var active atomic.Int32
94+ var maxActive atomic.Int32
95+
96+ for range attempts {
97+ wg .Add (1 )
98+ go func () {
99+ defer wg .Done ()
100+ if ! p .tryReserve () {
101+ return
102+ }
103+ defer p .pending .Done ()
104+
105+ n := active .Add (1 )
106+ for {
107+ old := maxActive .Load ()
108+ if n <= old || maxActive .CompareAndSwap (old , n ) {
109+ break
110+ }
111+ }
112+ active .Add (- 1 )
113+ p .landOne ()
114+ }()
115+ }
116+
117+ wg .Wait ()
118+ require .LessOrEqual (t , int (maxActive .Load ()), window ,
119+ "observed %d reservations active at once, exceeding window %d" , maxActive .Load (), window )
120+ }
121+
122+ // TestPublicAPIDoesNotExposeLockMethods guards against re-embedding
123+ // sync.RWMutex (rather than holding it as an unexported field), which would
124+ // silently promote Lock/Unlock/RLock/RUnlock onto the public API again.
125+ func TestPublicAPIDoesNotExposeLockMethods (t * testing.T ) {
126+ typ := reflect .TypeFor [* Pipeliner ]()
127+ for _ , name := range []string {"Lock" , "Unlock" , "RLock" , "RUnlock" } {
128+ _ , ok := typ .MethodByName (name )
129+ require .False (t , ok , "Pipeliner must not export %s" , name )
130+ }
131+ }
132+
133+ // TestSetErrorFirstWins pins down the sticky-error invariant directly: once
134+ // an error is recorded, later errors must never overwrite it.
135+ func TestSetErrorFirstWins (t * testing.T ) {
136+ p := NewPipeliner (4 )
137+ first := errors .New ("first" )
138+ second := errors .New ("second" )
139+
140+ p .setError (first )
141+ p .setError (second )
142+
143+ require .Equal (t , first , p .getError ())
144+ }
145+
146+ // TestStickyErrorReturnedByWaitForRoomAndFlush verifies the sticky error
147+ // surfaces consistently through the public API: once set, WaitForRoom and
148+ // Flush keep returning it, even across further calls on the same instance.
149+ func TestStickyErrorReturnedByWaitForRoomAndFlush (t * testing.T ) {
150+ p := NewPipeliner (1 )
151+ ctx := context .Background ()
152+
153+ require .NoError (t , p .WaitForRoom (ctx ))
154+ first := errors .New ("first" )
155+ go p .CompleteOne (first )
156+
157+ err := p .Flush (ctx )
158+ require .Equal (t , first , err )
159+
160+ // Reusing the instance after an error keeps returning the same error
161+ // rather than doing further work or surfacing a different one.
162+ err = p .WaitForRoom (ctx )
163+ require .Equal (t , first , err )
164+
165+ err = p .Flush (ctx )
166+ require .Equal (t , first , err )
167+ }
168+
169+ // testPipeliner runs a batch of 100 simulated requests through a Pipeliner.
170+ // It returns the results, the number of requests actually handed to a
171+ // goroutine before WaitForRoom stopped returning nil, and any resulting
172+ // error.
173+ func testPipeliner (doError bool , doCancel bool ) (v []int , launched int , err error ) {
174+ v = make ([]int , 100 )
54175 var vlock sync.Mutex
55176 pipeliner := NewPipeliner (4 )
56177 ctx := context .Background ()
@@ -74,17 +195,16 @@ func testPipeliner(doError bool, doCancel bool) ([]int, error) {
74195 }
75196
76197 for i := range v {
77- err := pipeliner .WaitForRoom (ctx )
78- if err != nil {
79- // Ensure any in-flight goroutines are complete before returning. Note that Flush does not actually wait if there is an error.
80- for pipeliner .hasOutstanding () {
81- <- pipeliner .ch
82- }
83- return v , err
198+ werr := pipeliner .WaitForRoom (ctx )
199+ if werr != nil {
200+ // WaitForRoom drains any in-flight goroutines before returning
201+ // an error, so it's safe to return immediately here.
202+ return v , launched , werr
84203 }
204+ launched ++
85205 go func (i int ) { pipeliner .CompleteOne (f (ctx , i )) }(i )
86206 }
87207
88- err : = pipeliner .Flush (ctx )
89- return v , err
208+ err = pipeliner .Flush (ctx )
209+ return v , launched , err
90210}
0 commit comments