* [PATCH net v6 1/2] tun: bound the NAPI receive queue
2026-11-13 21:22 [PATCH net v6 0/2] tun: bound the NAPI receive queue Antonio Quartulli
@ 2026-11-13 21:22 ` Antonio Quartulli
2026-11-13 21:22 ` [PATCH net v6 2/2] selftests: net: add a vhost-net test for the tun NAPI queue bound Antonio Quartulli
2026-11-13 21:28 ` [PATCH net v6 0/2] tun: bound the NAPI receive queue netdev-bot+sinfo
2 siblings, 0 replies; 4+ messages in thread
From: Antonio Quartulli @ 2026-11-13 21:22 UTC (permalink / raw)
To: willemdebruijn.kernel, jasowangio
Cc: Antonio Quartulli, andrew+netdev, davem, edumazet, kuba, pabeni,
horms, mst, eperezma, shuah, ast, daniel, hawk, john.fastabend,
sdf, maheshb, peterpenkov96, netdev, virtualization,
linux-kselftest, bpf
With IFF_NAPI, tun_get_user() queues every written packet for NAPI on
an unbounded list. When the poll runs asynchronously (threaded NAPI)
and is slower than the writer, the queue grows until the system runs
out of memory. An unprivileged user can do this from its own user and
network namespaces, and the queued skbs are not charged to its memcg:
in a test VM, a uid 1000 process limited to 128 MB caused a global OOM
in ~6 seconds. The socket send buffer cannot bound this: tun sets it to
INT_MAX, and the build_skb() and XDP batch paths do not check it.
Drop packets once more than netdev_max_backlog of them are queued, like
enqueue_to_backlog() does, and count them as rx_dropped, before
allocating and copying them. This covers write(), sendmsg() and the
batches of XDP buffers vhost-net passes to tun_sendmsg(), which share
the queue. Zerocopy buffers of dropped packets are completed. The check
is lockless, so concurrent writers can each still add one packet.
The NAPI poll moved the whole queue out, processed up to its budget and
put the rest back: while it ran, the queue looked empty to writers, so
with the poll on another CPU the backlog could still grow without limit.
Take at most budget packets instead, so that the queue never looks
emptier than it is.
Dropping because the queue is full also schedules NAPI. vhost-net sends
with MSG_MORE while it has more packets, and tun_get_user() schedules
NAPI only after the last one or once more than NAPI_POLL_WEIGHT are
queued. With a netdev_max_backlog below that, the queue would not drain
until vhost-net ran out of packets; with zerocopy, vhost-net never
would, as it stops sending when too many queued buffers are pending.
Reproducer, as an unprivileged user in a new user and network
namespace, without kernel modifications or fault injection:
ip tuntap add t0 mode tun; ip addr add 10.11.0.1/24 dev t0
ip link add d0 type dummy; ip addr add 10.20.0.1/24 dev d0
ip link set t0 up; ip link set d0 up; sysctl net.ipv4.ip_forward=1
install ~500 iptables FORWARD rules and a MASQUERADE rule on d0
open t0 with IFF_TUN | IFF_NO_PI | IFF_NAPI
echo 1 > /sys/class/net/t0/threaded
write() 1400-byte IPv4 packets for 10.20.0.2 in a loop
Tested in an x86-64 VM with 1 and 2 vCPUs: with the reproducer running
for 15 seconds, with 1400 to 65000-byte packets, memory use stays flat
and the forwarding rate is unchanged; the tun kselftest passes, as it
does without the patch; and a vhost-net test driving the batch, copy and
zerocopy paths (next patch) passes, while it fails without the patch.
Still open: the limit counts packets rather than bytes and applies per
queue, so with large packets and many queues (IFF_MULTI_QUEUE works in a
user namespace too) the queued skbs, which are not charged to the
writer's memcg, can still add up to an OOM. This is hard to reach in
practice: with large packets the writer's copy is slower than the NAPI
poll, so the queue does not fill. With 64 KB writes the reproducer never
reached the limit and memory use stayed flat.
Fixes: 943170998b20 ("tun: enable NAPI for TUN/TAP driver")
Signed-off-by: Antonio Quartulli <antonio@mandelbit.com>
---
drivers/net/tun.c | 55 ++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 47 insertions(+), 8 deletions(-)
diff --git a/drivers/net/tun.c b/drivers/net/tun.c
index 86fd44e10755..73f13684722e 100644
--- a/drivers/net/tun.c
+++ b/drivers/net/tun.c
@@ -58,6 +58,7 @@
#include <linux/nsproxy.h>
#include <linux/virtio_net.h>
#include <linux/rcupdate.h>
+#include <net/hotdata.h>
#include <net/net_namespace.h>
#include <net/netns/generic.h>
#include <net/rtnetlink.h>
@@ -227,6 +228,16 @@ struct veth {
static void tun_flow_init(struct tun_struct *tun);
static void tun_flow_uninit(struct tun_struct *tun);
+/* In IFF_NAPI mode written packets wait in the tfile's sk_write_queue for the
+ * NAPI poll, which may run later in a kthread or ksoftirqd. Bound that queue
+ * like the per-CPU backlog of netif_rx().
+ */
+static bool tun_napi_queue_full(const struct sk_buff_head *queue)
+{
+ return skb_queue_len_lockless(queue) >
+ READ_ONCE(net_hotdata.max_backlog);
+}
+
static int tun_napi_receive(struct napi_struct *napi, int budget)
{
struct tun_file *tfile = container_of(napi, struct tun_file, napi);
@@ -237,21 +248,24 @@ static int tun_napi_receive(struct napi_struct *napi, int budget)
__skb_queue_head_init(&process_queue);
+ /* Take at most budget skbs, so that the queue never looks emptier to
+ * writers than it is.
+ */
spin_lock(&queue->lock);
- skb_queue_splice_tail_init(queue, &process_queue);
+ if (skb_queue_len(queue) <= budget) {
+ skb_queue_splice_tail_init(queue, &process_queue);
+ } else {
+ while (skb_queue_len(&process_queue) < budget &&
+ (skb = __skb_dequeue(queue)))
+ __skb_queue_tail(&process_queue, skb);
+ }
spin_unlock(&queue->lock);
- while (received < budget && (skb = __skb_dequeue(&process_queue))) {
+ while ((skb = __skb_dequeue(&process_queue))) {
napi_gro_receive(napi, skb);
++received;
}
- if (!skb_queue_empty(&process_queue)) {
- spin_lock(&queue->lock);
- skb_queue_splice(&process_queue, queue);
- spin_unlock(&queue->lock);
- }
-
return received;
}
@@ -1862,6 +1876,20 @@ static ssize_t tun_get_user(struct tun_struct *tun, struct tun_file *tfile,
zerocopy = true;
}
+ /* NAPI queue full: drop before allocating, and schedule NAPI, which a
+ * MSG_MORE sender may not have done yet. Lockless: concurrent writers
+ * can each still add one.
+ */
+ if (tfile->napi_enabled &&
+ unlikely(tun_napi_queue_full(&tfile->sk.sk_write_queue))) {
+ local_bh_disable();
+ napi_schedule(&tfile->napi);
+ local_bh_enable();
+ net_zcopy_put(msg_control);
+ dev_core_stats_rx_dropped_inc(tun->dev);
+ return total_len;
+ }
+
if (!frags && tun_can_build_skb(tun, tfile, len, noblock, zerocopy)) {
/* For the packet that is not easy to be processed
* (e.g gso or jumbo packet), we will do it at after
@@ -2568,6 +2596,17 @@ static int tun_xdp_one(struct tun_struct *tun,
return -EIO;
}
+ /* NAPI queue full: drop, and schedule NAPI so that it drains even if
+ * nothing in this batch gets queued
+ */
+ if (tfile->napi_enabled &&
+ unlikely(tun_napi_queue_full(&tfile->sk.sk_write_queue))) {
+ napi_schedule(&tfile->napi);
+ put_page(virt_to_head_page(xdp->data));
+ dev_core_stats_rx_dropped_inc(tun->dev);
+ return 0;
+ }
+
xdp_prog = rcu_dereference(tun->xdp_prog);
if (xdp_prog) {
if (gso->gso_type) {
--
2.56.0
^ permalink raw reply related [flat|nested] 4+ messages in thread* [PATCH net v6 2/2] selftests: net: add a vhost-net test for the tun NAPI queue bound
2026-11-13 21:22 [PATCH net v6 0/2] tun: bound the NAPI receive queue Antonio Quartulli
2026-11-13 21:22 ` [PATCH net v6 1/2] " Antonio Quartulli
@ 2026-11-13 21:22 ` Antonio Quartulli
2026-11-13 21:28 ` [PATCH net v6 0/2] tun: bound the NAPI receive queue netdev-bot+sinfo
2 siblings, 0 replies; 4+ messages in thread
From: Antonio Quartulli @ 2026-11-13 21:22 UTC (permalink / raw)
To: willemdebruijn.kernel, jasowangio
Cc: Antonio Quartulli, andrew+netdev, davem, edumazet, kuba, pabeni,
horms, mst, eperezma, shuah, ast, daniel, hawk, john.fastabend,
sdf, maheshb, peterpenkov96, netdev, virtualization,
linux-kselftest, bpf
Feed a tap device with IFF_NAPI from vhost-net with netdev_max_backlog
set to 8, in the three ways vhost-net passes packets to tun: batches of
XDP buffers, single packets with MSG_MORE and zerocopy buffers. Check
that excess packets are dropped and counted, that the queue keeps
draining and that every buffer comes back.
The wrapper loads vhost_net and, if it can reload it, also runs the
zerocopy variant with experimental_zcopytx=1.
Signed-off-by: Antonio Quartulli <antonio@mandelbit.com>
---
tools/testing/selftests/net/.gitignore | 1 +
tools/testing/selftests/net/Makefile | 2 +
tools/testing/selftests/net/config | 1 +
tools/testing/selftests/net/tun_vhost.c | 222 +++++++++++++++++++++++
tools/testing/selftests/net/tun_vhost.sh | 23 +++
5 files changed, 249 insertions(+)
create mode 100644 tools/testing/selftests/net/tun_vhost.c
create mode 100755 tools/testing/selftests/net/tun_vhost.sh
diff --git a/tools/testing/selftests/net/.gitignore b/tools/testing/selftests/net/.gitignore
index 27761239d069..2f4104b1aa6d 100644
--- a/tools/testing/selftests/net/.gitignore
+++ b/tools/testing/selftests/net/.gitignore
@@ -56,6 +56,7 @@ timestamping
tls
tools
tun
+tun_vhost
txring_overwrite
txtimestamp
udp_splice_checksum
diff --git a/tools/testing/selftests/net/Makefile b/tools/testing/selftests/net/Makefile
index baea3e7f0f8f..b3965a976451 100644
--- a/tools/testing/selftests/net/Makefile
+++ b/tools/testing/selftests/net/Makefile
@@ -123,6 +123,7 @@ TEST_PROGS := \
test_vxlan_vnifiltering.sh \
tfo_passive.sh \
traceroute.sh \
+ tun_vhost.sh \
txtimestamp.sh \
udpgro.sh \
udpgro_bench.sh \
@@ -177,6 +178,7 @@ TEST_GEN_FILES := \
tcp_mmap \
tfo \
timestamping \
+ tun_vhost \
txring_overwrite \
txtimestamp \
udpgso \
diff --git a/tools/testing/selftests/net/config b/tools/testing/selftests/net/config
index 3e7ce41bd64d..c3bb0d572325 100644
--- a/tools/testing/selftests/net/config
+++ b/tools/testing/selftests/net/config
@@ -136,6 +136,7 @@ CONFIG_TRACEPOINTS=y
CONFIG_TUN=y
CONFIG_USER_NS=y
CONFIG_VETH=y
+CONFIG_VHOST_NET=m
CONFIG_VLAN_8021Q=y
CONFIG_VXLAN=m
CONFIG_XFRM_INTERFACE=m
diff --git a/tools/testing/selftests/net/tun_vhost.c b/tools/testing/selftests/net/tun_vhost.c
new file mode 100644
index 000000000000..c9bf6d07cc26
--- /dev/null
+++ b/tools/testing/selftests/net/tun_vhost.c
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Feed a tap device with IFF_NAPI from vhost-net, in batches of XDP buffers,
+ * as single packets with MSG_MORE, or as zerocopy buffers, with
+ * netdev_max_backlog set to 8: excess packets must be dropped and counted,
+ * the queue must keep draining, and every buffer must come back.
+ *
+ * Author: Antonio Quartulli <antonio@mandelbit.com>
+ */
+
+#define _GNU_SOURCE
+#include <endian.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <linux/if.h>
+#include <linux/if_tun.h>
+#include <linux/vhost.h>
+#include <linux/virtio_config.h>
+#include <linux/virtio_net.h>
+#include <linux/virtio_ring.h>
+#include <sys/eventfd.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+
+#include "kselftest_harness.h"
+
+#define RING 256
+#define BUFSZ 2048
+#define HDRLEN sizeof(struct virtio_net_hdr_v1)
+#define PKTS (64 * RING)
+
+static long read_long(const char *fmt, const char *arg, long def)
+{
+ char path[128];
+ long v = def;
+ FILE *f;
+
+ snprintf(path, sizeof(path), fmt, arg);
+ f = fopen(path, "r");
+ if (f) {
+ if (fscanf(f, "%ld", &v) != 1)
+ v = def;
+ fclose(f);
+ }
+ return v;
+}
+
+FIXTURE(tun_vhost)
+{
+ char ifname[IFNAMSIZ];
+ char bl[16]; /* saved netdev_max_backlog */
+ int bl_fd;
+ struct vring vr;
+ int vhost;
+ int kick;
+ int tap;
+};
+
+FIXTURE_VARIANT(tun_vhost)
+{
+ bool limit_sndbuf; /* no XDP batches */
+ bool zerocopy;
+ int pktlen;
+};
+
+FIXTURE_VARIANT_ADD(tun_vhost, batch)
+{
+ .pktlen = 64,
+};
+
+FIXTURE_VARIANT_ADD(tun_vhost, copy)
+{
+ .limit_sndbuf = true,
+ .pktlen = 64,
+};
+
+FIXTURE_VARIANT_ADD(tun_vhost, zerocopy)
+{
+ .limit_sndbuf = true,
+ .zerocopy = true,
+ .pktlen = 1000,
+};
+
+FIXTURE_SETUP(tun_vhost)
+{
+ struct ifreq ifr = { .ifr_flags = IFF_TAP | IFF_NO_PI | IFF_VNET_HDR |
+ IFF_NAPI };
+ unsigned long long features = 1ULL << VIRTIO_F_VERSION_1;
+ struct vhost_vring_state state = { .index = 1, .num = RING };
+ struct vhost_vring_file file = { .index = 1 };
+ struct vhost_vring_addr addr = { .index = 1 };
+ int hdrlen = HDRLEN, sndbuf = 1 << 20, i, fd;
+ struct vhost_memory *mem;
+ unsigned char *bufs;
+ void *ring;
+ bool zc;
+
+ self->bl_fd = -1;
+ self->vhost = open("/dev/vhost-net", O_RDWR);
+ if (self->vhost < 0)
+ SKIP(return, "no /dev/vhost-net");
+ zc = read_long("/sys/module/vhost_net/parameters/%s",
+ "experimental_zcopytx", 0);
+ if (variant->zerocopy && !zc)
+ SKIP(return, "vhost_net experimental_zcopytx not set");
+ if (!variant->limit_sndbuf && zc)
+ SKIP(return, "vhost_net does not batch with zerocopy");
+
+ self->tap = open("/dev/net/tun", O_RDWR);
+ ASSERT_GE(self->tap, 0);
+ strcpy(ifr.ifr_name, "tvh%d");
+ ASSERT_EQ(ioctl(self->tap, TUNSETIFF, &ifr), 0);
+ memcpy(self->ifname, ifr.ifr_name, IFNAMSIZ);
+ ASSERT_EQ(ioctl(self->tap, TUNSETVNETHDRSZ, &hdrlen), 0);
+ if (variant->limit_sndbuf)
+ ASSERT_EQ(ioctl(self->tap, TUNSETSNDBUF, &sndbuf), 0);
+ fd = socket(AF_INET, SOCK_DGRAM, 0);
+ ASSERT_EQ(ioctl(fd, SIOCGIFFLAGS, &ifr), 0);
+ ifr.ifr_flags |= IFF_UP;
+ ASSERT_EQ(ioctl(fd, SIOCSIFFLAGS, &ifr), 0);
+ close(fd);
+
+ /* each buffer: vnet header and an IPv4 frame for another host, which
+ * the stack drops without counting it
+ */
+ ASSERT_EQ(posix_memalign((void **)&bufs, 4096, RING * BUFSZ), 0);
+ memset(bufs, 0, RING * BUFSZ);
+ ASSERT_EQ(posix_memalign(&ring, 4096, vring_size(RING, 4096)), 0);
+ memset(ring, 0, vring_size(RING, 4096));
+ vring_init(&self->vr, RING, ring, 4096);
+ for (i = 0; i < RING; i++) {
+ unsigned char *eth = bufs + i * BUFSZ + HDRLEN;
+
+ eth[0] = 0x02;
+ eth[5] = 0x02;
+ eth[12] = 0x08;
+ eth[14] = 0x45;
+ self->vr.desc[i].addr = htole64((unsigned long)eth - HDRLEN);
+ self->vr.desc[i].len = htole32(HDRLEN + variant->pktlen);
+ self->vr.avail->ring[i] = htole16(i);
+ }
+
+ /* buffer addresses are passed as is: map them 1:1 */
+ mem = calloc(1, sizeof(*mem) + sizeof(mem->regions[0]));
+ ASSERT_NE(mem, NULL);
+ mem->nregions = 1;
+ mem->regions[0].guest_phys_addr = (unsigned long)bufs;
+ mem->regions[0].userspace_addr = (unsigned long)bufs;
+ mem->regions[0].memory_size = RING * BUFSZ;
+
+ self->kick = eventfd(0, 0);
+ ASSERT_GE(self->kick, 0);
+ addr.desc_user_addr = (unsigned long)self->vr.desc;
+ addr.avail_user_addr = (unsigned long)self->vr.avail;
+ addr.used_user_addr = (unsigned long)self->vr.used;
+ ASSERT_EQ(ioctl(self->vhost, VHOST_SET_OWNER, NULL), 0);
+ ASSERT_EQ(ioctl(self->vhost, VHOST_SET_FEATURES, &features), 0);
+ ASSERT_EQ(ioctl(self->vhost, VHOST_SET_MEM_TABLE, mem), 0);
+ ASSERT_EQ(ioctl(self->vhost, VHOST_SET_VRING_NUM, &state), 0);
+ state.num = 0;
+ ASSERT_EQ(ioctl(self->vhost, VHOST_SET_VRING_BASE, &state), 0);
+ ASSERT_EQ(ioctl(self->vhost, VHOST_SET_VRING_ADDR, &addr), 0);
+ file.fd = self->kick;
+ ASSERT_EQ(ioctl(self->vhost, VHOST_SET_VRING_KICK, &file), 0);
+ file.fd = self->tap;
+ ASSERT_EQ(ioctl(self->vhost, VHOST_NET_SET_BACKEND, &file), 0);
+ free(mem);
+}
+
+FIXTURE_TEARDOWN(tun_vhost)
+{
+ /* restore first: closing vhost blocks if buffers are not returned */
+ if (self->bl_fd >= 0 &&
+ pwrite(self->bl_fd, self->bl, strlen(self->bl), 0) < 0)
+ TH_LOG("cannot restore netdev_max_backlog to %s", self->bl);
+ close(self->vhost);
+ close(self->tap);
+}
+
+TEST_F(tun_vhost, napi_queue_bounded)
+{
+ const char *stat = "/sys/class/net/%s/statistics/rx_packets";
+ const char *drop = "/sys/class/net/%s/statistics/rx_dropped";
+ long rx = read_long(stat, self->ifname, -1);
+ long dropped = read_long(drop, self->ifname, -1);
+ unsigned short idx = 0;
+ int n, ms;
+
+ /* lowered only here, so that a failed setup leaves it alone */
+ self->bl_fd = open("/proc/sys/net/core/netdev_max_backlog", O_RDWR);
+ ASSERT_GE(self->bl_fd, 0);
+ n = pread(self->bl_fd, self->bl, sizeof(self->bl) - 1, 0);
+ ASSERT_GT(n, 0);
+ self->bl[n] = '\0';
+ ASSERT_EQ(pwrite(self->bl_fd, "8", 1, 0), 1);
+
+ /* post the whole ring, wait for every buffer to come back, repeat */
+ for (n = 0; n < PKTS; n += RING) {
+ idx += RING;
+ __atomic_store_n(&self->vr.avail->idx, htole16(idx),
+ __ATOMIC_RELEASE);
+ ASSERT_EQ(eventfd_write(self->kick, 1), 0);
+ for (ms = 0; le16toh(__atomic_load_n(&self->vr.used->idx,
+ __ATOMIC_ACQUIRE)) != idx;
+ ms++) {
+ ASSERT_LT(ms, 5000) TH_LOG("buffers not returned");
+ usleep(1000);
+ }
+ }
+
+ rx = read_long(stat, self->ifname, -1) - rx;
+ dropped = read_long(drop, self->ifname, -1) - dropped;
+ TH_LOG("sent %d, delivered %ld, dropped %ld", PKTS, rx, dropped);
+ EXPECT_GT(dropped, 0); /* bounded */
+ EXPECT_GE(rx, PKTS / 16); /* draining while the sender sends */
+ EXPECT_EQ(rx + dropped, PKTS);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/net/tun_vhost.sh b/tools/testing/selftests/net/tun_vhost.sh
new file mode 100755
index 000000000000..d92707de5e29
--- /dev/null
+++ b/tools/testing/selftests/net/tun_vhost.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-2.0
+#
+# Run tun_vhost with vhost_net loaded, and once more with zerocopy enabled
+# if the module can be reloaded.
+
+ksft_skip=4
+param=/sys/module/vhost_net/parameters/experimental_zcopytx
+
+modprobe -q vhost_net
+[ -e /dev/vhost-net ] || exit $ksft_skip
+
+./tun_vhost
+ret=$?
+
+zc=$(cat "$param" 2>/dev/null)
+if [ "$zc" = 0 ] && modprobe -q -r vhost_net 2>/dev/null; then
+ modprobe vhost_net experimental_zcopytx=1 &&
+ { ./tun_vhost -v zerocopy || ret=1; }
+ modprobe -q -r vhost_net
+ modprobe vhost_net experimental_zcopytx="$zc"
+fi
+exit $ret
--
2.56.0
^ permalink raw reply related [flat|nested] 4+ messages in thread