Repository: cocotb/cocotb

Issue:
Deprecate `cocotb.fork`
With #2660 bringing in `cocotb.start` and `cocotb.start_soon`, and these APIs replacing `cocotb.fork` we should deprecate `cocotb.fork` in favor of these APIs so it may be removed in 2.0. This is supported by the fact that a new scheduler implementation will likely not support `fork`'s semantics.

## Rationale
* `fork` causes the coroutine to be scheduled immediately, which causes recursion in the scheduler leading to bugs in corner cases that are not trivial to fix
* because of the immediate scheduling it is impossible to catch an exception that occur before the first `await`, `start_soon` does not have this problem
* Can lead to recursion exceptions in programs that recursively fork


Discussion:

Resolving patch:
diff --git a/cocotb/__init__.py b/cocotb/__init__.py
--- a/cocotb/__init__.py
+++ b/cocotb/__init__.py
@@ -186,7 +186,22 @@ def _reopen_stream_with_buffering(stream_name):
 
 
 def fork(coro: Union[RunningTask, Coroutine]) -> RunningTask:
-    """Schedule a coroutine to be run concurrently. See :ref:`coroutines` for details on its use."""
+    """
+    Schedule a coroutine to be run concurrently. See :ref:`coroutines` for details on its use.
+
+    .. deprecated:: 1.7.0
+        This function has been deprecated in favor of :func:`cocotb.start_soon` and :func:`cocotb.start`.
+        In most cases you can simply substitute ``cocotb.fork`` with ``cocotb.start_soon``.
+        For more information on when to use ``start_soon`` vs ``start`` see :ref:`_coroutines`.
+    """
+    warnings.warn(
+        "cocotb.fork has been deprecated in favor of cocotb.start_soon and cocotb.start.\n"
+        "In most cases you can simply substitute cocotb.fork with cocotb.start_soon.\n"
+        "For more information about when you would want to use cocotb.start see the docs,\n"
+        "https://docs.cocotb.org/en/latest/coroutines.html#concurrent-execution",
+        DeprecationWarning,
+        stacklevel=2,
+    )
     return scheduler.add(coro)
 
 


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
@@ -22,6 +22,7 @@ MODULE := "\
 	test_pytest,\
 	test_queues,\
 	test_utils,\
+	test_fork,\
 	"
 
 ifeq ($(SIM),questa)
diff --git a/tests/test_cases/test_cocotb/test_concurrency_primitives.py b/tests/test_cases/test_cocotb/test_concurrency_primitives.py
--- a/tests/test_cases/test_cocotb/test_concurrency_primitives.py
+++ b/tests/test_cases/test_cocotb/test_concurrency_primitives.py
@@ -137,7 +137,7 @@ async def test_fork_combine(dut):
     async def coro(delay):
         await Timer(delay, "ns")
 
-    tasks = [cocotb.fork(coro(dly)) for dly in [10, 30, 20]]
+    tasks = [cocotb.start_soon(coro(dly)) for dly in [10, 30, 20]]
 
     await Combine(*(t.join() for t in tasks))
 
diff --git a/tests/test_cases/test_cocotb/test_deprecated.py b/tests/test_cases/test_cocotb/test_deprecated.py
--- a/tests/test_cases/test_cocotb/test_deprecated.py
+++ b/tests/test_cases/test_cocotb/test_deprecated.py
@@ -197,3 +197,12 @@ async def test_lessthan_raises_error(dut):
         ret = dut.stream_in_data <= 0x12
     with pytest.raises(TypeError):
         bool(ret)
