Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Layer 0 Spec — io_uring Module, Stage 1: API Core

Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)

Position. This document specifies Stage 1 of the air-sys-syscall::io_uring module: the core on which all other phases build. It derives from the master inventory document (io-uring-0-inventaire-en.md) and from ADR-022. Soundness decisions S1 (pre-allocated slab), S2 (safe teardown), and S3 (sandbox) are made operational here. Business operations (submit_read, etc.) belong to Stages 2a–2d and 3a–3f; this document specifies the ring, submission, completion, and their safety.

Layer 0 conventions (ADR-021) applied without exception: Result<_, Errno> everywhere, EINTR propagated to the caller (never automatic retry), Option<T> instead of kernel sentinels, typed FDs (OwnedFd/BorrowedFd), no heap allocation in the happy path (S1), // SAFETY: on every unsafe block.


1. Scope of Stage 1

Stage 1 covers:

  1. The memory model: the three mmapped regions (SQ, CQ, SQE) and the publication protocol via head/tail with acquire/release ordering (§3). This is the correctness foundation for the entire module.
  2. Ring construction: IoUringBuilder, IoUring::new, io_uring_setup(2), feature negotiation, application of setup flags (§5).
  3. The in-flight operation slab (S1): allocation-free storage of transferred buffers and metadata, SubmissionToken slot+generation encoding, back-pressure (§4).
  4. Submission: submit, submit_and_wait, with(SubmitOptions), io_uring_enter(2) (§6).
  5. Completion: wait_completion, wait_completion_timeout, try_completion, completions(), and the Completion type with its generic typed interpretations (§7).
  6. Safe teardown (S2): explicit shutdown() + quiescent Drop, built on sync_cancel (§8).
  7. Introspection: supports_op (probe) and capabilities (§9).

The three underlying syscalls (identical numbers on x86_64 / ARM64):

Syscallx86_64 no.ARM64 no.Stage 1 usage
io_uring_setup425425construction (§5)
io_uring_enter426426submission + wait (§6, §7)
io_uring_register427427probe, sync_cancel, eventfd… (§8, §9)

2. Fundamental types introduced in Stage 1

TypeRoleair-sys-types?
IoUringthe ring (FD + mmaps + slab + capabilities). Send, !Sync.yes
IoUringBuilderconfigured construction (flags, sizes, restrictions).yes
SetupFlagsbitflags for io_uring_setup flags (axis A).yes
IoUringParamstyped mirror of io_uring_params (setup inputs/outputs).yes
IoUringCapabilitiesnegotiated features (axis G), stable predicates.yes
IoUringOpcodeenumeration of the 55 retained opcodes (probe, restrictions).yes
SubmissionTokenopaque slot+generation token (S1).yes
SubmitOptionsper-op options (drain, async, skip-cqe, link — exposed in Stage 3c).yes
Completiona typed completion (decoded CQE).yes
CompletionFlagsbitflags for CQE flags (axis E).yes
CompletionIter<'ring>iterator over available completions.no (borrow)
CancelTargetcancellation target (token / fd / op / any).yes

3. Memory model and ring protocol

Stage 1 implements this protocol but does not expose it raw (the raw layer is in Stage 4, ::raw). This section establishes the correctness contract.

3.1 The three mmapped regions

io_uring_setup returns an FD and fills io_uring_params with two sets of offsets (io_sqring_offsets, io_cqring_offsets). We mmap:

  • SQ ring at offset IORING_OFF_SQ_RING: contains head, tail, ring_mask, ring_entries, flags, dropped, and (unless NO_SQARRAY) the index array array.
  • SQE array at offset IORING_OFF_SQES: ring_entries entries of 64 bytes (or 128 bytes if SQE128).
  • CQ ring at offset IORING_OFF_CQ_RING: head, tail, ring_mask, ring_entries, overflow, flags, and the cqes array (16 bytes, or 32 bytes if CQE32).

With IORING_FEAT_SINGLE_MMAP (always present in 6.12), SQ and CQ share a single mmap; this is detected and handled accordingly (two mmaps otherwise). With NO_MMAP, the caller provides the memory — not covered in Stage 1 (advanced option, gated).

3.2 Publication protocol — memory ordering

The head/tail values are monotonic 32-bit counters shared with the kernel; the real index = counter & ring_mask. Ordering is non-negotiable:

