Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions docs/supported-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/simloop/_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions tests/test_pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down