From ef53e8c62826729544c533d2ffe0bee2f65c825b Mon Sep 17 00:00:00 2001 From: Simon Woolf Date: Fri, 9 Oct 2026 04:10:07 +0100 Subject: [PATCH] Raise when releasing a realtime channel that isn't detached (RTS4c-e) Spec 6.3.0 replaces RTS4a, under which Channels#release detaches the channel before removing it, with RTS4c-e: release now removes a channel only if it is initialized, detached or failed, and otherwise raises error 90011 (status 400) and leaves the channel alone, so that a channel is never released while it could still receive messages from Ably. 1.x logs a deprecation warning for this case (RTS4b); this is the breaking change for 2.0, documented in UPDATING.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- UPDATING.md | 19 ++++++++++++ core/lib/ably/pubsub/realtime/channels.rb | 27 ++++++++++++++--- spec/unit/realtime/channels_spec.rb | 36 +++++++++++++++++++---- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 7e4b9b29..2ebad821 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -78,6 +78,25 @@ Ably::PubSub::Realtime::Connection::STATE.Connected `Ably::Rest` and `Ably::Realtime` raise `NameError` in 2.0. +### Releasing realtime channels + +In 1.x, `Channels#release` detached a realtime channel before removing it. In 2.0, a realtime channel can only be released +when it is in the `initialized`, `detached` or `failed` state. Releasing it in any other state raises +`Ably::Exceptions::InvalidState` with code `90011`, and the channel is left in place. Detach the channel and wait for the +detach to complete before releasing it: + +```ruby +# 1.x +client.channels.release('example') + +# 2.0 +client.channels.get('example').detach do + client.channels.release('example') +end +``` + +`Ably::PubSub::Http::Channels#release` is unchanged. + ### Unchanged Everything after construction: diff --git a/core/lib/ably/pubsub/realtime/channels.rb b/core/lib/ably/pubsub/realtime/channels.rb index b05a23ba..0670acbb 100644 --- a/core/lib/ably/pubsub/realtime/channels.rb +++ b/core/lib/ably/pubsub/realtime/channels.rb @@ -35,17 +35,36 @@ def fetch(*args) super end - # Detaches the {Ably::PubSub::Realtime::Channel Realtime Channel} and releases all associated resources. + # Releases the {Ably::PubSub::Realtime::Channel Realtime Channel} with the given name and all associated resources. # # Releasing a Realtime Channel is not typically necessary as a channel, once detached, consumes no resources other than # the memory footprint of the {Ably::PubSub::Realtime::Channel Realtime Channel object}. Release channels to free up resources if required # + # A realtime channel can only be released when it is in the +INITIALIZED+, +DETACHED+, or +FAILED+ state. + # + # @spec RTS4c, RTS4d, RTS4e + # + # @param channel [String] The name of the channel + # + # @raise [Ably::Exceptions::InvalidState] if the channel is not in the +INITIALIZED+, +DETACHED+, or +FAILED+ state + # # @return [void] # def release(channel) - get(channel).detach do - @channels.delete(channel) - end if @channels.has_key?(channel) + return unless @channels.has_key?(channel) + + released_channel = get(channel) + unless released_channel.initialized? || released_channel.detached? || released_channel.failed? + raise Ably::Exceptions::InvalidState.new( + "Can only release a channel in a state where there is no possibility of further updates from the server being received " \ + "(initialized, detached, or failed). The current state is #{released_channel.state.to_sym}", + 400, + 90011 + ) + end + + @channels.delete(channel) + nil end # Sets channel serial to each channel from given serials hashmap diff --git a/spec/unit/realtime/channels_spec.rb b/spec/unit/realtime/channels_spec.rb index 959fc4bd..43add733 100644 --- a/spec/unit/realtime/channels_spec.rb +++ b/spec/unit/realtime/channels_spec.rb @@ -98,11 +98,37 @@ end context 'destroying channels' do - it '#release detaches and then releases the channel resources' do - released_channel = subject.get(channel_name, options) - expect(released_channel).to receive(:detach).and_yield - subject.release(channel_name) - expect(subject.get(channel_name, options).object_id).to_not eql(released_channel.object_id) + let(:released_channel) { subject.get(channel_name, options) } + + def stub_state(state) + allow(released_channel).to receive(:state).and_return(Ably::PubSub::Realtime::Channel::STATE(state)) + end + + it '#release does nothing if the channel does not exist (RTS4c)' do + expect { subject.release(channel_name) }.to_not raise_error + expect(subject.length).to eql(0) + end + + [:initialized, :detached, :failed].each do |state| + it "#release releases a channel in the #{state} state without detaching (RTS4d)" do + stub_state state + expect(released_channel).to_not receive(:detach) + subject.release(channel_name) + expect(subject.fetch(channel_name) { nil }).to be_nil + end + end + + [:attaching, :attached, :detaching, :suspended].each do |state| + it "#release raises 90011 and leaves a channel in the #{state} state untouched (RTS4e)" do + stub_state state + expect(released_channel).to_not receive(:detach) + expect { subject.release(channel_name) }.to raise_error(Ably::Exceptions::InvalidState) do |error| + expect(error.code).to eql(90011) + expect(error.status).to eql(400) + expect(error.message).to match(/The current state is #{state}/) + end + expect(subject.fetch(channel_name)).to equal(released_channel) + end end end