Page Menu
Home
FreeBSD
Search
Configure Global Search
Log In
Files
F177554433
D61328.1794705961.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
71 KB
Referenced Files
None
Subscribers
None
D61328.1794705961.diff
View Options
diff --git a/sys/kern/kern_jail.c b/sys/kern/kern_jail.c
--- a/sys/kern/kern_jail.c
+++ b/sys/kern/kern_jail.c
@@ -147,14 +147,23 @@
*/
TASKQUEUE_DEFINE_THREAD(jail_remove);
-/* allprison, allprison_racct and lastprid are protected by allprison_lock. */
+/* allprison, allprison_racct, jid index and lastprid are protected by allprison_lock. */
struct sx allprison_lock;
SX_SYSINIT(allprison_lock, &allprison_lock, "allprison");
struct prisonlist allprison = TAILQ_HEAD_INITIALIZER(allprison);
+static RB_HEAD(prison_jid_tree, prison) prison_jids =
+ RB_INITIALIZER(&prison_jids);
LIST_HEAD(, prison_racct) allprison_racct;
int lastprid = 0;
int lastdeadid = 0;
+static int prison_jid_cmp(const struct prison *a, const struct prison *b);
+static void prison_jid_insert(struct prison *pr);
+static void prison_jid_remove(struct prison *pr);
+static struct prison * prison_lookup(int prid);
+static struct prison * prison_lookup_gt(int prid);
+RB_GENERATE_STATIC(prison_jid_tree, prison, pr_jidlink, prison_jid_cmp);
+
static int get_next_prid(struct prison **insprp);
static int get_next_deadid(struct prison **insprp);
static int do_jail_attach(struct thread *td, struct prison *pr, int *drflagsp);
@@ -281,6 +290,77 @@
static unsigned jail_max_af_ips = 255;
#endif
+static int
+prison_jid_cmp(const struct prison *a, const struct prison *b)
+{
+
+ return ((a->pr_id > b->pr_id) - (a->pr_id < b->pr_id));
+}
+
+
+/*
+ * The index contains the same prisons as allprison, including dying prisons
+ * but excluding prison0. Keep both collections in step under the exclusive
+ * allprison_lock, including when a dying prison is renumbered.
+ */
+static void
+prison_jid_insert(struct prison *pr)
+{
+ struct prison *old __diagused;
+
+ sx_assert(&allprison_lock, SA_XLOCKED);
+ old = RB_INSERT(prison_jid_tree, &prison_jids, pr);
+ KASSERT(old == NULL, ("Duplicate prison ID %d", pr->pr_id));
+}
+
+static void
+prison_jid_remove(struct prison *pr)
+{
+ sx_assert(&allprison_lock, SA_XLOCKED);
+ RB_REMOVE(prison_jid_tree, &prison_jids, pr);
+}
+
+/*
+ * Return an unlocked prison with this ID, or NULL. Search by integer to avoid
+ * putting a struct prison on the kernel stack just to supply a search key.
+ */
+static struct prison *
+prison_lookup(int prid)
+{
+ struct prison *pr;
+
+ sx_assert(&allprison_lock, SX_LOCKED);
+ pr = RB_ROOT(&prison_jids);
+ while (pr != NULL) {
+ if (prid < pr->pr_id)
+ pr = RB_LEFT(pr, pr_jidlink);
+ else if (prid > pr->pr_id)
+ pr = RB_RIGHT(pr, pr_jidlink);
+ else
+ break;
+ }
+ return (pr);
+}
+
+/* Find the least ID strictly greater than prid, even if prid is absent. */
+static struct prison *
+prison_lookup_gt(int prid)
+{
+ struct prison *pr, *next;
+
+ sx_assert(&allprison_lock, SX_LOCKED);
+ pr = RB_ROOT(&prison_jids);
+ next = NULL;
+ while (pr != NULL) {
+ if (pr->pr_id > prid) {
+ next = pr;
+ pr = RB_LEFT(pr, pr_jidlink);
+ } else
+ pr = RB_RIGHT(pr, pr_jidlink);
+ }
+ return (next);
+}
+
/*
* Initialize the parts of prison0 that can't be static-initialized with
* constants. This is called from proc0_init() after creating thread0 cpuset.
@@ -1617,11 +1697,8 @@
* See if a requested jid already exists. Keep track of
* where it can be inserted later.
*/
- TAILQ_FOREACH(inspr, &allprison, pr_list) {
- if (inspr->pr_id < jid)
- continue;
- if (inspr->pr_id > jid)
- break;
+ inspr = prison_lookup(jid);
+ if (inspr != NULL) {
if (prison_isalive(inspr)) {
pr = inspr;
mtx_lock(&pr->pr_mtx);
@@ -1631,8 +1708,8 @@
deadpr = inspr;
}
inspr = NULL;
- break;
- }
+ } else
+ inspr = prison_lookup_gt(jid);
if (cuflags == JAIL_CREATE && pr != NULL) {
/*
* Even creators that cannot see the jail will
@@ -1777,7 +1854,9 @@
goto done_deref;
}
mtx_lock(&deadpr->pr_mtx);
+ prison_jid_remove(deadpr);
deadpr->pr_id = deadid;
+ prison_jid_insert(deadpr);
mtx_unlock(&deadpr->pr_mtx);
if (dinspr == deadpr)
inspr = deadpr;
@@ -1812,6 +1891,7 @@
TAILQ_INSERT_BEFORE(inspr, pr, pr_list);
else
TAILQ_INSERT_TAIL(&allprison, pr, pr_list);
+ prison_jid_insert(pr);
pr->pr_parent = ppr;
prison_hold(ppr);
@@ -2685,9 +2765,9 @@
error = vfs_copyopt(opts, JAIL_PARAM_LASTJID, &jid, sizeof(jid));
if (error == 0) {
- TAILQ_FOREACH(pr, &allprison, pr_list) {
- if (pr->pr_id > jid &&
- ((flags & JAIL_DYING) || prison_isalive(pr)) &&
+ for (pr = prison_lookup_gt(jid); pr != NULL;
+ pr = TAILQ_NEXT(pr, pr_list)) {
+ if (((flags & JAIL_DYING) || prison_isalive(pr)) &&
prison_ischild(mypr, pr)) {
mtx_lock(&pr->pr_mtx);
drflags |= PD_LOCKED;
@@ -3342,11 +3422,8 @@
struct prison *pr;
sx_assert(&allprison_lock, SX_LOCKED);
- TAILQ_FOREACH(pr, &allprison, pr_list) {
- if (pr->pr_id < prid)
- continue;
- if (pr->pr_id > prid)
- break;
+ pr = prison_lookup(prid);
+ if (pr != NULL) {
KASSERT(prison_isvalid(pr), ("Found invalid prison %p", pr));
mtx_lock(&pr->pr_mtx);
return (pr);
@@ -3361,16 +3438,14 @@
prison_find_child(struct prison *mypr, int prid)
{
struct prison *pr;
- int descend;
sx_assert(&allprison_lock, SX_LOCKED);
- FOREACH_PRISON_DESCENDANT(mypr, pr, descend) {
- if (pr->pr_id == prid) {
- KASSERT(prison_isvalid(pr),
- ("Found invalid prison %p", pr));
- mtx_lock(&pr->pr_mtx);
- return (pr);
- }
+ pr = prison_lookup(prid);
+ if (pr != NULL && prison_ischild(mypr, pr)) {
+ KASSERT(prison_isvalid(pr),
+ ("Found invalid prison %p", pr));
+ mtx_lock(&pr->pr_mtx);
+ return (pr);
}
return (NULL);
}
@@ -3777,6 +3852,7 @@
mac_prison_destroy(pr);
#endif
pr->pr_state = PRISON_STATE_INVALID;
+ prison_jid_remove(pr);
TAILQ_REMOVE(&allprison, pr, pr_list);
LIST_REMOVE(pr, pr_sibling);
TAILQ_INSERT_TAIL(&freeprison, pr,
@@ -3907,6 +3983,7 @@
LIST_REMOVE(rpr, pr_sibling);
rpr = cpr;
rpr->pr_state = PRISON_STATE_INVALID;
+ prison_jid_remove(rpr);
TAILQ_REMOVE(&allprison, rpr, pr_list);
TAILQ_INSERT_TAIL(freeprison, rpr, pr_list);
/*
diff --git a/sys/sys/jail.h b/sys/sys/jail.h
--- a/sys/sys/jail.h
+++ b/sys/sys/jail.h
@@ -235,6 +235,7 @@
#if defined(_KERNEL) || defined(_WANT_PRISON)
#include <sys/osd.h>
+#include <sys/tree.h>
#define HOSTUUIDLEN 64
#define DEFAULT_HOSTUUID "00000000-0000-0000-0000-000000000000"
@@ -274,6 +275,7 @@
*/
struct prison {
TAILQ_ENTRY(prison) pr_list; /* (a) all prisons */
+ RB_ENTRY(prison) pr_jidlink; /* (a) JID index */
int pr_id; /* (c) prison id */
volatile u_int pr_ref; /* (r) refcount */
volatile u_int pr_uref; /* (r) user (alive) refcount */
diff --git a/tests/sys/kern/Makefile b/tests/sys/kern/Makefile
--- a/tests/sys/kern/Makefile
+++ b/tests/sys/kern/Makefile
@@ -25,6 +25,7 @@
ATF_TESTS_C+= jail_lookup_root
ATF_TESTS_C+= jail_thread
ATF_TESTS_C+= jaildesc
+ATF_TESTS_C+= jail_jid_index
ATF_TESTS_C+= inotify_test
ATF_TESTS_C+= kill_zombie
.if ${MK_OPENSSL} != "no"
@@ -85,6 +86,7 @@
ATF_TESTS_SH+= coredump_phnum_test
ATF_TESTS_SH+= logsigexit_test
+TEST_METADATA.jail_jid_index+= is_exclusive="true"
ATF_TESTS_SH+= jailmeta
ATF_TESTS_SH+= sonewconn_overflow
TEST_METADATA.sonewconn_overflow+= required_programs="python"
@@ -105,6 +107,7 @@
LIBADD.copy_file_range+= md
LIBADD.jail_lookup_root+= jail util
LIBADD.jail_thread+= jail pthread
+LIBADD.jail_jid_index+= jail util
LIBADD.jaildesc+= kvm pthread
LIBADD.ssl_sendfile+= pthread crypto ssl
CFLAGS.sys_getrandom+= -I${SRCTOP}/sys/contrib/zstd/lib
diff --git a/tests/sys/kern/jail_jid_index.c b/tests/sys/kern/jail_jid_index.c
new file mode 100644
--- /dev/null
+++ b/tests/sys/kern/jail_jid_index.c
@@ -0,0 +1,671 @@
+/*-
+* SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Yuri Tkachenko
+ */
+#include <stdio.h>
+#include <stdlib.h>
+#include <time.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/param.h>
+#include <sys/jail.h>
+#include <sys/wait.h>
+#include <sys/socket.h>
+#include <sys/types.h>
+#include <errno.h>
+#include <atf-c.h>
+#include <jail.h>
+
+#define BASE_LOOKUP_ATTEMPTS 100
+#define RND_UPPER_BOUND 400000
+#define TEST_JID_FILE "jail_jid_test.lst"
+#define JAIL_PREFIX "jail_jid"
+#define JAIL_NAME(suffix) JAIL_PREFIX suffix
+#define ABSENT_JAIL_BY_JID(jid, flag) get_jail_by_jid(jid, flag, NULL) == -1 && \
+ errno == ENOENT
+#define ABSENT_JAIL_BY_JID_0(jid) ABSENT_JAIL_BY_JID(jid, 0)
+#define NO_JAIL_LOOKUP_OVERFLOW get_jail_by_lastjid(2147483647, 0, NULL) == -1 && \
+ errno == ENOENT
+
+struct jid_node
+{
+ int jid;
+ struct jid_node *next;
+};
+
+struct jail_fixture
+{
+ int parent;
+ int a;
+ int b;
+ int outside;
+ int base;
+};
+
+static struct jid_node *
+push(struct jid_node *head, int jid)
+{
+ struct jid_node *node = malloc(sizeof(*node));
+ if (node == NULL) {
+ return (NULL);
+ }
+
+ node->jid = jid;
+ node->next = head;
+
+ return (node);
+}
+
+static int
+find_jid(struct jid_node *head, int jid)
+{
+ struct jid_node *node;
+
+ if (head == NULL) {
+ return (0);
+ }
+
+ node = head;
+ while (node != NULL) {
+ if (node->jid == jid) return (1);
+ node = node->next;
+ }
+ return (0);
+}
+
+static void
+delete_jid_nodes(struct jid_node *head) {
+ struct jid_node *node;
+
+ while (head != NULL) {
+ node = head;
+ head = node->next;
+ free(node);
+ }
+}
+
+/* Create jail; returns jid or -1 when error */
+static int
+create_jail(int jid, const char *name, int children_max, int incl_bad_param)
+{
+ char children_max_str[2], jid_str[12];
+ int closed, written;
+ FILE *fp;
+
+ snprintf(children_max_str, sizeof(children_max_str), "%d", children_max);
+ snprintf(jid_str, sizeof(jid_str), "%d", jid);
+
+ if (incl_bad_param)
+ jid = jail_setv(JAIL_CREATE,
+ "jid", jid_str,
+ "name", name,
+ "persist", "true",
+ "path", "/",
+ "host", "inherit",
+ "ip4", "disable",
+ "ip6", "disable",
+ "children.max", children_max_str,
+ "jid_index_test_unknown_parameter", "1",
+ NULL
+ );
+ else
+ jid = jail_setv(JAIL_CREATE,
+ "jid", jid_str,
+ "name", name,
+ "persist", "true",
+ "path", "/",
+ "host", "inherit",
+ "ip4", "disable",
+ "ip6", "disable",
+ "children.max", children_max_str,
+ NULL
+ );
+ if (jid > -1) {
+ fp = fopen(TEST_JID_FILE, "a");
+ if (fp == NULL)
+ goto cleanup_jail;
+ written = fwrite(&jid, sizeof(jid), 1, fp);
+ closed = fclose(fp);
+ if (closed != 0 || written == 0)
+ goto cleanup_jail;
+ return (jid);
+
+ cleanup_jail:
+ jail_remove(jid);
+ errno = EIO;
+ jid = -1;
+ }
+
+ return (jid);
+}
+
+/* Return -1 with errno set if the record cannot be found or updated. */
+static int
+remove_journal_entry(int jid)
+{
+ FILE *fp;
+ long offset;
+ size_t nread;
+ int j, error;
+
+ fp = fopen(TEST_JID_FILE, "r+");
+ if (fp == NULL)
+ return (-1);
+ error = 0;
+ for (offset = 0; ; offset += sizeof(j)) {
+ errno = 0;
+ nread = fread(&j, 1, sizeof(j), fp);
+ if (nread != sizeof(j)) {
+ if (ferror(fp))
+ error = errno != 0 ? errno : EIO;
+ else
+ error = nread == 0 ? ENOENT : EIO;
+ break;
+ }
+ if (j != jid)
+ continue;
+ if (fseek(fp, offset, SEEK_SET) != 0) {
+ error = errno;
+ break;
+ }
+ j = -1;
+ errno = 0;
+ if (fwrite(&j, sizeof(j), 1, fp) != 1)
+ error = errno != 0 ? errno : EIO;
+ break;
+ }
+ if (fclose(fp) != 0 && error == 0)
+ error = errno;
+ if (error != 0) {
+ errno = error;
+ return (-1);
+ }
+ return (0);
+}
+
+/* remove jail and remove it from journal by setting jid record to -1 */
+static void
+purge_jail(int jid)
+{
+ if (jail_remove(jid) != 0) {
+ ATF_CHECK_MSG(0, "jail_remove(%d): %s", jid, strerror(errno));
+ } else {
+ ATF_REQUIRE_MSG(remove_journal_entry(jid) == 0,
+ "remove_journal_entry(%d): %s", jid, strerror(errno));
+ }
+}
+
+static int
+get_jail_by_jid(int jid, int flags, char *jail_name)
+{
+ char jid_str[12];
+
+ snprintf(jid_str, sizeof(jid_str), "%d", jid);
+ if (jail_name == NULL) {
+ jid = jail_getv(flags,
+ "jid", jid_str,
+ NULL
+ );
+ } else {
+ jid = jail_getv(flags,
+ "jid", jid_str,
+ "name", jail_name,
+ NULL
+ );
+ }
+ return (jid);
+}
+
+static int
+get_jail_by_lastjid(int jid, int flags, char *jail_name)
+{
+ char jid_str[12];
+
+ snprintf(jid_str, sizeof(jid_str), "%d", jid);
+ if (jail_name == NULL) {
+ jid = jail_getv(flags,
+ "lastjid", jid_str,
+ NULL
+ );
+ } else {
+ jid = jail_getv(flags,
+ "lastjid", jid_str,
+ "name", jail_name,
+ NULL
+ );
+ }
+ return (jid);
+}
+
+static struct jid_node *
+walk_jails(int start, int flags)
+{
+ struct jid_node *head;
+ int jid;
+
+ head = malloc(sizeof(*head));
+ if (head == NULL) {
+ return (NULL);
+ }
+ head->jid = -1;
+ head->next = NULL;
+
+ for (;; start = jid) {
+ jid = get_jail_by_lastjid(start, flags, NULL);
+ if (jid == -1) {
+ if (errno == ENOENT) return (head);
+ errno = EINVAL;
+ return NULL;
+ }
+ if (jid <= start) {
+ errno = EINVAL;
+ return NULL;
+ }
+ head = push(head, jid);
+ if (head == NULL) {
+ errno = EINVAL;
+ return NULL;
+ }
+ }
+}
+
+static int
+fork_check(int parent_jid, int a_jid, int b_jid, int outside_jid)
+{
+ pid_t pid;
+ int stat_loc, passed;
+ struct jid_node *jids;
+
+ pid = fork();
+ if (pid == 0) {
+ if (jail_attach(parent_jid)==0 &&
+ get_jail_by_jid(a_jid, 0, NULL) == a_jid &&
+ ABSENT_JAIL_BY_JID_0(parent_jid) &&
+ ABSENT_JAIL_BY_JID_0(outside_jid)
+ ) {
+ jids = walk_jails(0, 0);
+ passed = find_jid(jids, a_jid) && find_jid(jids, b_jid);
+ delete_jid_nodes(jids);
+ if (passed)
+ exit(EXIT_SUCCESS);
+ }
+ exit(EXIT_FAILURE);
+ } else if (pid == -1) {
+ return (-1);
+ }
+ while (waitpid(pid, &stat_loc, 0) == -1) {
+ if (errno != EINTR)
+ return (-1);
+ }
+ return (WIFEXITED(stat_loc) && WEXITSTATUS(stat_loc) == EXIT_SUCCESS ? 0 : -1);
+}
+
+/* Retain a jailed socket credential in the host until the returned FD closes. */
+static int
+held_socket(int jid)
+{
+ union {
+ struct cmsghdr align;
+ char buffer[CMSG_SPACE(sizeof(int))];
+ } control;
+ struct msghdr msg_hdr;
+ struct iovec iov;
+ struct cmsghdr *cmsg;
+ pid_t pid, waited;
+ ssize_t n;
+ int sv[2], sock_fd, received_fd, status, error;
+ char byte;
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+ return (-1);
+ pid = fork();
+ if (pid == -1) {
+ error = errno;
+ close(sv[0]);
+ close(sv[1]);
+ errno = error;
+ return (-1);
+ }
+
+ memset(&msg_hdr, 0, sizeof(msg_hdr));
+ memset(&control, 0, sizeof(control));
+ byte = 'x';
+ iov.iov_base = &byte;
+ iov.iov_len = sizeof(byte);
+ msg_hdr.msg_iov = &iov;
+ msg_hdr.msg_iovlen = 1;
+ msg_hdr.msg_control = control.buffer;
+ msg_hdr.msg_controllen = sizeof(control.buffer);
+
+ if (pid == 0) {
+ close(sv[0]);
+ if (jail_attach(jid) == -1)
+ _exit(EXIT_FAILURE);
+ sock_fd = socket(AF_UNIX, SOCK_DGRAM, 0);
+ if (sock_fd == -1)
+ _exit(EXIT_FAILURE);
+ cmsg = CMSG_FIRSTHDR(&msg_hdr);
+ cmsg->cmsg_level = SOL_SOCKET;
+ cmsg->cmsg_type = SCM_RIGHTS;
+ cmsg->cmsg_len = CMSG_LEN(sizeof(sock_fd));
+ memcpy(CMSG_DATA(cmsg), &sock_fd, sizeof(sock_fd));
+ do {
+ n = sendmsg(sv[1], &msg_hdr, 0);
+ } while (n == -1 && errno == EINTR);
+ close(sock_fd);
+ close(sv[1]);
+ _exit(n == sizeof(byte) ? EXIT_SUCCESS : EXIT_FAILURE);
+ }
+
+ close(sv[1]);
+ received_fd = -1;
+ error = 0;
+ do {
+ /* recvmsg() updates these fields; restore them before a retry. */
+ msg_hdr.msg_controllen = sizeof(control.buffer);
+ msg_hdr.msg_flags = 0;
+ n = recvmsg(sv[0], &msg_hdr, 0);
+ } while (n == -1 && errno == EINTR);
+ if (n == -1)
+ error = errno;
+ else {
+ /* Close any extra received rights even if the message is invalid. */
+ for (cmsg = CMSG_FIRSTHDR(&msg_hdr); cmsg != NULL;
+ cmsg = CMSG_NXTHDR(&msg_hdr, cmsg)) {
+ size_t len, offset;
+ int fd;
+
+ if (cmsg->cmsg_level != SOL_SOCKET ||
+ cmsg->cmsg_type != SCM_RIGHTS)
+ continue;
+ if (cmsg->cmsg_len < CMSG_LEN(0)) {
+ error = EIO;
+ break;
+ }
+ len = cmsg->cmsg_len - CMSG_LEN(0);
+ if (len % sizeof(fd) != 0)
+ error = EIO;
+ for (offset = 0; offset + sizeof(fd) <= len;
+ offset += sizeof(fd)) {
+ memcpy(&fd, (char *)CMSG_DATA(cmsg) + offset,
+ sizeof(fd));
+ if (received_fd == -1)
+ received_fd = fd;
+ else {
+ close(fd);
+ error = EIO;
+ }
+ }
+ }
+ if (n != sizeof(byte) || byte != 'x' || received_fd == -1 ||
+ (msg_hdr.msg_flags & (MSG_CTRUNC | MSG_TRUNC)) != 0)
+ error = EIO;
+ }
+ close(sv[0]);
+ do {
+ waited = waitpid(pid, &status, 0);
+ } while (waited == -1 && errno == EINTR);
+ if (waited == -1) {
+ if (error == 0)
+ error = errno;
+ } else if (!WIFEXITED(status) || WEXITSTATUS(status) != EXIT_SUCCESS) {
+ if (error == 0)
+ error = EIO;
+ }
+ if (error != 0) {
+ if (received_fd != -1)
+ close(received_fd);
+ errno = error;
+ return (-1);
+ }
+ return (received_fd);
+}
+
+static int
+wait_absent(int jid)
+{
+ struct timespec curr, start, sleep_ts;
+ double start_secs, curr_secs;
+
+ sleep_ts.tv_sec = (time_t)0;
+ sleep_ts.tv_nsec = 2e7;
+
+ clock_gettime(CLOCK_MONOTONIC, &start);
+ start_secs = start.tv_sec + (double)start.tv_nsec / 1e9;
+ while (!(ABSENT_JAIL_BY_JID(jid, JAIL_DYING))) {
+ clock_gettime(CLOCK_MONOTONIC, &curr);
+ curr_secs = curr.tv_sec + (double)curr.tv_nsec / 1e9;
+ if (curr_secs - start_secs > 10 || nanosleep(&sleep_ts, NULL) < 0)
+ return (-1);
+ }
+ return (0);
+}
+
+static void
+setup_jails(struct jail_fixture *fixture)
+{
+ int i, j;
+
+ srand((unsigned)time(NULL));
+
+ for (i=0; i < BASE_LOOKUP_ATTEMPTS; i++) {
+ fixture->base = rand() % RND_UPPER_BOUND;
+ for (j=0; j < 8; j++) {
+ if (!(ABSENT_JAIL_BY_JID(fixture->base + j, JAIL_DYING))) break;
+ }
+ if (j == 8) break;
+ }
+ fixture->parent = create_jail(fixture->base, JAIL_PREFIX, 4, 0);
+ ATF_REQUIRE_MSG(fixture->parent != -1, "parent create_jail: %s", jail_errmsg);
+ fixture->a = create_jail(fixture->base + 2, JAIL_NAME(".a"), 0, 0);
+ ATF_REQUIRE_MSG(fixture->a != -1, "create_jail[a]: %s", jail_errmsg);
+ fixture->b = create_jail(fixture->base + 4, JAIL_NAME(".b"), 0, 0);
+ ATF_REQUIRE_MSG(fixture->b != -1, "create_jail[b]: %s", jail_errmsg);
+ fixture->outside = create_jail(fixture->base + 6, JAIL_NAME("_outside"), 0, 0);
+ ATF_REQUIRE_MSG(fixture->outside != -1, "create_jail[outside]: %s", jail_errmsg);
+}
+
+static void
+cleanup_journal(void)
+{
+ FILE *fp;
+ int jid;
+ int failed = 0;
+ long offset;
+
+ fp = fopen(TEST_JID_FILE, "r");
+ if (fp != NULL) {
+ ATF_REQUIRE(fp != NULL);
+ if (fseek(fp, 0, SEEK_END) != 0)
+ goto io_error;
+ offset = ftell(fp);
+ if (offset < 0 || offset % sizeof(jid) != 0)
+ goto io_error;
+
+ while (offset > 0) {
+ offset -= sizeof(jid);
+ if (fseek(fp, offset, SEEK_SET) != 0 ||
+ fread(&jid, sizeof(jid), 1, fp) != 1)
+ goto io_error;
+ if (jid > -1 && jail_remove(jid) != 0 && errno != ENOENT) {
+ ATF_CHECK_MSG(0, "jail_remove(%d): %s", jid, strerror(errno));
+ failed = 1;
+ }
+ }
+ fclose(fp);
+ if (!failed)
+ remove(TEST_JID_FILE);
+ return;
+
+ io_error:
+ ATF_CHECK_MSG(0, "Unable to read jid tracker - %s", TEST_JID_FILE);
+ fclose(fp);
+ }
+}
+
+ATF_TC_WITH_CLEANUP(lookup_enumeration);
+ATF_TC_HEAD(lookup_enumeration, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "JID lookup and enumeration with sparse IDs and boundary cursors");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(lookup_enumeration, tc)
+{
+ struct jail_fixture fixture = {};
+ int ret;
+ char jail_name[MAXHOSTNAMELEN];
+ struct jid_node *jids;
+
+ setup_jails(&fixture);
+
+ ATF_REQUIRE_MSG(get_jail_by_jid(fixture.parent, 0, jail_name) != -1,
+ "get_jail: %s", jail_errmsg);
+ ATF_REQUIRE_MSG(strcmp(JAIL_PREFIX, jail_name) == 0,
+ "failed: %s != %s", JAIL_PREFIX, jail_name);
+ ATF_REQUIRE_MSG(get_jail_by_jid(fixture.a, 0, jail_name) != -1,
+ "get_jail[a]: %s", jail_errmsg);
+ ATF_REQUIRE(strcmp(JAIL_NAME(".a"), jail_name) == 0);
+ ATF_REQUIRE(ABSENT_JAIL_BY_JID_0(fixture.base + 1));
+ ATF_REQUIRE(get_jail_by_lastjid(fixture.base + 1, 0, NULL) == fixture.a);
+ ATF_REQUIRE(get_jail_by_lastjid(fixture.a, 0, NULL) == fixture.b);
+ ATF_REQUIRE(NO_JAIL_LOOKUP_OVERFLOW);
+ jids = walk_jails(-1, 0);
+ ret = find_jid(jids, fixture.parent);
+ delete_jid_nodes(jids);
+ ATF_REQUIRE_MSG(ret, "Unable to find parent jid: %d", fixture.parent);
+}
+ATF_TC_CLEANUP(lookup_enumeration, tc)
+{
+ cleanup_journal();
+}
+
+ATF_TC_WITH_CLEANUP(nested_visibility);
+ATF_TC_HEAD(nested_visibility, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "JID lookup and enumeration visibility from a parent jail");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(nested_visibility, tc)
+{
+ struct jail_fixture fixture;
+ int ret;
+
+ setup_jails(&fixture);
+ ret = fork_check(fixture.parent, fixture.a, fixture.b, fixture.outside);
+ ATF_REQUIRE_MSG(ret == 0, "jail_attach check via fork failed");
+}
+ATF_TC_CLEANUP(nested_visibility, tc)
+{
+ cleanup_journal();
+}
+
+ATF_TC_WITH_CLEANUP(enumeration_after_removal);
+ATF_TC_HEAD(enumeration_after_removal, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "JID enumeration after removing the jail used as a cursor");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(enumeration_after_removal, tc)
+{
+ struct jail_fixture fixture;
+
+ setup_jails(&fixture);
+ ATF_REQUIRE(create_jail(fixture.base + 1, JAIL_NAME("_failed"), 0, 1) == -1);
+ ATF_REQUIRE(wait_absent(fixture.base + 1) == 0);
+ purge_jail(fixture.a);
+ ATF_REQUIRE(wait_absent(fixture.a) == 0);
+ ATF_REQUIRE(get_jail_by_lastjid(fixture.a, 0, NULL) == fixture.b);
+ ATF_REQUIRE(get_jail_by_lastjid(fixture.base + 1, 0, NULL) == fixture.b);
+}
+ATF_TC_CLEANUP(enumeration_after_removal, tc)
+{
+ cleanup_journal();
+}
+
+ATF_TC_WITH_CLEANUP(dying_jid_reuse);
+ATF_TC_HEAD(dying_jid_reuse, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Dying jail renumbering, JID reuse, and final release");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(dying_jid_reuse, tc)
+{
+ struct jail_fixture fixture;
+ int i, old_jid, renamed_jid, held_fd;
+ char jail_name[MAXHOSTNAMELEN];
+ struct jid_node *jids;
+
+ setup_jails(&fixture);
+ old_jid = create_jail(fixture.base + 1, JAIL_NAME("_old"), 0, 0);
+ ATF_REQUIRE(old_jid != -1);
+ held_fd = held_socket(old_jid);
+ ATF_REQUIRE_MSG(held_fd != -1, "Unable to hold socket, dying jail won't be reproduced");
+ purge_jail(old_jid);
+ ATF_REQUIRE(get_jail_by_jid(old_jid, JAIL_DYING, jail_name) != -1 &&
+ strcmp(JAIL_NAME("_old"), jail_name) == 0);
+ ATF_REQUIRE(ABSENT_JAIL_BY_JID_0(old_jid));
+ ATF_REQUIRE(create_jail(old_jid, JAIL_NAME("_replacement"), 0, 0) != -1);
+ renamed_jid = jail_getv(JAIL_DYING, "name", JAIL_NAME("_old"), NULL);
+ ATF_REQUIRE(renamed_jid != old_jid);
+ ATF_REQUIRE(get_jail_by_jid(old_jid, 0, jail_name) != -1 &&
+ strcmp(JAIL_NAME("_replacement"), jail_name) == 0
+ );
+ ATF_REQUIRE(get_jail_by_jid(renamed_jid, JAIL_DYING, jail_name) != -1 &&
+ strcmp(JAIL_NAME("_old"), jail_name) == 0
+ );
+ ATF_REQUIRE(ABSENT_JAIL_BY_JID_0(renamed_jid));
+ jids = walk_jails(0, JAIL_DYING);
+ i = find_jid(jids, renamed_jid);
+ delete_jid_nodes(jids);
+ ATF_REQUIRE_MSG(i, "Unable to find dying jail(%d)", renamed_jid);
+ ATF_REQUIRE(close(held_fd) == 0);
+ ATF_REQUIRE(wait_absent(renamed_jid) == 0);
+}
+ATF_TC_CLEANUP(dying_jid_reuse, tc)
+{
+ cleanup_journal();
+}
+
+ATF_TC_WITH_CLEANUP(subtree_removal);
+ATF_TC_HEAD(subtree_removal, tc)
+{
+ atf_tc_set_md_var(tc, "descr",
+ "Parent removal destroys its subtree and preserves unrelated jails");
+ atf_tc_set_md_var(tc, "require.user", "root");
+}
+ATF_TC_BODY(subtree_removal, tc)
+{
+ struct jail_fixture fixture;
+ char jail_name[MAXHOSTNAMELEN];
+
+ setup_jails(&fixture);
+ purge_jail(fixture.parent);
+ ATF_REQUIRE(wait_absent(fixture.parent) == 0);
+ ATF_REQUIRE(wait_absent(fixture.b) == 0);
+ ATF_REQUIRE(wait_absent(fixture.a) == 0);
+ ATF_REQUIRE_MSG(remove_journal_entry(fixture.b) == 0,
+ "remove_journal_entry(%d): %s", fixture.b, strerror(errno));
+ ATF_REQUIRE_MSG(remove_journal_entry(fixture.a) == 0,
+ "remove_journal_entry(%d): %s", fixture.a, strerror(errno));
+ ATF_REQUIRE(get_jail_by_jid(fixture.outside, 0, jail_name) != -1 &&
+ strcmp(JAIL_NAME("_outside"), jail_name) == 0);
+}
+ATF_TC_CLEANUP(subtree_removal, tc)
+{
+ cleanup_journal();
+}
+
+ATF_TP_ADD_TCS(tp)
+{
+ ATF_TP_ADD_TC(tp, lookup_enumeration);
+ ATF_TP_ADD_TC(tp, nested_visibility);
+ ATF_TP_ADD_TC(tp, enumeration_after_removal);
+ ATF_TP_ADD_TC(tp, dying_jid_reuse);
+ ATF_TP_ADD_TC(tp, subtree_removal);
+ return (atf_no_error());
+}
diff --git a/tools/test/README b/tools/test/README
--- a/tools/test/README
+++ b/tools/test/README
@@ -12,6 +12,7 @@
gpioevents Test delivery of gpio pin-change events to userland.
hwpmc Automatically trigger every event in hwpmc(4).
iconv Character set conversion tests.
+jail Jail deletion, creation, listing, and JID lookup scaling benchmarks.
malloc A program to test and benchmark malloc().
net A set of generic test programs for networking.
netfibs Programs to test multi-FIB network stacks.
diff --git a/tools/test/jail/Makefile b/tools/test/jail/Makefile
new file mode 100644
--- /dev/null
+++ b/tools/test/jail/Makefile
@@ -0,0 +1,112 @@
+PROG= jail_get_bench
+MAN=
+
+PYTHON?= python3
+DTRACE?= dtrace
+DTRACEFLAGS?= -x nolibs
+
+.if make(smoke)
+MAX_JAILS?= 10
+LIST_EVERY?= 1
+.else
+MAX_JAILS?= 10000
+LIST_EVERY?= 100
+.endif
+REPEATS?= 5
+WARMUPS?= 1
+MODES?= jid,default
+OUTPUT?=
+PAUSE_AT?=
+ALLOW_EXISTING?= no
+RUN?=
+INPUT?= .
+KEEP_JAILS?= no
+CLEANUP_ORDER?= reverse
+DRAIN_TIMEOUT?= 30
+
+TRACE_REPEATS?= 20
+TRACE_LABEL?= manual
+TRACE_DIR?= ${.OBJDIR}/traces
+
+_RUNNER= ${.CURDIR}/jailbench.py
+_SYSCALL_TRACE= ${.CURDIR}/jls.d
+_LOCK_TRACE= ${.CURDIR}/allprison_wait.d
+_PROBE= ${.OBJDIR}/${PROG}
+_BENCH_ARGS= --max-jails ${MAX_JAILS:Q} --list-every ${LIST_EVERY:Q} \
+ --repeats ${REPEATS:Q} --warmups ${WARMUPS:Q} \
+ --modes ${MODES:Q} --probe ${_PROBE:Q} \
+ --cleanup-order ${CLEANUP_ORDER:Q} --drain-timeout ${DRAIN_TIMEOUT:Q}
+.if !empty(OUTPUT)
+_BENCH_ARGS+= --output ${OUTPUT:Q}
+.endif
+.if !empty(PAUSE_AT)
+_BENCH_ARGS+= --pause-at ${PAUSE_AT:Q}
+.endif
+.if ${ALLOW_EXISTING:tl} == "yes"
+_BENCH_ARGS+= --allow-existing
+.endif
+
+.if ${KEEP_JAILS:tl} == "yes"
+_BENCH_ARGS+= --keep-jails
+.endif
+
+_CLEANUP_ARGS= --cleanup-order ${CLEANUP_ORDER:Q} --drain-timeout ${DRAIN_TIMEOUT:Q}
+_ENUM_COMMAND= ./${PROG} enum ${TRACE_REPEATS}
+_TRACE_JLS= ${TRACE_DIR}/jls-${TRACE_LABEL}.txt
+_TRACE_ENUM= ${TRACE_DIR}/enum-${TRACE_LABEL}.txt
+_TRACE_LOCK= ${TRACE_DIR}/lock-${TRACE_LABEL}.txt
+_TRACE_CLEANUP= ${TRACE_DIR}/cleanup-${TRACE_LABEL}.txt
+
+.PHONY: test benchmark smoke cleanup-run report trace trace-jls trace-enum trace-lock trace-cleanup
+
+test benchmark smoke: ${PROG}
+ ${PYTHON:Q} ${_RUNNER:Q} ${_BENCH_ARGS}
+
+report:
+ ${PYTHON:Q} ${.CURDIR:Q}/report.py ${INPUT:Q}
+
+cleanup-run:
+.if empty(RUN)
+ @echo "Set RUN to a saved run directory" >&2
+ @false
+.else
+ ${PYTHON:Q} ${_RUNNER:Q} --cleanup-run ${RUN:Q} ${_CLEANUP_ARGS}
+.endif
+
+# Destructive: trace exactly one cleanup of a retained benchmark population.
+trace-cleanup:
+.if empty(RUN)
+ @echo "Set RUN to a retained benchmark run directory" >&2
+ @false
+.else
+ ${PYTHON:Q} ${_RUNNER:Q} --cleanup-run ${RUN:Q} ${_CLEANUP_ARGS} \
+ --trace-cleanup ${_TRACE_CLEANUP:Q} --dtrace ${DTRACE:Q} \
+ --dtrace-flags=${DTRACEFLAGS:Q}
+.endif
+
+# Run separately from timing trials, while the population is paused.
+# Recursive makes keep these tracing passes sequential even with make -j.
+trace:
+ ${MAKE} -C ${.CURDIR:Q} trace-jls
+ ${MAKE} -C ${.CURDIR:Q} trace-enum
+ ${MAKE} -C ${.CURDIR:Q} trace-lock
+
+trace-jls:
+ mkdir -p ${TRACE_DIR:Q}
+ ${DTRACE:Q} ${DTRACEFLAGS} -q -o ${_TRACE_JLS:Q} -s ${_SYSCALL_TRACE:Q} \
+ -c '/usr/sbin/jls -n jid' > /dev/null
+ @echo "DTrace report:" ${_TRACE_JLS:Q}
+
+trace-enum: ${PROG}
+ mkdir -p ${TRACE_DIR:Q}
+ ${DTRACE:Q} ${DTRACEFLAGS} -q -o ${_TRACE_ENUM:Q} -s ${_SYSCALL_TRACE:Q} \
+ -c ${_ENUM_COMMAND:Q} > /dev/null
+ @echo "DTrace report:" ${_TRACE_ENUM:Q}
+
+trace-lock: ${PROG}
+ mkdir -p ${TRACE_DIR:Q}
+ ${DTRACE:Q} ${DTRACEFLAGS} -q -o ${_TRACE_LOCK:Q} -s ${_LOCK_TRACE:Q} \
+ -c ${_ENUM_COMMAND:Q} > /dev/null
+ @echo "DTrace report:" ${_TRACE_LOCK:Q}
+
+.include <bsd.prog.mk>
diff --git a/tools/test/jail/README b/tools/test/jail/README
new file mode 100644
--- /dev/null
+++ b/tools/test/jail/README
@@ -0,0 +1,69 @@
+Jail management benchmark
+Requires root, Python 3, and an idle host. Creates flat persistent
+jails: path=/, host=inherit, no VNET, IP addresses, or processes.
+ make
+ sudo make smoke OUTPUT=smoke
+ sudo make test MAX_JAILS=10000 LIST_EVERY=100 OUTPUT=baseline
+test and benchmark are aliases. OUTPUT must be a new directory. Do not run timing trials concurrently.
+Make variables (defaults):
+ MAX_JAILS=10000 smoke: 10
+ LIST_EVERY=100 smoke: 1; queries also run at 0, 1, and MAX_JAILS
+ REPEATS=5 WARMUPS=1
+ MODES=jid,default optional: all
+ PAUSE_AT= comma-separated populations; wait for Enter
+ ALLOW_EXISTING=no yes permits unrelated live/dying jails
+ KEEP_JAILS=no yes retains jails only after success
+ CLEANUP_ORDER=reverse newest first; forward removes oldest first
+ DRAIN_TIMEOUT=30 seconds; 0 observes once without waiting
+ PYTHON=python3
+Results (times in ns):
+ creation.csv each jail -i -c, including process startup
+ queries.csv repeated query samples at each checkpoint
+ run.json environment, arguments, status, cleanup history
+ cleanup/*/deletion.csv each jail -r name, including process startup
+ cleanup/*/summary.json snapshot, removal, verification, drain, total
+
+Summarize existing results (no root needed):
+
+ make report INPUT=baseline
+ python3 report.py collection.tar.gz > report.txt
+
+Accepts directories, tar/zip archives, multiple inputs, and --all-checkpoints.
+Prints timing statistics, cleanup status, and trace diagnostics.
+
+Query operations:
+ jls_jid/default/all jls -n jid / jls / jls -n all; output discarded
+ syscall_enum jail_get(lastjid), jid only; N+1 calls
+ syscall_jid N exact-JID lookups; snapshot outside timing
+Direct probes exclude startup/printing and include the userspace loop.
+Creation row N has N-1 benchmark jails already present. Compare medians and
+spread across matched kernels/configurations; repeat whole growth runs.
+Cleanup uses journaled names, including after errors or interruption.
+Keep names.txt until cleanup completes. Retry with:
+ sudo make cleanup-run RUN=baseline
+deletion.csv tracks population from a snapshot and successful removals.
+Exclude status=not_live rows. Keep removal order fixed across comparisons;
+test each order on a fresh population. Drain polls jls -d about every 250 ms;
+disappearance means unlinking, not completion of all resource destruction.
+DTrace: pause the benchmark, then trace from another terminal:
+ sudo make test PAUSE_AT=1000,10000 OUTPUT=trace-populations
+ sudo kldload dtraceall
+ sudo make trace TRACE_LABEL=1000
+After tracing finishes, resume; repeat at 10000 with a new label. trace runs
+trace-jls, trace-enum, trace-lock sequentially, each tracing its own command.
+TRACE_LABEL selects filenames, not population. Defaults:
+ TRACE_DIR=${.OBJDIR}/traces TRACE_REPEATS=20 TRACE_LABEL=manual
+ DTRACE=dtrace DTRACEFLAGS="-x nolibs"
+Reports: jls-LABEL.txt, enum-LABEL.txt, lock-LABEL.txt. Lock events are
+system-wide allprison_lock sleep/spin waits, not hold times.
+Trace deletion on a fresh retained population:
+ sudo make test MAX_JAILS=10000 KEEP_JAILS=yes OUTPUT=delete-10000
+ sudo make trace-cleanup RUN=delete-10000 TRACE_LABEL=delete-10000
+trace-cleanup removes the jails and records cleanup measurements plus
+cleanup-LABEL.txt and .txt.stderr. It includes asynchronous prison cleanup;
+function times are inclusive and may overlap. Missing/inlined FBT probes
+are allowed. Check call counts, drops, and cpu_gt_elapsed_samples before
+attributing CPU time. Keep instrumented and uninstrumented trials separate.
+After installing tests/sys/kern:
+ cd /usr/tests/sys/kern
+ sudo kyua test jail_jid_index
diff --git a/tools/test/jail/allprison_wait.d b/tools/test/jail/allprison_wait.d
new file mode 100644
--- /dev/null
+++ b/tools/test/jail/allprison_wait.d
@@ -0,0 +1,21 @@
+#!/usr/sbin/dtrace -s
+/* System-wide waits during -c command. Arguments match this source tree. */
+#pragma D option quiet
+
+lockstat:::sx-block,
+lockstat:::sx-spin
+/arg0 == (uintptr_t)&`allprison_lock/
+{
+ /* arg1: nanoseconds; arg2: requested mode (writer=0, reader=1). */
+ @events[execname, probename, arg2] = count();
+ @wait_ns[execname, probename, arg2] = sum(arg1);
+ @wait_us[execname, probename, arg2] = quantize(arg1 / 1000);
+}
+
+END
+{
+ printf("allprison waits: process, probe, requested mode (0=writer, 1=reader)\n");
+ printa("%s %s mode=%d events=%@d\n", @events);
+ printa("%s %s mode=%d total_ns=%@d\n", @wait_ns);
+ printa("%s %s mode=%d microseconds:\n%@d\n", @wait_us);
+}
diff --git a/tools/test/jail/cleanup.d b/tools/test/jail/cleanup.d
new file mode 100644
--- /dev/null
+++ b/tools/test/jail/cleanup.d
@@ -0,0 +1,106 @@
+#!/usr/sbin/dtrace -s
+/* Run via make trace-cleanup; the runner specializes this prefix per run. */
+#pragma D option quiet
+
+inline string bench_prefix = "jidbench_";
+
+/* The target is the cleanup runner; jail/jls are its direct children. */
+syscall::jail_get:entry,
+syscall::jail_remove:entry
+/pid == $target || ppid == $target/
+{
+ @sys_entries[execname, probefunc] = count();
+ self->sys_wall = timestamp;
+ self->sys_cpu = vtimestamp;
+}
+
+syscall::jail_get:return,
+syscall::jail_remove:return
+/(pid == $target || ppid == $target) && self->sys_wall/
+{
+ this->wall = timestamp - self->sys_wall;
+ this->cpu = vtimestamp - self->sys_cpu;
+ @sys_returns[execname, probefunc] = count();
+ @sys_wall_ns[execname, probefunc] = sum(this->wall);
+ @sys_cpu_ns[execname, probefunc] = sum(this->cpu);
+ @sys_invalid_cpu[execname, probefunc] = sum(this->cpu > this->wall);
+ @sys_wall_us[execname, probefunc] = quantize(this->wall / 1000);
+ self->sys_wall = 0;
+ self->sys_cpu = 0;
+}
+
+/* Include asynchronous teardown on taskqueue threads, filtered by jail name.
+ * Track every entry's nesting so an unrelated nested prison cannot consume a
+ * matching outer function's timestamp. FBT probes may be absent/inlined; -Z
+ * permits that. These are inclusive times and MUST NOT be added together.
+ */
+fbt::prison_deref:entry,
+fbt::prison_deref_kill:entry,
+fbt::prison_complete:entry,
+fbt::prison_jid_remove:entry
+{
+ self->depth[probefunc]++;
+ self->start[probefunc, self->depth[probefunc]] =
+ substr(stringof(((struct prison *)arg0)->pr_name), 0,
+ strlen(bench_prefix)) == bench_prefix ? timestamp : 0;
+}
+
+/* The generated RB remove function takes the prison as its second argument. */
+fbt::prison_jid_tree_RB_REMOVE:entry
+{
+ self->depth[probefunc]++;
+ self->start[probefunc, self->depth[probefunc]] =
+ substr(stringof(((struct prison *)arg1)->pr_name), 0,
+ strlen(bench_prefix)) == bench_prefix ? timestamp : 0;
+}
+
+fbt::prison_deref:return,
+fbt::prison_deref_kill:return,
+fbt::prison_complete:return,
+fbt::prison_jid_remove:return,
+fbt::prison_jid_tree_RB_REMOVE:return
+/self->depth[probefunc] && self->start[probefunc, self->depth[probefunc]]/
+{
+ this->elapsed = timestamp - self->start[probefunc, self->depth[probefunc]];
+ @kernel_calls[probefunc] = count();
+ @kernel_ns[probefunc] = sum(this->elapsed);
+ @kernel_us[probefunc] = quantize(this->elapsed / 1000);
+}
+
+fbt::prison_deref:return,
+fbt::prison_deref_kill:return,
+fbt::prison_complete:return,
+fbt::prison_jid_remove:return,
+fbt::prison_jid_tree_RB_REMOVE:return
+/self->depth[probefunc]/
+{
+ self->start[probefunc, self->depth[probefunc]] = 0;
+ self->depth[probefunc]--;
+}
+
+/* System-wide lock waits during this cleanup, matching allprison_wait.d. */
+lockstat:::sx-block,
+lockstat:::sx-spin
+/arg0 == (uintptr_t)&`allprison_lock/
+{
+ @lock_events[execname, probename, arg2] = count();
+ @lock_ns[execname, probename, arg2] = sum(arg1);
+}
+
+END
+{
+ printf("Syscalls: executable, function; compare entry/return counts\n");
+ printa("%s %s entries=%@d\n", @sys_entries);
+ printa("%s %s returns=%@d\n", @sys_returns);
+ printa("%s %s elapsed_ns=%@d\n", @sys_wall_ns);
+ printa("%s %s cpu_ns=%@d\n", @sys_cpu_ns);
+ printa("%s %s cpu_gt_elapsed_samples=%@d\n", @sys_invalid_cpu);
+ printa("%s %s elapsed microseconds:\n%@d\n", @sys_wall_us);
+ printf("Kernel functions: benchmark prefix only; inclusive times\n");
+ printa("%s calls=%@d\n", @kernel_calls);
+ printa("%s elapsed_ns=%@d\n", @kernel_ns);
+ printa("%s microseconds:\n%@d\n", @kernel_us);
+ printf("System-wide allprison waits: executable, probe, mode (0=writer, 1=reader)\n");
+ printa("%s %s mode=%d events=%@d\n", @lock_events);
+ printa("%s %s mode=%d elapsed_ns=%@d\n", @lock_ns);
+}
diff --git a/tools/test/jail/jail_get_bench.c b/tools/test/jail/jail_get_bench.c
new file mode 100644
--- /dev/null
+++ b/tools/test/jail/jail_get_bench.c
@@ -0,0 +1,114 @@
+#include <sys/types.h>
+#include <sys/param.h>
+#include <sys/jail.h>
+#include <sys/uio.h>
+
+#include <err.h>
+#include <errno.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+static uint64_t
+nsec(void)
+{
+ struct timespec ts;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0)
+ err(1, "clock_gettime");
+ return ((uint64_t)ts.tv_sec * 1000000000 + ts.tv_nsec);
+}
+
+static int
+getone(const char *key, int value)
+{
+ /* Reconstruct iovecs: jail_get() may update their lengths. */
+ int jid = 0;
+ struct iovec iov[] = {
+ { .iov_base = __DECONST(void *, key), .iov_len = strlen(key) + 1 },
+ { .iov_base = &value, .iov_len = sizeof(value) },
+ { .iov_base = __DECONST(void *, "jid"), .iov_len = sizeof("jid") },
+ { .iov_base = &jid, .iov_len = sizeof(jid) },
+ };
+
+ /* Exact lookup needs only the key, with no separate output parameter. */
+ if (strcmp(key, "jid") == 0)
+ return (jail_get(iov, 2, 0));
+ return (jail_get(iov, 4, 0));
+}
+
+int
+main(int argc, char **argv)
+{
+ int *ids = NULL, *newids, jid, last, rounds;
+ size_t count = 0, capacity = 0, calls, visible;
+ uint64_t start, elapsed;
+ char *end;
+ long parsed;
+ int exact;
+
+ if (argc != 3 || (strcmp(argv[1], "enum") != 0 &&
+ strcmp(argv[1], "jid") != 0))
+ errx(1, "usage: %s enum|jid repetitions", argv[0]);
+ errno = 0;
+ parsed = strtol(argv[2], &end, 10);
+ if (errno != 0 || *end != '\0' || parsed < 1 || parsed > 100000)
+ errx(1, "invalid repetitions");
+ rounds = (int)parsed;
+ exact = strcmp(argv[1], "jid") == 0;
+ /* Snapshot lookup targets outside the timed exact-lookup loop. */
+ if (exact) {
+ last = 0;
+ while ((jid = getone("lastjid", last)) >= 0) {
+ if (jid <= last)
+ errx(1, "non-increasing JID");
+ if (count == capacity) {
+ capacity = capacity == 0 ? 128 : capacity * 2;
+ newids = realloc(ids, capacity * sizeof(*ids));
+ if (newids == NULL)
+ err(1, "realloc");
+ ids = newids;
+ }
+ ids[count++] = jid;
+ last = jid;
+ }
+ if (errno != ENOENT)
+ err(1, "snapshot jail_get");
+ }
+ puts("repeat,visible_jails,syscalls,elapsed_ns");
+ for (int r = 0; r < rounds; r++) {
+ calls = visible = 0;
+ last = 0;
+ start = nsec();
+ if (exact) {
+ for (size_t i = 0; i < count; i++) {
+ jid = getone("jid", ids[i]);
+ if (jid != ids[i])
+ errx(1, "JID disappeared/changed during lookup");
+ calls++;
+ visible++;
+ }
+ } else {
+ for (;;) {
+ jid = getone("lastjid", last);
+ calls++;
+ if (jid < 0) {
+ if (errno != ENOENT)
+ err(1, "enumeration jail_get");
+ break;
+ }
+ if (jid <= last)
+ errx(1, "non-increasing JID");
+ last = jid;
+ visible++;
+ }
+ }
+ elapsed = nsec() - start;
+ printf("%d,%zu,%zu,%ju\n", r, visible, calls,
+ (uintmax_t)elapsed);
+ }
+ free(ids);
+ return (0);
+}
diff --git a/tools/test/jail/jailbench.py b/tools/test/jail/jailbench.py
new file mode 100644
--- /dev/null
+++ b/tools/test/jail/jailbench.py
@@ -0,0 +1,376 @@
+#!/usr/bin/env python3
+import argparse
+import csv
+import datetime
+import io
+import json
+import os
+from pathlib import Path
+import platform
+import shlex
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+import uuid
+
+
+class Stopped(Exception):
+ pass
+
+
+def command(argv, timeout=120, capture=True):
+ return subprocess.run(argv, check=True, text=True, timeout=timeout,
+ stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
+ stderr=subprocess.PIPE)
+
+
+def stop(signum, frame):
+ raise Stopped(f"received signal {signum}")
+
+
+def listed_jails(include_dying=False):
+ argv = ["jls"] + (["-d"] if include_dying else []) + ["-n", "jid", "name"]
+ rows = []
+ for line in command(argv).stdout.splitlines():
+ fields = dict(item.split("=", 1) for item in shlex.split(line) if "=" in item)
+ rows.append((int(fields["jid"]), fields["name"]))
+ return rows
+
+
+def cleanup_names(run):
+ info = json.loads((run / "run.json").read_text())
+ prefix = info["prefix"]
+ names = list(dict.fromkeys((run / "names.txt").read_text().splitlines()))
+ if not prefix.startswith("jidbench_") or not prefix.replace("_", "").isalnum():
+ raise ValueError("invalid cleanup prefix")
+ if any(not n.startswith(prefix + "_") or
+ not n[len(prefix) + 1:].isdigit() for n in names):
+ raise ValueError("invalid cleanup journal")
+ return info, names
+
+
+def cleanup(run, order="reverse", drain_timeout=30):
+ """Time only journaled names; never remove a jail using a reusable numeric ID."""
+ start = time.perf_counter_ns()
+ _, names = cleanup_names(run)
+ if order == "reverse":
+ names.reverse()
+ attempt = (run / "cleanup" /
+ (datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]))
+ attempt.mkdir(parents=True)
+ failures = []
+ summary = {"order": order, "journaled_names": len(names), "removed": 0,
+ "already_gone": 0, "not_live": 0, "failures": failures,
+ "command_total_ns": 0, "verification_total_ns": 0,
+ "directory": str(attempt.relative_to(run)),
+ "drain_requested": drain_timeout > 0}
+ snapshot_start = time.perf_counter_ns()
+ try:
+ wanted = set(names)
+ live = {name: jid for jid, name in listed_jails() if name in wanted}
+ except (subprocess.SubprocessError, ValueError, KeyError) as exc:
+ live = None
+ summary["initial_snapshot_error"] = str(exc)
+ summary["initial_snapshot_ns"] = time.perf_counter_ns() - snapshot_start
+ summary["initial_tracked_live"] = len(live) if live is not None else None
+ remaining = len(live) if live is not None else None
+ removal_start = time.perf_counter_ns()
+ with (attempt / "deletion.csv").open("w", buffering=1) as stream, \
+ (run / "cleanup.log").open("a", buffering=1) as log:
+ writer = csv.writer(stream)
+ writer.writerow(["attempt", "tracked_live_before", "name", "initial_jid", "status",
+ "elapsed_ns", "verification_ns"])
+ for index, name in enumerate(names, 1):
+ before = remaining
+ jid = live.get(name, "") if live is not None else ""
+ elapsed = verification = 0
+ status = "removed"
+ if live is not None and name not in live:
+ status = "not_live"
+ summary["not_live"] += 1
+ else:
+ command_start = time.perf_counter_ns()
+ try:
+ command(["jail", "-r", name], timeout=60)
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
+ elapsed = time.perf_counter_ns() - command_start
+ check_start = time.perf_counter_ns()
+ try:
+ # Confirm disappearance using a successful listing, rather
+ # than treating every jls failure as evidence of absence.
+ present = any(n == name for _, n in listed_jails())
+ except (subprocess.SubprocessError, ValueError, KeyError):
+ present = True
+ verification = time.perf_counter_ns() - check_start
+ if present:
+ status = "failed"
+ failures.append(name)
+ log.write(f"{name}: {exc}; {getattr(exc, 'stderr', '')}\n")
+ else:
+ status = "already_gone"
+ summary["already_gone"] += 1
+ else:
+ elapsed = time.perf_counter_ns() - command_start
+ summary["removed"] += 1
+ if status != "failed" and remaining is not None:
+ remaining -= 1
+ summary["command_total_ns"] += elapsed
+ summary["verification_total_ns"] += verification
+ writer.writerow([index, before if before is not None else "", name, jid,
+ status, elapsed, verification])
+ if index % 1000 == 0 or index == len(names):
+ print(f"Cleanup progress: {index}/{len(names)} journaled names processed", flush=True)
+ summary["removal_phase_ns"] = time.perf_counter_ns() - removal_start
+ drain_start = time.perf_counter_ns()
+ deadline = time.monotonic() + drain_timeout
+ polls = 0
+ remaining_rows = []
+ wanted = set(names)
+ try:
+ while True:
+ remaining_rows = [(jid, name) for jid, name in listed_jails(True) if name in wanted]
+ polls += 1
+ if not remaining_rows or time.monotonic() >= deadline:
+ break
+ time.sleep(min(0.25, max(0, deadline - time.monotonic())))
+ summary["drain_complete"] = not remaining_rows
+ except (subprocess.SubprocessError, ValueError, KeyError) as exc:
+ summary["drain_complete"] = False
+ summary["drain_error"] = str(exc)
+ summary["drain_observation_ns"] = time.perf_counter_ns() - drain_start
+ summary["drain_polls"] = polls
+ summary["remaining_prisons"] = [{"jid": jid, "name": name} for jid, name in remaining_rows]
+ summary["total_ns"] = time.perf_counter_ns() - start
+ (attempt / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")
+ print(f"Cleanup: {summary['removed']} removed, "
+ f"{summary['command_total_ns'] / 1e9:.3f}s in removal commands, "
+ f"{summary['drain_observation_ns'] / 1e9:.3f}s observing final release; "
+ f"results: {attempt}", flush=True)
+ return summary
+
+
+def record_cleanup(info, summary):
+ info["cleanup_failures"] = summary["failures"]
+ info["cleanup_last"] = summary
+ info.setdefault("cleanup_history", []).append(summary["directory"])
+ info["cleanup_pending"] = bool(summary["failures"] or not summary["drain_complete"])
+
+
+def cleanup_failed(summary):
+ return bool(summary["failures"] or summary.get("drain_error") or
+ (summary["drain_requested"] and not summary["drain_complete"]))
+
+
+def trace_cleanup(run, args):
+ info, names = cleanup_names(run)
+ wanted = set(names)
+ if not any(name in wanted for _, name in listed_jails(True)):
+ raise ValueError("no retained benchmark prisons; create a fresh run with KEEP_JAILS=yes")
+ report = args.trace_cleanup.resolve()
+ report.parent.mkdir(parents=True, exist_ok=True)
+ diagnostics = report.with_name(report.name + ".stderr")
+ if report.exists() or diagnostics.exists():
+ raise ValueError("trace report/stderr already exists; choose a new TRACE_LABEL")
+ source = Path(__file__).resolve().with_name("cleanup.d").read_text()
+ anchor = 'inline string bench_prefix = "jidbench_";'
+ if source.count(anchor) != 1:
+ raise ValueError("unexpected cleanup.d prefix definition")
+ source = source.replace(anchor, 'inline string bench_prefix = "' + info["prefix"] + '_";')
+ # DTrace -c splits on whitespace. A whitespace-free wrapper path permits
+ # arbitrary runner/run paths; the wrapper exec keeps the target PID stable.
+ with tempfile.TemporaryDirectory(prefix="jid-cleanup-trace-", dir="/tmp") as directory:
+ wrapper = Path(directory) / "cleanup.sh"
+ argv = [sys.executable, str(Path(__file__).resolve()), "--cleanup-run", str(run.resolve()),
+ "--cleanup-order", args.cleanup_order, "--drain-timeout", str(args.drain_timeout)]
+ wrapper.write_text("#!/bin/sh\nexec " + shlex.join(argv) + "\n")
+ wrapper.chmod(0o700)
+ script = Path(directory) / "cleanup.d"
+ script.write_text(source)
+ argv = [args.dtrace] + shlex.split(args.dtrace_flags) + [
+ "-Z", "-q", "-o", str(report), "-s", str(script), "-c", str(wrapper)]
+ with diagnostics.open("w") as stream:
+ result = subprocess.run(argv, stdout=subprocess.DEVNULL, stderr=stream)
+ print(f"Cleanup trace: {report}; diagnostics: {diagnostics}", flush=True)
+ if diagnostics.stat().st_size:
+ print("DTrace produced diagnostics; inspect stderr for drops/errors.", file=sys.stderr)
+ # DTrace may exit zero despite a failed -c command: inspect the child's
+ # persisted result, and ensure it actually performed a fresh cleanup.
+ updated = json.loads((run / "run.json").read_text())
+ last = updated.get("cleanup_last")
+ previous = info.get("cleanup_last", {}).get("directory")
+ if not last or last.get("directory") == previous:
+ print("No new cleanup result: tracing may have failed before cleanup started.", file=sys.stderr)
+ return 1
+ return result.returncode or int(cleanup_failed(last))
+
+
+def positive(value):
+ n = int(value)
+ if n < 1:
+ raise argparse.ArgumentTypeError("must be positive")
+ return n
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--max-jails", type=positive, default=10000)
+ parser.add_argument("--list-every", type=positive, default=100,
+ help="sample at 0, 1, every N jails, and the final count")
+ parser.add_argument("--repeats", type=positive, default=5)
+ parser.add_argument("--warmups", type=int, default=1)
+ parser.add_argument("--modes", default="jid,default",
+ help="comma-separated jls modes: jid,default,all")
+ parser.add_argument("--probe", type=Path, help="compiled jail_get_bench executable")
+ parser.add_argument("--pause-at", default="",
+ help="comma-separated counts to pause at for external tracing")
+ parser.add_argument("--output", type=Path)
+ parser.add_argument("--allow-existing", action="store_true")
+ parser.add_argument("--keep-jails", action="store_true",
+ help="retain jails after a successful run for a separate cleanup trace")
+ parser.add_argument("--cleanup-order", choices=("reverse", "forward"), default="reverse")
+ parser.add_argument("--drain-timeout", type=float, default=30,
+ help="seconds to observe final release; 0 makes one observation")
+ parser.add_argument("--trace-cleanup", type=Path, help="trace cleanup into this report")
+ parser.add_argument("--dtrace", default="dtrace")
+ parser.add_argument("--dtrace-flags", default="-x nolibs")
+ parser.add_argument("--cleanup-run", type=Path,
+ help="remove names journaled in a previous run directory")
+ args = parser.parse_args()
+ if platform.system() != "FreeBSD" or os.geteuid() != 0:
+ parser.error("run as root on a FreeBSD benchmark host")
+ for tool in ("jail", "jls", "sysctl"):
+ if shutil.which(tool) is None:
+ parser.error(f"missing command: {tool}")
+ if command(["sysctl", "-n", "security.jail.jailed"]).stdout.strip() != "0":
+ parser.error("run on the host, outside a jail")
+ if not (0 <= args.drain_timeout < float("inf")):
+ parser.error("--drain-timeout must be finite and nonnegative")
+ if args.trace_cleanup and not args.cleanup_run:
+ parser.error("--trace-cleanup requires --cleanup-run")
+ if args.cleanup_run:
+ if args.trace_cleanup:
+ return trace_cleanup(args.cleanup_run, args)
+ info = json.loads((args.cleanup_run / "run.json").read_text())
+ summary = cleanup(args.cleanup_run, args.cleanup_order, args.drain_timeout)
+ record_cleanup(info, summary)
+ (args.cleanup_run / "run.json").write_text(json.dumps(info, indent=2) + "\n")
+ return int(cleanup_failed(summary))
+ modes = args.modes.split(",")
+ try:
+ pauses = {positive(n) for n in args.pause_at.split(",") if n}
+ except (ValueError, argparse.ArgumentTypeError):
+ parser.error("invalid --pause-at counts")
+ if any(n > args.max_jails for n in pauses):
+ parser.error("--pause-at counts must not exceed --max-jails")
+ if args.warmups < 0 or any(m not in ("jid", "default", "all") for m in modes):
+ parser.error("invalid warmups or modes")
+ if args.probe:
+ args.probe = args.probe.resolve(strict=True)
+ if not os.access(args.probe, os.X_OK):
+ parser.error("probe must be executable")
+ existing_live = command(["jls", "-n", "jid"]).stdout.splitlines()
+ existing_all = command(["jls", "-d", "-n", "jid"]).stdout.splitlines()
+ if existing_all and not args.allow_existing:
+ parser.error("existing or dying jails found; use an empty host or --allow-existing")
+ run = args.output or Path("jailbench-" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+ + "-" + uuid.uuid4().hex[:6])
+ run.mkdir(parents=True, exist_ok=False)
+ prefix = "jidbench_" + uuid.uuid4().hex[:12]
+ info = {
+ "prefix": prefix, "started_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ "uname": list(platform.uname()), "python": sys.version,
+ "arguments": {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
+ "existing_live": existing_live, "existing_all": existing_all,
+ "creation_parameters": ["persist", "path=/", "host=inherit", "ip4=disable", "ip6=disable"],
+ "status": "running",
+ }
+ for key in ("kern.version", "hw.model", "hw.ncpu", "hw.physmem"):
+ info[key] = command(["sysctl", "-n", key]).stdout.strip()
+ (run / "run.json").write_text(json.dumps(info, indent=2) + "\n")
+ (run / "names.txt").touch()
+ print(f"Results: {run.resolve()}; jail prefix: {prefix}", flush=True)
+ signal.signal(signal.SIGTERM, stop)
+ signal.signal(signal.SIGINT, stop)
+ result = 0
+ try:
+ with (run / "creation.csv").open("w", buffering=1) as cf, \
+ (run / "queries.csv").open("w", buffering=1) as qf, \
+ (run / "names.txt").open("a", buffering=1) as journal:
+ creation = csv.writer(cf)
+ queries = csv.writer(qf)
+ creation.writerow(["created_jails", "visible_live_jails", "name", "jid", "elapsed_ns"])
+ queries.writerow(["created_jails", "visible_live_jails", "operation", "repeat", "syscalls", "elapsed_ns"])
+
+ def sample(n):
+ total = len(existing_live) + n
+ for mode in modes:
+ argv = ["jls"] + {"jid": ["-n", "jid"], "default": [], "all": ["-n", "all"]}[mode]
+ for r in range(-args.warmups, args.repeats):
+ start = time.perf_counter_ns()
+ command(argv, capture=False)
+ elapsed = time.perf_counter_ns() - start
+ if r >= 0:
+ queries.writerow([n, total, "jls_" + mode, r, "", elapsed])
+ if args.probe:
+ for mode in ("enum", "jid"):
+ output = command([str(args.probe), mode, str(args.repeats + args.warmups)]).stdout
+ for row in csv.DictReader(io.StringIO(output)):
+ if int(row["visible_jails"]) != total:
+ raise RuntimeError("jail population changed during probe")
+ r = int(row["repeat"]) - args.warmups
+ if r >= 0:
+ queries.writerow([n, total, "syscall_" + mode, r,
+ row["syscalls"], row["elapsed_ns"]])
+ print(f"Sampled {n} created jails ({total} live visible)", flush=True)
+
+ sample(0)
+ for n in range(1, args.max_jails + 1):
+ name = f"{prefix}_{n:05d}"
+ # Journal BEFORE spawning: cleanup covers interrupted creation.
+ journal.write(name + "\n")
+ start = time.perf_counter_ns()
+ out = command(["jail", "-i", "-c", "name=" + name] + info["creation_parameters"])
+ elapsed = time.perf_counter_ns() - start
+ jid = int(out.stdout.strip())
+ creation.writerow([n, len(existing_live) + n, name, jid, elapsed])
+ if n == 1 or n % args.list_every == 0 or n == args.max_jails or n in pauses:
+ sample(n)
+ if n in pauses:
+ input(f"Paused at {n} jails. Trace from another terminal; press Enter to continue: ")
+ info["status"] = "complete"
+ except (Exception, KeyboardInterrupt) as exc:
+ result = 1
+ info["status"] = "interrupted" if isinstance(exc, (Stopped, KeyboardInterrupt)) else "failed"
+ info["error"] = str(exc)
+ if isinstance(exc, subprocess.CalledProcessError):
+ info["stderr"] = exc.stderr
+ print(f"Benchmark stopped: {exc}", file=sys.stderr)
+ finally:
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+ if args.keep_jails and result == 0:
+ info["cleanup_pending"] = True
+ print(f"Retained jails. Remove with: make cleanup-run RUN={shlex.quote(str(run))}", flush=True)
+ else:
+ print("Removing benchmark jails; this can take time...", flush=True)
+ try:
+ summary = cleanup(run, args.cleanup_order, args.drain_timeout)
+ record_cleanup(info, summary)
+ if cleanup_failed(summary):
+ result = 1
+ print("Cleanup incomplete; see cleanup summary and cleanup.log", file=sys.stderr)
+ except Exception as exc:
+ result = 1
+ info["cleanup_error"] = str(exc)
+ info["cleanup_pending"] = True
+ print(f"Cleanup error: {exc}", file=sys.stderr)
+ info["finished_utc"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
+ (run / "run.json").write_text(json.dumps(info, indent=2) + "\n")
+ return result
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/test/jail/jls.d b/tools/test/jail/jls.d
new file mode 100644
--- /dev/null
+++ b/tools/test/jail/jls.d
@@ -0,0 +1,29 @@
+#!/usr/sbin/dtrace -s
+/* dtrace -q -s jls.d -c '/usr/sbin/jls -n jid' */
+#pragma D option quiet
+
+syscall::jail_get:entry
+/pid == $target/
+{
+ self->wall = timestamp;
+ self->cpu = vtimestamp;
+}
+
+syscall::jail_get:return
+/pid == $target && self->wall/
+{
+ @calls = count();
+ @wall_ns = sum(timestamp - self->wall);
+ @cpu_ns = sum(vtimestamp - self->cpu);
+ @wall_us = quantize((timestamp - self->wall) / 1000);
+ self->wall = 0;
+ self->cpu = 0;
+}
+
+END
+{
+ printa("jail_get calls: %@d\n", @calls);
+ printa("jail_get total elapsed ns: %@d\n", @wall_ns);
+ printa("jail_get total on-CPU ns: %@d\n", @cpu_ns);
+ printa("jail_get elapsed distribution (microseconds):\n%@d\n", @wall_us);
+}
diff --git a/tools/test/jail/report.py b/tools/test/jail/report.py
new file mode 100644
--- /dev/null
+++ b/tools/test/jail/report.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-2-Clause
+import argparse
+import csv
+import io
+import json
+import math
+import re
+import statistics
+import sys
+import tarfile
+import zipfile
+from collections import Counter, defaultdict
+from pathlib import Path, PurePosixPath
+
+
+def stats(values):
+ values = sorted(values)
+ n = len(values)
+ return (n, statistics.mean(values), statistics.median(values),
+ values[math.ceil(.95 * n) - 1], values[math.ceil(.99 * n) - 1],
+ values[0], values[-1])
+
+
+def timing(label, values):
+ if not values:
+ print(f" {label}: no samples")
+ return
+ n, mean, median, p95, p99, low, high = stats(values)
+ print(f" {label}: {n:,} samples; total {sum(values) / 1e9:.3f} s")
+ print(f" ms: mean {mean / 1e6:.3f}, median {median / 1e6:.3f}, "
+ f"p95 {p95 / 1e6:.3f}, p99 {p99 / 1e6:.3f}, "
+ f"min {low / 1e6:.3f}, max {high / 1e6:.3f}")
+
+
+def population_bins(rows, population):
+ unknown = sum(not r.get(population, '').strip() for r in rows)
+ if unknown:
+ print(f' {unknown} samples have unknown population; excluded from population bins')
+ rows = [r for r in rows if r.get(population, '').strip()]
+ if not rows:
+ return
+ width = max(1, math.ceil(max(int(r[population]) for r in rows) / 10))
+ groups = defaultdict(list)
+ for row in rows:
+ n = int(row[population])
+ groups[max(0, n - 1) // width].append(row)
+ print(" population samples mean ms median ms")
+ for _, group in sorted(groups.items()):
+ counts = [int(r[population]) for r in group]
+ values = [int(r['elapsed_ns']) / 1e6 for r in group]
+ label = f"{min(counts)}..{max(counts)}"
+ print(f" {label:16} {len(values):7d} {statistics.mean(values):10.3f} "
+ f"{statistics.median(values):10.3f}")
+
+
+def collection(path):
+ # Read selected regular files in place. Never extract archive paths.
+ wanted = lambda n: (PurePosixPath(n).name in
+ {'run.json', 'creation.csv', 'queries.csv', 'deletion.csv', 'summary.json'}
+ or PurePosixPath(n).name.endswith(('.txt', '.stderr')))
+ files = {}
+ if path.is_dir():
+ for p in sorted(path.rglob('*')):
+ if p.is_file() and not p.is_symlink() and wanted(p.name):
+ files[p.relative_to(path).as_posix()] = p.read_text()
+ elif zipfile.is_zipfile(path):
+ with zipfile.ZipFile(path) as archive:
+ for entry in archive.infolist():
+ if not entry.is_dir() and wanted(entry.filename):
+ key = PurePosixPath(entry.filename).as_posix()
+ if key in files:
+ raise ValueError(f"duplicate archive member: {entry.filename}")
+ files[key] = archive.read(entry).decode('utf-8', errors='replace')
+ else:
+ with tarfile.open(path) as archive:
+ for entry in archive:
+ if entry.isfile() and wanted(entry.name):
+ key = PurePosixPath(entry.name).as_posix()
+ if key in files:
+ raise ValueError(f"duplicate archive member: {entry.name}")
+ files[key] = archive.extractfile(entry).read().decode('utf-8', errors='replace')
+ return files
+
+
+def report_run(base, files, all_checkpoints):
+ def name(leaf):
+ return str(PurePosixPath(base) / leaf)
+
+ def rows(leaf):
+ return list(csv.DictReader(io.StringIO(files.get(name(leaf), ''))))
+
+ info = json.loads(files[name('run.json')]) if name('run.json') in files else {}
+ args = info.get('arguments', {})
+ print(f"\nRun: {base or '.'}")
+ print(f" status: {info.get('status', 'unknown')}; "
+ f"cleanup pending: {info.get('cleanup_pending', 'unknown')}")
+ kernel = info.get('kern.version')
+ if kernel:
+ print(' kernel: ' + kernel.strip().replace('\n', ' '))
+ print(' settings: ' + ', '.join(f'{k}={args[k]}' for k in
+ ('max_jails', 'list_every', 'repeats', 'warmups', 'cleanup_order', 'keep_jails') if k in args))
+ if info.get('error'):
+ print(' ERROR: ' + str(info['error']))
+ if info.get('stderr'):
+ print(' stderr: ' + str(info['stderr']).strip())
+
+ creation = rows('creation.csv')
+ if creation:
+ timing('creation commands', [int(r['elapsed_ns']) for r in creation])
+ population_bins(creation, 'created_jails')
+
+ queries = rows('queries.csv')
+ if queries:
+ groups = defaultdict(list)
+ for row in queries:
+ groups[(int(row['created_jails']), row['operation'])].append(int(row['elapsed_ns']))
+ checkpoints = sorted({n for n, _ in groups})
+ selected = set(checkpoints)
+ if not all_checkpoints and len(checkpoints) > 12:
+ selected = {min(checkpoints, key=lambda n: abs(n - i * checkpoints[-1] / 10))
+ for i in range(11)}
+ selected.add(checkpoints[0])
+ if 1 in checkpoints:
+ selected.add(1)
+ print(' Queries (each time is a complete listing or lookup batch):')
+ print(' created operation samples median ms min ms max ms')
+ for (n, op), values in sorted(groups.items()):
+ if n in selected:
+ print(f" {n:7d} {op:18} {len(values):7d} "
+ f"{statistics.median(values) / 1e6:11.3f} "
+ f"{min(values) / 1e6:11.3f} {max(values) / 1e6:11.3f}")
+ if len(selected) < len(checkpoints):
+ print(f' Showing {len(selected)}/{len(checkpoints)} checkpoints; use --all-checkpoints for all.')
+
+ prefix = name('cleanup') + '/'
+ attempts = sorted({str(PurePosixPath(n).parent) for n in files
+ if n.startswith(prefix) and n.endswith(('/deletion.csv', '/summary.json'))})
+ for attempt in attempts:
+ print(f' Cleanup attempt: {PurePosixPath(attempt).name}')
+ deletion = list(csv.DictReader(io.StringIO(files.get(attempt + '/deletion.csv', ''))))
+ print(' statuses: ' + ', '.join(f'{k}={v}' for k, v in sorted(Counter(r['status'] for r in deletion).items())))
+ # Skips and failed commands are not successful deletion latency samples.
+ removed = [r for r in deletion if r['status'] == 'removed']
+ timing('successful removal commands', [int(r['elapsed_ns']) for r in removed])
+ population_bins(removed, 'tracked_live_before')
+ summary_name = attempt + '/summary.json'
+ if summary_name not in files:
+ print(' WARNING: missing summary.json; cleanup may be incomplete')
+ continue
+ summary = json.loads(files[summary_name])
+ print(f" order: {summary.get('order', 'unknown')}; "
+ f"drain complete: {summary.get('drain_complete', 'unknown')}; "
+ f"failures: {len(summary.get('failures', []))}")
+ for key in ('initial_snapshot_ns', 'command_total_ns', 'verification_total_ns',
+ 'removal_phase_ns', 'drain_observation_ns', 'total_ns'):
+ if key in summary:
+ print(f" {key.removesuffix('_ns')}: {summary[key] / 1e9:.3f} s")
+ for key in ('failures', 'remaining_prisons', 'drain_error'):
+ if summary.get(key):
+ print(f' WARNING {key}: {summary[key]}')
+
+
+def report_traces(files):
+ for name, body in sorted(files.items()):
+ filename = PurePosixPath(name).name
+ if filename.endswith('.stderr'):
+ print(f"\nDiagnostics: {name}: {'NONEMPTY' if body.strip() else 'empty'}")
+ if body.strip():
+ print(body.strip())
+ continue
+ if not filename.endswith('.txt') or not filename.startswith(('jls-', 'enum-', 'lock-', 'cleanup-')):
+ continue
+ print(f'\nTrace: {name}')
+ entries, returns = {}, {}
+ for line in body.splitlines():
+ match = re.fullmatch(r'(\S+) (\S+) (entries|returns)=(\d+)', line)
+ if match:
+ exe, func, kind, count = match.groups()
+ (entries if kind == 'entries' else returns)[(exe, func)] = int(count)
+ match = re.fullmatch(r'(.+?) (elapsed_ns|cpu_ns|total_ns|calls|events|cpu_gt_elapsed_samples)=(\d+)', line)
+ if match:
+ label, metric, value = match.groups()
+ if metric.endswith('_ns'):
+ print(f" {label} {metric.removesuffix('_ns')}: {int(value) / 1e6:.3f} ms")
+ else:
+ print(f' {label} {metric}: {value}')
+ if metric == 'cpu_gt_elapsed_samples' and int(value):
+ print(' WARNING: invalid CPU samples; do not attribute CPU time')
+ for key in sorted(entries.keys() | returns.keys()):
+ a, b = entries.get(key, 0), returns.get(key, 0)
+ print(f' {key[0]} {key[1]}: entries={a}, returns={b}')
+ if a != b:
+ print(' WARNING: entry/return count mismatch')
+ for label, value in re.findall(r'^(.+?) elapsed_ns=(\d+)$', body, re.M):
+ calls = re.search(r'^' + re.escape(label) + r' calls=(\d+)$', body, re.M)
+ count = int(calls[1]) if calls else returns.get(tuple(label.split()), 0)
+ if count:
+ print(f' {label} mean elapsed: {int(value) / count / 1000:.3f} us/call')
+ if filename.startswith(('jls-', 'enum-')):
+ for line in body.splitlines():
+ if line.startswith('jail_get calls:'):
+ print(' ' + line)
+ wall = re.search(r'jail_get total elapsed ns:\s*(\d+)', body)
+ cpu = re.search(r'jail_get total on-CPU ns:\s*(\d+)', body)
+ for label, match in [('elapsed', wall), ('on-CPU', cpu)]:
+ if match:
+ print(f' jail_get {label}: {int(match[1]) / 1e6:.3f} ms')
+ if wall and cpu and int(cpu[1]) > int(wall[1]):
+ print(' WARNING: on-CPU time exceeds elapsed time')
+ if 'allprison waits:' in body and not re.search(r'\bevents=\d+|\b(?:sx-block|sx-spin)\b', body):
+ print(' No instrumented lock waits recorded; this does not measure lock hold time.')
+ if not body.strip():
+ print(' WARNING: empty report')
+
+
+parser = argparse.ArgumentParser(description='Summarize jailbench directories, tar archives, or zip archives; read-only.')
+parser.add_argument('inputs', nargs='+', type=Path)
+parser.add_argument('--all-checkpoints', action='store_true')
+args = parser.parse_args()
+failed = False
+for source in args.inputs:
+ try:
+ files = collection(source)
+ bases = sorted({str(PurePosixPath(n).parent) for n in files
+ if PurePosixPath(n).name in {'run.json', 'creation.csv', 'queries.csv'}})
+ traces = any(PurePosixPath(n).name.startswith(('cleanup-', 'jls-', 'enum-', 'lock-'))
+ and n.endswith('.txt') for n in files)
+ if not bases and not traces:
+ raise ValueError('no benchmark runs or trace reports found')
+ print(f'Collection: {source}')
+ for base in bases:
+ report_run(base, files, args.all_checkpoints)
+ report_traces(files)
+ except (OSError, ValueError, KeyError, TypeError, csv.Error, tarfile.TarError, zipfile.BadZipFile) as exc:
+ print(f'ERROR {source}: {exc}', file=sys.stderr)
+ failed = True
+print('\nNotes: command timings include process startup; query timings are batch totals.')
+print('Percentiles use nearest rank. Cleanup phases and inclusive trace timings overlap; do not sum them.')
+print('Keep instrumented and uninstrumented trials separate. Labels do not establish indexed/unindexed status.')
+sys.exit(1 if failed else 0)
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Nov 15, 1:26 AM (14 h, 47 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
41674864
Default Alt Text
D61328.1794705961.diff (71 KB)
Attached To
Mode
D61328: jail: index prison ids to optimize lookups
Attached
Detach File
Event Timeline