Submission (userspace as SQ producer):

  1. Write the complete SQE into sqes[tail & mask].
  2. (Unless NO_SQARRAY) write tail & mask into array[tail & mask].
  3. Publish the new tail with a store release (AtomicU32::store(.., Release)) — guarantees the kernel sees SQE writes before seeing the tail advance.
  4. The SQ head is read with a load acquire to calculate available space.

Completion (userspace as CQ consumer):

  1. Read the CQ tail with a load acquire — guarantees that CQEs written by the kernel are visible.
  2. Read the CQEs between head and tail.
  3. Publish the new head with a store release to return entries to the kernel.

Every implementation must respect this protocol; it is model-tested (loom, §11) in addition to functional tests. Reference: io_uring(7) and io_uring_setup(2).

3.3 Wakeup and ring flags

  • IORING_SQ_NEED_WAKEUP (SQ flags): in SQPOLL mode, if set, the kernel thread must be woken via io_uring_enter(.., SQ_WAKEUP) (handled in Stage 3e; in Stage 1 we read/expose the flag).
  • IORING_SQ_CQ_OVERFLOW: the CQ has overflowed; with IORING_FEAT_NODROP (present in 6.12) the kernel retains completions and re-delivers them, but signals the condition. Stage 1 surfaces this (IoUring::completion_queue_overflowed()).
  • IORING_SQ_TASKRUN: task-work is pending (with TASKRUN_FLAG); indicates that a kernel entry is useful.

4. The in-flight operation slab (Decision S1)

4.1 Problem and shape

Between a submission and its completion, the kernel potentially holds a buffer (ownership transfer model, ADR-022 Decision 3) and the façade must retrieve, at completion time, which operation and which buffer correspond to the CQE. The link is user_data (u64, placed in the SQE, returned in the CQE).

A dynamic table would allocate per operation — forbidden (CLAUDE.md). We use a pre-allocated slab:

struct InflightSlab {
    slots: Box<[Slot]>,   // allocated ONCE at construction
    free_head: Option<u32>,
    in_flight: u32,
}

struct Slot {
    generation: u32,           // incremented at each reuse (compteur de génération anti-réutilisation)
    state: SlotState,          // Free | Inflight { kind, buffer } | Multishot { .. }
}
  • Capacity = maximum number of simultaneously in-flight operations, defaulting to cq_entries (the kernel cannot have more pending completions than the CQ, modulo overflow handled by NODROP). Configurable via the builder.
  • The transferred buffer is moved into the slot: no copy, no reallocation in the happy path.

4.2 SubmissionToken encoding

SubmissionToken encapsulates { slot: u32, generation: u32 }. The kernel user_data = ((generation as u64) << 32) | (slot as u64). At completion:

  1. decode user_data(generation, slot);
  2. if slots[slot].generation != generationstale completion (op already cancelled/recycled, e.g. a late multishot CQE): silently ignored;
  3. otherwise, consume the slot and return the buffer to the caller.

4.3 Back-pressure

submit_* reserves a slot before writing the SQE. Slab full (in_flight == capacity) ⇒ returns Err(Errno::EBUSY) without touching the kernel. This is Air’s structural back-pressure: we refuse politely rather than overflow.

4.4 Multishot

A multishot operation (Stage 3d) occupies one slot but produces multiple CQEs. The slot stays alive as long as completions carry IORING_CQE_F_MORE; it is freed on the final completion (without F_MORE) or on cancellation. The generation guards against CQEs arriving after cancellation.


5. Ring construction

5.1 IoUringBuilder

#![allow(unused)]
fn main() {
pub struct IoUringBuilder { /* ... */ }

impl IoUringBuilder {
    pub fn new(entries: NonZeroU32) -> Self;
    pub fn with_completion_queue_entries(self, entries: NonZeroU32) -> Self;       // SETUP_CQSIZE
    pub fn max_inflight(self, n: NonZeroU32) -> Self;            // slab capacity
    pub fn with_flags(self, flags: SetupFlags) -> Self;
    pub fn with_sqpoll_idle(self, dur: Duration) -> Self;            // SQPOLL (Stage 3e)
    pub fn with_sqpoll_cpu(self, cpu: u32) -> Self;                  // SQ_AFF
    pub fn attach_work_queue(self, other: &IoUring) -> Self;            // ATTACH_WQ
    pub fn restrict(self, restrictions: &[Restriction]) -> Self; // S3 (Stage 3f)
    pub fn build(self) -> Result<IoUring, Errno>;
}
}

