Repository: redis/redis-py

Issue:
[Bug] Aioredis cancellation of blocking operations stucks next operations
**Version**: redis-py 4.4.2, redis 7.0.8-1

**Platform**: Python 3.9.16 on Arch Linux

**Description**: Aioredis cancellation of blocking operations, like blpop, does not work correctly. Operation seems cancelled, but any next operation, like rpush will stuck, key does not matter. If there is timeout - stuck will last as long as time is out, otherwise forever.

**Example**
```
import asyncio
import time
from typing import Awaitable, List, TypeVar, cast

from redis import asyncio as aioredis

_T = TypeVar('_T')


async def _task_1(redis: aioredis.Redis) -> str:
    async with redis as r:
        await cast(Awaitable[List[str]], r.blpop('key_1', timeout=10))

    return 'task_1_completed'


async def _task_2() -> str:
    await asyncio.sleep(1)

    return 'task_2_completed'


async def _first_completed(*tasks: Awaitable[_T]) -> _T:
    done, pending = await asyncio.wait(
        [asyncio.ensure_future(task) for task in tasks], return_when=asyncio.FIRST_COMPLETED
    )

    for task in pending:
        task.cancel()

    await asyncio.wait(pending)

    print(done)
    print(pending)

    return done.pop().result()


async def _redis_rpush(redis: aioredis.Redis) -> None:
    async with redis as r:
        await cast(Awaitable[int], r.rpush('key_2', ''))


async def _main() -> None:
    redis = aioredis.from_url('redis://localhost/3', encoding='utf-8', decode_responses=True)

    print(await _first_completed(_task_1(redis), _task_2()))

    rpush_start_time = time.time()
    await _redis_rpush(redis)
    rpush_end_time = time.time()

    print(f"rpush seconds: {rpush_end_time - rpush_start_time}")

    await cast(Awaitable[bool], redis.flushdb())


if __name__ == '__main__':
    asyncio.run(_main())

```
**Output**
```
{<Task finished name='Task-3' coro=<_task_2() done, defined at /home/merunes/vhome/proj/work/robohood-roboapi/robohood_roboapi/main.py:17> result='task_2_completed'>}
{<Task cancelled name='Task-2' coro=<_task_1() done, defined at /home/merunes/vhome/proj/work/robohood-roboapi/robohood_roboapi/main.py:10>>}
task_2_completed
rpush seconds: 9.014980554580688
```

rpush seconds are weird, it seems like internally blpop wasn't cancelled and rpush is waiting it to complete.

**PS**:
Maybe connected with:

https://github.com/redis/redis-py/issues/2028
https://github.com/redis/redis-py/issues/2551
https://github.com/redis/redis-py/issues/2523


Discussion:

Resolving patch:
diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py
--- a/redis/asyncio/client.py
+++ b/redis/asyncio/client.py
@@ -1349,10 +1349,16 @@ async def execute(self, raise_on_error: bool = True):
         conn = cast(Connection, conn)
 
         try:
-            return await conn.retry.call_with_retry(
-                lambda: execute(conn, stack, raise_on_error),
-                lambda error: self._disconnect_raise_reset(conn, error),
+            return await asyncio.shield(
+                conn.retry.call_with_retry(
+                    lambda: execute(conn, stack, raise_on_error),
+                    lambda error: self._disconnect_raise_reset(conn, error),
+                )
             )
+        except asyncio.CancelledError:
+            # not supposed to be possible, yet here we are
+            await conn.disconnect(nowait=True)
+            raise
         finally:
             await self.reset()
 
diff --git a/redis/asyncio/cluster.py b/redis/asyncio/cluster.py
--- a/redis/asyncio/cluster.py
+++ b/redis/asyncio/cluster.py
@@ -879,10 +879,18 @@ async def execute_command(self, *args: Any, **kwargs: Any) -> Any:
         await connection.send_packed_command(connection.pack_command(*args), False)
 
         # Read response
+        return await asyncio.shield(
+            self._parse_and_release(connection, args[0], **kwargs)
+        )
+
+    async def _parse_and_release(self, connection, *args, **kwargs):
         try:
-            return await self.parse_response(connection, args[0], **kwargs)
+            return await self.parse_response(connection, *args, **kwargs)
+        except asyncio.CancelledError:
+            # should not be possible
+            await connection.disconnect(nowait=True)
+            raise
         finally:
-            # Release connection
             self._free.append(connection)
 
     async def execute_pipeline(self, commands: List["PipelineCommand"]) -> bool:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -8,7 +8,7 @@
     long_description_content_type="text/markdown",
     keywords=["Redis", "key-value store", "database"],
     license="MIT",
-    version="4.3.5",
+    version="4.3.6",
     packages=find_packages(
         include=[
             "redis",


Test patch:
diff --git a/tests/test_asyncio/test_cluster.py b/tests/test_asyncio/test_cluster.py
--- a/tests/test_asyncio/test_cluster.py
+++ b/tests/test_asyncio/test_cluster.py
@@ -333,6 +333,23 @@ async def test_execute_command_node_flag_random(self, r: RedisCluster) -> None:
                 called_count += 1
         assert called_count == 1
 
+    async def test_asynckills(self, r) -> None:
+
+        await r.set("foo", "foo")
+        await r.set("bar", "bar")
+
+        t = asyncio.create_task(r.get("foo"))
+        await asyncio.sleep(1)
+        t.cancel()
+        try:
+            await t
+        except asyncio.CancelledError:
+            pytest.fail("connection is left open with unread response")
+
+        assert await r.get("bar") == b"bar"
+        assert await r.ping()
+        assert await r.get("foo") == b"foo"
+
     async def test_execute_command_default_node(self, r: RedisCluster) -> None:
         """
         Test command execution without node flag is being executed on the
diff --git a/tests/test_asyncio/test_connection.py b/tests/test_asyncio/test_connection.py
--- a/tests/test_asyncio/test_connection.py
+++ b/tests/test_asyncio/test_connection.py
@@ -28,6 +28,29 @@ async def test_invalid_response(create_redis):
     assert str(cm.value) == f"Protocol Error: {raw!r}"
 
 
+@pytest.mark.onlynoncluster
+async def test_asynckills():
+    from redis.asyncio.client import Redis
+
+    for b in [True, False]:
+        r = Redis(single_connection_client=b)
+
+        await r.set("foo", "foo")
+        await r.set("bar", "bar")
+
+        t = asyncio.create_task(r.get("foo"))
+        await asyncio.sleep(1)
+        t.cancel()
+        try:
+            await t
+        except asyncio.CancelledError:
+            pytest.fail("connection left open with unread response")
+
+        assert await r.get("bar") == b"bar"
+        assert await r.ping()
+        assert await r.get("foo") == b"foo"
+
+
 @skip_if_server_version_lt("4.0.0")
 @pytest.mark.redismod
 @pytest.mark.onlynoncluster