Repository: maxfischer2781/asyncstdlib

Issue:
Possible mishandling of GeneratorExit in contextmanager
There seems to be an error in `contextmanager.__aexit__` if the wrapped async generator was/is closed already. In this case, `GeneratorExit` kills the wrapped generator which then propagates onwards; however, when `__aexit__` attempts to close the wrapped generator it only expects `StopAsyncIteration` and assumes the `GeneratorExit` is an actual error.

Apparently, this is related to generator cleanup during shutdown when we have a sandwich of "async generator"->"contextmanager"->"async generator" (see also https://github.com/python-trio/trio/issues/2081). In this case it is ill-defined in which order the async generators are cleaned up and the inner generator may die before the outer one.

I have only been able to catch this in the wild by accident. 
```none
unhandled exception during asyncio.run() shutdown
task: <Task finished coro=<async_generator_athrow()> exception=RuntimeError("can't send non-None value to a just-started coroutine",)>
RuntimeError: can't send non-None value to a just-started coroutine
an error occurred during closing of asynchronous generator <async_generator object Node.from_pool at 0x7f07722cee80>
asyncgen: <async_generator object Node.from_pool at 0x7f07722cee80>
Traceback (most recent call last):
  File "/.../condor_accountant/_infosystem/nodes.py", line 34, in from_pool
    yield cls(name, machine, __type, address)
GeneratorExit

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/.../condor_accountant/_infosystem/nodes.py", line 34, in from_pool
    yield cls(name, machine, __type, address)
  File "/.../pca_venv/lib64/python3.6/site-packages/asyncstdlib/contextlib.py", line 109, in __aexit__
    raise RuntimeError("generator did not stop after throw() in __aexit__")
RuntimeError: generator did not stop after throw() in __aexit__
```

----------

Possibly related to #3.

Discussion:

Patch:
diff --git a/asyncstdlib/contextlib.py b/asyncstdlib/contextlib.py
index ea8d11d..52a98c7 100644
--- a/asyncstdlib/contextlib.py
+++ b/asyncstdlib/contextlib.py
@@ -90,7 +90,13 @@ class _AsyncGeneratorContextManager(Generic[T]):
                 raise RuntimeError("generator did not stop after __aexit__")
         else:
             try:
-                await self.gen.athrow(exc_type, exc_val, exc_tb)
+                # We are being closed as part of (async) generator shutdown.
+                # Use `aclose` to have additional checks for the child to
+                # handle shutdown properly.
+                if exc_type is GeneratorExit:
+                    result = await self.gen.aclose()
+                else:
+                    result = await self.gen.athrow(exc_type, exc_val, exc_tb)
             except StopAsyncIteration as exc:
                 return exc is not exc_tb
             except RuntimeError as exc:
@@ -106,6 +112,16 @@ class _AsyncGeneratorContextManager(Generic[T]):
                     raise
                 return False
             else:
+                # During shutdown, the child generator might be cleaned up early.
+                # In this case,
+                # - the child will return nothing/None,
+                # - we get cleaned up via GeneratorExit as well,
+                # and we should go on with our own cleanup.
+                # This might happen if the child mishandles GeneratorExit as well,
+                # but is the closest we can get to checking the situation.
+                # See https://github.com/maxfischer2781/asyncstdlib/issues/84
+                if exc_type is GeneratorExit and result is None:
+                    return False
                 raise RuntimeError("generator did not stop after throw() in __aexit__")
 
 


Test patch:
diff --git a/unittests/test_contextlib.py b/unittests/test_contextlib.py
index 7a5ae2f..22211d1 100644
--- a/unittests/test_contextlib.py
+++ b/unittests/test_contextlib.py
@@ -34,6 +34,8 @@ async def test_contextmanager():
 
 @sync
 async def test_contextmanager_no_yield():
+    """Test that it is an error for a context to not yield"""
+
     @a.contextmanager
     async def no_yield():
         if False:
@@ -46,6 +48,8 @@ async def test_contextmanager_no_yield():
 
 @sync
 async def test_contextmanager_no_stop():
+    """Test that it is an error for a context to yield again after stopping"""
+
     @a.contextmanager
     async def no_stop():
         yield
@@ -69,6 +73,8 @@ async def test_contextmanager_no_stop():
 
 @sync
 async def test_contextmanager_raise_asyncstop():
+    """Test that StopAsyncIteration may propagate out of a context block"""
+
     @a.contextmanager
     async def no_raise():
         yield
@@ -113,6 +119,8 @@ async def test_contextmanager_raise_runtimeerror():
 
 @sync
 async def test_contextmanager_raise_same():
+    """Test that outer exceptions do not shadow inner/newer ones"""
+
     @a.contextmanager
     async def reraise():
         try:
@@ -136,6 +144,65 @@ async def test_contextmanager_raise_same():
             raise KeyError("outside")
 
 
+@sync
+async def test_contextmanager_raise_generatorexit():
+    """Test that shutdown via GeneratorExit is propagated"""
+
+    @a.contextmanager
+    async def no_op():
+        yield
+
+    with pytest.raises(GeneratorExit):
+        async with no_op():
+            raise GeneratorExit("used to tear down coroutines")
+
+    # during shutdown, generators may be killed in arbitrary order
+    # make sure we do not suppress GeneratorExit
+    with pytest.raises(GeneratorExit, match="inner"):
+        context = no_op()
+        async with context:
+            # simulate cleanup closing the child early
+            await context.gen.aclose()
+            raise GeneratorExit("inner")
+
+
+@sync
+async def test_contextmanager_no_suppress_generatorexit():
+    """Test that GeneratorExit is not suppressed"""
+
+    @a.contextmanager
+    async def no_op():
+        yield
+
+    exc = GeneratorExit("GE should not be replaced normally")
+    with pytest.raises(type(exc)) as exc_info:
+        async with no_op():
+            raise exc
+    assert exc_info.value is exc
+
+    @a.contextmanager
+    async def exit_ge():
+        try:
+            yield
+        except GeneratorExit:
+            pass
+
+    with pytest.raises(GeneratorExit):
+        async with exit_ge():
+            raise GeneratorExit("Resume teardown if child exited")
+
+    @a.contextmanager
+    async def ignore_ge():
+        try:
+            yield
+        except GeneratorExit:
+            yield
+
+    with pytest.raises(RuntimeError):
+        async with ignore_ge():
+            raise GeneratorExit("Warn if child does not exit")
+
+
 @sync
 async def test_nullcontext():
     async with a.nullcontext(1337) as value: