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.
- {loopmini-0.1.1 → loopmini-0.2.2}/.github/workflows/ci.yml +11 -2
- {loopmini-0.1.1 → loopmini-0.2.2}/Cargo.lock +1 -42
- {loopmini-0.1.1 → loopmini-0.2.2}/Cargo.toml +4 -7
- loopmini-0.2.2/DEV.md +101 -0
- {loopmini-0.1.1 → loopmini-0.2.2}/PKG-INFO +5 -6
- {loopmini-0.1.1 → loopmini-0.2.2}/README.md +3 -4
- {loopmini-0.1.1 → loopmini-0.2.2}/pyproject.toml +1 -1
- loopmini-0.2.2/python/loopmini/loop.py +251 -0
- loopmini-0.2.2/python/loopmini/subproc.py +20 -0
- loopmini-0.2.2/python/loopmini/transports.py +209 -0
- loopmini-0.2.2/rustfmt.toml +3 -0
- {loopmini-0.1.1 → loopmini-0.2.2}/src/lib.rs +5 -3
- {loopmini-0.1.1 → loopmini-0.2.2}/src/pyreactor.rs +6 -19
- {loopmini-0.1.1 → loopmini-0.2.2}/src/reactor.rs +31 -11
- {loopmini-0.1.1 → loopmini-0.2.2}/tests/oracle_util.py +62 -45
- {loopmini-0.1.1 → loopmini-0.2.2}/tests/test_bench.py +20 -7
- {loopmini-0.1.1 → loopmini-0.2.2}/tests/test_loop.py +2 -1
- loopmini-0.1.1/DEV.md +0 -204
- loopmini-0.1.1/python/loopmini/loop.py +0 -603
- loopmini-0.1.1/python/loopmini/subproc.py +0 -114
- loopmini-0.1.1/python/loopmini/transports.py +0 -481
- loopmini-0.1.1/src/tokio_core.rs +0 -118
- {loopmini-0.1.1 → loopmini-0.2.2}/.gitignore +0 -0
- {loopmini-0.1.1 → loopmini-0.2.2}/LICENSE +0 -0
- {loopmini-0.1.1 → loopmini-0.2.2}/_config.yml +0 -0
- {loopmini-0.1.1 → loopmini-0.2.2}/_layouts/default.html +0 -0
- {loopmini-0.1.1 → loopmini-0.2.2}/python/loopmini/__init__.py +0 -0
- {loopmini-0.1.1 → loopmini-0.2.2}/tests/test_oracle.py +0 -0
- {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:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
+
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
|