git: f9f4abe8c9f8 - main - tests/sys/file: add tests for the lowest fd after fork

From: Maxim Konovalov <maxim_at_FreeBSD.org>
Date: Mon, 02 Nov 2026 21:13:04 UTC
The branch main has been updated by maxim:

URL: https://cgit.FreeBSD.org/src/commit/?id=f9f4abe8c9f8b5a783538d2b99254d9845572e70

commit f9f4abe8c9f8b5a783538d2b99254d9845572e70
Author:     Maxim Konovalov <maxim@FreeBSD.org>
AuthorDate: 2026-11-01 14:59:08 +0000
Commit:     Maxim Konovalov <maxim@FreeBSD.org>
CommitDate: 2026-11-02 21:11:44 +0000

    tests/sys/file: add tests for the lowest fd after fork
    
    fdcopy() skips plain kqueues and close-on-fork descriptors when it
    copies the table into the child.  Check that dup(2) in the child still
    returns the lowest unused descriptor, both when the skipped descriptor
    is the lowest free slot and when it sits above an existing hole.
    
    Test cases:
    
      kqueue_no_hole      a plain kqueue is the lowest descriptor; the
                          child's dup() reuses its slot
      kqueue_two          two adjacent plain kqueues; the child's dup()
                          returns the lower one
      kqueue_above_hole   a plain kqueue sits above a closed descriptor;
                          the child's dup() returns the hole
      kqueue_cponfork     a KQUEUE_CPONFORK kqueue is copied; the child's
                          dup() returns the next slot
      clofork_no_hole     an O_CLOFORK descriptor is the lowest one; the
                          child's dup() reuses its slot
      clofork_above_hole  an O_CLOFORK descriptor sits above a closed
                          descriptor; the child's dup() returns the hole
    
    Without 99a58803f9a3, kqueue_no_hole, kqueue_two and
    clofork_above_hole fail.
    
    PR:             300234
    Reviewed by:    kib
    MFC after:      1 week
    MFC with:       99a58803f9a3
    Co-authored-by: Claude Code Opus 5.5
    Differential Revision:  https://reviews.freebsd.org/D61124
---
 tests/sys/file/Makefile          |   1 +
 tests/sys/file/fork_lowfd_test.c | 141 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 142 insertions(+)

diff --git a/tests/sys/file/Makefile b/tests/sys/file/Makefile
index c1fcef68d08e..173a01a1ff61 100644
--- a/tests/sys/file/Makefile
+++ b/tests/sys/file/Makefile
@@ -6,6 +6,7 @@ ATF_TESTS_C+=	path_test
 ATF_TESTS_C+=	closefrom_test
 TAP_TESTS_C+=	dup_test
 ATF_TESTS_C+=	fcntlflags_test
+ATF_TESTS_C+=	fork_lowfd_test
 TAP_TESTS_SH+=	flock_test
 PLAIN_TESTS_C+=	ftruncate_test
 PLAIN_TESTS_C+=	newfileops_on_fork_test
