diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6db6aa..84beb50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] - python: ["3.12", "3.13"] + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 diff --git a/docs/supported-api.md b/docs/supported-api.md index 02145fb..0a26ac8 100644 --- a/docs/supported-api.md +++ b/docs/supported-api.md @@ -80,9 +80,12 @@ Anything that reaches outside the simulation raises `SimulationFenceError`: executors and threads (`run_in_executor`, `call_soon_threadsafe`), signal handlers, subprocesses, file-descriptor callbacks (`add_reader` / `add_writer`), loop-level TLS upgrades (`start_tls`, -`create_connection(ssl=...)`), `sendfile`, and pipes. TLS a library -performs in memory reaches no loop API and so reaches no fence; what that -means in practice is in [docs/compatibility.md](compatibility.md). +`create_connection(ssl=...)`), `sendfile`, pipes, and an eager task start +(`create_task(eager_start=True)`), which would run a task's first step at +creation time, before the seeded draw could order it against anything. TLS +a library performs in memory reaches no loop API and so reaches no fence; +what that means in practice is in +[docs/compatibility.md](compatibility.md). The socket calls are fenced with one exception. `sock_connect` on an `AF_INET` stream socket is simulated — it is how client stacks reach the diff --git a/src/simloop/_loop.py b/src/simloop/_loop.py index 118bb03..2c74049 100644 --- a/src/simloop/_loop.py +++ b/src/simloop/_loop.py @@ -449,8 +449,15 @@ def run_until_complete(self, future: Any) -> Any: # collector to complain about. Draining stops as soon as no work # remains, keeping the seeded draw the only source of order. fut.cancel() - while (self._ready or self._timers) and not fut.done(): - self._step() + # The drain steps outside run_forever, but a task will only step + # while its loop is the running one (asyncio enforces this from + # 3.14), so the drain must declare itself the same way. + events._set_running_loop(self) + try: + while (self._ready or self._timers) and not fut.done(): + self._step() + finally: + events._set_running_loop(None) # A fire-and-forget task that failed keeps itself alive through a # reference cycle (its exception's traceback pins the coroutine frame), # so its exception only reaches call_exception_handler when the cycle @@ -516,8 +523,14 @@ def create_task( *, name: str | None = None, context: Context | None = None, + eager_start: bool | None = None, ) -> asyncio.Task[Any]: self._check_closed() + if eager_start: + # An eager first step runs at creation time, before the ready + # queue ever sees the task, so the seeded draw would never get to + # order it against anything. + _fence("create_task(eager_start=True)") if self._task_factory is None: task: asyncio.Task[Any] = asyncio.Task( coro, loop=self, name=name, context=context diff --git a/tests/test_loop.py b/tests/test_loop.py index 89b8e3c..c412572 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -179,6 +179,27 @@ def test_unsupported_apis_are_fenced() -> None: loop.close() +def test_an_eager_task_start_is_fenced() -> None: + # An eager first step would run at creation time, before the seeded draw + # could order it, so asking for one must fail loudly. Declining it is the + # stdlib default and keeps working. + async def noop() -> None: + pass + + loop = SimLoop(seed=0) + + async def main() -> None: + await loop.create_task(noop(), eager_start=False) + coro = noop() + with pytest.raises(SimulationFenceError, match="eager_start"): + loop.create_task(coro, eager_start=True) + coro.close() + try: + loop.run_until_complete(main()) + finally: + loop.close() + + def test_trace_is_recorded() -> None: async def main() -> None: await asyncio.sleep(1.0) diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 9b6cbe4..7d8c9d4 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -2,6 +2,16 @@ import pytest + +@pytest.fixture(autouse=True) +def _utf8_child_output(monkeypatch: pytest.MonkeyPatch) -> None: + # A failure traceback can render source lines that carry an em-dash. On + # Windows the child pytest writes them in the console code page while + # pytester reads the output back as UTF-8, so pin the child's stdio to + # UTF-8 and let the bytes survive the round trip. + monkeypatch.setenv("PYTHONIOENCODING", "utf-8") + + _FLAKY = """ import asyncio from simloop import sim_test