build — behavior.

  • Translates the configuration into io_uring_params, calls io_uring_setup(2).
  • entries is rounded up by the kernel to the next power of two; the effective sizes are read back (sq_entries, cq_entries).
  • mmaps SQ/CQ/SQE per §3.1 (RAII: the mmaps are owned by IoUring).
  • allocates the slab (§4) once, sized to max_inflight or cq_entries.
  • reads params.featuresIoUringCapabilities.
  • if REGISTERED_FD_ONLY/REG_REG_RING: registers the ring fd and transparently switches to registered-fd mode.
  • if restrict(..) is non-empty: sets R_DISABLED, applies REGISTER_RESTRICTIONS, leaves the ring disabled (caller invokes enable(); see Stage 3f).

Preconditions. entries ≤ kernel limit (otherwise EINVAL). Incompatible flag combinations are rejected (e.g. IOPOLL + SQPOLL depending on context): the kernel’s EINVAL is propagated without masking.

Errors. EINVAL (invalid params), ENOMEM (memory), EPERM (SQPOLL/affinity without privilege depending on config), EFAULT, ENOSYS (io_uring unavailable — sandbox/container: see §10).

Performance. Cost dominated by io_uring_setup + mmaps + single slab allocation: ~a few tens of µs. Done once, off the hot path.

Example.

#![allow(unused)]
fn main() {
use core::num::NonZeroU32;
let ring = IoUringBuilder::new(NonZeroU32::new(256).unwrap())
    .with_flags(SetupFlags::SINGLE_ISSUER | SetupFlags::DEFER_TASKRUN)
    .build()?;
}

Tests. unit (rounded sizes, feature reading), property (arbitrary entries → size invariants), integration (real creation on 6.12), syscall simulator to force ENOSYS/ENOMEM, fuzzing of flag combinations.

5.2 IoUring::new and enable

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn new(entries: NonZeroU32) -> Result<Self, Errno>; // = builder.build()
    pub fn enable(&mut self) -> Result<(), Errno>;          // REGISTER_ENABLE_RINGS
    pub fn completion_queue_overflowed(&self) -> bool;                    // reads SQ_CQ_OVERFLOW
    pub fn submission_queue_space_left(&self) -> u32;
    pub fn in_flight(&self) -> u32;                          // occupied slots (S1)
}
}

enable is only useful for a ring created in disabled state (via restrict); on an already-active ring it returns Err(EINVAL) (propagated as-is).


6. Submission

6.1 submit and submit_and_wait

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn submit(&mut self) -> Result<u32, Errno>;
    pub fn submit_and_wait(&mut self, want: u32) -> Result<u32, Errno>;
}
}

Behavior. submit publishes the SQ tail (§3.2) then calls io_uring_enter(fd, to_submit, 0, 0, ..); returns the number of SQEs consumed by the kernel. submit_and_wait(want) adds IORING_ENTER_GETEVENTS and min_complete = want.

In SQPOLL mode, if the kernel thread is running, submit may require no syscall (just the release publication); it wakes the thread only if SQ_NEED_WAKEUP.

EINTR. Propagated as-is (ADR-021 conv. 2). The caller who wants to retry writes its own loop.

Errors. EINTR, EAGAIN (transient resources), EBUSY (CQ full and not drained, depending on context), EINVAL, EFAULT, EBADF.

Performance. The io_uring gain is here: we batch N operations for a single io_uring_enter. A submit without wait on a hot SQPOLL ring ≈ cost of an atomic store.

6.2 Per-operation options

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, Default)]
pub struct SubmitOptions { /* ... */ }
impl SubmitOptions {
    pub fn drain(self) -> Self;               // IOSQE_IO_DRAIN
    pub fn force_async(self) -> Self;         // IOSQE_ASYNC
    pub fn skip_cqe_on_success(self) -> Self; // IOSQE_CQE_SKIP_SUCCESS (FEAT_CQE_SKIP)
    // link / hardlink exposed via LinkedChainBuilder (Stage 3c)
}

impl IoUring {
    pub fn with(&mut self, opts: SubmitOptions) -> &mut Self;
}
}

with applies options to the next submit_* call. Note: skip_cqe_on_success frees the S1 slot at submission time (no CQE expected on success) — the slot spec documents this special release case.


7. Completion

