loopmini 0.1.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {loopmini-0.1.1 → loopmini-0.2.2}/.github/workflows/ci.yml +11 -2
  2. {loopmini-0.1.1 → loopmini-0.2.2}/Cargo.lock +1 -42
  3. {loopmini-0.1.1 → loopmini-0.2.2}/Cargo.toml +4 -7
  4. loopmini-0.2.2/DEV.md +101 -0
  5. {loopmini-0.1.1 → loopmini-0.2.2}/PKG-INFO +5 -6
  6. {loopmini-0.1.1 → loopmini-0.2.2}/README.md +3 -4
  7. {loopmini-0.1.1 → loopmini-0.2.2}/pyproject.toml +1 -1
  8. loopmini-0.2.2/python/loopmini/loop.py +251 -0
  9. loopmini-0.2.2/python/loopmini/subproc.py +20 -0
  10. loopmini-0.2.2/python/loopmini/transports.py +209 -0
  11. loopmini-0.2.2/rustfmt.toml +3 -0
  12. {loopmini-0.1.1 → loopmini-0.2.2}/src/lib.rs +5 -3
  13. {loopmini-0.1.1 → loopmini-0.2.2}/src/pyreactor.rs +6 -19
  14. {loopmini-0.1.1 → loopmini-0.2.2}/src/reactor.rs +31 -11
  15. {loopmini-0.1.1 → loopmini-0.2.2}/tests/oracle_util.py +62 -45
  16. {loopmini-0.1.1 → loopmini-0.2.2}/tests/test_bench.py +20 -7
  17. {loopmini-0.1.1 → loopmini-0.2.2}/tests/test_loop.py +2 -1
  18. loopmini-0.1.1/DEV.md +0 -204
  19. loopmini-0.1.1/python/loopmini/loop.py +0 -603
  20. loopmini-0.1.1/python/loopmini/subproc.py +0 -114
  21. loopmini-0.1.1/python/loopmini/transports.py +0 -481
  22. loopmini-0.1.1/src/tokio_core.rs +0 -118
  23. {loopmini-0.1.1 → loopmini-0.2.2}/.gitignore +0 -0
  24. {loopmini-0.1.1 → loopmini-0.2.2}/LICENSE +0 -0
  25. {loopmini-0.1.1 → loopmini-0.2.2}/_config.yml +0 -0
  26. {loopmini-0.1.1 → loopmini-0.2.2}/_layouts/default.html +0 -0
  27. {loopmini-0.1.1 → loopmini-0.2.2}/python/loopmini/__init__.py +0 -0
  28. {loopmini-0.1.1 → loopmini-0.2.2}/tests/test_oracle.py +0 -0
  29. {loopmini-0.1.1 → loopmini-0.2.2}/tests/test_soak.py +0 -0
@@ -8,13 +8,16 @@ on:
8
8
 
9
9
  jobs:
10
10
  test:
11
+ strategy:
12
+ matrix:
13
+ python-version: ['3.11', '3.12', '3.13', '3.14']
11
14
  runs-on: ubuntu-latest
12
15
  steps:
13
16
  - uses: actions/checkout@v7
14
17
  - uses: dtolnay/rust-toolchain@stable
15
18
  - uses: actions/setup-python@v7
16
19
  with:
17
- python-version: '3.12'
20
+ python-version: ${{ matrix.python-version }}
18
21
  - run: pip install -e '.[dev]'
19
22
  - run: pytest -q
20
23
 
@@ -28,7 +31,7 @@ jobs:
28
31
  - uses: actions/checkout@v7
29
32
  - uses: PyO3/maturin-action@v1
30
33
  with:
31
- args: --release --out dist -i python3.10 -i python3.11 -i python3.12 -i python3.13
34
+ args: --release --out dist -i python3.11 -i python3.12 -i python3.13 -i python3.14
32
35
  manylinux: auto
33
36
  - uses: actions/upload-artifact@v7
34
37
  with:
@@ -57,6 +60,12 @@ jobs:
57
60
  contents: write
58
61
  steps:
59
62
  - uses: actions/checkout@v7
63
+ - uses: dtolnay/rust-toolchain@stable
64
+ - id: crates-auth
65
+ uses: rust-lang/crates-io-auth-action@v1
66
+ - run: cargo publish
67
+ env:
68
+ CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }}
60
69
  - uses: actions/download-artifact@v8
61
70
  with:
62
71
  path: dist
@@ -65,22 +65,10 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
65
65
 
66
66
  [[package]]
67
67
  name = "loopmini"
68
- version = "0.1.1"
68
+ version = "0.2.2"
69
69
  dependencies = [
70
70
  "polling",
71
71
  "pyo3",
72
- "tokio",
73
- ]
74
-
75
- [[package]]
76
- name = "mio"
77
- version = "1.2.2"
78
- source = "registry+https://github.com/rust-lang/crates.io-index"
79
- checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
80
- dependencies = [
81
- "libc",
82
- "wasi",
83
- "windows-sys",
84
72
  ]
85
73
 
86
74
  [[package]]
@@ -203,16 +191,6 @@ dependencies = [
203
191
  "windows-sys",
204
192
  ]
205
193
 
