Skip to content

Commit 3579a5f

Browse files
shubhamdhamaclaude
andcommitted
drpcpool: close connections returned to a closed pool
Pool.Close only sees the connections currently idle in the cache. Connections that are checked out serving an in-flight Invoke or NewStream live outside the cache and are returned later via Put. Because the pool had no closed state, such a late Put re-inserted the connection and armed a fresh expiration timer, resurrecting the pool and leaking the connection (and its manager goroutines) until the timer fired. This is reachable on shutdown: long-lived streams (e.g. rangefeeds) hold their connection checked out, so Close cannot reach them. When the stream context is later canceled, the connection is handed back via Put and lingers well past the point the pool was closed. Mark the pool closed under the lock in Close, and have Put close the connection immediately instead of caching it once the pool is closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bea82b2 commit 3579a5f

2 files changed

Lines changed: 56 additions & 0 deletions

File tree

drpcpool/pool.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type Options struct {
5252
type Pool[K comparable, V Conn] struct {
5353
opts Options
5454
mu sync.Mutex
55+
closed bool
5556
entries map[K]*list[K, V]
5657
order list[K, V]
5758
}
@@ -119,10 +120,20 @@ func (p *Pool[K, V]) log(what string, cb func() string) {
119120

120121
// Close evicts all entries from the Pool's cache, closing them and returning all
121122
// of the combined errors from closing.
123+
//
124+
// Close also marks the pool as closed so that any connection subsequently
125+
// returned via Put is closed immediately rather than cached. This matters
126+
// because Close can only see the connections currently idle in the cache:
127+
// connections that are checked out (serving an in-flight Invoke or NewStream)
128+
// live outside the cache and are returned later via Put. Without the closed
129+
// flag those late returns would resurrect the pool, re-arming an expiration
130+
// timer and leaking the connection (and its manager goroutines) until it fires.
122131
func (p *Pool[K, V]) Close() (err error) {
123132
p.mu.Lock()
124133
defer p.mu.Unlock()
125134

135+
p.closed = true
136+
126137
var eg errs.Group
127138
for ent := p.order.head; ent != nil; ent = ent.global.next {
128139
eg.Add(p.closeEntry(ent))
@@ -233,6 +244,13 @@ func (p *Pool[K, V]) Put(key K, val V) {
233244
p.mu.Lock()
234245
defer p.mu.Unlock()
235246

247+
// If the pool has been closed, don't cache the connection (which would
248+
// resurrect the pool and leak the connection); close it instead.
249+
if p.closed {
250+
_ = val.Close()
251+
return
252+
}
253+
236254
local := p.entries[key]
237255
if local == nil {
238256
local = new(list[K, V])

drpcpool/pool_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,3 +672,41 @@ func BenchmarkPool(b *testing.B) {
672672
invoke(ctx, conn)
673673
}
674674
}
675+
676+
// TestPoolPutAfterClose verifies that a connection returned to the pool after
677+
// the pool is closed is closed immediately rather than cached. This models a
678+
// long-lived stream whose connection is checked out (and therefore invisible to
679+
// Close) when the pool is closed, and only handed back via Put once the stream
680+
// finally ends. Caching it would re-arm the expiration timer and leak the
681+
// connection until it fires.
682+
func TestPoolPutAfterClose(t *testing.T) {
683+
ctx := drpctest.NewTracker(t)
684+
defer ctx.Close()
685+
686+
// Use a non-zero expiration so a cached connection would linger (and arm a
687+
// timer) rather than be evicted by capacity limits.
688+
pool := New[string, Conn](Options{Expiration: time.Hour})
689+
690+
closed := make(chan string, 1)
691+
conn := &callbackConn{CloseFn: func() error { closed <- "key"; return nil }}
692+
693+
// Stage the connection in the pool, then check it out, modeling an in-flight
694+
// stream. While checked out the connection lives outside the cache.
695+
pool.Put("key", conn)
696+
got, ok := pool.Take("key")
697+
assert.True(t, ok)
698+
assert.Equal(t, got, conn)
699+
assert.Equal(t, len(closed), 0)
700+
701+
// Closing the pool cannot see the checked-out connection.
702+
assert.NoError(t, pool.Close())
703+
assert.Equal(t, len(closed), 0)
704+
705+
// Returning it now must close it instead of resurrecting the pool.
706+
pool.Put("key", conn)
707+
assert.Equal(t, <-closed, "key")
708+
709+
// And it must not have been retained.
710+
_, ok = pool.Take("key")
711+
assert.That(t, !ok)
712+
}

0 commit comments

Comments
 (0)