Skip to content

Commit 9f4b483

Browse files
committed
process: implement thread-backed fork()/vfork()/execve()/waitpid() (opt-in)
OSv is a single-address-space unikernel, so a literal copy-on-write fork() is not the model. This adds a thread-backed fork() emulation that covers the useful, compatible subset of fork semantics, gated entirely behind a new CONFIG_fork kconfig option that defaults to OFF - so a default OSv build is unchanged (none of this code is compiled in and fork()/vfork() return ENOSYS as before). With CONFIG_fork enabled: - fork()/vfork() create a child OSv thread that resumes in fork()'s caller and returns 0 in the child / the child pid in the parent (the classic twin return). The child runs on a private copy of the parent's user stack, so parent and child have independent locals after the return; it gets its own fresh OSv per-thread TLS block (own errno etc). Implemented in libc/process/fork.cc + arch/{x64,aarch64}/fork.cc. - execve() launches the target as a fresh OSv application (its own ELF namespace) and does not return, making fork()+exec() work. - waitpid()/wait4()/wait() reap a child's exit status via a pid->child registry; SIGCHLD is raised to the parent on child exit; SIGCHLD/SIGURG/ SIGWINCH now correctly default to ignore (not poweroff). - exit()/_exit() in a fork child ends only that child, not the whole unikernel. - pthread_atfork prepare/parent/child handlers are now actually run around fork() (they were a no-op stub) - glibc/musl register these internally. - sys_clone() routes the non-CLONE_THREAD (fork) case here when enabled. Validated: tst-fork passes 10/10 on both x86-64 and aarch64 (twin return, private-stack isolation, fork+exec, vfork, waitpid reaping). Documented limitations (documentation/fork.md): the child shares the parent's heap/globals (no per-process memory isolation in one address space - a follow-up adds per-child copy-on-write address spaces behind the same flag); deep-call- chain child unwind and fork-as-memory-snapshot (Redis BGSAVE) are not carried by this base; execve()'s new-ELF-namespace path has a separate pre-existing fault. Copyright (C) 2026 Greg Burd
1 parent cb8c720 commit 9f4b483

15 files changed

Lines changed: 939 additions & 25 deletions

File tree

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,9 @@ objects += arch/$(arch)/firmware.o
10571057
objects += arch/$(arch)/hypervisor.o
10581058
objects += arch/$(arch)/interrupt.o
10591059
objects += arch/$(arch)/clone.o
1060+
ifeq ($(conf_fork),1)
1061+
objects += arch/$(arch)/fork.o
1062+
endif
10601063
ifeq ($(conf_drivers_pci),1)
10611064
objects += arch/$(arch)/pci.o
10621065
objects += arch/$(arch)/msi.o
@@ -1660,11 +1663,20 @@ musl += prng/srand48.o
16601663
libc += random.o
16611664

16621665
libc += process/execve.o
1666+
ifeq ($(conf_fork),1)
1667+
libc += process/fork.o
1668+
endif
16631669
musl += process/execle.o
16641670
musl += process/execv.o
16651671
musl += process/execl.o
16661672
libc += process/waitpid.o
1673+
# When fork() is enabled, our waitpid.cc provides wait()/waitpid()/wait4()
1674+
# backed by the fork() child registry, superseding musl's process/wait.o.
1675+
# When fork() is disabled, waitpid.cc provides only the ECHILD waitpid() stub,
1676+
# so keep musl's wait.o for wait()/wait4().
1677+
ifneq ($(conf_fork),1)
16671678
musl += process/wait.o
1679+
endif
16681680

16691681
musl += setjmp/$(musl_arch)/setjmp.o
16701682
musl += setjmp/$(musl_arch)/longjmp.o

