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/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/.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 dcad568..d5bf64a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Upload Package +name: Publish package on: release: @@ -8,7 +8,62 @@ permissions: contents: read jobs: - release-build: + build-sdist: + name: Build source distribution + 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 + + - 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 wheel + uses: ./.github/actions/build-distribution + with: + kind: wheel + wheel-build: ${{ matrix.build }} + + - 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 + needs: [build-sdist, build-wheel] runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -20,9 +75,12 @@ jobs: - name: Prepare environment uses: ./.github/actions/prepare - - name: Build release distributions - run: | - uv build -v --sdist + - 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 +88,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/.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..d38aa65 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,21 @@ 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 exclude uringloop/_liburing.c 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/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/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/_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 new file mode 100644 index 0000000..19a5d6a --- /dev/null +++ b/docs/phase1-native-ring-decision.md @@ -0,0 +1,143 @@ +# 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 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 + +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 | 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 | +| 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. 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 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 + after the selected ring backend is packaged and sanitizer-clean. + +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 new file mode 100644 index 0000000..a6494bc --- /dev/null +++ b/docs/phase1-static-liburing-spike.md @@ -0,0 +1,38 @@ +# 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 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 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 add full sanitizer coverage, move this implementation +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: + +| Route | Extension size | +| --- | ---: | +| Raw syscalls | 35,152 bytes | +| Static liburing | 109,840 bytes | + +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. 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/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() diff --git a/setup.py b/setup.py index 1a8ce9f..b1f891a 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,90 @@ -from setuptools import setup +import os +from pathlib import Path +import shutil +import subprocess +from setuptools import Extension, setup +from setuptools.command.build_ext import build_ext -setup(cffi_modules=["_ffi_build.py:ffibuilder"]) + +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"], + ), + ], +) 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]: ...