fix(sentinel): guard typed-nil pool on ctor panic

newConnPool returns a nil *pool.ConnPool for an out-of-range PoolSize;
assigning it straight into the pool.Pooler interface field left a
typed-nil that closeResources' `!= nil` check treats as present, so the
ctor-panic cleanup nil-dereffed inside ConnPool.Close and replaced the
intended "failed to create connection pool" panic. Assign through a local
after the error check, as NewClient already does. pubSubPool is a concrete
*pool.PubSubPool whose nil is caught correctly, but assign it the same way
to keep this constructor identical to NewClient.

Refs #4002
This commit is contained in:
Nedyalko Dyakov committed 2026-09-08 19:26:45 +03:00
1 parent 2d433b0c2f
commit 5e50f2b301
2 files changed
+42 -3

No files matched your search

+29
View File
@@ -158,3 +158,32 @@ func TestNewFailoverClientPanicClosesPartialClient(t *testing.T) {
t.Fatalf("cleanup replaced the construction panic (partial failover Close nil-deref?): recovered=%v (%T)", recovered, recovered)
}
}
// TestNewFailoverClientPanicCleanupSurvivesNilPool is the NewFailoverClient twin
// of TestNewClientPanicCleanupSurvivesNilPool, pinning the specific bug where the
// failover constructor assigned newConnPool's typed-nil straight into the
// pool.Pooler field. An out-of-range PoolSize makes newConnPool return (nil, err),
// so the field must stay a plain nil (not a typed-nil interface) — otherwise
// closeResources' `connPool != nil` guard passes and the panic-cleanup defer
// nil-derefs inside ConnPool.Close, replacing the intended "failed to create
// connection pool" panic. MinIdleConns is 0 and the sentinel addrs are never
// contacted: the panic precedes any dialing.
func TestNewFailoverClientPanicCleanupSurvivesNilPool(t *testing.T) {
var recovered any
func() {
defer func() { recovered = recover() }()
NewFailoverClient(&FailoverOptions{
MasterName: "mymaster",
SentinelAddrs: []string{"127.0.0.1:26379"}, // never contacted: panic precedes dialing
PoolSize: math.MaxInt32 + 1, // overflows int32 in newConnPool
MinIdleConns: 0,
})
}()
if recovered == nil {
t.Fatal("expected NewFailoverClient to panic on an out-of-range PoolSize")
}
err, ok := recovered.(error)
if !ok || !strings.Contains(err.Error(), "failed to create connection pool") {
t.Fatalf("cleanup replaced the construction panic (typed-nil connPool nil-deref?): recovered=%v (%T)", recovered, recovered)
}
}
+13 -3
View File
@@ -603,15 +603,25 @@ func NewFailoverClient(failoverOpt *FailoverOptions) *Client {
mainPoolName := opt.Addr + "_" + uniqueID
pubsubPoolName := opt.Addr + "_" + uniqueID + "_pubsub"
var err error
rdb.connPool, err = newConnPool(opt, rdb.dialHook, mainPoolName)
// Assign the pool fields only AFTER the error check, mirroring NewClient.
// newConnPool returns a nil *pool.ConnPool on error, and assigning that
// straight to the pool.Pooler interface field would leave a typed-nil
// interface that closeResources treats as present (its != nil check passes),
// so the panic-cleanup defer above would nil-deref inside ConnPool.Close and
// replace the intended "failed to create connection pool" panic. A local var
// keeps the field nil on failure. (pubSubPool is a concrete *pool.PubSubPool
// whose nil is caught correctly by the != nil check, but assign it the same
// way to keep this constructor identical to NewClient.)
connPool, err := newConnPool(opt, rdb.dialHook, mainPoolName)
if err != nil {
panic(fmt.Errorf("redis: failed to create connection pool: %w", err))
}
rdb.pubSubPool, err = newPubSubPool(opt, rdb.dialHook, pubsubPoolName)
rdb.connPool = connPool
pubSubPool, err := newPubSubPool(opt, rdb.dialHook, pubsubPoolName)
if err != nil {
panic(fmt.Errorf("redis: failed to create pubsub pool: %w", err))
}
rdb.pubSubPool = pubSubPool
// Create the dedicated pipeline pool unconditionally, mirroring NewClient
// via the shared buildPipelinePool helper. PipelinePoolSize < 0 opts out.