From 46110efc681f11177a06252c604e35a0880898fb Mon Sep 17 00:00:00 2001 From: Ben Jarvis Date: Sun, 17 May 2026 20:19:45 +0000 Subject: [PATCH 1/5] xlio: skip poll group poll when idle, toggle force_poll_mode dynamically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The XLIO Ultra API only supports busy polling — there is no eventfd or CQ-event notification we can sleep on. Until now, the first XLIO socket created on a thread pinned the libevpl event loop into force_poll_mode for the lifetime of the framework instance, so any thread that owned an XLIO listener spun at 100% CPU even when no TCP traffic was flowing (e.g. a deployment receiving only RDMA connections). Track the count of non-listen XLIO sockets per thread and: - Short-circuit evpl_xlio_poll when the count is zero; the XLIO library is not called at all in that case. - Drop force_poll_mode on the 1→0 transition so libevpl can drain its spin_ns idle window and park in evpl_core_wait. - Re-arm force_poll_mode on the 0→1 transition so per-iteration polling resumes for active connections. A 10ms idle_timer runs alongside the poll callback whenever any XLIO socket exists. It drives a single xlio_poll_group_poll/flush, which is what services listen-socket accepts and any deferred XLIO work while the main poll is short-circuited. The trade-off is up to ~10ms of latency for the first inbound connection on an otherwise-idle thread. --- src/core/xlio/common.h | 13 +++++++++++++ src/core/xlio/xlio.c | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/core/xlio/common.h b/src/core/xlio/common.h index e77a8fb6..e03276de 100644 --- a/src/core/xlio/common.h +++ b/src/core/xlio/common.h @@ -112,12 +112,14 @@ struct evpl_xlio { struct xlio_api_t *extra; struct evpl_xlio_api *api; struct evpl_poll *poll; + struct evpl_timer idle_timer; xlio_poll_group_t poll_group; struct evpl_xlio_socket **active_sockets; struct evpl_xlio_buffer *free_xlio_buffers; struct evpl_xlio_zc *free_zc; int num_active_sockets; int max_active_sockets; + int num_connections; }; struct evpl_xlio_accepted_socket { @@ -216,6 +218,17 @@ evpl_xlio_pending_close( #endif // if 0 s->socket = 0; + + if (!s->listen) { + --xlio->num_connections; + if (xlio->num_connections == 0) { + /* Last active connection gone — release the event loop so + * it can drain spin_ns of idleness and park in + * evpl_core_wait until the idle_timer (or some other + * source) wakes it. */ + evpl->force_poll_mode = 0; + } + } } } /* evpl_xlio_pending_close */ diff --git a/src/core/xlio/xlio.c b/src/core/xlio/xlio.c index 0a971b1c..fd033a84 100644 --- a/src/core/xlio/xlio.c +++ b/src/core/xlio/xlio.c @@ -265,6 +265,17 @@ evpl_xlio_socket_rx( evpl_xlio_socket_check_active(xlio, s); } /* evpl_xlio_socket_rx */ +static void +evpl_xlio_idle_timer( + struct evpl *evpl, + struct evpl_timer *timer) +{ + struct evpl_xlio *xlio = container_of(timer, struct evpl_xlio, idle_timer); + + xlio->extra->xlio_poll_group_poll(xlio->poll_group); + xlio->extra->xlio_poll_group_flush(xlio->poll_group); +} /* evpl_xlio_idle_timer */ + static void evpl_xlio_poll( struct evpl *evpl, @@ -275,6 +286,14 @@ evpl_xlio_poll( struct evpl_bind *bind; int i, res; + /* When this thread has no established XLIO connections, skip the entire + * poll. The idle_timer wakes us every 10ms to service listen sockets and + * detect new incoming work; once any connection exists, the regular poll + * path takes over again. */ + if (xlio->num_connections == 0) { + return; + } + xlio->extra->xlio_poll_group_poll(xlio->poll_group); if (xlio->num_active_sockets) { @@ -376,6 +395,7 @@ evpl_xlio_destroy( if (xlio->poll) { evpl_remove_poll(evpl, xlio->poll); + evpl_remove_timer(evpl, &xlio->idle_timer); evpl->force_poll_mode = 0; } @@ -461,8 +481,21 @@ evpl_xlio_socket_init( } if (!xlio->poll) { - xlio->poll = evpl_add_poll(evpl, NULL, NULL, evpl_xlio_poll, xlio); - evpl->force_poll_mode = 1; + xlio->poll = evpl_add_poll(evpl, NULL, NULL, evpl_xlio_poll, xlio); + + /* 10ms fallback: services listen sockets and any other XLIO work + * while the main poll callback is short-circuited because + * num_connections == 0. */ + evpl_add_timer(evpl, &xlio->idle_timer, evpl_xlio_idle_timer, 10000UL); + } + + if (!listen) { + if (xlio->num_connections == 0) { + /* First active connection on this thread — pin the event loop + * into poll mode so we get per-iteration polling latency. */ + evpl->force_poll_mode = 1; + } + ++xlio->num_connections; } s->readable = 0; From 556a1d397634af0c63858a0ef88be76ab47c4e58 Mon Sep 17 00:00:00 2001 From: Ben Jarvis Date: Mon, 18 May 2026 11:51:18 +0000 Subject: [PATCH 2/5] xlio: initialize addrlen before getpeername in accept callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evpl_address_alloc() zero-fills the struct, so srcaddr->addrlen is 0 when xlio_socket_getpeername() is called. With a zero-sized buffer the kernel writes nothing and srcaddr->sa is left as all zeros — sa_family ends up 0 (AF_UNSPEC), which evpl_address_get_address() does not recognise, leaving the caller's string buffer uninitialised. The visible symptom is corrupted peer addresses in logs for accepted XLIO connections (e.g. NFS disconnect notifications showing junk where the client IP should be). Compare with the kernel-socket accept path in core/socket/tcp.c which already initialises addrlen to sizeof(remote_addr->sa) before accept(). --- src/core/xlio/xlio.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/xlio/xlio.c b/src/core/xlio/xlio.c index fd033a84..c400b030 100644 --- a/src/core/xlio/xlio.c +++ b/src/core/xlio/xlio.c @@ -225,6 +225,11 @@ evpl_xlio_socket_accept( srcaddr = evpl_address_alloc(); + /* getpeername needs addrlen initialized to the buffer size; evpl_address_alloc + * zero-fills the struct, so without this the call writes nothing and we'd + * pass an empty sockaddr (sa_family = 0) up to the application. */ + srcaddr->addrlen = sizeof(srcaddr->sa); + xlio->extra->xlio_socket_getpeername(sock, srcaddr->addr, &srcaddr->addrlen); rc = xlio->extra->xlio_socket_detach_group(sock); From 6978299f25da7d3c1a167747069d648e0c09e557 Mon Sep 17 00:00:00 2001 From: Ben Jarvis Date: Mon, 18 May 2026 12:35:27 +0000 Subject: [PATCH 3/5] core: handle non-INET address families in evpl_address_get_address Without a fallback, an evpl_address whose sa_family is anything other than AF_INET or AF_INET6 (including AF_UNSPEC / 0 from a partially- populated sockaddr) caused the caller's str buffer to be left uninitialised, exposing stack garbage in log lines. Emit "(unspecified)" instead. --- src/core/address.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/address.h b/src/core/address.h index 6fdf6852..30357755 100644 --- a/src/core/address.h +++ b/src/core/address.h @@ -83,5 +83,7 @@ evpl_address_get_address( sin6 = (struct sockaddr_in6 *) sa; inet_ntop(AF_INET6, &sin6->sin6_addr, addr_str, sizeof(addr_str)); snprintf(str, len, "[%s]:%d", addr_str, ntohs(sin6->sin6_port)); + } else { + snprintf(str, len, "(unspecified)"); } } /* evpl_bind_get_local_address */ From 645af2921d19fa50eaac412e8fbc9c5ac7401a9a Mon Sep 17 00:00:00 2001 From: Ben Jarvis Date: Mon, 18 May 2026 12:37:23 +0000 Subject: [PATCH 4/5] xlio: resolve peer address on the worker thread to avoid accept-time race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evpl_xlio_socket_accept() runs on the listener thread and previously called xlio_socket_getpeername() on the brand-new sockinfo_tcp before handing the accepted socket off to a worker thread. That call races XLIO's connection state machine: m_connected is populated during the lwip clone, but between the time the new sockinfo's lock is released (accept_lwip_cb) and the time the user callback fires (accept_connection_xlio_socket), state transitions on other contexts can clear or invalidate m_connected. Even when getpeername returns 0, sock_addr::get_sa_by_family() will silently copy zero bytes and zero the caller's addrlen — producing a sockaddr with sa_family = 0 that gets propagated to bind->remote. Under load (e.g. nconnect=16 with churn), this surfaced as corrupted peer addresses in NFS disconnect logs. Move the peer-address resolution into evpl_xlio_tcp_attach() on the worker thread, alongside the existing getsockname() call. By the time attach runs the socket has been re-attached to the worker's poll group and there's no concurrent state transition to race. Pass NULL for remote_address from the listener-thread callback; bind->remote is now populated synchronously inside attach. Supersedes the addrlen-init workaround from 556a1d3 — that call site no longer exists. --- src/core/xlio/tcp.c | 13 +++++++++++++ src/core/xlio/xlio.c | 17 ++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/core/xlio/tcp.c b/src/core/xlio/tcp.c index ef4488ca..ea60c057 100644 --- a/src/core/xlio/tcp.c +++ b/src/core/xlio/tcp.c @@ -330,6 +330,19 @@ evpl_xlio_tcp_attach( bind->local->addrlen = sslen; memcpy(bind->local->addr, &ss, sslen); + /* Now that the socket is attached to this thread's poll group, resolve + * the peer address here. evpl_xlio_socket_accept (the listener-thread + * callback) deliberately leaves remote NULL because calling + * getpeername() in that context races XLIO's connection state machine + * for short-lived connections. */ + sslen = sizeof(ss); + rc = xlio->extra->xlio_socket_getpeername(sock, (struct sockaddr *) &ss, &sslen); + evpl_xlio_abort_if(rc < 0, "xlio_socket_getpeername failed"); + + bind->remote = evpl_address_alloc(); + bind->remote->addrlen = sslen; + memcpy(bind->remote->addr, &ss, sslen); + rc = xlio->extra->xlio_socket_update(s->socket, 0, (uintptr_t) s); evpl_xlio_abort_if(rc, "Failed to update socket"); diff --git a/src/core/xlio/xlio.c b/src/core/xlio/xlio.c index c400b030..d8504bfb 100644 --- a/src/core/xlio/xlio.c +++ b/src/core/xlio/xlio.c @@ -207,7 +207,6 @@ evpl_xlio_socket_accept( struct evpl_xlio *xlio; struct evpl_bind *listen_bind; struct evpl_xlio_socket *ls; - struct evpl_address *srcaddr; struct evpl_xlio_accepted_socket *accepted_socket; int rc; @@ -223,20 +222,16 @@ evpl_xlio_socket_accept( xlio = evpl_framework_private(evpl, EVPL_FRAMEWORK_XLIO); - srcaddr = evpl_address_alloc(); - - /* getpeername needs addrlen initialized to the buffer size; evpl_address_alloc - * zero-fills the struct, so without this the call writes nothing and we'd - * pass an empty sockaddr (sa_family = 0) up to the application. */ - srcaddr->addrlen = sizeof(srcaddr->sa); - - xlio->extra->xlio_socket_getpeername(sock, srcaddr->addr, &srcaddr->addrlen); - rc = xlio->extra->xlio_socket_detach_group(sock); evpl_xlio_abort_if(rc, "Failed to detach socket from group"); - listen_bind->accept_callback(evpl, listen_bind, srcaddr, accepted_socket, listen_bind->private_data); + /* The peer address is resolved on the worker thread inside + * evpl_xlio_tcp_attach, after the socket has been re-attached to that + * thread's poll group. Calling getpeername() from this listener-thread + * context races XLIO's internal connection state machine and can return + * stale or empty data when sockets churn quickly. */ + listen_bind->accept_callback(evpl, listen_bind, NULL, accepted_socket, listen_bind->private_data); } /* evpl_xlio_socket_accept */ From e7d3e239e888b01f12a9a4c46f5f71d4307b851f Mon Sep 17 00:00:00 2001 From: Ben Jarvis Date: Mon, 18 May 2026 12:43:05 +0000 Subject: [PATCH 5/5] xlio: only register the idle timer on threads with a listen socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker threads (which only hold accepted/outbound sockets) don't need the 10ms wake-up: when they have connections force_poll_mode keeps the regular poll running every iteration, and when they don't there is no listen socket on the same thread that could possibly hand them new work. Only listener threads need to be woken up periodically to service incoming accepts. Gate evpl_add_timer() on first listen-socket creation and skip it entirely for non-listen call sites. The flag is one-shot — once a thread has hosted any listen socket it keeps the timer until the XLIO framework instance is destroyed, which avoids tracking listen counts without losing the optimisation on worker threads. --- src/core/xlio/common.h | 1 + src/core/xlio/xlio.c | 21 ++++++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/core/xlio/common.h b/src/core/xlio/common.h index e03276de..ebdb7281 100644 --- a/src/core/xlio/common.h +++ b/src/core/xlio/common.h @@ -120,6 +120,7 @@ struct evpl_xlio { int num_active_sockets; int max_active_sockets; int num_connections; + int idle_timer_active; }; struct evpl_xlio_accepted_socket { diff --git a/src/core/xlio/xlio.c b/src/core/xlio/xlio.c index d8504bfb..0b7216e8 100644 --- a/src/core/xlio/xlio.c +++ b/src/core/xlio/xlio.c @@ -395,7 +395,9 @@ evpl_xlio_destroy( if (xlio->poll) { evpl_remove_poll(evpl, xlio->poll); - evpl_remove_timer(evpl, &xlio->idle_timer); + if (xlio->idle_timer_active) { + evpl_remove_timer(evpl, &xlio->idle_timer); + } evpl->force_poll_mode = 0; } @@ -482,14 +484,19 @@ evpl_xlio_socket_init( if (!xlio->poll) { xlio->poll = evpl_add_poll(evpl, NULL, NULL, evpl_xlio_poll, xlio); - - /* 10ms fallback: services listen sockets and any other XLIO work - * while the main poll callback is short-circuited because - * num_connections == 0. */ - evpl_add_timer(evpl, &xlio->idle_timer, evpl_xlio_idle_timer, 10000UL); } - if (!listen) { + if (listen) { + /* Listen sockets need a periodic poll to catch incoming accepts + * even when num_connections == 0 (poll callback short-circuits). + * Worker threads never hit this branch and therefore never pay + * for the timer: when a worker has connections the regular poll + * runs, and when it doesn't there's nothing for XLIO to service. */ + if (!xlio->idle_timer_active) { + evpl_add_timer(evpl, &xlio->idle_timer, evpl_xlio_idle_timer, 10000UL); + xlio->idle_timer_active = 1; + } + } else { if (xlio->num_connections == 0) { /* First active connection on this thread — pin the event loop * into poll mode so we get per-iteration polling latency. */