diff --git a/UPDATING.md b/UPDATING.md index a63e8ea1..64f69d56 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -105,6 +105,25 @@ async def subscribe(client: PubSubRealtimeClient, name: str) -> None: ... def publish_blocking(client: PubSubHttpClientSync, name: str) -> None: ... ``` +### Releasing a realtime channel requires it to be detached + +`channels.release(name)` on a realtime client now raises an `AblyException` with code 90011 +if the channel is in any state other than `INITIALIZED`, `DETACHED` or `FAILED`, and leaves the +channel as it was. 3.x removed the channel from the collection whatever its state, which could +leave it attached in the Ably service, and later 3.x releases log a deprecation warning when +they do so. Detach the channel, and wait for that to complete, before releasing it: + +Example 3.x code: +```python +realtime.channels.release('my-channel') +``` + +Example 4.0.0 code: +```python +await pubsub_realtime_client.channels.get('my-channel').detach() +pubsub_realtime_client.channels.release('my-channel') +``` + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/realtime/channel.py b/ably/pubsub/realtime/channel.py index d9b74921..e484f656 100644 --- a/ably/pubsub/realtime/channel.py +++ b/ably/pubsub/realtime/channel.py @@ -1085,17 +1085,34 @@ def release(self, name: str) -> None: """Releases a RealtimeChannel object, deleting it, and enabling it to be garbage collected It also removes any listeners associated with the channel. - To release a channel, the channel state must be INITIALIZED, DETACHED, or FAILED. - + A realtime channel can only be released when it is in the INITIALIZED, DETACHED, or FAILED state. Parameters ---------- name: str Channel name + + Raises + ------ + AblyException + With code 90011 if the channel is in any other state. Await channel.detach() before + calling release(). """ + # RTS4c if name not in self.__all: return - channel = self.__all.pop(name) + channel = self.__all[name] + # RTS4e + if channel.state not in (ChannelState.INITIALIZED, ChannelState.DETACHED, ChannelState.FAILED): + raise AblyException( + '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 ' + f'{channel.state.value}', + 400, + 90011, + ) + # RTS4d + del self.__all[name] # The released channel receives nothing more, so its objects stop their GC timer and their waits channel.object._release() diff --git a/test/unit/liveobjects/realtime_object_test.py b/test/unit/liveobjects/realtime_object_test.py index 63e05163..c93786fb 100644 --- a/test/unit/liveobjects/realtime_object_test.py +++ b/test/unit/liveobjects/realtime_object_test.py @@ -293,15 +293,16 @@ def failing_replace_data(self, object_message): # --- R2: the GC timer and waits of a released channel --------------------------------------- async def test_rto10_a_released_channel_stops_its_gc_timer(): - """RTO10a: the GC timer every ATTACHED schedules stops when its channel is released. A - released channel is out of the client's collection, so closing the client never detaches - it; the release itself stops the timer, for good.""" + """RTO10a: the GC timer every ATTACHED schedules stops when its channel is detached, and + once the channel is released (RTS4) nothing schedules it again.""" clock = FakeClock() client, channel, root, mock_ws = await setup_synced_channel('test', clock=clock) timer = channel.object._gc_timer assert timer is not None + await channel.detach() client.channels.release('test') + channel.object._schedule_gc_timer() await client.close() await clock.advance(GC_INTERVAL_MS) @@ -310,23 +311,15 @@ async def test_rto10_a_released_channel_stops_its_gc_timer(): async def test_rto23c1_a_wait_for_synced_fails_when_its_channel_is_released(): - """RTO23c1: a released channel receives nothing more, so it can never sync. A `get()` - waiting for SYNCED when the channel is released fails with 92008, and so does a later one, - at once.""" + """RTO23c1: a released channel receives nothing more, so it can never sync. A wait for + SYNCED begun after the channel is released fails with 92008 at once.""" client, channel, root, mock_ws = await setup_synced_channel('test') - await _restart_sync(channel, mock_ws) - task = asyncio.ensure_future(channel.object.get()) - await settle() - assert not task.done() - + await channel.detach() client.channels.release('test') with pytest.raises(AblyException) as excinfo: - await asyncio.wait_for(task, OPERATION_TIMEOUT) - _assert_sync_wait_failure(excinfo, 'released') - - with pytest.raises(AblyException) as excinfo: - await asyncio.wait_for(channel.object.get(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel.object._wait_for_synced('The object could not be retrieved'), + OPERATION_TIMEOUT) _assert_sync_wait_failure(excinfo, 'released') diff --git a/test/uts/deviations.md b/test/uts/deviations.md index 3d5c5734..28535f9f 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -24,40 +24,40 @@ One Test ID can become more than one derived test: five Test IDs in `rest/unit` `error_types_test.py`, `fallback_test.py`, `rest_client_test.py` (two) and `paginated_result_test.py` — assert several independent things under a single id, and the derivation writes a function for each rather than one function with an unrelated -second half. That turns 1471 Test IDs into 1480 derived tests. Going the other way, one +second half. That turns 1472 Test IDs into 1481 derived tests. Going the other way, one derived test can become more than one case: five of the twelve `rest/integration` specifications, five of the twenty `realtime/integration` ones and the three `objects/integration` ones outside `proxy` carry a `## Protocol Variants` section and run every one of their tests twice, once per protocol; nine `rest/unit` tests are parametrized over a table of fixtures the specification gives inline; and five `objects/unit` tests are parametrized, three over a table and two over the scenarios the -specification loops through. That turns 1480 derived tests into 1607 pytest cases. +specification loops through. That turns 1481 derived tests into 1608 pytest cases. -Of **1471 Test IDs, derived as 1480 tests and run as 1607 pytest cases**: 1287 Test IDs -(1296 tests, 1421 cases) pass, 169 (169 tests, 171 cases) are gated behind +Of **1472 Test IDs, derived as 1481 tests and run as 1608 pytest cases**: 1289 Test IDs +(1298 tests, 1423 cases) pass, 168 (168 tests, 170 cases) are gated behind `RUN_DEVIATIONS`, and 15 (15 tests, 15 cases) cannot be run at all. The three groups are disjoint: two Test IDs have a gated part and a passing part — `TI1`, as two functions, and `TO/endpoint-affects-host`, as one parametrized test — and are counted with the gated, while their passing parts count among the passing tests and cases. Every gated test has been confirmed to fail when enabled, so none of them passes under both behaviours. 494 of -the Test IDs come from `uts/rest/unit` (503 tests, 536 cases), 481 from -`uts/realtime/unit` (481, 481), 322 from `uts/objects/unit` (322, 344), 84 from +the Test IDs come from `uts/rest/unit` (503 tests, 536 cases), 482 from +`uts/realtime/unit` (482, 482), 322 from `uts/objects/unit` (322, 344), 84 from `uts/rest/integration` (84, 122), 73 from `uts/realtime/integration` (73, 95) and 17 from `uts/objects/integration` (17, 29); 8 of the REST integration ids (8, 8), 30 of the realtime ones (30, 30) and 5 of the objects ones (5, 5) come from the `proxy` package -within each. Of the gated Test IDs 79 are REST and 90 realtime, which is 80 REST cases -and 91 realtime. None of the 339 objects Test IDs is gated or unrunnable. +within each. Of the gated Test IDs 79 are REST and 89 realtime, which is 80 REST cases +and 90 realtime. None of the 339 objects Test IDs is gated or unrunnable. A further 148 pytest cases cover the harness itself and are not derived from a specification: 130 under `helpers/`, for the mocks, and 18 under `objects/helpers/`, for the objects builders and the synced-channel mock. -The 158 gated Test IDs that record SDK non-compliance — 158 tests, 160 cases — reduce to -**70 distinct root causes**, 26 on the REST side and 44 on the realtime side. Three further +The 157 gated Test IDs that record SDK non-compliance — 157 tests, 159 cases — reduce to +**69 distinct root causes**, 26 on the REST side and 43 on the realtime side. Three further defects are recorded below with no test of their own, because the specification's test cannot discriminate (RTP18a), has nothing to assert against (the timezone split on synthesized LEAVE timestamps), or is worked around in the setup of every test that would otherwise trip over it (`enterClient` on an anonymous connection), so the file -carries **73 SDK root causes** in all. The remaining 11 gated Test IDs are +carries **72 SDK root causes** in all. The remaining 11 gated Test IDs are specification faults, and reduce to 8. The objects tier records no SDK non-compliance; its specification faults are all corrected or worked around in tests that pass, and reduce to the seven candidate issues O.1–O.7, none of them filed. @@ -433,7 +433,7 @@ the site. | RTL4b (`channel_attach.md`) | `channelRetryTimeout: 100` ("short timeout for testing"), then `AWAIT_STATE client.connection.state == suspended` | `channelRetryTimeout` governs channel retries, not the connection's suspend timer, which runs for `connectionStateTtl`. The test does not enable fake timers either, so on real time it would wait out two minutes. Derived with a `FakeClock`, passing the named option through unchanged | | RTP5f, RTL11 (`realtime_presence_channel_state.md`) | `simulate_disconnect()` then `AWAIT_STATE channel.state == suspended` | A transport drop reaches DISCONNECTED. RTL3c propagates SUSPENDED to channels only from a SUSPENDED *connection*, so the awaited state never arrives. RTP5f's own note ("e.g. connection transitions to SUSPENDED") says as much; the steps do not carry it out | | RTP5a (`realtime_presence_channel_state.md`) | detach, then `presence.get(waitForSync: false).length == 0` | RTP11e has `get` run the ensure-active-channel procedure for any state but SUSPENDED, so the read-back re-attaches the channel and the specification's own server then repopulates the very map being checked for emptiness. The derived test reads the two maps directly | -| RTS3c1, RTL16a (`channel_options.md`), RTS4a (`channels_collection.md`) | `autoConnect: false`, no mock installed, `connect()` never called, then `AWAIT channel.attach()` and assert ATTACHED | RTL4b requires `attach()` to fail unless the connection is CONNECTING, CONNECTED or DISCONNECTED, and with no mock nothing would answer the ATTACH in any case. Derived with a mock that connects and answers each ATTACH. Upstream should give these three setups a mock, as the sibling sections of the same files do | +| RTS3c1, RTL16a (`channel_options.md`) | `autoConnect: false`, no mock installed, `connect()` never called, then `AWAIT channel.attach()` and assert ATTACHED | RTL4b requires `attach()` to fail unless the connection is CONNECTING, CONNECTED or DISCONNECTED, and with no mock nothing would answer the ATTACH in any case. Derived with a mock that connects and answers each ATTACH. Upstream should give these setups a mock, as the sibling sections of the same file do | | RTS3c1 `error-reattach-modes-1` (`channel_options.md`) | `# Put channel in attaching state (implementation detail)` | The premise the test turns on is the one step it does not give, and the setup has no mock to reach ATTACHING with | ### Fixtures written against mock methods the contract does not define @@ -1439,22 +1439,6 @@ exactly as one arriving while ATTACHED does. **Status:** open bug. -#### `Channels.release` does not detach the channel — 1 test - -**Spec point:** RTS4a. - -Release "detaches the channel and then releases the channel resource". `Channels.release` -(`channel.py:1012-1026`) is `if name not in self.__all: return` followed by -`del self.__all[name]`, and sends nothing. An attached channel is dropped from the -collection while still attached in the Ably service, and the orphaned object stays in -ATTACHED. It overrides the REST implementation, which is correct for REST, without adding -the detach. - -**Tests affected:** `test_rts4a_release_detaches_attached` — `assert 0 == 1` on the -DETACH-message count. - -**Status:** open bug. - #### A decode error other than 40018 has no channel-level handling — 2 tests, 3 cases **Spec point:** PC3. @@ -2031,7 +2015,7 @@ through an internal object to get at a value the specification makes public. | RTP2d1, RTP2h1a, and the `Interface Under Test` blocks of all three presence-map specs | `put(message) -> PresenceMessage?` and `remove(message) -> PresenceMessage?` | both return `bool` (`presencemap.py:111`, `:159`); the message to emit is the caller's own, which `set_presence` appends to `broadcast_messages` when the return is true. `IS NOT null` is read as `is True`. `put` stores a *copy* with the action rewritten to PRESENT and leaves the caller's message untouched, so RTP2d1's "emit the original action" falls out for free | all of `presence_map_test.py` | | RTP19, and the `Interface Under Test` block of `presence_sync.md` | `endSync() -> List`, the synthesized LEAVEs | `end_sync()` returns `(residual, absent)` of the *stored* members; the synthesis lives one level up in `RealtimePresence.set_presence` (`presence.py:575-587`). Tests reading only counts and clientIds concatenate the two lists exactly as `set_presence` does; tests reading the LEAVE itself drive a `RealtimePresence` and assert on what its subscribers receive | 4 in `presence_sync_test.py` | | TB2, RTS3b, RTS3c, RTS3c1, RTL16 | `channel.options` as a `ChannelOptions` | a dict keyed by wire names, because `RealtimeChannel` passes `ChannelOptions.to_dict()` to the REST `Channel` constructor (`channel.py:84`). Assertions read `channel.options['params']['rewind']`. On `ChannelOptions` itself the cipher attribute is spelled `cipher`, not `cipherParams`. `set_options_without_reattach` replaces the stored mapping wholesale rather than merging, which `test_rts3c_options_updated_existing` pins | 5 | -| RTS2, RTS4a | `channels.exists(name)`, `channels.names`, and an awaitable `release()` | `name in client.channels` (`Channels.__contains__`); the collection iterates over its channels rather than their names; `release` is synchronous. Genuinely idiomatic spelling rather than an absence — recorded only because of the `__getattr__` hazard noted below | 4 | +| RTS2, RTS4c, RTS4d, RTS4e | `channels.exists(name)` and `channels.names` | `name in client.channels` (`Channels.__contains__`); the collection iterates over its channels rather than their names. Genuinely idiomatic spelling rather than an absence — recorded only because of the `__getattr__` hazard noted below | 6 | | RSH1b1, RSH1b2, RSH1b3, RSH1b4, RSH1b5, RSH1c3 | `DevicePushDetails`. The specification builds every device as `DeviceDetails(…, push: DevicePushDetails(recipient: {…}))`; ably-python has no such type | `DeviceDetails.__init__` takes `push` as a plain dict and stores it unchanged (`ably/types/device.py:10-40`), and `DeviceDetails.push` hands that dict back, so the tests read `{'recipient': {…}}` directly. The recipient's `transportType` is still validated against `DevicePushTransportType` in the constructor, which is the only part of `DevicePushDetails` carrying behaviour | the 7 in `push_admin_test.py` that register a device, through its `apns_device()` helper | **Status:** open bugs of the missing-API kind, not of the wrong-behaviour kind. Adding the @@ -2764,7 +2748,7 @@ is missing accessors over correct behaviour. Within a tier the order is blast ra | Issue | Covers | Rows below it answers | |---|---|---| | [#706](https://github.com/ably/ably-python/issues/706) | the fabricated `"None:0"` message id | 3.3 | -| [#658](https://github.com/ably/ably-python/issues/658) | presence messages sent on reconnection before reattach | adjacent to 3.14, which is the other half of RTP17 automatic re-entry | +| [#658](https://github.com/ably/ably-python/issues/658) | presence messages sent on reconnection before reattach | adjacent to 3.13, which is the other half of RTP17 automatic re-entry | | [#656](https://github.com/ably/ably-python/issues/656) | `utcfromtimestamp` deprecation | adjacent to 2.4 | | [#709](https://github.com/ably/ably-python/issues/709)–[#712](https://github.com/ably/ably-python/issues/712) | REST request timeout, token-request nonce reuse, single-host retry, `dispose()` teardown | adjacent to I.3, which is a second defect on the line #709 is about and is not covered by it; otherwise none, these having been filed from the REST derivation | @@ -2999,33 +2983,27 @@ disconnect per RTN7e", which issue 1.2 above proves false. No gated test: the UT this cannot distinguish the behaviours (recorded under UTS Spec Errors), so the finding is the output. -**3.9 `Channels.release` does not detach the channel.** RTS4a. `channel.py:1012-1026` deletes -the entry and sends nothing, so the channel is dropped from the collection while still -attached in the Ably service — the application goes on being billed for and delivered to a -channel it believes it released. -`test/uts/realtime/unit/channels/channels_collection_test.py -k rts4a_release_detaches_attached` - -**3.10 The UPDATE event drops the CONNECTED message's error.** RTN24. `on_connected` +**3.9 The UPDATE event drops the CONNECTED message's error.** RTN24. `on_connected` (`connectionmanager.py:425-428`) builds the `ConnectionStateChange` without the `reason` it was handed, so an application never sees the error a CONNECTED carries — including the RTN15c7 failed-resume error, which RTN25 lists among those that must set `Connection#errorReason`. A one-line fix. `test/uts/realtime/unit/connection/update_events_test.py -k rtn24_update_event_with_error` -**3.11 Detach protocol faults.** RTL5i — a detach requested while already DETACHING sends a +**3.10 Detach protocol faults.** RTL5i — a detach requested while already DETACHING sends a **second** DETACH, because `_request_state` calls `_check_pending_state()` even after `_notify_state` returns early. RTL5k — an ATTACHED arriving while DETACHING or DETACHED is ignored instead of answered with a new DETACH, so the detach times out and the channel returns to ATTACHED. Same method, one issue. `test/uts/realtime/unit/channels/channel_detach_test.py -k "rtl5i or rtl5k"` -**3.12 The deleted RTL4j ATTACH_RESUME flag is still set on every reattach.** RTL4j, deleted +**3.11 The deleted RTL4j ATTACH_RESUME flag is still set on every reattach.** RTL4j, deleted in specification 6.1.0. `_notify_state` sets `__attach_resume` on every ATTACHED and `_encode_flags` ORs it in, so every reattach carries `flags: 32` and tells the server something the server now decides for itself. `test/uts/realtime/unit/channels/channel_attach_test.py -k rtl4j` -**3.13 A decode error other than 40018 never fails the channel.** PC3. `channel.py:744-748` +**3.12 A decode error other than 40018 never fails the channel.** PC3. `channel.py:744-748` gives channel-level handling to 40018 alone; every other decode error is logged and the batch **silently skipped**, with no state change and no `error_reason`, so a vcdiff message with no decoder is simply lost. Compounding it, `message.py:302-305` checks the delta's `from` id @@ -3033,34 +3011,34 @@ decoder is simply lost. Compounding it, `message.py:302-305` checks the delta's missing-decoder branch at all. `test/uts/realtime/unit/channels/channel_delta_decoding_test.py -k pc3_no_plugin_fails` -**3.14 A failed automatic re-entry reports the NACK, not the 91004 wrapper.** RTP17e. +**3.13 A failed automatic re-entry reports the NACK, not the 91004 wrapper.** RTP17e. `_reenter_member` (`presence.py:667-674`) emits the UPDATE with `resumed=False` and the raw NACK as `reason`, where the spec requires `resumed` true and a 91004 naming the clientId with the NACK as `cause`. Local to one method. `test/uts/realtime/unit/presence/realtime_presence_reentry_test.py -k rtp17e` -**3.15 `ping()` rejects DISCONNECTED, and charges the connect wait to the caller's timeout.** +**3.14 `ping()` rejects DISCONNECTED, and charges the connect wait to the caller's timeout.** RTN13b, RTN13c, RTN13d. `ConnectionManager.ping` (`:362`) admits only CONNECTED and CONNECTING where RTN13d requires a ping from DISCONNECTED to be deferred; and `:375` enters `asyncio.wait_for` as soon as `ping()` is called, so a ping requested while CONNECTING can expire before its HEARTBEAT has gone out. `test/uts/realtime/unit/connection/connection_ping_test.py -k "rtn13b_deferred or rtn13c or rtn13d"` -**3.16 A failed RTN22 reauth leaves no trace on the connection.** RSA4c1, RSA4c3. +**3.15 A failed RTN22 reauth leaves no trace on the connection.** RSA4c1, RSA4c3. `websockettransport.py:170-175` awaits `auth.authorize()` inside a bare `except Exception` that only logs, so nothing reaches `on_error_from_authorize` and `errorReason` stays as it was. **Hold this one**: specification#466 would make the current behaviour correct, and the two UTS specs disagree about it today. `test/uts/realtime/unit/auth/connection_auth_test.py -k rsa4c3_callback_error_stays_connected` -**3.17 TokenParams passed to an authCallback carry no clientId on a realtime client.** +**3.16 TokenParams passed to an authCallback carry no clientId on a realtime client.** RSA12a, RTN2e. `Auth.__init__` nulls `client_id` for a realtime client (`rest/auth.py:36-41`) and `_ensure_valid_auth_credentials` only adds it when non-null, so an auth server never learns the configured clientId. The REST client does pass it. The same nulling is why `client.client_id` and `client.auth.client_id` disagree before CONNECTED (RTC17). `test/uts/realtime/unit/auth/connection_auth_test.py -k rtn2e` -**3.18 No 40171 log at instantiation with a non-renewable token.** RSA4a1. Requires an +**3.17 No 40171 log at instantiation with a non-renewable token.** RSA4a1. Requires an info-level record carrying 40171 and the TI5 help URL; the SDK logs at debug with no code, and `grep -rn href ably/` finds no help URLs at all. Small, and the TI5 half is a gap of its own. @@ -3753,16 +3731,16 @@ The header states how many derived tests there are, how many pass, how many are gated and how many cannot run. Those numbers are the check that the file is still true: in pytest cases, the gated count must equal the number of failures under `RUN_DEVIATIONS=1`, and gated plus unrunnable must equal the number of skips without -it. As of this writing that is 171 failures, 1569 passes and 15 skips with the variable -set, and 186 skips and 1569 passes without it, the 1569 being 1421 derived cases and 148 +it. As of this writing that is 170 failures, 1571 passes and 15 skips with the variable +set, and 185 skips and 1571 passes without it, the 1571 being 1423 derived cases and 148 harness ones — 130 under `helpers/` and 18 under `objects/helpers/`. Those figures need the `submodules` checkout CI makes: without it, three `rest/unit/encoding` tests that read the ably-common fixtures skip as well. The tiers are told apart by the junit report's `classname`, `test.uts..`, with `proxy` the package within an integration tier. The other two counts are measured from the source rather than from a run. The number of -**derived tests** is the number of `# UTS:` comments, 1480. The number of **Test IDs** is -the number of *distinct* ids in them, 1471 — not the same figure, because five ids in +**derived tests** is the number of `# UTS:` comments, 1481. The number of **Test IDs** is +the number of *distinct* ids in them, 1472 — not the same figure, because five ids in `rest/unit` are carried by more than one test function. Counting the comments and calling the result Test IDs is the easy mistake here, and it overstates the specification coverage by nine. The other is to gate or un-gate a test, record it in the body, and leave the diff --git a/test/uts/realtime/unit/channels/channels_collection_test.py b/test/uts/realtime/unit/channels/channels_collection_test.py index e455a584..8d85a2df 100644 --- a/test/uts/realtime/unit/channels/channels_collection_test.py +++ b/test/uts/realtime/unit/channels/channels_collection_test.py @@ -1,6 +1,6 @@ """Derived from uts/realtime/unit/channels/channels_collection.md in ably/specification. -Spec points: RTS1, RTS2, RTS3a, RTS4a +Spec points: RTS1, RTS2, RTS3a, RTS4c, RTS4d, RTS4e The specification's `channels.exists(name)` and `channels.names` are spelled `name in channels` and iteration over the collection in ably-python, and @@ -11,13 +11,15 @@ import asyncio +import pytest + from ably.pubsub.realtime.channel import Channels as RealtimeChannels from ably.pubsub.realtime.channel import RealtimeChannel from ably.pubsub.realtime.connection import ConnectionState from ably.pubsub.transport.websockettransport import ProtocolMessageAction from ably.pubsub.types.channelstate import ChannelState -from test.uts.helpers.client import await_connection_state, poll_until, realtime_client -from test.uts.helpers.deviations import deviation +from ably.pubsub.util.exceptions import AblyException +from test.uts.helpers.client import await_connection_state, realtime_client from test.uts.helpers.mock_websocket import ( CONNECTED_MESSAGE, MockWebSocket, @@ -119,37 +121,35 @@ async def test_rts3a_subscript_operator_channel(): assert channel1.name == channel_name -# UTS: realtime/unit/RTS4a/release-removes-channel-0 -async def test_rts4a_release_removes_channel(): - channel_name = 'test-RTS4a' +# UTS: realtime/unit/RTS4c/release-nonexistent-noop-0 +async def test_rts4c_release_nonexistent_noop(): + channel_name = 'test-RTS4c-nonexistent' client = realtime_client() - client.channels.get(channel_name) - assert (channel_name in client.channels) is True - - # `release` is synchronous here, so there is nothing to await client.channels.release(channel_name) assert (channel_name in client.channels) is False -# UTS: realtime/unit/RTS4a/release-nonexistent-noop-1 -async def test_rts4a_release_nonexistent_noop(): - channel_name = 'test-RTS4a-nonexistent' +# UTS: realtime/unit/RTS4d/release-removes-channel-0 +async def test_rts4d_release_removes_channel(): + channel_name = 'test-RTS4d' client = realtime_client() + channel = client.channels.get(channel_name) + assert channel.state == ChannelState.INITIALIZED + assert (channel_name in client.channels) is True + client.channels.release(channel_name) assert (channel_name in client.channels) is False -# UTS: realtime/unit/RTS4a/release-detaches-attached-2 -@deviation -async def test_rts4a_release_detaches_attached(): - # DEVIATION: RTS4a has release detach the channel before dropping it. - # `Channels.release` (`ably/realtime/channel.py:1012`) only deletes the entry from - # its dict, so no DETACH is sent and the channel is left attached in the Ably service. - channel_name = 'test-RTS4a-attached' +async def connected_client(answer_detach): + """A connected client whose mock answers each ATTACH, and each DETACH if `answer_detach`. + + Returns the client and the list of messages it sends. + """ messages_from_client = [] mock_ws = MockWebSocket( @@ -159,33 +159,51 @@ async def test_rts4a_release_detaches_attached(): def on_message_from_client(msg): messages_from_client.append(msg) if msg.get('action') == ProtocolMessageAction.ATTACH: - mock_ws.send_to_client(attached_message(channel_name)) - elif msg.get('action') == ProtocolMessageAction.DETACH: - mock_ws.send_to_client(detached_message(channel_name)) + mock_ws.send_to_client(attached_message(msg['channel'])) + elif msg.get('action') == ProtocolMessageAction.DETACH and answer_detach: + mock_ws.send_to_client(detached_message(msg['channel'])) mock_ws.on_message_from_client = on_message_from_client client = realtime_client(mock_ws) client.connect() await await_connection_state(client, ConnectionState.CONNECTED) + return client, messages_from_client + +# UTS: realtime/unit/RTS4d/release-after-detach-1 +async def test_rts4d_release_after_detach(): + channel_name = 'test-RTS4d-detached' + client, _ = await connected_client(answer_detach=True) channel = client.channels.get(channel_name) - await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) - assert channel.state == ChannelState.ATTACHED - state_before_release = channel.state + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.DETACHED client.channels.release(channel_name) - # A release which detaches sends DETACH on a task, and the channel reaches - # DETACHED only once the mock's answer has been handled, which is several - # yields away rather than one - await poll_until(lambda: channel.state == ChannelState.DETACHED, OPERATION_TIMEOUT, - 'the released channel to detach') - assert state_before_release == ChannelState.ATTACHED assert (channel_name in client.channels) is False + + +# UTS: realtime/unit/RTS4e/release-attached-fails-0 +async def test_rts4e_release_attached_fails(): + channel_name = 'test-RTS4e-attached' + client, messages_from_client = await connected_client(answer_detach=False) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + with pytest.raises(AblyException) as exc_info: + client.channels.release(channel_name) + + assert exc_info.value.code == 90011 + assert exc_info.value.status_code == 400 + assert channel.state == ChannelState.ATTACHED + assert (channel_name in client.channels) is True + assert client.channels.get(channel_name) is channel detach_messages = [m for m in messages_from_client if m.get('action') == ProtocolMessageAction.DETACH] - assert len(detach_messages) == 1 - assert channel.state == ChannelState.DETACHED + assert len(detach_messages) == 0 # UTS: realtime/unit/RTS3a/get-after-release-new-3