Hypersonic
view release on metacpan or search on metacpan
lib/Hypersonic/Event/IOUring.pm view on Meta::CPAN
package Hypersonic::Event::IOUring;
use strict;
use warnings;
use 5.010;
use parent 'Hypersonic::Event::Role';
our $VERSION = '0.19';
sub name { 'io_uring' }
sub available {
return 0 unless $^O eq 'linux';
# Check kernel version >= 5.13.
#
# We need kernel 5.13+ (not just 5.1+) because the readiness-only
# backend in 0.19+ uses io_uring_prep_poll_multishot() which was
# added in Linux 5.13 / liburing 2.1 (Aug 2021). Multi-shot poll
# is essential: with one-shot poll_add the userspace re-arm in
# gen_get_fd races against the main loop's recv() in a way that
# makes the freshly re-armed (level-triggered) poll fire while
# the buffer still has unread data, then fire AGAIN with an
# empty buffer after recv() drains, causing the next iteration
# to recv() and get EAGAIN, which the main loop treats as a
# disconnect. This bug killed all sequential-keep-alive tests
# (t/2100..t/2102, t/0035 WebSocket echo) the first time we
# tried readiness-only mode. Multi-shot lets the kernel manage
# the re-arm atomically with the readiness check, avoiding the
# race entirely.
#
# Kernels < 5.13 fall back to epoll automatically via
# Hypersonic::Event::best_backend's priority list. cpansmoker
# hosts on Debian 12 (kernel 6.1+) and Fedora 43 (kernel 6.x)
# all satisfy this; Debian 11 (5.10) and older fall back.
my $ver = `uname -r 2>/dev/null` || '';
my ($major, $minor) = $ver =~ /^(\d+)\.(\d+)/;
return 0 unless $major && ($major > 5 || ($major == 5 && $minor >= 13));
# Check for liburing headers
my $has_header = -f '/usr/include/liburing.h'
|| -f '/usr/local/include/liburing.h'
|| -f '/usr/include/x86_64-linux-gnu/liburing.h';
return 0 unless $has_header;
# io_uring may be disabled at the kernel level. RHEL9 ships with
# kernel.io_uring_disabled=2 by default; a value of 1 or 2 means
# the syscall returns EINVAL/EPERM regardless of liburing being
# linkable. Bail before we sink time into a compile+link probe.
if (open my $fh, '<', '/proc/sys/kernel/io_uring_disabled') {
my $disabled = <$fh>;
close $fh;
chomp $disabled if defined $disabled;
return 0 if defined $disabled && $disabled ne '0';
}
# Compile-link-and-RUN probe. A pure link check passes on systems
# that have liburing installed but where io_uring_setup() will
# nevertheless fail at runtime (kernel disabled, sandboxing, missing
# liburing.so at exec time). Also probe for io_uring_prep_poll_multishot
# symbol availability - the symbol was added in liburing 2.1, and
# the actual multishot poll operation requires kernel 5.13+. If
# either is missing we want to fall back to epoll silently.
require Hypersonic::JIT::Util;
return Hypersonic::JIT::Util->can_run(
'',
'-luring',
'struct io_uring ring; int rc = io_uring_queue_init(8, &ring, 0); '
. 'if (rc < 0) return 1; '
. 'struct io_uring_sqe* sqe = io_uring_get_sqe(&ring); '
. 'if (!sqe) { io_uring_queue_exit(&ring); return 2; } '
. 'io_uring_prep_poll_multishot(sqe, 0, POLLIN); '
. 'io_uring_sqe_set_data(sqe, (void*)0); '
. 'io_uring_queue_exit(&ring); return 0;',
"#include <liburing.h>\n#include <poll.h>",
);
}
sub includes {
# liburing.h for the server loop.
# <poll.h> for POLLIN (the readiness mask we pass to prep_poll_add).
# <sys/epoll.h> is needed for the UA::Async slot-tracking helpers
# (gen_create_loop / _add_with_slot / _get_slot) - io_uring is
# Linux 5.1+ which always has epoll.
return "#include <liburing.h>\n#include <poll.h>\n#include <sys/epoll.h>";
}
lib/Hypersonic/Event/IOUring.pm view on Meta::CPAN
# table; the underlying socket stays open (and the peer never sees a
# TCP FIN) until io_uring drops its file reference. Since
# io_uring_prep_cancel is async, that drop happens at an unspecified
# later time -- the client can wait indefinitely for the EOF that
# tells it "the response is complete".
#
# Symptom: short-lived `Connection: close` HTTP requests appear to
# succeed on the server (response is fully sent) but the client's
# blocking recv() loop never returns 0. Tests like t/2100 hang at
# the first POST.
#
# Fix: call shutdown(fd, SHUT_RDWR) here BEFORE submitting the
# cancel. shutdown operates on the socket directly and unconditionally
# sends FIN to the peer regardless of any reference counts.
# io_uring's struct file ref is irrelevant to whether TCP FIN goes
# out. The caller's close() afterwards still does the right thing
# (marks the fd unused in our process); io_uring will eventually
# release its own ref when the cancel completes async.
#
# Bumping the generation counter (still done) is what closes the
# fd-reuse race: from this moment on, any pending CQE that still
# carries the old generation is silently discarded by gen_get_fd,
# even if accept() reuses the fd number before the cancel completes.
#
# We use io_uring_prep_cancel (not io_uring_prep_poll_remove) because
# poll_remove's signature flipped from void* to __u64 around liburing
# 2.0 whereas prep_cancel's void* user_data is stable from 0.7+.
#
# The cancel SQE carries user_data=0 so its own CQE is cheaply
# distinguishable from real poll CQEs (which always have non-zero
# user_data thanks to the generation in the high 32 bits).
sub gen_del {
my ($class, $builder, $loop_var, $fd_var) = @_;
$builder->line('{')
->line(" if ($fd_var >= 0 && $fd_var < MAX_FD) {")
->line(" g_iouring_fd_gen[$fd_var]++;")
->line(' }')
->comment('Force-send TCP FIN regardless of io_uring file refs')
->line(" shutdown($fd_var, SHUT_RDWR);")
->line(' struct io_uring_sqe* _dsqe = io_uring_get_sqe(&ring);')
->line(' if (_dsqe) {')
->line(" io_uring_prep_cancel(_dsqe, (void*)(uintptr_t)$fd_var, 0);")
->line(' io_uring_sqe_set_data(_dsqe, NULL);')
->line(' io_uring_submit(&ring);')
->line(' }')
->line('}');
}
# Copy CQEs out of the ring buffer into our private value array, then
# release all consumed ring slots at once with io_uring_cq_advance.
# This avoids BUG 1 (pointer staleness) - we never reference ring
# slots after they've been released. We also lose nothing functionally
# because the only fields we ever need from a CQE are user_data and
# res.
#
# CRUCIAL: do NOT `continue;` on -ETIME or -EINTR. The main loop's
# shutdown-drain branch (which force-closes all connections when
# g_shutdown is set) lives AFTER gen_wait but BEFORE the event-
# processing loop. If we `continue;` here, we never reach that branch,
# and a server with idle keep-alive connections that gets SIGTERMed
# will spin in gen_wait forever (no CQEs arriving means perpetual
# -ETIME). Instead, set count=0 and fall through so the shutdown
# branch runs and the cleanup pass drains the connections. This is
# the same shape as epoll_wait()=0 in the Epoll backend.
sub gen_wait {
my ($class, $builder, $loop_var, $events_var, $count_var, $timeout_var) = @_;
$builder->line('struct io_uring_cqe* cqe;')
->line('struct __kernel_timespec ts;')
->line("ts.tv_sec = $timeout_var / 1000;")
->line("ts.tv_nsec = ($timeout_var % 1000) * 1000000;")
->blank
->line("int $count_var = 0;")
->line('static hs_iouring_event_t events_buf[MAX_EVENTS];')
->line("$events_var = events_buf;")
->blank
->comment('Block until at least one completion is ready')
->line('int wait_result = io_uring_wait_cqe_timeout(&ring, &cqe, &ts);')
->if('wait_result == 0')
->comment('Drain all currently available CQEs (BUG 1 fix: copy values)')
->line('unsigned head;')
->line('io_uring_for_each_cqe(&ring, head, cqe) {')
->line(" if ($count_var < MAX_EVENTS) {")
->line(" events_buf[$count_var].ud = (uint64_t)(uintptr_t)io_uring_cqe_get_data(cqe);")
->line(" events_buf[$count_var].res = cqe->res;")
->line(" $count_var++;")
->line(' }')
->line('}')
->line("io_uring_cq_advance(&ring, (unsigned)$count_var);")
->elsif('wait_result == -ETIME || wait_result == -EINTR')
->comment('Timeout / signal: fall through with count=0 so the')
->comment('cleanup-on-shutdown branch can run. Do NOT continue;')
->else
->line('break;')
->endif;
}
# Extract fd from our private value-array CQE. NO io_uring_cqe_seen
# call here - gen_wait already advanced the ring cursor once for the
# whole batch.
#
# Filters applied (any failure -> continue, skip this event):
# * ud == 0 -> CQE is from a cancel SQE
# * res < 0 -> poll cancelled/errored (-ECANCELED, -EBADF, ...)
# * fd out of range -> defensive guard against corruption
# * stale generation -> fd was closed and reused, this CQE is for the
# old lifetime (see BUG 2 in defines() comment)
sub gen_get_fd {
my ($class, $builder, $events_var, $index_var, $fd_var) = @_;
$builder->line("uint64_t _ud = ${events_var}[$index_var].ud;")
->line("int _res = ${events_var}[$index_var].res;")
->if('_ud == 0')
->line('continue;') # cancel-SQE completion
->endif
->if('_res < 0')
->line('continue;') # poll cancelled / errored
->endif
->line('uint32_t _ud_gen = (uint32_t)(_ud >> 32);')
->line("int $fd_var = (int)(_ud & 0xFFFFFFFFu);")
( run in 1.180 second using v1.01-cache-2.11-cpan-a5162978ef8 )