206
- [[package]]
207
- name = "socket2"
208
- version = "0.6.5"
209
- source = "registry+https://github.com/rust-lang/crates.io-index"
210
- checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
211
- dependencies = [
212
- "libc",
213
- "windows-sys",
214
- ]
215
-
216
194
  [[package]]
217
195
  name = "syn"
218
196
  version = "2.0.119"
@@ -230,31 +208,12 @@ version = "0.13.5"
230
208
  source = "registry+https://github.com/rust-lang/crates.io-index"
231
209
  checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
232
210
 
233
- [[package]]
234
- name = "tokio"
235
- version = "1.53.1"
236
- source = "registry+https://github.com/rust-lang/crates.io-index"
237
- checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
238
- dependencies = [
239
- "libc",
240
- "mio",
241
- "pin-project-lite",
242
- "socket2",
243
- "windows-sys",
244
- ]
245
-
246
211
  [[package]]
247
212
  name = "unicode-ident"
248
213
  version = "1.0.24"
249
214
  source = "registry+https://github.com/rust-lang/crates.io-index"
250
215
  checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
251
216
 
252
- [[package]]
253
- name = "wasi"
254
- version = "0.11.1+wasi-snapshot-preview1"
255
- source = "registry+https://github.com/rust-lang/crates.io-index"
256
- checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
257
-
258
217
  [[package]]
259
218
  name = "windows-link"
260
219
  version = "0.2.1"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "loopmini"
3
- version = "0.1.1"
3
+ version = "0.2.2"
4
4
  edition = "2021"
5
5
  license = "Apache-2.0"
6
6
  description = "Rust-backed asyncio event loop"
@@ -12,12 +12,9 @@ name = "loopmini"
12
12
  crate-type = ["cdylib", "rlib"]
13
13
 
14
14
  [dependencies]
15
- pyo3 = { version = ">=0.28", features = ["py-clone"] }
15
+ pyo3 = { version = ">=0.28", features = ["py-clone"], optional = true }
16
16
  polling = "3"
17
- tokio = { version = "1", features = ["rt", "time", "net"] }
18
-
19
- [dev-dependencies]
20
- tokio = { version = "1", features = ["rt-multi-thread"] }
21
17
 
22
18
  [features]
