Skip to content

fs: implement io_uring async I/O interface - #1401

Closed
gburd wants to merge 4 commits into
cloudius-systems:masterfrom
gburd:pr/io-uring-clean
Closed

fs: implement io_uring async I/O interface#1401
gburd wants to merge 4 commits into
cloudius-systems:masterfrom
gburd:pr/io-uring-clean

Conversation

@gburd

@gburd gburd commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Implement the Linux io_uring(7) async I/O interface on OSv primitives.

  • ABI follows Linux 5.15: SQ/CQ ring layout, io_uring_params, and the
    three syscalls io_uring_setup/io_uring_enter/io_uring_register
    (425/426/427). SQ/CQ rings are mmap'd into user memory.
  • Built on existing OSv primitives (sched::thread, mutex, condvar);
    submissions run on the existing async I/O path.
  • Supported opcodes: NOP, READ, WRITE, READV, WRITEV, FSYNC, OPENAT,
    CLOSE, POLL_ADD, POLL_REMOVE, TIMEOUT, ACCEPT, CONNECT, RECV, SEND.
  • SQ poll thread (IORING_SETUP_SQPOLL) for kernel-side submission,
    linked SQEs (IOSQE_IO_LINK), and drain barriers (IOSQE_IO_DRAIN).
  • Syscall registration (linux.cc, syscalls.cc.in) is arch-neutral.
    aarch64 already defines NR_io_uring* and the SYS* aliases in
    include/api/aarch64/bits/syscall.h; this brings x64's
    include/api/x64/bits/syscall.h to parity, so io_uring works on both
    arches.

Tests: tst-io_uring.cc, 14 sub-tests covering each opcode, link
semantics, drain ordering, and SQ-poll mode. Registered in
modules/tests/Makefile and zfs-tools/usr.manifest.

This is standalone: three syscalls, nothing else depends on it. Most
of the implementation was done with Claude assistance, then reviewed
and tested against the ring ABI.

Note: OSv's io_uring is not exercised by OpenJDK 21 virtual threads;
JDK 21's virtual-thread scheduler still uses epoll on Linux rather
than io_uring.

Build-qualified (kernel compile+link, image=empty) on a binutils 2.44
/ g++ 14.3.0 host.

@nyh

nyh commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

The commit message says "Note: OSv's io_uring is not used by OpenJDK 21 virtual threads
(JDK 21 still goes through epoll); see the cover letter for details." but I don't see any details in the cover letter.

Also, for single-patch merges, we often not push the cover letter at all (we just push a single patch), so for single-patch pull requests please keep the cover letter and the single patch's commit message identical. Thanks.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an OSv implementation of the Linux io_uring interface, including the io_uring_setup/io_uring_enter/io_uring_register syscalls, the userspace ABI header, kernel backing logic (ring mmap + SQE execution), and a new self-test wired into the test build.