+
+
+@cocotb.test()
+async def test_fork_deprecated(_):
+    async def example_coro():
+        pass
+
+    with pytest.warns(DeprecationWarning):
+        cocotb.fork(example_coro())
diff --git a/tests/test_cases/test_cocotb/test_fork.py b/tests/test_cases/test_cocotb/test_fork.py
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_cocotb/test_fork.py
@@ -0,0 +1,63 @@
+# Copyright cocotb contributors
+# Licensed under the Revised BSD License, see LICENSE for details.
+# SPDX-License-Identifier: BSD-3-Clause
+import warnings
+
+from common import MyException
+
+import cocotb
+
+
+@cocotb.test()
+async def test_fork_start_immediately(_):
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+
+        a = 0
+
+        async def increments():
+            nonlocal a
+            a += 1
+
+        # fork runs the body of increments() up until the first await immediately, so "a" is incremented
+        cocotb.fork(increments())
+        assert a == 1
+
+
+@cocotb.test()
+async def test_start_soon_doesnt_start_immediately(_):
+    a = 0
+
+    async def increments():
+        nonlocal a
+        a += 1
+
+    # start_soon doesn't run incremenents() immediately, so "a" is never incremented
+    cocotb.start_soon(increments())
+    assert a == 0
+
+
+async def coro():
+    raise MyException()
+
+
+@cocotb.test(expect_error=RuntimeError)
+async def test_fork_keep_running(_):
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+
+        # failing task ends the test, but the test keeps running because fork causes re-entrancy
+        # so the next raise causes the test to fail with a different error
+        cocotb.fork(coro())
+        raise RuntimeError()
+
+
+@cocotb.test(expect_error=MyException)
+async def test_start_doesnt_keep_running(_):
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+
+        # failing task ends the test, but because we have to suspend the current task
+        # the next raise is never run again after the test fails because the test coro is not scheduled again
+        await cocotb.start(coro())
+        raise RuntimeError()
diff --git a/tests/test_cases/test_cocotb/test_scheduler.py b/tests/test_cases/test_cocotb/test_scheduler.py
--- a/tests/test_cases/test_cocotb/test_scheduler.py
+++ b/tests/test_cases/test_cocotb/test_scheduler.py
@@ -10,6 +10,7 @@
 """
 import logging
 import re
+import warnings
 from typing import Coroutine
 
 import pytest
@@ -44,7 +45,7 @@ async def test_coroutine_kill(dut):
     global test_flag
     clk_gen = cocotb.start_soon(Clock(dut.clk, 100, "ns").start())
     await Timer(100, "ns")
-    cocotb.fork(clock_yield(clk_gen))
+    cocotb.start_soon(clock_yield(clk_gen))
     await Timer(100, "ns")
     clk_gen.kill()
     assert not test_flag
@@ -71,10 +72,10 @@ async def clock_two(dut):
 @cocotb.test()
 async def test_coroutine_close_down(dut):
     log = logging.getLogger("cocotb.test")
-    cocotb.fork(Clock(dut.clk, 100, "ns").start())
+    cocotb.start_soon(Clock(dut.clk, 100, "ns").start())
 
-    coro_one = cocotb.fork(clock_one(dut))
-    coro_two = cocotb.fork(clock_two(dut))
+    coro_one = cocotb.start_soon(clock_one(dut))
+    coro_two = cocotb.start_soon(clock_two(dut))
 
     await Join(coro_one)
     await Join(coro_two)
@@ -95,7 +96,7 @@ async def some_coro():
         await Timer(1, "ns")
         return retval
 
-    coro = cocotb.fork(some_coro())
+    coro = cocotb.start_soon(some_coro())
 
     retval = 1
     x = await coro.join()
@@ -120,10 +121,10 @@ async def wait_for(clk, cycles):
             await rising_edge
         return 3
 
-    cocotb.fork(Clock(dut.clk, 2000, "ps").start())
+    cocotb.start_soon(Clock(dut.clk, 2000, "ps").start())
 
-    short_wait = cocotb.fork(wait_for(dut.clk, 10))
-    long_wait = cocotb.fork(wait_for(dut.clk, 30))
+    short_wait = cocotb.start_soon(wait_for(dut.clk, 10))
+    long_wait = cocotb.start_soon(wait_for(dut.clk, 30))
 
     await wait_for(dut.clk, 20)
     a = await short_wait.join()
@@ -136,7 +137,7 @@ async def test_kill_twice(dut):
     """
     Test that killing a coroutine that has already been killed does not crash
     """
-    clk_gen = cocotb.fork(Clock(dut.clk, 100, "ns").start())
+    clk_gen = cocotb.start_soon(Clock(dut.clk, 100, "ns").start())
     await Timer(1, "ns")
     clk_gen.kill()
     await Timer(1, "ns")
@@ -148,7 +149,7 @@ async def test_join_identity(dut):
     """
     Test that Join() returns the same object each time
     """
-    clk_gen = cocotb.fork(Clock(dut.clk, 100, "ns").start())
+    clk_gen = cocotb.start_soon(Clock(dut.clk, 100, "ns").start())
 
     assert Join(clk_gen) is Join(clk_gen)
     await Timer(1, "ns")
@@ -219,13 +220,13 @@ async def victim():
         nonlocal victim_resumed
         victim_resumed = True
 
-    victim_task = cocotb.fork(victim())
+    victim_task = cocotb.start_soon(victim())
 
     async def killer():
         await RisingEdge(dut.clk)
         victim_task.kill()
 
-    cocotb.fork(killer())
+    cocotb.start_soon(killer())
 
     await Timer(
         2, "step"
@@ -413,7 +414,9 @@ async def coroutine_inner():
         log.info(repr(this_task))
         assert re.match(r"<Task \d+ running coro=coroutine_outer\(\)>", repr(this_task))
 
-        cocotb.fork(coroutine_forked(this_task))
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            cocotb.fork(coroutine_forked(this_task))
         await Combine(*(coroutine_wait() for _ in range(2)))
 
         return "Combine done"
diff --git a/tests/test_cases/test_forked_exception/test_forked_exception.py b/tests/test_cases/test_forked_exception/test_forked_exception.py
--- a/tests/test_cases/test_forked_exception/test_forked_exception.py
+++ b/tests/test_cases/test_forked_exception/test_forked_exception.py
@@ -25,5 +25,5 @@ async def fails():
         await Timer(10, "ns")
         raise MyException
 
-    cocotb.fork(fails())
+    cocotb.start_soon(fails())
     await Timer(20, "ns")