mm: resolve fork COW write faults lock-free when nested (fork-stack follow-on to #1456) - #1475
Draft
gburd wants to merge 7 commits into
Draft
mm: resolve fork COW write faults lock-free when nested (fork-stack follow-on to #1456)#1475gburd wants to merge 7 commits into
gburd wants to merge 7 commits into
Conversation
…pt-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
execve() (CONF_fork) launches the target as a fresh OSv application in its own
ELF namespace via application::run(new_program=true) and, matching Linux, does
not return to the caller. A successful exec DID launch the program (the
elf::program new-namespace construction path is fine), but after the exec'd
program finished the unikernel would hang at shutdown instead of powering off:
the loader's application::join() blocked forever on the top-level app's
_terminated flag.
Root cause is the fork child thread's lifecycle, not execve or elf::program.
fork_thread() (arch/{x64,aarch64}/fork.cc) creates the child as a normal
*attached* sched::thread. At construction the thread captures a shared_ptr to
the current application's application_runtime (sched.cc sets _app_runtime =
app->runtime()). Nothing ever join()s the fork child -- the parent reaps it
through the fork pid registry / waitpid(), not sched::thread::join() -- so the
thread object is never destroyed and its _app_runtime shared_ptr is never
released. With that reference outstanding the application_runtime's use count
never reaches zero, ~application_runtime never runs, the app's _terminated is
never set, and application::join() waits forever. (This bit every fork(), and
was most visible after fork()+execve() where the child adopts the caller app's
runtime.)
Fix: create the fork child detached and dispose it in its cleanup.
- arch/{x64,aarch64}/fork.cc: mark the child attr().detached(), so on
completion it is handed to the thread reaper (which runs its set_cleanup()),
rather than sitting forever waiting to be joined.
- libc/process/fork.cc: the child's cleanup now also calls
sched::thread::dispose(child) after the existing bookkeeping (record exit
status if it fell off the end, free the copied user stack). Disposing the
thread releases its _app_runtime reference, letting the owning application's
runtime drop to zero so join() completes and OSv powers off. This mirrors
the default detached-thread cleanup ([this]{ dispose(this); }).
Both files are compiled only under CONF_fork, so default OSv builds are
unchanged.
Tests: tst-fork test 2 now execs a real payload (/tests/payload-exit7.so, which
prints a marker and exit(7)s) instead of the previous _exit(7) sentinel that
masked whether execve actually launched anything; a return from execve() is now
treated as a failure. Added tst-execve.so (fork+execve launches the payload and
its exit code is reaped; missing path returns -1/ENOENT) and payload-exit7.so.
Validated on x86-64 (KVM disk boot): tst-fork 10/10 and tst-execve 3/3 pass with
the real exec payload and OSv shuts down cleanly (5/5 repeat runs, no hang).
Copyright (C) 2026 Greg Burd
Follow-up to the base fork() PR: gives a forked child its own address space with copy-on-write of private mappings, so a forked child has real memory isolation like Linux fork() while MAP_SHARED / shm stays shared. Also gated behind CONFIG_fork (default off): with it disabled none of this is compiled and OSv's single-address-space model and context-switch path are unchanged. With CONFIG_fork enabled: - mmu::address_space object (page-table root + vma_list); the previous global becomes 'address space 0' (kernel + init app). - Per-thread current address space; the context switch loads the target CR3 only when the address space differs (a no-op for AS0-only workloads). Kernel PML4 entries are shared across all address spaces so OSv code + the kernel heap work identically after a switch. - fork() clones the parent's vmas into a child address space: PRIVATE writable mappings are write-protected in both and copied on the first write (reusing OSv's existing COW fault machinery); MAP_SHARED / shm map the same physical pages (truly shared). execve() returns the thread to AS0. - Lock/condvar wait_records for fork-child (non-AS0) threads are allocated from the AS-shared kernel heap instead of the thread stack, so a wait_record queued on a shared kernel mutex resolves to the same physical page for any waker across address spaces (kernel-stack coherence). Validated: tst-fork-cow proves a forked child's private memory stays private while MAP_SHARED stays shared; tst-fork stays 10/10. Known limitation (documented): the child's stack is still relocated+copied rather than same-VA COW, so a child that unwinds a very deep call chain (e.g. a multi-process server forking backends) can still hit a stack-fidelity issue (tst-fork-deep); the same-VA stack fix is a further follow-up. This PR delivers memory-isolated fork for the common cases with COW proven. Copyright (C) 2026 Greg Burd
Give a forked child its parent's EXACT user stack -- same virtual addresses, private physical pages -- instead of a relocated copy with a biased SP. A relocated+biased stack leaves every saved rbp, return address and &local in the copied frames pointing at the parent's stack (off by the bias), so a deep unwind in the child dereferences the parent and faults. tst-fork-deep (fork 12 frames deep, child unwinds all the way up and _exit()s a checked value) is the test that catches this; it now passes. Gated behind CONF_fork (default n); conf_fork=0 is unchanged and builds/boots clean (fork code fully excluded, tst-mmap passes). Four coupled changes make same-VA correct, layered on the Stage 2 COW address space and the fork-child lifecycle fix (detached child + dispose(child) in the reaper): 1. core/mmu.cc: clone_address_space() PRIVATIZES the forking thread's live stack VA range into the child AS -- fresh private pages that byte-copy the parent's stack, mapped writable (not COW: OSv runs kernel code with irqs off on the app stack, a COW fault there is illegal) at the SAME VA. The parent's PTEs are untouched. 2. arch/x64/fork.cc + libc/process/fork.cc + include/osv/fork.hh: the child resumes with the caller's FULL callee-saved register context (rbx, rbp, r12-r15, rsp, rip), captured at fork() entry into osv::fork_resume_ctx and restored by the child trampoline off a scratch base register (so the restore never clobbers its own base). fork() jmps past its own epilogue, so without this the caller would resume with fork()'s internal register values -- a deep chain that keeps an accumulator in a callee-saved reg across fork() computes garbage. 3. arch/x64/arch-switch.hh: defer the fork CR3 switch to EXACTLY the rsp/rbp swap inside switch_to()'s asm. Loading the incoming AS's CR3 early, while still executing on the outgoing thread's stack (the fpu-cw/mxcsr scratch saves), resolved those shared-VA stack writes through the wrong address space and clobbered a blocked thread's live stack (root-caused via hardware watchpoint: switch_to wrote garbage into a blocked parent's on-stack user_mutex slot in AS0). 4. core/lfmutex.cc + core/mmu.cc: a wait_record on a shared kernel condvar/mutex must live in the identity-mapped kernel heap (coherent in every AS) whenever a cross-address-space wake is possible. Extend the Option-A rule from 'current thread is a fork child' to also cover 'any child address space is live' (live_child_address_spaces counter), so the parent in AS0 -- woken by a child that walks the queue through its own page tables -- is safe too. libc/process/execve.cc: on a fork child, move the thread onto its own kernel stack before switching to AS0 and running the target, so reloading CR3 to AS0 cannot alias the same-VA app stack onto the parent's physical page (which would corrupt a blocked parent). The child address space is destroyed HERE, on the kernel stack, exactly once: the reap-time cleanup (libc/process/fork.cc) then sees the thread's AS is AS0 (!= child_as) and skips its own destroy, so destroy_address_space() runs once whether the child exits normally (reaper destroys) or execs (execve destroys) -- no leak, no double-free. A pre-exec existence check keeps a failed exec returning to the caller. Validated on x86-64 (KVM): tst-fork 10/10, tst-fork-cow 6/6, tst-fork-deep 0 failures, tst-execve 3/3 with clean shutdown (no hang, no double-free at reap), on smp=1 and smp=4, 5/5 repeat runs; conf_fork=0 builds and boots clean (fork excluded, tst-mmap passes).
The per-child COW address space (clone_pt_level<1>) shared any 2 MB large
page verbatim between parent and child instead of copy-on-writing it:
if (ppte.large()) { child_pt[i] = ppte; continue; } // 2MB: share as-is
so a forked child writing a private large mapping scribbled the parent(s)
too. This hit large malloc (>= 2 MB, mmap-backed), large MAP_ANON regions,
and any 2 MB-backed private writable mapping in the application slots.
Fix: for a WRITABLE large PD entry, split it to 4 K in the parent in place
(the existing split_large_page primitive) and fall through to the normal 4 K
clone path, which already makes the correct per-page decision -- private
writable -> COW, MAP_SHARED -> stays shared, read-only -> shared as-is. A
read-only large page can never diverge, so it is still shared verbatim. This
reuses the whole 4 K COW fault machinery (clone_pt_level0 +
handle_cow_write_fault); the only cost is one 4 K page table per split large
page, acceptable under the opt-in CONF_fork.
Verified on x86-64 (KVM microvm):
- a standalone musl-PIE fork test now isolates a private >2 MB malloc across
fork (child write does not change the parent; parent write does not change
the child), while a MAP_SHARED region stays mutually visible;
- tst-fork 10/10, tst-fork-cow 6/6, tst-fork-deep and tst-execve all pass
(no regressions);
- conf_fork=0 builds clean with the whole fork path (this change included)
compiled out (clone_address_space absent from loader.elf).
Note: this fixes large-page (application-slot) COW. OSv's small-object
malloc heap lives in the shared kernel identity map (mem_area::mempool), which
fork() shares by design; isolating that is a separate change.
All gated behind CONF_fork (default off).
Copyright (C) 2026 Greg Burd
clone_address_space() rebuilt EVERY child VMA as an anon_vma, so a fork
child's file-backed mappings (file_vma -- e.g. the ELF loader's file-backed
.text, mapped via map_file) became anonymous in the child. A demand fault on
a child file-backed page that the parent had not already resident-loaded then
dispatched to the base zero-fill path instead of file_vma::fault reading the
file: the child read (or executed) zeros. This is the "wild-branch" bug that
blocked file-backed fork workloads such as PostgreSQL, whose checkpointer child
faults large cold .text regions the postmaster never touched.
Preserve each parent VMA's dynamic type across the clone:
- In the VMA-clone loop (under the parent's vmas_mutex), dynamic_cast each
vma to file_vma. For a file_vma, rebuild the child's copy via the file's
own mmap() -- the SAME call map_file and file_vma::split use -- so the
child gets the correct page_allocator (map_file_page_read for MAP_PRIVATE,
map_file_page_mmap for a cached/shared mapping) and its demand faults read
the file. The new file_vma holds its own fileref. Anon vmas still clone
as anon_vma. file::mmap()/default_file_mmap()/map_file_mmap() are pure
allocation (no locks, no I/O), matching the anon_vma allocation this loop
already performs under the lock.
- In destroy_address_space(), dispose the child's owned vma objects before
freeing the address space. ~file_vma releases the fileref this child took
(and deletes its page_allocator), closing the file-reference leak the type
preservation would otherwise introduce -- and also the pre-existing
anon_vma + edge-marker leak. ~vma touches no global list, so disposing
here is safe.
The whole change is inside #if CONF_fork, so a conf_fork=0 build is
byte-identical.
Adds tests/tst-fork-file-mmap.cc (gated via the fork test group): a parent
mmaps a read-only ROFS file MAP_PRIVATE without faulting a mid-file page,
forks, and the child faults that page first -- it must read the real file
bytes, not zeros. Reproduces the bug on the unfixed tree (child reads 0x00)
and passes with this fix (child reads the real byte).
Signed-off-by: Greg Burd <greg@burd.me>
…hedule at exception_depth >= 2 A copy-on-write write fault taken while another exception is already in flight (a real IRQ landing a fault during a COW fork, driving the fault handler to exception_depth >= 2) must not reschedule: the per-CPU nested-exception stacks are finite and reschedule_from_interrupt asserts(exception_depth <= 1). The normal COW path both acquires the AS-wide vmas_mutex->for_write() (a contended rwlock that blocks) and, in handle_cow_write_fault, calls flush_tlb_all() (takes tlb_flush_mutex and wait_until()s every CPU) -- either reschedules, tripping the assert deep in the fork path under concurrent load. When nested that deep, resolve the fault lock-free: handle_cow_write_fault already walks only this address space's own page tables and installs the private page with a single PTE write to this AS, so a LOCAL TLB flush suffices (the page is private to this AS; other CPUs run a different CR3 and reload on switch-in). Nothing blocks, so no reschedule can happen at exception_depth >= 2. A concurrent COW copy of the same page by another CPU at most duplicates a private page (the loser leaks one page -- rare, only in this nested race, and correctness-preserving). The non-nested (exception_depth <= 1) path is byte-for-byte unchanged, and the whole change is under #if CONF_fork so conf_fork=0 builds are unaffected.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DRAFT / fork-stack follow-on -- not a standalone master change.
This is a one-commit follow-on to the opt-in
fork()copy-on-write work(#1456), and belongs on that stack, tracked under #1458. It is not
proposable against stock master on its own: the code it touches
(
handle_cow_write_faultand the nested COW branch ofvm_fault) onlyexists under
#if CONF_fork, which lands with #1455/#1456. On aconf_fork=0build the change compiles out entirely and the binary isbyte-identical.
The reviewable unit here is the single top commit
mm: resolve fork COW write faults lock-free when nested ...touchingcore/mmu.cconly. Theother commits shown in the diff are the #1456 COW base this stacks on; they
appear because master does not yet carry that base. Review just the top
commit.
What it fixes
A copy-on-write write fault can be taken while another exception is already
in flight -- e.g. a device/timer IRQ lands a fault while a COW fault is
already being serviced during a
fork()-- driving the fault handler toexception_depth >= 2. At that depth no reschedule is allowed:reschedule_from_interruptassertsexception_depth <= 1(the per-CPUnested-exception stacks are finite).
The normal COW path reschedules in two places, either of which trips that
assert deep in the fork path under concurrency:
vmas_mutex->for_write()-- a contended rwlock thatblocks (and therefore reschedules), and
handle_cow_write_faultcallsflush_tlb_all(), which takestlb_flush_mutexandwait_until()s every other CPU (also a reschedule).The change
When the COW write fault is itself nested (
exception_depth >= 2), resolveit lock-free:
handle_cow_write_faultalready walks only this addressspace's own page tables and installs the private page with a single PTE
write to this AS, so a local TLB flush suffices (the page is private to
this AS; other CPUs run a different CR3 and reload on switch-in). Nothing
blocks, so no reschedule can happen at
exception_depth >= 2. A concurrentCOW copy of the same page by another CPU at most duplicates a private page
(the loser leaks one page -- rare, only in this nested race, and
correctness-preserving).
The non-nested (
exception_depth <= 1) path is byte-for-byte unchanged.Why it matters
Beyond the crash-safety (it removes an assert that fires deep in the fork
path under concurrent load), it also removes a latency serialization for
forked-process servers: a backend blocking on the AS-wide
vmas_mutexand aglobal cross-CPU TLB flush on the nested COW write-fault path serializes
concurrent forked-process workloads. Resolving the nested fault lock-free
with a local flush lets those backends make progress independently.
Scope / stacking
CONF_forkCOW machinery from mm: per-child copy-on-write address space for fork() (opt-in, stacked on #1455) #1456 (and the fork gateprocess: implement thread-backed fork()/vfork()/execve()/waitpid() (opt-in, off by default) #1455); tracked under [DRAFT / tracking - DO NOT MERGE as one unit] PostgreSQL-on-OSv fork+ZFS integration (will split into S1..S6 on #1455/#1456/#1457 + #1423) #1458.
#if CONF_fork;conf_fork=0byte-identical.core/mmu.cconly (+44 / -3).Draft while the fork stack is gated on #1455 -- opened so the fix is banked
reviewably on the right stack, not to request near-term merge.