Drive the NFSD control plane from many namespaces at once, to exercise
every edge of the lock nesting:
nn->nfsd_mutex -> nfsd_global_mutex -> nfsd_file_cache_mutex
Each worker owns a namespace and churns between "no serv" and "serv with
threads", mixing in the operations that cross into the host-wide locks:
start/stop a server nn -> global (nfsd_users 0->1->0, notifiers)
-> cache (cache init/shutdown)
empty LISTENER_SET creates and destroys a serv in one call, so the
host-wide refcount goes 0->1->0 on its own
nfsd.fh/flush the cache lock with nothing else held
read filecache likewise
read pool_stats nn, reached through svc_info.mutex
Random churn alone is a weak race finder, so the run ends with
synchronized rounds where every namespace attempts the host-wide 0->1
transition at the same instant. That window only opened up once the
per-namespace lock stopped serializing namespaces against each other.
The test asserts little by itself; the result that matters is that the
kernel did not warn. The taint word is sampled before and after and a
newly set TAINT_WARN fails the run, which catches lockdep splats,
WARN_ON()s and refcount saturation alike. Add PROVE_LOCKING and friends
to the config, and log a note if lockdep looks absent.
Two things can silently gut the coverage, so both are reported rather
than left to look like a pass:
- without lockdep there is very little for the kernel to complain
about;
- a server already running outside these namespaces pins the
host-wide refcount above zero for the whole run, so the 0->1
transition the synchronized rounds are built around never happens.
Each worker leaves only NFSv4.1 enabled. A fresh namespace has v3 on,
which makes nfsd_needs_lockd() true, and the lockd that comes up then
waits out an RPC timeout against an rpcbind that is not there on every
single server start.
Every namespace binds the same port; they are isolated, so that must
work, and a bind failure is a louder signal than a silent pass. Sized
from nproc, capped at 8 namespaces and 5s of churn by default, and
tunable with NFSD_STRESS_WORKERS and NFSD_STRESS_SECS. The barrier waits
with a deadline so a worker that dies cannot wedge the run, and the test
carries an explicit timeout because the harness otherwise caps it at
TEST_TIMEOUT_DEFAULT.
Assisted-by: LLM
Signed-off-by: Jeff Layton <[email protected]>
---
tools/testing/selftests/nfsd/.gitignore | 1 +
tools/testing/selftests/nfsd/Makefile | 1 +
tools/testing/selftests/nfsd/config | 6 +
tools/testing/selftests/nfsd/nfsd_netns_stress.c | 572 +++++++++++++++++++++++
tools/testing/selftests/nfsd/settings | 2 +-
5 files changed, 581 insertions(+), 1 deletion(-)
diff --git a/tools/testing/selftests/nfsd/.gitignore
b/tools/testing/selftests/nfsd/.gitignore
index 0304b80eb844..2347491c634d 100644
--- a/tools/testing/selftests/nfsd/.gitignore
+++ b/tools/testing/selftests/nfsd/.gitignore
@@ -1,2 +1,3 @@
nfsd_netlink_listener
nfsd_netns_isolation
+nfsd_netns_stress
diff --git a/tools/testing/selftests/nfsd/Makefile
b/tools/testing/selftests/nfsd/Makefile
index 2b7c44c3fb00..b29bf642c0ad 100644
--- a/tools/testing/selftests/nfsd/Makefile
+++ b/tools/testing/selftests/nfsd/Makefile
@@ -3,5 +3,6 @@ CFLAGS += $(KHDR_INCLUDES) -Wall
TEST_GEN_PROGS := nfsd_netlink_listener
TEST_GEN_PROGS += nfsd_netns_isolation
+TEST_GEN_PROGS += nfsd_netns_stress
include ../lib.mk
diff --git a/tools/testing/selftests/nfsd/config
b/tools/testing/selftests/nfsd/config
index 0eef03af3503..c6407806b58b 100644
--- a/tools/testing/selftests/nfsd/config
+++ b/tools/testing/selftests/nfsd/config
@@ -12,3 +12,9 @@ CONFIG_INOTIFY_USER=y
CONFIG_SUNRPC=y
CONFIG_NFSD=y
CONFIG_NFSD_V4=y
+
+# nfsd_netns_stress leans on lockdep to find anything; without these the
+# soak still runs but proves very little.
+CONFIG_PROVE_LOCKING=y
+CONFIG_DEBUG_MUTEXES=y
+CONFIG_DEBUG_ATOMIC_SLEEP=y
diff --git a/tools/testing/selftests/nfsd/nfsd_netns_stress.c
b/tools/testing/selftests/nfsd/nfsd_netns_stress.c
new file mode 100644
index 000000000000..7ca278a5d4a0
--- /dev/null
+++ b/tools/testing/selftests/nfsd/nfsd_netns_stress.c
@@ -0,0 +1,572 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Concurrency soak for the NFSD control plane across network namespaces.
+ *
+ * NFSD's control plane is serialized by three locks, nested in this order:
+ *
+ * nn->nfsd_mutex -> nfsd_global_mutex -> nfsd_file_cache_mutex
+ *
+ * Most of what used to be one module-wide mutex is now per-namespace, so
+ * namespaces run their control planes in parallel and only meet on the
+ * host-wide bits: the refcount that brings the open file cache and the
+ * NFSv4 global tables up and down, and the address-notifier registration.
+ *
+ * This test exists to drive every one of those edges at once from many
+ * namespaces. It asserts little by itself -- the point is to give lockdep
+ * something to work with, so it is close to worthless without
+ * CONFIG_PROVE_LOCKING=y. What it does check is that the kernel did not
+ * warn: the taint word is sampled before and after, and a newly set
+ * TAINT_WARN fails the run. That catches lockdep splats, WARN_ON()s and
+ * refcount saturation alike.
+ *
+ * Each worker drives these, which between them cover every edge:
+ *
+ * start a server nn -> global (nfsd_users 0->1, notifiers 0->1)
+ * -> cache (nfsd_file_cache_init)
+ * stop a server nn -> global (1->0) -> cache (cache_shutdown)
+ * empty LISTENER_SET creates and destroys a serv in one call, so the
+ * host-wide refcount goes 0->1->0 on its own
+ * nfsd.fh/flush the cache lock with nothing else held
+ * read filecache likewise
+ * read pool_stats nn, reached through svc_info.mutex
+ *
+ * Random churn alone is a weak race finder, so the run ends with
+ * synchronized rounds where every worker attempts the host-wide 0->1
+ * transition at the same instant. That is the window that only opened up
+ * once the per-namespace lock stopped serializing namespaces against each
+ * other.
+ *
+ * Tunable with NFSD_STRESS_WORKERS and NFSD_STRESS_SECS.
+ */
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <sched.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <sys/mount.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <net/if.h>
+#include <netinet/in.h>
+
+#include "../kselftest_harness.h"
+#include "nfsd_netlink.h"
+
+#define NFSD_MNT "/mnt/nfsd"
+#define EXPKEY_FLUSH "/proc/net/rpc/nfsd.fh/flush"
+
+/*
+ * Every worker binds the same port. They are in different namespaces, so
+ * that has to work; if isolation ever breaks, the second bind fails loudly
+ * rather than the test quietly passing.
+ */
+#define STRESS_PORT 20049
+
+#define DEFAULT_WORKERS 8
+#define DEFAULT_SECS 5
+#define MAX_WORKERS 64
+#define SYNC_ROUNDS 20
+#define BARRIER_TIMEOUT_SEC 30
+
+/*
+ * Generous: the harness caps a test at TEST_TIMEOUT_DEFAULT otherwise, and
+ * the churn duration is tunable.
+ */
+#define SOAK_TIMEOUT_SEC 600
+
+#define TAINT_WARN_BIT (1UL << 9)
+
+/* Worker exit codes. */
+#define WORKER_OK 0
+#define WORKER_FAIL 1
+#define WORKER_NO_SETUP 2
+
+/* ------------------- cross-process barrier ------------------- */
+
+struct barrier {
+ unsigned int n;
+ unsigned int count;
+ unsigned int generation;
+ unsigned int aborted; /* latched: a rendezvous was never completed */
+};
+
+/*
+ * Spin with a deadline rather than blocking, so a worker that dies cannot
+ * wedge the run.
+ *
+ * The first waiter to give up latches ->aborted, which kills the barrier
+ * for good: every later call returns at once instead of waiting out its
+ * own deadline. Without that latch a single missing worker costs
+ * BARRIER_TIMEOUT_SEC on every remaining rendezvous -- three per round,
+ * SYNC_ROUNDS rounds -- which runs into tens of minutes before the
+ * harness timeout fires.
+ *
+ * A giving-up waiter leaves its ->count increment behind. That is fine:
+ * once ->aborted is set nothing reads ->count again.
+ *
+ * Returns false if the rendezvous did not happen.
+ */
+static bool barrier_wait(struct barrier *b)
+{
+ unsigned int gen = __atomic_load_n(&b->generation, __ATOMIC_ACQUIRE);
+ time_t deadline = time(NULL) + BARRIER_TIMEOUT_SEC;
+
+ if (__atomic_load_n(&b->aborted, __ATOMIC_ACQUIRE))
+ return false;
+
+ if (__atomic_add_fetch(&b->count, 1, __ATOMIC_ACQ_REL) == b->n) {
+ __atomic_store_n(&b->count, 0, __ATOMIC_RELEASE);
+ __atomic_add_fetch(&b->generation, 1, __ATOMIC_ACQ_REL);
+ return true;
+ }
+ while (__atomic_load_n(&b->generation, __ATOMIC_ACQUIRE) == gen) {
+ if (__atomic_load_n(&b->aborted, __ATOMIC_ACQUIRE))
+ return false;
+ if (time(NULL) > deadline) {
+ __atomic_store_n(&b->aborted, 1, __ATOMIC_RELEASE);
+ return false;
+ }
+ sched_yield();
+ }
+ return true;
+}
+
+/* ------------------- namespace setup ------------------- */
+
+static int netns_enter(void)
+{
+ struct ifreq ifr = {0};
+ int s;
+
+ if (unshare(CLONE_NEWNET | CLONE_NEWNS) < 0)
+ return -errno;
+ if (mount("", "/", NULL, MS_REC | MS_PRIVATE, NULL) < 0)
+ return -errno;
+ if (mount("tmpfs", "/mnt", "tmpfs", 0, NULL) < 0)
+ return -errno;
+ if (mkdir(NFSD_MNT, 0755) < 0)
+ return -errno;
+ if (mount("nfsd", NFSD_MNT, "nfsd", 0, NULL) < 0)
+ return -errno;
+
+ s = socket(AF_INET, SOCK_DGRAM, 0);
+ if (s < 0)
+ return -errno;
+ strcpy(ifr.ifr_name, "lo");
+ if (ioctl(s, SIOCGIFFLAGS, &ifr) < 0) {
+ close(s);
+ return -errno;
+ }
+ ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
+ if (ioctl(s, SIOCSIFFLAGS, &ifr) < 0) {
+ close(s);
+ return -errno;
+ }
+ close(s);
+
+ nfsd_family = genl_resolve_nfsd();
+ if (nfsd_family < 0)
+ return -ENOENT;
+ return 0;
+}
+
+/* ------------------- the operations ------------------- */
+
+static int read_nfsd_file(const char *name)
+{
+ char path[128], buf[4096];
+ int fd, n;
+
+ snprintf(path, sizeof(path), "%s/%s", NFSD_MNT, name);
+ fd = open(path, O_RDONLY);
+ if (fd < 0)
+ return -errno;
+ do {
+ n = read(fd, buf, sizeof(buf));
+ } while (n > 0);
+ close(fd);
+ return n < 0 ? -errno : 0;
+}
+
+/*
+ * The only userspace trigger that reaches nfsd_file_cache_purge(), and so
+ * the only one that takes the file cache lock with no other nfsd lock
+ * held. NFSD_CMD_CACHE_FLUSH does not get there: cache_purge() never calls
+ * the cache_detail's ->flush hook.
+ */
+static int expkey_flush(void)
+{
+ int fd = open(EXPKEY_FLUSH, O_WRONLY);
+ int n;
+
+ if (fd < 0)
+ return -errno;
+ n = write(fd, "1\n", 2);
+ close(fd);
+ return n < 0 ? -errno : 0;
+}
+
+/* Create a serv and tear it straight back down inside one call. */
+static int serv_cycle(void)
+{
+ return listener_set(NULL, 0);
+}
+
+static int server_up(int nthreads)
+{
+ char attrs[128];
+ int off, ret;
+
+ off = put_listener(attrs, 0, "tcp", STRESS_PORT);
+ off = put_attr(attrs, off, NFSD_A_SERVER_SOCK_USERSPACE_RPCBIND,
+ NULL, 0);
+ ret = listener_set(attrs, off);
+ if (ret)
+ return ret;
+ return threads_set(nthreads);
+}
+
+/* threads_set(0) drops the last thread, which destroys the serv with it. */
+static int server_down(void)
+{
+ return threads_set(0);
+}
+
+/* ------------------- the worker ------------------- */
+
+struct worker_err {
+ const char *op;
+ int err;
+};
+
+static struct worker_err worker_fault;
+
+static bool fail(const char *op, int err)
+{
+ if (err == 0)
+ return false;
+ worker_fault.op = op;
+ worker_fault.err = err;
+ return true;
+}
+
+/*
+ * Random churn between "no serv" and "serv with threads", with the
+ * lock-crossing reads and flushes mixed in at both ends. Every call here
+ * is one that must succeed in the state it is issued from, so any error is
+ * a real failure rather than an expected race.
+ */
+static bool worker_churn(time_t deadline)
+{
+ bool up = false;
+
+ while (time(NULL) < deadline) {
+ int r = random() % 8;
+
+ if (!up) {
+ switch (r) {
+ case 0:
+ case 1:
+ if (fail("serv_cycle", serv_cycle()))
+ return false;
+ break;
+ case 2:
+ if (fail("version_set", version_set_only(4, 1)))
+ return false;
+ break;
+ case 3:
+ if (fail("flush", expkey_flush()))
+ return false;
+ break;
+ case 4:
+ if (fail("filecache",
read_nfsd_file("filecache")))
+ return false;
+ break;
+ case 5:
+ if (fail("pool_stats",
read_nfsd_file("pool_stats")))
+ return false;
+ break;
+ default:
+ if (fail("server_up", server_up(1 + random() %
3)))
+ return false;
+ up = true;
+ break;
+ }
+ } else {
+ switch (r) {
+ case 0:
+ if (fail("flush", expkey_flush()))
+ return false;
+ break;
+ case 1:
+ if (fail("filecache",
read_nfsd_file("filecache")))
+ return false;
+ break;
+ case 2:
+ if (fail("pool_stats",
read_nfsd_file("pool_stats")))
+ return false;
+ break;
+ case 3:
+ if (fail("threads_set", threads_set(1 +
random() % 3)))
+ return false;
+ break;
+ default:
+ if (fail("server_down", server_down()))
+ return false;
+ up = false;
+ break;
+ }
+ }
+ }
+
+ if (up && fail("server_down", server_down()))
+ return false;
+ return true;
+}
+
+/*
+ * Every worker arrives at the barrier with no serv, so the host-wide
+ * refcount is at zero, then they all try to take it to one together. The
+ * second barrier lines up the drop back to zero the same way.
+ */
+static bool worker_sync_rounds(struct barrier *b)
+{
+ int i;
+
+ for (i = 0; i < SYNC_ROUNDS; i++) {
+ /*
+ * A failed rendezvous means a peer is gone, which its own
+ * exit status already reports. Stop the rounds rather than
+ * stalling on every remaining barrier.
+ */
+ if (!barrier_wait(b))
+ return true;
+ if (fail("sync server_up", server_up(1)))
+ return false;
+
+ if (!barrier_wait(b))
+ return true;
+ if (fail("sync flush", expkey_flush()))
+ return false;
+
+ if (!barrier_wait(b))
+ return true;
+ if (fail("sync server_down", server_down()))
+ return false;
+ }
+ return true;
+}
+
+static int worker(int idx, struct barrier *b, unsigned int secs)
+{
+ int ret = netns_enter();
+
+ if (ret) {
+ /*
+ * Report setup trouble rather than a failure: a restricted
+ * environment is not a kernel bug.
+ */
+ fprintf(stderr, "netns %d: setup: %s\n", idx, strerror(-ret));
+ return WORKER_NO_SETUP;
+ }
+
+ /*
+ * Leave only NFSv4.1 enabled. A fresh namespace has v3 on, which
+ * makes nfsd_needs_lockd() true, and the lockd that comes up then
+ * tries to reach an rpcbind that is not there -- so every server
+ * start waits out an RPC timeout and floods the log. The version
+ * set sticks across serv teardown, so once is enough.
+ */
+ ret = version_set_only(4, 1);
+ if (ret) {
+ fprintf(stderr, "netns %d: version_set: %s\n", idx,
+ strerror(-ret));
+ return WORKER_FAIL;
+ }
+
+ srandom(getpid() ^ (unsigned int)time(NULL));
+
+ if (!worker_churn(time(NULL) + secs))
+ goto fault;
+ if (!worker_sync_rounds(b))
+ goto fault;
+ return WORKER_OK;
+
+fault:
+ fprintf(stderr, "netns %d: %s: %s\n", idx, worker_fault.op,
+ strerror(-worker_fault.err));
+ server_down();
+ return WORKER_FAIL;
+}
+
+/*
+ * Threads running outside the test's namespaces. Any at all pin the
+ * host-wide refcount above zero for the whole run, so the 0->1 transition
+ * the synchronized rounds are built around never happens.
+ */
+static int nfsd_threads_here(void)
+{
+ char buf[32] = "";
+ int fd = open("/proc/fs/nfsd/threads", O_RDONLY);
+ int n = 0;
+
+ if (fd < 0)
+ return 0;
+ if (read(fd, buf, sizeof(buf) - 1) > 0)
+ n = atoi(buf);
+ close(fd);
+ return n;
+}
+
+/* ------------------- taint ------------------- */
+
+static unsigned long read_taint(void)
+{
+ unsigned long v = 0;
+ FILE *f = fopen("/proc/sys/kernel/tainted", "r");
+
+ if (!f)
+ return 0;
+ if (fscanf(f, "%lu", &v) != 1)
+ v = 0;
+ fclose(f);
+ return v;
+}
+
+/* ------------------- the test ------------------- */
+
+static unsigned int env_uint(const char *name, unsigned int def, unsigned int
max)
+{
+ const char *s = getenv(name);
+ unsigned long v;
+
+ if (!s || !*s)
+ return def;
+ v = strtoul(s, NULL, 0);
+ if (v == 0 || v > max)
+ return def;
+ return (unsigned int)v;
+}
+
+FIXTURE(soak) {
+ int unused;
+};
+
+FIXTURE_SETUP(soak) { }
+FIXTURE_TEARDOWN(soak) { }
+
+TEST_F_TIMEOUT(soak, netns_control_plane_soak, SOAK_TIMEOUT_SEC)
+{
+ unsigned long taint_before, taint_after;
+ unsigned int workers, secs;
+ pid_t pid[MAX_WORKERS];
+ int failed = 0, skipped = 0;
+ unsigned int aborted;
+ struct barrier *b;
+ unsigned int i;
+ long ncpu;
+
+ if (geteuid() != 0)
+ SKIP(return, "must be run as root");
+
+ ncpu = sysconf(_SC_NPROCESSORS_ONLN);
+ if (ncpu < 2)
+ ncpu = 2;
+ workers = env_uint("NFSD_STRESS_WORKERS",
+ ncpu < DEFAULT_WORKERS ? (unsigned int)ncpu
+ : DEFAULT_WORKERS,
+ MAX_WORKERS);
+ secs = env_uint("NFSD_STRESS_SECS", DEFAULT_SECS, 3600);
+
+ b = mmap(NULL, sizeof(*b), PROT_READ | PROT_WRITE,
+ MAP_SHARED | MAP_ANONYMOUS, -1, 0);
+ ASSERT_NE(MAP_FAILED, b);
+ b->n = workers;
+ b->count = 0;
+ b->generation = 0;
+ b->aborted = 0;
+
+ TH_LOG("%u namespaces, %us of churn then %d synchronized rounds",
+ workers, secs, SYNC_ROUNDS);
+
+ /*
+ * A server already running outside these namespaces holds the
+ * host-wide refcount above zero for the whole run, so the 0->1
+ * transition the synchronized rounds are built around never
+ * happens. Worth saying out loud rather than reporting coverage
+ * that was not there.
+ */
+ if (nfsd_threads_here() > 0)
+ TH_LOG("note: nfsd runs outside these namespaces; refcount
never hits 0");
+
+ taint_before = read_taint();
+
+ for (i = 0; i < workers; i++) {
+ pid[i] = fork();
+ ASSERT_GE(pid[i], 0);
+ if (pid[i] == 0)
+ _exit(worker(i, b, secs));
+ }
+
+ for (i = 0; i < workers; i++) {
+ int status = 0;
+
+ waitpid(pid[i], &status, 0);
+ if (!WIFEXITED(status)) {
+ failed++;
+ continue;
+ }
+ if (WEXITSTATUS(status) == WORKER_FAIL)
+ failed++;
+ else if (WEXITSTATUS(status) == WORKER_NO_SETUP)
+ skipped++;
+ }
+
+ taint_after = read_taint();
+ aborted = __atomic_load_n(&b->aborted, __ATOMIC_ACQUIRE);
+ munmap(b, sizeof(*b));
+
+ if (skipped == (int)workers)
+ SKIP(return, "no worker could set up a private nfsd namespace");
+
+ EXPECT_EQ(0, failed)
+ TH_LOG("%d of %u namespaces reported an error", failed,
workers);
+
+ /*
+ * A namespace that never reached the rounds -- one that could not
+ * set up, say -- leaves the rest with nobody to meet. Say so: the
+ * synchronized part did not run to completion.
+ */
+ if (aborted)
+ TH_LOG("note: synchronized rounds cut short; a namespace did
not arrive");
+
+ /*
+ * The real result. Without CONFIG_PROVE_LOCKING there is very little
+ * here for the kernel to complain about, so say so rather than
+ * letting a quiet pass look like coverage.
+ */
+ EXPECT_EQ(0, (taint_after & ~taint_before) & TAINT_WARN_BIT)
+ TH_LOG("kernel warned during the run (taint %#lx -> %#lx);
check dmesg",
+ taint_before, taint_after);
+
+ /*
+ * lockdep_proc_init() puts these in procfs, not debugfs, and
+ * lockdep_chains is the one that appears only with
+ * CONFIG_PROVE_LOCKING -- the part that validates ordering rather
+ * than merely tracking. Root-only, but so is this test.
+ */
+ if (access("/proc/lockdep_chains", R_OK) != 0)
+ TH_LOG("note: CONFIG_PROVE_LOCKING looks absent; this proves
little");
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/nfsd/settings
b/tools/testing/selftests/nfsd/settings
index 694d70710ff0..a62d2fa1275c 100644
--- a/tools/testing/selftests/nfsd/settings
+++ b/tools/testing/selftests/nfsd/settings
@@ -1 +1 @@
-timeout=300
+timeout=600
--
2.55.0