7.1 Retrieval

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn wait_completion(&mut self) -> Result<Completion, Errno>;
    pub fn wait_completion_timeout(&mut self, timeout: Duration)
        -> Result<Option<Completion>, Errno>;
    pub fn try_completion(&mut self) -> Option<Completion>;
    pub fn completions(&mut self) -> CompletionIter<'_>;
}
}
  • wait_completion: blocks until at least one completion (io_uring_enter(.., GETEVENTS, min_complete=1)), decodes it, and consumes it.
  • wait_completion_timeout: uses IORING_ENTER_EXT_ARG (io_uring_getevents_arg + __kernel_timespec) or ABS_TIMER (FEAT_MIN_TIMEOUT); Ok(None) on expiry.
  • try_completion: non-blocking, reads the CQ without a syscall if CQEs are present (load acquire of the tail).
  • completions(): drains what is available, advances head (store release) at end of iteration or in batches.

Stale completion. If the decoded user_data points to a slot whose generation does not match (§4.2), the completion is filtered out: these functions skip it and move to the next without returning it to the caller.

7.2 The Completion type

#![allow(unused)]
fn main() {
pub struct Completion { /* token, res, flags, slot access for buffer retrieval */ }

impl Completion {
    pub fn token(&self) -> SubmissionToken;
    pub fn raw_result(&self) -> i32;          // raw, semantics depend on op
    pub fn flags(&self) -> CompletionFlags;

    pub fn has_more(&self) -> bool;           // CQE_F_MORE (multishot)
    pub fn is_notif(&self) -> bool;           // CQE_F_NOTIF (zero-copy)
    pub fn buffer_id(&self) -> Option<u16>;   // CQE_F_BUFFER
    pub fn socket_has_pending_data(&self) -> bool;      // CQE_F_SOCK_NONEMPTY

    // Generic interpretation (rich typed variants are in Stages 2x):
    pub fn into_result(self) -> Result<i32, Errno>;       // res<0 => Err(-res)
    pub fn into_buffer_result(self) -> Result<(Vec<u8>, usize), Errno>; // S1 buffer retrieval
    pub fn completed(&self) -> Result<(), Errno>;         // success with no value
}
}

Result convention. A negative res is a -errno; the into_* methods convert it to Err(Errno). A res ≥ 0 is the useful value (bytes, fd, etc.), interpreted by the appropriate method — the developer knows which operation they submitted (ADR-022 Decision 9).

Buffer retrieval. into_buffer_result takes the buffer moved out of the slot (S1) and returns it to the caller along with the byte count — zero copy.

7.3 CompletionFlags

bitflags for the 5 flags on axis E: BUFFER, MORE, SOCK_NONEMPTY, NOTIF, BUF_MORE.


8. Safe teardown (Decision S2)

8.1 sync_cancel (building block)

#![allow(unused)]
fn main() {
pub enum CancelTarget {
    Token(SubmissionToken),
    Fd(BorrowedFd<'_>),
    Op(IoUringOpcode),
    Any,
}
impl IoUring {
    pub fn sync_cancel(&mut self, target: CancelTarget) -> Result<u32, Errno>;
}
}

Relies on IORING_REGISTER_SYNC_CANCEL (io_uring_sync_cancel_reg, with timeout). Maps CancelTarget to IORING_ASYNC_CANCEL_* flags (USERDATA / FD / OP / ANY). Returns the number of cancelled operations.

8.2 shutdown and Drop

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn shutdown(self) -> Result<(), Errno>;
}
impl Drop for IoUring { fn drop(&mut self); }
}

shutdown (clean path).

  1. sync_cancel(Any) with a bounded timeout.
  2. Drain the CQ until in_flight() == 0 (slot buffers are cleanly released as they come).
  3. munmap the rings, close the FD (RAII), release the slab.
  4. Returns Err if draining fails/expires (caller decides).

Drop (safety net). If in_flight() > 0, executes the same quiesce sequence in a blocking best-effort manner (bounded loop). The cost (potential blocking) is documented: on the hot path, prefer shutdown(). Rationale: “over-secure then trim after measurement” (Principle 5) — a Drop that allows the kernel to write into freed memory is an unacceptable soundness defect in layer 0.

Safety invariant. As long as an operation is in flight, its associated buffer lives in its slot (S1) and cannot be freed by the caller; the ring cannot be destroyed without quiescence. These two invariants guarantee that no kernel write lands on freed memory.


