From 60fa9cf39f778f6e8acee443b46f280136fbf5bf Mon Sep 17 00:00:00 2001 From: Simon Woolf Date: Fri, 9 Oct 2026 04:09:51 +0100 Subject: [PATCH] feat!: make RealtimeChannels.release() async and surface platform errors Spec 6.3.0 replaces RTS4a, under which release() detaches a channel that is not already INITIALIZED, DETACHED or FAILED before removing it, with RTS4c-e: release() of a channel in any other state must fail with error 90011 and leave the channel in place. That check is done by the platform SDKs (ably-java, ably-cocoa) in their next major versions. release() previously fired the platform call without awaiting it and dropped the Dart-side channel unconditionally, so a platform error would have been lost as an unhandled async error while the Dart and platform collections diverged. It now returns a Future that completes with the platform's AblyException, and only removes the Dart-side channel once the platform release has succeeded. The state check is deliberately left to the platform SDK, because RealtimeChannel.state is updated asynchronously from platform events and so can lag behind the platform channel's state. Co-Authored-By: Claude Opus 5.5 (1M context) --- UPDATING.md | 1 + example/lib/ui/realtime_sliver.dart | 2 +- lib/src/common/src/channels.dart | 6 +-- .../src/realtime/realtime_channels.dart | 16 +++++--- test/mock_method_call_manager.dart | 5 +++ test/realtime/channels_test.dart | 40 ++++++++++++++++--- 6 files changed, 55 insertions(+), 15 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index c517ce379..4ed2c8748 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -13,6 +13,7 @@ This guide lists the changes needed to upgrade from one version of Ably to a new - `RestChannel`, `RestChannels`, `RestPresence`, `RestChannelOptions`, `RestHistoryParams` and `RestPresenceParams` have been removed. Use `RealtimeChannel`, `RealtimeChannels`, `RealtimePresence`, `RealtimeChannelOptions`, `RealtimeHistoryParams` and `RealtimePresenceParams`. - `Message.fromEncoded`, `Message.fromEncodedArray`, `PresenceMessage.fromEncoded` and `PresenceMessage.fromEncodedArray` now take a `RealtimeChannelOptions` instead of a `RestChannelOptions`. - `Push` and `PushChannel` no longer accept a `rest` client; they now require a `realtime` client. +- `RealtimeChannels.release()` now returns a `Future`, which completes once the channel has been released. A realtime channel can only be released when it is in the `INITIALIZED`, `DETACHED`, or `FAILED` state. Releasing a channel in any other state no longer detaches it implicitly. Instead, the future completes with an `AblyException` with code 90011 and the channel is not released. To release an attached channel, `await channel.detach()` first and then `await realtime.channels.release(name)`. ## [Upgrading from v1.2.13] diff --git a/example/lib/ui/realtime_sliver.dart b/example/lib/ui/realtime_sliver.dart index dbc1b0e0b..71b1e0a68 100644 --- a/example/lib/ui/realtime_sliver.dart +++ b/example/lib/ui/realtime_sliver.dart @@ -142,7 +142,7 @@ class RealtimeSliver extends HookWidget { TextButton( onPressed: () async { await channel.detach(); - realtime.channels.release(Constants.channelName); + await realtime.channels.release(Constants.channelName); channel = realtime.channels.get(Constants.channelName); setupListeners( connectionState, channelState, connectionId, recoveryKey); diff --git a/lib/src/common/src/channels.dart b/lib/src/common/src/channels.dart index 3e8c9918d..be893fc40 100644 --- a/lib/src/common/src/channels.dart +++ b/lib/src/common/src/channels.dart @@ -40,9 +40,9 @@ abstract class Channels extends Iterable { /// Releases a [RealtimeChannel] object with a specified /// [name], deleting it. /// - /// It also removes any listeners associated with the channel. To release a - /// channel, the [ChannelState] must be `INITIALIZED`, `DETACHED`, or - /// `FAILED`. + /// It also removes any listeners associated with the channel. A realtime + /// channel can only be released when it is in the `INITIALIZED`, + /// `DETACHED`, or `FAILED` state. void release(String name) { _channels.remove(name); } diff --git a/lib/src/platform/src/realtime/realtime_channels.dart b/lib/src/platform/src/realtime/realtime_channels.dart index b6d3585ba..2396850a8 100644 --- a/lib/src/platform/src/realtime/realtime_channels.dart +++ b/lib/src/platform/src/realtime/realtime_channels.dart @@ -19,12 +19,18 @@ class RealtimeChannels extends Channels { /// Releases a [RealtimeChannel] object with a specified [name], deleting it. /// - /// It also removes any listeners associated with the channel. To release a - /// channel, the [ChannelState] must be `INITIALIZED`, `DETACHED`, or - /// `FAILED`. + /// It also removes any listeners associated with the channel. A realtime + /// channel can only be released when it is in the `INITIALIZED`, + /// `DETACHED`, or `FAILED` state. If the channel is in any other state, the + /// returned future completes with an [AblyException] with code 90011 and + /// the channel is not released. Await [RealtimeChannel.detach] before calling + /// `release()` on an attached channel. Releasing a channel that does not + /// exist completes without error. @override - void release(String name) { - realtime.invoke(PlatformMethod.releaseRealtimeChannel, { + Future release(String name) async { + // The check against the channel state is left to the platform SDK, as + // [RealtimeChannel.state] is updated asynchronously and can lag behind it. + await realtime.invoke(PlatformMethod.releaseRealtimeChannel, { TxTransportKeys.channelName: name, }); super.release(name); diff --git a/test/mock_method_call_manager.dart b/test/mock_method_call_manager.dart index affa3cfef..51c7f79ea 100644 --- a/test/mock_method_call_manager.dart +++ b/test/mock_method_call_manager.dart @@ -10,6 +10,7 @@ class MockMethodCallManager { bool isAuthenticated = false; final channels = {}; final publishedMessages = >[]; + PlatformException? releaseRealtimeChannelError; MockMethodCallManager() { final channel = @@ -22,6 +23,7 @@ class MockMethodCallManager { void reset() { channels.clear(); publishedMessages.clear(); + releaseRealtimeChannelError = null; handleCounter = 0; final channel = MethodChannel('io.ably.flutter.plugin', StandardMethodCodec(Codec())); @@ -69,6 +71,9 @@ class MockMethodCallManager { return null; case PlatformMethod.releaseRealtimeChannel: + if (releaseRealtimeChannelError != null) { + throw releaseRealtimeChannelError!; + } return null; default: diff --git a/test/realtime/channels_test.dart b/test/realtime/channels_test.dart index afad0dbd2..771b675a7 100644 --- a/test/realtime/channels_test.dart +++ b/test/realtime/channels_test.dart @@ -1,4 +1,5 @@ import 'package:ably_pubsub_device_flutter/ably_pubsub_device_flutter.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../mock_method_call_manager.dart'; @@ -87,30 +88,57 @@ void main() { group('#release', () { test( - '(RSN4a) Takes one argument, the channel name,' - ' and releases the corresponding channel entity', () { + '(RTS4d) Releasing a channel the platform SDK releases' + ' removes it from the collection', () async { channels ..get('channel-1') ..get('channel-2'); expect(channels.length, 2); - channels.release('channel-1'); + await channels.release('channel-1'); expect(channels.length, 1); expect(channels.exists('channel-1'), false); }); test( - '(RSN4b) Calling release() with a channel name' + '(RTS4c) Calling release() with a channel name' ' that does not correspond to an extant channel' - ' entity must return without error', () { + ' must return without error', () async { channels ..get('channel-1') ..get('channel-2'); expect(channels.length, 2); - channels.release('channel-3'); + await channels.release('channel-3'); expect(channels.length, 2); expect(channels.exists('channel-1'), true); expect(channels.exists('channel-2'), true); }); + + test( + '(RTS4e) If the platform SDK fails to release the channel,' + ' release() completes with its error and the channel is not' + ' removed', () async { + final channel = channels.get('channel-1'); + manager.releaseRealtimeChannelError = PlatformException( + code: '90011', + message: '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 attached', + details: ErrorInfo(code: 90011, statusCode: 400), + ); + + await expectLater( + channels.release('channel-1'), + throwsA( + isA() + .having((e) => e.errorInfo?.code, 'errorInfo.code', 90011) + .having((e) => e.errorInfo?.statusCode, 'errorInfo.statusCode', + 400), + ), + ); + expect(channels.exists('channel-1'), true); + expect(channels.get('channel-1'), same(channel)); + }); }); }); }