Steps to reproduce
Configure ActionCable's Redis subscription adapter to use ConnectionPool::Wrapper, a pattern recommended by redis-rb's README for thread safety:
config/initializers/action_cable_redis.rb
require "action_cable/subscription_adapter/redis"
ActionCable::SubscriptionAdapter::Redis.redis_connector = lambda { |redis_config|
ConnectionPool::Wrapper.new(size: Rails.configuration.action_cable.worker_pool_size, timeout: 3) do
Redis.new(redis_config.except(:adapter, :channel_prefix))
end
}
Then have a client subscribe to a channel and disconnect (or call stop_all_streams) so that ActionCable::SubscriptionAdapter::Redis::Listener#remove_channel is invoked from a worker thread.
Expected behavior
The channel should be unsubscribed cleanly without raising.
Actual behavior
Redis::SubscriptionError: This client is not subscribed
redis-5.4.1/lib/redis.rb:185:in 'Redis#_subscription'
redis-5.4.1/lib/redis/commands/pubsub.rb:28:in 'Redis::Commands::Pubsub#unsubscribe'
connection_pool-3.0.2/lib/connection_pool/wrapper.rb:39:in 'block in ConnectionPool::Wrapper#method_missing'
connection_pool-3.0.2/lib/connection_pool.rb:68:in 'block (2 levels) in ConnectionPool#with'
...
actioncable-8.1.3/lib/action_cable/subscription_adapter/redis.rb:151:in 'block (2 levels) in ActionCable::SubscriptionAdapter::Redis::Listener#remove_channel'
actioncable-8.1.3/lib/action_cable/subscription_adapter/redis.rb:179:in 'ActionCable::SubscriptionAdapter::Redis::Listener#when_connected'
actioncable-8.1.3/lib/action_cable/subscription_adapter/redis.rb:150:in 'ActionCable::SubscriptionAdapter::Redis::Listener#remove_channel'
actioncable-8.1.3/lib/action_cable/subscription_adapter/subscriber_map.rb:33:in 'block in ActionCable::SubscriptionAdapter::SubscriberMap#remove_subscriber'
actioncable-8.1.3/lib/action_cable/subscription_adapter/redis.rb:37:in 'ActionCable::SubscriptionAdapter::Redis#unsubscribe'
actioncable-8.1.3/lib/action_cable/channel/streams.rb:139:in 'block in ActionCable::Channel::Streams#stop_all_streams'
...
Root cause analysis
In actioncable/lib/action_cable/subscription_adapter/redis.rb, the redis-rb 5.x branch defines extract_subscribed_client as:
Impact
ActionCable's redis_connector is officially user-configurable, and ConnectionPool::Wrapper is the pattern that the redis-rb README points users toward for "thread safety". Users naturally combine the two and silently get a broken pub/sub on redis-rb 5.x.
The error only triggers on subscription teardown, so it slips through development and bites in production once a client disconnects.
System configuration
Rails / ActionCable: 8.1.3
redis-rb: 5.4.1
connection_pool: 3.0.2
Ruby: 3.4.x
Steps to reproduce
Configure ActionCable's Redis subscription adapter to use ConnectionPool::Wrapper, a pattern recommended by redis-rb's README for thread safety:
config/initializers/action_cable_redis.rb
require "action_cable/subscription_adapter/redis"
ActionCable::SubscriptionAdapter::Redis.redis_connector = lambda { |redis_config|
ConnectionPool::Wrapper.new(size: Rails.configuration.action_cable.worker_pool_size, timeout: 3) do
Redis.new(redis_config.except(:adapter, :channel_prefix))
end
}
Then have a client subscribe to a channel and disconnect (or call stop_all_streams) so that ActionCable::SubscriptionAdapter::Redis::Listener#remove_channel is invoked from a worker thread.
Expected behavior
The channel should be unsubscribed cleanly without raising.
Actual behavior
Redis::SubscriptionError: This client is not subscribed
redis-5.4.1/lib/redis.rb:185:in 'Redis#_subscription'
redis-5.4.1/lib/redis/commands/pubsub.rb:28:in 'Redis::Commands::Pubsub#unsubscribe'
connection_pool-3.0.2/lib/connection_pool/wrapper.rb:39:in 'block in ConnectionPool::Wrapper#method_missing'
connection_pool-3.0.2/lib/connection_pool.rb:68:in 'block (2 levels) in ConnectionPool#with'
...
actioncable-8.1.3/lib/action_cable/subscription_adapter/redis.rb:151:in 'block (2 levels) in ActionCable::SubscriptionAdapter::Redis::Listener#remove_channel'
actioncable-8.1.3/lib/action_cable/subscription_adapter/redis.rb:179:in 'ActionCable::SubscriptionAdapter::Redis::Listener#when_connected'
actioncable-8.1.3/lib/action_cable/subscription_adapter/redis.rb:150:in 'ActionCable::SubscriptionAdapter::Redis::Listener#remove_channel'
actioncable-8.1.3/lib/action_cable/subscription_adapter/subscriber_map.rb:33:in 'block in ActionCable::SubscriptionAdapter::SubscriberMap#remove_subscriber'
actioncable-8.1.3/lib/action_cable/subscription_adapter/redis.rb:37:in 'ActionCable::SubscriptionAdapter::Redis#unsubscribe'
actioncable-8.1.3/lib/action_cable/channel/streams.rb:139:in 'block in ActionCable::Channel::Streams#stop_all_streams'
...
Root cause analysis
In actioncable/lib/action_cable/subscription_adapter/redis.rb, the redis-rb 5.x branch defines extract_subscribed_client as:
Impact
ActionCable's redis_connector is officially user-configurable, and ConnectionPool::Wrapper is the pattern that the redis-rb README points users toward for "thread safety". Users naturally combine the two and silently get a broken pub/sub on redis-rb 5.x.
The error only triggers on subscription teardown, so it slips through development and bites in production once a client disconnects.
System configuration
Rails / ActionCable: 8.1.3
redis-rb: 5.4.1
connection_pool: 3.0.2
Ruby: 3.4.x