23
- extension-module = ["pyo3/extension-module"]
19
+ python = ["dep:pyo3"]
20
+ extension-module = ["python", "pyo3/extension-module"]
loopmini-0.2.2/DEV.md ADDED
@@ -0,0 +1,101 @@
1
+ # Developer guide
2
+
3
+ ## Why this exists
4
+
5
+ Kernmini's Rust engine (see kernmini `meta/ROUGH.md`, "Separate project idea: a Rust-backed asyncio loop" and the 2026-08-28 review) needs Python kernels to run arbitrary user asyncio code. loopmini tests whether a Rust reactor can host that loop with full asyncio compatibility. The compatibility bar is the full public asyncio surface, because solveit users run arbitrary packages; the strategy is maximal reuse of CPython's own asyncio machinery, with Rust owning only what Python cannot express well.
6
+
7
+ ## Crate/Python split
8
+
9
+ A key goal is that Rust crates and PyO3 wrappers share code: the same reactor must be drivable by a pure-Rust kernel (as kernmini's native crate will be). The implementation has two layers plus module registration:
10
+
11
+ - `src/reactor.rs`: `Reactor<H>`, a PyO3-free, Tokio-free core generic over the handle type. It owns the ready queue, timer map, fd interest map (oneshot `polling` sources, re-armed on delivery), thread-safe `schedule_ts` + notify, and the turn phases: `next_timeout` / `poll` / `process` / `take_batch` / `requeue_front`. `poll` holds no locks, so a driver may block in it with the GIL released. PyO3 is optional and the default Cargo build is pure Rust, so a Rust consumer instantiates `Reactor<RustHandle>` without linking Python.
12
+ - `src/pyreactor.rs`: `PyReactor`, compiled by the `python` feature and exposed by maturin's `extension-module` feature. It provides the Python-facing pyclass, scheduling and readiness methods, and the canonical dispatch loop (`check_signals` each turn, `py.detach` around `poll`, EINTR retry, and the injected-exception requeue rule, which must exist exactly once). The driver blocks directly in the core poller with the GIL released. Rust runtimes keep their futures on worker threads and wake the loop with its thread-safe scheduling path.
13
+ - `src/lib.rs`: module registration and the public re-exports (`ReactorCore`, `PyReactor`) for embedding crates.
14
+
15
+ Python (`python/loopmini/loop.py`) subclasses `asyncio.BaseEventLoop`, delegating scheduling to the reactor while inheriting task/future creation, executors, exception handling, high-level networking, TLS orchestration, servers, subprocess entry points, and buffered sendfile. Reactor-neutral implementations of socket operations and accepting connections are borrowed from `BaseSelectorEventLoop`; Unix connection/server methods and pipe transports are borrowed from `_UnixSelectorEventLoop`. Loopmini supplies the hooks those implementations call. This reuse buys exact CPython contextvars, cancellation, introspection, lifecycle, and version-specific semantics. `run_forever` on the main thread routes signals through `signal.set_wakeup_fd` into an fd watched by the reactor.
16
+
17
+ ## Implemented surface
18
+
19
+ Beyond the scheduling core, loopmini implements TCP transports (`SockTransport`, speaking both `Protocol` and `BufferedProtocol`, with `TCP_NODELAY`), UDP (`DatagramTransport`), signal dispatch through the `set_wakeup_fd` socketpair, and the reactor's reader/writer registrations. CPython supplies the high-level TCP/UDP/TLS/Unix/server operations, Unix pipe transports, flow control, and buffered sendfile. `subproc.py` prepares `Popen` in the executor because fork/exec blocks, then gives it to CPython's `BaseSubprocessTransport`; one reaper thread per child reports exit through the loop's thread-safe scheduling path.
20
+
21
+ Contracts learned from the oracles, kept working by them: `remove_reader` cancels the stored `Handle`, so an already-queued readiness callback for a removed fd never fires; `connection_lost` is scheduled exactly once however `close()`, `abort()`, and fatal errors overlap; and a Unix socket path is unlinked at close only when its inode still matches the one bound. A cancelled timer leaves the reactor's timer map at once: `schedule_at` returns a `(deadline µs, seq)` key, the `TimerHandle` subclass carries it, and `_timer_handle_cancelled` removes the entry. Retention until the original deadline would grow memory for an hour per cancelled `wait_for(..., 3600)`. The reactor clock carries a constant offset captured at loop creation because aiohttp compares `loop.time()` with `time.monotonic`. A socket transport sends its bytearray directly rather than holding an explicit `memoryview` export across a potentially raising `send`, whose traceback could otherwise keep the buffer unresizable.
22
+
23
+ ## Deliberately not implemented yet
24
+
25
+ Native sendfile and Windows. `BaseEventLoop` provides the portable buffered sendfile fallback; the socket transport declares that capability rather than pretending to support the platform-native fast path.
26
+
27
+ ## Tests
28
+
29
+ The tiers, so the inner loop stays seconds:
30
+
31
+ - `pytest -q` per change (~2s).
32
+ - `pytest -m oracle -n auto` when a feature lands, not per edit.
33
+ - `pytest -m bench -s` and `pytest -m soak -s` only when performance or stability is the question.
34
+ - `chkstyle` once, at the final PR stage, never per edit.
35
+
36
+ To test against another Python version locally (the workspace venv is pinned, so
37
+ plain `uv run --python` refuses): `uv run --no-project --python 3.14 --with
38
+ '.[dev]' pytest -q` from the repo root. uv builds against its managed
39
+ interpreter into its cache, so repeat runs are fast and there is no venv to
40
+ maintain. CI runs the same suite on every supported version.
41
+
42
+ `pytest -q`. Eleven integration stories, deliberately few: scheduling/tasks/threads (timers, contextvars, gather, TaskGroup, timeout, cancellation, cross-thread wakeup, to_thread), socket I/O through the reactor (accept/connect/backpressure on a 5MB payload), asyncio streams echo with drain backpressure, a background task surviving between `run_until_complete` calls (the kernel-persistence story), KeyboardInterrupt injection with the loop reused afterwards, uvicorn serving an ASGI app fetched by urllib and by httpx-over-anyio, TLS echo against a throwaway openssl cert, a subprocess round-trip (exec and shell, streams and communicate), and the interrupt-torture story: window-scoped `PyThreadState_SetAsyncExc` injection under stream/timer/cross-thread load, mirroring kernmini's `sync_execution_context` contract (0.5s by default; `LOOPMINI_TORTURE_SECONDS` extends it), and the KI-at-`_run`-entry orphan repro that pins the traceback-depth requeue rule. Rust-side unit tests should exist only for reactor invariants Python stories cannot reach.
43
+
44
+ Two CPython 3.13 facts the torture test depends on, discovered the hard way:
45
+ an async-injected exception raised at an eval-breaker check escapes the
46
+ raising frame even past a same-frame `try/except` (open regression
47
+ [gh-139622](https://github.com/python/cpython/issues/139622); 3.12 and 3.14
48
+ unaffected), so the guard must sit in a parent frame; and a busy window
49
+ shorter than the GIL switch interval (5ms) is invisible to a sampling thread,
50
+ because the GIL is only ever released while the window is closed.
51
+
52
+ ## The compatibility oracles
53
+
54
+ Four external suites are the conformance measure, run in-repo through
55
+ `tests/test_oracle.py` (marked `oracle`, deselected by default):
56
+
57
+ ```bash
58
+ pytest -m oracle -n auto # all four suites, one worker per module
59
+ pytest -m oracle -k uvloop # one suite; add "and test_tcp" etc. for one module
60
+ ```
61
+
62
+ `tests/oracle_util.py` fetches and caches what each suite needs under `~/.cache/loopmini-oracle` (override with `LOOPMINI_ORACLE_CACHE`). Modified external source trees are content-addressed by the source of their adapter function, so changing an adapter creates a fresh fixture while unchanged fixtures retain their cache:
63
+
64
+ - CPython's own test_asyncio (uvloop's strategy). This uv-managed Python ships
65
+ without the stdlib `test` package, so the matching source tarball is fetched
66
+ and its functional modules run under a loopmini event-loop policy. Suites
67
+ asserting standard-loop internals (base_events, selector_events,
68
+ unix_events) are not fair oracles and are excluded.
69
+ - anyio's test suite, via its sanctioned alternative-loop mechanism: the sdist
70
+ matching the installed version is unpacked and a loopmini entry added to
71
+ `asyncio_params` in its conftest. Runs as a subprocess; requires the anyio
72
+ test deps (`trustme`, `blockbuster`) in the venv. blockbuster's
73
+ blocking-call detector allowlists Popen's blocking os.read only under
74
+ `asyncio/base_events.py`, which is one reason the Popen spawn goes through
75
+ the executor.
76
+ - uvloop's test suite, from a source clone (`LOOPMINI_UVLOOP_REPO`, default
77
+ `~/aai-ws/links/uvloop`): its loop-parameterized test classes are cloned
78
+ onto loopmini with `implementation='asyncio'`, so branchy tests take the
79
+ standard-loop expectation paths. `test_tcp` needs pyOpenSSL and is skipped
80
+ without it.
81
+ - aiohttp's test suite: the sdist is unpacked, uvloop is stubbed to loopmini in its conftest so `--aiohttp-loop=uvloop` selects it, and its blockbuster fixture gains an allowlist entry for the same unlink-if-unchanged stat used by asyncio's Unix loop. It runs pure-Python aiohttp (`AIOHTTP_NO_EXTENSIONS=1`) with a private `--basetemp`, because permission tests leave chmod-000 directories that pytest's numbered-directory sweeper cannot remove under aiohttp's `filterwarnings = error`. Deselections and their reasons live in `test_oracle.py`.
82
+
83
+ Status on 2026-08-28: all green; 26 oracle stories, including about 4,200 aiohttp tests, complete in about 26 seconds under xdist on the development Mac.
84
+
85
+ ## The soak gate
86
+
87
+ `pytest -m soak -s` runs a kernel-shaped workload on one loopmini loop for
88
+ `LOOPMINI_SOAK_SECONDS` (default 30): a fasthtml app on uvicorn under
89
+ threaded httpx load, a websocket echo server with a bot-style client, a
90
+ housekeeping tick, and periodic cells submitted from another thread, as a
91
+ kernel would. It asserts zero errors, bounded fd growth, and that every
92
+ component made progress. It watches stability, not speed.
93
+
94
+ ## Benchmarks
95
+
96
+ `pytest -m bench -s` prints loopmini vs the standard loop on loop-bound microbenchmarks; informational, never gating. Numbers on 2026-08-28 (M-series macOS): call_soon 1.04x, sleep0 1.03x, tcp_echo 1.02x, task spawn 1.17x (stdlib=1.0); the corresponding extra cost is approximately 0.5µs per scheduled callback, 0.5µs per `sleep(0)` suspend/resume, 1.1µs per TCP echo, and 0.3µs per spawned task. Median 1ms-timer overshoot is 0.188ms versus 0.156ms.
97
+
98
+ ## Style and releases
99
+
100
+ fastai style (`chkstyle` before committing). Releases via fastship; the tree
101
+ carries the next version.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopmini
3
- Version: 0.1.1
3
+ Version: 0.2.2
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Requires-Dist: fastship>=0.0.11 ; extra == 'dev'
@@ -20,7 +20,7 @@ License-File: LICENSE
20
20
  Summary: Rust-backed asyncio event loop
