Page MenuHomeFreeBSD

D61328.1794705961.diff
No OneTemporary

Authored By
Unknown
Size
71 KB
Referenced Files
None
Subscribers
None

D61328.1794705961.diff

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

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)

Event Timeline