From dd17080ffbc4cec3654c5493763f0e2c2f245ac0 Mon Sep 17 00:00:00 2001 From: bright Date: Thu, 30 Jul 2026 00:59:30 +0800 Subject: [PATCH 1/2] spike raw-syscall native ring lifecycle --- .gitignore | 1 + MANIFEST.in | 2 + docs/phase1-raw-syscall-spike.md | 22 +++ setup.py | 12 +- src/uringcore.c | 322 +++++++++++++++++++++++++++++++ tests/unit/test_uringcore.py | 35 ++++ uringloop/_uringcore.pyi | 22 +++ 7 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 docs/phase1-raw-syscall-spike.md create mode 100644 src/uringcore.c create mode 100644 tests/unit/test_uringcore.py create mode 100644 uringloop/_uringcore.pyi diff --git a/.gitignore b/.gitignore index d876fab..ec44c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ wheels/ _liburing.c _liburing.o _liburing.*.so +_uringcore.*.so diff --git a/MANIFEST.in b/MANIFEST.in index da7c1d5..f0f5dda 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,6 @@ include _ffi_build.py +include src/uringcore.c +recursive-include docs *.md recursive-include uringloop *.py *.pyi include uringloop/py.typed exclude uringloop/_liburing.c diff --git a/docs/phase1-raw-syscall-spike.md b/docs/phase1-raw-syscall-spike.md new file mode 100644 index 0000000..0b86e5b --- /dev/null +++ b/docs/phase1-raw-syscall-spike.md @@ -0,0 +1,22 @@ +# Phase 1 raw-syscall ring spike + +This is the first of the two native ring implementation spikes required by +Phase 1 of the roadmap. It establishes an importable `_uringcore` extension +whose `Ring` type owns: + +- the file descriptor returned by `io_uring_setup`; +- the submission and completion queue mappings; and +- the submission queue entry mapping. + +`Ring.close()` releases those resources deterministically and is idempotent. +Deallocation provides the same cleanup as a fallback. + +The spike intentionally does not submit or reap operations and is not wired +into the Python proactor. The CFFI implementation remains the behavioral +oracle while the native API is developed. + +The extension calls the kernel ABI directly and does not link to liburing. +The follow-up static-liburing spike should implement the same lifecycle +boundary. A decision record can then compare measured wheel size, build and +sanitizer complexity, syscall overhead, license obligations, and unsafe +surface before either route becomes the production backend. diff --git a/setup.py b/setup.py index 1a8ce9f..0d83377 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,12 @@ -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", + sources=["src/uringcore.c"], + ) + ], +) diff --git a/src/uringcore.c b/src/uringcore.c new file mode 100644 index 0000000..68714f9 --- /dev/null +++ b/src/uringcore.c @@ -0,0 +1,322 @@ +#define PY_SSIZE_T_CLEAN +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct { + PyObject_HEAD + int ring_fd; + void *sq_ring; + size_t sq_ring_size; + void *cq_ring; + size_t cq_ring_size; + void *sqes; + size_t sqes_size; + unsigned int sq_entries; + unsigned int cq_entries; + unsigned int features; +} UringCoreRing; + +static void +uringcore_ring_close_resources(UringCoreRing *self) +{ + if (self->sqes != NULL) { + munmap(self->sqes, self->sqes_size); + self->sqes = NULL; + self->sqes_size = 0; + } + + if (self->cq_ring != NULL) { + munmap(self->cq_ring, self->cq_ring_size); + self->cq_ring = NULL; + self->cq_ring_size = 0; + } + + if (self->sq_ring != NULL) { + munmap(self->sq_ring, self->sq_ring_size); + self->sq_ring = NULL; + self->sq_ring_size = 0; + } + + if (self->ring_fd >= 0) { + close(self->ring_fd); + self->ring_fd = -1; + } +} + +static PyObject * +uringcore_ring_new( + PyTypeObject *type, + PyObject *Py_UNUSED(args), + PyObject *Py_UNUSED(kwargs)) +{ + UringCoreRing *self = (UringCoreRing *)type->tp_alloc(type, 0); + + if (self != NULL) { + self->ring_fd = -1; + } + return (PyObject *)self; +} + +static int +uringcore_ring_init(UringCoreRing *self, PyObject *args, PyObject *kwargs) +{ + static char *keyword_names[] = {"entries", NULL}; + unsigned int entries = 256; + struct io_uring_params params; + size_t sq_ring_size; + size_t cq_ring_size; + + if (!PyArg_ParseTupleAndKeywords( + args, kwargs, "|I:Ring", keyword_names, &entries)) { + return -1; + } + if (entries == 0) { + PyErr_SetString(PyExc_ValueError, "entries must be greater than zero"); + return -1; + } + + uringcore_ring_close_resources(self); + memset(¶ms, 0, sizeof(params)); + + self->ring_fd = (int)syscall(__NR_io_uring_setup, entries, ¶ms); + if (self->ring_fd < 0) { + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + + sq_ring_size = + params.sq_off.array + params.sq_entries * sizeof(unsigned int); + cq_ring_size = + params.cq_off.cqes + params.cq_entries * sizeof(struct io_uring_cqe); + + if ((params.features & IORING_FEAT_SINGLE_MMAP) != 0) { + self->sq_ring_size = + sq_ring_size > cq_ring_size ? sq_ring_size : cq_ring_size; + self->sq_ring = mmap( + NULL, + self->sq_ring_size, + PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_POPULATE, + self->ring_fd, + IORING_OFF_SQ_RING); + if (self->sq_ring == MAP_FAILED) { + self->sq_ring = NULL; + PyErr_SetFromErrno(PyExc_OSError); + goto error; + } + } else { + self->sq_ring_size = sq_ring_size; + self->sq_ring = mmap( + NULL, + self->sq_ring_size, + PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_POPULATE, + self->ring_fd, + IORING_OFF_SQ_RING); + if (self->sq_ring == MAP_FAILED) { + self->sq_ring = NULL; + PyErr_SetFromErrno(PyExc_OSError); + goto error; + } + + self->cq_ring_size = cq_ring_size; + self->cq_ring = mmap( + NULL, + self->cq_ring_size, + PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_POPULATE, + self->ring_fd, + IORING_OFF_CQ_RING); + if (self->cq_ring == MAP_FAILED) { + self->cq_ring = NULL; + PyErr_SetFromErrno(PyExc_OSError); + goto error; + } + } + + self->sqes_size = + params.sq_entries * sizeof(struct io_uring_sqe); + self->sqes = mmap( + NULL, + self->sqes_size, + PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_POPULATE, + self->ring_fd, + IORING_OFF_SQES); + if (self->sqes == MAP_FAILED) { + self->sqes = NULL; + PyErr_SetFromErrno(PyExc_OSError); + goto error; + } + + self->sq_entries = params.sq_entries; + self->cq_entries = params.cq_entries; + self->features = params.features; + return 0; + +error: + uringcore_ring_close_resources(self); + return -1; +} + +static void +uringcore_ring_dealloc(UringCoreRing *self) +{ + uringcore_ring_close_resources(self); + Py_TYPE(self)->tp_free((PyObject *)self); +} + +static PyObject * +uringcore_ring_close(UringCoreRing *self, PyObject *Py_UNUSED(ignored)) +{ + uringcore_ring_close_resources(self); + Py_RETURN_NONE; +} + +static PyObject * +uringcore_ring_enter(UringCoreRing *self, PyObject *Py_UNUSED(ignored)) +{ + if (self->ring_fd < 0) { + PyErr_SetString(PyExc_RuntimeError, "ring is closed"); + return NULL; + } + return Py_NewRef(self); +} + +static PyObject * +uringcore_ring_exit( + UringCoreRing *self, + PyObject *Py_UNUSED(args)) +{ + uringcore_ring_close_resources(self); + Py_RETURN_FALSE; +} + +static PyObject * +uringcore_ring_get_closed( + UringCoreRing *self, + void *Py_UNUSED(context)) +{ + return PyBool_FromLong(self->ring_fd < 0); +} + +static PyMethodDef uringcore_ring_methods[] = { + { + "close", + (PyCFunction)uringcore_ring_close, + METH_NOARGS, + PyDoc_STR("Release the ring mappings and file descriptor."), + }, + { + "__enter__", + (PyCFunction)uringcore_ring_enter, + METH_NOARGS, + NULL, + }, + { + "__exit__", + (PyCFunction)uringcore_ring_exit, + METH_VARARGS, + NULL, + }, + {NULL, NULL, 0, NULL}, +}; + +static PyMemberDef uringcore_ring_members[] = { + { + "sq_entries", + T_UINT, + offsetof(UringCoreRing, sq_entries), + READONLY, + PyDoc_STR("Number of submission queue entries allocated by the kernel."), + }, + { + "cq_entries", + T_UINT, + offsetof(UringCoreRing, cq_entries), + READONLY, + PyDoc_STR("Number of completion queue entries allocated by the kernel."), + }, + { + "features", + T_UINT, + offsetof(UringCoreRing, features), + READONLY, + PyDoc_STR("Feature flags returned by io_uring_setup."), + }, + {NULL}, +}; + +static PyGetSetDef uringcore_ring_getset[] = { + { + "closed", + (getter)uringcore_ring_get_closed, + NULL, + PyDoc_STR("Whether the native ring resources have been released."), + NULL, + }, + {NULL}, +}; + +PyDoc_STRVAR( + uringcore_ring_doc, + "Ring(entries=256)\n" + "--\n" + "\n" + "Own an io_uring file descriptor and its SQ, CQ, and SQE mappings."); + +static PyTypeObject UringCoreRingType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "uringloop._uringcore.Ring", + .tp_basicsize = sizeof(UringCoreRing), + .tp_dealloc = (destructor)uringcore_ring_dealloc, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = uringcore_ring_doc, + .tp_methods = uringcore_ring_methods, + .tp_members = uringcore_ring_members, + .tp_getset = uringcore_ring_getset, + .tp_init = (initproc)uringcore_ring_init, + .tp_new = uringcore_ring_new, +}; + +static PyModuleDef uringcore_module = { + PyModuleDef_HEAD_INIT, + .m_name = "_uringcore", + .m_doc = "Native io_uring ring primitives.", + .m_size = -1, +}; + +PyMODINIT_FUNC +PyInit__uringcore(void) +{ + PyObject *module; + + if (PyType_Ready(&UringCoreRingType) < 0) { + return NULL; + } + + module = PyModule_Create(&uringcore_module); + if (module == NULL) { + return NULL; + } + + if (PyModule_AddObjectRef( + module, "Ring", (PyObject *)&UringCoreRingType) < 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.py b/tests/unit/test_uringcore.py new file mode 100644 index 0000000..a355333 --- /dev/null +++ b/tests/unit/test_uringcore.py @@ -0,0 +1,35 @@ +import pytest + +from uringloop import _uringcore + + +def test_native_core_has_versioned_abi(): + assert _uringcore.ABI_VERSION == 1 + + +def test_ring_rejects_empty_queue(): + with pytest.raises(ValueError, match="entries must be greater than zero"): + _uringcore.Ring(0) + + +def test_ring_owns_and_releases_kernel_resources(): + ring = _uringcore.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_ring_context_manager_closes_resources(): + with _uringcore.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.pyi b/uringloop/_uringcore.pyi new file mode 100644 index 0000000..959461d --- /dev/null +++ b/uringloop/_uringcore.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 88d87d72c3713668a8cef3a8d3d8fcf57379112f Mon Sep 17 00:00:00 2001 From: bright Date: Thu, 30 Jul 2026 01:11:56 +0800 Subject: [PATCH 2/2] validate native ring queue size --- src/uringcore.c | 34 ++++++++++++++++++++++++++++------ tests/unit/test_uringcore.py | 7 ++++--- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/uringcore.c b/src/uringcore.c index 68714f9..37553ec 100644 --- a/src/uringcore.c +++ b/src/uringcore.c @@ -2,10 +2,9 @@ #include #include -#include +#include #include #include -#include #include #include #include @@ -70,18 +69,41 @@ static int uringcore_ring_init(UringCoreRing *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; size_t sq_ring_size; size_t cq_ring_size; if (!PyArg_ParseTupleAndKeywords( - args, kwargs, "|I:Ring", keyword_names, &entries)) { + args, kwargs, "|O:Ring", keyword_names, &entries_object)) { return -1; } - if (entries == 0) { - PyErr_SetString(PyExc_ValueError, "entries must be greater than zero"); - 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_ring_close_resources(self); diff --git a/tests/unit/test_uringcore.py b/tests/unit/test_uringcore.py index a355333..313554b 100644 --- a/tests/unit/test_uringcore.py +++ b/tests/unit/test_uringcore.py @@ -7,9 +7,10 @@ def test_native_core_has_versioned_abi(): assert _uringcore.ABI_VERSION == 1 -def test_ring_rejects_empty_queue(): - with pytest.raises(ValueError, match="entries must be greater than zero"): - _uringcore.Ring(0) +@pytest.mark.parametrize("entries", [-(2**32), -1, 0, 2**32, 2**64]) +def test_ring_rejects_queue_size_outside_unsigned_int_range(entries): + with pytest.raises(ValueError, match="entries must be between"): + _uringcore.Ring(entries) def test_ring_owns_and_releases_kernel_resources():