Repository: cocotb/cocotb

Issue:
Scheduler creates duplicate ReadWrite and NextTimeStep triggers
It's not clear that this is deliberate. This means that the scheduler calling `self._readwrite.prime(self.react)` will overwrite whatever was primed through `ReadWrite().prime(...)`.

This can be mostly resolved by keeping track of which triggers are scheduled by the scheduler itself, and ensuring we don't unprime them just because no coroutines are waiting on them.

However, this approach seems to still cause trouble - If normal singleton instances are used, `test_cocotb.test_readwrite_in_readonly` causes the test that follows it to fail. More investigation is probably needed here.


Discussion:
Here's a real bug that this causes:
```python
@cocotb.test()
def test_builtin_triggers(dut):
    yield Timer(1)
    dut.clk <= 1
    yield ReadWrite()
    cocotb.log.info("OK")
```

This fails with the following, followed by an early simulator termination:
```
Attempt to prime an already primed trigger for cbReadWriteSynch!
We seem to already be registered, deregistering cbReadWriteSynch!
```

Resolving patch:
diff --git a/cocotb/scheduler.py b/cocotb/scheduler.py
--- a/cocotb/scheduler.py
+++ b/cocotb/scheduler.py
@@ -209,12 +209,6 @@ class Scheduler(object):
 
     # Singleton events, recycled to avoid spurious object creation
     _readonly = ReadOnly()
-    # TODO[gh-759]: For some reason, the scheduler requires that these triggers
-    # are _not_ the same instances used by the tests themselves. This is risky,
-    # because it can lead to them overwriting each other's callbacks. We should
-    # try to remove this `copy.copy` in future.
-    _next_timestep = copy.copy(NextTimeStep())
-    _readwrite = copy.copy(ReadWrite())
     _timer1 = Timer(1)
     _timer0 = Timer(0)
 
@@ -248,35 +242,42 @@ def __init__(self):
         self._entrypoint = None
         self._main_thread = threading.current_thread()
 
-        # Select the appropriate scheduling algorithm for this simulator
-        self.advance = self.default_scheduling_algorithm
         self._is_reacting = False
 
-    def default_scheduling_algorithm(self):
-        """
-        Decide whether we need to schedule our own triggers (if at all) in
-        order to progress to the next mode.
+        self._write_coro_inst = None
+        self._writes_pending = Event()
 
-        This algorithm has been tested against the following simulators:
-            Icarus Verilog
-        """
-        if not self._terminate and self._writes:
+    @cocotb.decorators.coroutine
+    def _do_writes(self):
+        """ An internal coroutine that performs pending writes """
+        while True:
+            yield self._writes_pending.wait()
+            if self._mode != Scheduler._MODE_NORMAL:
+                yield NextTimeStep()
+
+            yield ReadWrite()
 
-            if self._mode == Scheduler._MODE_NORMAL:
-                if not self._readwrite.primed:
-                    self._readwrite.prime(self.react)
-            elif not self._next_timestep.primed:
-                self._next_timestep.prime(self.react)
+            while self._writes:
+                handle, value = self._writes.popitem()
+                handle.setimmediatevalue(value)
+            self._writes_pending.clear()
 
-        elif self._terminate:
+    def _check_termination(self):
+        """
+        Handle a termination that causes us to move onto the next test.
+        """
+        if self._terminate:
             if _debug:
                 self.log.debug("Test terminating, scheduling Timer")
 
+            if self._write_coro_inst is not None:
+                self._write_coro_inst.kill()
+                self._write_coro_inst = None
+
             for t in self._trigger2coros:
                 t.unprime()
 
-            for t in [self._readwrite, self._readonly, self._next_timestep,
-                      self._timer1, self._timer0]:
+            for t in [self._timer1, self._timer0]:
                 if t.primed:
                     t.unprime()
 
@@ -284,6 +285,8 @@ def default_scheduling_algorithm(self):
             self._trigger2coros = collections.defaultdict(list)
             self._coro2trigger = {}
             self._terminate = False
+            self._writes = {}
+            self._writes_pending.clear()
             self._mode = Scheduler._MODE_TERM
 
     def begin_test(self, trigger=None):
@@ -316,7 +319,7 @@ def begin_test(self, trigger=None):
                 test = self._entrypoint
                 self._entrypoint = None
                 self.schedule(test)
