Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions arch/aarch64/fork.cc
Original file line number Diff line number Diff line change
@@ -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 <errno.h>
#include <string.h>
#include <cstdlib>
#include <osv/sched.hh>

// 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<char*>(si.begin) + si.size;
char *sp = static_cast<char*>(caller_sp);
if (sp < static_cast<char*>(si.begin) || sp > stack_base) {
return nullptr;
}
size_t stack_size = si.size;

char *child_stack_mem = static_cast<char*>(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<size_t>(stack_base - sp);
memcpy(child_base - live, sp, live);
char *child_sp = sp + bias;

volatile u64 resume_sp = reinterpret_cast<u64>(child_sp);
volatile u64 resume_pc = reinterpret_cast<u64>(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;
}
115 changes: 115 additions & 0 deletions arch/x64/fork.cc
Original file line number Diff line number Diff line change
@@ -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 <errno.h>
#include <string.h>
#include <cstdlib>
#include <osv/sched.hh>

// 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<char*>(si.begin) + si.size;
char *sp = static_cast<char*>(caller_sp);
if (sp < static_cast<char*>(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<char*>(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<size_t>(stack_base - sp);
memcpy(child_base - live, sp, live);
char *child_sp = sp + bias;

volatile u64 resume_sp = reinterpret_cast<u64>(child_sp);
volatile u64 resume_pc = reinterpret_cast<u64>(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;
}
13 changes: 13 additions & 0 deletions conf/kconfig/threads
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
111 changes: 111 additions & 0 deletions documentation/fork.md
Original file line number Diff line number Diff line change
@@ -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.
Loading