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_uringmodule: 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 everyunsafeblock.
1. Scope of Stage 1
Stage 1 covers:
- 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.
- Ring construction:
IoUringBuilder,IoUring::new,io_uring_setup(2), feature negotiation, application of setup flags (§5). - The in-flight operation slab (S1): allocation-free storage of transferred
buffers and metadata,
SubmissionTokenslot+generation encoding, back-pressure (§4). - Submission:
submit,submit_and_wait,with(SubmitOptions),io_uring_enter(2)(§6). - Completion:
wait_completion,wait_completion_timeout,try_completion,completions(), and theCompletiontype with its generic typed interpretations (§7). - Safe teardown (S2): explicit
shutdown()+ quiescentDrop, built onsync_cancel(§8). - Introspection:
supports_op(probe) andcapabilities(§9).
The three underlying syscalls (identical numbers on x86_64 / ARM64):
| Syscall | x86_64 no. | ARM64 no. | Stage 1 usage |
|---|---|---|---|
io_uring_setup | 425 | 425 | construction (§5) |
io_uring_enter | 426 | 426 | submission + wait (§6, §7) |
io_uring_register | 427 | 427 | probe, sync_cancel, eventfd… (§8, §9) |
2. Fundamental types introduced in Stage 1
| Type | Role | air-sys-types? |
|---|---|---|
IoUring | the ring (FD + mmaps + slab + capabilities). Send, !Sync. | yes |
IoUringBuilder | configured construction (flags, sizes, restrictions). | yes |
SetupFlags | bitflags for io_uring_setup flags (axis A). | yes |
IoUringParams | typed mirror of io_uring_params (setup inputs/outputs). | yes |
IoUringCapabilities | negotiated features (axis G), stable predicates. | yes |
IoUringOpcode | enumeration of the 55 retained opcodes (probe, restrictions). | yes |
SubmissionToken | opaque slot+generation token (S1). | yes |
SubmitOptions | per-op options (drain, async, skip-cqe, link — exposed in Stage 3c). | yes |
Completion | a typed completion (decoded CQE). | yes |
CompletionFlags | bitflags for CQE flags (axis E). | yes |
CompletionIter<'ring> | iterator over available completions. | no (borrow) |
CancelTarget | cancellation 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: containshead,tail,ring_mask,ring_entries,flags,dropped, and (unlessNO_SQARRAY) the index arrayarray. - SQE array at offset
IORING_OFF_SQES:ring_entriesentries of 64 bytes (or 128 bytes ifSQE128). - CQ ring at offset
IORING_OFF_CQ_RING:head,tail,ring_mask,ring_entries,overflow,flags, and thecqesarray (16 bytes, or 32 bytes ifCQE32).
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):
- Write the complete SQE into
sqes[tail & mask]. - (Unless
NO_SQARRAY) writetail & maskintoarray[tail & mask]. - Publish the new
tailwith a store release (AtomicU32::store(.., Release)) — guarantees the kernel sees SQE writes before seeing the tail advance. - The SQ
headis read with a load acquire to calculate available space.
Completion (userspace as CQ consumer):
- Read the CQ
tailwith a load acquire — guarantees that CQEs written by the kernel are visible. - Read the CQEs between
headandtail. - Publish the new
headwith 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): inSQPOLLmode, if set, the kernel thread must be woken viaio_uring_enter(.., SQ_WAKEUP)(handled in Stage 3e; in Stage 1 we read/expose the flag).IORING_SQ_CQ_OVERFLOW: the CQ has overflowed; withIORING_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 (withTASKRUN_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:
- decode
user_data→(generation, slot); - if
slots[slot].generation != generation→ stale completion (op already cancelled/recycled, e.g. a late multishot CQE): silently ignored; - 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, callsio_uring_setup(2). entriesis 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_inflightorcq_entries. - reads
params.features→IoUringCapabilities. - if
REGISTERED_FD_ONLY/REG_REG_RING: registers the ring fd and transparently switches to registered-fd mode. - if
restrict(..)is non-empty: setsR_DISABLED, appliesREGISTER_RESTRICTIONS, leaves the ring disabled (caller invokesenable(); 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: usesIORING_ENTER_EXT_ARG(io_uring_getevents_arg+__kernel_timespec) orABS_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, advanceshead(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).
sync_cancel(Any)with a bounded timeout.- Drain the CQ until
in_flight() == 0(slot buffers are cleanly released as they come). munmapthe rings, close the FD (RAII), release the slab.- Returns
Errif 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: queriesIORING_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:
SubmissionTokenencoding/decoding (slot+generation), head/tail arithmetic (masking, wrap), back-pressureEBUSY, 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,EFAULTatenter/setup/registerboundaries 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)
| Function | Notable errors |
|---|---|
build / new | EINVAL, ENOMEM, EPERM, ENOSYS, EFAULT |
enable | EINVAL, EBADF |
submit / submit_and_wait | EINTR, EAGAIN, EBUSY, EINVAL, EBADF, EFAULT |
wait_completion* | EINTR, ETIME (internal timeout, per mapping), EBADF |
submit_* (slot reservation) | EBUSY (slab full) before any syscall |
sync_cancel | EINTR, EALREADY, ENOENT, EINVAL |
shutdown | propagates 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
SubmissionToken= slot+generation, not rawuser_data. The API never lets the caller manipulate an arbitraryuser_data: the generation guards against ABA and stale completions (multishot/cancel).- Slab sized by default to
cq_entries. Aligns application-level back-pressure with the kernel’s actual completion capacity. - Blocking
Dropaccepted. Soundness over performance, reversible after measurement (but never in the dangerous direction). - No
Submittabletrait in Stage 1. Inherent methods; the abstraction is introduced only if Stages 3c/3d prove the need for it (Principle 7). - Timeouts via
EXT_ARG/ABS_TIMER, not via a linkedTIMEOUToperation — 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.