[PATCH RESEND v2 13/13] selftests/proc: check that removing a polled entry wakes its pollers

From: Christian Brauner

Date: Wed Nov 04 2026 - 19:00:01 EST


Poll the channel file of a sunrpc cache in /proc/net of a network
namespace of its own and let the namespace die while the poller sleeps:

- the poller wakes up and gets POLLERR | POLLPRI

- so does an epoll with an item on the entry

- a poll of the removed entry keeps reporting POLLERR, and a read of it
fails with -EIO

The namespace is torn down from a workqueue, so a regression stalls the
test for ten seconds before it fails.

Signed-off-by: Christian Brauner (Amutable) <brauner@xxxxxxxxxx>
---
tools/testing/selftests/proc/.gitignore | 1 +
tools/testing/selftests/proc/Makefile | 1 +
tools/testing/selftests/proc/config | 2 +
tools/testing/selftests/proc/proc-remove-polled.c | 189 ++++++++++++++++++++++
4 files changed, 193 insertions(+)

diff --git a/tools/testing/selftests/proc/.gitignore b/tools/testing/selftests/proc/.gitignore
index ed863db1688f..d06bb704d29d 100644
--- a/tools/testing/selftests/proc/.gitignore
+++ b/tools/testing/selftests/proc/.gitignore
@@ -31,5 +31,6 @@
/self
/setns-dcache
/setns-sysvipc
+/proc-remove-polled
/proc-sysctl-readdir-churn
/thread-self
diff --git a/tools/testing/selftests/proc/Makefile b/tools/testing/selftests/proc/Makefile
index 95c9212fb670..e4f5142da47d 100644
--- a/tools/testing/selftests/proc/Makefile
+++ b/tools/testing/selftests/proc/Makefile
@@ -30,6 +30,7 @@ TEST_GEN_PROGS += setns-sysvipc
TEST_GEN_PROGS += thread-self
TEST_GEN_PROGS += proc-multiple-procfs
TEST_GEN_PROGS += proc-fsconfig-hidepid
+TEST_GEN_PROGS += proc-remove-polled
TEST_GEN_PROGS += proc-sysctl-readdir-churn
TEST_GEN_PROGS += proc-pidns
TEST_GEN_PROGS += proc-remove-slow-sysctl
diff --git a/tools/testing/selftests/proc/config b/tools/testing/selftests/proc/config
index 15b307189392..3cc12e91e5a7 100644
--- a/tools/testing/selftests/proc/config
+++ b/tools/testing/selftests/proc/config
@@ -6,6 +6,8 @@ CONFIG_NET_NS=y
CONFIG_IPV6=y
CONFIG_TUN=y
CONFIG_USERFAULTFD=y
+CONFIG_USER_NS=y
+CONFIG_SUNRPC=y
CONFIG_SOUND=y
CONFIG_SND=y
CONFIG_SND_DUMMY=m
diff --git a/tools/testing/selftests/proc/proc-remove-polled.c b/tools/testing/selftests/proc/proc-remove-polled.c
new file mode 100644
index 000000000000..e313d22be43f
--- /dev/null
+++ b/tools/testing/selftests/proc/proc-remove-polled.c
@@ -0,0 +1,189 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * A poll() or epoll on a /proc entry must wake up when the entry is removed,
+ * and report the removed entry as an error. The channel file of a sunrpc
+ * cache lives in /proc/net of a network namespace and goes away with it.
+ */
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <pthread.h>
+#include <sched.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/epoll.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "../kselftest_harness.h"
+
+#define CHANNEL_FMT "/proc/%d/net/rpc/auth.unix.ip/channel"
+#define TIMEOUT_MS 10000
+
+struct netns_child {
+ pid_t pid;
+ int go; /* closing it lets the child exit, and its netns die */
+};
+
+struct poller {
+ int fd;
+ int done;
+ int ret;
+ short revents;
+};
+
+static int write_file(const char *path, const char *fmt, ...)
+{
+ char buf[64];
+ va_list ap;
+ int fd, len, ret;
+
+ va_start(ap, fmt);
+ len = vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+ fd = open(path, O_WRONLY | O_CLOEXEC);
+ if (fd < 0)
+ return -1;
+ ret = write(fd, buf, len) == len ? 0 : -1;
+ close(fd);
+ return ret;
+}
+
+/*
+ * A child in a network namespace of its own, owned by a user namespace that
+ * maps us to root, so that we may open the files in its /proc/net. The child
+ * is the only thing that keeps the namespace alive.
+ */
+static int start_netns_child(struct netns_child *c)
+{
+ uid_t uid = getuid();
+ gid_t gid = getgid();
+ int ready[2], go[2];
+ char ch = 'f';
+
+ if (pipe2(ready, O_CLOEXEC) || pipe2(go, O_CLOEXEC))
+ return -1;
+ c->pid = fork();
+ if (c->pid < 0)
+ return -1;
+ if (c->pid == 0) {
+ close(ready[0]);
+ close(go[1]);
+ if (!unshare(CLONE_NEWUSER) &&
+ !write_file("/proc/self/setgroups", "deny") &&
+ !write_file("/proc/self/uid_map", "0 %d 1", uid) &&
+ !write_file("/proc/self/gid_map", "0 %d 1", gid) &&
+ !unshare(CLONE_NEWNET))
+ ch = 'r';
+ if (write(ready[1], &ch, 1) == 1 && ch == 'r')
+ (void)!read(go[0], &ch, 1);
+ _exit(0);
+ }
+ close(ready[1]);
+ close(go[0]);
+ c->go = go[1];
+ if (read(ready[0], &ch, 1) != 1 || ch != 'r') {
+ close(ready[0]);
+ close(c->go);
+ waitpid(c->pid, NULL, 0);
+ errno = EPERM;
+ return -1;
+ }
+ close(ready[0]);
+ return 0;
+}
+
+static void end_netns_child(struct netns_child *c)
+{
+ close(c->go);
+ waitpid(c->pid, NULL, 0);
+}
+
+static int open_channel(struct netns_child *c, char *path, size_t len)
+{
+ snprintf(path, len, CHANNEL_FMT, c->pid);
+ return open(path, O_RDONLY | O_CLOEXEC);
+}
+
+static void *poll_thread(void *arg)
+{
+ struct poller *p = arg;
+ struct pollfd pfd = { .fd = p->fd, .events = POLLPRI };
+ ssize_t ret;
+
+ p->ret = poll(&pfd, 1, TIMEOUT_MS);
+ p->revents = pfd.revents;
+ ret = write(p->done, "x", 1);
+ return (void *)ret;
+}
+
+TEST(poll_wakes_on_removal)
+{
+ struct netns_child c;
+ struct poller p = {};
+ struct pollfd pfd;
+ char path[64], buf[16];
+ pthread_t pt;
+ int done[2];
+
+ if (start_netns_child(&c))
+ SKIP(return, "network namespace: %s", strerror(errno));
+ p.fd = open_channel(&c, path, sizeof(path));
+ if (p.fd < 0)
+ SKIP(return, "%s: %s", path, strerror(errno));
+
+ /* A fresh channel has nothing to read and no error. */
+ pfd = (struct pollfd){ .fd = p.fd, .events = POLLIN | POLLPRI };
+ ASSERT_EQ(poll(&pfd, 1, 0), 0);
+
+ ASSERT_EQ(pipe2(done, O_CLOEXEC), 0);
+ p.done = done[1];
+ ASSERT_EQ(pthread_create(&pt, NULL, poll_thread, &p), 0);
+
+ /* The poller sleeps on the entry, now its namespace goes away. */
+ end_netns_child(&c);
+ pfd = (struct pollfd){ .fd = done[0], .events = POLLIN };
+ EXPECT_EQ(poll(&pfd, 1, TIMEOUT_MS), 1) {
+ TH_LOG("removing %s didn't wake its poller", path);
+ }
+ ASSERT_EQ(pthread_join(pt, NULL), 0);
+ EXPECT_EQ(p.ret, 1);
+ EXPECT_EQ(p.revents & (POLLERR | POLLPRI), POLLERR | POLLPRI);
+
+ /* The removed entry stays an error, and there is nothing to read. */
+ pfd = (struct pollfd){ .fd = p.fd, .events = POLLIN };
+ EXPECT_EQ(poll(&pfd, 1, 0), 1);
+ EXPECT_TRUE(pfd.revents & POLLERR);
+ EXPECT_EQ(read(p.fd, buf, sizeof(buf)), -1);
+ EXPECT_EQ(errno, EIO);
+}
+
+TEST(epoll_wakes_on_removal)
+{
+ struct epoll_event ev = { .events = EPOLLPRI };
+ struct netns_child c;
+ char path[64];
+ int fd, epfd;
+
+ if (start_netns_child(&c))
+ SKIP(return, "network namespace: %s", strerror(errno));
+ fd = open_channel(&c, path, sizeof(path));
+ if (fd < 0)
+ SKIP(return, "%s: %s", path, strerror(errno));
+
+ epfd = epoll_create1(EPOLL_CLOEXEC);
+ ASSERT_GE(epfd, 0);
+ ASSERT_EQ(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev), 0);
+ ASSERT_EQ(epoll_wait(epfd, &ev, 1, 0), 0);
+
+ /* The item sits on the entry's queue, now its namespace goes away. */
+ end_netns_child(&c);
+ EXPECT_EQ(epoll_wait(epfd, &ev, 1, TIMEOUT_MS), 1) {
+ TH_LOG("removing %s didn't wake epoll", path);
+ }
+ EXPECT_EQ(ev.events & (EPOLLERR | EPOLLPRI), EPOLLERR | EPOLLPRI);
+}
+
+TEST_HARNESS_MAIN

--
2.53.0