From 32d1d12528d06025abb786ac6e33e862f24513e3 Mon Sep 17 00:00:00 2001 From: Jan Kattelans Date: Thu, 1 Oct 2026 08:47:23 +0200 Subject: [PATCH] Ignore already-closed channels when shutting down publisher pools A confirm timeout closes the channel and resets the confirms pool while the channel is still checked out. On checkin the pool's shutdown block closed it again, raising Bunny::ChannelAlreadyClosed. That masked the PublishConfirmationTimeout, and the retry handler then retried it as a connection error, risking a duplicate message. Side effects: - Confirm timeouts and nacks reach the caller and are not retried. - A pool reset by another thread no longer makes a successful publish raise on checkin. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ Gemfile.lock | 4 +-- lib/ears/publisher_channel_pool.rb | 12 ++++++-- lib/ears/version.rb | 2 +- spec/ears/publisher_channel_pool_spec.rb | 36 ++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98154a8..474c6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.26.1 (2026-10-01) + +### Fixed + +- Resetting a publisher channel pool while a closed channel is checked out no longer raises `Bunny::ChannelAlreadyClosed` on checkin. Previously this masked the original error (e.g. `PublishConfirmationTimeout`) and caused it to be retried as a connection error, which could duplicate a message. + ## 0.26.0 (2026-06-01) - Upgrade `connection_pool` dependency to `~> 3.0` diff --git a/Gemfile.lock b/Gemfile.lock index b9382d3..08a8330 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ears (0.26.0) + ears (0.26.1) bunny (>= 3.0.0) connection_pool (~> 3.0) json (>= 2.9.0) @@ -102,7 +102,7 @@ CHECKSUMS bunny (3.3.0) sha256=85ef5562ec2b24f542e8fad69c4302d30d966fb79ff803dc9d0c398a4054672a connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 - ears (0.26.0) + ears (0.26.1) json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 diff --git a/lib/ears/publisher_channel_pool.rb b/lib/ears/publisher_channel_pool.rb index 3bcb368..44171a9 100644 --- a/lib/ears/publisher_channel_pool.rb +++ b/lib/ears/publisher_channel_pool.rb @@ -34,8 +34,8 @@ def reset! @confirms_pool = nil @creator_pid = nil - std_pool&.shutdown(&:close) - cnf_pool&.shutdown(&:close) + std_pool&.shutdown { |channel| close_channel(channel) } + cnf_pool&.shutdown { |channel| close_channel(channel) } nil end @@ -47,7 +47,7 @@ def reset_confirms_pool! cnf_pool = @confirms_pool @confirms_pool = nil - cnf_pool&.shutdown(&:close) + cnf_pool&.shutdown { |channel| close_channel(channel) } nil end @@ -112,6 +112,12 @@ def validate_channel_health!(channel, confirms) 'Channel is closed' end + def close_channel(channel) + channel.close + rescue Bunny::ChannelAlreadyClosed + nil + end + def cleanup_closed_channel(channel) return unless channel.respond_to?(:close) diff --git a/lib/ears/version.rb b/lib/ears/version.rb index 9aba081..876a131 100644 --- a/lib/ears/version.rb +++ b/lib/ears/version.rb @@ -1,3 +1,3 @@ module Ears - VERSION = '0.26.0' + VERSION = '0.26.1' end diff --git a/spec/ears/publisher_channel_pool_spec.rb b/spec/ears/publisher_channel_pool_spec.rb index 74beb5d..0db3801 100644 --- a/spec/ears/publisher_channel_pool_spec.rb +++ b/spec/ears/publisher_channel_pool_spec.rb @@ -84,6 +84,42 @@ end end + context 'when the pool is reset while its channel is checked out and closed' do + let(:mock_channel) do + instance_double(Bunny::Channel, confirm_select: nil) + end + + before do + allow(mock_channel).to receive(:open?).and_return(true, false) + allow(mock_channel).to receive(:close).and_raise( + Bunny::ChannelAlreadyClosed.new( + 'cannot use a closed channel!', + mock_channel, + ), + ) + end + + it 'returns the result of the block' do + result = + described_class.with_channel(confirms: true) do + described_class.reset_confirms_pool! + :published + end + + expect(result).to eq(:published) + end + + it 'raises the error from the block' do + expect { + described_class.with_channel(confirms: true) do + described_class.reset_confirms_pool! + raise Ears::PublishConfirmationTimeout, + 'Confirmation timeout after 5.0s' + end + }.to raise_error(Ears::PublishConfirmationTimeout) + end + end + context 'with confirms: false (default)' do it 'yields a channel from the standard pool' do allow(ConnectionPool).to receive(:new).and_return(mock_standard_pool)