diff --git a/tests/sys/file/fork_lowfd_test.c b/tests/sys/file/fork_lowfd_test.c
new file mode 100644
index 000000000000..81d867e03255
--- /dev/null
+++ b/tests/sys/file/fork_lowfd_test.c
@@ -0,0 +1,141 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Maxim Konovalov <maxim@FreeBSD.org>
+ */
+
+/*
+ * fdcopy() does not copy some descriptors into the child (plain kqueues,
+ * close-on-fork descriptors).  The child's lowest-free-descriptor hint
+ * must still let dup(2) return the lowest unused descriptor, as POSIX
+ * requires.  See PR 300234.
+ */
+
+#include <sys/types.h>
+#include <sys/event.h>
+#include <sys/wait.h>
+
+#include <fcntl.h>
+#include <unistd.h>
+
+#include <atf-c.h>
+
+#define	RFD	100
+#define	WFD	101
+
+/* Fork and return the descriptor the child gets from dup(0). */
+static int
+child_dup(void)
+{
+	int fds[2], fd, status;
+	pid_t pid;
+
+	/* Park the pipe high so that it does not change the layout. */
+	ATF_REQUIRE(pipe(fds) == 0);
+	ATF_REQUIRE(dup2(fds[0], RFD) == RFD);
+	ATF_REQUIRE(dup2(fds[1], WFD) == WFD);
+	ATF_REQUIRE(close(fds[0]) == 0);
+	ATF_REQUIRE(close(fds[1]) == 0);
+
+	pid = fork();
+	ATF_REQUIRE(pid != -1);
+	if (pid == 0) {
+		fd = dup(STDIN_FILENO);
+		_exit(write(WFD, &fd, sizeof(fd)) == sizeof(fd) ? 0 : 1);
+	}
+	ATF_REQUIRE(close(WFD) == 0);
+	ATF_REQUIRE(read(RFD, &fd, sizeof(fd)) == sizeof(fd));
+	ATF_REQUIRE(close(RFD) == 0);
+	ATF_REQUIRE(waitpid(pid, &status, 0) == pid);
+	ATF_REQUIRE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
+	return (fd);
+}
+
+static int
+open_null(int flags)
+{
+	int fd;
+
+	fd = open("/dev/null", O_RDONLY | flags);
+	ATF_REQUIRE(fd != -1);
+	return (fd);
+}
+
+ATF_TC_WITHOUT_HEAD(kqueue_no_hole);
+ATF_TC_BODY(kqueue_no_hole, tc)
+{
+	int kq;
+
+	kq = kqueue();
+	ATF_REQUIRE(kq != -1);
+	ATF_CHECK_EQ(child_dup(), kq);
+}
+
+ATF_TC_WITHOUT_HEAD(kqueue_two);
+ATF_TC_BODY(kqueue_two, tc)
+{
+	int kq1, kq2;
+
+	kq1 = kqueue();
+	ATF_REQUIRE(kq1 != -1);
+	kq2 = kqueue();
+	ATF_REQUIRE(kq2 != -1);
+	ATF_CHECK_EQ(child_dup(), kq1);
+}
+
+ATF_TC_WITHOUT_HEAD(kqueue_above_hole);
+ATF_TC_BODY(kqueue_above_hole, tc)
+{
+	int a, b, kq;
+
+	a = open_null(0);
+	b = open_null(0);
+	kq = kqueue();
+	ATF_REQUIRE(kq > b);
+	ATF_REQUIRE(close(a) == 0);
+	ATF_CHECK_EQ(child_dup(), a);
+}
+
+ATF_TC_WITHOUT_HEAD(kqueue_cponfork);
+ATF_TC_BODY(kqueue_cponfork, tc)
+{
+	int kq;
+
+	kq = kqueuex(KQUEUE_CPONFORK);
+	ATF_REQUIRE(kq != -1);
+	ATF_CHECK_EQ(child_dup(), kq + 1);
+}
+
+ATF_TC_WITHOUT_HEAD(clofork_no_hole);
+ATF_TC_BODY(clofork_no_hole, tc)
+{
+	int fd;
+
+	fd = open_null(O_CLOFORK);
+	ATF_CHECK_EQ(child_dup(), fd);
+}
+
+ATF_TC_WITHOUT_HEAD(clofork_above_hole);
+ATF_TC_BODY(clofork_above_hole, tc)
+{
+	int a, b, c;
+
+	a = open_null(0);
+	b = open_null(0);
+	c = open_null(O_CLOFORK);
+	ATF_REQUIRE(c > b);
+	ATF_REQUIRE(close(a) == 0);
+	ATF_CHECK_EQ(child_dup(), a);
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+	ATF_TP_ADD_TC(tp, kqueue_no_hole);
+	ATF_TP_ADD_TC(tp, kqueue_two);
+	ATF_TP_ADD_TC(tp, kqueue_above_hole);
+	ATF_TP_ADD_TC(tp, kqueue_cponfork);
+	ATF_TP_ADD_TC(tp, clofork_no_hole);
+	ATF_TP_ADD_TC(tp, clofork_above_hole);
+
+	return (atf_no_error());
+}