From 3ffb6ab18dbe8afe9b62d67d871936c5aaa5f559 Mon Sep 17 00:00:00 2001 From: bright Date: Thu, 30 Jul 2026 01:18:16 +0800 Subject: [PATCH 1/6] spike static-liburing ring lifecycle --- .gitignore | 1 + MANIFEST.in | 3 + THIRD_PARTY_LICENSES/liburing-MIT.txt | 21 +++ docs/phase1-static-liburing-spike.md | 33 ++++ pyproject.toml | 2 +- setup.py | 14 +- src/uringcore_liburing.c | 256 ++++++++++++++++++++++++++ tests/unit/test_uringcore_liburing.py | 36 ++++ uringloop/_uringcore_liburing.pyi | 22 +++ 9 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 THIRD_PARTY_LICENSES/liburing-MIT.txt create mode 100644 docs/phase1-static-liburing-spike.md create mode 100644 src/uringcore_liburing.c create mode 100644 tests/unit/test_uringcore_liburing.py create mode 100644 uringloop/_uringcore_liburing.pyi diff --git a/.gitignore b/.gitignore index d876fab..e0429b7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ wheels/ _liburing.c _liburing.o _liburing.*.so +_uringcore_liburing.*.so diff --git a/MANIFEST.in b/MANIFEST.in index da7c1d5..b55285d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,7 @@ include _ffi_build.py +include src/uringcore_liburing.c +include THIRD_PARTY_LICENSES/liburing-MIT.txt +recursive-include docs *.md recursive-include uringloop *.py *.pyi include uringloop/py.typed exclude uringloop/_liburing.c diff --git a/THIRD_PARTY_LICENSES/liburing-MIT.txt b/THIRD_PARTY_LICENSES/liburing-MIT.txt new file mode 100644 index 0000000..c89bbf2 --- /dev/null +++ b/THIRD_PARTY_LICENSES/liburing-MIT.txt @@ -0,0 +1,21 @@ +liburing +Copyright 2020 Jens Axboe + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/phase1-static-liburing-spike.md b/docs/phase1-static-liburing-spike.md new file mode 100644 index 0000000..af0e71b --- /dev/null +++ b/docs/phase1-static-liburing-spike.md @@ -0,0 +1,33 @@ +# Phase 1 static-liburing ring spike + +This is the second native ring implementation spike required by Phase 1 of +the roadmap. It mirrors the lifecycle boundary of the raw-syscall +`_uringcore.Ring` with a separate `_uringcore_liburing.Ring` implemented +through the pinned liburing submodule. + +The extension links `libs/src/liburing.a` into the module. It therefore has +no runtime dependency on a system `liburing.so`. liburing is used under its +MIT license, whose notice is included in the package. + +Like the raw-syscall spike, this module owns ring initialization and teardown +but does not submit or reap operations and is not wired into the Python +proactor. The source checkout must configure and build the pinned submodule +before building this experimental extension; packaging the vendored sources +for standalone wheel builds remains part of the route decision. + +The decision record can now compare the two lifecycle implementations using +the same API and tests. Neither spike is the production backend until that +record selects a route. + +On the initial CPython 3.12 x86-64 development build, including debug +information, the module sizes are: + +| Route | Extension size | +| --- | ---: | +| Raw syscalls | 35,152 bytes | +| Static liburing | 109,840 bytes | + +The static module adds 74,688 bytes in this build. These are spike +measurements rather than release-wheel results; the decision record must +repeat them with the release build and record its compiler and strip +settings. diff --git a/pyproject.toml b/pyproject.toml index 02e8b57..9904a33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "An io_uring-based proactor event loop for asyncio" readme = "README.md" license = "MIT" -license-files = ["LICENSE"] +license-files = ["LICENSE", "THIRD_PARTY_LICENSES/*"] requires-python = ">=3.12" dependencies = [ "cffi>=1.17.1", diff --git a/setup.py b/setup.py index 1a8ce9f..fdc9ab3 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,14 @@ -from setuptools import setup +from setuptools import Extension, setup -setup(cffi_modules=["_ffi_build.py:ffibuilder"]) +setup( + cffi_modules=["_ffi_build.py:ffibuilder"], + ext_modules=[ + Extension( + "uringloop._uringcore_liburing", + sources=["src/uringcore_liburing.c"], + include_dirs=["libs/src/include"], + extra_objects=["libs/src/liburing.a"], + ), + ], +) diff --git a/src/uringcore_liburing.c b/src/uringcore_liburing.c new file mode 100644 index 0000000..79c1e3e --- /dev/null +++ b/src/uringcore_liburing.c @@ -0,0 +1,256 @@ +#define PY_SSIZE_T_CLEAN +#include +#include + +#include +#include +#include +#include +#include + +typedef struct { + PyObject_HEAD + struct io_uring ring; + int initialized; + unsigned int sq_entries; + unsigned int cq_entries; + unsigned int features; +} UringCoreLiburingRing; + +static void +uringcore_liburing_ring_close_resources(UringCoreLiburingRing *self) +{ + if (self->initialized) { + io_uring_queue_exit(&self->ring); + memset(&self->ring, 0, sizeof(self->ring)); + self->initialized = 0; + } +} + +static PyObject * +uringcore_liburing_ring_new( + PyTypeObject *type, + PyObject *Py_UNUSED(args), + PyObject *Py_UNUSED(kwargs)) +{ + return type->tp_alloc(type, 0); +} + +static int +uringcore_liburing_ring_init( + UringCoreLiburingRing *self, + PyObject *args, + PyObject *kwargs) +{ + static char *keyword_names[] = {"entries", NULL}; + PyObject *entries_object = NULL; + PyObject *entries_index = NULL; + unsigned long parsed_entries; + unsigned int entries = 256; + struct io_uring_params params; + int result; + + if (!PyArg_ParseTupleAndKeywords( + args, kwargs, "|O:Ring", keyword_names, &entries_object)) { + return -1; + } + if (entries_object != NULL) { + entries_index = PyNumber_Index(entries_object); + if (entries_index == NULL) { + return -1; + } + parsed_entries = PyLong_AsUnsignedLong(entries_index); + Py_DECREF(entries_index); + if (parsed_entries == (unsigned long)-1 && PyErr_Occurred()) { + PyErr_Clear(); + PyErr_Format( + PyExc_ValueError, + "entries must be between 1 and %u", + UINT_MAX); + return -1; + } + if (parsed_entries == 0 || parsed_entries > UINT_MAX) { + PyErr_Format( + PyExc_ValueError, + "entries must be between 1 and %u", + UINT_MAX); + return -1; + } + entries = (unsigned int)parsed_entries; + } + + uringcore_liburing_ring_close_resources(self); + memset(¶ms, 0, sizeof(params)); + + result = io_uring_queue_init_params(entries, &self->ring, ¶ms); + if (result < 0) { + errno = -result; + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + + self->initialized = 1; + self->sq_entries = params.sq_entries; + self->cq_entries = params.cq_entries; + self->features = params.features; + return 0; +} + +static void +uringcore_liburing_ring_dealloc(UringCoreLiburingRing *self) +{ + uringcore_liburing_ring_close_resources(self); + Py_TYPE(self)->tp_free((PyObject *)self); +} + +static PyObject * +uringcore_liburing_ring_close( + UringCoreLiburingRing *self, + PyObject *Py_UNUSED(ignored)) +{ + uringcore_liburing_ring_close_resources(self); + Py_RETURN_NONE; +} + +static PyObject * +uringcore_liburing_ring_enter( + UringCoreLiburingRing *self, + PyObject *Py_UNUSED(ignored)) +{ + if (!self->initialized) { + PyErr_SetString(PyExc_RuntimeError, "ring is closed"); + return NULL; + } + return Py_NewRef(self); +} + +static PyObject * +uringcore_liburing_ring_exit( + UringCoreLiburingRing *self, + PyObject *Py_UNUSED(args)) +{ + uringcore_liburing_ring_close_resources(self); + Py_RETURN_FALSE; +} + +static PyObject * +uringcore_liburing_ring_get_closed( + UringCoreLiburingRing *self, + void *Py_UNUSED(context)) +{ + return PyBool_FromLong(!self->initialized); +} + +static PyMethodDef uringcore_liburing_ring_methods[] = { + { + "close", + (PyCFunction)uringcore_liburing_ring_close, + METH_NOARGS, + PyDoc_STR("Release the liburing ring resources."), + }, + { + "__enter__", + (PyCFunction)uringcore_liburing_ring_enter, + METH_NOARGS, + NULL, + }, + { + "__exit__", + (PyCFunction)uringcore_liburing_ring_exit, + METH_VARARGS, + NULL, + }, + {NULL, NULL, 0, NULL}, +}; + +static PyMemberDef uringcore_liburing_ring_members[] = { + { + "sq_entries", + T_UINT, + offsetof(UringCoreLiburingRing, sq_entries), + READONLY, + PyDoc_STR("Number of submission queue entries allocated by the kernel."), + }, + { + "cq_entries", + T_UINT, + offsetof(UringCoreLiburingRing, cq_entries), + READONLY, + PyDoc_STR("Number of completion queue entries allocated by the kernel."), + }, + { + "features", + T_UINT, + offsetof(UringCoreLiburingRing, features), + READONLY, + PyDoc_STR("Feature flags returned by io_uring_setup."), + }, + {NULL}, +}; + +static PyGetSetDef uringcore_liburing_ring_getset[] = { + { + "closed", + (getter)uringcore_liburing_ring_get_closed, + NULL, + PyDoc_STR("Whether the liburing ring resources have been released."), + NULL, + }, + {NULL}, +}; + +PyDoc_STRVAR( + uringcore_liburing_ring_doc, + "Ring(entries=256)\n" + "--\n" + "\n" + "Own an io_uring lifecycle through statically linked liburing."); + +static PyTypeObject UringCoreLiburingRingType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "uringloop._uringcore_liburing.Ring", + .tp_basicsize = sizeof(UringCoreLiburingRing), + .tp_dealloc = (destructor)uringcore_liburing_ring_dealloc, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = uringcore_liburing_ring_doc, + .tp_methods = uringcore_liburing_ring_methods, + .tp_members = uringcore_liburing_ring_members, + .tp_getset = uringcore_liburing_ring_getset, + .tp_init = (initproc)uringcore_liburing_ring_init, + .tp_new = uringcore_liburing_ring_new, +}; + +static PyModuleDef uringcore_liburing_module = { + PyModuleDef_HEAD_INIT, + .m_name = "_uringcore_liburing", + .m_doc = "Statically linked liburing ring primitives.", + .m_size = -1, +}; + +PyMODINIT_FUNC +PyInit__uringcore_liburing(void) +{ + PyObject *module; + + if (PyType_Ready(&UringCoreLiburingRingType) < 0) { + return NULL; + } + + module = PyModule_Create(&uringcore_liburing_module); + if (module == NULL) { + return NULL; + } + + if (PyModule_AddObjectRef( + module, + "Ring", + (PyObject *)&UringCoreLiburingRingType) < 0) { + Py_DECREF(module); + return NULL; + } + if (PyModule_AddIntConstant(module, "ABI_VERSION", 1) < 0) { + Py_DECREF(module); + return NULL; + } + return module; +} diff --git a/tests/unit/test_uringcore_liburing.py b/tests/unit/test_uringcore_liburing.py new file mode 100644 index 0000000..22b7f9a --- /dev/null +++ b/tests/unit/test_uringcore_liburing.py @@ -0,0 +1,36 @@ +import pytest + +from uringloop import _uringcore_liburing + + +def test_static_liburing_core_has_versioned_abi(): + assert _uringcore_liburing.ABI_VERSION == 1 + + +@pytest.mark.parametrize("entries", [-(2**32), -1, 0, 2**32, 2**64]) +def test_static_liburing_ring_rejects_out_of_range_queue_size(entries): + with pytest.raises(ValueError, match="entries must be between"): + _uringcore_liburing.Ring(entries) + + +def test_static_liburing_ring_owns_and_releases_kernel_resources(): + ring = _uringcore_liburing.Ring(8) + + assert ring.sq_entries >= 8 + assert ring.cq_entries >= ring.sq_entries + assert ring.closed is False + + ring.close() + assert ring.closed is True + + ring.close() + assert ring.closed is True + + +def test_static_liburing_ring_context_manager_closes_resources(): + with _uringcore_liburing.Ring(entries=8) as ring: + assert ring.closed is False + + assert ring.closed is True + with pytest.raises(RuntimeError, match="ring is closed"): + ring.__enter__() diff --git a/uringloop/_uringcore_liburing.pyi b/uringloop/_uringcore_liburing.pyi new file mode 100644 index 0000000..959461d --- /dev/null +++ b/uringloop/_uringcore_liburing.pyi @@ -0,0 +1,22 @@ +from typing import Final, Literal, Self + +ABI_VERSION: Final[int] + +class Ring: + def __init__(self, entries: int = 256) -> None: ... + @property + def sq_entries(self) -> int: ... + @property + def cq_entries(self) -> int: ... + @property + def features(self) -> int: ... + @property + def closed(self) -> bool: ... + def close(self) -> None: ... + def __enter__(self) -> Self: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object | None, + ) -> Literal[False]: ... From c23e644bd38a7f5e7f43ca194350903f8a69102d Mon Sep 17 00:00:00 2001 From: bright Date: Sat, 1 Aug 2026 02:16:41 +0800 Subject: [PATCH 2/6] docs: select static liburing backend --- ROADMAP.md | 10 +- docs/phase1-native-ring-decision.md | 136 +++++++++++++++++++++++++++ docs/phase1-static-liburing-spike.md | 19 ++-- 3 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 docs/phase1-native-ring-decision.md diff --git a/ROADMAP.md b/ROADMAP.md index 798f7cf..93135a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,15 +138,21 @@ API. The decision record must compare wheel size, license/attribution, build complexity, sanitizer coverage, syscall overhead, and the unsafe surface. Raw syscalls require correctly implementing the acquire/release ordering on - shared ring heads and tails that liburing already handles. + shared ring heads and tails that liburing already handles. The + [Phase 1 backend decision](docs/phase1-native-ring-decision.md), accepted on + 2026-08-01, selects the statically linked, vendored liburing route and + records the measurements and required follow-up. + - Move request lifetimes into native structs. A request owns every kernel-referenced buffer, sockaddr, msghdr, and iovec. + - Use a refcounted, multi-completion request state machine rather than "release on the first CQE." At minimum it must model submitted, cancel-pending, operation-complete, notification-pending, and released states. `SEND_ZC` normally produces an operation CQE followed by a notification CQE, and asynchronous cancellation can race with a successful operation completion. + - Add a bounded provided-buffer pool with explicit memory accounting and backpressure. Be precise about copying: @@ -158,8 +164,10 @@ API. - Size the CQ explicitly for multishot workloads, detect overflow, drain promptly, and test re-arming when the kernel terminates a multishot operation by clearing `IORING_CQE_F_MORE`. + - Expose a small, GIL-conscious batch API: prepare operations, submit once per loop tick, and reap a batch of completions in one native call. + - Add EINTR handling, runtime opcode/feature probing, and registered-ring-fd support where probing and measurement justify it. diff --git a/docs/phase1-native-ring-decision.md b/docs/phase1-native-ring-decision.md new file mode 100644 index 0000000..c64fcb9 --- /dev/null +++ b/docs/phase1-native-ring-decision.md @@ -0,0 +1,136 @@ +# Phase 1 native ring backend decision + +**Status:** accepted\ +**Date:** 2026-08-01 + +## Decision + +Use the pinned, vendored liburing as the implementation layer for the native +ring and link it statically into the extension. The production module will use +the canonical `_uringcore` name; `_uringcore_liburing` is only the +comparison-spike name. + +The raw-syscall implementation will remain available only until a follow-up +change moves the selected implementation to `_uringcore` and removes the two +experimental backends. The project will not maintain both implementations in +production. + +## Context + +Phase 1 required lifecycle spikes for both viable implementation routes: + +- direct `io_uring_setup` and `mmap` calls; and +- a statically linked, vendored liburing. + +Both spikes expose the same `Ring` construction, introspection, deterministic +close, context-manager, and deallocation behavior. Neither submits or reaps +requests, so the measurements below compare only the lifecycle boundary. In +particular, they cannot establish submit/reap throughput. + +## Measurements + +Measurements were taken from commit `467543c` on CPython 3.12.3, x86-64, +GCC 13.3.0, GNU binutils 2.42, and Linux +6.18.33.2-microsoft-standard-WSL2. The pinned liburing revision was +`f4e42a515cd78c8c9cac2be14222834be5f8df2b` (liburing 2.5). + +### Correctness boundary + +The two extensions have matching tests for: + +- queue-size validation; +- kernel-reported SQ/CQ sizes and features; +- deterministic, idempotent `close()`; +- context-manager cleanup and closed-ring rejection; and +- equivalent public ABI-version reporting. + +This demonstrates equivalent behavior at the spike boundary, not equivalence +for request submission, completion, cancellation, or shared-ring ordering. + +### Wheel contribution + +`uv build --wheel` used the interpreter's normal extension flags, including +`-O2 -g`; the wheel was not stripped. The table reports each wheel member, +not an estimated whole wheel containing only that backend. + +| Route | Wheel member, unpacked | Wheel member, compressed | `strip --strip-unneeded` copy | +| --- | ---: | ---: | ---: | +| Raw syscalls | 35,152 B | 13,099 B | 15,536 B | +| Static liburing | 109,840 B | 45,476 B | 27,792 B | +| Static-liburing increase | 74,688 B | 32,377 B | 12,256 B | + +The complete comparison wheel, which contains both backends and the existing +CFFI extension, was 170,228 bytes compressed. The static extension's dynamic +section lists only `libc.so.6`; it does not require a system `liburing.so` at +runtime. + +The size cost is acceptable for avoiding a substantially larger +correctness-sensitive implementation. Release manylinux and musllinux wheel +sizes must still be recorded when those builds exist. + +### Lifecycle syscalls + +`strace -c` around 1,000 construct/close iterations reported identical calls +for both routes: + +| Syscall | Raw syscalls | Static liburing | +| --- | ---: | ---: | +| `io_uring_setup` | 1,000 | 1,000 | +| `mmap` | 2,080 | 2,080 | +| `munmap` | 2,008 | 2,008 | +| `close` | 1,147 | 1,147 | + +The totals include interpreter startup and imports, but those totals are the +same for both processes. Neither spike calls `io_uring_enter`. Repeated timing +on WSL2 varied by more than 4x within each route, so the observed lifecycle +timings are not used to select a backend. + +Submission and completion benchmarks remain mandatory once the native batch +API exists. liburing helpers are largely inline, but that is not evidence that +their hot-path cost is zero. + +## Tradeoff comparison + +| Criterion | Raw syscalls | Static, vendored liburing | +| --- | --- | --- | +| Wheel size | Smaller | About 32 KB more compressed in this comparison build | +| Runtime dependency | None beyond libc and the kernel ABI | None beyond libc and the kernel ABI | +| Build complexity | Ordinary extension build | Must build and statically link the pinned submodule | +| Source packaging | Self-contained now | Vendored sources are not yet present in the sdist | +| License work | No additional bundled-library notice | MIT notice must remain in binary/source distributions | +| Sanitizers | Extension flags cover all project-owned ring code | liburing must also be rebuilt with matching sanitizer flags | +| Lifecycle syscall count | Identical | Identical | +| Unsafe surface | Project owns mappings and all future shared-ring ordering | liburing owns mappings and established SQ/CQ ordering helpers | +| Maintenance | Must track kernel ABI details directly | Must update and test a pinned upstream dependency | + +The current raw spike is 344 C lines, compared with 256 C lines for the +liburing wrapper. The more important difference is future code: the raw route +would make this project responsible for acquire/release ordering of shared SQ +and CQ heads and tails, ring wrapping, feature-specific layouts, and upstream +kernel ABI evolution. Sanitizers do not prove that this concurrency protocol +is correct. + +liburing already centralizes those rules and is the same abstraction used by +the existing CFFI implementation. Its build and attribution costs are +concrete and bounded. The extra wheel size is small compared with the +correctness and maintenance risk removed from the Phase 1 request core. + +## Required follow-up + +Selecting liburing does not declare the current spike production-ready. The +following work blocks that transition: + +1. Include the pinned liburing source and required headers in the sdist, and + build the archive as part of a source/wheel build instead of assuming that + `libs/src/liburing.a` already exists. +1. Build liburing itself with ASAN/UBSAN flags in sanitizer jobs, then run the + native e2e suite against that instrumented archive. +1. Move the selected implementation behind the canonical `_uringcore` name + and remove the raw backend, duplicate stub, tests, and build configuration. +1. Implement and benchmark the native prepare/submit/reap batch boundary + before making claims about syscall or CPU improvements. +1. Continue with the refcounted multi-completion request state machine only + after the selected ring backend is packaged and sanitizer-clean. + +The pure-Python/CFFI implementation remains the behavioral oracle throughout +this work. diff --git a/docs/phase1-static-liburing-spike.md b/docs/phase1-static-liburing-spike.md index af0e71b..ff89ea7 100644 --- a/docs/phase1-static-liburing-spike.md +++ b/docs/phase1-static-liburing-spike.md @@ -12,12 +12,14 @@ MIT license, whose notice is included in the package. Like the raw-syscall spike, this module owns ring initialization and teardown but does not submit or reap operations and is not wired into the Python proactor. The source checkout must configure and build the pinned submodule -before building this experimental extension; packaging the vendored sources -for standalone wheel builds remains part of the route decision. +before building this experimental extension. Packaging the vendored sources +for standalone wheel builds remains required follow-up. -The decision record can now compare the two lifecycle implementations using -the same API and tests. Neither spike is the production backend until that -record selects a route. +The two lifecycle implementations use the same API and tests. The resulting +[backend decision](phase1-native-ring-decision.md) selects the statically +linked, vendored liburing route. This spike is not yet the production backend: +the follow-up work must make source builds self-contained, add full sanitizer +coverage, move this implementation to `_uringcore`, and remove the raw spike. On the initial CPython 3.12 x86-64 development build, including debug information, the module sizes are: @@ -27,7 +29,6 @@ information, the module sizes are: | Raw syscalls | 35,152 bytes | | Static liburing | 109,840 bytes | -The static module adds 74,688 bytes in this build. These are spike -measurements rather than release-wheel results; the decision record must -repeat them with the release build and record its compiler and strip -settings. +The static module adds 74,688 bytes in this build. See the backend decision +for compressed wheel-member sizes, stripped sizes, the measurement +environment, and the other selection criteria. From 12e2ebad9bee33fe141f0e22d96cb26effac7640 Mon Sep 17 00:00:00 2001 From: bright Date: Sat, 1 Aug 2026 02:56:05 +0800 Subject: [PATCH 3/6] build: make liburing source builds self-contained --- .github/actions/prepare/action.yml | 28 +--------- MANIFEST.in | 14 +++++ README.md | 11 ++-- _ffi_build.py | 3 -- docs/phase1-native-ring-decision.md | 14 +++-- docs/phase1-static-liburing-spike.md | 14 ++--- setup.py | 80 +++++++++++++++++++++++++++- 7 files changed, 116 insertions(+), 48 deletions(-) diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 081c2a1..b35b96f 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -1,5 +1,5 @@ name: "Prepare Build Environment" -description: "Build/install liburing and install Python deps (repo must already be checked out with submodules)" +description: "Install Python and project dependencies (repo must already be checked out with submodules)" inputs: python-version: description: "Python version to set up" @@ -8,36 +8,10 @@ inputs: runs: using: "composite" steps: - - name: Get liburing commit hash - id: cache-info - shell: bash - run: echo "liburing_hash=$(cd libs && git rev-parse HEAD)" >> $GITHUB_OUTPUT - - - name: Cache liburing build - id: cache-liburing - uses: actions/cache@v4 - with: - path: libs/ - key: ${{ runner.os }}-${{ runner.arch }}-liburing-${{ steps.cache-info.outputs.liburing_hash }} - - uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} - - name: Build liburing (if cache miss) - if: steps.cache-liburing.outputs.cache-hit != 'true' - shell: bash - run: | - cd libs - ./configure - make - - - name: Install liburing (always) - shell: bash - run: | - cd libs - sudo make install - - name: Install UV uses: astral-sh/setup-uv@v5 with: diff --git a/MANIFEST.in b/MANIFEST.in index b55285d..d38aa65 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,20 @@ include _ffi_build.py +include libs/LICENSE +include libs/Makefile.common +include libs/Makefile.quiet +include libs/configure +include libs/liburing.spec +include libs/src/Makefile include src/uringcore_liburing.c include THIRD_PARTY_LICENSES/liburing-MIT.txt +recursive-include libs/src *.c +include libs/src/int_flags.h +include libs/src/lib.h +include libs/src/syscall.h +recursive-include libs/src/arch *.h +include libs/src/include/liburing.h +include libs/src/include/liburing/barrier.h +include libs/src/include/liburing/io_uring.h recursive-include docs *.md recursive-include uringloop *.py *.pyi include uringloop/py.typed diff --git a/README.md b/README.md index 4a6b47e..4a9fbe9 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,9 @@ A Python implementation of a liburing-based proactor event loop for asyncio, des - Python 3.12+ -- liburing development libraries - - ```bash - sudo apt install liburing-dev - ``` +- A C compiler and `make` when installing from source. The pinned liburing + sources are included and built automatically; a system `liburing-dev` + package is not required. ## Installation @@ -39,7 +37,8 @@ uv add uringloop pip install uringloop ``` -Only a source distribution is published, so the CFFI extension is compiled during installation; the liburing development headers (see Requirements) and a C compiler must be present. +Only a source distribution is currently published, so the native extensions +and bundled liburing sources are compiled during installation. ## Quick Start diff --git a/_ffi_build.py b/_ffi_build.py index 7340503..8a9212d 100644 --- a/_ffi_build.py +++ b/_ffi_build.py @@ -23,11 +23,8 @@ "uringloop._liburing", source_code, sources=[], - include_dirs=["./libs/src/include"], define_macros=[("_GNU_SOURCE", "1")], extra_compile_args=["-D_GNU_SOURCE"], - libraries=["uring"], # Link against liburing - library_dirs=["./libs/src"], # Path to the library ) diff --git a/docs/phase1-native-ring-decision.md b/docs/phase1-native-ring-decision.md index c64fcb9..c26c23f 100644 --- a/docs/phase1-native-ring-decision.md +++ b/docs/phase1-native-ring-decision.md @@ -96,7 +96,7 @@ their hot-path cost is zero. | Wheel size | Smaller | About 32 KB more compressed in this comparison build | | Runtime dependency | None beyond libc and the kernel ABI | None beyond libc and the kernel ABI | | Build complexity | Ordinary extension build | Must build and statically link the pinned submodule | -| Source packaging | Self-contained now | Vendored sources are not yet present in the sdist | +| Source packaging | Self-contained | Self-contained through the vendored-source build hook | | License work | No additional bundled-library notice | MIT notice must remain in binary/source distributions | | Sanitizers | Extension flags cover all project-owned ring code | liburing must also be rebuilt with matching sanitizer flags | | Lifecycle syscall count | Identical | Identical | @@ -120,9 +120,6 @@ correctness and maintenance risk removed from the Phase 1 request core. Selecting liburing does not declare the current spike production-ready. The following work blocks that transition: -1. Include the pinned liburing source and required headers in the sdist, and - build the archive as part of a source/wheel build instead of assuming that - `libs/src/liburing.a` already exists. 1. Build liburing itself with ASAN/UBSAN flags in sanitizer jobs, then run the native e2e suite against that instrumented archive. 1. Move the selected implementation behind the canonical `_uringcore` name @@ -134,3 +131,12 @@ following work blocks that transition: The pure-Python/CFFI implementation remains the behavioral oracle throughout this work. + +## Packaging follow-up + +The first required follow-up was completed by teaching `build_ext` to copy, +configure, and compile the pinned liburing sources in its private build +directory. The sdist includes only the source, internal headers, and build +metadata needed for that archive. Both extension modules link the resulting +archive, so a clean source build neither consumes a checkout artifact nor +links a system `liburing.so`. diff --git a/docs/phase1-static-liburing-spike.md b/docs/phase1-static-liburing-spike.md index ff89ea7..1a32d86 100644 --- a/docs/phase1-static-liburing-spike.md +++ b/docs/phase1-static-liburing-spike.md @@ -5,21 +5,23 @@ the roadmap. It mirrors the lifecycle boundary of the raw-syscall `_uringcore.Ring` with a separate `_uringcore_liburing.Ring` implemented through the pinned liburing submodule. -The extension links `libs/src/liburing.a` into the module. It therefore has +The extension links a private archive built from the pinned `libs` sources into +the module. It therefore has no runtime dependency on a system `liburing.so`. liburing is used under its MIT license, whose notice is included in the package. Like the raw-syscall spike, this module owns ring initialization and teardown but does not submit or reap operations and is not wired into the Python -proactor. The source checkout must configure and build the pinned submodule -before building this experimental extension. Packaging the vendored sources -for standalone wheel builds remains required follow-up. +proactor. The build configures and compiles a private archive from the pinned +vendored sources, then links that archive into both the existing CFFI module +and this experimental extension. Source builds therefore do not require a +prebuilt archive or a system `liburing.so`. The two lifecycle implementations use the same API and tests. The resulting [backend decision](phase1-native-ring-decision.md) selects the statically linked, vendored liburing route. This spike is not yet the production backend: -the follow-up work must make source builds self-contained, add full sanitizer -coverage, move this implementation to `_uringcore`, and remove the raw spike. +the follow-up work must add full sanitizer coverage, move this implementation +to `_uringcore`, and remove the raw spike. On the initial CPython 3.12 x86-64 development build, including debug information, the module sizes are: diff --git a/setup.py b/setup.py index fdc9ab3..b1f891a 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,90 @@ +import os +from pathlib import Path +import shutil +import subprocess + from setuptools import Extension, setup +from setuptools.command.build_ext import build_ext + + +ROOT = Path(__file__).resolve().parent +LIBURING_SOURCE = ROOT / "libs" +LIBURING_EXTENSION_NAMES = { + "uringloop._liburing", + "uringloop._uringcore_liburing", +} + + +class VendoredLiburingBuildExt(build_ext): + """Build one private liburing archive for extensions that need it.""" + + def build_extensions(self): + extensions = [extension for extension in self.extensions if extension.name in LIBURING_EXTENSION_NAMES] + if extensions: + archive = self._build_vendored_liburing() + include_directory = archive.parent / "include" + for extension in extensions: + extension.extra_objects = [*(extension.extra_objects or []), str(archive)] + extension.depends = [*(extension.depends or []), str(archive)] + extension.include_dirs = [str(include_directory)] + + super().build_extensions() + + def _build_vendored_liburing(self): + build_root = Path(self.build_temp).resolve() / "vendored-liburing" + if build_root.exists(): + shutil.rmtree(build_root) + + self.announce(f"copying vendored liburing sources to {build_root}", level=2) + shutil.copytree( + LIBURING_SOURCE, + build_root, + ignore=shutil.ignore_patterns( + ".git", + "*.a", + "*.d", + "*.o", + "*.ol", + "*.os", + "*.so", + "*.so.*", + "examples", + "man", + "test", + ), + ) + + environment = os.environ.copy() + compiler_command = getattr(self.compiler, "compiler", None) + if compiler_command: + environment.setdefault("CC", compiler_command[0]) + liburing_cflags = environment.get("LIBURING_CFLAGS", "") + environment["LIBURING_CFLAGS"] = f"{liburing_cflags} -fPIC".strip() + + self.announce("configuring vendored liburing", level=2) + subprocess.run( + ["sh", "configure", "--use-libc"], + cwd=build_root, + env=environment, + check=True, + ) + self.announce("building vendored static liburing", level=2) + subprocess.run( + ["make", "-C", "src", "liburing.a"], + cwd=build_root, + env=environment, + check=True, + ) + return build_root / "src" / "liburing.a" setup( cffi_modules=["_ffi_build.py:ffibuilder"], + cmdclass={"build_ext": VendoredLiburingBuildExt}, ext_modules=[ Extension( "uringloop._uringcore_liburing", sources=["src/uringcore_liburing.c"], - include_dirs=["libs/src/include"], - extra_objects=["libs/src/liburing.a"], ), ], ) From ff78ee89f3c45d98b25267e503ee75f3878c0b1f Mon Sep 17 00:00:00 2001 From: bright Date: Sat, 1 Aug 2026 03:16:46 +0800 Subject: [PATCH 4/6] build: keep raw syscall spike separate --- docs/phase1-native-ring-decision.md | 11 ++++++----- docs/phase1-static-liburing-spike.md | 4 +++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/phase1-native-ring-decision.md b/docs/phase1-native-ring-decision.md index c26c23f..19a5d6a 100644 --- a/docs/phase1-native-ring-decision.md +++ b/docs/phase1-native-ring-decision.md @@ -10,10 +10,10 @@ ring and link it statically into the extension. The production module will use the canonical `_uringcore` name; `_uringcore_liburing` is only the comparison-spike name. -The raw-syscall implementation will remain available only until a follow-up -change moves the selected implementation to `_uringcore` and removes the two -experimental backends. The project will not maintain both implementations in -production. +The raw-syscall spike is preserved on the +`feature/raw-syscall-ring-spike` branch for future reference, but it is not +built or packaged by the selected implementation branch. The project will not +maintain both implementations in production. ## Context @@ -123,7 +123,8 @@ following work blocks that transition: 1. Build liburing itself with ASAN/UBSAN flags in sanitizer jobs, then run the native e2e suite against that instrumented archive. 1. Move the selected implementation behind the canonical `_uringcore` name - and remove the raw backend, duplicate stub, tests, and build configuration. + and replace the comparison-spike module name, stub, tests, and build + configuration. 1. Implement and benchmark the native prepare/submit/reap batch boundary before making claims about syscall or CPU improvements. 1. Continue with the refcounted multi-completion request state machine only diff --git a/docs/phase1-static-liburing-spike.md b/docs/phase1-static-liburing-spike.md index 1a32d86..a6494bc 100644 --- a/docs/phase1-static-liburing-spike.md +++ b/docs/phase1-static-liburing-spike.md @@ -21,7 +21,9 @@ The two lifecycle implementations use the same API and tests. The resulting [backend decision](phase1-native-ring-decision.md) selects the statically linked, vendored liburing route. This spike is not yet the production backend: the follow-up work must add full sanitizer coverage, move this implementation -to `_uringcore`, and remove the raw spike. +to `_uringcore`, and replace the comparison-spike module name. The raw spike is +preserved separately on `feature/raw-syscall-ring-spike`; it is not built or +packaged by this branch. On the initial CPython 3.12 x86-64 development build, including debug information, the module sizes are: From b2ce17b4932afbf04e83a15fe804a2d3ccd42621 Mon Sep 17 00:00:00 2001 From: bright Date: Sat, 1 Aug 2026 11:05:03 +0800 Subject: [PATCH 5/6] ci: build Linux wheel matrix --- .github/workflows/publish.yml | 82 ++++++++++++++++++++++++++++++++--- scripts/verify_wheel.py | 43 ++++++++++++++++++ 2 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 scripts/verify_wheel.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dcad568..3beeb18 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,14 +1,17 @@ -name: Upload Package +name: Build and upload package on: + pull_request: release: types: [published] + workflow_dispatch: permissions: contents: read jobs: - release-build: + build-sdist: + name: Build source distribution runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -20,9 +23,75 @@ jobs: - name: Prepare environment uses: ./.github/actions/prepare - - name: Build release distributions - run: | - uv build -v --sdist + - name: Build source distribution + run: uv build -v --sdist + + - name: Check source distribution + run: uv run twine check dist/* + + - name: Upload source distribution + uses: actions/upload-artifact@v4 + with: + name: distribution-sdist + path: dist/*.tar.gz + if-no-files-found: error + + build-wheel: + name: Build ${{ matrix.build }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + build: + - cp312-manylinux_x86_64 + - cp313-manylinux_x86_64 + - cp312-musllinux_x86_64 + - cp313-musllinux_x86_64 + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build and test wheel + uses: pypa/cibuildwheel@v3.4.1 + with: + package-dir: . + output-dir: wheelhouse + env: + CIBW_BUILD: ${{ matrix.build }} + CIBW_BUILD_VERBOSITY: "1" + CIBW_TEST_COMMAND: python {project}/scripts/verify_wheel.py + + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: distribution-${{ matrix.build }} + path: wheelhouse/*.whl + if-no-files-found: error + + publish: + name: Upload distributions to PyPI + if: github.event_name == 'release' + needs: [build-sdist, build-wheel] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Prepare environment + uses: ./.github/actions/prepare + + - name: Download distributions + uses: actions/download-artifact@v4 + with: + pattern: distribution-* + path: dist + merge-multiple: true # TODO: switch to PyPI Trusted Publishing (OIDC) and drop the token: # https://docs.pypi.org/trusted-publishers/ @@ -30,5 +99,4 @@ jobs: env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: | - uv run twine upload --verbose --repository pypi dist/* + run: uv run twine upload --verbose --repository pypi dist/* diff --git a/scripts/verify_wheel.py b/scripts/verify_wheel.py new file mode 100644 index 0000000..6f31fa3 --- /dev/null +++ b/scripts/verify_wheel.py @@ -0,0 +1,43 @@ +"""Verify that an installed wheel is self-contained and importable.""" + +from importlib import import_module +from pathlib import Path +import shutil +import subprocess + + +EXTENSION_MODULES = ( + "uringloop._liburing", + "uringloop._uringcore_liburing", +) + + +def dynamic_section(module_name: str) -> str: + module = import_module(module_name) + module_file = getattr(module, "__file__", None) + if module_file is None: + raise RuntimeError(f"{module_name} does not have an extension module path") + + readelf = shutil.which("readelf") + if readelf is None: + raise RuntimeError("readelf is required to verify wheel dependencies") + + result = subprocess.run( + [readelf, "-d", Path(module_file)], + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def main() -> None: + for module_name in EXTENSION_MODULES: + dependencies = dynamic_section(module_name) + if "liburing.so" in dependencies: + raise RuntimeError(f"{module_name} dynamically links liburing") + print(f"verified {module_name}") + + +if __name__ == "__main__": + main() From 885b880404291f8d72a99e980f73b69e6b587f75 Mon Sep 17 00:00:00 2001 From: bright Date: Sat, 1 Aug 2026 16:27:57 +0800 Subject: [PATCH 6/6] ci: separate distribution checks from publishing --- .github/actions/build-distribution/action.yml | 54 +++++++++++++++++++ .github/workflows/ci.yml | 41 ++++++++++++++ .github/workflows/publish.yml | 27 +++------- 3 files changed, 103 insertions(+), 19 deletions(-) create mode 100644 .github/actions/build-distribution/action.yml diff --git a/.github/actions/build-distribution/action.yml b/.github/actions/build-distribution/action.yml new file mode 100644 index 0000000..4529cee --- /dev/null +++ b/.github/actions/build-distribution/action.yml @@ -0,0 +1,54 @@ +name: Build distribution +description: Build and validate one source distribution or wheel target + +inputs: + kind: + description: Distribution kind to build (sdist or wheel) + required: true + wheel-build: + description: cibuildwheel build selector used when kind is wheel + required: false + default: "" + +runs: + using: composite + steps: + - name: Validate build selection + shell: bash + env: + DISTRIBUTION_KIND: ${{ inputs.kind }} + WHEEL_BUILD: ${{ inputs.wheel-build }} + run: | + if [[ "$DISTRIBUTION_KIND" != "sdist" && "$DISTRIBUTION_KIND" != "wheel" ]]; then + echo "::error::kind must be 'sdist' or 'wheel'" + exit 1 + fi + if [[ "$DISTRIBUTION_KIND" == "wheel" && -z "$WHEEL_BUILD" ]]; then + echo "::error::wheel-build is required when kind is 'wheel'" + exit 1 + fi + + - name: Prepare source distribution environment + if: ${{ inputs.kind == 'sdist' }} + uses: ./.github/actions/prepare + + - name: Build source distribution + if: ${{ inputs.kind == 'sdist' }} + shell: bash + run: uv build -v --sdist + + - name: Check source distribution + if: ${{ inputs.kind == 'sdist' }} + shell: bash + run: uv run twine check dist/* + + - name: Build and test wheel + if: ${{ inputs.kind == 'wheel' }} + uses: pypa/cibuildwheel@v3.4.1 + with: + package-dir: . + output-dir: wheelhouse + env: + CIBW_BUILD: ${{ inputs.wheel-build }} + CIBW_BUILD_VERBOSITY: "1" + CIBW_TEST_COMMAND: python {project}/scripts/verify_wheel.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f98339..936d3e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,44 @@ jobs: - name: Run tests run: uv run pytest tests/ -v + + check-sdist: + name: Check source distribution + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build source distribution + uses: ./.github/actions/build-distribution + with: + kind: sdist + + check-wheel: + name: Check ${{ matrix.build }} + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + build: + - cp312-manylinux_x86_64 + - cp313-manylinux_x86_64 + - cp312-musllinux_x86_64 + - cp313-musllinux_x86_64 + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build wheel + uses: ./.github/actions/build-distribution + with: + kind: wheel + wheel-build: ${{ matrix.build }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3beeb18..d5bf64a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,10 +1,8 @@ -name: Build and upload package +name: Publish package on: - pull_request: release: types: [published] - workflow_dispatch: permissions: contents: read @@ -20,14 +18,10 @@ jobs: with: submodules: recursive - - name: Prepare environment - uses: ./.github/actions/prepare - - name: Build source distribution - run: uv build -v --sdist - - - name: Check source distribution - run: uv run twine check dist/* + uses: ./.github/actions/build-distribution + with: + kind: sdist - name: Upload source distribution uses: actions/upload-artifact@v4 @@ -54,15 +48,11 @@ jobs: with: submodules: recursive - - name: Build and test wheel - uses: pypa/cibuildwheel@v3.4.1 + - name: Build wheel + uses: ./.github/actions/build-distribution with: - package-dir: . - output-dir: wheelhouse - env: - CIBW_BUILD: ${{ matrix.build }} - CIBW_BUILD_VERBOSITY: "1" - CIBW_TEST_COMMAND: python {project}/scripts/verify_wheel.py + kind: wheel + wheel-build: ${{ matrix.build }} - name: Upload wheel uses: actions/upload-artifact@v4 @@ -73,7 +63,6 @@ jobs: publish: name: Upload distributions to PyPI - if: github.event_name == 'release' needs: [build-sdist, build-wheel] runs-on: ubuntu-latest timeout-minutes: 15