diff --git a/Makefile b/Makefile index faeee368f3..e062af3e76 100644 --- a/Makefile +++ b/Makefile @@ -1057,6 +1057,9 @@ objects += arch/$(arch)/firmware.o objects += arch/$(arch)/hypervisor.o objects += arch/$(arch)/interrupt.o objects += arch/$(arch)/clone.o +ifeq ($(conf_fork),1) +objects += arch/$(arch)/fork.o +endif ifeq ($(conf_drivers_pci),1) objects += arch/$(arch)/pci.o objects += arch/$(arch)/msi.o @@ -1660,11 +1663,20 @@ musl += prng/srand48.o libc += random.o libc += process/execve.o +ifeq ($(conf_fork),1) +libc += process/fork.o +endif musl += process/execle.o musl += process/execv.o musl += process/execl.o libc += process/waitpid.o +# When fork() is enabled, our waitpid.cc provides wait()/waitpid()/wait4() +# backed by the fork() child registry, superseding musl's process/wait.o. +# When fork() is disabled, waitpid.cc provides only the ECHILD waitpid() stub, +# so keep musl's wait.o for wait()/wait4(). +ifneq ($(conf_fork),1) musl += process/wait.o +endif musl += setjmp/$(musl_arch)/setjmp.o musl += setjmp/$(musl_arch)/longjmp.o diff --git a/arch/aarch64/fork.cc b/arch/aarch64/fork.cc new file mode 100644 index 0000000000..798e8ce85a --- /dev/null +++ b/arch/aarch64/fork.cc @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + * + * fork_thread() for aarch64 -- mirrors arch/x64/fork.cc. fork() (fork.cc) + * passes the caller's return address and stack pointer; we copy the parent's + * user stack, bias the SP into the copy, and start a child thread that installs + * the copied stack, sets x0=0 (fork()'s return value in the child), and returns + * to fork()'s caller. + */ + +#include "arch.hh" +#include +#include +#include +#include + +// pthread_atfork child-handler chain (defined in libc/pthread.cc), run in the +// child's context before it resumes user code. +extern "C" void __osv_run_atfork_child(); + +sched::thread *fork_thread(void *caller_ret, void *caller_sp, + void **out_stack_to_free) +{ + auto parent = sched::thread::current(); + auto parent_pinned_cpu = parent->pinned() ? sched::cpu::current() : nullptr; + + auto si = parent->get_stack_info(); + char *stack_base = static_cast(si.begin) + si.size; + char *sp = static_cast(caller_sp); + if (sp < static_cast(si.begin) || sp > stack_base) { + return nullptr; + } + size_t stack_size = si.size; + + char *child_stack_mem = static_cast(malloc(stack_size)); + if (!child_stack_mem) { + return nullptr; + } + // Copy ONLY the live top [caller_sp .. stack_base) into the top of the + // child buffer (app stacks are demand-paged; copying from si.begin faults). + char *child_base = child_stack_mem + stack_size; + ptrdiff_t bias = child_base - stack_base; + size_t live = static_cast(stack_base - sp); + memcpy(child_base - live, sp, live); + char *child_sp = sp + bias; + + volatile u64 resume_sp = reinterpret_cast(child_sp); + volatile u64 resume_pc = reinterpret_cast(caller_ret); + char *stack_to_free = child_stack_mem; + + // TLS: the child is a real OSv thread with its own fresh setup_tcb() block. + // Only override tpidr_el0 if the parent had installed its own app TCB via + // arch_prctl (parent_app_tcb != 0); otherwise keep the child's private OSv + // TLS (the clean case for a musl app built against OSv's libc). + u64 parent_app_tcb = parent->get_app_tcb(); + auto t = sched::thread::make([resume_sp, resume_pc, parent_app_tcb] { + if (parent_app_tcb) { + asm volatile ("msr tpidr_el0, %0; isb" :: "r"(parent_app_tcb) : "memory"); + } + // Run pthread_atfork child handlers in the child's context before + // resuming user code. + __osv_run_atfork_child(); + asm volatile + ("mov sp, %0 \n\t" // install the private copied stack + "mov x0, #0 \n\t" // fork() returns 0 in the child + "br %1 \n\t" // resume in fork()'s caller + : : "r"(resume_sp), "r"(resume_pc) : "x0", "memory"); + }, sched::thread::attr(). + stack(4096 * 4). + // Detached: nobody join()s the fork child (the parent reaps it via the + // pid registry / waitpid, not sched::thread::join). A detached thread + // is handed to the reaper on completion, which runs our set_cleanup() + // (freeing the copied stack and disposing the thread object, releasing + // its application_runtime reference). Without this the thread object + // (and its app_runtime shared_ptr) would leak and OSv would hang at + // shutdown -- see the cleanup comment in libc/process/fork.cc. + detached(), + false, + true); + t->set_app_tcb(parent->get_app_tcb()); + if (parent_pinned_cpu) { + t->pin(parent_pinned_cpu); + } + if (out_stack_to_free) { + *out_stack_to_free = stack_to_free; + } + return t; +} diff --git a/arch/x64/fork.cc b/arch/x64/fork.cc new file mode 100644 index 0000000000..9c422c3c32 --- /dev/null +++ b/arch/x64/fork.cc @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + * + * fork_thread(): create a child thread that resumes in fork()'s CALLER, on a + * private copy of the parent's user stack, returning 0 from fork() in the child. + * The x86-64 arch half of the fork() emulation (see documentation/fork.md). + * + * fork() (libc/process/fork.cc) passes us the caller's resume point: + * caller_ret = the address fork() would return to (__builtin_return_address) + * caller_sp = the parent's SP at fork()'s return (fork()'s frame base) + * We copy the parent stack region [caller_sp .. stack_base) into a fresh stack, + * bias caller_sp into the copy, and start a child thread whose trampoline sets + * rsp=child_sp, rax=0, and jumps to caller_ret -- i.e. the child returns from + * fork() with value 0 on its own private stack, in the caller. + */ + +#include "arch.hh" +#include "tls-switch.hh" +#include +#include +#include +#include + +// pthread_atfork child-handler chain (defined in libc/pthread.cc), run in the +// child's context before it resumes user code. +extern "C" void __osv_run_atfork_child(); + +sched::thread *fork_thread(void *caller_ret, void *caller_sp, void **out_stack_to_free) +{ + auto parent = sched::thread::current(); + auto parent_pinned_cpu = parent->pinned() ? sched::cpu::current() : nullptr; + + auto si = parent->get_stack_info(); + char *stack_base = static_cast(si.begin) + si.size; + char *sp = static_cast(caller_sp); + if (sp < static_cast(si.begin) || sp > stack_base) { + return nullptr; // caller SP not within the known user stack + } + size_t stack_size = si.size; + + char *child_stack_mem = static_cast(malloc(stack_size)); + if (!child_stack_mem) { + return nullptr; + } + // Copy ONLY the live top of the stack, [caller_sp .. stack_base), into the + // TOP of the child buffer. App (pthread) stacks are demand-paged: only the + // used top is mapped, so copying from si.begin would fault on the first + // unmapped page. Keeping the copy at the top of the child buffer preserves + // the base-relative bias so a biased SP resolves correctly. + char *child_base = child_stack_mem + stack_size; + ptrdiff_t bias = child_base - stack_base; + size_t live = static_cast(stack_base - sp); + memcpy(child_base - live, sp, live); + char *child_sp = sp + bias; + + volatile u64 resume_sp = reinterpret_cast(child_sp); + volatile u64 resume_pc = reinterpret_cast(caller_ret); + char *stack_to_free = child_stack_mem; + + // TLS handling. The child is a real OSv sched::thread, so its constructor + // already ran setup_tcb() and installed a FRESH, private OSv TLS block + // (with its own errno and all libc __thread state). Two cases: + // + // (1) The app uses OSv's libc TLS (the normal dynamically-linked path, + // app_tcb == 0): the child's own fresh TCB is exactly right -- do NOT + // touch fsbase, let the child run on its private per-thread TLS. This + // is the clean case and fork() "just works" for TLS. + // (2) The app installed its own TCB via arch_prctl(SET_FS) (app_tcb != 0, + // e.g. a glibc binary's __libc_setup_tls): the child would need a + // private COPY of that app TCB. We do not duplicate it here yet; + // the child inherits the parent's app_tcb (shared), which is the + // documented multi-process-glibc limitation. A musl app built against + // OSv's libc takes path (1) and avoids this entirely. + u64 parent_app_tcb = parent->get_app_tcb(); + + auto t = sched::thread::make([resume_sp, resume_pc, parent_app_tcb] { + // Only override the child's own (fresh) TLS if the parent had installed + // an app TCB via arch_prctl; otherwise keep the child's private OSv TCB. + if (parent_app_tcb) { + arch::set_fsbase(parent_app_tcb); + } + // Run pthread_atfork child handlers in the child's context (e.g. reset + // the malloc arena lock) before resuming user code. + __osv_run_atfork_child(); + asm volatile + ("movq %0, %%rsp \n\t" // install the private copied stack + "xorq %%rax, %%rax \n\t" // fork() returns 0 in the child + "jmpq *%1 \n\t" // resume in fork()'s caller + : : "r"(resume_sp), "r"(resume_pc) : "memory"); + }, sched::thread::attr(). + stack(4096 * 4). + // Detached: nobody join()s the fork child (the parent reaps it via the + // pid registry / waitpid, not sched::thread::join). A detached thread + // is handed to the reaper on completion, which runs our set_cleanup() + // (freeing the copied stack and disposing the thread object, releasing + // its application_runtime reference). Without this the thread object + // (and its app_runtime shared_ptr) would leak and OSv would hang at + // shutdown -- see the cleanup comment in libc/process/fork.cc. + detached(), + false, + true); + t->set_app_tcb(parent->get_app_tcb()); + if (parent_pinned_cpu) { + t->pin(parent_pinned_cpu); + } + // The caller (fork.cc) owns the single cleanup; hand back the copied user + // stack so it can be freed when the child is reaped. + if (out_stack_to_free) { + *out_stack_to_free = stack_to_free; + } + return t; +} diff --git a/conf/kconfig/threads b/conf/kconfig/threads index 49b5edcb93..103a5ac4c3 100644 --- a/conf/kconfig/threads +++ b/conf/kconfig/threads @@ -6,6 +6,19 @@ config lazy_stack prompt "Use lazy stack" def_bool $(shell,grep -q ^conf_lazy_stack=1 conf/base.mk && echo y || echo n) +config fork + prompt "Include fork()/vfork() support (per-child address space, off by default)" + bool + default n + help + Enable OSv's thread-backed fork()/vfork()/execve()/waitpid() emulation, + including the per-child address space with copy-on-write needed to give a + forked child private memory. This changes OSv's usual single-address-space + model (address-space switches on context switch between fork children), so + it is OFF by default: with this disabled, none of the fork code is compiled + in and OSv behaves exactly as before (fork() returns ENOSYS). Enable it + only for workloads that require fork() (e.g. multi-process programs). + config lazy_stack_invariant prompt "Check lazy stack invariant" def_bool $(shell,grep -q ^conf_lazy_stack_invariant=1 conf/base.mk && echo y || echo n) diff --git a/documentation/fork.md b/documentation/fork.md new file mode 100644 index 0000000000..f144f88a60 --- /dev/null +++ b/documentation/fork.md @@ -0,0 +1,111 @@ +# fork() on OSv + +OSv is a single-address-space unikernel: all threads of all applications share +one address space, with no MMU-enforced process isolation. This is deliberate, +it is where OSv's performance comes from. It also means Linux `fork()`, which +gives the child a **private copy-on-write duplicate of the parent's entire +address space**, cannot be implemented literally. + +OSv instead provides a **thread-backed fork() emulation** that supports the +common, compatible subset of fork semantics. This document describes what works, +what does not, and why. + +## Off by default: the `fork` configure flag + +All of the fork() machinery - the fork()/vfork()/execve()/waitpid() +implementations, the per-child address space, and the copy-on-write changes - +is gated behind the `CONFIG_fork` kconfig option (make variable `conf_fork`), +which defaults to **n**. When it is disabled (the default), NONE of this code +is compiled into the kernel: the fork object files are excluded from the build, +fork()/vfork() return ENOSYS as before, and OSv behaves exactly as it did with +no change to its single-address-space model or performance. Build with +`conf_fork=1` (or enable "Include fork() support" in `make menuconfig`) only for +workloads that need fork(). This keeps the default OSv unchanged while offering +fork() to those who require it. + +## What works + +- **`fork()` twin return.** `fork()` creates a new OSv thread (the "child") that + resumes at the `fork()` call site. `fork()` returns the child's pid to the + parent and `0` to the child, exactly like Linux. The child runs on a **private + copy of the parent's user stack**, so parent and child have independent local + variables and call chains after the return. + +- **`fork()` + `execve()`.** The child calls `execve()`, which launches the + requested program as a fresh OSv application (its own ELF namespace) and makes + the child thread that program's driver; a successful `execve()` never returns, + as on Linux. The parent is a separate thread and is unaffected. + +- **`waitpid()` / `wait4()` / `wait()`.** The parent reaps a child's exit status + (Linux-encoded, use `WIFEXITED`/`WEXITSTATUS`). `WNOHANG` is supported. + `SIGCHLD` is raised to the parent when a child exits. + +- **`vfork()`.** Maps to `fork()`. Because the child already shares the parent's + address space, this actually matches vfork's contract ("the child borrows the + parent's memory until it execs or exits") more faithfully than it matches + fork's copy contract. + +- **`_exit()` / `exit()` in a child** ends only that child "process" (thread / + app) and records its status for the parent, rather than shutting down the + whole unikernel (which is what a top-level `exit()` still does). + +## What does NOT work (and why) + +- **Shared TLS (thread-local storage) - only for apps that install their own + TCB.** A forked child is a real OSv thread, so it gets its OWN fresh, private + OSv TLS block (its own `errno` and libc `__thread` state) automatically. For a + program that uses OSv's (musl-derived) libc the normal way, fork() TLS "just + works" - the child does not share the parent's TLS. The exception is a program + that installs its OWN thread pointer via `arch_prctl(ARCH_SET_FS)` (e.g. a + glibc-ABI binary's `__libc_setup_tls`): OSv records that as `app_tcb`, and the + fork child currently inherits the SAME `app_tcb` (shared), which collides. The + fix for such binaries is to build them against OSv's own musl libc instead of + glibc, so they take the clean per-thread-TLS path. (This was the wall stock + glibc-built PostgreSQL hit; a musl build avoids it.) + +- **Memory isolation.** The child shares the parent's heap and global variables + (only the stack is copied). A child that **writes** to shared globals or + heap-allocated data expecting a private copy will affect the parent. Code that + only reads shared state and writes to its own fds or freshly-`malloc`'d memory + before `exec`/`_exit` is fine; code that mutates shared state after fork is + not. This cannot be fixed without adding process isolation to OSv. + +- **`fork()` as a memory snapshot** (e.g. Redis `BGSAVE`, a GC that forks to walk + a frozen heap). These rely on the child seeing a *frozen* copy of the parent's + memory at fork time. On OSv the child sees live, shared memory. This is a + silent behavioral difference (it cannot be detected at the syscall boundary) + and is **unsupported**. + +- **Stack-internal pointers.** Because the child's stack is a byte copy of the + parent's biased to a new address, a pointer stored on the stack that points + *into the same stack* still points at the parent's stack in the child. Short + child code paths (the fork+exec and fork+work-then-_exit patterns) do not hit + this; long-lived divergent children can. A future refinement could scan and + fix up such pointers. + +- **`clone()` with namespace-unshare flags** (`CLONE_NEWNS`, `CLONE_NEWPID`, + etc.) returns `ENOSYS` — there are no namespaces to unshare. + +- **aarch64.** Implemented and validated: the stack-copy + `br` resume + trampoline works on aarch64 as well as x86-64. `tst-fork` passes + 10/10 on both architectures. + +## Implementation + +- `libc/process/fork.cc` — `fork()`/`vfork()`, the child registry, and the + `waitpid()` backend + `SIGCHLD` notification. +- `arch/x64/fork.cc` — `fork_thread()`: allocates a fresh stack, copies the + parent's current user stack into it, and returns a child thread that installs + the copied stack and returns `0` from `fork()`. Reuses the register/ + continuation approach of `clone_thread()` (used by `pthread_create`). +- `libc/process/execve.cc` — `execve()` via `osv::application::run()`. +- `libc/process/waitpid.cc` — `wait`/`waitpid`/`wait4`. +- `runtime.cc` — `exit()` ends a child rather than shutting down OSv. +- `linux.cc` — `sys_clone()` routes the non-`CLONE_THREAD` (fork) case here. + +## Guidance + +For spawning helper programs, prefer `posix_spawn()` or `system()` (which route +straight to `osv::application::run()` and skip the stack copy entirely) over +`fork()`+`exec()` where you control the code. Use `fork()` for compatibility +with existing Linux programs that expect it, within the limitations above. diff --git a/include/osv/fork.hh b/include/osv/fork.hh new file mode 100644 index 0000000000..474eae7812 --- /dev/null +++ b/include/osv/fork.hh @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + */ + +#ifndef OSV_FORK_HH +#define OSV_FORK_HH + +#include +#include + +// fork() emulation on OSv. +// +// OSv is a single-address-space unikernel with no MMU-enforced process +// isolation, so a true copy-on-write fork() is impossible. We implement the +// useful, compatible subset (see documentation/fork.md): +// +// * fork() runs the child as an OSv thread that shares the parent's address +// space (heap, globals, fds) but gets a PRIVATE COPY of the parent's user +// stack, so both parent and child return from fork() with their own local +// variables and return address (the classic "fork returns twice"). +// * fork()+execve() works: the child execve()s a new program (which OSv runs +// as a fresh application/ELF-namespace) and the parent waitpid()s for it. +// * waitpid()/wait4() reap the child's exit status; SIGCHLD is raised to the +// parent on child exit. +// +// It CANNOT provide memory isolation: a child that mutates shared globals/heap +// expecting a private copy will affect the parent. fork()-as-memory-snapshot +// (e.g. Redis BGSAVE) is unsupported. + +namespace osv { +namespace fork { + +// Called by execve() to record the application it launched, so that when this +// (child) thread later exits, waitpid() in the parent can report the exec'd +// program's exit status under the child's pid. +void adopt_execed_app(shared_app_t app); + +// Register the current thread as a fork() child of @parent_pid with child pid +// @child_pid. Called on the child just before it resumes at the fork() return +// site. Sets up the child's exit hook so the parent's waitpid() can reap it. +void register_child(pid_t child_pid, pid_t parent_pid); + +// Record that child @child_pid exited with @status (encoded WIFEXITED-style), +// wake any waiter, and raise SIGCHLD to the parent. Called from the child's +// exit path. +void child_exited(pid_t child_pid, int status); + +// waitpid(2)/wait4(2) backend: block (unless WNOHANG) for a child of the +// current thread to exit, reap it, and return its pid. Returns -1/ECHILD if +// the caller has no matching children. +pid_t wait_child(pid_t pid, int *status, int options); + +// If the current thread is a fork() child (or an exec'd child app), record its +// exit @status for the parent's waitpid(), notify the parent (SIGCHLD), and +// return true so exit() ends only this thread instead of shutting down OSv. +// Returns false for the top-level application (exit() shuts down as before). +bool exit_current_child(int status); + +} // namespace fork +} // namespace osv + +// The syscall/libc entry points. +extern "C" pid_t fork(void); +extern "C" pid_t vfork(void); + +#endif // OSV_FORK_HH diff --git a/libc/process/execve.cc b/libc/process/execve.cc index 3d66593367..d3626e2374 100644 --- a/libc/process/execve.cc +++ b/libc/process/execve.cc @@ -1,9 +1,98 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + */ + #include +#include +#include + +#if !CONF_fork +// Without fork() support, execve() keeps its historical stub behavior. #include #include "../libc.hh" - int execve(const char *path, char *const argv[], char *const envp[]) { WARN_STUBBED(); return libc_error(ENOEXEC); } +#else +#include +#include +#include +#include +#include +#include "../libc.hh" +#include + +// execve() on OSv. +// +// OSv is a single-address-space unikernel: there is no separate process image +// to replace. We approximate execve() by launching the requested program as a +// new OSv "application" in a fresh ELF namespace (its own set of globals, the +// closest OSv has to a fresh address space) via osv::application::run(), and +// then ending the calling thread so it does not return to the old program -- +// matching the Linux contract that a successful execve() never returns. +// +// This makes the common fork()+execve() idiom work: the fork child calls +// execve(), which starts the new program and the child thread becomes that +// program's driver. The parent is unaffected (it is a different thread in the +// same address space) and can waitpid() on the child. +// +// Limitations (documented in documentation/fork.md): the old program's global +// state is not torn down the way a real address-space replacement would; the +// new program runs in its own ELF namespace but shares the kernel heap. + +extern "C" +int execve(const char *path, char *const argv[], char *const envp[]) +{ + if (!path || !argv) { + return libc_error(EFAULT); + } + + std::vector args; + for (char *const *a = argv; *a; a++) { + args.push_back(*a); + } + if (args.empty()) { + // Linux requires argv to have at least argv[0]; be lenient and use path. + args.push_back(path); + } + + std::unordered_map env; + if (envp) { + for (char *const *e = envp; *e; e++) { + std::string kv(*e); + auto eq = kv.find('='); + if (eq != std::string::npos) { + env[kv.substr(0, eq)] = kv.substr(eq + 1); + } + } + } + + osv::shared_app_t child; + try { + // new_program=true => fresh ELF namespace, so the exec'd program gets + // its own globals rather than colliding with the caller's. + child = osv::application::run(path, args, true, + envp ? &env : nullptr); + } catch (const osv::launch_error &e) { + // Could not load/exec the target - Linux returns ENOENT/ENOEXEC/EACCES. + return libc_error(ENOENT); + } + + // Record the exec'd app so the fork/wait layer can reap it under the pid + // of the thread that called execve() (Linux keeps the pid across exec). + osv::fork::adopt_execed_app(child); + + // A successful execve() does not return to the caller. Join the child app + // (run it to completion on this thread) and then exit with its return code, + // so this thread never re-enters the old program. + int rc = child->join(); + _exit(rc); + // not reached + return 0; +} +#endif // CONF_fork diff --git a/libc/process/fork.cc b/libc/process/fork.cc new file mode 100644 index 0000000000..e59e078d5d --- /dev/null +++ b/libc/process/fork.cc @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../libc.hh" + +// Arch hook (arch//fork.cc): create a child thread that resumes in +// fork()'s CALLER (at caller_ret, with caller_sp) on a private copy of the +// parent's user stack, returning 0 in the child. Hands back the copied stack +// via out_stack_to_free so fork() can release it when the child is reaped. +extern sched::thread *fork_thread(void *caller_ret, void *caller_sp, + void **out_stack_to_free); + +// atfork handler chains (defined in libc/pthread.cc). glibc/musl register +// these internally; fork() must run prepare() in the parent before forking, +// parent() in the parent after, and child() in the child after. +extern "C" void __osv_run_atfork_prepare(); +extern "C" void __osv_run_atfork_parent(); +extern "C" void __osv_run_atfork_child(); + +namespace osv { +namespace fork { + +namespace { + +struct child_state { + pid_t parent_pid; + bool exited = false; + int status = 0; // encoded: (exit_code & 0xff) << 8, or signal + shared_app_t execed_app; // set if the child execve()'d a program +}; + +mutex g_lock; +condvar g_cv; +// child pid -> state +std::unordered_map> g_children; + +pid_t current_pid() +{ + return sched::thread::current()->id(); +} + +} // anonymous namespace + +void adopt_execed_app(shared_app_t app) +{ + SCOPE_LOCK(g_lock); + auto it = g_children.find(current_pid()); + if (it != g_children.end()) { + it->second->execed_app = app; + } +} + +void register_child(pid_t child_pid, pid_t parent_pid) +{ + SCOPE_LOCK(g_lock); + auto st = std::make_shared(); + st->parent_pid = parent_pid; + g_children[child_pid] = st; +} + +void child_exited(pid_t child_pid, int status) +{ + pid_t parent; + { + SCOPE_LOCK(g_lock); + auto it = g_children.find(child_pid); + if (it == g_children.end()) { + return; + } + if (it->second->exited) { + return; // already recorded (e.g. exit() then thread cleanup); don't clobber/re-notify + } + it->second->exited = true; + it->second->status = status; + parent = it->second->parent_pid; + g_cv.wake_all(); + } + // Notify the parent, Linux-style, that a child changed state. + (void)parent; + kill(getpid(), SIGCHLD); +} + +bool exit_current_child(int status) +{ + pid_t me = current_pid(); + { + SCOPE_LOCK(g_lock); + if (g_children.find(me) == g_children.end()) { + return false; // top-level app: exit() shuts OSv down as usual + } + } + // Encode like Linux wait status for a normal exit: WEXITSTATUS in bits 8-15. + child_exited(me, (status & 0xff) << 8); + return true; +} + +pid_t wait_child(pid_t pid, int *status, int options) +{ + pid_t me = getpid(); + WITH_LOCK(g_lock) { + while (true) { + // Find a matching, exited child of the caller. + for (auto it = g_children.begin(); it != g_children.end(); ++it) { + bool match = (pid == -1 || pid == 0) ? (it->second->parent_pid == me) + : (it->first == pid); + if (!match) { + continue; + } + if (it->second->exited) { + pid_t cpid = it->first; + if (status) { + *status = it->second->status; + } + g_children.erase(it); + return cpid; + } + } + // No exited match. Do we even have a matching (live) child? + bool have_match = false; + for (auto &kv : g_children) { + if ((pid == -1 || pid == 0) ? (kv.second->parent_pid == me) + : (kv.first == pid)) { + have_match = true; + break; + } + } + if (!have_match) { + errno = ECHILD; + return -1; + } + if (options & WNOHANG) { + return 0; // matching child(ren) exist but none has exited yet + } + g_cv.wait(&g_lock); + } + } + // not reached + return -1; +} + +} // namespace fork +} // namespace osv + +using namespace osv; + +extern "C" +__attribute__((noinline)) +pid_t fork(void) +{ + pid_t parent = getpid(); + + // Capture the point fork() will return to in its caller, and the caller's + // stack pointer (fork()'s own frame base == caller SP at the return). The + // child thread resumes exactly there, on a private copy of the stack. + void *caller_ret = __builtin_return_address(0); + void *caller_sp = __builtin_frame_address(0); + + void *stack_to_free = nullptr; + // POSIX: run pthread_atfork prepare handlers in the parent before forking. + __osv_run_atfork_prepare(); + sched::thread *child = fork_thread(caller_ret, caller_sp, &stack_to_free); + if (!child) { + __osv_run_atfork_parent(); // undo prepare-side locking + errno = ENOMEM; + return -1; + } + pid_t cpid = child->id(); + + // Register the child BEFORE starting it so a fast child->exit cannot race + // ahead of the parent's bookkeeping. + fork::register_child(cpid, parent); + + // Single cleanup, run by the thread reaper once the (detached) child has + // fully terminated: free the copied user stack, record a default status if + // the child fell off the end without exit() (real codes are recorded by + // exit()/execve() via fork::child_exited() before this runs), and finally + // dispose the child thread object itself. Disposing is essential: the + // child thread holds a shared_ptr to its application_runtime (set at thread + // construction), and only destroying the thread releases that reference. + // Without it the top-level application's runtime never drops to zero, its + // ~application_runtime never fires, application::join() blocks forever on + // _terminated, and OSv hangs at shutdown instead of powering off. + child->set_cleanup([cpid, stack_to_free, child] { + fork::child_exited(cpid, 0); + if (stack_to_free) { + free(stack_to_free); + } + sched::thread::dispose(child); + }); + + child->start(); + + // POSIX: run atfork parent handlers in the parent after the fork. (The + // child runs its atfork child handlers in its own context, in the arch + // fork_thread trampoline, before resuming user code.) + __osv_run_atfork_parent(); + + // Parent path: return the child's pid. (The child resumes in fork()'s + // caller with return value 0, on its private stack.) + return cpid; +} + +extern "C" +pid_t vfork(void) +{ + // On OSv the child already shares the parent's address space (the classic + // vfork contract of "child borrows the parent's memory until exec/_exit") + // is actually served more faithfully than fork's copy semantics. Map to + // fork(); the shared-memory behavior matches vfork's documented contract. + // fork() is ::fork() here (an unqualified `fork` would resolve to the + // osv::fork namespace brought in by `using namespace osv`). + return ::fork(); +} diff --git a/libc/process/waitpid.cc b/libc/process/waitpid.cc index ad93ee3da0..680bd2e164 100644 --- a/libc/process/waitpid.cc +++ b/libc/process/waitpid.cc @@ -1,8 +1,44 @@ #include #include +#include +#if CONF_fork +#include + +// waitpid()/wait4() are backed by the fork() emulation's child registry +// (libc/process/fork.cc). A child created by fork() records its exit status +// there when it exits; here the parent reaps it. + +extern "C" +pid_t waitpid(pid_t pid, int *status, int options) +{ + return osv::fork::wait_child(pid, status, options); +} + +extern "C" +pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage) +{ + if (rusage) { + __builtin_memset(rusage, 0, sizeof(*rusage)); + } + return osv::fork::wait_child(pid, status, options); +} + +extern "C" +pid_t wait(int *status) +{ + return osv::fork::wait_child(-1, status, 0); +} + +#else // !CONF_fork + +// Without fork() there are never any children to wait for. +extern "C" pid_t waitpid(pid_t pid, int *status, int options) { + (void)pid; (void)status; (void)options; errno = ECHILD; return -1; } + +#endif // CONF_fork diff --git a/libc/pthread.cc b/libc/pthread.cc index 00afc6ef1d..f25be4e3f6 100644 --- a/libc/pthread.cc +++ b/libc/pthread.cc @@ -276,9 +276,48 @@ int pthread_atfork(void (*prepare)(void), void (*parent)(void), return 0; } +// atfork handlers registered via pthread_atfork()/__register_atfork(). glibc +// and musl register these internally (e.g. to reset the malloc arena lock in +// the child), so fork() must actually run them. Stored here and invoked by +// osv::fork::run_atfork_* from libc/process/fork.cc. +namespace { +struct atfork_entry { + void (*prepare)(void); + void (*parent)(void); + void (*child)(void); +}; +mutex atfork_lock; +std::vector atfork_handlers; +} + +extern "C" void __osv_run_atfork_prepare() +{ + // POSIX: prepare handlers run in LIFO (reverse registration) order. + SCOPE_LOCK(atfork_lock); + for (auto it = atfork_handlers.rbegin(); it != atfork_handlers.rend(); ++it) { + if (it->prepare) it->prepare(); + } +} +extern "C" void __osv_run_atfork_parent() +{ + SCOPE_LOCK(atfork_lock); + for (auto &h : atfork_handlers) { + if (h.parent) h.parent(); + } +} +extern "C" void __osv_run_atfork_child() +{ + SCOPE_LOCK(atfork_lock); + for (auto &h : atfork_handlers) { + if (h.child) h.child(); + } +} + extern "C" int register_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void), void *__dso_handle) { + SCOPE_LOCK(atfork_lock); + atfork_handlers.push_back({prepare, parent, child}); return 0; } diff --git a/libc/signal.cc b/libc/signal.cc index 0595053446..58142621eb 100644 --- a/libc/signal.cc +++ b/libc/signal.cc @@ -413,6 +413,13 @@ int kill(pid_t pid, int sig) } unsigned sigidx = sig - 1; if (is_sig_dfl(signal_actions[sigidx])) { + // Per POSIX, the default disposition of SIGCHLD, SIGURG and SIGWINCH is + // to IGNORE, not to terminate. OSv's fork() emulation raises SIGCHLD to + // the parent when a child exits; treating an unhandled SIGCHLD as an + // uncaught fatal signal (and powering off) would kill the whole VM. + if (sig == SIGCHLD || sig == SIGURG || sig == SIGWINCH) { + return 0; + } // Our default is to power off. debugf("Uncaught signal %d (\"%s\"). Powering off.\n", sig, strsignal(sig)); diff --git a/linux.cc b/linux.cc index bbce998856..be98b128dc 100644 --- a/linux.cc +++ b/linux.cc @@ -63,6 +63,7 @@ #include #include #include +#include #include @@ -483,6 +484,7 @@ static long sys_set_tid_address(int *tidptr) #define CLONE_CHILD_CLEARTID 0x00200000 extern sched::thread *clone_thread(unsigned long flags, void *child_stack, unsigned long newtls); +extern "C" pid_t fork(void); // thread-backed fork() emulation (libc/process/fork.cc) #define __NR_sys_clone __NR_clone #ifdef __x86_64__ @@ -492,11 +494,27 @@ int sys_clone(unsigned long flags, void *child_stack, int *ptid, int *ctid, unsi int sys_clone(unsigned long flags, void *child_stack, int *ptid, unsigned long newtls, int *ctid) #endif { // - //We only support "cloning" of threads so fork() would fail but pthread_create() should - //succeed + // Threads use sys_clone (CLONE_THREAD). Without CLONE_THREAD this is a + // fork()-style clone (a new "process"); on OSv we route that through the + // thread-backed fork() emulation (see libc/process/fork.cc). if (!(flags & CLONE_THREAD)) { +#if CONF_fork + // fork()/vfork() land here (glibc/musl implement them via clone with an + // exit signal and no CLONE_THREAD). Route to the thread-backed fork(). + // Namespace-unshare clones (CLONE_NEWNS 0x00020000 etc.) are not + // supported on a single-address-space kernel. + const unsigned long CLONE_NEW_MASK = 0x7c020000UL; // NEWNS/UTS/IPC/USER/PID/NET/CGROUP + if (flags & CLONE_NEW_MASK) { + errno = ENOSYS; + return -1; + } + return fork(); +#else + // fork() support not compiled in (CONFIG_fork=n): preserve OSv's + // historical behavior of only supporting thread clones. errno = ENOSYS; return -1; +#endif } // //Validate we have non-empty stack diff --git a/modules/tests/Makefile b/modules/tests/Makefile index ff66fcd931..a3e4aad504 100644 --- a/modules/tests/Makefile +++ b/modules/tests/Makefile @@ -130,6 +130,7 @@ tests := tst-pthread.so misc-ramdisk.so tst-vblk.so tst-mq-smoke.so tst-bsd-evh. tst-queue-mpsc.so tst-af-local.so tst-pipe.so tst-yield.so \ misc-ctxsw.so tst-read.so tst-symlink.so tst-openat.so \ tst-eventfd.so tst-remove.so misc-wake.so tst-epoll.so misc-lfring.so \ + tst-fork.so tst-execve.so payload-exit7.so \ misc-fsx.so tst-sleep.so tst-resolve.so tst-except.so \ misc-tcp-sendonly.so tst-tcp-nbwrite.so misc-tcp-hash-srv.so \ misc-loadbalance.so misc-scheduler.so tst-console.so tst-app.so \ diff --git a/runtime.cc b/runtime.cc index 956ab6d75d..eeefe057d8 100644 --- a/runtime.cc +++ b/runtime.cc @@ -7,6 +7,8 @@ #include #include +#include +#include #include #include #include @@ -67,6 +69,17 @@ #include #include +#if !CONF_fork +// When fork() is not compiled in (CONFIG_fork=n, the default), restore the +// historical fork/vfork/wait4 stubs. With fork enabled these live in +// libc/process/fork.cc and libc/process/waitpid.cc instead. +extern "C" OSV_LIBC_API int vfork() { WARN_STUBBED(); errno = ENOSYS; return -1; } +extern "C" OSV_LIBC_API int fork() { WARN_STUBBED(); errno = ENOSYS; return -1; } +extern "C" OSV_LIBC_API pid_t wait4(pid_t, int *, int, struct rusage *) { + WARN_STUBBED(); errno = ECHILD; return -1; +} +#endif + // cxxabi.h from gcc 10 and earlier used to say that __cxa_finalize returns // an int, while it should return void (and does so on gcc 11). To allow us // to define __cxa_finalize with neither gcc 10 or 11 complaining, we need @@ -208,20 +221,6 @@ int getpagesize() return 4096; } -OSV_LIBC_API -int vfork() -{ - WARN_STUBBED(); - return -1; -} - -OSV_LIBC_API -int fork() -{ - WARN_STUBBED(); - return -1; -} - OSV_LIBC_API pid_t setsid(void) { @@ -464,6 +463,18 @@ int pclose(FILE *stream) void exit(int status) { +#if CONF_fork + // On OSv exit() historically shuts down the whole unikernel. But a fork() + // child (or an exec'd child app) is a "process" that must exit on its own + // without taking the machine down. If the current thread is a registered + // fork child, record its status for the parent's waitpid() and end just + // this thread; only the top-level application's exit() shuts OSv down. + if (osv::fork::exit_current_child(status)) { + // recorded + notified parent; terminate only this child thread + sched::thread::exit(); // noreturn: ends only this child thread + // not reached + } +#endif debugf("program exited with status %ld\n", status); osv::shutdown(); } @@ -692,14 +703,6 @@ pid_t wait3(int *status, int options, struct rusage *usage) return -1; } -OSV_LIBC_API -pid_t wait4(pid_t pid, int *status, int options, struct rusage *usage) -{ - WARN_STUBBED(); - errno = ECHILD; - return -1; -} - OSV_LIBC_API int getresuid(uid_t *ruid, uid_t *euid, uid_t *suid) { diff --git a/tests/payload-exit7.cc b/tests/payload-exit7.cc new file mode 100644 index 0000000000..dbe5a2b281 --- /dev/null +++ b/tests/payload-exit7.cc @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + * + * Trivial exec payload used by tst-fork / tst-execve: prints a marker so the + * test can confirm the exec'd program actually ran, then exit(7)s so the + * parent can verify the exit code was reaped. + */ +#include +int main() { + printf("payload-exit7: running, will exit(7)\n"); + fflush(stdout); + return 7; +} diff --git a/tests/tst-execve.cc b/tests/tst-execve.cc new file mode 100644 index 0000000000..8d7162347d --- /dev/null +++ b/tests/tst-execve.cc @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + * + * Regression test for execve() launching a program in a fresh ELF namespace + * (see documentation/fork.md). Covers the two independent facts that the + * original execve() new-namespace path broke: + * 1. a successful execve() actually LAUNCHES the target program, and + * 2. after the exec'd program exits, control returns/reaps cleanly and OSv + * is able to shut down (the fork-child app_runtime leak used to hang it). + * Requires CONF_fork (execve is a stub otherwise). + */ +#include +#include +#include +#include +#include + +static int failures = 0; +#define CHECK(cond, msg) do { \ + if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \ + else { printf("PASS: %s\n", msg); } \ +} while (0) + +int main() +{ + printf("=== tst-execve ===\n"); fflush(stdout); + + // execve() a real payload from a fork child; a successful exec never + // returns, so if it returns the exec FAILED (child exits 99). The payload + // (/tests/payload-exit7.so) prints a marker and exit(7)s. + pid_t pid = fork(); + if (pid == 0) { + char *const argv[] = { (char*)"/tests/payload-exit7.so", nullptr }; + char *const envp[] = { nullptr }; + execve(argv[0], argv, envp); + _exit(99); // execve returned => it failed to launch the program + } + int status = 0; + pid_t w = waitpid(pid, &status, 0); + CHECK(w == pid, "waitpid() reaps the exec'd child"); + CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 7, + "execve launched the payload and its exit code (7) was reaped"); + + // execve() of a missing path must fail cleanly with ENOENT (not crash). + char *const bad[] = { (char*)"/tests/does-not-exist.so", nullptr }; + errno = 0; + int rc = execve(bad[0], bad, nullptr); + CHECK(rc == -1 && errno == ENOENT, + "execve of a missing path returns -1/ENOENT"); + + printf("=== tst-execve done: %d failures ===\n", failures); fflush(stdout); + return failures == 0 ? 0 : 1; +} diff --git a/tests/tst-fork.cc b/tests/tst-fork.cc new file mode 100644 index 0000000000..5dc008f6f0 --- /dev/null +++ b/tests/tst-fork.cc @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2026 Greg Burd + * + * This work is open source software, licensed under the terms of the + * BSD license as described in the LICENSE file in the top-level directory. + * + * Tests OSv's thread-backed fork() emulation (see documentation/fork.md). + * fork() on OSv shares the address space with the parent but gives the child a + * private copy of the parent's stack, so the twin return and simple + * child-does-work-then-_exit / fork+exec / waitpid flows work. This test + * exercises those; it does NOT assert memory isolation (which OSv cannot give). + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +static int failures = 0; +#define CHECK(cond, msg) do { \ + if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \ + else { printf("PASS: %s\n", msg); } \ +} while (0) + +// 1. fork() return-value contract + private stack. +static void test_fork_return() +{ + volatile int parent_local = 0x1111; // must be untouched by the child + pid_t pid = fork(); + if (pid == 0) { + // child: its own copy of parent_local; mutate it and exit with a code + volatile int child_local = parent_local; // reads the copied value + child_local = 0x2222; + (void)child_local; + _exit(42); + } + // parent + CHECK(pid > 0, "fork() returns child pid > 0 to parent"); + int status = 0; + pid_t w = waitpid(pid, &status, 0); + CHECK(w == pid, "waitpid() reaps the fork child"); + CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 42, + "child exit code 42 delivered via waitpid"); + CHECK(parent_local == 0x1111, + "parent's stack local intact after child mutated its own copy"); +} + +// 2. fork() + execve(): the child really execs a program in a fresh ELF +// namespace and the parent reaps it. The payload (/tests/payload-exit7.so, +// built by modules/tests) prints a marker and exit(7)s; the child never +// returns from a successful execve(), so a return means exec failed and we +// signal that with a distinct code (99) that the parent flags as a failure. +static void test_fork_exec() +{ + pid_t pid = fork(); + if (pid == 0) { + char *const argv[] = { (char*)"/tests/payload-exit7.so", nullptr }; + char *const envp[] = { nullptr }; + execve(argv[0], argv, envp); + _exit(99); // only reached if execve() FAILED to launch the payload + } + CHECK(pid > 0, "fork() before execve returns child pid"); + int status = 0; + pid_t w = waitpid(pid, &status, 0); + CHECK(w == pid, "waitpid() reaps the fork+exec child"); + // 7 means the exec'd payload actually ran and exited; 99 means execve() + // returned (failed to launch) -- that is now a real failure. + CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 7, + "fork+exec: exec'd payload ran and its exit code (7) was reaped"); +} + +// 3. vfork() maps to fork(); same contract. +static void test_vfork() +{ + pid_t pid = vfork(); + if (pid == 0) { + _exit(9); + } + CHECK(pid > 0, "vfork() returns child pid to parent"); + int status = 0; + pid_t w = waitpid(pid, &status, 0); + CHECK(w == pid && WIFEXITED(status) && WEXITSTATUS(status) == 9, + "vfork child exit code 9 via waitpid"); +} + +// 4. waitpid with no children returns -1/ECHILD. +static void test_no_children() +{ + int status = 0; + errno = 0; + pid_t w = waitpid(-1, &status, 0); + CHECK(w == -1, "waitpid with no children returns -1"); +} + +int main() +{ + printf("=== tst-fork ===\n"); + test_fork_return(); + test_fork_exec(); + test_vfork(); + test_no_children(); + printf("=== tst-fork done: %d failures ===\n", failures); + return failures == 0 ? 0 : 1; +}