Repository: agronholm/anyio

Issue:
CancelScope.cancel() does not work as expected if called within move_on_after() context
I ran  this: 
```python
from anyio import move_on_after, sleep, run, CancelScope

result1 = 0
result2 = 0

async def main1():
    global result1
    with CancelScope() as ascope:
        with move_on_after(1, shield=True) as scope:  # XXX
            with CancelScope(shield=True):
                print("Starting sleep")
                ascope.cancel()
                print("ascope cancelled")
                await sleep(2)

                print( "Cancel scopes=", scope.cancel_called, ascope.cancel_called)

        print("zzzz")
        await sleep(0)

        result1 = 1
        print("Exited cancel scopes=", scope.cancel_called, ascope.cancel_called)

async def main2():
    global result2
    with CancelScope() as ascope:
        with move_on_after(1) as scope:  # XXX
            with CancelScope(shield=True):  # XXX
                with CancelScope(shield=True):
                    print("Starting sleep")
                    ascope.cancel()
                    print("ascope cancelled")
                    await sleep(2)

                    print("Cancel scopes=", scope.cancel_called, ascope.cancel_called)

        print("zzzz")
        await sleep(0)

        result2 = 1
        print("Exited cancel scopes=", scope.cancel_called, ascope.cancel_called)

run(main1); print()
run(main2); print()
assert result1 == result2, "main1() and main2() should be equivalent"
```
And the outcome is:
```
Starting sleep
ascope cancelled
Cancel scopes= True True
zzzz

Starting sleep
ascope cancelled
Cancel scopes= True True
zzzz
Exited cancel scopes= True True

Traceback (most recent call last):
  File "C:\tmp\whichmove.py", line 45, in <module>
    assert result1 == result2, "main1() and main2() should be equivalent"
AssertionError: main1() and main2() should be equivalent
```
This came from `win10`, `python 3.9.6`, `asyncio`, `anyio-3.3.1`

From my reading of the documentation both main1 and main2 should be equivalent.  If that is the case, I would say there is an issue with main2.  Thank you.

Discussion:

Patch:
diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst
index 8614af4..4c86183 100644
--- a/docs/versionhistory.rst
+++ b/docs/versionhistory.rst
@@ -3,6 +3,12 @@ Version history
 
 This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
 
+**UNRELEASED**
+
+- Fixed cancellation problem on asyncio where level-triggered cancellation for **all** parent
+  cancel scopes would not resume after exiting a shielded nested scope
+  (`#370 <https://github.com/agronholm/anyio/issues/370>`_)
+
 **3.3.1**
 
 - Added missing documentation for the ``ExceptionGroup.exceptions`` attribute
diff --git a/pyproject.toml b/pyproject.toml
index d6c4fa1..fe93e5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,9 @@ testpaths = ["tests"]
 filterwarnings = [
     "error",
     "ignore:unclosed <socket.socket.*:ResourceWarning",
-    "ignore:unclosed transport <_ProactorSocketTransport.*:ResourceWarning"
+    "ignore:unclosed transport <_ProactorSocketTransport.*:ResourceWarning",
+    # Workaround for Python 3.9.7 (see https://bugs.python.org/issue45097)
+    "ignore:The loop argument is deprecated since Python 3\\.8, and scheduled for removal in Python 3\\.10\\.:DeprecationWarning:asyncio"
 ]
 markers = [
     "network: marks tests as requiring Internet access",
diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py
index d5abdd3..8e9fed0 100644
--- a/src/anyio/_backends/_asyncio.py
+++ b/src/anyio/_backends/_asyncio.py
@@ -305,7 +305,7 @@ class CancelScope(BaseCancelScope):
 
         host_task_state.cancel_scope = self._parent_scope
 
-        # Restart the cancellation effort in the nearest directly cancelled parent scope if this
+        # Restart the cancellation effort in the farthest directly cancelled parent scope if this
         # one was shielded
         if self._shield:
             self._deliver_cancellation_to_parent()
@@ -365,12 +365,12 @@ class CancelScope(BaseCancelScope):
             self._cancel_handle = None
 
     def _deliver_cancellation_to_parent(self) -> None:
-        """Start cancellation effort in the nearest directly cancelled parent scope"""
+        """Start cancellation effort in the farthest directly cancelled parent scope"""
         scope = self._parent_scope
+        scope_to_cancel: Optional[CancelScope] = None
         while scope is not None:
             if scope._cancel_called and scope._cancel_handle is None:
-                scope._deliver_cancellation()
-                break
+                scope_to_cancel = scope
 
             # No point in looking beyond any shielded scope
             if scope._shield:
@@ -378,6 +378,9 @@ class CancelScope(BaseCancelScope):
 
             scope = scope._parent_scope
 
+        if scope_to_cancel is not None:
+            scope_to_cancel._deliver_cancellation()
+
     def _parent_cancelled(self) -> bool:
         # Check whether any parent has been cancelled
         cancel_scope = self._parent_scope


Test patch:
diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py
index 62a2389..1980470 100644
--- a/tests/test_taskgroups.py
+++ b/tests/test_taskgroups.py
@@ -12,6 +12,7 @@ from anyio import (
     CancelScope, ExceptionGroup, create_task_group, current_effective_deadline, current_time,
     fail_after, get_cancelled_exc_class, move_on_after, sleep, wait_all_tasks_blocked)
 from anyio.abc import TaskGroup, TaskStatus
+from anyio.lowlevel import checkpoint
 
 if sys.version_info < (3, 7):
     current_task = asyncio.Task.current_task
@@ -246,7 +247,7 @@ async def test_host_exception() -> None:
     assert result is None
 
 
-async def test_edge_cancellation() -> None:
+async def test_level_cancellation() -> None:
     marker = None
 
     async def dummy() -> None:
@@ -723,6 +724,29 @@ async def test_nested_shield() -> None:
                         await sleep(2)
 
 
+async def test_triple_nested_shield() -> None:
+    """Regression test for #370."""
+
+    got_past_checkpoint = False
+
+    async def taskfunc() -> None:
+        nonlocal got_past_checkpoint
+
+        with CancelScope() as scope1:
+            with CancelScope() as scope2:
+                with CancelScope(shield=True):
+                    scope1.cancel()
+                    scope2.cancel()
+
+            await checkpoint()
+            got_past_checkpoint = True
+
+    async with create_task_group() as tg:
+        tg.start_soon(taskfunc)
+
+    assert not got_past_checkpoint
+
+
 def test_task_group_in_generator(anyio_backend_name: str,
                                  anyio_backend_options: Dict[str, Any]) -> None:
     async def task_group_generator() -> AsyncGenerator[None, None]: