Repository: cocotb/cocotb

Issue:
Improve error reporting for `yield` inside async function
Those that are in the process of converting `cocotb.coroutine` functions using `yield` to async functions may run into problems where they miss a `yield`, which turns their coroutine into an async generator. If an async generator makes it to the scheduler it will result in a less than helpful error message. Instead we should check for async generators where we expect coroutines and throw a `TypeError` with a more helpful message, much like we do with old style coroutines missing `yield` statements.


Discussion:
Good idea
I assume the current error message is from these lines:

https://github.com/cocotb/cocotb/blob/c4a573defa921e5a16e63f585481ad3790bcf5d7/cocotb/decorators.py#L90-L98

Is all you're suggesting a more precise error if `inspect.isasyncgen` is true?
@eric-wieser I think that will be the only place that needs to change, `fork`s and `yield`s must go through that, when using `await` you have different semantics. But I will test.

Resolving patch:
diff --git a/cocotb/decorators.py b/cocotb/decorators.py
--- a/cocotb/decorators.py
+++ b/cocotb/decorators.py
@@ -85,12 +85,18 @@ class RunningTask(object):
         task.kill() will destroy a coroutine instance (and cause any Join
         triggers to fire.
     """
+
     def __init__(self, inst):
 
         if inspect.iscoroutine(inst):
             self._natively_awaitable = True
         elif inspect.isgenerator(inst):
             self._natively_awaitable = False
+        elif sys.version_info >= (3, 6) and inspect.isasyncgen(inst):
+            raise TypeError(
+                "{} is an async generator, not a coroutine. "
+                "You likely used the yield keyword instead of await.".format(
+                    inst.__qualname__))
         else:
             raise TypeError(
                 "%s isn't a valid coroutine! Did you forget to use the yield keyword?" % inst)
diff --git a/cocotb/scheduler.py b/cocotb/scheduler.py
--- a/cocotb/scheduler.py
+++ b/cocotb/scheduler.py
@@ -34,6 +34,7 @@
 the ReadOnly (and this is invalid, at least in Modelsim).
 """
 import os
+import sys
 import logging
 import threading
 import inspect
@@ -648,6 +649,12 @@ def add(self, coroutine):
         if inspect.iscoroutine(coroutine):
             return self.add(cocotb.decorators.RunningTask(coroutine))
 
+        elif sys.version_info >= (3, 6) and inspect.isasyncgen(coroutine):
+            raise TypeError(
+                "{} is an async generator, not a coroutine. "
+                "You likely used the yield keyword instead of await.".format(
+                    coroutine.__qualname__))
+
         elif not isinstance(coroutine, cocotb.decorators.RunningTask):
             raise TypeError(
                 "Attempt to add a object of type {} to the scheduler, which "
@@ -721,6 +728,12 @@ def _trigger_from_any(self, result):
         if isinstance(result, cocotb.triggers.Waitable):
             return self._trigger_from_waitable(result)
 
+        if sys.version_info >= (3, 6) and inspect.isasyncgen(result):
+            raise TypeError(
+                "{} is an async generator, not a coroutine. "
+                "You likely used the yield keyword instead of await.".format(
+                    result.__qualname__))
+
         raise TypeError(
             "Coroutine yielded an object of type {}, which the scheduler can't "
             "handle: {!r}\n"


Test patch:
diff --git a/tests/test_cases/test_cocotb/Makefile b/tests/test_cases/test_cocotb/Makefile
--- a/tests/test_cases/test_cocotb/Makefile
+++ b/tests/test_cases/test_cocotb/Makefile
@@ -29,4 +29,8 @@
 
 include ../../designs/sample_module/Makefile
 
-MODULE = test_cocotb,test_deprecated
+MODULE := test_cocotb,test_deprecated
+
+ifeq ($(shell python -c "import sys; print(sys.version_info >= (3, 6))"), "True")
+MODULE += ,test_async_generators
+endif
diff --git a/tests/test_cases/test_cocotb/test_async_generators.py b/tests/test_cases/test_cocotb/test_async_generators.py
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_cocotb/test_async_generators.py
@@ -0,0 +1,42 @@
+import cocotb
+
+
+async def whoops_async_generator():
+    # the user should have used `await` here, but they wrote `yield` by accident.
+    yield cocotb.triggers.Timer(1)
+
+
+@cocotb.test()
+def test_yielding_accidental_async_generator(dut):
+    # this test deliberately does not use `async def`, as we are testing the behavior of `yield`
+    try:
+        yield whoops_async_generator()
+    except TypeError as e:
+        assert "async generator" in str(e)
+    else:
+        assert False, "should have thrown"
+
+
+@cocotb.test()
+async def test_forking_accidental_async_generator(dut):
+    try:
+        cocotb.fork(whoops_async_generator())
+    except TypeError as e:
+        assert "async generator" in str(e)
+    else:
+        assert False, "should have thrown"
+
+
+@cocotb.coroutine
+async def whoops_async_generator_decorated():
+    yield cocotb.triggers.Timer(1)
+
+
+@cocotb.test()
+async def test_decorating_accidental_async_generator(dut):
+    try:
+        await whoops_async_generator_decorated()
+    except TypeError as e:
+        assert "async generator" in str(e)
+    else:
+        assert False, "should have thrown"