Repository: aio-libs/async-timeout

Issue:
aiohttp swallows asyncio.CancelledError during connection timeout
### Describe the bug

There is a race condition in code that handles connection timeout. If you call `cancel` on a task that is currently pending in `create_connection` and connection timeout was already fired then `asyncio.CancelledError` is not propagated and you get `asyncio.TimeoutError` instead. The main problem is in how timeouts are handled in [async_timeout](https://github.com/aio-libs/async-timeout/blob/v3.0.1/async_timeout/__init__.py#L88) package. When exitting the context manager after timeout had passed all `CancelledError` exceptions are swallowed and `TimeoutError` is raised instead. Unfortunately this is true also if you explicitly cancel the task yourself.

The main problem is that you cannot cancel a task that is using aiohttp because you never know if `CancelledError` will be raised.

### To Reproduce
**EDIT: THIS REPRODUCER DOES NOT SHOW THE BEHAVIOUR CORRECTLY - PLEASE REFER TO COMMENTS BELLOW!**
```python
import asyncio
from async_timeout import timeout


async def test_task():
    with timeout(1):
        await asyncio.sleep(10)


async def main():
    t = asyncio.create_task(test_task())
    await asyncio.sleep(2)
    t.cancel()
    try:
        await t
    except asyncio.TimeoutError:
        print("Raised TimeoutError")
    except asyncio.CancelledError:
        print("Raised CancelledError")

asyncio.run(main())
```

### Expected behavior

`asyncio.CancelledError` should never be suppressed when you cancel the task explicitly.

### Logs/tracebacks

```python-traceback
---
```


### Python Version

```console
Python 3.8.10
```


### aiohttp Version

```console
3.7.4.post0
```


### multidict Version

```console
4.7.6
```


### yarl Version

```console
1.6.0
```


### OS

Linux

### Related component

Client

### Additional context

_No response_

### Code of Conduct

- [x] I agree to follow the aio-libs Code of Conduct

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/aio-libs/async-timeout/pull/230?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) Report
> Merging [#230](https://codecov.io/gh/aio-libs/async-timeout/pull/230?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) (7e136a2) into [master](https://codecov.io/gh/aio-libs/async-timeout/commit/7e088ce654ddd9981c2c120cc9cf7c8af5d4d998?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) (7e088ce) will **decrease** coverage by `1.86%`.
> The diff coverage is `0.00%`.

[![Impacted file tree graph](https://codecov.io/gh/aio-libs/async-timeout/pull/230/graphs/tree.svg?width=650&height=150&src=pr&token=IG0gPXCx1e&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs)](https://codecov.io/gh/aio-libs/async-timeout/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs)

```diff
@@             Coverage Diff             @@
##            master     #230      +/-   ##
===========================================
- Coverage   100.00%   98.13%   -1.87%     
===========================================
  Files            1        1              
  Lines          105      107       +2     
  Branches        15       16       +1     
===========================================
  Hits           105      105              
- Misses           0        1       +1     
- Partials         0        1       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| unit | `96.26% <0.00%> (-1.84%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/aio-libs/async-timeout/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs) | Coverage Δ | |
|---|---|---|
| [async\_timeout/\_\_init\_\_.py](https://codecov.io/gh/aio-libs/async-timeout/pull/230/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs#diff-YXN5bmNfdGltZW91dC9fX2luaXRfXy5weQ==) | `98.13% <0.00%> (-1.87%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/aio-libs/async-timeout/pull/230?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/aio-libs/async-timeout/pull/230?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs). Last update [7e088ce...7e136a2](https://codecov.io/gh/aio-libs/async-timeout/pull/230?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aio-libs).

asvetlov: Ok, let me merge and make a new release

Patch:
diff --git a/async_timeout/__init__.py b/async_timeout/__init__.py
index 6222654..31a4aef 100644
--- a/async_timeout/__init__.py
+++ b/async_timeout/__init__.py
@@ -198,6 +198,9 @@ class Timeout:
         return None
 
     def _on_timeout(self, task: "asyncio.Task[None]") -> None:
+        # See Issue #229 and PR #230 for details      
+        if task._fut_waiter and task._fut_waiter.cancelled():  # type: ignore[attr-defined]  # noqa: E501
+            return
         task.cancel()
         self._state = _State.TIMEOUT
 


Test patch:
diff --git a/tests/test_timeout.py b/tests/test_timeout.py
index e76c54c..131c94d 100644
--- a/tests/test_timeout.py
+++ b/tests/test_timeout.py
@@ -1,9 +1,10 @@
 import asyncio
+import sys
 import time
 
 import pytest
 
-from async_timeout import timeout, timeout_at
+from async_timeout import Timeout, timeout, timeout_at
 
 
 @pytest.mark.asyncio
@@ -344,3 +345,55 @@ async def test_deprecated_with() -> None:
     with pytest.warns(DeprecationWarning):
         with timeout(1):
             await asyncio.sleep(0)
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="Not supported in 3.6")
+@pytest.mark.asyncio
+async def test_race_condition_cancel_before() -> None:
+    """Test race condition when cancelling before timeout.
+
+    If cancel happens immediately before the timeout, then
+    the timeout may overrule the cancellation, making it
+    impossible to cancel some tasks.
+    """
+
+    async def test_task(deadline: float, loop: asyncio.AbstractEventLoop) -> None:
+        # We need the internal Timeout class to specify the deadline (not delay).
+        # This is needed to create the precise timing to reproduce the race condition.
+        with Timeout(deadline, loop):
+            await asyncio.sleep(10)
+
+    loop = asyncio.get_running_loop()
+    deadline = loop.time() + 1
+    t = asyncio.create_task(test_task(deadline, loop))
+    loop.call_at(deadline, t.cancel)
+    await asyncio.sleep(1.1)
+    # If we get a TimeoutError, then the code is broken.
+    with pytest.raises(asyncio.CancelledError):
+        await t
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="Not supported in 3.6")
+@pytest.mark.asyncio
+async def test_race_condition_cancel_after() -> None:
+    """Test race condition when cancelling after timeout.
+
+    Similarly to the previous test, if a cancel happens
+    immediately after the timeout (but before the __exit__),
+    then the explicit cancel can get overruled again.
+    """
+
+    async def test_task(deadline: float, loop: asyncio.AbstractEventLoop) -> None:
+        # We need the internal Timeout class to specify the deadline (not delay).
+        # This is needed to create the precise timing to reproduce the race condition.
+        with Timeout(deadline, loop):
+            await asyncio.sleep(10)
+
+    loop = asyncio.get_running_loop()
+    deadline = loop.time() + 1
+    t = asyncio.create_task(test_task(deadline, loop))
+    loop.call_at(deadline + 0.000001, t.cancel)
+    await asyncio.sleep(1.1)
+    # If we get a TimeoutError, then the code is broken.
+    with pytest.raises(asyncio.CancelledError):
+        await t