mm: per-child copy-on-write address space for fork() (opt-in, stacked on #1455) - #1456
mm: per-child copy-on-write address space for fork() (opt-in, stacked on #1455)#1456gburd wants to merge 6 commits into
Conversation
|
Update: the known deep-call-chain limitation is now fixed (the child stack is same-VA COW rather than relocated+copied), so this PR delivers memory-isolated fork() including deep-call-chain children. Root cause of the deep-unwind fault (found via hardware watchpoint): Fixes (all gated CONF_fork): defer the CR3 switch into the switch_to asm coincident with the rsp/rbp swap; privatize the forking thread's stack VA range into the child address space (same VA, private COW pages); restore the child's full callee-saved register context on resume; heap-allocate lock/condvar wait_records while any child address space is live (cross-AS coherence); and, on execve of a fork child, tear down the child address space from the kernel stack so destroy_address_space runs exactly once whether the child exits or execs (no leak, no double-free). Validated x86-64/KVM: tst-fork 10/10, tst-fork-cow 6/6, tst-fork-deep PASS, tst-execve 3/3, clean shutdown across 5x repeat runs and smp1/smp4; conf_fork=0 still builds with all fork code excluded and tst-mmap/tst-pthread passing (no scheduler regression). |
clone_address_space() rebuilt EVERY child VMA as an anon_vma, so a file-backed VMA (file_vma -- e.g. an ELF-loaded executable's .text, mapped via map_file) became anonymous in the fork child. When the child demand-faulted a .text page not already present from the COW page-table clone, vm_fault dispatched to the base vma::fault and demand-ZERO-filled the page instead of file_vma::fault reading the file. The child then executed zeros -> wild indirect branch -> SIGSEGV. This was the wall blocking stock PostgreSQL: its checkpointer child runs a large, cold .text path (InitAuxiliaryProcess/BaseInit) whose pages were never demand-faulted in the postmaster, so the COW clone handed the child absent PTEs and it zero-filled its own executable pages. Root-cause diagnosis: KVM+hbreak (see /tmp/w-branch-kvm.txt). Fix, all inside the existing CONF_fork region: * core/mmu.cc clone_address_space(): widen vma_snap to carry a fileref + f_offset for file-backed vmas. Under the parent's vmas_mutex, capture the file_vma's file() (an intrusive_ptr copy -> refcount bump kept alive into the rebuild) and offset(); anon vmas store a null fileref. In the post-lock rebuild loop, reconstruct a file_vma 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 / map_file_page_mmap) and reads the file on fault. anon stays anon. * core/mmu.cc destroy_address_space(): dispose the child's own vma_list (clear_and_dispose delete) before delete as, so ~file_vma releases the child's fileref and page_allocator. This also closes the pre-existing leak of the child's anon vmas and edge markers. * Keep shared FS kernel objects (struct file, vnode + its fs-private v_data, dentry, ramfs inode) OFF the COW fork arena via fork_arena::kernel_heap_scope, in fs/fs.hh make_file<>, fs/vfs/vfs_vnode.cc vget, fs/vfs/vfs_dentry.cc dentry_alloc, fs/ramfs/ramfs_vnops.cc ramfs_allocate_node. These are inherited across fork; if arena-allocated, the child's first write to them (a refcount bump, a v_lock, an atime update) while servicing a demand fault takes a COW fault nested in the page-fault handler -> fatal exception_depth assert. Same class the fork-arena design already documents for thread objects / wait_records. * runtime.cc setsid(): return a session id instead of -1 so sessionless callers (PostgreSQL) do not treat it as FATAL. Regression test tests/tst-fork-file-mmap.cc: parent mmaps a read-only ROFS file MAP_PRIVATE without faulting a target page, forks, and the child demand-faults that page -- it must read the real file bytes, not zeros. Reproduces on the parent commit (child reads 0x00); passes with this fix (0x72). Validated: new test PASS x5; tst-fork, tst-pgfork, tst-fork-cow, tst-fork-deep, tst-execve, tst-fork-child-mmap, tst-fork-preempt all 0 failures; tst-mmap, tst-mmap-file (30/0), tst-mmap-file-cow, tst-huge, tst-vfs green; conf_fork=0 builds clean. Stock PostgreSQL 18.4 now runs its forked child's real file-backed .text through BaseInit (the old wild-branch wall is gone) and reaches the listen stage; the next wall is a distinct BSD-netstack inpcb/socket fork-inheritance assert, not this bug. No psql rows yet. This clone_address_space correctness fix is arena-independent and also strengthens PR cloudius-systems#1456 (per-child COW address space) for file-backed mappings under fork. Author: Greg Burd
…or semaphores, waitqueues and epoll watcher lists -> stock PostgreSQL survives a heavy pgbench -i bulk load on OSv
The storage / shared-buffer coherence wall under `pgbench -i` (parallel index
build / checkpoint large write) was a ramfs segment DATA BUFFER landing in one
forked backend's private COW address space:
ramfs_enlarge_data_buffer() grows a file's segment with malloc(). Once the
segment exceeds the fork arena's 2 MiB max_alloc it falls through to
malloc_large()'s size>=huge_page && !contiguous branch, which reserves an
APP-SLOT (VA < 0x400000000000) anonymous mmap in the CURRENT process's address
space. With per-child COW address spaces that buffer exists ONLY in the
backend that enlarged the file; a sibling backend read()ing/write()ing the
same ramfs file then memcpy's (uiomove) against an app-slot VA unmapped in its
own page tables:
page fault outside application, addr: 0x2000....
RIP: memcpy_repmov_ssse3 ; pread/pwrite -> vfs_file::read/write -> ramfs
i.e. the exact fault under `pgbench -i -s 5`. A kernel_heap_scope does not
help large allocations: it only skips the fork arena; malloc_large still uses
app-slot map_anon for huge non-contiguous requests.
Fix (fs/ramfs/ramfs_vnops.cc): allocate the segment data buffer as physically-
contiguous IDENTITY-mapped memory (memory::alloc_phys_contiguous_aligned, VA >=
0x400000000000, in the kernel PML4 slots clone_address_space() shares verbatim),
so every address space reaches the same file image. Plain free() dispatches by
address to free_large (identity path), so the existing free(data) sites are
correct from any address space.
Investigating the concurrent-writer path uncovered three more instances of the
same "kernel structure a cross-AS waker dereferences must be identity-mapped"
rule (all #if CONF_fork; non-fork byte-identical):
- libc/sem.cc: a pshared POSIX semaphore's posix_semaphore object (its _val,
_mtx and _waiters list) was heap-allocated on the creating app thread -> COW
arena. A waiter (sem_wait) blocked on its private _waiters and a poster
(sem_post) in another backend bumped a private _val -> lost wakeup, and
post_unlocked() GP-faulted dereferencing a stack wait_record cross-AS. Force
the object onto the identity kernel heap.
- core/semaphore.cc: the semaphore wait_record itself is queued on the shared
_waiters list and read by a cross-AS poster. Route it through the same
fork_child_needs_heap_wait_record() gate lockfree::mutex uses (identity heap
when a cross-AS wake is possible; on-stack fast path in AS0).
- include/osv/waitqueue.hh + core/waitqueue.cc: wait_object<waitqueue> queued a
stack wait_record on the shared _waiters_fifo (epoll's activity waiters, etc.)
that a cross-AS waker walks. Give it a coherent_wait_record, like condvar.
- fs/vfs/kern_descrip.cc: file::epoll_add allocated f_epolls (the per-file list
of watching epoll instances) on the registering backend's COW arena; the
RX/netisr thread walks so->fp->f_epolls cross-AS to wake watchers. Keep the
vector and its growth on the identity kernel heap.
Regression test tests/tst-fork-shared-write.cc (registered in
modules/tests/Makefile): a forked child ftruncate()s + writes a large pattern to
a ramfs file (forcing the >2 MiB segment buffer into its AS), then a SIBLING
forked child and the parent read it back. Reproduces the "page fault outside
application" memcpy abort on HEAD; passes with the fix.
Results (KVM -m 4G -smp 2, pg18-fork, ramfs /data):
pgbench -i -s 5 (heavy bulk load): completes -- 500k rows, vacuum, primary keys.
pgbench -c 1 -T 20 (sustained heavy TPC-B write): 41,042 txns, 2053 tps, 0 failed.
pgbench -S -c 4 (concurrent read): sustains ~45-50k tps for a bounded window.
All fork regression tests: 0 failures (tst-fork, -cow, -deep, -serial,
-arena-freelist, -preempt, -irq-cow, -conn-socket, -socket, -pgfork, -execve,
tst-fork-shared-write NEW). conf_fork=0 builds clean (CONF_fork==0).
HONEST STATUS: the storage/shared-buffer coherence wall this targeted is FIXED
(pgbench -i completes; sustained single-writer load completes). Sustained
CONCURRENT load still hits two distinct, precisely-symbolized cross-child-COW
walls that are NOT storage coherence: (1) a post-fork MAP_SHARED write-visibility
race (pre-existing tst-fork-posix-shm part 3, fails on clean HEAD, passes only
when gdb/instrumentation serializes the CPUs), and (2) a park_timers boost
intrusive-list double-insert assert in sched::cpu::reschedule_from_interrupt
during epoll_file::wait under concurrency. Both are in the #if CONF_fork
scheduler/timer/AS-clone machinery, the next investigation for a full HammerDB run.
PR cloudius-systems#1456 relevance: MAP_SHARED / cross-AS coherence across per-child COW address
spaces is core to it; these are five more instances of the shipped rule (struct
file, signal table, thread objects, arena free-list, DSM registry, mbufs, app
runtime, net_req): any kernel structure a forked child or a cross-AS waker reads
must live on the identity kernel heap, never the COW fork arena.
|
Added a correctness fix to Previously the child-AS rebuild recreated every parent VMA as an Fix (entirely inside
New test |
clone_address_space() rebuilt EVERY child VMA as an anon_vma, so a file-backed VMA (file_vma -- e.g. an ELF-loaded executable's .text, mapped via map_file) became anonymous in the fork child. When the child demand-faulted a .text page not already present from the COW page-table clone, vm_fault dispatched to the base vma::fault and demand-ZERO-filled the page instead of file_vma::fault reading the file. The child then executed zeros -> wild indirect branch -> SIGSEGV. This was the wall blocking stock PostgreSQL: its checkpointer child runs a large, cold .text path (InitAuxiliaryProcess/BaseInit) whose pages were never demand-faulted in the postmaster, so the COW clone handed the child absent PTEs and it zero-filled its own executable pages. Root-cause diagnosis: KVM+hbreak (see /tmp/w-branch-kvm.txt). Fix, all inside the existing CONF_fork region: * core/mmu.cc clone_address_space(): widen vma_snap to carry a fileref + f_offset for file-backed vmas. Under the parent's vmas_mutex, capture the file_vma's file() (an intrusive_ptr copy -> refcount bump kept alive into the rebuild) and offset(); anon vmas store a null fileref. In the post-lock rebuild loop, reconstruct a file_vma 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 / map_file_page_mmap) and reads the file on fault. anon stays anon. * core/mmu.cc destroy_address_space(): dispose the child's own vma_list (clear_and_dispose delete) before delete as, so ~file_vma releases the child's fileref and page_allocator. This also closes the pre-existing leak of the child's anon vmas and edge markers. * Keep shared FS kernel objects (struct file, vnode + its fs-private v_data, dentry, ramfs inode) OFF the COW fork arena via fork_arena::kernel_heap_scope, in fs/fs.hh make_file<>, fs/vfs/vfs_vnode.cc vget, fs/vfs/vfs_dentry.cc dentry_alloc, fs/ramfs/ramfs_vnops.cc ramfs_allocate_node. These are inherited across fork; if arena-allocated, the child's first write to them (a refcount bump, a v_lock, an atime update) while servicing a demand fault takes a COW fault nested in the page-fault handler -> fatal exception_depth assert. Same class the fork-arena design already documents for thread objects / wait_records. * runtime.cc setsid(): return a session id instead of -1 so sessionless callers (PostgreSQL) do not treat it as FATAL. Regression test tests/tst-fork-file-mmap.cc: parent mmaps a read-only ROFS file MAP_PRIVATE without faulting a target page, forks, and the child demand-faults that page -- it must read the real file bytes, not zeros. Reproduces on the parent commit (child reads 0x00); passes with this fix (0x72). Validated: new test PASS x5; tst-fork, tst-pgfork, tst-fork-cow, tst-fork-deep, tst-execve, tst-fork-child-mmap, tst-fork-preempt all 0 failures; tst-mmap, tst-mmap-file (30/0), tst-mmap-file-cow, tst-huge, tst-vfs green; conf_fork=0 builds clean. Stock PostgreSQL 18.4 now runs its forked child's real file-backed .text through BaseInit (the old wild-branch wall is gone) and reaches the listen stage; the next wall is a distinct BSD-netstack inpcb/socket fork-inheritance assert, not this bug. No psql rows yet. This clone_address_space correctness fix is arena-independent and also strengthens PR cloudius-systems#1456 (per-child COW address space) for file-backed mappings under fork. Author: Greg Burd
…or semaphores, waitqueues and epoll watcher lists -> stock PostgreSQL survives a heavy pgbench -i bulk load on OSv
The storage / shared-buffer coherence wall under `pgbench -i` (parallel index
build / checkpoint large write) was a ramfs segment DATA BUFFER landing in one
forked backend's private COW address space:
ramfs_enlarge_data_buffer() grows a file's segment with malloc(). Once the
segment exceeds the fork arena's 2 MiB max_alloc it falls through to
malloc_large()'s size>=huge_page && !contiguous branch, which reserves an
APP-SLOT (VA < 0x400000000000) anonymous mmap in the CURRENT process's address
space. With per-child COW address spaces that buffer exists ONLY in the
backend that enlarged the file; a sibling backend read()ing/write()ing the
same ramfs file then memcpy's (uiomove) against an app-slot VA unmapped in its
own page tables:
page fault outside application, addr: 0x2000....
RIP: memcpy_repmov_ssse3 ; pread/pwrite -> vfs_file::read/write -> ramfs
i.e. the exact fault under `pgbench -i -s 5`. A kernel_heap_scope does not
help large allocations: it only skips the fork arena; malloc_large still uses
app-slot map_anon for huge non-contiguous requests.
Fix (fs/ramfs/ramfs_vnops.cc): allocate the segment data buffer as physically-
contiguous IDENTITY-mapped memory (memory::alloc_phys_contiguous_aligned, VA >=
0x400000000000, in the kernel PML4 slots clone_address_space() shares verbatim),
so every address space reaches the same file image. Plain free() dispatches by
address to free_large (identity path), so the existing free(data) sites are
correct from any address space.
Investigating the concurrent-writer path uncovered three more instances of the
same "kernel structure a cross-AS waker dereferences must be identity-mapped"
rule (all #if CONF_fork; non-fork byte-identical):
- libc/sem.cc: a pshared POSIX semaphore's posix_semaphore object (its _val,
_mtx and _waiters list) was heap-allocated on the creating app thread -> COW
arena. A waiter (sem_wait) blocked on its private _waiters and a poster
(sem_post) in another backend bumped a private _val -> lost wakeup, and
post_unlocked() GP-faulted dereferencing a stack wait_record cross-AS. Force
the object onto the identity kernel heap.
- core/semaphore.cc: the semaphore wait_record itself is queued on the shared
_waiters list and read by a cross-AS poster. Route it through the same
fork_child_needs_heap_wait_record() gate lockfree::mutex uses (identity heap
when a cross-AS wake is possible; on-stack fast path in AS0).
- include/osv/waitqueue.hh + core/waitqueue.cc: wait_object<waitqueue> queued a
stack wait_record on the shared _waiters_fifo (epoll's activity waiters, etc.)
that a cross-AS waker walks. Give it a coherent_wait_record, like condvar.
- fs/vfs/kern_descrip.cc: file::epoll_add allocated f_epolls (the per-file list
of watching epoll instances) on the registering backend's COW arena; the
RX/netisr thread walks so->fp->f_epolls cross-AS to wake watchers. Keep the
vector and its growth on the identity kernel heap.
Regression test tests/tst-fork-shared-write.cc (registered in
modules/tests/Makefile): a forked child ftruncate()s + writes a large pattern to
a ramfs file (forcing the >2 MiB segment buffer into its AS), then a SIBLING
forked child and the parent read it back. Reproduces the "page fault outside
application" memcpy abort on HEAD; passes with the fix.
Results (KVM -m 4G -smp 2, pg18-fork, ramfs /data):
pgbench -i -s 5 (heavy bulk load): completes -- 500k rows, vacuum, primary keys.
pgbench -c 1 -T 20 (sustained heavy TPC-B write): 41,042 txns, 2053 tps, 0 failed.
pgbench -S -c 4 (concurrent read): sustains ~45-50k tps for a bounded window.
All fork regression tests: 0 failures (tst-fork, -cow, -deep, -serial,
-arena-freelist, -preempt, -irq-cow, -conn-socket, -socket, -pgfork, -execve,
tst-fork-shared-write NEW). conf_fork=0 builds clean (CONF_fork==0).
HONEST STATUS: the storage/shared-buffer coherence wall this targeted is FIXED
(pgbench -i completes; sustained single-writer load completes). Sustained
CONCURRENT load still hits two distinct, precisely-symbolized cross-child-COW
walls that are NOT storage coherence: (1) a post-fork MAP_SHARED write-visibility
race (pre-existing tst-fork-posix-shm part 3, fails on clean HEAD, passes only
when gdb/instrumentation serializes the CPUs), and (2) a park_timers boost
intrusive-list double-insert assert in sched::cpu::reschedule_from_interrupt
during epoll_file::wait under concurrency. Both are in the #if CONF_fork
scheduler/timer/AS-clone machinery, the next investigation for a full HammerDB run.
PR cloudius-systems#1456 relevance: MAP_SHARED / cross-AS coherence across per-child COW address
spaces is core to it; these are five more instances of the shipped rule (struct
file, signal table, thread objects, arena free-list, DSM registry, mbufs, app
runtime, net_req): any kernel structure a forked child or a cross-AS waker reads
must live on the identity kernel heap, never the COW fork arena.
eb0647e to
b7fcad1
Compare
…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>
b7fcad1 to
e23e282
Compare
What
Follow-up to #1455 (base thread-backed
fork()). Gives a forked child its own address space with copy-on-write of private mappings, so a forked child gets real memory isolation like Linuxfork()— its private heap/globals/stack writes stay private, whileMAP_SHARED/shm stays shared between parent and child. Like the base PR, this is gated entirely behindCONFIG_fork(default off): with the flag off, none of the address-space/COW/context-switch code is compiled and OSv's single-address-space model and context-switch path are byte-for-byte unchanged.Stacked on #1455 — please review/merge that first.
How (with
CONFIG_forkenabled)mmu::address_spaceobject (page-table root + vma_list); the previous global becomes "address space 0" (kernel + init app).fork()clones the parent's vmas into a child address space: private writable mappings are write-protected in both and copied on 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, and the child address space is destroyed exactly once (guarded against the exec-path also tearing it down).wait_records for fork-child (non-AS0) threads are allocated from the AS-shared kernel heap rather than the thread stack, so await_recordqueued on a shared kernel mutex resolves to the same physical page for any waker across address spaces.Validation (x86-64 / KVM, both flag states)
CONFIG_forkoff (default): buildsEXIT=0,nm loader.elfshows zero COW/CR3 symbols (context switch has no address-space check compiled in), non-fork tests healthy (tst-mmappasses).CONFIG_forkon: buildsEXIT=0;tst-fork10/10;tst-fork-cow6/6 — the proof: a forked child's private global is unchanged in the parent after the child writes it (private stays private), and the child'sMAP_SHAREDwrite is visible in the parent (shared stays shared);tst-execve3/3; clean shutdown on every test.Known limitation (documented, non-blocking)
The child's stack is currently 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 fault —
tst-fork-deepfails by design here. The same-VA-stack fix (which requires addressing OSv's lack of a separate kernel stack) is a further follow-up. This PR delivers memory-isolatedfork()with COW proven for the common cases.Off by default, so no risk to existing OSv builds.