Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions pubsub.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ type PubSub struct {
closed bool
exit chan struct{}

// stickyErr is set when PubSub is constructed in a failed state (e.g. Ring
// shard lookup failure). Subsequent operations return this error instead of
// panicking at construction time.
stickyErr error

cmd *Cmd

chOnce sync.Once
Expand Down Expand Up @@ -72,6 +77,9 @@ func (c *PubSub) connWithLock(ctx context.Context) (*pool.Conn, error) {
}

func (c *PubSub) conn(ctx context.Context, newChannels []string) (*pool.Conn, error) {
if c.stickyErr != nil {
return nil, c.stickyErr
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sticky error blocks Channel close

High Severity

conn returns stickyErr before checking closed, so after Close on a failed PubSub (from Ring empty subscribe or shard lookup failure), Receive never yields pool.ErrClosed. initMsgChan / initAllChan only exit on that error, so calling Channel then Close leaves the receive goroutine running forever and for range on the channel hangs. That breaks the documented contract that the Go channel closes with the PubSub. The pool sticky pattern in SingleConnPool.Close overrides sticky with ErrClosed instead.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 924803b. Configure here.

if c.closed {
return nil, pool.ErrClosed
Comment on lines +80 to 84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let Close override the sticky error

When Ring.Subscribe, PSubscribe, or SSubscribe returns a failed PubSub and the caller uses either channel API, the receiver goroutine repeatedly gets stickyErr. After Close sets closed, this ordering still returns the sticky error instead of pool.ErrClosed, so initMsgChan/initAllChan never exits or closes the documented output channel; consumers can remain blocked and one goroutine leaks per failed subscription. Check closed before stickyErr, or otherwise make channels terminate for a permanently failed PubSub.

Useful? React with 👍 / 👎.

}
Expand Down Expand Up @@ -575,8 +583,13 @@ func (c *PubSub) Channel(opts ...ChannelOption) <-chan *Message {
c.msgCh.initMsgChan()
})
if c.msgCh == nil {
err := fmt.Errorf("redis: Channel can't be called after ChannelWithSubscriptions")
panic(err)
// Already using ChannelWithSubscriptions — return a closed channel
// instead of panicking so callers can recover (issue #3761).
internal.Logger.Printf(c.getContext(),
"redis: Channel can't be called after ChannelWithSubscriptions")
Comment on lines +588 to +589

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reading the receiver command from the logging path

When the caller invokes the conflicting channel API after the first channel API returns, that first call has already started a receiver goroutine which initializes c.cmd in ReceiveTimeout; this new c.getContext() call reads the same field without synchronization and can therefore race with that initialization even though the two public calls were sequential. The symmetric logging path in ChannelWithSubscriptions has the same problem; log with a context that does not inspect receiver-owned state or synchronize access to that state.

Useful? React with 👍 / 👎.

ch := make(chan *Message)
close(ch)
return ch
}
return c.msgCh.msgCh
}
Expand All @@ -600,8 +613,13 @@ func (c *PubSub) ChannelWithSubscriptions(opts ...ChannelOption) <-chan interfac
c.allCh.initAllChan()
})
if c.allCh == nil {
err := fmt.Errorf("redis: ChannelWithSubscriptions can't be called after Channel")
panic(err)
// Already using Channel — return a closed channel instead of panicking
// so callers can recover (issue #3761).
internal.Logger.Printf(c.getContext(),
"redis: ChannelWithSubscriptions can't be called after Channel")
ch := make(chan interface{})
close(ch)
return ch
}
return c.allCh.allCh
}
Expand Down
51 changes: 51 additions & 0 deletions pubsub_sticky_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package redis_test

import (
"context"
"testing"

"github.com/redis/go-redis/v9"
)

func TestRingSubscribeEmptyChannelsNoPanic(t *testing.T) {
ring := redis.NewRing(&redis.RingOptions{
Addrs: map[string]string{"shard1": "localhost:6379"},
})
defer ring.Close()

pubsub := ring.Subscribe(context.Background())
if pubsub == nil {
t.Fatal("expected non-nil PubSub")
}
// Receive should surface sticky error, not panic.
_, err := pubsub.Receive(context.Background())
if err == nil {
t.Fatal("expected sticky error from empty Subscribe")
}
_ = pubsub.Close()
}

func TestPubSubChannelMutualExclusionNoPanic(t *testing.T) {
// Construct via a client that may not be reachable — Subscribe without
// channels just builds a PubSub handle.
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"})
defer client.Close()
pubsub := client.Subscribe(context.Background())
defer pubsub.Close()

_ = pubsub.ChannelWithSubscriptions()
ch := pubsub.Channel()
// Channel must return a closed/empty channel rather than panicking.
select {
case _, ok := <-ch:
if ok {
t.Fatal("expected closed channel from conflicting Channel() call")
}
default:
// non-blocking closed channel may still be receivable; try again with receive
_, ok := <-ch
if ok {
t.Fatal("expected closed channel")
}
}
}
29 changes: 20 additions & 9 deletions ring.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,43 +705,54 @@ func (c *Ring) Len() int {
return c.sharding.Len()
}

// failedPubSub returns a PubSub that surfaces err on every operation instead of
// panicking at construction (issue #3761).
func (c *Ring) failedPubSub(err error) *PubSub {
if err == nil {
err = fmt.Errorf("redis: pubsub failed")
}
pubsub := &PubSub{
opt: c.opt.clientOptions(),
stickyErr: err,
}
pubsub.init()
return pubsub
}

// Subscribe subscribes the client to the specified channels.
func (c *Ring) Subscribe(ctx context.Context, channels ...string) *PubSub {
if len(channels) == 0 {
panic("at least one channel is required")
return c.failedPubSub(fmt.Errorf("redis: at least one channel is required"))
}

shard, err := c.sharding.GetByKey(channels[0])
if err != nil {
// TODO: return PubSub with sticky error
panic(err)
return c.failedPubSub(err)
}
return shard.Client.Subscribe(ctx, channels...)
}

// PSubscribe subscribes the client to the given patterns.
func (c *Ring) PSubscribe(ctx context.Context, channels ...string) *PubSub {
if len(channels) == 0 {
panic("at least one channel is required")
return c.failedPubSub(fmt.Errorf("redis: at least one channel is required"))
}

shard, err := c.sharding.GetByKey(channels[0])
if err != nil {
// TODO: return PubSub with sticky error
panic(err)
return c.failedPubSub(err)
}
return shard.Client.PSubscribe(ctx, channels...)
}

// SSubscribe Subscribes the client to the specified shard channels.
func (c *Ring) SSubscribe(ctx context.Context, channels ...string) *PubSub {
if len(channels) == 0 {
panic("at least one channel is required")
return c.failedPubSub(fmt.Errorf("redis: at least one channel is required"))
}
shard, err := c.sharding.GetByKey(channels[0])
if err != nil {
// TODO: return PubSub with sticky error
panic(err)
return c.failedPubSub(err)
}
return shard.Client.SSubscribe(ctx, channels...)
}
Expand Down
Loading