9. Introspection: probe and capabilities

#![allow(unused)]
fn main() {
impl IoUring {
    pub fn supports_op(&self, op: IoUringOpcode) -> bool;
    pub fn capabilities(&self) -> IoUringCapabilities;
}
}
  • supports_op: queries IORING_REGISTER_PROBE (cached at construction). Enables fallback to synchronous syscalls when an op is not supported by the current kernel (ADR-022 Decision 8).
  • capabilities: exposes the 16 features on axis G via stable predicates (single_mmap, nodrop, cqe_skip, min_timeout, recvsend_bundle, reg_reg_ring, …).

Fallback example.

#![allow(unused)]
fn main() {
if ring.supports_op(IoUringOpcode::Openat2) {
    let tok = ring.submit_openat2(/* ... */)?; // Stage 2a
} else {
    // fall back to synchronous air-sys-syscall::fs::openat2
}
}

10. io_uring unavailability (ADR-022 Decision 10)

If the kernel does not support io_uring or if the environment has disabled it (seccomp, sandbox, hardened container), build() returns Err(Errno::ENOSYS) (or EPERM depending on the filter). No hidden automatic fallback: the caller chooses to switch to synchronous mode or to refuse to start.


11. Test strategy (layer 0 — 100% lines + branches)

  • Unit: SubmissionToken encoding/decoding (slot+generation), head/tail arithmetic (masking, wrap), back-pressure EBUSY, stale completion filtering.
  • Property-based (proptest): for any sequence of submit/complete, the invariants in_flight ≤ capacity, head ≤ tail, no doubly-freed slot, no lost buffer.
  • Concurrency model (loom): the acquire/release protocol from §3.2 on a producer(userspace)/consumer(simulated kernel) model; detects any missing ordering.
  • Integration: on a real 6.12 kernel — creation, nop, submit/wait, timeout, clean shutdown, Drop with in-flight ops, CQ overflow (NODROP).
  • Syscall simulator: harness injecting EINTR, EAGAIN, ENOSYS, EFAULT at enter/setup/register boundaries to cover error branches without depending on the kernel.
  • Fuzzing (cargo-fuzz): decoding of CQEs and returned io_uring_params (any data coming from the kernel is treated as external input, Principle 3).
  • Drop tests: Miri/valgrind to confirm absence of use-after-free on quiescence paths.

Non-coverable branches (e.g. kernel errors impossible to trigger): recorded in docs/COVERAGE-EXCEPTIONS.md with justification.


12. Error summary (Stage 1)

FunctionNotable errors
build / newEINVAL, ENOMEM, EPERM, ENOSYS, EFAULT
enableEINVAL, EBADF
submit / submit_and_waitEINTR, EAGAIN, EBUSY, EINVAL, EBADF, EFAULT
wait_completion*EINTR, ETIME (internal timeout, per mapping), EBADF
submit_* (slot reservation)EBUSY (slab full) before any syscall
sync_cancelEINTR, EALREADY, ENOENT, EINVAL
shutdownpropagates drain/cancel errors

13. Types added to air-sys-types (Stage 1)

IoUring, IoUringBuilder, IoUringParams, SetupFlags, IoUringCapabilities, IoUringOpcode, SubmissionToken, SubmitOptions, Completion, CompletionFlags, CancelTarget. Approximately 11 public types (the slab and mmap wrappers are internal, not exposed).


14. Core decisions emerging from Stage 1

  1. SubmissionToken = slot+generation, not raw user_data. The API never lets the caller manipulate an arbitrary user_data: the generation guards against ABA and stale completions (multishot/cancel).
  2. Slab sized by default to cq_entries. Aligns application-level back-pressure with the kernel’s actual completion capacity.
  3. Blocking Drop accepted. Soundness over performance, reversible after measurement (but never in the dangerous direction).
  4. No Submittable trait in Stage 1. Inherent methods; the abstraction is introduced only if Stages 3c/3d prove the need for it (Principle 7).
  5. Timeouts via EXT_ARG/ABS_TIMER, not via a linked TIMEOUT operation — that remains available in Stage 2c for explicit cases.

15. Next steps

Following specs, on this model: io-uring-2a-filesystem-en.md (the 26 FS operations built on the core above), then 2b, 2c, 2d, 3a–3f, 4. The global English translation is produced once the French documents are validated.


Document license: MPL 2.0 Status: Technical specification of Stage 1 (core) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.