21
21
  Author-email: Jeremy Howard <github@jhoward.fastmail.fm>
22
22
  License: Apache-2.0
23
- Requires-Python: >=3.10
23
+ Requires-Python: >=3.11
24
24
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
25
25
  Project-URL: Homepage, https://github.com/AnswerDotAI/loopmini
26
26
  Project-URL: Issues, https://github.com/AnswerDotAI/loopmini/issues
@@ -48,9 +48,9 @@ pip install loopmini
48
48
 
49
49
  ## Design
50
50
 
51
- The Python side subclasses `asyncio.AbstractEventLoop` and reuses the stock `Task`, `Future`, `Handle`, and `sslproto` machinery. Contextvars, cancellation, and task introspection therefore behave exactly as in the standard loop. CPython releases change little that loopmini must track, because the version-sensitive objects are CPython's own.
51
+ The Python side subclasses `asyncio.BaseEventLoop` and reuses its task, future, executor, error-handling, networking, server, sendfile, and subprocess machinery. Reactor-neutral socket, Unix, pipe, and accept implementations come directly from CPython's selector loop. Loopmini supplies the scheduling and fd-readiness hooks plus socket and datagram transports, so version-sensitive asyncio behavior remains CPython's own.
52
52
 
53
- The Rust side owns fd readiness, timers, and cross-thread wakeup. A small level-triggered reactor core (the `polling` crate, kqueue/epoll) is hosted on a Tokio current-thread runtime, which waits on the reactor's own pollable fd. This split preserves the level-triggered `add_reader` contract that asyncio requires and Tokio's edge-triggered driver cannot express. Rust futures spawned on the runtime advance during every blocking poll, with the GIL released, on the same thread and reactor as the Python loop.
53
+ The Rust side owns fd readiness, timers, and cross-thread wakeup through a small level-triggered reactor core (`polling`, using kqueue/epoll). The Python driver blocks directly in the reactor with the GIL released. The same PyO3-free core is available to Rust consumers; embedding runtimes can run futures on their own workers and wake the Python loop through its thread-safe scheduling path.
54
54
 
55
55
  ## Compatibility
56
56
 
@@ -62,6 +62,5 @@ A KeyboardInterrupt injected while the loop runs (the kernel interrupt mechanism
62
62
 
63
63
  ## Performance
64
64
 
65
- Throughput matches the standard loop on real I/O workloads. A 30-second soak serving a fasthtml app under concurrent HTTP and websocket load holds a 2.6ms median response with no fd or memory growth. Microbenchmarks run 5 to 15% slower than the standard loop, and creating a task costs about twice as much, because each schedule crosses the Python/Rust boundary. uvloop is faster where speed is the requirement.
66
-
65
+ Throughput matches the standard loop on real I/O workloads. A 30-second soak serving a fasthtml app under concurrent HTTP and websocket load holds a 2.6ms median response with no fd or memory growth. There is a small overhead involved in getting the better interrupt semantics, due to having to cross the Rust boundary. It applies only when something is scheduled onto the loop, and is then under 1µs per operation: `create_task`, an `await` that suspends, `asyncio.sleep`, or `call_soon`. Code that stays inside Python, including an `await` that does not suspend, pays nothing. The median overshoot of a 1ms timer is ~0.2ms, similar to Python's standard loop.
67
66
 
@@ -20,9 +20,9 @@ pip install loopmini
20
20
 
21
21
  ## Design
22
22
 
23
- The Python side subclasses `asyncio.AbstractEventLoop` and reuses the stock `Task`, `Future`, `Handle`, and `sslproto` machinery. Contextvars, cancellation, and task introspection therefore behave exactly as in the standard loop. CPython releases change little that loopmini must track, because the version-sensitive objects are CPython's own.
23
+ The Python side subclasses `asyncio.BaseEventLoop` and reuses its task, future, executor, error-handling, networking, server, sendfile, and subprocess machinery. Reactor-neutral socket, Unix, pipe, and accept implementations come directly from CPython's selector loop. Loopmini supplies the scheduling and fd-readiness hooks plus socket and datagram transports, so version-sensitive asyncio behavior remains CPython's own.
24
24
 
25
- The Rust side owns fd readiness, timers, and cross-thread wakeup. A small level-triggered reactor core (the `polling` crate, kqueue/epoll) is hosted on a Tokio current-thread runtime, which waits on the reactor's own pollable fd. This split preserves the level-triggered `add_reader` contract that asyncio requires and Tokio's edge-triggered driver cannot express. Rust futures spawned on the runtime advance during every blocking poll, with the GIL released, on the same thread and reactor as the Python loop.
25
+ The Rust side owns fd readiness, timers, and cross-thread wakeup through a small level-triggered reactor core (`polling`, using kqueue/epoll). The Python driver blocks directly in the reactor with the GIL released. The same PyO3-free core is available to Rust consumers; embedding runtimes can run futures on their own workers and wake the Python loop through its thread-safe scheduling path.
26
26
 
27
27
  ## Compatibility
28
28
 
@@ -34,5 +34,4 @@ A KeyboardInterrupt injected while the loop runs (the kernel interrupt mechanism
34
34
 
35
35
  ## Performance
36
36
 
37
- Throughput matches the standard loop on real I/O workloads. A 30-second soak serving a fasthtml app under concurrent HTTP and websocket load holds a 2.6ms median response with no fd or memory growth. Microbenchmarks run 5 to 15% slower than the standard loop, and creating a task costs about twice as much, because each schedule crosses the Python/Rust boundary. uvloop is faster where speed is the requirement.
38
-
37
+ Throughput matches the standard loop on real I/O workloads. A 30-second soak serving a fasthtml app under concurrent HTTP and websocket load holds a 2.6ms median response with no fd or memory growth. There is a small overhead involved in getting the better interrupt semantics, due to having to cross the Rust boundary. It applies only when something is scheduled onto the loop, and is then under 1µs per operation: `create_task`, an `await` that suspends, `asyncio.sleep`, or `call_soon`. Code that stays inside Python, including an `await` that does not suspend, pays nothing. The median overshoot of a 1ms timer is ~0.2ms, similar to Python's standard loop.
@@ -7,7 +7,7 @@ name = "loopmini"
7
7
  dynamic = ["version"]
8
8
  description = "Rust-backed asyncio event loop"
9
9
  license = {text = "Apache-2.0"}
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.11"
11
11
  readme = "README.md"
12
12
  authors = [{name = "Jeremy Howard", email = "github@jhoward.fastmail.fm"}]
13
13
  classifiers = [
@@ -0,0 +1,251 @@
1
+ "An asyncio event loop whose reactor (fd readiness, timers, cross-thread wakeup) runs in Rust."
2
+ import errno, functools, os, signal, socket, subprocess, sys, threading, time, weakref
3
+ from asyncio import base_events, events, selector_events, sslproto, unix_events
4
+ from ._core import Reactor
5
+ from .transports import SockTransport, DatagramTransport
6
+ from .subproc import SubprocessTransport
7
+
8
+ __all__ = ["Loop", "new_event_loop"]
9
+
10
+ _SelLoop = selector_events.BaseSelectorEventLoop
11
+ _UnixLoop = unix_events._UnixSelectorEventLoop
12
+
13
+ def _fileno(fd): return fd if isinstance(fd, int) else fd.fileno()
14
+
15
+ def _sighandler_noop(signum, frame): pass
16
+
17
+ def _timed_run(h, run):
18
+ "Debug-mode dispatch: log callbacks exceeding `loop.slow_callback_duration`, like the standard loop"
19
+ t0 = time.monotonic()
20
+ run()
21
+ dt = time.monotonic() - t0
22
+ if dt >= h._loop.slow_callback_duration: base_events.logger.warning('Executing %s took %.3f seconds', base_events._format_handle(h), dt)
23
+
24
+ class _TimedHandle(events.Handle):
25
+ def _run(self): _timed_run(self, lambda: events.Handle._run(self))
26
+
27
+ class _TimerHandle(events.TimerHandle):
28
+ "A stock TimerHandle plus the reactor key that lets cancellation drop the timer at once."
29
+ __slots__ = ('_reactor_key',)
30
+
31
+ class _TimedTimerHandle(_TimerHandle):
32
+ def _run(self): _timed_run(self, lambda: events.TimerHandle._run(self))
33
+
34
+ # Tests and log scrapers match on '<Handle ...>' reprs, so the subclasses keep those names
35
+ _TimedHandle.__name__ = _TimedHandle.__qualname__ = 'Handle'
36
+ _TimerHandle.__name__ = _TimerHandle.__qualname__ = 'TimerHandle'
37
+ _TimedTimerHandle.__name__ = _TimedTimerHandle.__qualname__ = 'TimerHandle'
38
+
39
+ class Loop(base_events.BaseEventLoop):
40
+ def __init__(self,
41
+ reactor=None, # A `_core.Reactor`-shaped object; an embedding host passes one on its own runtime
42
+ ):
43
+ super().__init__()
44
+ self._r = reactor if reactor is not None else Reactor()
45
+ # Anchor the reactor clock to time.monotonic (same underlying clock, so the
46
+ # offset is constant): libraries compare loop.time() against monotonic directly
47
+ self._time_offset = time.monotonic() - self._r.time()
48
+ self._signal_handlers = {}
49
+ self._unix_server_sockets = {}
50
+ self._transports = weakref.WeakValueDictionary()
51
+ self._readers,self._writers = {},{}
52
+
53
+ def time(self): return self._r.time() + self._time_offset
54
+
55
+ def _call_soon(self, callback, args, context):
56
+ h = (_TimedHandle if self._debug else events.Handle)(callback, args, self, context)
57
+ if h._source_traceback: del h._source_traceback[-1]
58
+ self._r.schedule(h)
59
+ return h
60
+
61
+ def call_soon_threadsafe(self, callback, *args, context=None):
62
+ self._check_closed()
63
+ self._check_callback(callback, 'call_soon_threadsafe')
64
+ h = (_TimedHandle if self._debug else events.Handle)(callback, args, self, context)
65
+ if h._source_traceback: del h._source_traceback[-1]
66
+ self._r.schedule_ts(h)
67
+ return h
68
+
69
+ def call_at(self, when, callback, *args, context=None):
70
+ if when is None: raise TypeError('when cannot be None')
71
+ self._check_closed()
72
+ if self._debug:
73
+ self._check_thread()
74
+ self._check_callback(callback, 'call_at')
75
+ h = (_TimedTimerHandle if self._debug else _TimerHandle)(when, callback, args, self, context)
76
+ if h._source_traceback: del h._source_traceback[-1]
77
+ h._reactor_key = self._r.schedule_at(when - self._time_offset, h)
78
+ return h
79
+
80
+ def _timer_handle_cancelled(self, handle):
81
+ # False (already fired) is fine: dispatch skips the promoted handle's cancelled flag
82
+ self._r.cancel_timer(handle._reactor_key)
83
+
84
+ def run_forever(self):
85
+ self._run_forever_setup()
86
+ main = threading.current_thread() is threading.main_thread()
87
+ try:
88
+ if main: old_wakeup = self._setup_signal_wakeup()
89
+ try: self._r.run()
90
+ finally:
91
+ if main: self._teardown_signal_wakeup(old_wakeup)
92
+ finally: self._run_forever_cleanup()
93
+
94
+ def _setup_signal_wakeup(self):
95
+ self._ssock,self._csock = socket.socketpair()
96
+ for s in (self._ssock,self._csock): s.setblocking(False)
97
+ old = signal.set_wakeup_fd(self._csock.fileno())
98
+ self.add_reader(self._ssock.fileno(), self._drain_signal_sock)
99
+ return old
100
+
101
+ def _drain_signal_sock(self):
102
+ try: data = self._ssock.recv(4096)
103
+ except (BlockingIOError, InterruptedError): return
104
+ for sig in data:
105
+ h = self._signal_handlers.get(sig)
106
+ if h is not None and not h.cancelled(): self._r.schedule(h)
107
+
108
+ def add_signal_handler(self, sig, callback, *args):
109
+ self._check_callback(callback, 'add_signal_handler')
110
+ h = events.Handle(callback, args, self, None)
111
+ self._signal_handlers[sig] = h
112
+ try: signal.signal(sig, _sighandler_noop)
113
+ except OSError as e:
114
+ del self._signal_handlers[sig]
115
+ if e.errno == errno.EINVAL: raise RuntimeError(f'sig {sig} cannot be caught') from None
116
+ raise
117
+ except ValueError:
118
+ del self._signal_handlers[sig]
119
+ raise
120
+
121
+ def remove_signal_handler(self, sig):
122
+ if sig not in self._signal_handlers: return False
123
+ del self._signal_handlers[sig]
124
+ try: signal.signal(sig, signal.default_int_handler if sig == signal.SIGINT else signal.SIG_DFL)
125
+ except OSError as e:
126
+ if e.errno == errno.EINVAL: raise RuntimeError(f'sig {sig} cannot be caught') from None
127
+ raise
128
+ return True
129
+
130
+ def _teardown_signal_wakeup(self, old):
131
+ self.remove_reader(self._ssock.fileno())
132
+ signal.set_wakeup_fd(old)
133
+ self._ssock.close()
134
+ self._csock.close()
135
+
136
+ def stop(self): self._r.stop()
137
+
138
+ def close(self):
139
+ if self.is_running(): raise RuntimeError('Cannot close a running event loop')
140
+ if self.is_closed(): return
141
+ for sig in list(self._signal_handlers): self.remove_signal_handler(sig)
142
+ self._r.close()
143
+ super().close()
144
+
145
+ def _add_io(self, fd, callback, args, handles, add, context=None):
146
+ fd = _fileno(fd)
147
+ h = events.Handle(callback, args, self, context)
148
+ old = handles.get(fd)
149
+ if old is not None: old.cancel()
150
+ handles[fd] = h
151
+ add(fd, h)
152
+ return h
153
+
154
+ def _remove_io(self, fd, handles, remove):
155
+ # A queued readiness callback may still fire this turn; cancelling the stored
156
+ # handle makes the dispatcher skip it, matching the standard loop's remove_reader
157
+ fd = _fileno(fd)
158
+ h = handles.pop(fd, None)
159
+ if h is not None: h.cancel()
160
+ if fd < 0: return False
161
+ return remove(fd)
162
+
163
+ def add_reader(self, fd, callback, *args): return self._add_io(fd, callback, args, self._readers, self._r.add_reader)
164
+ def remove_reader(self, fd): return self._remove_io(fd, self._readers, self._r.remove_reader)
165
+ def add_writer(self, fd, callback, *args): return self._add_io(fd, callback, args, self._writers, self._r.add_writer)
166
+ def remove_writer(self, fd): return self._remove_io(fd, self._writers, self._r.remove_writer)
167
+ def _add_reader(self, fd, callback, *args): return self.add_reader(fd, callback, *args)
168
+ def _remove_reader(self, fd): return self.remove_reader(fd)
169
+ def _add_writer(self, fd, callback, *args): return self.add_writer(fd, callback, *args)
170
+ def _remove_writer(self, fd): return self.remove_writer(fd)
171
+
172
+ _ensure_fd_no_transport,_sock_read_done,_sock_write_done = _SelLoop._ensure_fd_no_transport,_SelLoop._sock_read_done,_SelLoop._sock_write_done
173
+ sock_recv,_sock_recv = _SelLoop.sock_recv,_SelLoop._sock_recv
174
+ sock_recv_into,_sock_recv_into = _SelLoop.sock_recv_into,_SelLoop._sock_recv_into
175
+ sock_recvfrom,_sock_recvfrom = _SelLoop.sock_recvfrom,_SelLoop._sock_recvfrom
176
+ sock_recvfrom_into,_sock_recvfrom_into = _SelLoop.sock_recvfrom_into,_SelLoop._sock_recvfrom_into
177
+ sock_sendall,_sock_sendall = _SelLoop.sock_sendall,_SelLoop._sock_sendall
178
+ sock_sendto,_sock_sendto = _SelLoop.sock_sendto,_SelLoop._sock_sendto
179
+ sock_accept,_sock_accept = _SelLoop.sock_accept,_SelLoop._sock_accept
180
+ sock_connect,_sock_connect,_sock_connect_cb = _SelLoop.sock_connect,_SelLoop._sock_connect,_SelLoop._sock_connect_cb
181
+
182
+ def _make_socket_transport(self, sock, protocol, waiter=None, *, extra=None, server=None, context=None):
183
+ # The accept path (gh: _accept_connection2) swallows transport-creation errors
184
+ # outside debug mode; report before re-raising so server-side bugs are never silent
185
+ try: return SockTransport(self, sock, protocol, waiter, extra, server, context)
186
+ except (SystemExit, KeyboardInterrupt): raise
187
+ except BaseException as e:
188
+ if server is not None: self.call_exception_handler(dict(message='loopmini: error creating server-side transport', exception=e, socket=sock))
189
+ raise
190
+
191
+ def _make_ssl_transport(self, sock, protocol, sslcontext, waiter=None, *, server_side=False, server_hostname=None, extra=None, server=None,
192
+ ssl_handshake_timeout=None, ssl_shutdown_timeout=None, call_connection_made=True, context=None):
193
+ ssl_protocol = sslproto.SSLProtocol(self, protocol, sslcontext, waiter, server_side, server_hostname,
194
+ call_connection_made=call_connection_made, ssl_handshake_timeout=ssl_handshake_timeout, ssl_shutdown_timeout=ssl_shutdown_timeout)
195
+ SockTransport(self, sock, ssl_protocol, extra=extra, server=server, context=context)
196
+ return ssl_protocol._app_transport
197
+
198
+ def _make_datagram_transport(self, sock, protocol, address=None, waiter=None, extra=None):
199
+ return DatagramTransport(self, sock, protocol, address, waiter, extra)
200
+ def _make_read_pipe_transport(self, pipe, protocol, waiter=None, extra=None): return unix_events._UnixReadPipeTransport(self, pipe, protocol, waiter, extra)
201
+ def _make_write_pipe_transport(self, pipe, protocol, waiter=None, extra=None):
202
+ return unix_events._UnixWritePipeTransport(self, pipe, protocol, waiter, extra)
203
+
204
+ async def _make_subprocess_transport(self, protocol, args, shell, stdin, stdout, stderr, bufsize, extra=None, **kwargs):
205
+ popen = functools.partial(subprocess.Popen, args, shell=shell, stdin=stdin, stdout=stdout, stderr=stderr, bufsize=bufsize, **kwargs)
206
+ proc = await self.run_in_executor(None, popen)
207
+ waiter = self.create_future()
208
+ transport = SubprocessTransport(self, protocol, args, shell, stdin, stdout, stderr, bufsize, proc, waiter, extra, **kwargs)
209
+ try: await waiter
210
+ except BaseException:
211
+ transport.close()
212
+ await transport._wait()
213
+ raise
214
+ return transport
215
+
216
+ _start_serving = _SelLoop._start_serving
217
+ _accept_connection = _SelLoop._accept_connection
218
+ _accept_connection2 = _SelLoop._accept_connection2
219
+
220
+ def _stop_serving(self, sock):
221
+ path = sock.getsockname() if sock in self._unix_server_sockets else None
222
+ self.remove_reader(sock.fileno())
223
+ sock.close()
224
+ if path is None: return
225
+ inode = self._unix_server_sockets.pop(sock)
226
+ try:
227
+ if os.stat(path).st_ino == inode: os.unlink(path)
228
+ except OSError: pass
229
+
230
+ create_unix_connection = _UnixLoop.create_unix_connection
231
+ create_unix_server = _UnixLoop.create_unix_server
232
+
233
+ if not hasattr(base_events.BaseEventLoop, '_run_forever_setup'): # the helpers first appear in 3.12; transcribed from 3.11's inline run_forever, which is frozen
234
+ def _run_forever_setup(self):
235
+ self._check_closed()
236
+ self._check_running()
237
+ self._set_coroutine_origin_tracking(self._debug)
238
+ self._thread_id = threading.get_ident()
239
+ self._old_agen_hooks = sys.get_asyncgen_hooks()
240
+ sys.set_asyncgen_hooks(firstiter=self._asyncgen_firstiter_hook, finalizer=self._asyncgen_finalizer_hook)
241
+ events._set_running_loop(self)
242
+ def _run_forever_cleanup(self):
243
+ self._thread_id = None
244
+ events._set_running_loop(None)
245
+ self._set_coroutine_origin_tracking(False)
246
+ sys.set_asyncgen_hooks(*self._old_agen_hooks)
247
+ Loop._run_forever_setup, Loop._run_forever_cleanup = _run_forever_setup, _run_forever_cleanup
248
+
249
+ def new_event_loop(reactor=None):
250
+ "An event loop on a Rust reactor, for `asyncio.run(..., loop_factory=new_event_loop)`."
251
+ return Loop(reactor)
@@ -0,0 +1,20 @@
1
+ "A nonblocking process spawn feeding asyncio's standard subprocess transport."
2
+ import threading
3
+ from asyncio import base_subprocess
4
+
5
+ __all__ = ["SubprocessTransport"]
6
+
7
+ class SubprocessTransport(base_subprocess.BaseSubprocessTransport):
8
+ def __init__(self, loop, protocol, args, shell, stdin, stdout, stderr, bufsize, proc, waiter=None, extra=None, **kwargs):
9
+ self._prepared_proc = proc
10
+ super().__init__(loop, protocol, args, shell, stdin, stdout, stderr, bufsize, waiter, extra, **kwargs)
11
+ threading.Thread(target=self._reap, daemon=True).start()
12
+
13
+ def _start(self, **kwargs):
14
+ self._proc = self._prepared_proc
15
+ del self._prepared_proc
16
+
17
+ def _reap(self):
18
+ returncode = self._proc.wait()
19
+ try: self._loop.call_soon_threadsafe(self._process_exited, returncode)
20
+ except RuntimeError: pass