ringloop 1.0.0__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 (40) hide show
  1. ringloop-1.0.0/CHANGELOG.md +40 -0
  2. ringloop-1.0.0/DESIGN.md +373 -0
  3. ringloop-1.0.0/LICENSE +21 -0
  4. ringloop-1.0.0/MANIFEST.in +7 -0
  5. ringloop-1.0.0/PKG-INFO +235 -0
  6. ringloop-1.0.0/README.md +207 -0
  7. ringloop-1.0.0/conformance/cpython_skips.txt +41 -0
  8. ringloop-1.0.0/conformance/run_cpython.py +154 -0
  9. ringloop-1.0.0/conformance/smoke.py +24 -0
  10. ringloop-1.0.0/pyproject.toml +80 -0
  11. ringloop-1.0.0/setup.cfg +4 -0
  12. ringloop-1.0.0/setup.py +22 -0
  13. ringloop-1.0.0/src/ringloop/__init__.py +88 -0
  14. ringloop-1.0.0/src/ringloop/__main__.py +55 -0
  15. ringloop-1.0.0/src/ringloop/_core.pyx +3120 -0
  16. ringloop-1.0.0/src/ringloop/_owner.py +38 -0
  17. ringloop-1.0.0/src/ringloop/extras.py +1179 -0
  18. ringloop-1.0.0/src/ringloop/ll_abi.h +149 -0
  19. ringloop-1.0.0/src/ringloop/ll_sync.h +51 -0
  20. ringloop-1.0.0/src/ringloop/ll_uring.h +387 -0
  21. ringloop-1.0.0/src/ringloop/loop.py +1192 -0
  22. ringloop-1.0.0/src/ringloop/py.typed +0 -0
  23. ringloop-1.0.0/src/ringloop/pytest_plugin.py +24 -0
  24. ringloop-1.0.0/src/ringloop/tls.py +505 -0
  25. ringloop-1.0.0/src/ringloop.egg-info/PKG-INFO +235 -0
  26. ringloop-1.0.0/src/ringloop.egg-info/SOURCES.txt +38 -0
  27. ringloop-1.0.0/src/ringloop.egg-info/dependency_links.txt +1 -0
  28. ringloop-1.0.0/src/ringloop.egg-info/top_level.txt +1 -0
  29. ringloop-1.0.0/tests/__init__.py +0 -0
  30. ringloop-1.0.0/tests/abi_check.c +98 -0
  31. ringloop-1.0.0/tests/fallback_check.py +38 -0
  32. ringloop-1.0.0/tests/soak/client.py +145 -0
  33. ringloop-1.0.0/tests/soak/run.sh +19 -0
  34. ringloop-1.0.0/tests/soak/server.py +111 -0
  35. ringloop-1.0.0/tests/test_asyncio_compat.py +890 -0
  36. ringloop-1.0.0/tests/test_frameworks.py +140 -0
  37. ringloop-1.0.0/tests/test_hardening.py +381 -0
  38. ringloop-1.0.0/tests/test_loop.py +1006 -0
  39. ringloop-1.0.0/tests/test_robustness.py +1017 -0
  40. ringloop-1.0.0/tests/test_threads.py +607 -0
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ The versions follow semantic versioning. The public interface is what
4
+ `README.md` describes: `ringloop.run`, `new_event_loop`, `install`, `Loop`,
5
+ `Server`, `NATIVE`, `loop.backend`, the `RINGLOOP_BACKEND` variable,
6
+ `python -m ringloop` and `ringloop.pytest_plugin`. Everything else,
7
+ `ringloop._core` included, can change in any release.
8
+
9
+ ## 1.0.0
10
+
11
+ The first public release.
12
+
13
+ - An asyncio event loop for Linux on io_uring (Linux 6.1 and newer), with an
14
+ epoll backend for older kernels and for containers that do not allow
15
+ io_uring. The loop chooses the backend itself.
16
+ - The whole `asyncio.AbstractEventLoop` interface: TCP and Unix servers and
17
+ clients, TLS, `start_tls`, `sock_*`, UDP, pipes, subprocesses, signals,
18
+ executors, `sendfile`, Happy Eyeballs, debug mode.
19
+ - CPython 3.12 to 3.15, and the free-threaded builds 3.14t and 3.15t with
20
+ one loop per thread. Wheels for manylinux and musllinux, x86-64 and
21
+ aarch64.
22
+ - On systems other than Linux the package installs and hands out asyncio's
23
+ loop.
24
+ - `ringloop.install()` for programs that take no loop factory, and
25
+ `ringloop.pytest_plugin` to run a test suite on the loop.
26
+
27
+ How it was checked before the release:
28
+
29
+ - CPython's own asyncio test suite runs against the loop on both backends
30
+ (`conformance/`), on Python 3.12 to 3.15.
31
+ - This repository's suite runs on both backends, on every supported Python,
32
+ with io_uring on kernels 6.1, 6.6, 6.8 (Ubuntu), 6.12, 6.17 and 7.0.
33
+ - AddressSanitizer and UBSan builds, a soak test with misbehaving clients
34
+ (`tests/soak`), and smoke tests of uvicorn/Starlette, FastAPI, aiohttp
35
+ and httpx on the loop. The test suite of Stario (1,804 tests) passes on
36
+ both backends.
37
+ - Two independent code reviews of the engine. What they found is fixed, and
38
+ `tests/test_hardening.py` holds a test for each defect.
39
+
40
+ `README.md` lists the differences from asyncio and the limits.
@@ -0,0 +1,373 @@
1
+ # Design
2
+
3
+ ## What the loop is
4
+
5
+ `ringloop.Loop` implements `asyncio.AbstractEventLoop` on its own engine. It
6
+ does not subclass or patch asyncio's loop classes, so it is a drop-in loop
7
+ for any asyncio program:
8
+
9
+ ```python
10
+ import ringloop
11
+ ringloop.run(main()) # like asyncio.run
12
+ asyncio.run(main(), loop_factory=ringloop.new_event_loop)
13
+ ```
14
+
15
+ | File | Holds |
16
+ | --- | --- |
17
+ | `_core.pyx` (Cython) | `LoopCore`: ready queue, timer heap, `call_soon` / `call_later` / `add_reader`. `Core`: the I/O turn on io_uring or epoll. `Transport`: stream sockets. `Listener`: accept |
18
+ | `ll_uring.h` (C) | io_uring by raw syscalls: ring setup, provided-buffer ring, SQE builders, the spill list for a full submission queue. No liburing, no libuv |
19
+ | `ll_abi.h` (C) | The part of the io_uring ABI the engine uses, written out, so that the extension builds against any kernel headers (`tests/abi_check.c` compares it with the kernel's header) |
20
+ | `ll_sync.h` (C) | Atomic loads, stores and compare-and-swap for the few fields other threads touch |
21
+ | `loop.py` | `Loop`: run / stop / close, tasks, exception handler, executors, DNS, signals, `sock_*`, `create_server`, `create_connection`, Unix sockets, `start_tls`; `Server` |
22
+ | `tls.py` | `SSLTransport`: TLS on `ssl.MemoryBIO`, one object that is both the raw transport's protocol and the application's transport |
23
+ | `extras.py` | Pipes, subprocesses, datagram endpoints (Python on `add_reader` / `add_writer`); `sendfile` |
24
+ | `__init__.py` | `run`, `new_event_loop`; on systems other than Linux these hand out asyncio's loop |
25
+
26
+ ### What it still touches that is not plainly public
27
+
28
+ - `asyncio._set_running_loop` / `asyncio._get_running_loop`: underscore
29
+ names, but exported by asyncio for exactly this (every third-party loop
30
+ uses them).
31
+ - `asyncio.subprocess.Process.wait()` calls `transport._wait()`, so
32
+ `ProcessTransport` provides it.
33
+ - `get_extra_info("socket")` returns `asyncio.trsock.TransportSocket`, the
34
+ same restricted view asyncio returns.
35
+ - `ringloop.pytest_plugin` (test tooling only) swaps the default policy's
36
+ loop factory to run other projects' test suites on ringloop.
37
+ - `asyncio.staggered.staggered_race` runs the connection attempts of Happy
38
+ Eyeballs (the module has an `__all__`, but is not in the documentation).
39
+ - `start_tls` on the server side moves bytes a `StreamReader` has already
40
+ buffered (the client's ClientHello) into the TLS layer, through the
41
+ reader's private `_buffer`, exactly as asyncio does (gh-142352). There is
42
+ no public way to take them out.
43
+
44
+ A stream transport is an extension type, and an extension type cannot
45
+ inherit from `asyncio.Transport` (a Python class with its own instance
46
+ layout). Code written for asyncio checks `isinstance(transport,
47
+ asyncio.Transport)`, so the transport answers `__class__` with a subclass of
48
+ `asyncio.Transport`: `isinstance` consults `__class__`. `type(transport)` is
49
+ still the extension type. The transports written in Python inherit from the
50
+ asyncio classes directly.
51
+
52
+ ## Two backends
53
+
54
+ - **io_uring** (default when the kernel allows it). One ring per loop with
55
+ `SINGLE_ISSUER` and `DEFER_TASKRUN`, 1024 submission and 4096 completion
56
+ entries. Linux 6.1 is the minimum because `DEFER_TASKRUN` is: an older
57
+ kernel fails ring setup and gets epoll. Receives are one multishot `RECV` per
58
+ connection drawing from a shared ring of 256 x 16 KiB buffers (256:
59
+ the buffer ring then fits in one page; kernels before 6.9 map only the
60
+ first page of a ring the program allocated). Sends are
61
+ `SEND` with `MSG_WAITALL` from a buffer the transport owns until the
62
+ completion arrives. Accept is one request per connection (see "Accept is
63
+ inline").
64
+ - **epoll** (`RINGLOOP_BACKEND=epoll`, or automatically when
65
+ `io_uring_setup` fails). Same transports, readiness-driven: `recv` into a
66
+ loop-wide scratch buffer, `send` at the end of the turn. Container
67
+ runtimes often block io_uring with seccomp, so this is the backend many
68
+ deployments get. `loop.backend` says which one is in use. Timeouts are
69
+ exact (`epoll_pwait2`, Linux 5.11) unless the kernel or the seccomp policy
70
+ refuses that call; then they round up to a millisecond.
71
+
72
+ A `SINGLE_ISSUER` ring belongs to the thread that created it. The ring is
73
+ created on first use; if the loop is later run on a different thread (set up
74
+ with `run_until_complete` on the main thread, then `run_forever` on a
75
+ worker), the core replaces the ring when the loop starts running and arms
76
+ its listeners and open connections again on the new one. The owner is
77
+ tracked by a per-thread number that no other thread of the process ever
78
+ gets (`pthread_self()` values are reused as soon as a thread exits, kernel
79
+ thread ids once the pid space wraps). Before the old ring is dropped,
80
+ completions still sitting in its queue are run, and with `DEFER_TASKRUN` no
81
+ others can exist, because completions only happen inside the loop's own
82
+ `io_uring_enter`. The move is refused, with an error, in two cases, and the
83
+ loop keeps its ring and its thread: a send is still in flight on the old
84
+ thread (how much of it the kernel has done is known only to the old ring),
85
+ or the new ring cannot be created (out of memory or descriptors; it is
86
+ created before the old one is dropped).
87
+
88
+ The backend is chosen, and the ring created, when the loop opens its first
89
+ socket (or is asked for `loop.backend`). Until then the loop waits in
90
+ `epoll_wait`: callbacks, timers, `add_reader` / `add_writer` and the wake-up
91
+ eventfd need nothing else. A program that only runs tasks, timers or
92
+ subprocesses never creates a ring, and a test suite that makes a loop per
93
+ test does not create and destroy a ring (and its 8 MiB of receive buffers)
94
+ per test.
95
+
96
+ Ring memory counts against `RLIMIT_MEMLOCK` (8 MiB by default) unless the
97
+ process has `CAP_IPC_LOCK`, and the kernel gives it back some time after the
98
+ ring is closed. That is why the ring is small: about 140 KiB, so some 50
99
+ loops fit in the default limit (one loop per thread on a large machine). The
100
+ sizes do not limit a turn (see "The submission queue cannot overflow"; the
101
+ kernel keeps completions that do not fit and hands them over on the next
102
+ call). When `io_uring_setup` fails with `ENOMEM` the loop logs one warning
103
+ and uses epoll.
104
+
105
+ `add_reader` / `add_writer` registrations and the wake-up eventfd live in an
106
+ epoll set the core owns, on both backends. Under io_uring that epoll fd is
107
+ watched with a one-shot `POLL_ADD`, re-armed each turn, which keeps those
108
+ registrations level-triggered as asyncio code expects.
109
+
110
+ ## Threads
111
+
112
+ A loop belongs to one thread at a time: the thread running it, or, while no
113
+ thread runs it, the thread calling into it. That thread is the loop's
114
+ *owner* (`Core.owner`, claimed with an atomic compare-and-swap).
115
+
116
+ - **Scheduling works from every thread.** `call_soon`,
117
+ `call_soon_threadsafe`, `call_later`, `call_at` and `stop` are all safe
118
+ from any thread (the first two are the same function). The running thread
119
+ appends to its own queue directly. Any other thread posts the handle to a
120
+ small queue under a mutex and wakes the loop; the running thread moves
121
+ posted handles to its queue and timer heap at the start of each turn.
122
+ Nothing is lost, the handles one thread posts run in the order it posted
123
+ them, and a handle posted before the loop thread schedules its own runs
124
+ first (the loop thread takes in what was posted before it adds to its
125
+ queue), as with a single queue. Under the same mutex, `close()` marks the loop closed before
126
+ it closes the wake-up eventfd, so a late `call_soon_threadsafe` raises
127
+ instead of writing to a descriptor number that may belong to someone else
128
+ by then.
129
+ - **Everything else belongs to the owner.** Transports, listeners,
130
+ `add_reader` / `add_writer`, `create_server`'s listening and closing: a call
131
+ from a thread that does not own the loop raises `RuntimeError` (asyncio's
132
+ debug-mode message). While no thread runs the loop, such a call claims it
133
+ for its duration, so setting up on one thread and running on another
134
+ works; two threads using an idle loop at the same moment get the error
135
+ instead of a race. `Server.close()`, `start_serving()`, `loop.close()` and
136
+ the transports written in Python (TLS, pipes, subprocesses, UDP) check
137
+ before they change anything (a TLS record encrypted but never sent would
138
+ break the connection).
139
+ - **`close()` and `abort()` are the exception.** The garbage collector runs
140
+ finalizers on whichever thread triggers it, and finalizers close
141
+ transports (`StreamWriter.__del__`, aiohttp's connections). A refused
142
+ close would leave the connection open for good, with nothing left that
143
+ refers to it. From a thread that does not own the loop, `close()` and
144
+ `abort()` of every transport are handed to the loop's thread
145
+ (`call_soon_threadsafe`).
146
+ - **Questions are answered on every thread.** `get_protocol()`,
147
+ `get_extra_info()`, `is_closing()`, `is_running()`, `is_closed()`,
148
+ `loop.backend`. (`get_write_buffer_size()`, `is_closing()` and
149
+ `is_reading()` do not crash either, but they read plain flags and
150
+ counters: from another thread the answer may be a moment old.) The object
151
+ fields the others read (protocol, socket, extra info) are written under
152
+ the transport's critical section; the socket object behind
153
+ `get_extra_info` is created outside it (creating one makes system calls,
154
+ and a critical section is given up whenever its thread blocks) and
155
+ published with a compare-and-set.
156
+ - **`Handle.cancel()` works from every thread.** The handle's fields are read
157
+ and cleared under its critical section; the cancelled flag is atomic.
158
+ Cancelled timers are counted for the heap clean-up exactly on the loop's
159
+ thread and in a separate atomic counter on other threads.
160
+ - **Free-threaded builds.** The extension declares that it does not need the
161
+ GIL (`freethreading_compatible`), so 3.14t keeps the GIL off. One loop per
162
+ thread is the intended way to use several cores: each loop has its own
163
+ ring, buffers and queues and shares nothing with the others. Give each
164
+ loop's server `reuse_port=True` to let the kernel spread connections. On
165
+ the regular build the critical sections compile to nothing; the owner
166
+ check is a thread-id comparison.
167
+
168
+ ## Choices that matter
169
+
170
+ - **The submission queue cannot overflow.** A turn may queue more requests
171
+ than the 1024-entry queue holds (one send per connection that wrote). A
172
+ full queue is handed to the kernel part-way through the turn; if the
173
+ kernel does not take it, further requests go to a side list that is fed
174
+ in, in order, before the loop sleeps again. A queued request is never
175
+ overwritten or dropped: the send buffers are pooled, so a lost or
176
+ repeated send could put one connection's bytes on another.
177
+ - **The end of a connection has its own poll.** A multishot recv can miss
178
+ it. The kernel parks the recv after a receive that emptied the socket and
179
+ wakes it for the next event. But when data and the peer's close (or reset)
180
+ both arrive before the loop takes its completions, the two wake-ups count
181
+ as one: the recv reports the data, finds the socket empty, and waits for a
182
+ wake-up that has already happened. TCP hides this for an orderly close (a
183
+ FIN counts as pending input), not for a reset, and not on Unix sockets
184
+ (seen on Linux 6.17). The connection would stay open for good. So every
185
+ transport also holds one one-shot poll for "peer closed or reset"
186
+ (`POLLRDHUP`; errors and hang-ups are always reported). When it fires, the
187
+ recv is cancelled and armed again, and a new recv reads the socket before
188
+ it parks: what is there arrives, in order, then the end. The cancel waits
189
+ for the end of that run of completions, because after an orderly TCP close
190
+ the recv usually reports the end itself a few completions later and
191
+ nothing needs cancelling. The cost is one request per connection, none
192
+ per message.
193
+ - **Stopping settles.** When `stop()` ends the run, the loop sends what the
194
+ last turn wrote and takes the completions of what it closed, so those
195
+ sockets are closed when `run_until_complete()` returns. Under asyncio a
196
+ write reaches the socket at once and a closed transport's socket is closed
197
+ in `connection_lost`; a program may not run the loop again for a while.
198
+ - **Signals are checked before sleeping.** The loop is not bytecode, so
199
+ Python-level signal handlers run only when it asks: before each wait and
200
+ whenever a wait returns `EINTR`. That is what makes Ctrl-C (and
201
+ `asyncio.Runner`'s SIGINT handling) work on an idle loop.
202
+ - **Scheduling is two lists and a native heap.** `call_soon` appends to a
203
+ list; a turn swaps it for an empty one and runs the batch, so callbacks
204
+ scheduled by callbacks wait for the next turn. Timers are a binary heap of
205
+ the `TimerHandle` objects themselves, ordered by (time, insertion) on C
206
+ fields; both containers are ordinary lists, so the garbage collector still
207
+ sees everything a pending callback keeps alive.
208
+ - **Writes wait for the end of the turn.** `transport.write()` copies into
209
+ an output buffer and marks the transport dirty; the next I/O turn flushes
210
+ every dirty transport before entering the kernel. With io_uring, N
211
+ responses plus the wait for new input are one syscall. If more than the
212
+ high-water mark is buffered, the transport first tries a plain
213
+ non-blocking `send`, so a large response does not pause the protocol for
214
+ nothing.
215
+ - **Output is copied, into pooled buffers.** A transport takes a buffer from
216
+ a per-loop pool on its first write and gives it back when the send
217
+ completes, so idle connections hold none. The copy is ~1 µs for 16 KiB.
218
+ A multi-megabyte `write()` is copied once too; sending large buffers by
219
+ reference is not done.
220
+ - **Accept is inline.** `protocol_factory()` and `connection_made()` run
221
+ from the accept completion: no coroutine, no Task, no socket object per
222
+ connection (one is created only if `get_extra_info("socket")`,
223
+ `"peername"` or `"sockname"` is asked for, and then with the listener's
224
+ family, so the socket module asks the kernel nothing). `TCP_NODELAY` is
225
+ set once, on the listening socket; accepted sockets inherit it.
226
+ - **Accept is not multishot.** A multishot accept keeps its place at the
227
+ head of the listening socket's queue of waiters. Of several processes
228
+ that accept on one socket (`uvicorn --workers`, gunicorn, any pre-fork
229
+ server) the one that armed first would then get every connection, also
230
+ while it is busy and the others idle. A one-shot accept that is queued
231
+ again after each connection goes to the back of that queue, and the
232
+ kernel deals the connections round. It costs one request per connection.
233
+ - **`pause_reading()` is lazy under io_uring.** The multishot recv stays
234
+ armed, so a pause that ends within the same turn costs nothing. If data
235
+ does arrive while paused it is kept, and only then is the recv cancelled
236
+ so TCP backpressure reaches the peer.
237
+ - **Closing cancels, then closes.** An io_uring request holds its file open,
238
+ so `close(fd)` alone would leave the socket open while a recv is armed.
239
+ The transport cancels its outstanding requests when it closes (one
240
+ cancel for everything on the descriptor) and closes the socket when their
241
+ final completions have arrived, usually within the next turn. Not earlier: a request still queued names the socket by its
242
+ descriptor number, and a number freed too early can be given to a new
243
+ connection (or any file another thread opens), which would then receive
244
+ this connection's bytes. A listener closes its socket at once when the
245
+ kernel takes the queued requests at once, else when its accept's final
246
+ completion arrives. Under epoll the socket is removed from the epoll set
247
+ explicitly and closed at once. `loop.close()` cancels every request of
248
+ the ring and lets the kernel finish them before the ring goes, so a
249
+ listening address is free, and peers see the end, when `close()` returns.
250
+ - **Each connection has its own context.** As under asyncio, every accepted
251
+ connection runs its protocol callbacks (the factory included) in its own
252
+ copy of the context the server started listening in, so a context
253
+ variable one connection sets is not seen by the next. A run of
254
+ completions for the same connection costs one Enter/Exit.
255
+ - **Input is delivered from inside the I/O turn.** `data_received` and the
256
+ other protocol callbacks of a socket run while the loop goes through its
257
+ completions, before the callbacks queued with `call_soon` in the previous
258
+ turn. asyncio queues both in arrival order. The one place where programs
259
+ rely on the order is kept: `await loop.create_connection()` (and
260
+ `connect_accepted_socket`, `create_unix_connection`) returns before the
261
+ first `data_received`; for these, reading starts one turn later, behind
262
+ the waiting task's step.
263
+ - **`sendfile` is `sendfile(2)`.** On a plain connection `loop.sendfile`
264
+ takes the transport's write side (a `write()` meanwhile raises, as under
265
+ asyncio), waits for buffered output to leave, and calls `sendfile(2)` on
266
+ the socket, waiting for "writable" in between (a one-shot poll under
267
+ io_uring, `EPOLLOUT` under epoll). Over TLS, and for files that are not
268
+ regular files, it copies through `write()`.
269
+ - **TLS is one object and one hop.** Ciphertext goes from the raw transport
270
+ into OpenSSL's memory BIO; plaintext is read straight into the
271
+ application's buffer (`BufferedProtocol`) or handed over as one `bytes`.
272
+ `writelines` becomes a single TLS record. The read loop stops when neither
273
+ the BIO nor OpenSSL has bytes left instead of reading until
274
+ `SSLWantReadError`; this relies on OpenSSL taking one record at a time
275
+ from the BIO (no read-ahead, which `ssl` never enables).
276
+
277
+ ## Known gaps
278
+
279
+ - In a forked child a loop of the parent is closed: create a new one. A
280
+ fork hook (`os.register_at_fork`) does it. The ring's queues and the epoll
281
+ set are shared with the parent, so the child must not queue a request or
282
+ change a registration: the hook drops the child's references without a
283
+ word to the kernel, closes the child's copies of the connections, and
284
+ leaves listening sockets to whoever holds them (a pre-fork server passes
285
+ them to the child's own loop).
286
+ - A loop cannot move to another thread while a send is in flight (see
287
+ above).
288
+ - On kernels before 6.10 a turn in which more than 256 connections have
289
+ input runs out of receive buffers. The recvs that got none end with
290
+ `ENOBUFS` and are armed again: correct, with some extra work. (Newer
291
+ kernels hand out at most 20 completions per call, so it does not happen
292
+ there.) A kernel-allocated buffer ring (6.4) could be larger.
293
+ - Ubuntu's 6.8 kernels (seen: 6.8.0-1070-azure, GitHub's `ubuntu-22.04`
294
+ runners) carry a backport of the buffer ring registration in which the
295
+ test of the reserved bytes is inverted (`!memchr_inv(reg.resv, ...)`): the
296
+ correct call gets `EINVAL`. `ll_ring_init` then repeats the call with one
297
+ reserved byte set, which only such a kernel takes. The same defect is in
298
+ the unregister call, which the engine does not use (closing the ring
299
+ frees the buffer ring).
300
+ - Pipes, subprocesses and UDP are correct but not accelerated.
301
+ - Not done: zero-copy send, registered file descriptors, large writes by
302
+ reference, a Cython build of `tls.py`.
303
+ - The engine is Linux only. Elsewhere `ringloop.run()` and
304
+ `new_event_loop()` use asyncio's loop (`ringloop.NATIVE` is false).
305
+ - `Handle.cancel()` from another thread does not wait for a callback that is
306
+ already running (asyncio's handle for `call_soon_threadsafe` holds a lock
307
+ while it runs).
308
+
309
+ ## Tests
310
+
311
+ - `tests/`: this repository's suite, every test on both backends. It also
312
+ smoke-tests uvicorn/Starlette, FastAPI, aiohttp and httpx on the loop.
313
+ - `conformance/run_cpython.py`: CPython's own asyncio test suite
314
+ (`Lib/test/test_asyncio`) on ringloop. Every way that suite makes a loop
315
+ is pointed at `ringloop.Loop`. The tests that examine private parts of
316
+ asyncio's own loop classes are listed in `conformance/cpython_skips.txt`,
317
+ each with its reason; an entry that matches no test is reported. CI runs
318
+ it on both backends.
319
+ - Kernels: CI runs the suite with io_uring on the runners' own kernels
320
+ (7.0, and Ubuntu's 6.8) and, in a virtual machine (virtme-ng), on the
321
+ latest 6.1, 6.6 and 6.12 kernels.
322
+ - `tests/soak`: a server under clients that misbehave, then a check that
323
+ nothing is left behind.
324
+ - The fallback on systems other than Linux: CI installs the package on macOS
325
+ and Windows and runs a program on it.
326
+ - [Stario](https://stario.dev)'s suite, through `ringloop.pytest_plugin`
327
+ (run by hand before a release).
328
+
329
+ ## Checking a change
330
+
331
+ ```sh
332
+ python setup.py build_ext --inplace && python -m pytest # both backends
333
+ python conformance/run_cpython.py --lib ../cpython/Lib # CPython's asyncio suite
334
+ ```
335
+
336
+ `--lib` is the `Lib` directory of a CPython source tree of the version that
337
+ runs the tests (`git clone --depth 1 --branch v3.14.0
338
+ https://github.com/python/cpython`).
339
+
340
+ On a free-threaded build (3.14t), run the suite with
341
+ `-W error::RuntimeWarning`: importing an extension that would turn the GIL
342
+ back on then fails instead of warning.
343
+
344
+ Memory errors in the C and Cython code (AddressSanitizer + UBSan, GCC):
345
+
346
+ ```sh
347
+ CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -O1 -g" \
348
+ LDFLAGS="-fsanitize=address,undefined" python setup.py build_ext --inplace --force
349
+ LD_PRELOAD=$(gcc -print-file-name=libasan.so) ASAN_OPTIONS=detect_leaks=0 \
350
+ PYTHONMALLOC=malloc python -m pytest tests/test_loop.py tests/test_robustness.py
351
+ ```
352
+
353
+ Performance against another checkout (built in place), interleaved:
354
+
355
+ ```sh
356
+ python bench/ab.py --base ../ringloop-main --inproc --instructions # scheduling, timers, TLS: instructions per operation
357
+ python bench/ab.py --base ../ringloop-main --micro # req/s and CPU per request under bench/loadgen
358
+ python bench/ab.py --base ../ringloop-main --churn # server CPU per connection, one request each
359
+ ```
360
+
361
+ Against other loops (uvloop, rloop, asyncio), on one thread and with one
362
+ loop per thread on a free-threaded build:
363
+
364
+ ```sh
365
+ python bench/compare.py --pipeline 1,16 # one thread
366
+ python bench/compare.py --python python3.14t --threads 1,2,3 --work 300 # one loop per thread
367
+ ```
368
+
369
+ `--instructions` counts user-space instructions with `perf_event_open`, so
370
+ it gives the same answer on a busy machine; use it for anything smaller than
371
+ a few percent. `bench/run.py` and `--micro` print the server's instructions
372
+ per request next to the time-based columns for the same reason.
373
+ `bench/README.md` lists every tool.
ringloop-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Bobowski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,7 @@
1
+ # The extension is built from the .pyx with Cython; the generated C is not shipped.
2
+ include src/ringloop/*.pyx src/ringloop/*.h
3
+ exclude src/ringloop/_core.c
4
+ include DESIGN.md
5
+ include CHANGELOG.md
6
+ recursive-include tests *.py *.c *.sh
7
+ recursive-include conformance *.py *.txt