Skip to content

mm: per-child copy-on-write address space for fork() (opt-in, stacked on #1455) - #1456

Open
gburd wants to merge 6 commits into
cloudius-systems:masterfrom
gburd:wip/fork-stage2-pr
Open

mm: per-child copy-on-write address space for fork() (opt-in, stacked on #1455)#1456
gburd wants to merge 6 commits into
cloudius-systems:masterfrom
gburd:wip/fork-stage2-pr

Conversation

@gburd

@gburd gburd commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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 Linux fork() — its private heap/globals/stack writes stay private, while MAP_SHARED/shm stays shared between parent and child. Like the base PR, this is gated entirely behind CONFIG_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_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 and 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 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).
  • Kernel-stack coherence: lock/condvar wait_records for fork-child (non-AS0) threads are allocated from the AS-shared kernel heap rather than 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.

Validation (x86-64 / KVM, both flag states)

  • CONFIG_fork off (default): builds EXIT=0, nm loader.elf shows zero COW/CR3 symbols (context switch has no address-space check compiled in), non-fork tests healthy (tst-mmap passes).
  • CONFIG_fork on: builds EXIT=0; tst-fork 10/10; tst-fork-cow 6/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's MAP_SHARED write is visible in the parent (shared stays shared); tst-execve 3/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-deep fails 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-isolated fork() with COW proven for the common cases.

Off by default, so no risk to existing OSv builds.

@gburd

gburd commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

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): thread::switch_to() loaded the incoming thread's CR3 too early. OSv runs the scheduler on the app thread's stack; with same-VA stacks, a CHILD->PARENT switch loaded the parent's CR3 but then executed fnstcw/stmxcsr scratch saves while rsp still pointed at the child's (same-VA) stack, so those writes landed on the parent's physical stack page and clobbered its live state.

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).

gburd added a commit to gburd/osv-1 that referenced this pull request Jul 24, 2026
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
gburd added a commit to gburd/osv-1 that referenced this pull request Jul 25, 2026
…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.
@gburd

gburd commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Added a correctness fix to clone_address_space (commit on top of the stack): preserve file_vma type when cloning a child address space on fork.

Previously the child-AS rebuild recreated every parent VMA as an anon_vma. For an anonymous or COW-private mapping that's fine, but a file-backed mapping (file_vma, e.g. an ELF segment mapped by the loader via map_file) would become anonymous in the child. A demand fault on such a page in the child then dispatched to the base zero-fill path instead of file_vma::fault, so the child read zeros where file contents should be (for an executable text segment, it would execute zeros → wild branch). This doesn't bite a purely-anonymous fork workload, which is why the existing tests passed, but it's a latent correctness bug for any fork child that demand-faults a file-backed page it inherited.

Fix (entirely inside #if CONF_fork):

  • In the rebuild, dynamic_cast<file_vma*> each parent VMA; a file-backed one is recreated via fv->file()->mmap(range, flags, perm, offset) (the same call map_file/file_vma::split use, so the child gets the correct page allocator and its faults read the file); anonymous VMAs still become anon_vma.
  • destroy_address_space now disposes the child's owned VMA objects so ~file_vma releases the fileref the child took (no leak).

New test tst-fork-file-mmap: the child reads a page of a MAP_PRIVATE file mapping the parent hadn't faulted in — without the fix it reads 0x00 (fails), with it reads the real bytes. tst-fork, tst-fork-cow, tst-fork-deep, tst-execve remain 0 failures; conf_fork=0 is byte-identical (the whole change is under #if CONF_fork).

gburd added a commit to gburd/osv-1 that referenced this pull request Jul 31, 2026
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
gburd added a commit to gburd/osv-1 that referenced this pull request Jul 31, 2026
…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.
@gburd
gburd force-pushed the wip/fork-stage2-pr branch from eb0647e to b7fcad1 Compare July 31, 2026 13:16
gburd added 6 commits July 31, 2026 09:18
…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>
@gburd
gburd force-pushed the wip/fork-stage2-pr branch from b7fcad1 to e23e282 Compare July 31, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant