Repository: byllyfish/shellous (language: python)

Issue:
Add tests with eager task factory in 3.12.



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b9152ad8..636b97ee 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -80,6 +80,15 @@ jobs:
         fi
       env:
         SHELLOUS_LOOP_TYPE: "uvloop"
+    - name: Run Tests with eager task factory
+      if: matrix.python-version == '3.12-dev'
+      run: |
+        if ! pytest -vv -s --durations=20 --log-cli-level=DEBUG | ./ci/annotate.awk -v step=Run_Tests; then
+          echo "::warning title=Run Tests Warning::Re-running failed tests in $BUILD_NAME"
+          pytest -vv -s --durations=20 --log-cli-level=DEBUG --last-failed
+        fi
+      env:
+        SHELLOUS_LOOP_TYPE: "eager_task_factory"
     - name: Run Tests with PidfdWatcher (Linux)
       if: matrix.os == 'ubuntu-22.04'
       run: |
diff --git a/shellous/__init__.py b/shellous/__init__.py
index 30b7aaf5..ed1f0220 100644
--- a/shellous/__init__.py
+++ b/shellous/__init__.py
@@ -14,7 +14,12 @@
 from .pipeline import Pipeline
 from .pty_util import cbreak, cooked, raw
 from .result import Result, ResultError
-from .runner import AUDIT_EVENT_SUBPROCESS_SPAWN, PipeRunner, Runner
+from .runner import (
+    AUDIT_EVENT_SUBPROCESS_SPAWN,
+    PipeRunner,
+    Runner,
+    UNLAUNCHED_EXIT_CODE,
+)
 
 if sys.platform != "win32":
     from .watcher import DefaultChildWatcher
@@ -58,4 +63,5 @@
     "DefaultChildWatcher",
     "AUDIT_EVENT_SUBPROCESS_SPAWN",
     "AuditEventInfo",
+    "UNLAUNCHED_EXIT_CODE",
 ]
diff --git a/shellous/runner.py b/shellous/runner.py
index d7a72405..09b362ef 100644
--- a/shellous/runner.py
+++ b/shellous/runner.py
@@ -36,7 +36,6 @@
 _KILL_TIMEOUT = 3.0
 _CLOSE_TIMEOUT = 0.25
 _UNKNOWN_EXIT_CODE = 255
-_UNLAUNCHED_EXIT_CODE = -255
 
 AUDIT_EVENT_SUBPROCESS_SPAWN = "byllyfish/shellous.subprocess_spawn"
 """Audit event raised by sys.audit() when shellous runs a subprocess.
@@ -44,6 +43,11 @@
 The audit event has one argument: the name of the command.
 """
 
+UNLAUNCHED_EXIT_CODE = -255
+"""Special exit code used in audit events when process launch itself
+was cancelled."""
+
+
 _T = TypeVar("_T")
 
 
@@ -431,7 +435,7 @@ def returncode(self) -> Optional[int]:
         if not self._proc:
             if self._cancelled:
                 # The process was cancelled before starting.
-                return _UNLAUNCHED_EXIT_CODE
+                return UNLAUNCHED_EXIT_CODE
             return None
         code = self._proc.returncode
         if code == _UNKNOWN_EXIT_CODE and self._last_signal is not None:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 9b72d06e..42803250 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -27,22 +27,36 @@
 childwatcher_type = os.environ.get("SHELLOUS_CHILDWATCHER_TYPE")
 loop_type = os.environ.get("SHELLOUS_LOOP_TYPE")
 
-if loop_type:
-    if sys.platform != "win32" and loop_type == "uvloop":
-        import uvloop  # pyright: ignore[reportMissingImports]
-
-        @pytest.fixture
-        def event_loop():  # pyright: ignore[reportGeneralTypeIssues]
-            loop = uvloop.new_event_loop()
-            loop.set_debug(True)
-            yield loop
-            loop.close()
-            # Force garbage collection to flush out un-run __del__ methods.
-            del loop
-            gc.collect()
-
-    else:
-        raise NotImplementedError
+if loop_type == "eager_task_factory":
+    assert sys.version_info[0:2] >= (3, 12), "requires python 3.12"
+
+    @pytest.fixture
+    def event_loop():
+        _init_child_watcher()
+        loop = asyncio.new_event_loop()
+        loop.set_debug(True)
+        loop.set_task_factory(asyncio.eager_task_factory)
+        yield loop
+        loop.close()
+        # Force garbage collection to flush out un-run __del__ methods.
+        del loop
+        gc.collect()
+
+elif sys.platform != "win32" and loop_type == "uvloop":
+    import uvloop  # pyright: ignore[reportMissingImports]
+
+    @pytest.fixture
+    def event_loop():  # pyright: ignore[reportGeneralTypeIssues]
+        loop = uvloop.new_event_loop()
+        loop.set_debug(True)
+        yield loop
+        loop.close()
+        # Force garbage collection to flush out un-run __del__ methods.
+        del loop
+        gc.collect()
+
+elif loop_type:
+    raise NotImplementedError
 
 else:
 
diff --git a/tests/test_shellous.py b/tests/test_shellous.py
index e37ad419..ceed1aef 100644
--- a/tests/test_shellous.py
+++ b/tests/test_shellous.py
@@ -13,7 +13,7 @@
 import asyncstdlib as asl
 import pytest
 
-from shellous import Result, ResultError, sh
+from shellous import Result, ResultError, sh, UNLAUNCHED_EXIT_CODE
 from shellous.harvest import harvest_results
 
 # 4MB + 1: Much larger than necessary.
@@ -23,7 +23,6 @@
 # On Windows, the exit_code of a terminated process is 1.
 CANCELLED_EXIT_CODE = -15 if sys.platform != "win32" else 1
 KILL_EXIT_CODE = -9 if sys.platform != "win32" else 1
-UNLAUNCHED_EXIT_CODE = -255
 
 
 def _is_uvloop():
@@ -1214,13 +1213,31 @@ def _audit(phase, info):
     with pytest.raises(asyncio.TimeoutError):
         await asyncio.wait_for(sleep(10), 0.0)
 
-    # There are no start/stop audit calls when the timeout expires before
-    # launching the process.
-    assert not calls
+    # Python 3.12 added support for "eager" tasks. This test behaves differently
+    # when using eager tasks.
+    if sys.version_info[:2] >= (3, 12):
+        is_eager_task = (
+            asyncio.get_running_loop().get_task_factory() == asyncio.eager_task_factory
+        )
+    else:
+        is_eager_task = False
+
+    if is_eager_task:
+        # Eager task started but was cancelled before launching process.
+        assert calls == [
+            ("start", "sleep", None, ""),
+            ("stop", "sleep", UNLAUNCHED_EXIT_CODE, "CancelledError"),
+        ]
+    else:
+        # The task was cancelled before it even started, so there are no audit
+        # events.
+        assert not calls
 
 
 async def test_timeout_zero_seconds(sleep_cmd):
-    "Test command with a timeout of zero seconds."
+    """Test command with a shellous timeout of zero seconds.
+
+    Note that this behaves differently than `asyncio.wait_for`."""
     calls = []
 
     def _audit(phase, info):