From 88b40d307b305e2815d1815215d6ba7b0af4c7fd Mon Sep 17 00:00:00 2001 From: Dhruv Kumar Singh Date: Sat, 1 Aug 2026 22:13:16 +0530 Subject: [PATCH 1/3] Test on Windows and Python 3.14 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 8508e12be37cc383d04de839405adc93de4c7115 Mon Sep 17 00:00:00 2001 From: Dhruv Kumar Singh Date: Sat, 1 Aug 2026 22:24:44 +0530 Subject: [PATCH 2/3] Keep the drain and child pytest output portable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cancellation drain in run_until_complete steps the loop after run_forever has already cleared the running-loop slot, and from Python 3.14 a task refuses to step unless its loop is the running one — so the drain now declares itself for its duration. The plugin tests also pin child pytest stdio to UTF-8: a failure traceback can render source lines carrying an em-dash, which a Windows child writes in the console code page while pytester reads UTF-8 back. --- src/simloop/_loop.py | 11 +++++++++-- tests/test_pytest_plugin.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/simloop/_loop.py b/src/simloop/_loop.py index 118bb03..3d3ccb7 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 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 From 6298c184df8aa8387e2422486b3e106bd1f5f624 Mon Sep 17 00:00:00 2001 From: Dhruv Kumar Singh Date: Sat, 1 Aug 2026 22:35:28 +0530 Subject: [PATCH 3/3] Fence the eager task start 3.14 offers Python 3.14's loop contract grows an eager_start parameter on create_task. 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: accepting the argument and fencing a true value is the honest answer, and declining it stays the stdlib default. --- docs/supported-api.md | 9 ++++++--- src/simloop/_loop.py | 6 ++++++ tests/test_loop.py | 21 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) 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 3d3ccb7..2c74049 100644 --- a/src/simloop/_loop.py +++ b/src/simloop/_loop.py @@ -523,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)