Changes:

  • Add fs/io_uring.cc implementing the io_uring fd, ring mmap, submission dispatch, and register operations.
  • Expose the syscalls and tracepoints (syscalls/*.in, linux.cc) and define x64 syscall numbers (include/api/x64/bits/syscall.h).
  • Add and build the tst-io_uring test (tests/tst-io_uring.cc, test Makefiles).

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
tests/tst-io_uring.cc New io_uring syscall + ring I/O self-test
syscalls/syscalls.cc.in Add syscall wrappers for sys_io_uring_*
syscalls/syscall_tracepoints.cc.in Add syscall tracepoints for sys_io_uring_*
modules/tests/Makefile Build tst-io_uring.so as part of test module
Makefile Link fs/io_uring.o; include test artifact on aarch64 tooling
linux.cc Register/declare io_uring syscalls for Linux compatibility layer
include/osv/io_uring.h New public ABI header for io_uring structs/constants
include/api/x64/bits/syscall.h Define x64 SYS_* / __NR_* values for io_uring
fs/io_uring.cc Core io_uring implementation (fd + mmap + execution + register)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/tst-io_uring.cc
Comment on lines +14 to +18
#include <errno.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <assert.h>
Comment thread tests/tst-io_uring.cc Outdated
Comment on lines +154 to +157
/* Call io_uring_enter with no submissions
* Note: This tests the syscall interface, but actual I/O requires
* completing the mmap implementation for SQE submission.
*/
Comment thread tests/tst-io_uring.cc
Comment on lines +200 to +205
/* Create temporary files */
int test_fd1 = open("/tmp/io_uring_test1.txt", O_RDWR | O_CREAT, 0644);
assert(test_fd1 >= 0);

int test_fd2 = open("/tmp/io_uring_test2.txt", O_RDWR | O_CREAT, 0644);
assert(test_fd2 >= 0);
Comment thread fs/io_uring.cc Outdated
Comment on lines +1379 to +1400
/* Round up to next power of two */
entries = 1U << (32 - __builtin_clz(entries - 1 > 0 ? entries - 1 : 1));
if (entries > MAX_ENTRIES && (params->flags & IORING_SETUP_CLAMP))
entries = MAX_ENTRIES;

auto *ctx = new io_uring_ctx;

ctx->sq.entries = entries;
ctx->sq.mask = entries - 1;
ctx->sq.head = 0;
ctx->sq.tail = 0;
ctx->sq_ring = nullptr;

uint32_t cq_entries = entries * 2;
if (params->flags & IORING_SETUP_CQSIZE) {
cq_entries = params->cq_entries;
if (cq_entries < entries || cq_entries > MAX_ENTRIES * 2) {
delete ctx;
return -EINVAL;
}
cq_entries = 1U << (32 - __builtin_clz(cq_entries - 1 > 0 ? cq_entries - 1 : 1));
}
Comment thread fs/io_uring.cc Outdated
Comment on lines +1489 to +1491
struct file *fp;
if (fget(fd, &fp) != 0)
return -errno;
Comment thread fs/io_uring.cc Outdated
Comment on lines +1536 to +1538
struct file *fp;
if (fget(fd, &fp) != 0)
return -errno;
Comment thread fs/io_uring.cc
Comment on lines +1234 to +1244
size_t size = range.end() - range.start();

if (offset == IORING_OFF_SQ_RING) {
size_t need = sizeof(struct io_uring_sq_ring) +
_ctx->sq.entries * sizeof(uint32_t);
if (size < need)
throw make_error(EINVAL);

if (!_ctx->sq_ring) {
size_t alloc = align_up(need, mmu::page_size);
_ctx->sq_ring = memory::alloc_phys_contiguous_aligned(alloc, mmu::page_size);
Comment thread fs/io_uring.cc
Comment on lines +1261 to +1269
size_t need = sizeof(struct io_uring_cq_ring) +
_ctx->cq.entries * sizeof(struct io_uring_cqe);
if (size < need)
throw make_error(EINVAL);

if (!_ctx->cq_ring) {
size_t alloc = align_up(need, mmu::page_size);
_ctx->cq_ring = memory::alloc_phys_contiguous_aligned(alloc, mmu::page_size);
if (!_ctx->cq_ring)
Comment thread fs/io_uring.cc
Comment on lines +1286 to +1288
size_t need = _ctx->sq.entries * sizeof(struct io_uring_sqe);
if (size < need)
throw make_error(EINVAL);

@nyh nyh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impressive patch. I (and the AI) left a few relatively minor comments and requests.

This is a very big patch and I know very little about the details so I couldn't review it in detail, so again I have to trust that you actually use or will use the new feature and discover (and fix) its kinks yourself. Please just make sure (as I wrote in one of my comments) that the test you wrote runs also on Linux, so the behavior we test for is real, correct, behavior.

Comment thread Makefile Outdated
# to the bootfs.manifest.skel atm to get it to work.
#
tools += tests/tst-hello.so
tools += tests/tst-io_uring.so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not where we generally add a test to the build, the right place is modules/tests/Makefile (yes, this is probably not as obvious or friendly as it should be...)

Comment thread Makefile Outdated
fs_objs += sysfs/sysfs_vnops.o
endif
fs_objs += zfs/zfs_null_vfsops.o
fs_objs += io_uring.o

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think io_uring isn't really a type of filesystem, so fs/ may not be the best place for it.

Comment thread fs/io_uring.cc Outdated

if (ctx->cq_ring) {
auto *cq = static_cast<struct io_uring_cq_ring *>(ctx->cq_ring);
head = __atomic_load_n(&cq->head, __ATOMIC_ACQUIRE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: we generally use C++11's std::atomic instead of these nonstandard gcc extensions.

Comment thread tests/tst-io_uring.cc
@gburd
gburd force-pushed the pr/io-uring-clean branch from 32d5286 to fef25d7 Compare June 30, 2026 11:23
@gburd

gburd commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — fixed. I dropped the dangling "see the cover letter for details" reference and rewrote the commit message to be fully self-contained, since a single-patch PR squashes down to just this commit message. The PR description now matches the commit body verbatim.

gburd added 3 commits June 30, 2026 08:54
Implement the Linux io_uring(7) async I/O interface on OSv primitives.

- ABI follows Linux 5.15: SQ/CQ ring layout, io_uring_params, and the
  three syscalls io_uring_setup/io_uring_enter/io_uring_register
  (425/426/427).  SQ/CQ rings are mmap'd into user memory.  The shared
  head/tail/flags/overflow ring fields are typed std::atomic<uint32_t>
  (lock-free and layout-compatible with uint32_t on every OSv target),
  so the mmap'd layout and the offsets in io_uring_params are unchanged.
- Built on existing OSv primitives (sched::thread, mutex, condvar);
  submissions run on the existing async I/O path.
- Supported opcodes: NOP, READ, WRITE, READV, WRITEV, FSYNC, OPENAT,
  CLOSE, POLL_ADD, POLL_REMOVE, TIMEOUT, ACCEPT, CONNECT, RECV, SEND.
- SQ poll thread (IORING_SETUP_SQPOLL) for kernel-side submission,
  linked SQEs (IOSQE_IO_LINK), and drain barriers (IOSQE_IO_DRAIN).
- Syscall registration (linux.cc, syscalls.cc.in) is arch-neutral.
  aarch64 already defines __NR_io_uring_* and the SYS_* aliases in
  include/api/aarch64/bits/syscall.h; this brings x64's
  include/api/x64/bits/syscall.h to parity, so io_uring works on both
  arches.

io_uring is not a filesystem, so the implementation lives in core/
(core/io_uring.o) rather than fs/.

Tests: tst-io_uring.cc, 14 sub-tests covering each opcode, link
semantics, drain ordering, and SQ-poll mode.  Registered in
modules/tests/Makefile.

This is standalone: three syscalls, nothing else depends on it.  Most
of the implementation was done with Claude assistance, then reviewed
and tested against the ring ABI.

Note: OSv's io_uring is not exercised by OpenJDK 21 virtual threads;
JDK 21's virtual-thread scheduler still uses epoll on Linux rather
than io_uring.

Build-qualified on a g++ 14.3.0 host: kernel compile+link and a full
fs=zfs image build, with tst-io_uring.so passing all 14 sub-tests.
The io_uring_setup/enter/register entry points returned a negative errno
directly (Linux kernel style) without setting errno. syscall_wrapper() in
linux.cc reconstructs the syscall return as -errno, so a -EINVAL with stale
errno (~0) produced a false success of 0.

liburing's io_uring_queue_init_mem() relies on a genuine -EINVAL to fall back
from the NO_MMAP|NO_SQARRAY ring layout; the false 0 defeated that fallback and
left PostgreSQL committed to an unsupported layout, panicking with a full
submission queue on the first AIO write.

Split each syscall into an _impl() that returns negative-errno directly (so the
tst-io_uring self-tests that assert fd == -EINVAL/-EFAULT still pass) and a
sys_io_uring_*() wrapper that additionally sets errno for the liburing
inline-asm syscall path.
…eSQL

PostgreSQL's io_uring AIO path calls several Linux libc entry points that musl
declares but does not implement, and that OSv had not yet provided. A missing
non-weak symbol aborts the ELF loader at the first call site (e.g. the first
CHECKPOINT), never reaching PG's graceful ENOSYS handling.

- sync_file_range(): map to fsync() (whole-file flush is stronger than the
  requested byte-range hint, hence always correct). OSv has no separate
  writeback path to honour the range/flags.
- syncfs(): map to sys_sync(); OSv has a single global buffer cache with no
  per-mount writeback, so a full sync is correct.
- posix_fallocate(): emulate via fstat + ftruncate to grow the file; return the
  errno-style status code the POSIX API specifies.

Export sync_file_range and syncfs from osv_libc.so.6.symbols so the loader
resolves them.
@gburd
gburd force-pushed the pr/io-uring-clean branch from fef25d7 to 2c41242 Compare June 30, 2026 20:41
@gburd

gburd commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All the points from you and Copilot are addressed in the current head (2c412421):

  • fs/ placement (you): io_uring isn't a filesystem, so the implementation moved from fs/io_uring.cc to core/io_uring.cc.
  • Test build location (you): tst-io_uring is now wired in via modules/tests/Makefile, not the top-level Makefile.
  • Runs on Linux for cross-checking (you): the test now carries a header comment explaining how to build and run it against the system <linux/io_uring.h> on Linux >= 5.1 (g++ -std=c++11 ...), so the asserted behavior is validated against the real kernel ABI, not just OSv.
  • std::atomic vs gcc builtins (you): internal state uses std::atomic now. The one remaining __atomic_load_n is on the userspace-provided shared ring (reg->ring_addr in the buffer-ring path), where raw ABI access is intentional.
  • Missing <sys/uio.h> (Copilot): added.
  • O_TRUNC on temp files (Copilot): all test files now open with O_TRUNC for deterministic runs.
  • Stale "mmap not implemented" comment (Copilot): updated.
  • Power-of-two rounding turning 1 into 2 (Copilot): roundup_pow2() now leaves 0 and 1 unchanged before the clz-based round-up; same fix applies to cq_entries.
  • fget() errno (Copilot, both sites): captures fget()'s return value and returns -error instead of -errno.
  • mmap mapping larger than the ring (Copilot, all three regions): io_uring_file::mmap() now requires the requested size to fall within [need, align_up(need, page_size)] and throws EINVAL otherwise, so it can't map pages past the SQ/CQ/SQE buffers.

This is a large patch and I'm exercising it against real io_uring workloads (PostgreSQL), so I'll keep fixing kinks as they surface. Ready for another look when you have time.

@gburd

gburd commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Impressive patch. I (and the AI) left a few relatively minor comments and requests.

This is a very big patch and I know very little about the details so I couldn't review it in detail, so again I have to trust that you actually use or will use the new feature and discover (and fix) its kinks yourself. Please just make sure (as I wrote in one of my comments) that the test you wrote runs also on Linux, so the behavior we test for is real, correct, behavior.

Postgres now has the beginnings of AIO and support for io_uring, it's also my "day job" to work on Postgres and so I have a vested interest in this combination. Yes, currently Postgres has a fork model but that may change soon opening the door to creative new ways to package and deploy it running on ZFS using external storage (via NVMe-oF or the Crucible replicated block devices). This is a real effort by me to build something durable with OSv for a specific project. That's about all I can say. :)

@gburd

gburd commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

I'm working on improving this patch after reviewing it:

The OSv patch is a Linux 5.10-era opcode surface (self-admitted, io_uring.cc:11) implemented on a thread-per-submission engine. It is broad but shallow: it advertises feature bits it doesn't honor, and several
implemented opcodes are semantic stubs that return plausible values without doing what Linux does. So the answer to your question is emphatically yes — on two axes: missing opcodes, and unfaithful
implementations of present ones. The second axis matters more.


Axis 1 — Opcodes that don't exist at all

The header enum stops at IORING_OP_LINKAT = 39, IORING_OP_LAST = 40 (io_uring.h:59-60). Linux is at ~57-59 opcodes today. Missing, in ascending opcode order:

┌───────┬───────────────────────────────────────┬────────────────────────────────────────────┐
│ Op │ Name │ OSv could support it? │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 40 │ MSG_RING │ needs multi-ring registry — no │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 41-44 │ FSETXATTR/SETXATTR/FGETXATTR/GETXATTR │ trivial — OSv has the syscalls │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 45 │ SOCKET │ trivial │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 46 │ URING_CMD │ no — needs SQE128; blocks NVMe passthrough │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 47-48 │ SEND_ZC/SENDMSG_ZC │ no zero-copy net path in OSv │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 49 │ READ_MULTISHOT │ needs multishot engine (absent) │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 50 │ WAITID │ portable │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 51-53 │ FUTEX_WAIT/WAKE/WAITV │ portable-ish │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 54 │ FIXED_FD_INSTALL │ trivial │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 55 │ FTRUNCATE │ trivial │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 56-57 │ BIND/LISTEN │ trivial │
├───────┼───────────────────────────────────────┼────────────────────────────────────────────┤
│ 58+ │ RECV_ZC, EPOLL_WAIT │ newer │
└───────┴───────────────────────────────────────┴────────────────────────────────────────────┘

For a storage-focused unikernel driving PostgreSQL RAID-Z over NVMe, the one that stings is URING_CMD (NVMe passthrough) — and it's not just an opcode, it needs IORING_SETUP_SQE128, which the ring layout doesn't
support (fixed 64-byte SQE).


Axis 2 — Present but not faithful (the real problem)

These are worse than missing opcodes because callers get a success code for behavior that didn't happen:

  1. The async model is thread-per-chain. io_uring_submit_sqes spawns a detached sched::thread per SQE chain (io_uring.cc:981-983). Linux uses a bounded io-wq worker pool plus an inline non-blocking fast-path (try
    the op without blocking; only hand to a worker if it would block). Under PostgreSQL AIO batch submission, every batch becomes N threads. This is the opposite of "efficient and elegant" — it's an unbounded
    thread-creation amplifier with no io-wq, no IORING_REGISTER_IOWQ_MAX_WORKERS cap (declared io_uring.h:252, unhandled → -EINVAL).
  2. LINK_TIMEOUT is a no-op that lies. The code openly posts -ECANCELED after the linked op already completed (io_uring.cc:897-900, comment at 868-884). The timeout never races the operation. A linked op that
    blocks forever blocks forever — the timeout gives zero protection. Faithful Linux semantics: the timeout fires and cancels the in-flight op.
  3. POLL_ADD is a 30-second blocking poll() in a thread (io_uring.cc:369-380), and POLL_REMOVE always returns -ENOENT (io_uring.cc:383-391) because there's no way to interrupt it. This breaks the entire
    readiness/event-loop use case. Yet the ring advertises IORING_FEAT_FAST_POLL (io_uring.cc:1461) — a false feature bit; there is no armed readiness poll.
  4. IORING_FEAT_NODROP is advertised (io_uring.cc:1458) but CQ overflow silently drops (io_uring.cc:185-194): it bumps the overflow counter, decrements pending_ops, and discards the completion. NODROP promises a
    kernel-side backlog that is flushed later. This is a correctness lie a well-written liburing client will trust.
  5. Multishot is entirely absent. IORING_CQE_F_MORE is defined (io_uring.h:102) and never emitted. No multishot ACCEPT (accept4 fires once, io_uring.cc:470-476), no multishot RECV/POLL, no READ_MULTISHOT,
    IORING_TIMEOUT_MULTISHOT ignored.
  6. Provided-buffer ring mode (PBUF_RING) is broken by design. It snapshot-copies entries into a std::deque at register time (io_uring.cc:1737-1745). Linux's buffer ring is live: the kernel reads tail on every
    buffer pick and the app replenishes by advancing it. Copy-once means buffers the app adds after registration are never seen — which is the normal replenish pattern — so the fast path it's meant to enable doesn't
    work.
  7. IORING_TIMEOUT_ABS ignored — the timespec is always treated as relative (io_uring.cc:396-397), so absolute-deadline timeouts fire at the wrong time. Count-based timeout busy-waits with a 1 ms sleep loop
    polling the CQ tail (io_uring.cc:422-440) rather than a condition variable.
  8. Cancellation can't touch in-flight ops. ASYNC_CANCEL/SYNC_CANCEL set an atomic bool checked before the op starts (io_uring.cc:903). Once a worker enters a blocking ::recv/::read, cancel is impossible. Linux
    cancels in-flight work.
  9. Registered fixed buffers store only base pointers, no lengths (io_uring.cc:1601-1604). READ_FIXED/WRITE_FIXED use sqe->len with no bounds check against the registered region size (io_uring.cc:282-292) — a
    buffer overrun waiting to happen. buf_index is bounds-checked; the offset/length within the buffer is not.
  10. SPLICE/TEE are bounce-buffer copies (io_uring.cc:671-760), not zero-copy. TEE on a non-seekable fd returns -ESPIPE — OSv has no pipes. Functionally approximate, semantically not splice.
  11. SYNC_FILE_RANGE silently downgrades to full fsync (io_uring.cc:357-366) — ignores the offset/nbytes range and the flags, so it's far more expensive than requested.
  12. io_uring_enter's sig/sigsz are ignored (io_uring.cc:1522) — no signal-mask application, so GETEVENTS can't be interrupted the way Linux allows. No IORING_ENTER_EXT_ARG (timeout-via-enter), no
    IORING_ENTER_REGISTERED_RING.

Axis 3 — Setup/register flags rejected or unhandled

supported_flags in setup is only CQSIZE|SQPOLL|SQ_AFF|CLAMP (io_uring.cc:1389-1392). Everything else → -EINVAL:

  • IORING_SETUP_IOPOLL rejected — no polled completions for NVMe (the one you'd want for RAID-Z throughput).
  • IORING_SETUP_ATTACH_WQ declared (io_uring.h:78) but rejected.
  • Missing entirely: SUBMIT_ALL, COOP_TASKRUN, TASKRUN_FLAG, SQE128, CQE32, SINGLE_ISSUER, DEFER_TASKRUN, R_DISABLED, REGISTERED_FD_ONLY, NO_MMAP, NO_SQARRAY. DEFER_TASKRUN + SINGLE_ISSUER is the modern
    high-performance default; absent.
  • SQ_AFF is a semantic no-op — sq_thread_cpu is never used to pin the SQPOLL thread.

Register opcodes declared in the enum (io_uring.h:246-257) but unhandled (fall to default: -EINVAL, io_uring.cc:1776): PERSONALITY/UNREGISTER_PERSONALITY, RESTRICTIONS, ENABLE_RINGS, FILES2, FILES_UPDATE2,
BUFFERS2, BUFFERS_UPDATE, IOWQ_AFF, IOWQ_MAX_WORKERS, and critically RING_FDS/UNREGISTER_RING_FDS. Registered ring fds are what liburing uses by default since 5.18 to cut the per-enter fd lookup — without it,
liburing falls back to the slow path on every submit.

IORING_FEAT_SINGLE_MMAP is not advertised (io_uring.cc:1458), so clients do the older three-mmap dance.


What I'd fix first if the goal is PostgreSQL-on-RAID-Z performance

  1. Replace thread-per-chain with a bounded io-wq worker pool + inline non-blocking fast-path. This is the single biggest efficiency and elegance win and removes the thread-amplification hazard.
  2. Make LINK_TIMEOUT real, or reject linked timeouts with -EINVAL instead of silently no-op'ing — a lie is worse than an honest refusal.
  3. Stop advertising NODROP and FAST_POLL unless you implement the overflow backlog and armed readiness poll. Right now they mislead liburing.
  4. Add IORING_SETUP_IOPOLL for NVMe polled completions — directly relevant to your RAID-Z throughput numbers.
  5. Fix PBUF_RING to read the ring live rather than snapshot-copying at register.
  6. Bounds-check READ_FIXED/WRITE_FIXED against registered buffer lengths (currently an overrun).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.

Comment thread runtime.cc Outdated
Comment thread core/io_uring.cc
Comment on lines +826 to +833
/* --- RENAMEAT --- */
case IORING_OP_RENAMEAT: {
const char *oldpath = reinterpret_cast<const char *>(sqe->addr);
const char *newpath = reinterpret_cast<const char *>(sqe->addr2);
int r = ::renameat(sqe->fd, oldpath, (int)sqe->len, newpath);
res = (r < 0) ? -errno : 0;
break;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 600bb2e: IORING_OP_RENAMEAT now rejects nonzero sqe->rename_flags with -EINVAL rather than silently dropping them (OSv only implements plain renameat(), not renameat2 semantics).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But you could have just called renameat2(): We do have this function, and it returns EINVAL if there are flags (exactly like you did), and if in the future we'll implement renameat2 completely, you'll automatically gain this feature and won't need to implement it in a second place.

Comment thread core/io_uring.cc
Comment thread core/io_uring.cc
Comment thread core/io_uring.cc
Comment thread core/io_uring.cc
Comment thread core/io_uring.cc Outdated
Comment on lines +385 to +388
/* Blocking poll: wait up to ~30 s, then return 0 (timeout) */
int r = ::poll(&pfd, 1, 30000);
if (r < 0) res = -errno;
else res = pfd.revents;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 600bb2e: POLL_ADD no longer completes on the poll() timeout — it retries while r == 0, so the request completes only on events or a hard error. The finite poll() timeout is kept only to stay responsive.

Comment thread libc/unistd/sync.cc

@nyh nyh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks mostly good. I (and copilot) had a few last small comments, please take a look.

Comment thread core/io_uring.cc
if (v <= 1)
return v;
return 1U << (32 - __builtin_clz(v - 1));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. One day we should switch to C++20 (currently we default to the ancient C++14...), and have std::bit_ceil - https://en.cppreference.com/cpp/numeric/bit_ceil

Comment thread tests/tst-io_uring.cc
* directly through the mmap'd SQ/CQ memory and the io_uring_setup/enter/
* register syscalls, so it exercises the same ABI a real io_uring user would.
*
* This test currently includes <osv/io_uring.h> for the ring-structure and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To solve this, what we normally do is have an include/api/io_uring.h which includes just the API - not any OSv-specific things. We can then include <io_uring.h> and get the include/api one on OSv and the Linux one on Linux.

But not critical now. I'm happy enough that you tried to run this once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, that's the right long-term shape. I'll do the include/api/io_uring.h split (API-only header, so <io_uring.h> resolves to the OSv copy here and the kernel copy on Linux) as a follow-up rather than churn this PR now that it's approved. Noted for the enhancement branch.

Comment thread core/io_uring.cc
static long io_uring_setup_impl(unsigned entries, struct io_uring_params *params);
static int io_uring_enter_impl(int fd, unsigned to_submit, unsigned min_complete,
unsigned flags, const void *sig, size_t sigsz);
static int io_uring_register_impl(int fd, unsigned opcode, void *arg,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could have just written the errno code into the main functions, and didn't need those "_impl" functions that return it the "wrong" way. But if you like it better this way, then fine.

I just don't like, or don't understand, the justification about the test. The test should use the same functions as available in the Linux C library - the ones returning errno, not the "impl" ones. So how are the tests an excuse for the "impl" functions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that the test justification was a bad argument — I'll drop it. The real reason for the impl split is narrower: syscall_wrapper() in linux.cc turns any negative return into the syscall ABI's -errno, but the public sys_io_uring*() also needs errno set for the libc/liburing path, so having the wrapper set errno and return -1 while the inner function returns -errno directly avoids double-translating (a bare -EINVAL from a function that also cleared errno would otherwise surface as a false 0/success to liburing's probe). That's a plumbing detail, not a test convenience. If you'd prefer errno folded straight into the main functions I can collapse them; I kept the split only to keep the ABI-translation seam in one place. Not reverting anything in this approved PR — I'll fold this into the follow-up if you want it gone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This response is also kind of wrong: You're not "avoiding double translation", in fact it's quite the opposite - you are causing double-translation: You have an something_impl() function which returns -EINVAL, then the something() function converts this to errno=EINVAL (as we need for the C library) and then syscall_wrapper() turns the C library function setting errno=EINVAL back to a function that returns -EINVAL (as Linux syscalls do).

None of this is bad, and you don't need to change it, it's fine. But it's confusing to read wrong justifications to why you did it.

Address review robustness feedback on the io_uring core:

- posix_fallocate: return EOVERFLOW when offset+len overflows off_t
  instead of silently wrapping (runtime.cc).
- RENAMEAT: reject non-zero rename_flags with -EINVAL; OSv's renameat
  honors no flags, so accepting them would be a false success.
- TIMEOUT: validate timeout_flags against the supported mask, return
  -EFAULT for a null timespec and -EINVAL for out-of-range tv_sec/tv_nsec
  before dereferencing user memory.
- POLL_ADD: retry poll() on a 30s timeout rather than reporting a
  spurious revents==0 completion.
- REGISTER_BUFFERS / REGISTER_FILES: return -EFAULT on a null arg after
  the nr_args bounds check.
- REGISTER_PBUF_RING: read ring_entries early and reject a null ring_addr
  or non-power-of-two entry count with -EINVAL.
- syncfs: return int, validate the fd with fget/fdrop and set EBADF on a
  bad descriptor, matching the Linux prototype (unistd.h + sync.cc).

Verified: tst-io_uring 14/14 PASS at runtime; kernel sources compile clean.
@gburd

gburd commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Pushed one follow-up commit (600bb2e) addressing the robustness feedback from the latest review pass. No behavioral change to the already-approved core; these tighten error handling on edge inputs:

  • posix_fallocate: return EOVERFLOW when offset + len overflows off_t rather than silently wrapping.
  • RENAMEAT: reject non-zero rename_flags with -EINVAL — OSv's renameat honors no flags, so accepting them would be a false success.
  • TIMEOUT: validate timeout_flags against the supported mask, return -EFAULT for a NULL timespec and -EINVAL for out-of-range tv_sec/tv_nsec before dereferencing user memory.
  • POLL_ADD: retry poll() on a 30s timeout instead of reporting a spurious revents == 0 completion.
  • REGISTER_BUFFERS / REGISTER_FILES: return -EFAULT on a NULL arg after the nr_args bounds check.
  • REGISTER_PBUF_RING: read ring_entries early and reject a NULL ring_addr or non-power-of-two entry count with -EINVAL.
  • syncfs: now returns int, validates the fd via fget/fdrop, and sets EBADF on a bad descriptor — matching the Linux prototype.

Verified: tst-io_uring 14/14 PASS at runtime (qemu, x86_64); all modified kernel sources compile clean.

@nyh nyh closed this in 7c7c0d9 Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants