Skip to content

Commit 3d235b7

Browse files
Tests and partial fix for suppressing cancellations. (#230)
1 parent f2c160a commit 3d235b7

File tree

2 files changed

+57
-1
lines changed

2 files changed

+57
-1
lines changed

async_timeout/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ def _do_exit(self, exc_type: Type[BaseException]) -> None:
198198
return None
199199

200200
def _on_timeout(self, task: "asyncio.Task[None]") -> None:
201+
# See Issue #229 and PR #230 for details
202+
if task._fut_waiter and task._fut_waiter.cancelled(): # type: ignore[attr-defined] # noqa: E501
203+
return
201204
task.cancel()
202205
self._state = _State.TIMEOUT
203206

tests/test_timeout.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import asyncio
2+
import sys
23
import time
34

45
import pytest
56

6-
from async_timeout import timeout, timeout_at
7+
from async_timeout import Timeout, timeout, timeout_at
78

89

910
@pytest.mark.asyncio
@@ -344,3 +345,55 @@ async def test_deprecated_with() -> None:
344345
with pytest.warns(DeprecationWarning):
345346
with timeout(1):
346347
await asyncio.sleep(0)
348+
349+
350+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Not supported in 3.6")
351+
@pytest.mark.asyncio
352+
async def test_race_condition_cancel_before() -> None:
353+
"""Test race condition when cancelling before timeout.
354+
355+
If cancel happens immediately before the timeout, then
356+
the timeout may overrule the cancellation, making it
357+
impossible to cancel some tasks.
358+
"""
359+
360+
async def test_task(deadline: float, loop: asyncio.AbstractEventLoop) -> None:
361+
# We need the internal Timeout class to specify the deadline (not delay).
362+
# This is needed to create the precise timing to reproduce the race condition.
363+
with Timeout(deadline, loop):
364+
await asyncio.sleep(10)
365+
366+
loop = asyncio.get_running_loop()
367+
deadline = loop.time() + 1
368+
t = asyncio.create_task(test_task(deadline, loop))
369+
loop.call_at(deadline, t.cancel)
370+
await asyncio.sleep(1.1)
371+
# If we get a TimeoutError, then the code is broken.
372+
with pytest.raises(asyncio.CancelledError):
373+
await t
374+
375+
376+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Not supported in 3.6")
377+
@pytest.mark.asyncio
378+
async def test_race_condition_cancel_after() -> None:
379+
"""Test race condition when cancelling after timeout.
380+
381+
Similarly to the previous test, if a cancel happens
382+
immediately after the timeout (but before the __exit__),
383+
then the explicit cancel can get overruled again.
384+
"""
385+
386+
async def test_task(deadline: float, loop: asyncio.AbstractEventLoop) -> None:
387+
# We need the internal Timeout class to specify the deadline (not delay).
388+
# This is needed to create the precise timing to reproduce the race condition.
389+
with Timeout(deadline, loop):
390+
await asyncio.sleep(10)
391+
392+
loop = asyncio.get_running_loop()
393+
deadline = loop.time() + 1
394+
t = asyncio.create_task(test_task(deadline, loop))
395+
loop.call_at(deadline + 0.000001, t.cancel)
396+
await asyncio.sleep(1.1)
397+
# If we get a TimeoutError, then the code is broken.
398+
with pytest.raises(asyncio.CancelledError):
399+
await t

0 commit comments

Comments
 (0)