-                self.advance()
+                self._check_termination()
 
     def react(self, trigger):
         """
@@ -330,6 +333,8 @@ def react(self, trigger):
             self._pending_triggers.append(trigger)
             return
 
+        assert not self._pending_triggers
+
         # start the event loop
         self._is_reacting = True
         try:
@@ -371,34 +376,6 @@ def _event_loop(self, trigger):
             elif isinstance(trigger, GPITrigger):
                 self._mode = Scheduler._MODE_NORMAL
 
-            # We're the only source of ReadWrite triggers which are only used for
-            # playing back any cached signal updates
-            if trigger is self._readwrite:
-
-                if _debug:
-                    self.log.debug("Writing cached signal updates")
-
-                while self._writes:
-                    handle, value = self._writes.popitem()
-                    handle.setimmediatevalue(value)
-
-                self._readwrite.unprime()
-
-                return
-
-            # Similarly if we've scheduled our next_timestep on way to readwrite
-            if trigger is self._next_timestep:
-
-                if not self._writes:
-                    self.log.error(
-                        "Moved to next timestep without any pending writes!")
-                else:
-                    self.log.debug(
-                        "Priming ReadWrite trigger so we can playback writes")
-                    self._readwrite.prime(self.react)
-
-                return
-
             # work through triggers one by one
             is_first = True
             self._pending_triggers.append(trigger)
@@ -465,7 +442,7 @@ def _event_loop(self, trigger):
                     self._pending_events.pop(0).set()
 
             # no more pending triggers
-            self.advance()
+            self._check_termination()
             if _debug:
                 self.log.debug("All coroutines scheduled, handing control back"
                                " to simulator")
@@ -504,13 +481,29 @@ def unschedule(self, coro):
     def save_write(self, handle, value):
         if self._mode == Scheduler._MODE_READONLY:
             raise Exception("Write to object {0} was scheduled during a read-only sync phase.".format(handle._name))
+
+        # TODO: we should be able to better keep track of when this needs to
+        # be scheduled
+        if self._write_coro_inst is None:
+            self._write_coro_inst = self._do_writes()
+            self.schedule(self._write_coro_inst)
+
         self._writes[handle] = value
+        self._writes_pending.set()
 
     def _coroutine_yielded(self, coro, trigger):
         """Prime the trigger and update our internal mappings."""
         self._coro2trigger[coro] = trigger
 
-        self._trigger2coros[trigger].append(coro)
+        if coro is self._write_coro_inst:
+            # Our internal write coroutine always runs before any user coroutines.
+            # This preserves the behavior prior to the refactoring of writes to
+            # this coroutine.
+            self._trigger2coros[trigger].insert(0, coro)
+        else:
+            # Everything else joins the back of the queue
+            self._trigger2coros[trigger].append(coro)
+
         if not trigger.primed:
             try:
                 trigger.prime(self.react)
@@ -597,7 +590,7 @@ def add(self, coroutine):
             self.log.debug("Adding new coroutine %s" % coroutine.__name__)
 
         self.schedule(coroutine)
-        self.advance()
+        self._check_termination()
         return coroutine
 
     def new_test(self, coroutine):


Test patch:
diff --git a/tests/test_cases/test_cocotb/test_cocotb.py b/tests/test_cases/test_cocotb/test_cocotb.py
--- a/tests/test_cases/test_cocotb/test_cocotb.py
+++ b/tests/test_cases/test_cocotb/test_cocotb.py
@@ -1030,5 +1030,37 @@ def wait_for_nested_first():
     yield fire_task.join()
 
 
+@cocotb.test()
+def test_readwrite(dut):
+    """ Test that ReadWrite can be waited on """
+    # gh-759
+    yield Timer(1)
+    dut.clk <= 1
+    yield ReadWrite()
+
+
+@cocotb.test()
+def test_writes_have_taken_effect_after_readwrite(dut):
+    """ Test that ReadWrite fires first for the background write coro """
+    dut.stream_in_data.setimmediatevalue(0)
+
+    @cocotb.coroutine
+    def write_manually():
+        yield ReadWrite()
+        # this should overwrite the write written below
+        dut.stream_in_data.setimmediatevalue(2)
+
+    # queue a backround task to do a manual write
+    waiter = cocotb.fork(write_manually())
+
+    # do a delayed write. This will be overwritten
+    dut.stream_in_data <= 3
+    yield waiter
+
+    # check that the write we expected took precedence
+    yield ReadOnly()
+    assert dut.stream_in_data.value == 2
+
+
 if sys.version_info[:2] >= (3, 5):
     from test_cocotb_35 import *