arch/aarch64/fork.cc

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright (C) 2026 Greg Burd
3+
*
4+
* This work is open source software, licensed under the terms of the
5+
* BSD license as described in the LICENSE file in the top-level directory.
6+
*
7+
* fork_thread() for aarch64 -- mirrors arch/x64/fork.cc. fork() (fork.cc)
8+
* passes the caller's return address and stack pointer; we copy the parent's
9+
* user stack, bias the SP into the copy, and start a child thread that installs
10+
* the copied stack, sets x0=0 (fork()'s return value in the child), and returns
11+
* to fork()'s caller.
12+
*/
13+
14+
#include "arch.hh"
15+
#include <errno.h>
16+
#include <string.h>
17+
#include <cstdlib>
18+
#include <osv/sched.hh>
19+
20+
// pthread_atfork child-handler chain (defined in libc/pthread.cc), run in the
21+
// child's context before it resumes user code.
22+
extern "C" void __osv_run_atfork_child();
23+
24+
sched::thread *fork_thread(void *caller_ret, void *caller_sp,
25+
void **out_stack_to_free)
26+
{
27+
auto parent = sched::thread::current();
28+
auto parent_pinned_cpu = parent->pinned() ? sched::cpu::current() : nullptr;
29+
30+
auto si = parent->get_stack_info();
31+
char *stack_base = static_cast<char*>(si.begin) + si.size;
32+
char *sp = static_cast<char*>(caller_sp);
33+
if (sp < static_cast<char*>(si.begin) || sp > stack_base) {
34+
return nullptr;
35+
}
36+
size_t stack_size = si.size;
37+
38+
char *child_stack_mem = static_cast<char*>(malloc(stack_size));
39+
if (!child_stack_mem) {
40+
return nullptr;
41+
}
42+
// Copy ONLY the live top [caller_sp .. stack_base) into the top of the
43+
// child buffer (app stacks are demand-paged; copying from si.begin faults).
44+
char *child_base = child_stack_mem + stack_size;
45+
ptrdiff_t bias = child_base - stack_base;
46+
size_t live = static_cast<size_t>(stack_base - sp);
47+
memcpy(child_base - live, sp, live);
48+
char *child_sp = sp + bias;
49+
50+
volatile u64 resume_sp = reinterpret_cast<u64>(child_sp);
51+
volatile u64 resume_pc = reinterpret_cast<u64>(caller_ret);
52+
char *stack_to_free = child_stack_mem;
53+
54+
// TLS: the child is a real OSv thread with its own fresh setup_tcb() block.
55+
// Only override tpidr_el0 if the parent had installed its own app TCB via
56+
// arch_prctl (parent_app_tcb != 0); otherwise keep the child's private OSv
57+
// TLS (the clean case for a musl app built against OSv's libc).
58+
u64 parent_app_tcb = parent->get_app_tcb();
59+
auto t = sched::thread::make([resume_sp, resume_pc, parent_app_tcb] {
60+
if (parent_app_tcb) {
61+
asm volatile ("msr tpidr_el0, %0; isb" :: "r"(parent_app_tcb) : "memory");
62+
}
63+
// Run pthread_atfork child handlers in the child's context before
64+
// resuming user code.
65+
__osv_run_atfork_child();
66+
asm volatile
67+
("mov sp, %0 \n\t" // install the private copied stack
68+
"mov x0, #0 \n\t" // fork() returns 0 in the child
69+
"br %1 \n\t" // resume in fork()'s caller
70+
: : "r"(resume_sp), "r"(resume_pc) : "x0", "memory");
71+
}, sched::thread::attr().
72+
stack(4096 * 4),
73+
false,
74+
true);
75+
t->set_app_tcb(parent->get_app_tcb());
76+
if (parent_pinned_cpu) {
77+
t->pin(parent_pinned_cpu);
78+
}
79+
if (out_stack_to_free) {
80+
*out_stack_to_free = stack_to_free;
81+
}
82+
return t;
83+
}

arch/x64/fork.cc

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright (C) 2026 Greg Burd
3+
*
4+
* This work is open source software, licensed under the terms of the
5+
* BSD license as described in the LICENSE file in the top-level directory.
6+
*
7+
* fork_thread(): create a child thread that resumes in fork()'s CALLER, on a
8+
* private copy of the parent's user stack, returning 0 from fork() in the child.
9+
* The x86-64 arch half of the fork() emulation (see documentation/fork.md).
10+
*
11+
* fork() (libc/process/fork.cc) passes us the caller's resume point:
12+
* caller_ret = the address fork() would return to (__builtin_return_address)
13+
* caller_sp = the parent's SP at fork()'s return (fork()'s frame base)
14+
* We copy the parent stack region [caller_sp .. stack_base) into a fresh stack,
15+
* bias caller_sp into the copy, and start a child thread whose trampoline sets
16+
* rsp=child_sp, rax=0, and jumps to caller_ret -- i.e. the child returns from
17+
* fork() with value 0 on its own private stack, in the caller.
18+
*/
19+
20+
#include "arch.hh"
21+
#include "tls-switch.hh"
22+
#include <errno.h>
23+
#include <string.h>
24+
#include <cstdlib>
25+
#include <osv/sched.hh>
26+
27+
// pthread_atfork child-handler chain (defined in libc/pthread.cc), run in the
28+
// child's context before it resumes user code.
29+
extern "C" void __osv_run_atfork_child();
30+
31+
sched::thread *fork_thread(void *caller_ret, void *caller_sp, void **out_stack_to_free)
32+
{
33+
auto parent = sched::thread::current();
34+
auto parent_pinned_cpu = parent->pinned() ? sched::cpu::current() : nullptr;
35+
36+
auto si = parent->get_stack_info();
37+
char *stack_base = static_cast<char*>(si.begin) + si.size;
38+
char *sp = static_cast<char*>(caller_sp);
39+
if (sp < static_cast<char*>(si.begin) || sp > stack_base) {
40+
return nullptr; // caller SP not within the known user stack
41+
}
42+
size_t stack_size = si.size;
43+
44+
char *child_stack_mem = static_cast<char*>(malloc(stack_size));
45+
if (!child_stack_mem) {
46+
return nullptr;
47+
}
48+
// Copy ONLY the live top of the stack, [caller_sp .. stack_base), into the
49+
// TOP of the child buffer. App (pthread) stacks are demand-paged: only the
50+
// used top is mapped, so copying from si.begin would fault on the first
51+
// unmapped page. Keeping the copy at the top of the child buffer preserves
52+
// the base-relative bias so a biased SP resolves correctly.
53+
char *child_base = child_stack_mem + stack_size;
54+
ptrdiff_t bias = child_base - stack_base;
55+
size_t live = static_cast<size_t>(stack_base - sp);
56+
memcpy(child_base - live, sp, live);
57+
char *child_sp = sp + bias;
58+
59+
volatile u64 resume_sp = reinterpret_cast<u64>(child_sp);
60+
volatile u64 resume_pc = reinterpret_cast<u64>(caller_ret);
61+
char *stack_to_free = child_stack_mem;
62+
63+
// TLS handling. The child is a real OSv sched::thread, so its constructor
64+
// already ran setup_tcb() and installed a FRESH, private OSv TLS block
65+
// (with its own errno and all libc __thread state). Two cases:
66+
//
67+
// (1) The app uses OSv's libc TLS (the normal dynamically-linked path,
68+
// app_tcb == 0): the child's own fresh TCB is exactly right -- do NOT
69+
// touch fsbase, let the child run on its private per-thread TLS. This
70+
// is the clean case and fork() "just works" for TLS.
71+
// (2) The app installed its own TCB via arch_prctl(SET_FS) (app_tcb != 0,
72+
// e.g. a glibc binary's __libc_setup_tls): the child would need a
73+
// private COPY of that app TCB. We do not duplicate it here yet;
74+
// the child inherits the parent's app_tcb (shared), which is the
75+
// documented multi-process-glibc limitation. A musl app built against
76+
// OSv's libc takes path (1) and avoids this entirely.
77+
u64 parent_app_tcb = parent->get_app_tcb();
78+
79+
auto t = sched::thread::make([resume_sp, resume_pc, parent_app_tcb] {
80+
// Only override the child's own (fresh) TLS if the parent had installed
81+
// an app TCB via arch_prctl; otherwise keep the child's private OSv TCB.
82+
if (parent_app_tcb) {
83+
arch::set_fsbase(parent_app_tcb);
84+
}
85+
// Run pthread_atfork child handlers in the child's context (e.g. reset
86+
// the malloc arena lock) before resuming user code.
87+
__osv_run_atfork_child();
88+
asm volatile
89+
("movq %0, %%rsp \n\t" // install the private copied stack
90+
"xorq %%rax, %%rax \n\t" // fork() returns 0 in the child
91+
"jmpq *%1 \n\t" // resume in fork()'s caller
92+
: : "r"(resume_sp), "r"(resume_pc) : "memory");
93+
}, sched::thread::attr().
94+
stack(4096 * 4),
95+
false,
96+
true);
97+
t->set_app_tcb(parent->get_app_tcb());
98+
if (parent_pinned_cpu) {
99+
t->pin(parent_pinned_cpu);
100+
}
101+
// The caller (fork.cc) owns the single cleanup; hand back the copied user
102+
// stack so it can be freed when the child is reaped.
103+
if (out_stack_to_free) {
104+
*out_stack_to_free = stack_to_free;
105+
}
106+
return t;
107+
}

conf/kconfig/threads

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ config lazy_stack
66
prompt "Use lazy stack"
77
def_bool $(shell,grep -q ^conf_lazy_stack=1 conf/base.mk && echo y || echo n)
88

9+
config fork
10+
prompt "Include fork()/vfork() support (per-child address space, off by default)"
11+
bool
12+
default n
13+
help
14+
Enable OSv's thread-backed fork()/vfork()/execve()/waitpid() emulation,
15+
including the per-child address space with copy-on-write needed to give a
16+
forked child private memory. This changes OSv's usual single-address-space
17+
model (address-space switches on context switch between fork children), so
18+
it is OFF by default: with this disabled, none of the fork code is compiled
19+
in and OSv behaves exactly as before (fork() returns ENOSYS). Enable it
20+
only for workloads that require fork() (e.g. multi-process programs).
21+
922
config lazy_stack_invariant
1023
prompt "Check lazy stack invariant"
1124
def_bool $(shell,grep -q ^conf_lazy_stack_invariant=1 conf/base.mk && echo y || echo n)

documentation/fork.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# fork() on OSv
2+
3+
OSv is a single-address-space unikernel: all threads of all applications share
4+
one address space, with no MMU-enforced process isolation. This is deliberate,
5+
it is where OSv's performance comes from. It also means Linux `fork()`, which
6+
gives the child a **private copy-on-write duplicate of the parent's entire
7+
address space**, cannot be implemented literally.
8+
9+
OSv instead provides a **thread-backed fork() emulation** that supports the
10+
common, compatible subset of fork semantics. This document describes what works,
11+
what does not, and why.
12+
13+
## Off by default: the `fork` configure flag
14+
15+
All of the fork() machinery - the fork()/vfork()/execve()/waitpid()
16+
implementations, the per-child address space, and the copy-on-write changes -
17+
is gated behind the `CONFIG_fork` kconfig option (make variable `conf_fork`),
18+
which defaults to **n**. When it is disabled (the default), NONE of this code
19+
is compiled into the kernel: the fork object files are excluded from the build,
20+
fork()/vfork() return ENOSYS as before, and OSv behaves exactly as it did with
21+
no change to its single-address-space model or performance. Build with
22+
`conf_fork=1` (or enable "Include fork() support" in `make menuconfig`) only for
23+
workloads that need fork(). This keeps the default OSv unchanged while offering
24+
fork() to those who require it.
25+
26+
## What works
27+
28+
- **`fork()` twin return.** `fork()` creates a new OSv thread (the "child") that
29+
resumes at the `fork()` call site. `fork()` returns the child's pid to the
30+
parent and `0` to the child, exactly like Linux. The child runs on a **private
31+
copy of the parent's user stack**, so parent and child have independent local
32+
variables and call chains after the return.
33+
34+
- **`fork()` + `execve()`.** The child calls `execve()`, which launches the
35+
requested program as a fresh OSv application (its own ELF namespace) and makes
36+
the child thread that program's driver; a successful `execve()` never returns,
37+
as on Linux. The parent is a separate thread and is unaffected.
38+
39+
- **`waitpid()` / `wait4()` / `wait()`.** The parent reaps a child's exit status
40+
(Linux-encoded, use `WIFEXITED`/`WEXITSTATUS`). `WNOHANG` is supported.
41+
`SIGCHLD` is raised to the parent when a child exits.
42+
43+
- **`vfork()`.** Maps to `fork()`. Because the child already shares the parent's
44+
address space, this actually matches vfork's contract ("the child borrows the
45+
parent's memory until it execs or exits") more faithfully than it matches
46+
fork's copy contract.
47+
48+
- **`_exit()` / `exit()` in a child** ends only that child "process" (thread /
49+
app) and records its status for the parent, rather than shutting down the
50+
whole unikernel (which is what a top-level `exit()` still does).
51+
52+
## What does NOT work (and why)
53+
54+
- **Shared TLS (thread-local storage) - only for apps that install their own
55+
TCB.** A forked child is a real OSv thread, so it gets its OWN fresh, private
56+
OSv TLS block (its own `errno` and libc `__thread` state) automatically. For a
57+
program that uses OSv's (musl-derived) libc the normal way, fork() TLS "just
58+
works" - the child does not share the parent's TLS. The exception is a program
59+
that installs its OWN thread pointer via `arch_prctl(ARCH_SET_FS)` (e.g. a
60+
glibc-ABI binary's `__libc_setup_tls`): OSv records that as `app_tcb`, and the
61+
fork child currently inherits the SAME `app_tcb` (shared), which collides. The
62+
fix for such binaries is to build them against OSv's own musl libc instead of
63+
glibc, so they take the clean per-thread-TLS path. (This was the wall stock
64+
glibc-built PostgreSQL hit; a musl build avoids it.)
65+
66+
- **Memory isolation.** The child shares the parent's heap and global variables
67+
(only the stack is copied). A child that **writes** to shared globals or
68+
heap-allocated data expecting a private copy will affect the parent. Code that
69+
only reads shared state and writes to its own fds or freshly-`malloc`'d memory
70+
before `exec`/`_exit` is fine; code that mutates shared state after fork is
71+
not. This cannot be fixed without adding process isolation to OSv.
72+
73+
- **`fork()` as a memory snapshot** (e.g. Redis `BGSAVE`, a GC that forks to walk
74+
a frozen heap). These rely on the child seeing a *frozen* copy of the parent's
75+
memory at fork time. On OSv the child sees live, shared memory. This is a
76+
silent behavioral difference (it cannot be detected at the syscall boundary)
77+
and is **unsupported**.
78+
79+
- **Stack-internal pointers.** Because the child's stack is a byte copy of the
80+
parent's biased to a new address, a pointer stored on the stack that points
81+
*into the same stack* still points at the parent's stack in the child. Short
82+
child code paths (the fork+exec and fork+work-then-_exit patterns) do not hit
83+
this; long-lived divergent children can. A future refinement could scan and
84+
fix up such pointers.
85+
86+
- **`clone()` with namespace-unshare flags** (`CLONE_NEWNS`, `CLONE_NEWPID`,
87+
etc.) returns `ENOSYS` — there are no namespaces to unshare.
88+
89+
- **aarch64.** Implemented and validated: the stack-copy + `br` resume
90+
trampoline works on aarch64 (Graviton) as well as x86-64. `tst-fork` passes
91+
10/10 on both architectures.
92+
93+
## Implementation
94+
95+
- `libc/process/fork.cc``fork()`/`vfork()`, the child registry, and the
96+
`waitpid()` backend + `SIGCHLD` notification.
97+
- `arch/x64/fork.cc``fork_thread()`: allocates a fresh stack, copies the
98+
parent's current user stack into it, and returns a child thread that installs
99+
the copied stack and returns `0` from `fork()`. Reuses the register/
100+
continuation approach of `clone_thread()` (used by `pthread_create`).
101+
- `libc/process/execve.cc``execve()` via `osv::application::run()`.
102+
- `libc/process/waitpid.cc``wait`/`waitpid`/`wait4`.
103+
- `runtime.cc``exit()` ends a child rather than shutting down OSv.
104+
- `linux.cc``sys_clone()` routes the non-`CLONE_THREAD` (fork) case here.
105+
106+
## Guidance
107+
108+
For spawning helper programs, prefer `posix_spawn()` or `system()` (which route
109+
straight to `osv::application::run()` and skip the stack copy entirely) over
110+
`fork()`+`exec()` where you control the code. Use `fork()` for compatibility
111+
with existing Linux programs that expect it, within the limitations above.

0 commit comments

Comments
 (0)