Skip to content

io_uring event engine — Stage 2: completion-mode accept, tiers, hardening#31

Open
andypost wants to merge 11 commits into
feat/io-uring-stage1from
feat/io-uring-stage2
Open

io_uring event engine — Stage 2: completion-mode accept, tiers, hardening#31
andypost wants to merge 11 commits into
feat/io-uring-stage1from
feat/io-uring-stage2

Conversation

@andypost

Copy link
Copy Markdown
Owner

io_uring event engine — Stage 2: completion-mode accept, feature tiers, review hardening

Stacked on #30 (Stage 1). Merge that first; this PR then rebases to master.

What's here

  • Oneshot IORING_OP_ACCEPT re-armed per completion for listen sockets (tier ACCEPT, kernel 5.19+ probed at runtime). Accept completions replace the readiness+accept4() dance and — because each worker ring keeps at most one pending accept — the kernel distributes connections among waiting workers like N threads blocked in accept(2): no cross-worker thundering herd (Stage 1's poll listener woke every worker per connection), no worker monopolization (a persistent multishot accept funnels every connection into the first-armed ring; measured and documented in docs/io_uring/design.md §2.1a).
  • Stay-armed-while-BLOCKED: a CQE landing on a BLOCKED direction latches readiness and keeps the multishot poll armed (epoll-edge parity), removing Stage 1's POLL_REMOVE+POLL_ADD pair per request.
  • Feature tiers with per-feature runtime probes (io_uring_get_probe, functional multishot-poll test, EINVAL setup-flag fallback — never version parsing) and NXT_IO_URING_FORCE_TIER for exercising every degraded path on a new kernel. RECV-tier groundwork (buffer-ring multishot recv) is deliberately deferred; the three constraints a follow-up must solve are documented in-tree.
  • Review-hardening pass (multi-angle review, all findings verified): generation-wrap fix in the stale-CQE compares, signalfd full drain (queued-signal strand), ring leak on a failed create, NXT_IO_URING=0 kill-switch before any io_uring syscall (seccomp-KILL hosts), oneshot-arming duplicate-registration fix, dropped-connection handling on getpeername() failure, negative-fd guard, engine-change double-failure hardening, and deduplication against the epoll engine's helpers (shared accept4 test/conn-io, recvbuf EOF shim, signalfd setup+handler; net −265 lines).
  • docs/io_uring/: the verified engine contract, the design doc with all bring-up findings, and the benchmark method + results for both stages.

Benchmarks (5 interleaved rounds vs epoll, oha + ab, noise floor from a null A/A run)

  • Throughput/latency: parity on direct scenarios; proxy-to-self −8.7% (attributed to the accept re-arm round-trip on per-request internal peer connections — this workload creates one upstream connection per request).
  • Router CPU/req: Stage 1's +25-50% on accept-heavy paths reduced to +7-13%; keepalive near parity (+2-4%).
  • Full data: docs/io_uring/results-stage1.md, results-stage2.md.

Net standing is parity-minus on CPU with the projected wins in the deferred RECV tier — this lands as opt-in experimental (epoll remains the default in every flavor except the explicit --io-uring-default A/B build).

Testing

Tier-matrix functional runs (all tiers × c8/c64, zero errors), soak (0 failures, 0 crashes), SIGTERM-under-load 10/10, full pytest on the io_uring-default build (149 passed; 2 failures environmental, identical on the epoll build).

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request integrates the Stage 1 (poll-mode readiness bridge) and Stage 2 (completion-mode accept) of the new io_uring event engine, sharing several edge-trigger and signalfd helpers with the epoll engine. It also adds robust runtime fallback to epoll upon io_uring resource exhaustion and environment overrides for operational safety. The review feedback correctly identifies two instances (in nxt_epoll_signalfd_handler and nxt_io_uring_internal_cqe) where nxt_errno is checked or logged after executing nxt_debug macros, which risks overwriting the active error code; saving nxt_errno immediately after the system call is recommended.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/nxt_epoll_engine.c
Comment on lines +760 to +772
for ( ;; ) {
n = read(ev->fd, &sfd, sizeof(struct signalfd_siginfo));

nxt_debug(task, "read signalfd(%d): %d", ev->fd, n);
nxt_debug(task, "read signalfd(%d): %d", ev->fd, n);

if (n != sizeof(struct signalfd_siginfo)) {
nxt_alert(task, "read signalfd(%d) failed %E", ev->fd, nxt_errno);
return;
}
if (n != sizeof(struct signalfd_siginfo)) {
if (n == -1 && nxt_errno == NXT_EAGAIN) {
return;
}

nxt_alert(task, "read signalfd(%d) failed %E", ev->fd, nxt_errno);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In nxt_epoll_signalfd_handler, nxt_errno is checked after calling the nxt_debug macro. Since nxt_debug can internally invoke system calls or library functions that modify errno, there is a risk that nxt_errno is overwritten before it is checked for NXT_EAGAIN.\n\nTo prevent this, the value of nxt_errno should be saved immediately after the read system call, before any logging or debugging macros are executed.

    for ( ;; ) {\n        nxt_err_t  err;\n\n        n = read(ev->fd, &sfd, sizeof(struct signalfd_siginfo));\n        err = (n == -1) ? nxt_errno : 0;\n\n        nxt_debug(task, \"read signalfd(%d): %d\", ev->fd, n);\n\n        if (n != sizeof(struct signalfd_siginfo)) {\n            if (n == -1 && err == NXT_EAGAIN) {\n                return;\n            }\n\n            nxt_alert(task, \"read signalfd(%d) failed %E\", ev->fd, err);\n            return;\n        }\n

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 788e4c1: errno is saved right after read() and the EAGAIN check / alert use the saved value.

Comment thread src/nxt_io_uring_engine.c
Comment on lines +1812 to +1824
if (iou->neventfd++ >= 0xFFFFFFFE) {
iou->neventfd = 0;

n = read(iou->eventfd.fd, &value, sizeof(uint64_t));

nxt_debug(&engine->task, "read(%d): %z events:%uL",
iou->eventfd.fd, n, value);

if (n != sizeof(uint64_t)) {
nxt_alert(&engine->task, "read eventfd(%d) failed %E",
iou->eventfd.fd, nxt_errno);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In nxt_io_uring_internal_cqe, nxt_errno is checked after calling the nxt_debug macro. Since nxt_debug can internally invoke system calls or library functions that modify errno, there is a risk that nxt_errno is overwritten before it is logged in nxt_alert.\n\nTo prevent this, the value of nxt_errno should be saved immediately after the read system call, before any logging or debugging macros are executed.

    if (iou->neventfd++ >= 0xFFFFFFFE) {\n        nxt_err_t  err;\n\n        iou->neventfd = 0;\n\n        n = read(iou->eventfd.fd, &value, sizeof(uint64_t));\n        err = (n != sizeof(uint64_t)) ? nxt_errno : 0;\n\n        nxt_debug(&engine->task, \"read(%d): %z events:%uL\",\n                  iou->eventfd.fd, n, value);\n\n        if (n != sizeof(uint64_t)) {\n            nxt_alert(&engine->task, \"read eventfd(%d) failed %E\",\n                      iou->eventfd.fd, err);\n        }\n    }\n

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 788e4c1: errno is saved right after read(), before nxt_debug(), and the alert prints the saved value.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7f811eec3a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/nxt_io_uring_engine.c
Comment on lines +1227 to +1229
if (engine->u.io_uring.tier >= NXT_IOU_TIER_ACCEPT) {
nxt_io_uring_arm_accept(engine, ev, slot);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid mixing completion accept with the retry timer

When the listener is disabled for EMFILE/max_connections, nxt_conn_listen_timer_handler() later calls nxt_fd_event_enable_accept() and then immediately invokes the cached lev->accept syscall path. In the ACCEPT tier this branch arms an IORING_OP_ACCEPT, so the timer path now has both a pending io_uring accept and a synchronous accept/accept4; because completion-mode CQEs never initialized lev->ready, that synchronous path decrements it from zero and can drain the backlog through the legacy loop until EAGAIN. This only happens after the 100 ms backpressure/resource retry, but it defeats the one-pending-accept invariant and can make a recovering worker monopolize accepts.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 788e4c1. The exact failure was worse than a plain invariant break: nxt_conn_io_accept() decrements lev->ready from zero (never initialized in completion mode), the wrapped uint32 latches read_ready = 1, and the legacy loop then drains the whole backlog on the recovering worker until EAGAIN — alongside the IORING_OP_ACCEPT the timer's enable_accept just armed. The fix installs an ACCEPT-tier conn_io.accept handler whose only job is to guarantee a pending oneshot accept exists (enable_accept is idempotent), so connections keep arriving one CQE at a time and stay distributed across workers. Validated by soaking a 300-connection burst against a 128-fd rlimit: 470 backpressure/timer cycles, full recovery, 100% success afterwards, no legacy-loop drains.

@andypost
andypost force-pushed the feat/io-uring-stage2 branch from 7f811ee to 788e4c1 Compare July 13, 2026 16:35
@andypost

Copy link
Copy Markdown
Owner Author

Review addressed in 788e4c1: ACCEPT-tier conn_io.accept override so the 100 ms listen-timer retry path can no longer run the synchronous accept loop alongside the armed IORING_OP_ACCEPT (validated with a 300-connection burst against a 128-fd rlimit — 470 backpressure cycles, full recovery), errno saved before nxt_debug() in both flagged spots, io_uring_prep_cancel64 added to the configure test, and a duplicated macro dropped. The branch was also rebased onto the updated stage1 head (b61f1b6), so the diff base moved; pytest subset green on the rebuilt tree.

@andypost

Copy link
Copy Markdown
Owner Author

check-whitespace was failing on a blank line at EOF introduced inside the dedup-refactor commit; since the workflow runs git log --check per commit, the fix is rewritten into that commit (now be4b2c6) rather than patched at the tip — the final tree is unchanged apart from the removed blank line. The go-1.25 isolation failure is the known test_go_isolation_rootfs_automount_tmpfs mount-order flake, failing identically on master (io_uring code isn't even compiled in that job — CI configures without --io-uring).

andypost and others added 11 commits July 24, 2026 20:00
Introduce the Stage-2 tier ladder (POLL < ACCEPT < RECV < OPT) and resolve
the kernel's capability once, from a cached throwaway-ring probe, never from
uname:

  - multishot POLL_ADD  (floor, already required by Stage 1)
  - multishot accept    functional probe: arm IORING_ACCEPT_MULTISHOT on a
                        throwaway abstract-namespace listener; an immediate
                        -EINVAL completion means unsupported, a short timeout
                        with no CQE means armed-and-waiting => supported.
  - buf_ring + multishot recv functional probe: register a tiny buffer ring
                        and arm a multishot recv with buffer-select on a
                        socketpair; a delivered (non -EINVAL) completion means
                        supported.

NXT_IO_URING_FORCE_TIER now accepts none|create|poll|accept|recv|opt and caps
the resolved tier (never above kernel support) so every degraded path is
exercisable on a modern kernel.  The behaviour at ACCEPT/RECV is still the
Stage-1 poll path in this commit; the tier byte only gates which completion
paths later commits light up.

Add SINGLE_ISSUER + DEFER_TASKRUN with an -EINVAL setup-flag fallback probe,
gated behind an explicit NXT_IO_URING_SETUP_OPT opt-in and OFF by default.
The kernel binds a ring's permitted submitter task at io_uring_setup() time
(verified on 7.0: -EEXIST on a submit from any other task, even for
SINGLE_ISSUER without DEFER_TASKRUN).  Unit creates each router worker's
engine on the router-main thread and only later runs poll() on the spawned
worker thread, so enabling the flags by default busy-loops every worker's
poll() on -EEXIST.  The flags are safe only where an engine is polled on its
creating thread (main/controller/application processes).  create() also no
longer submits any probe SQE on the engine's own ring, so the first submit --
and thus the SINGLE_ISSUER binding -- always happens in poll() on the owning
thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stage 1 disarmed a direction (POLL_REMOVE) whenever a CQE landed on a BLOCKED
direction and re-armed (POLL_ADD) on the next enable_*, ~1 SQE pair per read --
a measured contributor to the router CPU/req regression on keepalive/proxy
paths (results-stage1.md).

Multishot poll is edge-like: the kernel posts one CQE per new wait-queue
wakeup and never re-reports steady-state readiness (design §1.7a).  A BLOCKED
direction therefore cannot busy-loop, so the disarm was defensive churn.  Keep
the multishot poll armed and merely latch read_ready/write_ready without
dispatching, exactly as epoll leaves a blocked fd in its set (block_read makes
no syscall).  The subsequent enable_read/enable_write becomes a pure state
write with zero SQEs.

Invariants preserved: dispatch is still gated on state (a BLOCKED direction is
excluded from the work-queue add) and on the per-drain read_seq/write_seq
dedupe, so no double dispatch; generation counters still reject stale CQEs; the
!F_MORE re-arm still fires for a BLOCKED direction whose multishot the kernel
dropped, keeping it armed.  The conn read/write discipline consumes the latched
readiness on its next voluntary read/write.

Verified: keepalive, proxy-keepalive (c64) and close-mode proxy loads all clean
(100% 200s, zero alerts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At tier >= ACCEPT, listen sockets are armed with IORING_ACCEPT_MULTISHOT
instead of a re-armed POLL_ADD.  One SQE yields a CQE per accepted connection
with the new fd in res, and -- because accept is a *consuming* wake-one
operation -- N worker rings sharing a listen fd behave like N threads blocked
in accept(2): each connection completes on exactly one ring.  This structurally
eliminates the cross-worker thundering herd that POLL_ADD (no EPOLLEXCLUSIVE
analogue) suffered in Stage 1, the dominant measured accept-path regression
(results-stage1.md).  Measured on 8 workers under close-mode load: voluntary
context switches per accepted connection drop from ~2.5 (all workers wake) to
~0.01 (one worker consumes each connection), a ~270x reduction.

Design decisions:

  - sockaddr: multishot accept's addr buffer is shared and overwritten
    asynchronously as connections arrive, so it is unreliable per-CQE.  The
    remote address is filled with getpeername() on each accepted fd.  This
    trades the removed accept4() syscall for a getpeername() (net syscall count
    roughly even); the herd elimination, not a syscall saving, is the win.
  - accept flags: SOCK_NONBLOCK, matching Unit's accept4(..., SOCK_NONBLOCK)
    contract (the existing accept4 path passes no CLOEXEC, so neither do we),
    letting the accepted fd skip the per-conn nonblocking fixup.
  - backpressure: cancel-based, reusing the existing accept machinery
    unchanged.  When a conn slot cannot be allocated (max_connections),
    nxt_conn_accept_next -> _close_idle already calls disable_read, which now
    cancels the multishot accept (ASYNC_CANCEL by the arming's user_data), and
    the 100 ms listen timer's enable_accept re-arms it.  No new backpressure
    logic; deterministic and reversible.
  - each accept CQE feeds nxt_conn_accept directly; its poll-mode re-arm tail
    is inert because lev->socket.read_ready is never set in multishot mode.
  - EMFILE/ENFILE/etc: the multishot terminates (!F_MORE) with res < 0 and is
    routed through nxt_conn_accept_error -> 100 ms timer re-arm, unchanged.
  - !F_MORE with a live listener re-arms the multishot accept.

Invariant preservation:

  - user_data gains a self-describing ACCEPT bit (generation narrowed 14->13
    bits, still 8192 values).  An accept CQE is routed and, when stale
    (generation mismatch after cancel/close/fd-reuse), still recognised so its
    already-accepted fd is CLOSED, never leaked -- the accept analogue of the
    recv "a stale CQE must return its buffer" rule.
  - the per-direction generation counter still rejects stale accept CQEs; the
    disable/close path bumps it and cancels the accept before Unit closes the
    listen fd.
  - a connection the kernel accepted but that cannot be serviced (over the
    limit, or queued before a cancel) is closed, so no fd leaks.

Tier POLL keeps the Stage-1 re-armed POLL_ADD listener.  Verified: accept-heavy
close-mode load at c8/c64 on both direct and proxy listeners is 100% clean;
getpeername fills the real client address (access log shows 127.0.0.1, not
0.0.0.0); full tier matrix (none/create/poll/accept/recv/opt) clean at c8/c64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kernel buf_ring + multishot recv support is probed and cached, but the
operational tier is clamped to ACCEPT and the completion-mode recv read path
is not wired in this change.  This is a deliberate, documented deferral -- the
in-tree note (near the tier defines) records the three coupled constraints that
must be resolved together first, none of them small:

  1. Scope (§2.4): recv must cover data conns only, never UDS port sockets
     (SCM_RIGHTS/cred cmsgs that provided-buffer recv cannot carry).  But both
     arm read through the same engine enable_read and nxt_fd_event_t has no
     type tag to separate them -- and §1.7 forbids adding one.  So recv has to
     be driven from a completion-mode nxt_conn_io_t, not enable_read.

  2. Buffer lifetime (OQ2): the zero-copy win needs a ring buffer handed to the
     pipeline as c->read, but the router can pin c->read across the app
     round-trip and starve the ring; the safe answer is copy-out at the parse
     boundary, and a naive copy-out-everywhere model adds a userspace copy that
     makes it net-negative.  A lifecycle slip is a soak signal-11.

  3. Read discipline: Unit block_read()s after each read while multishot recv
     delivers continuously, so a blocked conn accumulates ring buffers until
     ENOBUFS and the degrade-to-poll+recv path must be robust.

Since a subtly wrong recv path would break the DEFAULT build's soak with
signal-11 -- which the acceptance gate forbids -- wiring it is left to a
focused follow-up with its own soak gate.  The ACCEPT tier already delivers
the dominant measured win (thundering-herd removal).  The RECV capability stays
detected so the follow-up only flips the clamp and lights up the tier >= RECV
branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The multishot accept starved all but one router worker.  A multishot accept is
a PERSISTENT wake-one registration: with N worker rings arming it on the same
shared listen fd, the kernel keeps completing every incoming connection into
the same (first-armed) ring's CQ regardless of that worker's busyness --
observed on a debug build as ONE thread taking all 32 connections of a c=32
keepalive load while 7 workers idled, capping throughput at a single worker.
EPOLLEXCLUSIVE avoids this because it wakes only workers actually WAITING in
epoll_wait, so busy workers shed load organically.

Replace it with a ONESHOT IORING_OP_ACCEPT re-armed after each completion.
Each ring keeps at most one pending accept, so the set of pending accepts is
the set of workers that have reached poll(): the kernel distributes
connections among them like N threads blocked in accept(2) -- herd-free
(accept is consuming) AND load-balanced (a saturated worker re-arms late,
naturally yielding to idle workers).  IORING_OP_ACCEPT evaluates the backlog
at submission and completes immediately when it is non-empty, so bursts cannot
stall (level-equivalent, contract item #5).  This also lowers the accept-tier
kernel floor: the probe now tests plain IORING_OP_ACCEPT, no multishot flag.

Re-arm placement: measured both candidates on a debug build under c=64
keepalive and close-mode loads.  (a) inline in the accept CQE handler and
(b) a work item behind the listen handler give identical per-thread accept
distributions (8 workers x 11-14% each) because the SQ ring is only flushed
at the top of poll(), so both re-arms reach the kernel in the same submit --
after the worker's queued work has run.  (a) is kept as the simpler form with
no extra work item.

Kept from the multishot design: the SOCK_NONBLOCK accept flag, the
getpeername() sockaddr strategy, the self-describing ACCEPT user_data bit,
generation-based stale-CQE rejection, cancel-based backpressure via
disable_read + the 100 ms listen timer, and the EMFILE error path.  A stale
oneshot accept CQE still closes the fd it carries: such a CQE arises when the
kernel completed the accept before a disable/close-issued ASYNC_CANCEL took
effect -- the connection was already consumed from the backlog, the disable's
generation bump marks the CQE stale, and the handler close(2)s the fd before
the generation check returns (comment in nxt_io_uring_accept_cqe).  New in the
oneshot flow: benign accept errors (EAGAIN/ECONNABORTED) now re-arm
immediately -- under multishot the registration survived such errors, but a
oneshot arming is consumed even by an error CQE and the listener would
otherwise strand.

Verified (kernel 7.0, default tier ACCEPT):
  - distribution: c=64 keepalive AND close-mode both spread across all
    8 workers at 11-14% each (was: one worker 100%);
  - throughput sanity vs FORCE_TIER=poll: keepalive c=64 -8%, proxy c=64
    +8..14% (was -58%/-84%) -- starvation gone;
  - tier matrix none/create/poll/accept/recv/opt at c8 and c64: 100% 200s,
    zero alerts; soak 40x c8 + 10x c64: zero bad rounds, zero signal-11;
    SIGTERM under load x10 clean; pytest subset green on io_uring-default
    (50 passed) and plain (5 passed) builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five verified bugs, none yet observed in testing but all reachable:

  1. Generation-wrap fd death.  The stale-CQE comparisons matched the 13-bit
     generation from user_data against the raw free-running uint16_t slot
     counter: once a slot generation passed 8191, EVERY completion for that fd
     was rejected as stale, leaving it permanently dead.  All three compare
     sites now reduce the slot counter with the new nxt_iou_gen() macro; a
     comment documents the modulo-2^13 wrap semantics (a mis-accept would need
     exactly 8192 disable/enable cycles while one CQE is in flight -- CQEs are
     bounded by one CQ-drain window).

  2. Ring fd + mmap leak per failed create.  setup() could return NXT_ERROR at
     the tier/feature gates AFTER io_uring_queue_init_params() succeeded but
     BEFORE iou->tier was assigned, and free() keyed queue_exit on the tier.
     Ring liveness is now tracked by a dedicated ring_inited flag set at init
     and consumed by free().

  3. Duplicate user_data registrations from oneshot_read/oneshot_write
     (reachable via the sendfile job path re-arming a multishot-enabled conn):
     they armed without checking for a live arming -- registering a second
     poll under the SAME user_data since the generation was not bumped -- and
     set the opposite direction INACTIVE without removing its armed poll,
     leaking it.  Both now remove any live arming first (the remove bumps the
     generation, invalidating in-flight completions) before arming the oneshot.

  4. Zeroed client address after peer RST.  The getpeername() result was
     discarded, so a peer that dropped the connection between the kernel's
     accept and the CQ drain produced a conn with sa_family 0 flowing into
     access logs and ACL matching.  Such connections are now dropped: close
     the fd, keep the pre-allocated conn for the next completion, keep
     accepting -- the same disposition as the poll path's accept4() failing
     for a torn-down peer.

  5. Negative-fd table corruption.  nxt_io_uring_slot() cast fd to uint32_t
     for the grow check, so fd -1 would pass it, shrink the table to a single
     slot and index it out of bounds.  Negative fds now return NULL, which all
     callers tolerate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tup flags

Three hardening fixes from the review:

  1. Stranded queued signals.  The signalfd handler read exactly ONE siginfo
     per dispatch, but the engine's multishot poll is edge-like and the
     per-drain dedupe collapses multiple CQEs into a single handler run, so a
     second queued signal (e.g. SIGTERM behind SIGCHLD) stayed in the signalfd
     until an unrelated future signal produced a new wakeup.  The handler now
     drains to EAGAIN, dispatching the signal handler once per siginfo.

  2. Seccomp exposure of the startup probe.  Every --io-uring build performs
     one io_uring_setup(2) at registration; a seccomp policy answering
     io_uring syscalls with SCMP_ACT_KILL delivers an uncatchable SIGSYS and
     kills the binary at startup (SCMP_ACT_ERRNO policies were already
     handled).  That cannot be prevented from userspace, so minimise exposure:
     a NXT_IO_URING=0|off environment kill-switch is now checked BEFORE any
     io_uring syscall in both the registration probe and create(), and the
     caveat is documented in the engine header comment and the --io-uring
     help text.  Verified under strace: NXT_IO_URING=0 starts on epoll_edge
     with zero io_uring_setup calls.

  3. Signals require signalfd.  Without NXT_HAVE_SIGNALFD the engine used the
     sigwait()-thread fallback whose signal() carries a signo -- which the
     ring's eventfd doorbell drops -- so Unix signals would be silently lost.
     The registration probe (and, belt-and-braces, create()) now refuses the
     engine on such builds, keeping epoll as the default.

Also REMOVE the NXT_IO_URING_SETUP_OPT machinery (SINGLE_ISSUER +
DEFER_TASKRUN with the EINVAL retry): the kernel binds the ring's permitted
submitter task at io_uring_setup() time, and Unit creates router-worker
engines on the router-main thread while poll() runs on the worker thread, so
the flags busy-loop every worker on -EEXIST.  The opt-in was a trap for
exactly the deployments that would reach for it; the §2.5 findings stay
recorded in a comment at the setup site, to be revisited only if ring
creation moves onto the polling thread.  The registration probe now also
reuses the cached kernel-tier probe instead of standing up a second
throwaway ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ade path

Extract the io_uring->epoll degrade blocks duplicated between
nxt_event_engine_create() and nxt_event_engine_change() into one static
nxt_event_engine_fallback() helper (also removing their divergent exit
styles), and fix the unhandled double-failure state in change():

When change() had already freed the old facility and both the requested
create() and the epoll fallback failed, engine->event still held the OLD
facility's vtable over a freed/half-written union; a later
nxt_event_engine_free() or poll would dispatch stale function pointers into
dead state.  The double-failure path now zeroes both the vtable copy and the
engine union, and nxt_event_engine_free() NULL-guards event.free for exactly
this case.  The helper also zeroes the union before the fallback create
claims its arm (belt-and-braces; the failed create's own cleanup contract
already zeroes it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apply the review's verified cleanups (net -265 lines):

  - Share the four helpers the io_uring engine had copied from epoll, which
    it can reuse verbatim because multishot poll is equally edge-like:
    nxt_epoll_test_accept4 + the accept4 conn-io variant, the edge-mode
    recvbuf EOF shim, and the signalfd plumbing.  The signalfd creation is
    extracted into a shared nxt_epoll_signalfd_create() (engine-specific
    registration stays local: epoll_ctl vs enable_read), and the shared
    handler drains to EAGAIN -- required by io_uring's collapsed edge
    delivery, equally correct under epoll's level-triggered registration.
    Declarations live in nxt_event_engine.h beside the epoll externs.

  - Remove the RECV-tier probe machinery: the completion-mode recv path is
    not implemented, so probing buf_ring + multishot recv at every startup
    bought nothing.  The tier defines and FORCE_TIER names stay (documented
    as reserved; "recv"/"opt" resolve to the full implemented tier), and the
    deferral design note stays; the follow-up reintroduces a fresh probe
    with the actual implementation.

  - Probe IORING_OP_ACCEPT with io_uring_get_probe_ring()/
    io_uring_opcode_supported() instead of arming a throwaway listener and
    waiting 20 ms: accept is a first-class opcode (unlike multishot poll's
    flag bit), so the opcode probe suffices and the startup delay goes away.

  - Adopt epoll's eventfd counter trick: read the doorbell eventfd once per
    2^32-2 posts instead of a drain loop per wakeup; multishot poll posts a
    CQE per write()-side wakeup regardless of the accumulated counter, like
    EPOLLET, so skipping the read never suppresses future doorbells.

  - Drop the nsubmitted counter: close() now calls io_uring_submit()
    unconditionally -- liburing skips the io_uring_enter() syscall when the
    SQ ring is empty, so the counter only duplicated ring state.

  - Pass the caller's resolved slot into nxt_io_uring_arm()/_arm_accept()
    instead of a second table lookup (nothing between the lookups can
    realloc the table).

  - Register the appended (non-default) io_uring engine service through the
    existing nxt_service_add(), keeping its duplicate check; the prepend
    branch stays bespoke since ordering is the whole point there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verified nxt_event_interface_t contract (with the readiness-vs-completion
mismatch inventory), the io_uring engine design including the findings made
during bring-up (edge-like multishot poll delivery, shared-listener accept
distribution, task-bound SINGLE_ISSUER rings), and the A/B benchmark method
and results for both stages against the epoll engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ring

- Install an ACCEPT-tier conn_io accept handler: the generic
  nxt_conn_io_accept() drains the backlog with synchronous accept(2)
  batches driven by lev->ready, which completion mode never initializes.
  Invoked from the 100 ms listen-timer retry path it decremented
  lev->ready from zero, latched read_ready off the wrapped counter and
  looped the legacy accept path until EAGAIN -- monopolizing the backlog
  on the recovering worker while the IORING_OP_ACCEPT that the timer's
  enable_accept had just armed was also pending.  The replacement only
  guarantees a pending oneshot accept exists (enable_accept is
  idempotent); connections keep arriving one CQE at a time, distributed
  across workers.  Verified with a 300-connection burst against a
  128-fd rlimit: 470 backpressure cycles, full recovery, no legacy-loop
  drains.

- Save errno before nxt_debug() in the shared signalfd handler and the
  eventfd doorbell read: logging can clobber errno, misclassifying
  EAGAIN or printing a bogus error code.

- Extend the configure feature test with io_uring_prep_cancel64(),
  which the ACCEPT tier uses to cancel pending oneshot accepts.

- Drop a duplicated nxt_iou_gen definition (the Stage 1 backport and
  this branch's review-fix commit both carried one).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@andypost

Copy link
Copy Markdown
Owner Author

Rebased onto the updated stage1 base (962b72d, itself rebased onto post-1.36.0 master with three rounds of review fixes folded in). The stage1 fixes were propagated into stage2's own architecture where they applied — most notably: stage2's completion-mode accept had the same missing-SOCK_CLOEXEC issue as stage1's accept4 shim (its comment even documented the now-false premise); fixed inside the commits that introduced it, and runtime-verified via /proc fdinfo that every accepted socket carries O_CLOEXEC. The arm-failure escalation now covers all 13 arm sites including the oneshot IORING_OP_ACCEPT re-arms, the error-only single-dispatch policy and the delete/disable slot-armed gate survive the dispatch rework (an armed accept is covered by the gate via read_armed, and remove() cancels it with ASYNC_CANCEL by exact user_data), and stage1's and stage2's NXT_IO_URING kill switches were deduplicated into the env_disabled helper ('0' or 'off') checked in both the probe and create(). Both build variants clean, test subset green on each, tier smokes (tier 2 / FORCE_TIER=poll / FORCE_TIER=none / NXT_IO_URING=0) all verified. New head bd3f296.

@andypost
andypost force-pushed the feat/io-uring-stage2 branch from 64c8cb5 to bd3f296 Compare July 24, 2026 18:07
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.

1 participant