From: Babanpreet Singh <bbnpreetsingh@gmail.com>
To: yocto-patches@lists.yoctoproject.org
Cc: Paul Barker <paul@pbarker.dev>,
Richard Purdie <richard.purdie@linuxfoundation.org>,
Mark Hatle <mark.hatle@amd.com>,
Randy MacLeod <randy.macleod@windriver.com>,
Vincent Haupert <mail@vincent-haupert.de>,
Babanpreet Singh <bbnpreetsingh@gmail.com>
Subject: [pseudo] [PATCH v2 2/2] tests: Add close_range() test
Date: Thu, 16 Jul 2026 05:56:33 +0000 [thread overview]
Message-ID: <20260716055633.7-3-bbnpreetsingh@gmail.com> (raw)
In-Reply-To: <20260716055633.7-1-bbnpreetsingh@gmail.com>
Covers the ENOSYS regression itself, that the wrapper agrees with the
kernel about bad arguments, and the part that actually needs the care:
pseudo keeps its own descriptors in the range, including the connection
to its server, and has to come out of a close_range(3, ~0U, 0) still
tracking ownership.
CLOSE_RANGE_UNSHARE is exercised against a child cloned with
CLONE_FILES: the child closes a shared descriptor with the flag set,
and the parent must still hold that descriptor afterwards, which is
what the wrapper's unshare-before-closing order exists to preserve. A
second child then closes the same descriptor without the flag and the
parent must see it gone.
close_range() needs a 5.9 kernel and CLOSE_RANGE_CLOEXEC a 5.11 one, so
the shell wrapper probes for each with PSEUDO_DISABLED=1 before running.
On 5.9 and 5.10 the syscall probe succeeds, the flag probe fails, and
only the CLOSE_RANGE_CLOEXEC part of the test is left out.
[YOCTO #16339]
AI-Generated: Uses Claude (claude-opus-4-8)
Signed-off-by: Babanpreet Singh <bbnpreetsingh@gmail.com>
---
test/test-close-range.c | 283 +++++++++++++++++++++++++++++++++++++++
test/test-close-range.sh | 24 ++++
2 files changed, 307 insertions(+)
create mode 100644 test/test-close-range.c
create mode 100755 test/test-close-range.sh
diff --git a/test/test-close-range.c b/test/test-close-range.c
new file mode 100644
index 0000000..482c85e
--- /dev/null
+++ b/test/test-close-range.c
@@ -0,0 +1,283 @@
+/*
+ * SPDX-License-Identifier: LGPL-2.1-only
+ *
+ * close_range() was stubbed out to return ENOSYS unconditionally, on the
+ * assumption that callers all cope with that. Current systemd does not: it
+ * dropped its /proc fallback once its kernel baseline reached 5.10, so every
+ * fork+exec under pseudo failed. Check the wrapper works, that it agrees
+ * with the kernel about bad arguments, that CLOSE_RANGE_UNSHARE keeps a
+ * process sharing the descriptor table unaffected, and that pseudo still
+ * functions after a caller closes every descriptor from 3 up.
+ */
+#define _GNU_SOURCE
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <sched.h>
+#include <signal.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+/* <linux/close_range.h> is missing on hosts with pre-5.9 kernel headers */
+#ifndef CLOSE_RANGE_UNSHARE
+#define CLOSE_RANGE_UNSHARE (1U << 1)
+#endif
+#ifndef CLOSE_RANGE_CLOEXEC
+#define CLOSE_RANGE_CLOEXEC (1U << 2)
+#endif
+
+/* matches test-openat2-syscall: the shell wrapper turns 77 into "skipped" */
+#define SKIP 77
+
+#define TESTFILE "test-close-range-file"
+
+static int open_null(void) {
+ int fd = open("/dev/null", O_RDONLY);
+ if (fd < 0)
+ perror("open /dev/null");
+ return fd;
+}
+
+static int is_closed(int fd) {
+ return fcntl(fd, F_GETFD) == -1 && errno == EBADF;
+}
+
+/* Is close_range() there at all? The wrapper runs this with PSEUDO_DISABLED
+ * set, so the answer is the kernel's and a stubbed wrapper cannot pass
+ * itself off as an old kernel.
+ */
+static int probe(void) {
+ int fd = open_null();
+
+ if (fd < 0)
+ return 1;
+ if (close_range(fd, fd, 0) == -1) {
+ int err = errno;
+ close(fd);
+ return (err == ENOSYS) ? SKIP : 1;
+ }
+ return 0;
+}
+
+/* CLOSE_RANGE_CLOEXEC is newer than the syscall: 5.11 rather than 5.9. Probe
+ * for it separately, the same way, so that on a 5.9 or 5.10 kernel only the
+ * CLOSE_RANGE_CLOEXEC part of the test is left out, not the whole thing.
+ */
+static int probe_cloexec(void) {
+ int fd = open_null();
+ int rc;
+
+ if (fd < 0)
+ return 1;
+ rc = close_range(fd, fd, CLOSE_RANGE_CLOEXEC);
+ if (rc == -1) {
+ int err = errno;
+ close(fd);
+ return (err == EINVAL || err == ENOSYS) ? SKIP : 1;
+ }
+ close(fd);
+ return 0;
+}
+
+/* for the CLONE_FILES children: close one descriptor, with or without
+ * CLOSE_RANGE_UNSHARE, and report whether it is gone for the child itself.
+ */
+struct child_close {
+ int fd;
+ int flags;
+};
+
+static char child_stack[65536];
+
+static int child_close_one(void *arg) {
+ struct child_close *cc = arg;
+
+ if (close_range(cc->fd, cc->fd, cc->flags) == -1)
+ return 2;
+ return is_closed(cc->fd) ? 0 : 3;
+}
+
+int main(int argc, char **argv) {
+ struct child_close cc;
+ struct stat st;
+ int a, b, c;
+ int status;
+ int test_cloexec = 1;
+ pid_t pid;
+
+ if (argc > 1 && !strcmp(argv[1], "--probe"))
+ return probe();
+ if (argc > 1 && !strcmp(argv[1], "--probe-cloexec"))
+ return probe_cloexec();
+ if (argc > 1 && !strcmp(argv[1], "--no-cloexec"))
+ test_cloexec = 0;
+
+ /* the reported bug: this used to fail with ENOSYS every time */
+ a = open_null();
+ if (a < 0)
+ return 1;
+ if (close_range(a, a, 0) == -1) {
+ fprintf(stderr, "close_range(%d, %d, 0): %s\n", a, a, strerror(errno));
+ return 1;
+ }
+ if (!is_closed(a)) {
+ fprintf(stderr, "close_range() left fd %d open\n", a);
+ return 1;
+ }
+
+ /* a bounded range closes all of itself */
+ a = open_null();
+ b = open_null();
+ c = open_null();
+ if (a < 0 || b < 0 || c < 0)
+ return 1;
+ if (close_range(a, c, 0) == -1) {
+ perror("close_range(a, c, 0)");
+ return 1;
+ }
+ if (!is_closed(a) || !is_closed(b) || !is_closed(c)) {
+ fprintf(stderr, "close_range(%d, %d, 0) left descriptors open\n", a, c);
+ return 1;
+ }
+
+ /* CLOSE_RANGE_CLOEXEC marks descriptors, it does not close them. The
+ * flag needs a 5.11 kernel; the shell wrapper passes --no-cloexec
+ * when the probe says this kernel has the syscall but not the flag.
+ */
+ if (test_cloexec) {
+ a = open_null();
+ if (a < 0)
+ return 1;
+ if (close_range(a, a, CLOSE_RANGE_CLOEXEC) == -1) {
+ perror("close_range(a, a, CLOSE_RANGE_CLOEXEC)");
+ return 1;
+ }
+ if (is_closed(a)) {
+ fprintf(stderr, "CLOSE_RANGE_CLOEXEC closed fd %d\n", a);
+ return 1;
+ }
+ if (!(fcntl(a, F_GETFD) & FD_CLOEXEC)) {
+ fprintf(stderr, "CLOSE_RANGE_CLOEXEC did not set FD_CLOEXEC on fd %d\n", a);
+ return 1;
+ }
+ close(a);
+ }
+
+ /* bad arguments are refused, and a refused call closes nothing */
+ a = open_null();
+ if (a < 0)
+ return 1;
+ if (close_range(a, a, 0x80) != -1 || errno != EINVAL) {
+ fprintf(stderr, "close_range() with an unknown flag should fail EINVAL\n");
+ return 1;
+ }
+ if (is_closed(a)) {
+ fprintf(stderr, "a rejected close_range() closed fd %d anyway\n", a);
+ return 1;
+ }
+ if (close_range(a + 1, a, 0) != -1 || errno != EINVAL) {
+ fprintf(stderr, "close_range() with lowfd > maxfd should fail EINVAL\n");
+ return 1;
+ }
+ close(a);
+
+ /* a range entirely above INT_MAX holds no descriptors at all, but it
+ * still has to be accepted rather than mangled on the way through
+ */
+ if (close_range((unsigned int) INT_MAX + 1, ~0U, 0) == -1) {
+ fprintf(stderr, "close_range(INT_MAX+1, ~0U, 0): %s\n", strerror(errno));
+ return 1;
+ }
+
+ /* CLOSE_RANGE_UNSHARE unshares the descriptor table before closing:
+ * when a child cloned with CLONE_FILES closes our shared descriptor
+ * with the flag set, we must still hold that descriptor afterwards.
+ * This is what the wrapper's unshare-first order preserves; closing
+ * by hand before unsharing would go through the still-shared table.
+ */
+ a = open_null();
+ if (a < 0)
+ return 1;
+ cc.fd = a;
+ cc.flags = CLOSE_RANGE_UNSHARE;
+ pid = clone(child_close_one, child_stack + sizeof(child_stack),
+ CLONE_FILES | SIGCHLD, &cc);
+ if (pid == -1) {
+ perror("clone(CLONE_FILES)");
+ return 1;
+ }
+ if (waitpid(pid, &status, 0) == -1) {
+ perror("waitpid");
+ return 1;
+ }
+ if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
+ fprintf(stderr, "CLOSE_RANGE_UNSHARE failed in the child (status %d)\n",
+ status);
+ return 1;
+ }
+ if (is_closed(a)) {
+ fprintf(stderr, "child's CLOSE_RANGE_UNSHARE closed the parent's fd %d\n", a);
+ return 1;
+ }
+
+ /* the control for the test above: the same close without the flag
+ * happens in the shared table, so this time fd a must be gone for
+ * us as well. A clone that quietly stopped sharing the table would
+ * pass the test above no matter what the wrapper did; it fails here
+ * instead.
+ */
+ cc.flags = 0;
+ pid = clone(child_close_one, child_stack + sizeof(child_stack),
+ CLONE_FILES | SIGCHLD, &cc);
+ if (pid == -1) {
+ perror("clone(CLONE_FILES)");
+ return 1;
+ }
+ if (waitpid(pid, &status, 0) == -1) {
+ perror("waitpid");
+ return 1;
+ }
+ if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
+ fprintf(stderr, "close_range() failed in the CLONE_FILES child (status %d)\n",
+ status);
+ return 1;
+ }
+ if (!is_closed(a)) {
+ fprintf(stderr, "close in a CLONE_FILES child did not reach the parent's fd %d\n", a);
+ return 1;
+ }
+
+ /* And the point of the care in the wrapper: pseudo keeps descriptors
+ * of its own in this range and has to come out of it still working.
+ */
+ a = open(TESTFILE, O_CREAT | O_WRONLY, 0644);
+ if (a < 0) {
+ perror("open " TESTFILE);
+ return 1;
+ }
+ close(a);
+ if (chown(TESTFILE, 0, 0) == -1) {
+ perror("chown " TESTFILE);
+ return 1;
+ }
+ if (close_range(3, ~0U, 0) == -1) {
+ fprintf(stderr, "close_range(3, ~0U, 0): %s\n", strerror(errno));
+ return 1;
+ }
+ if (stat(TESTFILE, &st) == -1) {
+ perror("stat after close_range");
+ return 1;
+ }
+ if (st.st_uid != 0 || st.st_gid != 0) {
+ fprintf(stderr, "pseudo lost track after close_range: uid %d, gid %d\n",
+ (int) st.st_uid, (int) st.st_gid);
+ return 1;
+ }
+ unlink(TESTFILE);
+
+ return 0;
+}
diff --git a/test/test-close-range.sh b/test/test-close-range.sh
new file mode 100755
index 0000000..578102c
--- /dev/null
+++ b/test/test-close-range.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+#
+# SPDX-License-Identifier: LGPL-2.1-only
+#
+
+# close_range() needs a 5.9 kernel. Ask with pseudo out of the way, so that a
+# wrapper which has gone back to returning ENOSYS cannot pass itself off as an
+# old kernel and quietly skip the test it is supposed to fail.
+PSEUDO_DISABLED=1 ./test/test-close-range --probe
+case $? in
+0) ;;
+77) exit 255 ;;
+*) exit 1 ;;
+esac
+
+# CLOSE_RANGE_CLOEXEC needs 5.11. On a 5.9 or 5.10 kernel the syscall probe
+# above succeeds but the flag does not exist yet, so probe for it the same
+# way and leave only the CLOSE_RANGE_CLOEXEC part of the test out.
+PSEUDO_DISABLED=1 ./test/test-close-range --probe-cloexec
+case $? in
+0) ./test/test-close-range ;;
+77) ./test/test-close-range --no-cloexec ;;
+*) exit 1 ;;
+esac
--
2.43.0
next prev parent reply other threads:[~2026-07-16 5:56 UTC|newest]
Thread overview: 9+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-15 5:41 [pseudo] [PATCH 0/2] close_range: implement it rather than return ENOSYS Baban
2026-07-15 5:41 ` [pseudo] [PATCH 1/2] ports/linux/guts: Implement close_range() instead of returning ENOSYS Baban
2026-07-15 19:51 ` [yocto-patches] " Paul Barker
2026-07-15 5:41 ` [pseudo] [PATCH 2/2] tests: Add close_range() test Baban
2026-07-15 20:03 ` [yocto-patches] " Paul Barker
2026-07-16 5:56 ` [pseudo] [PATCH v2 0/2] close_range: implement it rather than return ENOSYS Babanpreet Singh
2026-07-16 5:56 ` [pseudo] [PATCH v2 1/2] ports/linux/guts: Implement close_range() instead of returning ENOSYS Babanpreet Singh
2026-07-16 5:56 ` Babanpreet Singh [this message]
2026-07-16 10:46 ` [pseudo] [PATCH v2 0/2] close_range: implement it rather than return ENOSYS Richard Purdie
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260716055633.7-3-bbnpreetsingh@gmail.com \
--to=bbnpreetsingh@gmail.com \
--cc=mail@vincent-haupert.de \
--cc=mark.hatle@amd.com \
--cc=paul@pbarker.dev \
--cc=randy.macleod@windriver.com \
--cc=richard.purdie@linuxfoundation.org \
--cc=yocto-patches@lists.yoctoproject.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.