Skip to content

Commit 95a650c

Browse files
committed
mm/fork: copy-on-write 2 MB large pages on fork (opt-in)
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
1 parent cf3d77d commit 95a650c

1 file changed

Lines changed: 15 additions & 1 deletion

File tree

core/mmu.cc

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ static void clone_pt_level0(pt_element<0> *parent_pt, pt_element<0> *child_pt,
334334
// clone_pt_level<N> for N in 1..2, carrying base_virt so leaf pages can be
335335
// tested against the share-ranges. (gnu++14: no if constexpr, so explicit
336336
// specialization.)
337+
template<int N> void split_large_page(hw_ptep<N> ptep); // defined below
338+
template<> void split_large_page(hw_ptep<1> ptep); // defined below
337339
template<int N>
338340
static void clone_pt_level(pt_element<N> *parent_pt, pt_element<N> *child_pt,
339341
uintptr_t base_virt);
@@ -347,7 +349,19 @@ void clone_pt_level<1>(pt_element<1> *parent_pt, pt_element<1> *child_pt,
347349
for (unsigned i = 0; i < pte_per_page; i++) {
348350
pt_element<1> ppte = parent_pt[i];
349351
if (ppte.empty()) { child_pt[i] = make_empty_pte<1>(); continue; }
350-
if (ppte.large()) { child_pt[i] = ppte; continue; } // 2MB: share as-is
352+
if (ppte.large()) {
353+
// A read-only 2 MB page can never diverge, so share it verbatim.
354+
// A WRITABLE 2 MB large page (large malloc, MAP_SHARED, large anon)
355+
// must NOT be shared as-is -- that lets a forked child's writes
356+
// leak into the parent. Split it to 4 K in the PARENT in place,
357+
// then fall through to the normal 4 K clone path, which makes the
358+
// correct per-page decision (private-writable -> COW, MAP_SHARED ->
359+
// stays shared, read-only -> shared as-is). Reuses the whole 4 K
360+
// COW machinery; cost is one 4 K page table per split large page.
361+
if (!ppte.writable()) { child_pt[i] = ppte; continue; }
362+
split_large_page(hw_ptep<1>::force(&parent_pt[i]));
363+
ppte = parent_pt[i]; // re-read: now a non-large intermediate pte
364+
}
351365
void *child_sub = memory::alloc_page();
352366
memset(child_sub, 0, page_size);
353367
auto parent_sub = phys_cast<pt_element<0>>(ppte.next_pt_addr());

0 commit comments

Comments
 (0)