From a695cc3528c5cc711a2ba757e6a4532115168b7c Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 19 Jun 2026 09:31:57 -0700 Subject: [PATCH 1/4] [tests] F: Add fork+exec vfsd-I/O hang reproducer Adds a standalone test suite 'fork-exec-c' that reproduces a Nanvix bug: a process reached via fork()+execv() hangs on its first vfsd filesystem I/O operation. execv() replaces the calling image and assigns it a new main-thread id; vfsd serves file I/O through a kernel push/pull rendezvous keyed by the client (pid, tid). After fork()+execv(), the exec'd image's first vfsd request never completes, so any 'fork then exec a program that touches the filesystem' workload (e.g. a Python subprocess) hangs. The suite bundles a target program into the ramfs at /target and runs in standalone mode. The caller reads /target via vfsd successfully (PARENT-READ-OK), then forks and execs the target. The exec'd target prints TARGET-STARTED (proving fork+exec works) and then hangs in its own vfsd read of /target; TARGET-READ-OK is never reached. On a fixed system all markers print and the suite exits 0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .nanvix/z.py | 4 ++ Makefile | 2 +- README.md | 1 + src/Makefile | 2 +- src/fork-exec-c/Makefile | 27 +++++++++ src/fork-exec-c/main.c | 120 +++++++++++++++++++++++++++++++++++++++ src/fork-exec-c/target.c | 66 +++++++++++++++++++++ 7 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/fork-exec-c/Makefile create mode 100644 src/fork-exec-c/main.c create mode 100644 src/fork-exec-c/target.c diff --git a/.nanvix/z.py b/.nanvix/z.py index 5c47e76..ee307b6 100644 --- a/.nanvix/z.py +++ b/.nanvix/z.py @@ -72,6 +72,7 @@ "echo-c", "echo-cpp", "file-c", + "fork-exec-c", "hello-c", "hello-cpp", "memory-c", @@ -102,6 +103,7 @@ STANDALONE_ONLY_SUITES = [ "dlfcn-c", "dlfcn-pie-c", + "fork-exec-c", ] # Suites that require host networking (passed as -allow-host-networking to nanvixd). @@ -114,6 +116,8 @@ SUITE_RAMFS_LIBS: dict[str, list[tuple[str, str]]] = { "dlfcn-c": [("libmul.so", "lib/libmul.so")], "dlfcn-pie-c": [("libmul-pie.so", "lib/libmul-pie.so")], + # The execv() target read by the fork+exec reproducer, placed at "/target". + "fork-exec-c": [("fork-exec-target.elf", "target")], } # Docker image for cross-compilation. diff --git a/Makefile b/Makefile index bfcb117..e902d29 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ PROCESS_MODE ?= multi-process MEMORY_SIZE ?= 128mb # Test suites to build. -SUITES := c-bindings dlfcn-c dlfcn-pie-c echo-c echo-cpp file-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c +SUITES := c-bindings dlfcn-c dlfcn-pie-c echo-c echo-cpp file-c fork-exec-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c # ELF binaries produced by each suite. BINARIES := $(addsuffix .elf,$(SUITES)) diff --git a/README.md b/README.md index 8a9d845..6602d69 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ as well as simple echo, hello-world, and no-op benchmarks. | `echo-c` | Echo stdin to stdout (C) | | `echo-cpp` | Echo stdin to stdout (C++) | | `file-c` | File system operations (open, read, write, stat, link, mkdir, etc.) | +| `fork-exec-c` | fork()+execv() reproducer: an exec'd image hangs on its first vfsd file I/O | | `hello-c` | Hello world (C) | | `hello-cpp` | Hello world (C++) | | `memory-c` | malloc/free, aligned\_alloc, realloc, mmap/munmap, heap stress | diff --git a/src/Makefile b/src/Makefile index 101a386..b2c4645 100644 --- a/src/Makefile +++ b/src/Makefile @@ -72,7 +72,7 @@ export LIBRARIES_DIR ?= $(BINARIES_DIR) # Test Suites #=============================================================================== -SUITES := c-bindings dlfcn-c dlfcn-global-c dlfcn-needed-c dlfcn-pie-c echo-c echo-cpp file-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c +SUITES := c-bindings dlfcn-c dlfcn-global-c dlfcn-needed-c dlfcn-pie-c echo-c echo-cpp file-c fork-exec-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c #=============================================================================== # Build Rules diff --git a/src/fork-exec-c/Makefile b/src/fork-exec-c/Makefile new file mode 100644 index 0000000..1d01881 --- /dev/null +++ b/src/fork-exec-c/Makefile @@ -0,0 +1,27 @@ +# Copyright(c) The Maintainers of Nanvix. +# Licensed under the MIT License. + +PROGRAM_NAME := fork-exec-c +BINARY := $(PROGRAM_NAME).elf + +# The execv() target, bundled into the ramfs at "/target" by the harness +# (see .nanvix/z.py SUITE_RAMFS_LIBS). +TARGET_BINARY := fork-exec-target.elf + +all: $(BINARIES_DIR)/$(BINARY) $(BINARIES_DIR)/$(TARGET_BINARY) + +# Caller / init program (forks, then execs the target). +$(BINARIES_DIR)/$(BINARY): main.o + $(CC) $(LDFLAGS) main.o $(LIBRARIES) -o $@ + +# execv() target program (does the vfsd read that hangs after fork+exec). +$(BINARIES_DIR)/$(TARGET_BINARY): target.o + $(CC) $(LDFLAGS) target.o $(LIBRARIES) -o $@ + +clean: + rm -f main.o target.o + rm -f $(BINARIES_DIR)/$(BINARY) + rm -f $(BINARIES_DIR)/$(TARGET_BINARY) + +%.o: %.c + $(CC) $(CFLAGS) $< -c -o $@ diff --git a/src/fork-exec-c/main.c b/src/fork-exec-c/main.c new file mode 100644 index 0000000..797c6c5 --- /dev/null +++ b/src/fork-exec-c/main.c @@ -0,0 +1,120 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * Reproducer: a fork()+execv()'d process hangs on its first vfsd filesystem + * I/O operation. + * + * Background + * ---------- + * On Nanvix, execv() replaces the calling process's image and gives it a NEW + * main-thread identifier (the old thread is retired). Filesystem reads/writes + * are served by the vfsd daemon through a kernel push/pull rendezvous that is + * keyed by the client's (pid, tid). After fork()+execv(), the exec'd image's + * first vfsd request never completes -- the client blocks forever -- which + * makes any "fork then exec a program that touches the filesystem" workload + * (e.g. a Python subprocess, or `python script.py` spawned from a server) hang. + * + * What this program demonstrates (run in standalone mode) + * ------------------------------------------------------- + * CALLER-START - the init/caller process starts. + * PARENT-READ-OK - the caller reads /target via vfsd successfully. This is + * the SAME vfsd read the target performs, proving vfsd I/O + * works from an ordinary (non-fork+exec) process. + * TARGET-STARTED - the child's execv("/target") succeeded and the new image + * is running (so fork() and execv() themselves work). + * TARGET-READ-OK - the exec'd image completed its vfsd read. <-- expected + * on a correct system; NEVER printed on the buggy system. + * ok - the whole sequence succeeded. + * + * Correct behavior : all markers print and the process exits 0. + * Buggy behavior : prints up to TARGET-STARTED, then hangs forever in the + * target's vfsd read (the run times out). + * + * The target program is bundled into the ramfs at "/target" by the test + * harness (see .nanvix/z.py SUITE_RAMFS_LIBS). + */ + +#include +#include +#include + +#define TARGET_PATH "/target" + +static void emit(const char *s) +{ + const char *p = s; + while (*p) { + p++; + } + (void)write(STDOUT_FILENO, s, (size_t)(p - s)); +} + +/* Reads the first 4 bytes of /target via vfsd and checks the ELF magic. + Returns 0 on success, -1 on failure. */ +static int read_target_magic(void) +{ + int fd = open(TARGET_PATH, O_RDONLY); + if (fd < 0) { + return (-1); + } + char buf[4]; + ssize_t n = read(fd, buf, sizeof(buf)); + (void)close(fd); + if (n != (ssize_t)sizeof(buf) || buf[0] != 0x7f || buf[1] != 'E' || + buf[2] != 'L' || buf[3] != 'F') { + return (-1); + } + return (0); +} + +int main(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + + emit("CALLER-START\n"); + + /* Control: the same vfsd read, but from this ordinary process. This works, + establishing that vfsd I/O is fine outside the fork+exec path. */ + if (read_target_magic() != 0) { + emit("PARENT-READ-FAILED\n"); + return (1); + } + emit("PARENT-READ-OK\n"); + + /* Now the failing case: fork, then exec a program that does the same read. */ + emit("CALLER-FORKING\n"); + pid_t pid = fork(); + if (pid < 0) { + emit("FORK-FAILED\n"); + return (1); + } + + if (pid == 0) { + /* Child: replace image with the target. On success this never returns. */ + char *const child_argv[] = {(char *)"target", (char *)0}; + execv(TARGET_PATH, child_argv); + /* Only reached if execv() failed. */ + emit("EXECV-FAILED\n"); + _exit(127); + } + + /* Parent: wait for the exec'd child. On the buggy system this blocks + forever because the child is stuck in its vfsd read. */ + int status = 0; + pid_t w = waitpid(pid, &status, 0); + if (w != pid) { + emit("WAITPID-FAILED\n"); + return (1); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + emit("CHILD-NONZERO-EXIT\n"); + return (1); + } + + emit("ok\n"); + return (0); +} diff --git a/src/fork-exec-c/target.c b/src/fork-exec-c/target.c new file mode 100644 index 0000000..69f2de9 --- /dev/null +++ b/src/fork-exec-c/target.c @@ -0,0 +1,66 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * execv() target for the fork+exec vfsd-I/O reproducer. + * + * This program is loaded into the guest ramfs at "/target" and is execv()'d by + * the caller (fork-exec-c.elf). After exec, it does two things: + * + * 1. Writes a marker to stdout (kernel IKC path -- no vfsd involvement). This + * proves the exec'd image is actually running. + * 2. Performs a filesystem read via vfsd: it opens and reads its own ELF file + * at "/target" (guaranteed present in the ramfs). This is the operation + * that hangs when the process was reached via fork()+execv(). + * + * On a correct system it prints both markers and exits 0. On the buggy system + * it prints only the first marker and then hangs forever inside the vfsd read. + */ + +#include +#include + +#define SELF_PATH "/target" + +static void emit(const char *s) +{ + /* Write directly to stdout (fd 1). On Nanvix this is routed to the kernel + via IKC and does NOT go through vfsd, so it works in an exec'd image. */ + const char *p = s; + while (*p) { + p++; + } + (void)write(STDOUT_FILENO, s, (size_t)(p - s)); +} + +int main(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + + emit("TARGET-STARTED\n"); + + /* The vfsd filesystem read that hangs after fork()+execv(). */ + int fd = open(SELF_PATH, O_RDONLY); + if (fd < 0) { + emit("TARGET-OPEN-FAILED\n"); + return (1); + } + + char buf[4]; + ssize_t n = read(fd, buf, sizeof(buf)); + (void)close(fd); + + if (n != (ssize_t)sizeof(buf) || buf[0] != 0x7f || buf[1] != 'E' || + buf[2] != 'L' || buf[3] != 'F') { + emit("TARGET-READ-FAILED\n"); + return (1); + } + + /* Only reached if the vfsd read returned. */ + emit("TARGET-READ-OK\n"); + emit("ok\n"); + return (0); +} From de1cb6820c1245622ef2fe08857dcd62fde2d069 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 19 Jun 2026 09:45:02 -0700 Subject: [PATCH 2/4] [tests] F: Add fork pid/pthread and pipe-dup2 tests Add three POSIX behavior tests covering issues found while enabling fork()+exec() for CPython on Nanvix: - fork-pid-c: asserts a fork()'d child observes its own (new) pid from getpid() and the parent's pid from getppid(). Guards against the stale cached-pid regression that made capability-sensitive calls (mmap during execv) fail with EACCES in the child. - fork-pthread-c: asserts a fork()'d child can re-initialize an inherited pthread mutex/cond, mirroring the post-fork lock rebuild that runtimes such as CPython perform (a fresh GIL). - pipe-dup2-c: reproduces that dup2(pipe, STDOUT_FILENO) does not redirect a standard stream, because Nanvix partitions the fd space by subsystem (stdio via kernel IKC vs vfsd pipes). dup2 onto fd 1 is refused, so the classic pipe+dup2+exec stdio redirection pattern cannot work. fork-pid-c and fork-pthread-c currently pass on the pinned sysroot and act as regression guards; pipe-dup2-c fails, demonstrating the open limitation. Wired into src/Makefile, .nanvix/z.py (ALL_SUITES + STANDALONE_ONLY_SUITES) and documented in README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .nanvix/z.py | 11 ++- README.md | 3 + src/Makefile | 2 +- src/fork-pid-c/Makefile | 18 +++++ src/fork-pid-c/main.c | 141 +++++++++++++++++++++++++++++++++ src/fork-pthread-c/Makefile | 18 +++++ src/fork-pthread-c/main.c | 153 ++++++++++++++++++++++++++++++++++++ src/pipe-dup2-c/Makefile | 18 +++++ src/pipe-dup2-c/main.c | 132 +++++++++++++++++++++++++++++++ 9 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 src/fork-pid-c/Makefile create mode 100644 src/fork-pid-c/main.c create mode 100644 src/fork-pthread-c/Makefile create mode 100644 src/fork-pthread-c/main.c create mode 100644 src/pipe-dup2-c/Makefile create mode 100644 src/pipe-dup2-c/main.c diff --git a/.nanvix/z.py b/.nanvix/z.py index ee307b6..2bc3e2a 100644 --- a/.nanvix/z.py +++ b/.nanvix/z.py @@ -73,6 +73,8 @@ "echo-cpp", "file-c", "fork-exec-c", + "fork-pid-c", + "fork-pthread-c", "hello-c", "hello-cpp", "memory-c", @@ -80,6 +82,7 @@ "network-c", "noop-c", "noop-cpp", + "pipe-dup2-c", "thread-c", ] @@ -99,11 +102,17 @@ "thread-c", ] -# Suites that require ramfs-bundled shared libraries and only run in standalone mode. +# Suites that require ramfs-bundled shared libraries and/or only make sense in +# standalone mode. The fork/pipe reproducers below need fork()/exec() and a +# deterministic single-init environment, so they are run only in standalone +# mode (alongside fork-exec-c). STANDALONE_ONLY_SUITES = [ "dlfcn-c", "dlfcn-pie-c", "fork-exec-c", + "fork-pid-c", + "fork-pthread-c", + "pipe-dup2-c", ] # Suites that require host networking (passed as -allow-host-networking to nanvixd). diff --git a/README.md b/README.md index 6602d69..6fadaff 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ as well as simple echo, hello-world, and no-op benchmarks. | `echo-cpp` | Echo stdin to stdout (C++) | | `file-c` | File system operations (open, read, write, stat, link, mkdir, etc.) | | `fork-exec-c` | fork()+execv() reproducer: an exec'd image hangs on its first vfsd file I/O | +| `fork-pid-c` | fork() test: asserts the child sees its own (new) pid from getpid()/getppid() | +| `fork-pthread-c` | fork() test: asserts the child can re-init an inherited pthread mutex/cond | | `hello-c` | Hello world (C) | | `hello-cpp` | Hello world (C++) | | `memory-c` | malloc/free, aligned\_alloc, realloc, mmap/munmap, heap stress | @@ -23,6 +25,7 @@ as well as simple echo, hello-world, and no-op benchmarks. | `network-c` | IPv4 (INET) and Unix domain sockets | | `noop-c` | No-op program (C) | | `noop-cpp` | No-op program (C++) | +| `pipe-dup2-c` | dup2() reproducer: dup2(pipe, STDOUT) does not redirect the standard stream | | `thread-c` | Threading, mutexes, condition variables, rwlocks, TLS, TDA | ## Prerequisites diff --git a/src/Makefile b/src/Makefile index b2c4645..13eb0e3 100644 --- a/src/Makefile +++ b/src/Makefile @@ -72,7 +72,7 @@ export LIBRARIES_DIR ?= $(BINARIES_DIR) # Test Suites #=============================================================================== -SUITES := c-bindings dlfcn-c dlfcn-global-c dlfcn-needed-c dlfcn-pie-c echo-c echo-cpp file-c fork-exec-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c +SUITES := c-bindings dlfcn-c dlfcn-global-c dlfcn-needed-c dlfcn-pie-c echo-c echo-cpp file-c fork-exec-c fork-pid-c fork-pthread-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp pipe-dup2-c thread-c #=============================================================================== # Build Rules diff --git a/src/fork-pid-c/Makefile b/src/fork-pid-c/Makefile new file mode 100644 index 0000000..a862a1c --- /dev/null +++ b/src/fork-pid-c/Makefile @@ -0,0 +1,18 @@ +# Copyright(c) The Maintainers of Nanvix. +# Licensed under the MIT License. + +PROGRAM_NAME := fork-pid-c + +SOURCES := $(wildcard *.c) +OBJECTS := $(SOURCES:.c=.o) +BINARY := $(PROGRAM_NAME).elf + +all: $(OBJECTS) + $(CC) $(LDFLAGS) $(OBJECTS) $(LIBRARIES) -o $(BINARIES_DIR)/$(BINARY) + +clean: + rm -f $(OBJECTS) + rm -f $(BINARIES_DIR)/$(BINARY) + +%.o: %.c + $(CC) $(CFLAGS) $< -c -o $@ diff --git a/src/fork-pid-c/main.c b/src/fork-pid-c/main.c new file mode 100644 index 0000000..f813b3d --- /dev/null +++ b/src/fork-pid-c/main.c @@ -0,0 +1,141 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * Regression test: a fork()'d child must observe its OWN (new) process id. + * + * This guards against a stale cached pid in the child (see "Background"). It + * asserts the POSIX invariant and currently passes on the pinned Nanvix + * sysroot; it will fail if the cached-pid regression is reintroduced. + * + * Background + * ---------- + * On Nanvix, getpid() is memoized in a per-process cached variable + * (CACHED_PID) in libposix. A fork()'d child inherits that cache through + * copy-on-write, so unless the cache is invalidated in the child, getpid() + * keeps returning the PARENT's pid. POSIX requires that, in the child, + * getpid() return the child's own (new) pid -- i.e. the same value the + * parent received from fork() -- and that getppid() return the parent's pid. + * + * The stale cache is not merely cosmetic: capability-sensitive kernel calls + * (e.g. mmap/mprotect during a subsequent execv()) are keyed by the caller's + * pid, so a child acting under the parent's identity fails those calls with + * EACCES. This is what broke fork()+exec() of a fresh interpreter. + * + * What this program demonstrates (run in standalone mode) + * ------------------------------------------------------- + * PID-START - the test starts. + * PARENT-PID-OK - the parent obtained a non-zero pid. + * CHILD-PID-RECEIVED - the child reported its getpid()/getppid() back. + * PID-MATCHES-FORK - the child's getpid() equals fork()'s return value + * AND differs from the parent's pid. <-- on a correct + * system; on the buggy system the child reports the + * parent's pid, so this check FAILS. + * PPID-OK - the child's getppid() equals the parent's pid. + * ok - the whole sequence succeeded. + * + * Correct behavior : all markers print and the process exits 0. + * Buggy behavior : prints PID-FORK-MISMATCH and exits non-zero (the child + * reported the parent's pid). + * + * The pid values are passed from the child to the parent over a pipe(); pipes + * themselves work on Nanvix, so they are a reliable side channel here. + */ + +#include +#include + +static void emit(const char *s) +{ + const char *p = s; + while (*p) { + p++; + } + (void)write(STDOUT_FILENO, s, (size_t)(p - s)); +} + +/* The child's report sent back to the parent over the pipe. */ +struct child_report { + pid_t self; /* child's getpid() */ + pid_t ppid; /* child's getppid() */ +}; + +int main(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + + emit("PID-START\n"); + + pid_t parent_pid = getpid(); + if (parent_pid <= 0) { + emit("PARENT-PID-INVALID\n"); + return (1); + } + emit("PARENT-PID-OK\n"); + + int p[2]; + if (pipe(p) != 0) { + emit("PIPE-FAILED\n"); + return (1); + } + + pid_t pid = fork(); + if (pid < 0) { + emit("FORK-FAILED\n"); + return (1); + } + + if (pid == 0) { + /* Child: report the pids the kernel attributes to us. */ + (void)close(p[0]); + struct child_report rep; + rep.self = getpid(); + rep.ppid = getppid(); + ssize_t w = write(p[1], &rep, sizeof(rep)); + (void)close(p[1]); + _exit(w == (ssize_t)sizeof(rep) ? 0 : 1); + } + + /* Parent. */ + (void)close(p[1]); + struct child_report rep = {0, 0}; + ssize_t n = read(p[0], &rep, sizeof(rep)); + (void)close(p[0]); + + int status = 0; + if (waitpid(pid, &status, 0) != pid) { + emit("WAITPID-FAILED\n"); + return (1); + } + if (n != (ssize_t)sizeof(rep)) { + emit("CHILD-REPORT-FAILED\n"); + return (1); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + emit("CHILD-NONZERO-EXIT\n"); + return (1); + } + emit("CHILD-PID-RECEIVED\n"); + + /* The child's getpid() must equal what fork() returned to the parent, and + must differ from the parent's own pid. On the stale-cache bug the child + reports the parent's pid instead. */ + if (rep.self != pid || rep.self == parent_pid) { + emit("PID-FORK-MISMATCH\n"); + return (1); + } + emit("PID-MATCHES-FORK\n"); + + /* The child's getppid() must be the parent's pid. */ + if (rep.ppid != parent_pid) { + emit("PPID-MISMATCH\n"); + return (1); + } + emit("PPID-OK\n"); + + emit("ok\n"); + return (0); +} diff --git a/src/fork-pthread-c/Makefile b/src/fork-pthread-c/Makefile new file mode 100644 index 0000000..a7f329c --- /dev/null +++ b/src/fork-pthread-c/Makefile @@ -0,0 +1,18 @@ +# Copyright(c) The Maintainers of Nanvix. +# Licensed under the MIT License. + +PROGRAM_NAME := fork-pthread-c + +SOURCES := $(wildcard *.c) +OBJECTS := $(SOURCES:.c=.o) +BINARY := $(PROGRAM_NAME).elf + +all: $(OBJECTS) + $(CC) $(LDFLAGS) $(OBJECTS) $(LIBRARIES) -o $(BINARIES_DIR)/$(BINARY) + +clean: + rm -f $(OBJECTS) + rm -f $(BINARIES_DIR)/$(BINARY) + +%.o: %.c + $(CC) $(CFLAGS) $< -c -o $@ diff --git a/src/fork-pthread-c/main.c b/src/fork-pthread-c/main.c new file mode 100644 index 0000000..67615eb --- /dev/null +++ b/src/fork-pthread-c/main.c @@ -0,0 +1,153 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * Regression test: a fork()'d child must be able to (re)initialize an inherited + * pthread mutex / condition variable. + * + * This guards the post-fork lock-rebuild path used by runtimes such as CPython + * (see "Background"). It asserts the POSIX-compatible invariant and currently + * passes on the pinned Nanvix sysroot; it will fail if the re-init regression + * is reintroduced. + * + * Background + * ---------- + * On Nanvix, libposix keeps address-keyed registries (MUTEXES / CONDITIONS) + * that map a pthread_mutex_t* / pthread_cond_t* to kernel synchronization + * objects. A fork()'d child inherits those registry entries via copy-on-write, + * but the kernel intentionally drops the per-process sync objects across fork + * and recreates them lazily. Consequently the child must be able to + * (re)initialize a mutex/cond at an address that is still present in the + * inherited registry. + * + * If pthread_mutex_init() / pthread_cond_init() reject an already-registered + * address, the child cannot rebuild its locks. This is exactly what runtimes + * such as CPython do after fork(): they re-create their locks (e.g. a fresh + * GIL) in the child. The bug surfaced there as + * "create_gil: PyMUTEX_INIT failed" and aborted the forked interpreter. + * + * What this program demonstrates (run in standalone mode) + * ------------------------------------------------------- + * PTHREAD-START - the test starts. + * PARENT-INIT-OK - the parent initialized and exercised a mutex + cond. + * CHILD-REINIT-OK - the child re-initialized the SAME (inherited) mutex + * and cond addresses successfully. <-- on a correct + * system; on the buggy system the re-init returns an + * error and this FAILS. + * CHILD-USE-OK - the child could lock/unlock the re-initialized mutex. + * ok - the whole sequence succeeded. + * + * Correct behavior : all markers print and the process exits 0. + * Buggy behavior : the child's status byte is non-zero, the parent prints + * CHILD-REINIT-FAILED and exits non-zero. + * + * A one-byte status is passed from the child to the parent over a pipe(); + * pipes themselves work on Nanvix, so they are a reliable side channel here. + */ + +#include +#include +#include + +static void emit(const char *s) +{ + const char *p = s; + while (*p) { + p++; + } + (void)write(STDOUT_FILENO, s, (size_t)(p - s)); +} + +/* Inherited by the child via copy-on-write at the SAME addresses. */ +static pthread_mutex_t mutex; +static pthread_cond_t cond; + +int main(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + + emit("PTHREAD-START\n"); + + /* Parent: initialize and exercise the primitives. */ + if (pthread_mutex_init(&mutex, NULL) != 0) { + emit("PARENT-MUTEX-INIT-FAILED\n"); + return (1); + } + if (pthread_cond_init(&cond, NULL) != 0) { + emit("PARENT-COND-INIT-FAILED\n"); + return (1); + } + if (pthread_mutex_lock(&mutex) != 0 || pthread_mutex_unlock(&mutex) != 0) { + emit("PARENT-MUTEX-USE-FAILED\n"); + return (1); + } + emit("PARENT-INIT-OK\n"); + + int p[2]; + if (pipe(p) != 0) { + emit("PIPE-FAILED\n"); + return (1); + } + + pid_t pid = fork(); + if (pid < 0) { + emit("FORK-FAILED\n"); + return (1); + } + + if (pid == 0) { + /* Child: rebuild its synchronization primitives, mirroring what a + runtime does after fork(). The addresses are the inherited ones. */ + (void)close(p[0]); + unsigned char st = 0; + + if (pthread_mutex_init(&mutex, NULL) != 0) { + st = 1; /* re-init of inherited mutex rejected */ + } else if (pthread_cond_init(&cond, NULL) != 0) { + st = 2; /* re-init of inherited cond rejected */ + } else if (pthread_mutex_lock(&mutex) != 0 || + pthread_mutex_unlock(&mutex) != 0) { + st = 3; /* re-initialized mutex not usable */ + } + + (void)write(p[1], &st, 1); + (void)close(p[1]); + _exit(st == 0 ? 0 : 1); + } + + /* Parent: collect the child's result. */ + (void)close(p[1]); + unsigned char st = 0xff; + ssize_t n = read(p[0], &st, 1); + (void)close(p[0]); + + int status = 0; + if (waitpid(pid, &status, 0) != pid) { + emit("WAITPID-FAILED\n"); + return (1); + } + if (n != 1) { + emit("CHILD-REPORT-FAILED\n"); + return (1); + } + if (st != 0) { + if (st == 3) { + emit("CHILD-MUTEX-USE-FAILED\n"); + } else { + emit("CHILD-REINIT-FAILED\n"); + } + return (1); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + emit("CHILD-NONZERO-EXIT\n"); + return (1); + } + emit("CHILD-REINIT-OK\n"); + emit("CHILD-USE-OK\n"); + + emit("ok\n"); + return (0); +} diff --git a/src/pipe-dup2-c/Makefile b/src/pipe-dup2-c/Makefile new file mode 100644 index 0000000..1d87d44 --- /dev/null +++ b/src/pipe-dup2-c/Makefile @@ -0,0 +1,18 @@ +# Copyright(c) The Maintainers of Nanvix. +# Licensed under the MIT License. + +PROGRAM_NAME := pipe-dup2-c + +SOURCES := $(wildcard *.c) +OBJECTS := $(SOURCES:.c=.o) +BINARY := $(PROGRAM_NAME).elf + +all: $(OBJECTS) + $(CC) $(LDFLAGS) $(OBJECTS) $(LIBRARIES) -o $(BINARIES_DIR)/$(BINARY) + +clean: + rm -f $(OBJECTS) + rm -f $(BINARIES_DIR)/$(BINARY) + +%.o: %.c + $(CC) $(CFLAGS) $< -c -o $@ diff --git a/src/pipe-dup2-c/main.c b/src/pipe-dup2-c/main.c new file mode 100644 index 0000000..9823d00 --- /dev/null +++ b/src/pipe-dup2-c/main.c @@ -0,0 +1,132 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * Reproducer: dup2() of a pipe onto a standard stream (stdin/stdout/stderr) + * does not redirect that stream. + * + * Background + * ---------- + * On Nanvix the file-descriptor space is range-partitioned by subsystem + * rather than backed by a single unified descriptor table: + * + * - 0,1,2 : the standard streams, routed to the kernel via IKC. + * - 1024 and above : VFS / pipe descriptors (served by vfsd). + * - 2048 and above : socket descriptors (served by networkd). + * + * Because these ranges are owned by different subsystems, dup2(oldfd, newfd) + * cannot move a descriptor from one range onto a number owned by another. In + * particular, dup2(pipe_fd, STDOUT_FILENO) cannot make fd 1 refer to the pipe: + * fd 1 stays wired to the kernel console. This breaks the classic + * "pipe + dup2 onto 0/1/2 + exec" pattern that shells and subprocess libraries + * use to redirect a child's standard streams. + * + * What this program demonstrates (run in standalone mode) + * ------------------------------------------------------- + * DUP2-START - the test starts. + * PIPE-OK - a plain pipe() round-trips data (pipes themselves work). + * DUP2-RET-OK - dup2(pipe_write_end, STDOUT_FILENO) returned without + * error. + * DUP2-REDIRECT-OK - data written to STDOUT_FILENO afterwards was actually + * readable from the pipe. + * ok - the whole sequence succeeded. + * + * Correct behavior : all markers print and the process exits 0. + * Buggy behavior : the redirect does not happen, and the process exits + * non-zero. Two failure shapes are possible and both are + * detected here: + * - dup2() itself returns an error -> DUP2-RET-FAILED + * (this is what stock Nanvix does: fd 1 is a kernel-IKC + * descriptor in a different range than the pipe, so the + * duplication is refused); + * - dup2() "succeeds" but the write still goes to the + * console -> DUP2-REDIRECT-FAILED. + * + * The test never blocks: the read end is set non-blocking before the final + * read, so a missing redirect returns immediately instead of hanging. + * + * All diagnostic markers are written to stderr (fd 2), NOT stdout, because the + * test deliberately attempts to rewire stdout; routing markers through stderr + * keeps them out of the pipe under test regardless of whether dup2 took effect. + */ + +#include +#include +#include +#include + +#define MARKER "REDIRECTED-VIA-PIPE" + +static void emit(const char *s) +{ + const char *p = s; + while (*p) { + p++; + } + /* Write to stderr so these markers never land in the pipe under test. */ + (void)write(STDERR_FILENO, s, (size_t)(p - s)); +} + +int main(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + + emit("DUP2-START\n"); + + int p[2]; + if (pipe(p) != 0) { + emit("PIPE-FAILED\n"); + return (1); + } + + /* Sanity: a plain pipe round-trips data on Nanvix. */ + const char probe[] = "AB"; + if (write(p[1], probe, sizeof(probe) - 1) != (ssize_t)(sizeof(probe) - 1)) { + emit("PIPE-WRITE-FAILED\n"); + return (1); + } + char pbuf[2] = {0, 0}; + if (read(p[0], pbuf, sizeof(pbuf)) != (ssize_t)sizeof(pbuf) || + pbuf[0] != 'A' || pbuf[1] != 'B') { + emit("PIPE-READ-FAILED\n"); + return (1); + } + emit("PIPE-OK\n"); + + /* Attempt to redirect stdout onto the pipe's write end. */ + if (dup2(p[1], STDOUT_FILENO) < 0) { + emit("DUP2-RET-FAILED\n"); + return (1); + } + emit("DUP2-RET-OK\n"); + + /* If the redirect took effect, this write lands in the pipe; otherwise it + goes to the kernel console. */ + if (write(STDOUT_FILENO, MARKER, strlen(MARKER)) < 0) { + emit("STDOUT-WRITE-FAILED\n"); + return (1); + } + + /* Make the read end non-blocking so a failed redirect does not hang. */ + int flags = fcntl(p[0], F_GETFL, 0); + if (flags < 0 || fcntl(p[0], F_SETFL, flags | O_NONBLOCK) < 0) { + emit("FCNTL-FAILED\n"); + return (1); + } + + char buf[64]; + ssize_t n = read(p[0], buf, sizeof(buf)); + if (n != (ssize_t)strlen(MARKER) || memcmp(buf, MARKER, (size_t)n) != 0) { + /* The bytes written to STDOUT_FILENO did not reach the pipe: dup2 did + not redirect the standard stream. */ + emit("DUP2-REDIRECT-FAILED\n"); + return (1); + } + emit("DUP2-REDIRECT-OK\n"); + + emit("ok\n"); + return (0); +} From a4a300d81aed1cfb8e5dbbe6fe820d9f0920a121 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 19 Jun 2026 10:50:49 -0700 Subject: [PATCH 3/4] [tests] F: Add socket-shared-across-fork reproducer Add socket-fork-c, a reproducer for the socket-not-reference-counted across fork() issue (nanvix/nanvix#2609). A parent creates and binds an AF_INET socket, forks, and the child close()s its inherited copy of the socket. POSIX requires fork() to give the child an independent reference, so the child's close() must leave the parent's socket usable; the test probes this with getsockname() before and after. On Nanvix networkd keeps no per-process reference count (it maps a guest socket fd to a host socket by a fixed arithmetic offset and has no ForkClone handler), so the child's close() tears down the shared host socket and the parent's getsockname() then fails (SOCKET-KILLED-BY-CHILD-CLOSE). This contrasts with vfsd, which clones file descriptors on fork() and behaves per POSIX. Needs host networking, so the suite is added to SUITES_REQUIRING_NETWORKING (the harness passes -allow-host-networking) and to STANDALONE_ONLY_SUITES. Verified to fail on the pinned sysroot, demonstrating the open issue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .nanvix/z.py | 3 + README.md | 1 + src/Makefile | 2 +- src/socket-fork-c/Makefile | 18 +++++ src/socket-fork-c/main.c | 144 +++++++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/socket-fork-c/Makefile create mode 100644 src/socket-fork-c/main.c diff --git a/.nanvix/z.py b/.nanvix/z.py index 2bc3e2a..176c631 100644 --- a/.nanvix/z.py +++ b/.nanvix/z.py @@ -83,6 +83,7 @@ "noop-c", "noop-cpp", "pipe-dup2-c", + "socket-fork-c", "thread-c", ] @@ -113,11 +114,13 @@ "fork-pid-c", "fork-pthread-c", "pipe-dup2-c", + "socket-fork-c", ] # Suites that require host networking (passed as -allow-host-networking to nanvixd). SUITES_REQUIRING_NETWORKING: set[str] = { "network-c", + "socket-fork-c", } # Shared libraries that must be bundled into the ramfs for specific suites. diff --git a/README.md b/README.md index 6fadaff..d726781 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ as well as simple echo, hello-world, and no-op benchmarks. | `noop-c` | No-op program (C) | | `noop-cpp` | No-op program (C++) | | `pipe-dup2-c` | dup2() reproducer: dup2(pipe, STDOUT) does not redirect the standard stream | +| `socket-fork-c` | fork() reproducer: a child close()ing an inherited socket destroys the parent's socket | | `thread-c` | Threading, mutexes, condition variables, rwlocks, TLS, TDA | ## Prerequisites diff --git a/src/Makefile b/src/Makefile index 13eb0e3..61c7360 100644 --- a/src/Makefile +++ b/src/Makefile @@ -72,7 +72,7 @@ export LIBRARIES_DIR ?= $(BINARIES_DIR) # Test Suites #=============================================================================== -SUITES := c-bindings dlfcn-c dlfcn-global-c dlfcn-needed-c dlfcn-pie-c echo-c echo-cpp file-c fork-exec-c fork-pid-c fork-pthread-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp pipe-dup2-c thread-c +SUITES := c-bindings dlfcn-c dlfcn-global-c dlfcn-needed-c dlfcn-pie-c echo-c echo-cpp file-c fork-exec-c fork-pid-c fork-pthread-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp pipe-dup2-c socket-fork-c thread-c #=============================================================================== # Build Rules diff --git a/src/socket-fork-c/Makefile b/src/socket-fork-c/Makefile new file mode 100644 index 0000000..f17a329 --- /dev/null +++ b/src/socket-fork-c/Makefile @@ -0,0 +1,18 @@ +# Copyright(c) The Maintainers of Nanvix. +# Licensed under the MIT License. + +PROGRAM_NAME := socket-fork-c + +SOURCES := $(wildcard *.c) +OBJECTS := $(SOURCES:.c=.o) +BINARY := $(PROGRAM_NAME).elf + +all: $(OBJECTS) + $(CC) $(LDFLAGS) $(OBJECTS) $(LIBRARIES) -o $(BINARIES_DIR)/$(BINARY) + +clean: + rm -f $(OBJECTS) + rm -f $(BINARIES_DIR)/$(BINARY) + +%.o: %.c + $(CC) $(CFLAGS) $< -c -o $@ diff --git a/src/socket-fork-c/main.c b/src/socket-fork-c/main.c new file mode 100644 index 0000000..eb9a233 --- /dev/null +++ b/src/socket-fork-c/main.c @@ -0,0 +1,144 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * Reproducer: a socket is SHARED (not reference-counted) across fork(), so a + * child that close()s an inherited socket destroys the PARENT's socket too. + * + * Background + * ---------- + * On Nanvix, filesystem descriptors and socket descriptors are owned by + * different subsystems. The vfsd daemon duplicates a process's open files on + * fork() (it implements a ForkClone handler), so files behave per POSIX: each + * process holds an independent reference and closing one does not affect the + * other. networkd, which backs AF_INET sockets, does NOT do this -- it maps a + * guest socket descriptor to a host socket by a fixed arithmetic offset and + * keeps no per-process reference count. After fork(), parent and child name + * the very same underlying host socket, and the first close() in EITHER process + * tears it down for BOTH. + * + * POSIX requires the opposite: fork() duplicates the parent's open socket + * descriptors, each holding an independent reference to the same open socket + * description; the socket is only released once the LAST descriptor referring + * to it is closed. A child closing its copy must leave the parent's socket + * fully usable. This is what every mainstream OS (Linux, the BSDs, etc.) does, + * and it is what server code relies on when it forks per-connection workers. + * + * What this program demonstrates (run in standalone mode with host networking) + * --------------------------------------------------------------------------- + * SOCKFORK-START - the test starts. + * SOCKET-OK - the parent created and bound an AF_INET socket and + * getsockname() on it succeeds (the socket is alive). + * CHILD-CLOSED - the child closed its inherited copy of the socket and + * exited. + * SOCKET-SURVIVED - getsockname() on the PARENT's socket still succeeds + * after the child closed its copy. <-- on a correct + * system; on Nanvix the parent's socket has been torn + * down, so this FAILS. + * ok - the whole sequence succeeded. + * + * Correct behavior : all markers print and the process exits 0. + * Buggy behavior : prints SOCKET-KILLED-BY-CHILD-CLOSE and exits non-zero + * (the child's close() destroyed the parent's socket). + * + * Requires nanvixd to be started with -allow-host-networking (the harness adds + * this automatically for suites in SUITES_REQUIRING_NETWORKING). + */ + +#include +#include +#include +#include +#include + +static void emit(const char *s) +{ + const char *p = s; + while (*p) { + p++; + } + (void)write(STDOUT_FILENO, s, (size_t)(p - s)); +} + +/* Probe whether a socket descriptor is still alive via getsockname(). + Returns 0 if the descriptor names a live socket, -1 otherwise. */ +static int socket_alive(int fd) +{ + struct sockaddr addrbuf; + socklen_t addrlen = sizeof(addrbuf); + return (getsockname(fd, &addrbuf, &addrlen) == 0) ? 0 : -1; +} + +int main(int argc, char *argv[]) +{ + (void)argc; + (void)argv; + + emit("SOCKFORK-START\n"); + + /* Create and bind an AF_INET socket in the parent. */ + int s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (s < 0) { + emit("SOCKET-CREATE-FAILED\n"); + return (1); + } + + struct sockaddr_in sa = { + .sin_len = sizeof(sa), + .sin_family = AF_INET, + .sin_port = htons(2609), + .sin_addr = {.s_addr = htonl(0x7f000001)}, /* 127.0.0.1 */ + }; + if (bind(s, (const struct sockaddr *)&sa, sizeof(sa)) != 0) { + emit("SOCKET-BIND-FAILED\n"); + return (1); + } + + /* The socket is alive before fork(). */ + if (socket_alive(s) != 0) { + emit("SOCKET-PREFORK-DEAD\n"); + return (1); + } + emit("SOCKET-OK\n"); + + pid_t pid = fork(); + if (pid < 0) { + emit("FORK-FAILED\n"); + return (1); + } + + if (pid == 0) { + /* Child: close its inherited copy of the socket and exit. Under POSIX + this only drops the child's reference; the parent's stays open. */ + int r = close(s); + _exit(r == 0 ? 0 : 1); + } + + /* Parent: wait for the child to finish closing its copy. */ + int status = 0; + if (waitpid(pid, &status, 0) != pid) { + emit("WAITPID-FAILED\n"); + return (1); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + emit("CHILD-CLOSE-FAILED\n"); + return (1); + } + emit("CHILD-CLOSED\n"); + + /* The parent's socket must still be alive. If the socket was shared rather + than reference-counted across fork(), the child's close() has destroyed + it and this probe fails. */ + if (socket_alive(s) != 0) { + emit("SOCKET-KILLED-BY-CHILD-CLOSE\n"); + (void)close(s); + return (1); + } + emit("SOCKET-SURVIVED\n"); + + (void)close(s); + emit("ok\n"); + return (0); +} From 446e89a29dae3a4dc79d8963bc563d0caa173d70 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 19 Jun 2026 11:02:05 -0700 Subject: [PATCH 4/4] [tests] B: Keep open-bug reproducers out of gating run fork-exec-c, pipe-dup2-c and socket-fork-c reproduce currently-open Nanvix issues, so they fail or hang by design. The CI caller runs the full './z test' in standalone mode, so leaving these in STANDALONE_ONLY_SUITES would turn the pipeline red. Move them to a documented BUILD_ONLY_REPRODUCERS list: they are still compiled (ALL_SUITES + src/Makefile, under -Wall -Wextra -Werror) and smoke-tested (binary exists), but excluded from the gating integration run. fork-pid-c and fork-pthread-c assert invariants the pinned Nanvix (0.16.32) satisfies, so they remain in STANDALONE_ONLY_SUITES as regression guards. Document the distinction in the README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .nanvix/z.py | 28 ++++++++++++++++++++++------ README.md | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.nanvix/z.py b/.nanvix/z.py index 176c631..71879da 100644 --- a/.nanvix/z.py +++ b/.nanvix/z.py @@ -103,18 +103,34 @@ "thread-c", ] +# Reproducers for currently-OPEN Nanvix issues. These fail or hang BY DESIGN, so +# they are deliberately kept out of the gating integration run. They are still +# built (they appear in ALL_SUITES and src/Makefile) and smoke-tested (the build +# verifies they compile under -Wall -Wextra -Werror and that the binary exists). +# To observe a bug, run a reproducer manually by temporarily adding it to +# STANDALONE_ONLY_SUITES below (see README "Reproducers"). +# fork-exec-c : vfsd filesystem I/O hangs after fork()+execv() +# pipe-dup2-c : dup2() of a pipe onto a standard stream (0/1/2) is refused +# socket-fork-c : a socket is shared (not reference-counted) across fork() +BUILD_ONLY_REPRODUCERS = [ + "fork-exec-c", + "pipe-dup2-c", + "socket-fork-c", +] + +# Build-only reproducers must still be real, built suites. +assert set(BUILD_ONLY_REPRODUCERS).issubset(ALL_SUITES) + # Suites that require ramfs-bundled shared libraries and/or only make sense in -# standalone mode. The fork/pipe reproducers below need fork()/exec() and a -# deterministic single-init environment, so they are run only in standalone -# mode (alongside fork-exec-c). +# standalone mode (e.g. fork-based suites). fork-pid-c and fork-pthread-c assert +# POSIX fork() invariants (own pid in the child; re-init of inherited pthread +# primitives) that the pinned Nanvix version satisfies, so they run here as +# regression guards. STANDALONE_ONLY_SUITES = [ "dlfcn-c", "dlfcn-pie-c", - "fork-exec-c", "fork-pid-c", "fork-pthread-c", - "pipe-dup2-c", - "socket-fork-c", ] # Suites that require host networking (passed as -allow-host-networking to nanvixd). diff --git a/README.md b/README.md index d726781..e3db411 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,24 @@ as well as simple echo, hello-world, and no-op benchmarks. | `socket-fork-c` | fork() reproducer: a child close()ing an inherited socket destroys the parent's socket | | `thread-c` | Threading, mutexes, condition variables, rwlocks, TLS, TDA | +### Reproducers + +Some suites are *reproducers* tied to specific Nanvix issues rather than +general conformance tests: + +- `fork-pid-c` and `fork-pthread-c` assert POSIX `fork()` invariants (a child + sees its own pid; a child can re-initialize inherited pthread primitives). + The pinned Nanvix version satisfies these, so they run as **regression + guards** in the standalone integration run. +- `fork-exec-c`, `pipe-dup2-c`, and `socket-fork-c` reproduce **currently-open** + issues — they fail or hang by design (an exec'd image hanging on its first + vfsd I/O; `dup2()` of a pipe onto a standard stream being refused; a socket + being shared rather than reference-counted across `fork()`). To keep CI green + they are **built and smoke-tested but excluded from the gating integration + run** (`BUILD_ONLY_REPRODUCERS` in `.nanvix/z.py`). Each program's header + comment documents the expected (buggy) output. To observe a bug, temporarily + add the suite to `STANDALONE_ONLY_SUITES` and run `./z test`. + ## Prerequisites - [Docker](https://docs.docker.com/engine/install/)