Skip to content

Commit cf3d77d

Browse files
committed
mm/fork: same-VA COW stack for deep call chains (opt-in)
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).
1 parent 39341d1 commit cf3d77d

8 files changed

Lines changed: 318 additions & 94 deletions

File tree

arch/aarch64/fork.cc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
extern "C" void __osv_run_atfork_child();
2323

2424
sched::thread *fork_thread(void *caller_ret, void *caller_sp,
25-
void **out_stack_to_free)
25+
void *resume_ctx, void **out_stack_to_free)
2626
{
27+
(void)resume_ctx; // aarch64 uses caller_sp bias-copy (same-VA is x86-64)
2728
auto parent = sched::thread::current();
2829
auto parent_pinned_cpu = parent->pinned() ? sched::cpu::current() : nullptr;
2930

arch/x64/arch-switch.hh

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,24 @@ void thread::switch_to()
9595
set_fsbase(reinterpret_cast<u64>(_tcb));
9696
barrier();
9797
#if CONF_fork
98-
// Address-space (CR3) switch for fork COW. Only touch CR3 when the target
98+
// Address-space (CR3) switch for fork COW. Only switch when the target
9999
// thread lives in a different address space than the outgoing one, so the
100-
// common single-address-space case pays nothing (and no TLB flush). The
101-
// kernel half of every AS is identically mapped, so the switch code, kernel
102-
// stacks and kernel heap remain valid across the write.
103-
if (_current_as != old->_current_as) {
104-
processor::write_cr3(mmu::pt_root_phys(_current_as));
105-
}
100+
// common single-address-space case pays nothing (and no TLB flush).
101+
//
102+
// CRITICAL (same-VA fork stacks): the CR3 write must happen EXACTLY at the
103+
// rsp/rbp swap below, NOT here. OSv runs kernel code (this switch, the
104+
// fpu-cw/mxcsr scratch saves, the register-save asm) on the app thread's
105+
// stack. A forked child resumes on the parent's EXACT stack VAs, so parent
106+
// and child alias the same stack virtual addresses (backed by different
107+
// physical pages per address space). If we loaded the incoming thread's
108+
// CR3 here, the subsequent scratch writes to the OUTGOING thread's stack
109+
// (fnstcw/stmxcsr at -0x34(%rbp), etc., with rsp/rbp still the old thread's)
110+
// would resolve through the WRONG address space and clobber the incoming
111+
// thread's physical page at that shared VA -- corrupting a blocked thread's
112+
// live stack. So we defer the CR3 load into the asm, coincident with the
113+
// rsp/rbp swap: old-stack writes use the old AS, new-stack writes the new.
114+
bool switch_as = (_current_as != old->_current_as);
115+
u64 new_cr3 = switch_as ? mmu::pt_root_phys(_current_as) : 0;
106116
#endif
107117
auto c = _detached_state->_cpu;
108118
old->_state.exception_stack = c->arch.get_exception_stack();
@@ -119,6 +129,34 @@ void thread::switch_to()
119129
c->arch._current_thread_kernel_tcb = reinterpret_cast<u64>(_tcb);
120130
auto fpucw = processor::fnstcw();
121131
auto mxcsr = processor::stmxcsr();
132+
#if CONF_fork
133+
if (switch_as) {
134+
// Same-VA-safe switch: swap rsp/rbp to the incoming thread, THEN load
135+
// its CR3 (mov %rax,%cr3), THEN jump. All old-stack writes above ran
136+
// under the old AS; every access after the rsp/rbp swap is to the new
137+
// thread's stack, now resolved through the new AS -- so a shared stack
138+
// VA never resolves through the wrong address space. Kernel .text and
139+
// the thread_state (heap) are identically mapped in every AS, so the
140+
// instruction fetch across the mov-to-cr3 and the %c[rip] load are
141+
// valid before and after the CR3 write.
142+
asm volatile
143+
("mov %%rbp, %c[rbp](%0) \n\t"
144+
"movq $1f, %c[rip](%0) \n\t"
145+
"mov %%rsp, %c[rsp](%0) \n\t"
146+
"mov %c[rsp](%1), %%rsp \n\t"
147+
"mov %c[rbp](%1), %%rbp \n\t"
148+
"mov %2, %%cr3 \n\t"
149+
"jmpq *%c[rip](%1) \n\t"
150+
"1: \n\t"
151+
:
152+
: "a"(&old->_state), "c"(&this->_state), "d"(new_cr3),
153+
[rsp]"i"(offsetof(thread_state, rsp)),
154+
[rbp]"i"(offsetof(thread_state, rbp)),
155+
[rip]"i"(offsetof(thread_state, rip))
156+
: "rbx", "rsi", "rdi", "r8", "r9",
157+
"r10", "r11", "r12", "r13", "r14", "r15", "memory");
158+
} else
159+
#endif
122160
asm volatile
123161
("mov %%rbp, %c[rbp](%0) \n\t"
124162
"movq $1f, %c[rip](%0) \n\t"

arch/x64/fork.cc

Lines changed: 64 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,26 @@
44
* This work is open source software, licensed under the terms of the
55
* BSD license as described in the LICENSE file in the top-level directory.
66
*
7-
* fork_thread(): create a child thread that resumes in fork()'s CALLER, on a
8-
* private copy of the parent's user stack, returning 0 from fork() in the child.
9-
* The x86-64 arch half of the fork() emulation (see documentation/fork.md).
7+
* fork_thread(): create a child thread that resumes in fork()'s CALLER on the
8+
* SAME user stack virtual addresses the parent had -- no stack relocation, no
9+
* SP bias -- and with the caller's FULL callee-saved register context restored,
10+
* exactly as a normal `ret` from fork() would leave it. The child's address
11+
* space (built by clone_address_space) maps the parent's live stack VA range to
12+
* freshly-allocated PRIVATE physical pages that byte-copy the parent's stack,
13+
* so the child sees identical stack contents at identical addresses but writes
14+
* to its own private frames.
1015
*
11-
* fork() (libc/process/fork.cc) passes us the caller's resume point:
12-
* caller_ret = the address fork() would return to (__builtin_return_address)
13-
* caller_sp = the parent's SP at fork()'s return (fork()'s frame base)
14-
* We copy the parent stack region [caller_sp .. stack_base) into a fresh stack,
15-
* bias caller_sp into the copy, and start a child thread whose trampoline sets
16-
* rsp=child_sp, rax=0, and jumps to caller_ret -- i.e. the child returns from
17-
* fork() with value 0 on its own private stack, in the caller.
16+
* Why same-VA + full register restore: OSv/x86-64 code addresses saved frame
17+
* pointers (rbp), return addresses, and &local pointers as absolute stack VAs,
18+
* and keeps live values in callee-saved registers (rbx, r12-r15) across calls.
19+
* Relocating the child stack to a different VA and biasing rsp leaves every
20+
* saved rbp/&local pointing at the PARENT's stack (off by the bias), and simply
21+
* jmp-ing to the return address skips fork()'s epilogue so the caller resumes
22+
* with fork()'s internal register values. Either corrupts a deep unwind.
23+
* Keeping the child on the parent's exact VAs (private phys) AND restoring the
24+
* full caller register context (osv::fork_resume_ctx, captured at fork() entry)
25+
* makes the child continue in fork()'s caller as if fork() had returned 0 -- the
26+
* only correct way to fork a deep stack (see tst-fork-deep, documentation/fork.md).
1827
*/
1928

2029
#include "arch.hh"
@@ -23,13 +32,16 @@
2332
#include <string.h>
2433
#include <cstdlib>
2534
#include <osv/sched.hh>
35+
#include <osv/fork.hh>
2636

2737
// pthread_atfork child-handler chain (defined in libc/pthread.cc), run in the
2838
// child's context before it resumes user code.
2939
extern "C" void __osv_run_atfork_child();
3040

31-
sched::thread *fork_thread(void *caller_ret, void *caller_sp, void **out_stack_to_free)
41+
sched::thread *fork_thread(void *caller_ret, void *caller_sp,
42+
void *resume_ctx, void **out_stack_to_free)
3243
{
44+
auto ctx = static_cast<osv::fork_resume_ctx*>(resume_ctx);
3345
auto parent = sched::thread::current();
3446
auto parent_pinned_cpu = parent->pinned() ? sched::cpu::current() : nullptr;
3547

@@ -39,57 +51,56 @@ sched::thread *fork_thread(void *caller_ret, void *caller_sp, void **out_stack_t
3951
if (sp < static_cast<char*>(si.begin) || sp > stack_base) {
4052
return nullptr; // caller SP not within the known user stack
4153
}
42-
size_t stack_size = si.size;
4354

44-
char *child_stack_mem = static_cast<char*>(malloc(stack_size));
45-
if (!child_stack_mem) {
46-
return nullptr;
47-
}
48-
// Copy ONLY the live top of the stack, [caller_sp .. stack_base), into the
49-
// TOP of the child buffer. App (pthread) stacks are demand-paged: only the
50-
// used top is mapped, so copying from si.begin would fault on the first
51-
// unmapped page. Keeping the copy at the top of the child buffer preserves
52-
// the base-relative bias so a biased SP resolves correctly.
53-
char *child_base = child_stack_mem + stack_size;
54-
ptrdiff_t bias = child_base - stack_base;
55-
size_t live = static_cast<size_t>(stack_base - sp);
56-
memcpy(child_base - live, sp, live);
57-
char *child_sp = sp + bias;
58-
59-
volatile u64 resume_sp = reinterpret_cast<u64>(child_sp);
60-
volatile u64 resume_pc = reinterpret_cast<u64>(caller_ret);
61-
char *stack_to_free = child_stack_mem;
55+
// Same-VA stack: the child resumes on the parent's EXACT register context.
56+
// No copy and no bias here -- clone_address_space() privatizes the parent's
57+
// live stack VA range into the child's address space (fresh private pages
58+
// that byte-copy the parent's stack), so these very addresses are valid and
59+
// private in the child. Capture the resume context by value in the lambda
60+
// (the parent's on-stack ctx is gone by the time the child runs).
61+
(void)caller_ret;
62+
osv::fork_resume_ctx rc = *ctx;
6263

6364
// TLS handling. The child is a real OSv sched::thread, so its constructor
64-
// already ran setup_tcb() and installed a FRESH, private OSv TLS block
65-
// (with its own errno and all libc __thread state). Two cases:
66-
//
67-
// (1) The app uses OSv's libc TLS (the normal dynamically-linked path,
68-
// app_tcb == 0): the child's own fresh TCB is exactly right -- do NOT
69-
// touch fsbase, let the child run on its private per-thread TLS. This
70-
// is the clean case and fork() "just works" for TLS.
71-
// (2) The app installed its own TCB via arch_prctl(SET_FS) (app_tcb != 0,
72-
// e.g. a glibc binary's __libc_setup_tls): the child would need a
73-
// private COPY of that app TCB. We do not duplicate it here yet;
74-
// the child inherits the parent's app_tcb (shared), which is the
75-
// documented multi-process-glibc limitation. A musl app built against
76-
// OSv's libc takes path (1) and avoids this entirely.
65+
// already ran setup_tcb() and installed a FRESH, private OSv TLS block. If
66+
// the parent installed its own app TCB via arch_prctl (app_tcb != 0), the
67+
// child inherits it (shared) -- the documented multi-process-glibc limit;
68+
// otherwise the child keeps its own private OSv per-thread TLS.
7769
u64 parent_app_tcb = parent->get_app_tcb();
7870

79-
auto t = sched::thread::make([resume_sp, resume_pc, parent_app_tcb] {
80-
// Only override the child's own (fresh) TLS if the parent had installed
81-
// an app TCB via arch_prctl; otherwise keep the child's private OSv TCB.
71+
auto t = sched::thread::make([rc, parent_app_tcb] {
8272
if (parent_app_tcb) {
8373
arch::set_fsbase(parent_app_tcb);
8474
}
8575
// Run pthread_atfork child handlers in the child's context (e.g. reset
8676
// the malloc arena lock) before resuming user code.
8777
__osv_run_atfork_child();
78+
// Restore the caller's full callee-saved register context and resume in
79+
// fork()'s caller with return value 0, on the parent's exact stack VAs
80+
// (private phys in the child AS). We base all loads off a scratch
81+
// register (rax) pointing at a LOCAL copy of the context, and restore
82+
// the base-conflicting registers (rbx, rbp) LAST, so the load sequence
83+
// never clobbers its own base pointer.
84+
osv::fork_resume_ctx c = rc; // local copy the asm can address stably
85+
// %0 = &c pinned in a register that is NOT one of the restored regs
86+
// (we let the compiler pick, but we consume it only to seed rax). We
87+
// move &c into rax first, then load every callee-saved reg from rax-
88+
// relative offsets, and load rsp last -- rax (the base) is never in the
89+
// restore set, so the sequence never clobbers its own base pointer.
90+
// Offsets match struct fork_resume_ctx { rbx,rbp,r12,r13,r14,r15,rsp,rip }.
8891
asm volatile
89-
("movq %0, %%rsp \n\t" // install the private copied stack
90-
"xorq %%rax, %%rax \n\t" // fork() returns 0 in the child
91-
"jmpq *%1 \n\t" // resume in fork()'s caller
92-
: : "r"(resume_sp), "r"(resume_pc) : "memory");
92+
("movq %0, %%rax \n\t" // rax = &c (base; not restored)
93+
"movq 0(%%rax), %%rbx \n\t" // rbx
94+
"movq 8(%%rax), %%rbp \n\t" // rbp
95+
"movq 16(%%rax), %%r12 \n\t" // r12
96+
"movq 24(%%rax), %%r13 \n\t" // r13
97+
"movq 32(%%rax), %%r14 \n\t" // r14
98+
"movq 40(%%rax), %%r15 \n\t" // r15
99+
"movq 56(%%rax), %%rcx \n\t" // rcx = caller rip (scratch)
100+
"movq 48(%%rax), %%rsp \n\t" // adopt parent's exact rsp
101+
"xorq %%rax, %%rax \n\t" // fork() returns 0 in the child
102+
"jmpq *%%rcx \n\t" // resume in fork()'s caller
103+
: : "r"(&c) : "rax", "rcx", "memory");
93104
}, sched::thread::attr().
94105
stack(4096 * 4).
95106
// Detached: nobody join()s the fork child (the parent reaps it via the
@@ -106,10 +117,10 @@ sched::thread *fork_thread(void *caller_ret, void *caller_sp, void **out_stack_t
106117
if (parent_pinned_cpu) {
107118
t->pin(parent_pinned_cpu);
108119
}
109-
// The caller (fork.cc) owns the single cleanup; hand back the copied user
110-
// stack so it can be freed when the child is reaped.
120+
// Same-VA: no separate user-stack buffer to free (the child's stack pages
121+
// are owned by its address space and freed on destroy_address_space()).
111122
if (out_stack_to_free) {
112-
*out_stack_to_free = stack_to_free;
123+
*out_stack_to_free = nullptr;
113124
}
114125
return t;
115126
}

core/lfmutex.cc

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,27 @@
1616
#endif
1717

1818
#if CONF_fork
19-
// See wait_record.hh: true iff the current thread runs in a forked-child
20-
// (non-AS0) address space, in which case a wait_record queued on a shared
21-
// kernel object must be heap-allocated to stay coherent cross-address-space.
19+
namespace mmu {
20+
// Live fork-child address-space count (defined in core/mmu.cc). When > 0,
21+
// more than one address space exists and a stack-resident wait_record on a
22+
// shared kernel object can be dereferenced cross-AS.
23+
extern std::atomic<int> live_child_address_spaces;
24+
}
25+
26+
// See wait_record.hh: with per-child same-VA private stacks, a wait_record
27+
// queued on a SHARED kernel condvar/mutex must live in the identity-mapped
28+
// kernel heap (same VA->phys in every address space) whenever a cross-address-
29+
// space wake is possible. That is true when EITHER the current thread runs in
30+
// a forked-child (non-AS0) address space (a child woken by the parent), OR any
31+
// child address space is currently live (the parent, in AS0, woken by a child
32+
// -- the child dereferences the parent's stack-resident wait_record through
33+
// its own page tables and would read a stale private stack copy). Default OSv
34+
// (no fork, single AS) always takes the zero-overhead on-stack fast path.
2235
bool fork_child_needs_heap_wait_record()
2336
{
37+
if (mmu::live_child_address_spaces.load(std::memory_order_relaxed) > 0) {
38+
return true;
39+
}
2440
auto t = sched::thread::current();
2541
return t && t->address_space() &&
2642
t->address_space() != mmu::kernel_address_space();

0 commit comments

Comments
 (0)