Skip to content

munmap and a FIXED mmap take only the length the mapping was asked for - #765

Merged
Japabu merged 4 commits into
mainfrom
wt/toyos-munmapsize
Oct 8, 2026
Merged

Japabu merged 4 commits into
mainfrom
wt/toyos-munmapsize

Conversation

@Japabu

@Japabu Japabu commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes issues/munmap-ignores-the-size-it-is-passed.md. Head 3b942f1aa: a5c566a72 (merged with origin/main 1084ddc9a) plus one commit that touches a C test, its .expect and two issue files. git diff --quiet a5c566a72 3b942f1aa -- kernel/ toyos-abi/ exits 0, and so does the same over userland/ toyos/ tests/toyos-rust-tests/: no kernel, ABI, libc, SDK or Rust guest program moved between the two heads. origin/main has moved since (#756, #761, #763) and is not merged in again; the merge queue tests the merged tree.

What changed, per decision

The kernel records the length each mmap was asked for, and SYS_MUNMAP takes only that length. sys_munmap(addr, _size) freed the mapping at addr whatever size came with it. The first round compared the size after rounding it to a 2 MiB span, which review round 1 showed keeps the loss for any partial length inside one span: through libc, mmap(NULL, 8192, …) then munmap(p, 4096) answered 0 and took the page the caller kept. Now MmapRegion.len holds the size word mmap was passed, and sys_munmap compares it with the size word it is passed, as given, with no arithmetic on either.

The edges of "the same length", at the kernel:

  • the exact length mapped: unmapped, 0;
  • any other length at a mapping's start — shorter, longer, zero, usize::MAX, and a length that only rounds to the same pages (8191 or 4096 of 8192; 2 MiB + 1 of 4 MiB): InvalidArgument, nothing unmapped;
  • an address that starts no mapping: NotFound, whatever the length.

No rounding decides which lengths name a mapping, so the accepted set does not move when stages 3 and 4 of the 2 MiB track change the page size.

POSIX counts in pages, and that is libc's. userland/libc's mmap asks the kernel for memreq::whole_pages(len) (the length rounded up to the 4096-byte page sysconf(_SC_PAGESIZE) answers) and munmap names the mapping by the same function of its own len. So a len that ends anywhere in the mapping's last page is the whole mapping, as POSIX has it, and one that ends short of that page names a part and is refused. Why this split and not page-rounding in the kernel: the kernel would need the page size libc tells its callers, and its accepted set would then depend on it. What callers pass: every in-tree caller of toyos_abi::syscall::munmap passes the length it mapped (read call by call, list in the round-1 comment); memmap2 on other Unixes passes the length it mapped, not a rounded one, and on ToyOS maps nothing (toyos.rs is a heap buffer); a C program that maps len and unmaps a page-rounded len, or the reverse, is correct POSIX and is accepted by the libc rule. That last claim is now measured: see the 5000-byte case below.

The FIXED arm of mmap had the same loss by another road, and takes the same rule (the orchestrator's direction mid-round). It replaced a mapping of "the same 2 MiB-rounded size", so a 4096-byte MAP_FIXED request at the address of an 8192-byte mapping replaced both pages with zeros and answered the address. It now replaces a mapping only when the length asked for is MmapRegion.len; any other is InvalidArgument, nothing replaced. Every FIXED replacement in the tree (abuse_mmap_regions at the region bound, mmap_stress, tlb_shootdown_waits) asks for the length of what it replaces.

libc's munmap answers every refusal with EINVAL, the only error POSIX defines for it.

Why not the other two designs (unchanged from round 0, and held by the review): partial unmap needs vma::Region and its PageAlloc to split, and the 2 MiB track's stage 4 rules the refusal instead; removing the argument leaves libc unable to refuse a partial len without a ledger of its own.

Known gap against POSIX, filed

issues/libc-munmap-refuses-part-of-a-mapping.md, in two halves:

  • munmap: POSIX unmaps any whole pages of a mapping; libc's unmaps one whole mapping or nothing. A prefix, suffix, interior range, a range over two mappings and a range holding no mapping are all -1/EINVAL with nothing unmapped.
  • mmap with MAP_FIXED (a paragraph new at this head, review round 2's NOTE): POSIX replaces whatever pages a fixed request overlaps; libc's places one over free pages or over one whole mapping by its page count, and over some of a mapping's pages answers MAP_FAILED/ENOMEM with nothing replaced. 206_libc_refusals.expect:41 pins that answer, and the issue's exit now reads that line as well as the munmap one.

After this change nothing a caller kept is taken; what remains is the refusal.

Stage 4, where the body and the issue disagreed. Round 2 found this body saying the track's stage 4 rules partial unmap out, and the issue's Owner (with a sentence this branch had added to the track's stage 4 line) saying stage 4 is where a region that splits would be built. The track's text decides it: stage 4 on main reads "munmap refuses a size that is not the whole mapping, by the orchestrator's ruling", and no stage of the track names a region that splits. The issue and the added sentence were wrong. At this head the issue's Owner says the orchestrator ruled the refusal in stage 4 and that no stage of the track builds a region that splits, and the track's stage 4 line says the refusal is in and names the issue as what it costs a C program, owing nothing more.

Callers outside the tree: what was searched and what it establishes

  • Fork clones of crates and ~/.cargo/git/checkouts: files naming munmap are memmap2's unix.rs and advice.rs, stacker's mmap_stack_restore_guard.rs and softbuffer's x11.rs. None is on a ToyOS path: memmap2 routes ToyOS to toyos.rs, stacker groups ToyOS with the targets that map nothing, and the X11 backend is not built.
  • rust/library: std/src/sys/pal/unix/stack_overflow.rs and backtrace's mmap_unix.rs, neither compiled for ToyOS.
  • The LLVM fork: the clone's working tree is a sparse checkout (clang, libcxx, libcxxabi), so a working-tree search of it sees none of the rest; the first round's search and the reviewer's were of that. git grep munmap at the pinned commit ceaf0fbb8 finds no file under libcxx, libcxxabi or libunwind, the three runtimes ToyOS builds and links into C++ programs. It finds three files under llvm/lib/Support: Unix/Memory.inc and Unix/Path.inc, each passing the length it mapped (read), and rpmalloc/rpmalloc.c, not read. Support builds for ToyOS hosts at that commit; nothing was run to show which of these a ToyOS program reaches.
  • dlmalloc 0.2.13 under std and libc: one release path is gated by can_release_part (release_unused_segments, false here); the other free is for a chunk flagged mmapped, which the crate never makes. Neither runs.

Checks (high-risk: memory management, the ABI)

Host. tests/libc-arch/src/memory_refusals.rs::a_length_names_the_pages_it_reaches_into holds whole_pages: zero, the page boundaries, the top of usize, and which lengths name an 8192-byte mapping. The kernel's decision is two word equalities; no host test re-tests ==. The host test calls whole_pages and never mmap, so it cannot see whether mmap uses it; the 5000-byte case is what does.

Running kernel, native. mmap_stress maps two 2 MiB spans and unmaps: the first span, one byte, one byte past, one byte short, the first span and a byte, zero, usize::MAX — each must be InvalidArgument, with both spans read back before the answer is judged. Then 8192 mapped: munmap of 4096 refused and the second page's byte read back; a FIXED request for 4096 refused and the byte read back; a FIXED request for 8192 replaces.

Running kernel, through libc. tests/testcases/tinycc/206_libc_refusals.c maps two 4096-byte pages and prints the answer, errno and both pages for: a fixed mapping of the first page; munmap of the first page, the second page, no bytes, a byte past both; then munmap by a length into the second page (0), and again (EINVAL). New at this head, for review round 2's second BLOCKER: it maps 5000 bytes and unmaps them by 5000, maps 5000 again and unmaps by 8192. Read under QEMU at 3b942f1aa (green.log):

mmap the first of two pages fixed: -1, ENOMEM
pages after it: f s
munmap the first of two pages: -1, EINVAL
pages after it: f s
munmap the second of two pages: -1, EINVAL
munmap no bytes: -1, EINVAL
munmap a byte past two pages: -1, EINVAL
pages after them: f s
munmap two pages by a length into the second: 0, no errno
munmap them again: -1, EINVAL
munmap 5000 bytes by 5000: 0, no errno
munmap 5000 bytes by 8192: 0, no errno

Neither program is a QEMU guest test and this branch makes neither one: a host test reaches whole_pages, and their verdict of record is the T14's. No guest test is added or changed; 206_libc_refusals is changed, not added, so tests/testcases/LICENSE's count does not move.

Negative controls and the mutation, taken under a throwaway QEMU registration. An uncommitted patch ran both programs on one x86-64 QEMU boot of tests/testcases, inside the test harness, and judged them by the lines above under nested_nmi_is_loud's name; it was reverted each time and the tree left clean. It is a throwaway, not a guest test: measure.patch at a5c566a72, and measure-r2.patch at 3b942f1aa, which is the same with the two 5000-byte lines added to what it requires. All patches are in the pull request's comments.

arm head command exit deciding lines
green 3b942f1aa cargo test --test toyos-build -- nested_nmi_is_loud --nocapture 0 the twelve lines above; ===TEST_END test_c_206_libc_refusals exit=0===, ===TEST_END test_rs_mmap_stress exit=0===
mutation-libc-mmap.patch (libc's mmap passes len to the kernel unrounded; review round 2's mutation) 3b942f1aa same 1 munmap 5000 bytes by 5000: -1, EINVAL, munmap 5000 bytes by 8192: -1, EINVAL; [throwaway] RED: 206_libc_refusals did not say "munmap 5000 bytes by 5000: 0, no errno" and the same for by 8192; mmap_stress exit=0
green a5c566a72 same 0 the first ten lines above; both TEST_END … exit=0
control-munmap.patch (sys_munmap rounds) a5c566a72 same 1 206_libc_refusals: SEGFAULT tid=0: read unmapped address at 0xfffe800000, exit=-1; mmap_stress: SEGFAULT … at 0xfffea00000, exit=-1
control-fixed.patch (the FIXED arm rounds) a5c566a72 same 1 mmap_stress: panicked at src/bin/mmap_stress.rs:101:5 … a FIXED request for the first 4096 bytes of 8192 replaced the second, left: 0 right: 119, exit=101; 206_libc_refusals: SEGFAULT … at 0xfffe800000, exit=-1

The two kernel controls were not run again at 3b942f1aa. They stand on the empty diff this body opens with: each reverts one compare in kernel/src/syscall/vm.rs, which is byte-identical at both heads, as are libc and mmap_stress; the commit between adds two calls at the end of the C program's munmap block, after every line the controls red on.

Under the mutation the guest runner's own TEST_END for the C program is exit=0: the throwaway boot does not judge against the .expect, and the program returns 0 whatever it printed, so the red there is the throwaway's line check. On the T14's C corpus boot the program is judged in the guest against the committed .expect, which holds both lines.

In both kernel control arms the C program's buffered output died with it, so its log shows the fault and not the line. Under control-munmap the fault is the read of the first page after munmap(pair, 4096). Under control-fixed it is inferred, not printed: the replacement leaves a 4096-byte mapping, which the next munmap(pair, 4096) then names exactly and takes.

Oracle. POSIX, for libc: which lengths name a whole mapping (pages containing the range), and EINVAL as munmap's error. For partial unmap and for a fixed mapping over part of one, POSIX says the opposite of what this does; that is the filed gap.

Gates

gate head exit
cargo metadata --locked --format-version 1 3b942f1aa 0
cargo test -p toyos-libc-copies 3b942f1aa 0
cargo run -- --build-only 3b942f1aa 0
two T14 stagings (--metal --metal-readback <dir> <filter>: 206_libc_refusals, munmap_reissues) 3b942f1aa 2 each: staged, touched no machine
cargo test --test toyos-build -- virt_unmap_touch a5c566a72 0
cargo test --test toyos-build -- virt_readonly_copyout a5c566a72 0
cargo test --test toyos-build -- virt_smp a5c566a72 0
cargo run -- --ci host CI run 37772453125 at 3b942f1aa: host success (job 113295620913), [ci] Host: 78 step(s), all green
whole guest suite (guest / suite, after toolchain / build) the same run: toolchain / build success (113295621156), guest / suite success (113296851511): PASS virt_unmap_touch, PASS virt_readonly_copyout, test result: ok. 32 passed, 32 total. It runs declared tests only, so it reaches sys_munmap through those and the SMP rows and executes none of the new assertions, which are the T14's

The three guest tests were not run again at 3b942f1aa: nothing under kernel/, toyos-abi/, userland/, toyos/ or tests/toyos-rust-tests/ moved (the empty diffs above).

The T14

Read at a5c566a72 (the orchestrator's run, PR comment 6058950679; seven images staged from the clean tree at that head, every boot exit 0, every judge exit 0):

section (filter) boots, image sha256 judge
mmap shared, cacaefe0…259c884d 4 passed: mmap_stress, mmap_prot, std_mmap, memmap_exec_refused
abuse_ shared, a3230871…e241ad8d; shared-debug, 2b3a35da…104e131c 19 passed over 2 boots
futex_wake_counts shared, ca98c21e…7950ee7c 1 passed
process_bound_regions testcases-bounds, e8d5b63a…85677115 1 passed (test_rs_abuse_mmap_regions exit 0)
tlb_shootdown_waits testcases-debug, 940d4d3b…9f3e8918 1 passed
206_libc_refusals ccorpus, 3a9499d5…8f38bff03 1 passed (===TEST_END 206_libc_refusals exit=0===)

Twenty-seven rows over seven boots, none failed; 29 ===TEST_END lines in the readbacks, each exit=0.

The first five sections' readings carry to 3b942f1aa and are not taken again: every program they boot, the kernel and the ABI are byte-identical at both heads (the two empty diffs). The sixth does not carry: its program changed.

A correction to what this body said before. It listed munmap_reissues_read_window and munmap_reissues_second_read_window in the mmap section. They were never in it: the filter mmap does not contain munmap_reissues (the section's stage log reads 0 registration(s) and 4 shared member(s)), so the two were booted at no head, on no machine and under no QEMU. Both call munmap, the second through a 4 MiB reservation released and two FIXED placements over it. The lengths they pass were read; that is not a measurement.

Read at 3b942f1aa by the orchestrator (comment 6059243360): the C corpus boot, 206_libc_refusals exit 0; munmap_reissues, both members exit 0:

section (filter) boot, image sha256 rows read
206_libc_refusals ccorpus, 22044348430d0862b91da4547e65a1f15fb7e59898a4c950fa63f09ca28567fd 206_libc_refusals, judged in the guest against the .expect with the two 5000-byte lines
munmap_reissues shared, 21daf70ec210ebceb0d58f305016f8861b370e6b6d640b150a2242ff1d883307 test_rs_munmap_reissues_read_window, test_rs_munmap_reissues_second_read_window (stage log: 0 registration(s) and 2 shared member(s) over 1 boot(s))

With those two, the sections hold every program in the tree that calls munmap or a FIXED mmap. Not shared whole: the suite takes one substring filter, and none selects a whole shared boot without the whole metal table. Members of shared that call neither are not booted at either head.

Unsure of

  • The T14 and CI have both answered at this head (above); the merge queue runs host and guest / suite again on the merged tree.
  • rpmalloc.c in LLVM Support was not read, and no LLVM tool was run on ToyOS.
  • A C program that trims a mapping, or maps fixed over part of one, now fails loudly with EINVAL or ENOMEM. None is in the tree, read across userland/ and the C corpus.

Net: 12 files, +271 −66. Production +73 −23 (kernel/src/syscall/vm.rs +35 −15, kernel/src/process.rs +2, toyos-abi/src/syscall.rs +8 −3, userland/libc/src/posix_io.rs +18 −5, userland/libc/src/memreq.rs +10). Tests +128 −1. Issues +70 −42.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A

`sys_munmap(addr, _size)` freed the mapping starting at `addr` whatever
size it was passed: a caller unmapping a prefix was answered 0 and lost
the part it kept, and a size past the mapping was answered 0 too.

The size is now made a `PageSpan` by `vma::window().span`, the rounding
`sys_mmap` gives its request, before any lock, and is refused with
`InvalidArgument` unless it is the mapping's recorded size. So the
length a caller mapped still unmaps it (4096 mapped, 4096 unmapped), a
prefix, an overrun, zero and a length no window holds are refused having
unmapped nothing, and an address that starts no mapping stays `NotFound`.
Partial unmap is not given semantics: a mapping is one region and one
`PageAlloc`, and the 2 MiB track's stage 4 ruled the refusal.

libc's `munmap` answers every refusal with `EINVAL`, the one error POSIX
defines for it; `NotFound` used to reach a C caller as `ENOENT`.

No caller in the tree passed a size other than the one it mapped, and
dlmalloc under std and libc never unmaps (`can_release_part` is false).

`mmap_stress` reads each refusal and then both spans of the mapping back.

Closes issues/munmap-ignores-the-size-it-is-passed.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Control patches for the QEMU measurement in the body, both against 59a3b0735. Neither is in the branch.

measure.patch — the throwaway registration (applied for both arms, reverted after):

diff --git a/tests/toyos.rs b/tests/toyos.rs
index 996ed86de..fa3fc0bd0 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -1666,7 +1666,7 @@ fn virt_job(profile: qemu::Profile, job: &str, said: &str) -> Result<(), String>
             smp: 1,
             kernel_features: toyos_build::build::TEST_KERNEL,
             ready_marker: "control registers: SCTLR_EL1=",
-            extra_root_files: vec![suite_bin(profile.arch(), VIRT_COPYOUT), suite_bin(profile.arch(), VIRT_RING0_TIMER)],
+            extra_root_files: vec![suite_bin(profile.arch(), VIRT_COPYOUT), suite_bin(profile.arch(), VIRT_RING0_TIMER), suite_bin(profile.arch(), "mmap_stress")],
             ..Default::default()
         },
     );
@@ -2407,7 +2407,7 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re
         "virt_timer_preempts" => virt_job(profile, "preempt", "preempt: the counting thread was preempted twice"),
         "virt_fp_isolation" => virt_job(profile, "fp_isolation", "fp_isolation: v0-v31, FPCR and FPSR survived"),
         "virt_first_entry" => virt_job(profile, "first_entry", "first_entry: x1-x30 were zero"),
-        "virt_unmap_touch" => virt_job(profile, "unmap_touch", UNMAP_TOUCH_SAID),
+        "virt_unmap_touch" => virt_job(profile, "test_rs_mmap_stress", "all mmap stress tests passed"),
         "virt_debug_refused" => virt_job(
             profile,
             "debug_refused",
diff --git a/tests/virtjobcase/system.toml b/tests/virtjobcase/system.toml
index bc852a436..6c45482ac 100644
--- a/tests/virtjobcase/system.toml
+++ b/tests/virtjobcase/system.toml
@@ -14,7 +14,7 @@ syscap = ["logread"]
 receives = ["power"]
 # Four minutes from boot rather than one: every job here runs on an emulated
 # CPU, and a job past the bound reboots the guest with the job named.
-args = ["--bound-ms=240000", "preempt", "fp_isolation", "first_entry", "unmap_touch", "debug_refused", "test_rs_ring0_timer_in_syscall", "test_rs_abuse_readonly_copyout"]
+args = ["--bound-ms=240000", "test_rs_mmap_stress", "preempt", "fp_isolation", "first_entry", "unmap_touch", "debug_refused", "test_rs_ring0_timer_in_syscall", "test_rs_abuse_readonly_copyout"]
 
 [programs.kernelprobe]
 

fix.patch — the whole change less its test and the issue deletion; applied with git apply -R for the control arm (exit 1), re-applied after:

diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index 729f0af9e..63e30f7e7 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -175,26 +175,39 @@ pub(super) fn sys_mmap(req_addr: u64, size: u64, prot: MmapProt, flags: MmapFlag
     }
 }
 
-/// Frees an anonymous mapping and shoots down every sibling thread's
-/// translation for its range.
-pub(super) fn sys_munmap(addr: u64, _size: u64) -> u64 {
+/// Frees the anonymous mapping that starts at `addr`, whole, and shoots down
+/// every sibling thread's translation for its range. `size` names it as the
+/// `mmap` that made it did: one that does not round to the mapping's recorded
+/// span is `InvalidArgument` and unmaps nothing, a mapping being one region
+/// with no part to take away.
+pub(super) fn sys_munmap(addr: u64, size: u64) -> u64 {
+    let Some(span) = crate::vma::window().span(size) else {
+        return SyscallError::InvalidArgument.to_u64();
+    };
     let pt = process::current_address_space();
     let taken = process::with_process_data(|data| {
-        let idx = data.mmap_regions.iter().position(|r| r.addr.raw() == addr)?;
+        let Some(idx) = data.mmap_regions.iter().position(|r| r.addr.raw() == addr) else {
+            return Err(SyscallError::NotFound);
+        };
+        if data.mmap_regions[idx].size as u64 != span.bytes() {
+            return Err(SyscallError::InvalidArgument);
+        }
         let region = data.mmap_regions.swap_remove(idx);
         data.free_count += 1;
         pt.lock()
             .free_and_unmap(region.addr)
             .expect("an mmap region is registered in the address space it was placed in");
-        Some(crate::mm::Unmapped::new(region))
+        Ok(crate::mm::Unmapped::new(region))
     });
-    let Some(unmapped) = taken else {
-        return SyscallError::NotFound.to_u64();
-    };
     // Dropped here, outside the closure: the drop shoots down and waits, and a
     // sibling can be spinning on the process-data lock.
-    drop(unmapped);
-    0
+    match taken {
+        Ok(unmapped) => {
+            drop(unmapped);
+            0
+        }
+        Err(e) => e.to_u64(),
+    }
 }
 
 /// The first `len` bytes of the shared memory object `handle` names, as a
diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs
index 3e3260d78..82bebfb22 100644
--- a/toyos-abi/src/syscall.rs
+++ b/toyos-abi/src/syscall.rs
@@ -2276,10 +2276,15 @@ pub unsafe fn mmap(addr: *mut u8, size: usize, prot: MmapProt, flags: MmapFlags)
     }
 }
 
-/// Unmap a previously mapped region.
+/// Unmap the whole mapping `mmap` returned at `addr`.
+///
+/// `size` names it as the `mmap` that made it did: any length that rounds to
+/// the same 2 MiB span. No mapping starting at `addr` is `NotFound`; a `size`
+/// that rounds to another span — a prefix, more than the mapping, zero — is
+/// `InvalidArgument` and unmaps nothing. A mapping has no part to give back.
 ///
 /// # Safety
-/// `addr` and `size` must describe a region previously returned by `mmap`.
+/// Nothing reaches the mapping at `addr` after this answers `Ok`.
 pub unsafe fn munmap(addr: *mut u8, size: usize) -> Result<(), SyscallError> {
     check_unit(syscall(SYS_MUNMAP, addr as u64, size as u64, 0, 0))
 }
diff --git a/userland/libc/src/posix_io.rs b/userland/libc/src/posix_io.rs
index a08d3e581..cb57f3815 100644
--- a/userland/libc/src/posix_io.rs
+++ b/userland/libc/src/posix_io.rs
@@ -555,12 +555,18 @@ pub unsafe extern "C" fn madvise(addr: *mut u8, _len: usize, advice: i32) -> i32
     }
 }
 
+/// Unmaps the whole mapping at `addr` or nothing: the kernel refuses a `len`
+/// that names part of one, which POSIX would unmap. `EINVAL` is the one error
+/// POSIX gives `munmap`, so it is every refusal's.
 #[no_mangle]
 pub unsafe extern "C" fn munmap(addr: *mut u8, len: usize) -> i32 {
-    // SAFETY: caller is responsible for addr/len matching a previous mmap
+    // SAFETY: the caller's, as C's `munmap` leaves it.
     match unsafe { syscall::munmap(addr, len) } {
         Ok(()) => 0,
-        Err(e) => set_errno(e),
+        Err(_) => {
+            crate::errno::set(EINVAL);
+            -1
+        }
     }
 }
 

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at head 59a3b0735 (orchestrator's run; the image staged from the clean tree at that head, sha256 5080297d…8aef3c2a, checked against the request and printed by the command that flashed it):

boot toyos-metal judge (--metal --metal-readback … mmap_, clean tree at the head)
shared (the three members the mmap_ filter keeps) exit 0 exit 0, [metal] 3 passed, 0 failed, 1 boot(s)

===TEST_END test_rs_mmap_stress exit=0=== with all mmap stress tests passed (64 regions, no overlaps), ===TEST_END test_rs_mmap_prot exit=0===, ===TEST_END test_rs_memmap_exec_refused exit=0===. abuse_mmap_regions is not a member of this boot and was not read.

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 1 of wt/toyos-munmapsize at 59a3b0735 against origin/main 1084ddc9a (merge base 2e781a49d, 46 commits behind). Read only: no build, test or QEMU run.

Net: 5 files, +76 −53. Production +38 −14 (kernel/src/syscall/vm.rs +23 −10, toyos-abi/src/syscall.rs +7 −2, userland/libc/src/posix_io.rs +8 −2). Tests +38 −1. Issue −38. The production growth is the check and its doc comments; accepted.

BLOCKER

  • userland/libc/src/posix_io.rs:562, kernel/src/syscall/vm.rs:192 — the loss the closed issue names survives for any partial length inside one 2 MiB span, and nothing records it — libc answers sysconf(_SC_PAGESIZE) 4096 (userland/libc/src/memreq.rs:14), so mmap(NULL, 8192, …) then munmap(p, 4096) is a POSIX prefix unmap; the kernel rounds 4096 to the same span as the recorded 2 MiB, answers 0 and takes the page the caller kept. The body ("a prefix or interior unmap is refused with EINVAL and nothing is unmapped") and the commit message say the opposite, and the deleted issue's own second paragraph describes exactly this call. mmap_stress.rs:80 asserts the accepting half as intended (munmap(pair, page_2m + 1)), and no test on any tier calls libc's munmap: 119_random_stuff.c maps below the window's floor, is refused, and never reaches it. Two legal exits: the kernel records the length mmap was asked for and compares against that, so the accepted set stops depending on the page size stage 3 and 4 of the 2 MiB track change; or the residue is filed. Either way the measurement is owed: through libc, map two 4096-byte pages, unmap the first, read the answer and errno, then touch the second.
  • issues/ — the POSIX gap has no issue file — the body says so itself ("it has no issue file yet … owed in the next commit"). A compromise the branch found is recorded with owner, evidence and exit or removed; a doc comment and a pull request body are neither. It must state the residue above truthfully: refused for a length that leaves the span, silently whole for one that does not.
  • Pull request body, Gates — cargo run -- --ci host has no measurement at any head — "not run locally" is the Evidence BLOCKER. Owed at the merged head, with command, exit code and log.
  • Pull request body, Gates — the guest suite has no measurement — three filtered tests are not "every guest test the change reaches" shown green by a suite run. Owed at the merged head. Note what it will and will not say: guest / suite runs declared tests only, so it reaches sys_munmap through unmap_touch and abuse_readonly_copyout alone and executes none of the new assertions.
  • T14 reading — taken at 59a3b0735 with the mmap_ filter, three members — two defects. Scope: discovered binaries and the C corpus ride no QEMU boot, so the claim "no caller in the tree needed changing" stands on reading for every other caller that .expects its unmap: on shared, std_mmap, futex_wake_counts, munmap_reissues_read_window, munmap_reissues_second_read_window, abuse_handle_table, abuse_page_straddle, abuse_spawn_argv, abuse_tls_alloc, abuse_readonly_copyout; on shared-debug, abuse_kernel_addr; and the rows process_bound_regions (abuse_mmap_regions: yes, owed — it unmaps PROT_NONE mappings and a FIXED replacement at the region bound, every one through the new compare) and tlb_shootdown_waits. I read each call and found the mapped length passed in all of them; one run says so, and reading is not the measurement. Head: the reading does not carry to the merged head. One workspace, one lock: the root, the kernel, the loader, userland and the SDK resolve together #746's byte-identity covers its own fold and nothing else; main has moved 45 further commits that change the kernel this test boots, among them toyos-userbound/src/place.rs and span.rs (the Window whose span this check calls), kernel/src/process.rs, kernel/src/loader/, kernel/src/object/shm.rs and kernel/src/syscall/ipc.rs. Owed at the merged head: shared, shared-debug, process_bound_regions, tlb_shootdown_waits, and the C corpus boot if the libc case above lands in it.

NOTE

  • issues/process-memory-is-2-mib-pages-and-that-caps-the-process-count.md:79 — stage 4 still lists "munmap refuses a size that is not the whole mapping" as work to build — false of the tree once this lands; this pull request corrects it, saying the refusal is in and what of it stage 4 still owes when the rounding moves to 4 KiB. The body calls it outside the fence; it is the record this change makes stale.
  • Pull request body — "can_release_part is false, which gates both of its release paths" — dlmalloc 0.2.13 has one gated release, release_unused_segments (dlmalloc.rs:1361), reached from free and sys_trim; the other system_allocator.free (dlmalloc.rs:1221, a chunk flagged mmapped) is not gated by it and is dead only because the crate never makes such a chunk.
  • Pull request body — "issue file −33" is −38; "T14 shared boot … requested, not run" is contradicted by the later comment.
  • Pull request body — "No fork clone calls it" — forkcallers.txt holds five comment hits in memmap2's advice.rs and nothing else. My own search of the fork clones and ~/.cargo/git/checkouts found 27 files naming munmap, none on a ToyOS path (memmap2 routes ToyOS to toyos.rs, stacker groups it with the targets that map nothing). C and C++ callers that reach libc's munmap from the LLVM fork are established by neither search.

Held, no finding

  • Design against its two alternatives: check-and-refuse stands. Partial unmap is ruled out by the track's stage 4 ruling and needs vma::Region and PageAlloc split; removing the argument leaves libc unable to refuse a partial len without a second ledger beside the kernel's two. Before this change a trimming allocator or guard-page trick lost the whole mapping and was told 0; a refusal is the right answer of the two. What does not stand is the equality chosen, first BLOCKER.
  • The check: both arms of sys_mmap are the only writers of mmap_regions (vm.rs:138, vm.rs:163) and both store window().span(size).bytes(); vma::window() is one const. Stack, TLS, dlopen, shared and pipe mappings are in no mmap_regions and stay NotFound. A hostile size is refused by align_2m_checked before any sum and before any lock. Lookup, compare and removal are one with_process_data closure, the lock the FIXED replacement arm also holds, so no sibling's unmap or remap falls between check and act; the Unmapped drops outside it.
  • No host test: accepted. Window::span's three named tests exist and cover zero, rounding and the top of u64; what the kernel adds is one equality a reader checks.
  • Negative control: the whole change reverted with the test kept, red by a fault on the first span, control.log exit 1; green arm green.log exit 0. AArch64 under QEMU with an uncommitted registration, which the tree does not carry.
  • Trial merge with origin/main is clean; main did not touch vm.rs, posix_io.rs or mmap_stress.rs. No citation of the deleted issue's slug remains at either head.

The landing rests on host and guest / suite at the merged head once the pull request is ready, and on the T14 rows above for everything the change actually asserts.

SEND BACK

Japabu and others added 2 commits October 8, 2026 12:37
Review round 1 found the loss the closed issue named still there: the
kernel compared the size it was passed after rounding it to a 2 MiB span,
so through libc, whose page is 4096 bytes, `mmap(NULL, 8192, ...)` then
`munmap(p, 4096)` answered 0 and took the page the caller kept. The `FIXED`
arm of `mmap` had the same loss by another road: it replaced a mapping of
"the same 2 MiB-rounded size", so a 4096-byte `MAP_FIXED` request at `p`
replaced both pages with zeros and answered `p`.

The kernel now records the length each `mmap` was asked for beside the
rounded size (`MmapRegion.len`), and both `SYS_MUNMAP` and the `FIXED`
replacement compare the two words as given; any other length is
`InvalidArgument` with nothing unmapped or replaced. No rounding decides
which lengths name a mapping, so the accepted set does not move when the
2 MiB track's stages 3 and 4 change the page size. Every caller in the tree
passes the length it mapped, and every `FIXED` replacement in it
(`abuse_mmap_regions`, `mmap_stress`, `tlb_shootdown_waits`) asks for the
length of what it replaces.

POSIX counts in pages, and that lives in libc: `mmap` asks the kernel for
`memreq::whole_pages(len)` and `munmap` names the mapping by the same
function of its own `len`. A `len` that ends in the mapping's last 4096-byte
page is the whole mapping, as POSIX has it; one that ends short of it names
a part and is refused with EINVAL, nothing unmapped.

Tests: `whole_pages` on the host (`toyos-libc-copies`); `206_libc_refusals`
maps two pages and reads the answer, errno and both pages for a fixed
mapping of the first and for each partial `munmap`; `mmap_stress` adds the
two lengths that round to its mapping's spans and are not its length, and
for the first 4096 bytes of 8192 the `munmap` and the `FIXED` request.

Filed: libc's `munmap` still refuses a part of a mapping that POSIX unmaps
(`issues/libc-munmap-refuses-part-of-a-mapping.md`). The 2 MiB track's
stage 4 says the refusal is in and what it still owes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu Japabu changed the title munmap unmaps the whole mapping its size names, or refuses munmap and a FIXED mmap take only the length the mapping was asked for Oct 8, 2026
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Fix round 1, head a5c566a72. Patches for the QEMU measurement in the body, all against that head; none is in the branch. Each was applied with git apply --check then git apply, run, and reverted with git apply -R (exit 0 each); the tree was clean afterwards.

measure.patch — the throwaway registration, applied for all three arms:

diff --git a/tests/toyos.rs b/tests/toyos.rs
index b0ebf22de..6a3fe6304 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -2786,12 +2786,81 @@ fn scanout_wc(console: &str) -> Result<(), String> {
     Ok(())
 }
 
+/// THROWAWAY, never committed: `206_libc_refusals` and `mmap_stress` on one
+/// x86-64 QEMU boot of `tests/testcases`, judged under `nested_nmi_is_loud`'s
+/// name. Neither is a guest test; both ride the T14's boots.
+fn throwaway_munmap(test_config: &Path) -> Result<(), String> {
+    let case = "206_libc_refusals".to_string();
+    let c_bins = compile_c_tests(std::slice::from_ref(&case));
+    let rust_bins = vec![(
+        "mmap_stress".to_string(),
+        qemu::build_toyos_bin(
+            qemu::SUITE_ARCH,
+            &compile::repo_root().join("tests/toyos-rust-tests"),
+            "mmap_stress",
+        ),
+    )];
+    let mut guest = QemuInstance::boot_with_options(test_config, &c_bins, &rust_bins, BootOptions::default());
+    let c = guest.run_test("test_c_206_libc_refusals", Duration::from_secs(120));
+    eprintln!(
+        "[throwaway] 206_libc_refusals: exit={:?} error={:?}\n[throwaway] its stdout:\n{}[throwaway] end of its stdout",
+        c.exit_code,
+        c.error.as_ref().map(|e| e.to_string()),
+        c.stdout
+    );
+    let rs = guest.run_test("test_rs_mmap_stress", Duration::from_secs(120));
+    eprintln!(
+        "[throwaway] mmap_stress: exit={:?} error={:?}\n[throwaway] its stdout:\n{}[throwaway] end of its stdout",
+        rs.exit_code,
+        rs.error.as_ref().map(|e| e.to_string()),
+        rs.stdout
+    );
+    let mut wrong = Vec::new();
+    if c.exit_code != Some(0) {
+        wrong.push(format!("206_libc_refusals ended with {:?}", c.exit_code));
+    }
+    for line in [
+        "mmap the first of two pages fixed: -1, ENOMEM",
+        "munmap the first of two pages: -1, EINVAL",
+        "pages after it: f s",
+        "munmap the second of two pages: -1, EINVAL",
+        "munmap no bytes: -1, EINVAL",
+        "munmap a byte past two pages: -1, EINVAL",
+        "pages after them: f s",
+        "munmap two pages by a length into the second: 0, no errno",
+        "munmap them again: -1, EINVAL",
+        "limit after getrlimit: 0 0",
+    ] {
+        if !c.stdout.lines().any(|said| said.ends_with(line)) {
+            wrong.push(format!("206_libc_refusals did not say {line:?}"));
+        }
+    }
+    if rs.exit_code != Some(0) {
+        wrong.push(format!("mmap_stress ended with {:?}", rs.exit_code));
+    }
+    if !rs.stdout.contains("all mmap stress tests passed") {
+        wrong.push("mmap_stress did not say it passed".to_string());
+    }
+    for line in &wrong {
+        eprintln!("[throwaway] RED: {line}");
+    }
+    if wrong.is_empty() {
+        eprintln!("[throwaway] GREEN: both said every line");
+        Ok(())
+    } else {
+        Err(wrong.join("; "))
+    }
+}
+
 /// Run the machine-shape test, which owns its QEMU: the machine shape *is* the
 /// test.
 fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> {
     match name {
         "iommu_virtio_platform" => common::iommu::iommu_virtio_platform(test_config),
-        "nested_nmi_is_loud" => faults::nested_nmi_is_loud(test_config),
+        "nested_nmi_is_loud" => {
+            let _ = faults::nested_nmi_is_loud;
+            throwaway_munmap(test_config)
+        }
         "machine_shutdown" => power::machine_shutdown(test_config),
         "acpi_power_button" => power::acpi_power_button(test_config),
         "acpi_mediated_access" => acpi_mediated_access(),

control-munmap.patch — exit 1:

diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index 9d9e34126..e80ce313e 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -196,7 +196,9 @@ pub(super) fn sys_munmap(addr: u64, size: u64) -> u64 {
         let Some(idx) = data.mmap_regions.iter().position(|r| r.addr.raw() == addr) else {
             return Err(SyscallError::NotFound);
         };
-        if data.mmap_regions[idx].len != size {
+        // CONTROL, never committed: the rounded compare review round 1 refused.
+        let _ = data.mmap_regions[idx].len;
+        if crate::vma::window().span(size).map(|span| span.bytes()) != Some(data.mmap_regions[idx].size as u64) {
             return Err(SyscallError::InvalidArgument);
         }
         let region = data.mmap_regions.swap_remove(idx);

control-fixed.patch — exit 1:

diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index 9d9e34126..00fcd7961 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -107,7 +107,7 @@ pub(super) fn sys_mmap(req_addr: u64, size: u64, prot: MmapProt, flags: MmapFlag
                     let mine = data
                         .mmap_regions
                         .iter()
-                        .position(|r| r.addr == start && r.len == size);
+                        .position(|r| r.addr == start && r.size == aligned); // CONTROL, never committed: the rounded compare.
                     match mine {
                         Some(idx) => Some(idx),
                         None => return Err(SyscallError::InvalidArgument),

Exits, as the request script recorded them:

head a5c566a7269515248d35cb013d97fe642c3d35df
metadata EXIT=0
host-libc-copies EXIT=0
build EXIT=0
guest-virt_unmap_touch EXIT=0
guest-virt_readonly_copyout EXIT=0
guest-virt_smp EXIT=0
control-green-arm EXIT=0
control-munmap-arm EXIT=1 (expected 1)
control-munmap.patch reverted EXIT=0
control-fixed-arm EXIT=1 (expected 1)
control-fixed.patch reverted EXIT=0
measure.patch reverted EXIT=0
stage-metal-mmap EXIT=2 (2 = staged)
stage-metal-abuse EXIT=2 (2 = staged)
stage-metal-futex EXIT=2 (2 = staged)
stage-metal-bounds EXIT=2 (2 = staged)
stage-metal-tlb EXIT=2 (2 = staged)
stage-metal-ccorpus EXIT=2 (2 = staged)
tree clean at a5c566a72 after staging EXIT=0
done

In-tree munmap callers and the lengths they map, read call by call: kernelprobe's unmap_touch (2 MiB/2 MiB); mmap_stress, mmap_prot (4096/4096), std_mmap, munmap_reissues_read_window, munmap_reissues_second_read_window, abuse_spawn_argv and abuse_handle_table (4 MiB/4 MiB), abuse_tls_alloc, abuse_readonly_copyout, abuse_page_straddle and abuse_kernel_addr (4 MiB/4 MiB), abuse_mmap_regions, tlb_shootdown_waits, futex_wake_counts: each passes the constant it mapped with. dlmalloc's free in sdk/std/sys/alloc.rs and userland/libc/src/lib.rs does not run.

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at head a5c566a72 (orchestrator's run; seven images staged from the clean tree at that head, each sha256 as the request has it and printed by the command that flashed it; each section judged with --metal --metal-readback <dir> <filter> from the clean tree; every boot exit 0, every judge exit 0):

section (filter) boots, image sha256 judge
mmap shared, cacaefe0…259c884d 4 passed
abuse_ shared, a3230871…e241ad8d; shared-debug, 2b3a35da…104e131c 19 passed over 2 boots
futex_wake_counts shared, ca98c21e…7950ee7c 1 passed
process_bound_regions testcases-bounds, e8d5b63a…85677115 1 passed (test_rs_abuse_mmap_regions exit 0)
tlb_shootdown_waits testcases-debug, 940d4d3b…9f3e8918 1 passed
206_libc_refusals ccorpus, 3a9499d5…8f38bff03 1 passed (===TEST_END 206_libc_refusals exit=0===)

Twenty-seven rows over seven boots, none failed; 29 ===TEST_END lines in the readbacks, each exit=0.

@Japabu
Japabu marked this pull request as ready for review October 8, 2026 11:34
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 2 of wt/toyos-munmapsize at a5c566a72 against origin/main 47cbd2e5b (merge base 1084ddc9a). Read only: no build, test or QEMU run.

Net: 12 files, +252 −66. Production +73 −23 (kernel/src/syscall/vm.rs +35 −15, kernel/src/process.rs +2, toyos-abi/src/syscall.rs +8 −3, userland/libc/src/posix_io.rs +18 −5, userland/libc/src/memreq.rs +10). Tests +120 −1. Issues +59 −42. The production growth is one recorded word, two compares, one rounding function and their contracts; accepted.

Round 1's BLOCKERs

  1. The rounded compare — CLOSED. vm.rs:199 compares MmapRegion.len with the word passed, vm.rs:110 does the same for the FIXED replacement; green.log exit 0 with the ten libc lines and both TEST_END … exit=0; control-munmap.log exit 1 (both programs fault on memory the caller kept); control-fixed.log exit 1 (mmap_stress.rs:101 on its assertion). On the T14 at this head test_rs_mmap_stress exit=0 and 206_libc_refusals exit=0, the second judged in the guest against the committed .expect.
  2. No issue for the libc gap — CLOSED. issues/libc-munmap-refuses-part-of-a-mapping.md: defect/open, an owner that exists, an exit 206_libc_refusals can read. One NOTE on it below.
  3. cargo run -- --ci host — OPEN. No measurement at a5c566a72: the host check (run 37770905951, job 113289948617) was pending when read once.
  4. The guest suite — OPEN. No measurement at a5c566a72: toolchain / build (same run, job 113289948978) was pending and guest / suite had not started; the two skipping rows are the draft's run and say nothing.
  5. The T14 reading — head CLOSED, scope OPEN. Seven boots at a5c566a72, 27 rows, every TEST_END exit=0: mmap_stress, mmap_prot, std_mmap, memmap_exec_refused; eighteen abuse_ members on shared and abuse_kernel_addr on shared-debug; futex_wake_counts; process_bound_regions (abuse_mmap_regions, abuse_thread_table, abuse_dlopen_ledger); tlb_shootdown_waits; 206_libc_refusals. What is open is the first finding below.

The ruling asked for: sections that hold every caller of munmap and of a FIXED mmap do meet round 1's "shared and shared-debug whole". The change is two word compares in two syscalls and one recorded word; a member that issues neither executes the recording alone, which every booted member's allocator already did. The sections taken do not hold every caller.

BLOCKER

  • Pull request body, "The T14 sections" — munmap_reissues_read_window and munmap_reissues_second_read_window were booted at no head, and the body says they were — the mmap section's own log reads 0 registration(s) and 4 shared member(s) and its readback holds four TEST_END lines; "munmap" does not contain "mmap", so the filter never kept them. Both .expect their unmaps (munmap_reissues_read_window.rs:92,116; munmap_reissues_second_read_window.rs:54,88,111,112), the second through a 4 MiB reservation released and two FIXED placements over it; round 1 named both as owed, and they ride no QEMU boot. I read the mapped length passed in every call; reading is not the measurement. Owed at the landing head: one shared boot under the filter munmap_reissues, two members.
  • userland/libc/src/posix_io.rs:527 — no test on any tier can fail on the claim that libc's mmap asks the kernel for whole pages — 206_libc_refusals.c maps 4096 and 8192 and nothing else, and the host test calls whole_pages and never mmap. The mutation: replace lines 527-528 with let ptr = unsafe { syscall::mmap(addr, len, mp, mf) };. I expect every test at this head to stay green under it, and under it mmap(NULL, 5000, …) then munmap(p, 5000) answers -1, EINVAL and keeps the mapping: the commonest C use there is, since the kernel now takes the length to the byte and munmap rounds its own side. Owed: a case in 206_libc_refusals.c that maps a length off a page and unmaps it by that length, and by the length rounded up, each reading 0; the implementer runs the mutation and the case turns red; the C corpus boot on the T14 is taken again with it.
  • Pull request body, Gates — host and guest / suite at the landing head, as 3 and 4 above. The next push restarts both; the landing rests on host and on guest / suite after toolchain / build, each concluded green on the head that lands, not skipped.

NOTE

  • issues/libc-munmap-refuses-part-of-a-mapping.md — the body says the MAP_FIXED half of the gap has its "owner and exit … in the file", and the file never names MAP_FIXED — POSIX replaces whatever pages a fixed request overlaps; libc answers MAP_FAILED, ENOMEM for one over part of a mapping, and 206_libc_refusals.expect:41 now pins that answer. No other issue records it. One paragraph in this file, and its exit reading that line too.
  • Pull request body against the issue — the body has partial unmap as what "the 2 MiB track's stage 4 rules out"; the issue's Owner and the track's stage 4 line have stage 4 as where a region that splits would be built. One of the two.
  • Pull request body, Gates — "T14: staged at this head … no result in this body yet" is answered by the later comment.

Held, no finding

  • Where len is written: vm.rs:144 and vm.rs:169 are the only constructors of MmapRegion; loader/mod.rs:627 and sched/kthread.rs:130 start the list empty, process.rs:1084 clears it. No mapping exists without the word its mmap was passed, and that word passed vma::window().span first, so it is never zero and never past the window.
  • The compares: sys_munmap's lookup, compare and removal are one with_process_data closure; the FIXED arm's are under that lock and the address-space lock together. No sibling's unmap or replacement falls between check and act, and the Unmapped still drops outside both.
  • A hostile length: an equality on two u64 words, no arithmetic. Zero, usize::MAX and a wrapping length are InvalidArgument at a mapping's start and NotFound anywhere else; mmap_stress reads both spans back after each.
  • libc's two sides: mmap and munmap pass the same memreq::whole_pages of their own len, so they agree for every length; zero is EINVAL on both (mmap_refusal first on the one, None on the other), a length within 4095 of usize::MAX is ENOMEM and EINVAL. An address off a page starts no mapping and is EINVAL, as POSIX has it. By reading; the second BLOCKER is its measurement.
  • Too strict for a portable program: no. Rust std and libc reach munmap only through dlmalloc 0.2.13's Allocator::free, called from release_unused_segments (dlmalloc.rs:1362, gated by can_release_part, false in both backends) and from destroy (dlmalloc.rs:1706, which neither backend calls). The Rust fork at cc9c8b1be names munmap in sys/pal/unix/stack_overflow.rs alone. A C caller that maps n and unmaps n rounded to its page, or the reverse, is accepted by libc's rounding.
  • The ABI doc: mmap's and munmap's contracts say what the kernel does, and munmap's safety clause is the caller's whole obligation.
  • The controls revert one compare each onto the measured head with the tests kept, and each is red on the assertion its compare owns.
  • Merges: One workspace, one lock: the root, the kernel, the loader, userland and the SDK resolve together #746 is in by d68850cf2, and the branch's diff against origin/main is its own twelve files. git merge-tree --write-tree origin/main a5c566a72 exits 0, and so does the same against A device call acts on its claim's binding or is refused: a claim lends a &Binding or &isa::Row under the lock its release takes #763's head 2bf134fc6, which touches none of these files. No citation of the deleted issue's slug remains at the head or on origin/main.
  • 206_libc_refusals is changed, not added: the corpus count in tests/testcases/LICENSE does not move. No guest test is added, changed or cut.

SEND BACK

…its pages', and the issue names the MAP_FIXED half

Review round 2 of #765 found no test that fails when libc's `mmap` passes
the kernel `len` unrounded: `206_libc_refusals` mapped 4096 and 8192 alone.
With the kernel comparing lengths to the byte and `munmap` rounding its own
side, that mutation makes `mmap(NULL, 5000)` then `munmap(p, 5000)` answer
`EINVAL`. The case maps 5000 bytes twice and unmaps once by 5000 and once by
8192; each reads 0.

`issues/libc-munmap-refuses-part-of-a-mapping.md` gains the `MAP_FIXED` half
of the gap (POSIX replaces whatever pages a fixed request overlaps; libc
answers `MAP_FAILED`, `ENOMEM` over part of a mapping) with an exit that reads
the `.expect` line pinning it. Its Owner and the 2 MiB track's stage 4 line
said stage 4 is where a region that splits would be built; the track's stage
4 rules the refusal and no stage builds one, so both now say that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Fix round 2, head 3b942f1aa. Patches for the QEMU measurement in the body, both against that head; neither is in the branch. Each was applied with git apply --check then git apply, run, and reverted with git apply -R (exit 0 each); the tree was clean afterwards. Round 1's control-munmap.patch and control-fixed.patch are in comment 6058420753 and were not run again: git diff --quiet a5c566a72 3b942f1aa -- kernel/ toyos-abi/ exits 0.

mutation-libc-mmap.patch — review round 2's mutation, exit 1 (munmap 5000 bytes by 5000: -1, EINVAL, munmap 5000 bytes by 8192: -1, EINVAL):

diff --git a/userland/libc/src/posix_io.rs b/userland/libc/src/posix_io.rs
index 47dfd48e7..d544cfd2e 100644
--- a/userland/libc/src/posix_io.rs
+++ b/userland/libc/src/posix_io.rs
@@ -524,8 +524,8 @@ pub unsafe extern "C" fn mmap(
 
     // Asked for in whole pages, the length `munmap` names it by; more pages
     // than a `usize` counts is the null the kernel answers a size it cannot map.
-    let ptr = memreq::whole_pages(len)
-        .map_or(core::ptr::null_mut(), |len| unsafe { syscall::mmap(addr, len, mp, mf) });
+    // MUTATION, never committed: the length as given, not in whole pages.
+    let ptr = unsafe { syscall::mmap(addr, len, mp, mf) };
     if ptr.is_null() {
         // The kernel's refusal does not say which it is: ENOMEM is POSIX's
         // for a place or size the address space does not allow.

measure-r2.patch — the THROWAWAY registration both arms ran under (round 1's measure.patch with the two 5000-byte lines added to what it requires); green arm exit 0:

diff --git a/tests/toyos.rs b/tests/toyos.rs
index b0ebf22de..a7b29d3e8 100644
--- a/tests/toyos.rs
+++ b/tests/toyos.rs
@@ -2786,12 +2786,83 @@ fn scanout_wc(console: &str) -> Result<(), String> {
     Ok(())
 }
 
+/// THROWAWAY, never committed: `206_libc_refusals` and `mmap_stress` on one
+/// x86-64 QEMU boot of `tests/testcases`, judged under `nested_nmi_is_loud`'s
+/// name. Neither is a guest test; both ride the T14's boots.
+fn throwaway_munmap(test_config: &Path) -> Result<(), String> {
+    let case = "206_libc_refusals".to_string();
+    let c_bins = compile_c_tests(std::slice::from_ref(&case));
+    let rust_bins = vec![(
+        "mmap_stress".to_string(),
+        qemu::build_toyos_bin(
+            qemu::SUITE_ARCH,
+            &compile::repo_root().join("tests/toyos-rust-tests"),
+            "mmap_stress",
+        ),
+    )];
+    let mut guest = QemuInstance::boot_with_options(test_config, &c_bins, &rust_bins, BootOptions::default());
+    let c = guest.run_test("test_c_206_libc_refusals", Duration::from_secs(120));
+    eprintln!(
+        "[throwaway] 206_libc_refusals: exit={:?} error={:?}\n[throwaway] its stdout:\n{}[throwaway] end of its stdout",
+        c.exit_code,
+        c.error.as_ref().map(|e| e.to_string()),
+        c.stdout
+    );
+    let rs = guest.run_test("test_rs_mmap_stress", Duration::from_secs(120));
+    eprintln!(
+        "[throwaway] mmap_stress: exit={:?} error={:?}\n[throwaway] its stdout:\n{}[throwaway] end of its stdout",
+        rs.exit_code,
+        rs.error.as_ref().map(|e| e.to_string()),
+        rs.stdout
+    );
+    let mut wrong = Vec::new();
+    if c.exit_code != Some(0) {
+        wrong.push(format!("206_libc_refusals ended with {:?}", c.exit_code));
+    }
+    for line in [
+        "mmap the first of two pages fixed: -1, ENOMEM",
+        "munmap the first of two pages: -1, EINVAL",
+        "pages after it: f s",
+        "munmap the second of two pages: -1, EINVAL",
+        "munmap no bytes: -1, EINVAL",
+        "munmap a byte past two pages: -1, EINVAL",
+        "pages after them: f s",
+        "munmap two pages by a length into the second: 0, no errno",
+        "munmap them again: -1, EINVAL",
+        "munmap 5000 bytes by 5000: 0, no errno",
+        "munmap 5000 bytes by 8192: 0, no errno",
+        "limit after getrlimit: 0 0",
+    ] {
+        if !c.stdout.lines().any(|said| said.ends_with(line)) {
+            wrong.push(format!("206_libc_refusals did not say {line:?}"));
+        }
+    }
+    if rs.exit_code != Some(0) {
+        wrong.push(format!("mmap_stress ended with {:?}", rs.exit_code));
+    }
+    if !rs.stdout.contains("all mmap stress tests passed") {
+        wrong.push("mmap_stress did not say it passed".to_string());
+    }
+    for line in &wrong {
+        eprintln!("[throwaway] RED: {line}");
+    }
+    if wrong.is_empty() {
+        eprintln!("[throwaway] GREEN: both said every line");
+        Ok(())
+    } else {
+        Err(wrong.join("; "))
+    }
+}
+
 /// Run the machine-shape test, which owns its QEMU: the machine shape *is* the
 /// test.
 fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> {
     match name {
         "iommu_virtio_platform" => common::iommu::iommu_virtio_platform(test_config),
-        "nested_nmi_is_loud" => faults::nested_nmi_is_loud(test_config),
+        "nested_nmi_is_loud" => {
+            let _ = faults::nested_nmi_is_loud;
+            throwaway_munmap(test_config)
+        }
         "machine_shutdown" => power::machine_shutdown(test_config),
         "acpi_power_button" => power::acpi_power_button(test_config),
         "acpi_mediated_access" => acpi_mediated_access(),

Exits, as the request script recorded them:

head 3b942f1aaa73d2bef8683957f16c9fe65cf3f191
kernel-abi-diff-empty EXIT=0 (0 = no change under kernel/ or toyos-abi/ since a5c566a72)
userland-sdk-rusttests-diff-empty EXIT=0 (0 = no change there since a5c566a72)
diff-stat EXIT=0
metadata EXIT=0
host-libc-copies EXIT=0
build EXIT=0
throwaway-green-arm EXIT=0
throwaway-mutation-libc-mmap-arm EXIT=1 (expected 1)
mutation-libc-mmap.patch reverted EXIT=0
measure-r2.patch reverted EXIT=0
green arm said: 11:42:15 [serial 0] [ 2.860 test-runner pid=10] munmap 5000 bytes by 5000: 0, no errno
green arm said: 11:42:15 [serial 0] [ 2.860 test-runner pid=10] munmap 5000 bytes by 8192: 0, no errno
green arm said: 11:42:15 [serial 0] [ 2.864 test-runner] ===TEST_END test_c_206_libc_refusals exit=0===
green arm said: munmap 5000 bytes by 5000: 0, no errno
green arm said: munmap 5000 bytes by 8192: 0, no errno
green arm said: 11:42:15 [serial 0] [ 3.009 test-runner] ===TEST_END test_rs_mmap_stress exit=0===
mutation-libc-mmap arm said: 11:44:29 [serial 0] [ 2.881 test-runner pid=10] munmap 5000 bytes by 5000: -1, EINVAL
mutation-libc-mmap arm said: 11:44:29 [serial 0] [ 2.881 test-runner pid=10] munmap 5000 bytes by 8192: -1, EINVAL
mutation-libc-mmap arm said: 11:44:29 [serial 0] [ 2.883 test-runner] ===TEST_END test_c_206_libc_refusals exit=0===
mutation-libc-mmap arm said: munmap 5000 bytes by 5000: -1, EINVAL
mutation-libc-mmap arm said: munmap 5000 bytes by 8192: -1, EINVAL
mutation-libc-mmap arm said: 11:44:29 [serial 0] [ 3.004 test-runner] ===TEST_END test_rs_mmap_stress exit=0===
mutation-libc-mmap arm said: 11:44:29 [throwaway] RED: 206_libc_refusals did not say "munmap 5000 bytes by 5000: 0, no errno"
mutation-libc-mmap arm said: 11:44:29 [throwaway] RED: 206_libc_refusals did not say "munmap 5000 bytes by 8192: 0, no errno"
mutation-libc-mmap arm said: 11:44:29 FAIL nested_nmi_is_loud: 206_libc_refusals did not say "munmap 5000 bytes by 5000: 0, no errno"; 206_libc_refusals did not say "munmap 5000 bytes by 8192: 0, no errno"
mutation-libc-mmap arm said: 11:44:29     nested_nmi_is_loud: 206_libc_refusals did not say "munmap 5000 bytes by 5000: 0, no errno"; 206_libc_refusals did not say "munmap 5000 bytes by 8192: 0, no errno"
stage-metal-ccorpus EXIT=2 (2 = staged)
stage-metal-ccorpus: 11:46:35 [metal] 0 registration(s) and 1 shared member(s) over 1 boot(s)
stage-metal-reissues EXIT=2 (2 = staged)
stage-metal-reissues: 11:46:56 [metal] 0 registration(s) and 2 shared member(s) over 1 boot(s)
tree clean at 3b942f1aa after staging EXIT=0
done

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

T14 at head 3b942f1aa (orchestrator's run; the two images staged from the clean tree at that head, each sha256 as hashed before the flash and printed by the command that flashed it; each judged with --metal --metal-readback <dir> <filter>; both boots exit 0, both judges exit 0):

section (filter) boot, image sha256 judge
206_libc_refusals ccorpus, 22044348…ca28567fd 1 passed (===TEST_END 206_libc_refusals exit=0===, judged in the guest against its .expect, the two 5000-byte lines among them)
munmap_reissues shared, 21daf70e…1d883307 2 passed: test_rs_munmap_reissues_read_window, test_rs_munmap_reissues_second_read_window, each exit=0

The five other sections were read at a5c566a72 (comment 6058950679); git diff a5c566a72 3b942f1aa -- kernel/ toyos-abi/ userland/ is empty.

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review round 3 of wt/toyos-munmapsize at 3b942f1aa against origin/main ed80f9099 (merge base 1084ddc9a). Read only: no build, test or QEMU run. Reviewed since round 2: the one commit on a5c566a72 (four files: the C case, its .expect, two issue files).

Net: 12 files, +271 −66. Production +73 −23, unchanged since round 2 and accepted there. Tests +128 −1 (this round +8). Issues +70 −42.

Round 2's BLOCKERs

  1. munmap_reissues_read_window and munmap_reissues_second_read_window booted at no head: CLOSED. One shared boot on the T14 at 3b942f1aa, filter munmap_reissues; the stage log reads 0 registration(s) and 2 shared member(s) over 1 boot(s); the readback holds munmap_reissues_read_window: the frame held under the copy and munmap_reissues_second_read_window: both frames held under the copy, each followed by its ===TEST_END … exit=0===; the judge reads 2 passed, 0 failed, 1 boot(s). The image hash in the stage request is the one comment 6059243360 names.
  2. No test could fail on libc's mmap asking for whole pages: CLOSED. 206_libc_refusals.c:154-157 with .expect:51-52. Under the throwaway registration at 3b942f1aa: green arm exit 0 with munmap 5000 bytes by 5000: 0, no errno and munmap 5000 bytes by 8192: 0, no errno; mutation-libc-mmap.patch (the exact patch round 2 named) exit 1 with both lines -1, EINVAL, reverted exit 0. On the T14 at this head the ccorpus boot records exit: test_c_206_libc_refusals pid=11 code=0 and exit: 206_libc_refusals pid=10 code=0; the second is ccheck's MATCHED, a byte compare of the case's whole output with the .expect staged from the clean tree at this head; judge 1 passed, 0 failed, 1 boot(s).
  3. host and guest / suite at the landing head: OPEN. Read once: run 37772453125 at 3b942f1aa has host (job 113295620913) pending and toolchain / build (job 113295621156) pending; guest / suite has not started. No measurement of either exists at this head.

Round 2's NOTEs: the MAP_FIXED half is in the issue file with its evidence (.expect:41) and its exit; the issue's Owner and the track's stage 4 line now agree with stage 4 as origin/main has it, and the body says which side was wrong. The third is the first NOTE below, in a new form.

BLOCKER

  • Pull request body, Gates: cargo run -- --ci host and the guest suite have no result at 3b942f1aa. Nothing in the branch is asked to change for this; what closes it is two conclusions on this head and no other: host (run 37772453125, job 113295620913) concluded success, which is the only run at this head of the host gates over the two issue files and the changed corpus case, and of memory_refusals::a_length_names_the_pages_it_reaches_into inside the whole host suite; and guest / suite concluded success, not skipped, after toolchain / build (job 113295621156) concluded success, which is where unmap_touch, abuse_readonly_copyout and the SMP rows reach sys_munmap. Each goes into the body's Gates rows with its run and job. The merge queue's runs of both on the merged tree are a second measurement and replace neither.

NOTE

Held, no finding

  • The new case as a test. It fails on what it claims: the kernel compares the recorded word to the byte, so mmap passing 5000 leaves munmap's 8192 naming nothing, and both lines turn -1, EINVAL (measured). The other half, munmap passing its len unrounded, already reds munmap two pages by a length into the second. 8192 is right: 5000 reaches into two 4096-byte pages, and whole_pages(5000) and whole_pages(8192) are the same word. A failed mmap prints -2, which no expectation holds. On the green path both mappings are gone before malloc SIZE_MAX; on the red path two mappings stay that no later line reads or names, page being the first mapping of the program and still held for the madvise lines. said clears errno after each line, so neither new line reads an earlier one's.
  • What carries from a5c566a72. git diff --quiet a5c566a72 3b942f1aa over kernel/ toyos-abi/ exits 0, and over userland/ toyos/ tests/toyos-rust-tests/ src/ tests/toyos.rs tests/libc-arch exits 0. So control-munmap.patch and control-fixed.patch would apply to the same bytes and judge the same programs, and the five T14 sections (mmap, abuse_, futex_wake_counts, process_bound_regions, tlb_shootdown_waits) booted the same kernel, libc and Rust programs. The sixth does not carry and was taken again (2 above).
  • The merge. git merge-tree --write-tree origin/main 3b942f1aa exits 0 (tree 6d61e17e9). The one file both sides touch is toyos-abi/src/syscall.rs: main's hunk is a doc line on the BAR window, the branch's are the mmap and munmap contracts. In the merged tree MmapRegion is built at vm.rs:143 and vm.rs:168 and nowhere else, no file main changed since the base names munmap, MmapFlags::FIXED or mmap_regions outside that doc file, and no citation of the deleted issue's slug remains.
  • The T14 reading against the merged tree. A device call acts on its claim's binding or is refused: a claim lends a &Binding or &isa::Row under the lock its release takes #763 changed kernel/src/pcidev/mod.rs, isa.rs, device.rs, inventory.rs, object/{device,mod,ops}.rs, syscall/device.rs and both acpi_mode.rs. git diff --quiet 1084ddc9a origin/main over kernel/src/syscall/vm.rs, kernel/src/process.rs, kernel/src/vma*, kernel/src/mm, userland/libc, tests/testcases and the four programs read at this head exits 0. sys_mmap and sys_munmap read the process's mapping list, its address space and PageAlloc, and none of what A device call acts on its claim's binding or is refused: a claim lends a &Binding or &isa::Row under the lock its release takes #763 moved, so every row read on the T14 carries for what this change claims. It is not a reading of the merged kernel: whether a claim's binding boots the T14 is A device call acts on its claim's binding or is refused: a claim lends a &Binding or &isa::Row under the lock its release takes #763's record to answer.
  • The issue files against issues/README.md: defect/open, an owner that exists, an exit 206_libc_refusals reads in both halves; the track's stage 4 line states the ruling as main gave it and names the FIXED arm as what is in the tree, not as ruled.
  • 206_libc_refusals is changed, not added, and is a metal row, not a guest test: tests/testcases/LICENSE's count does not move and no guest test is added, changed or cut.

SEND BACK

@Japabu

Japabu commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing review of wt/toyos-munmapsize at 3b942f1aa against origin/main ed80f9099 (merge base 1084ddc9a), round 4. Read only; nothing was built or run. The head is the one round 3 read (gh pr view: headRefOid 3b942f1aa; the branch's tip the same); reviewed since: CI at that head and the body.

Net (git diff --shortstat origin/main...3b942f1aa): 12 files, +271 −66. Production +73 −23, tests +128 −1, issues +70 −42. Unchanged.

Round 3's BLOCKER

  1. host and guest / suite at the landing head: CLOSED. Run 37772453125, headSha 3b942f1aa, concluded success: host (job 113295620913) success, its log ending [ci] Host: 78 step(s), all green; toolchain / build (113295621156) success; guest / suite (113296851511) success, not skipped, with PASS virt_unmap_touch (4s), PASS virt_readonly_copyout (4s) and test result: ok. 32 passed, 32 total. Both jobs checked out Merge 3b942f1aa… into ed80f9099…, today's origin/main, so the suite read sys_munmap in the merged kernel, A device call acts on its claim's binding or is refused: a claim lends a &Binding or &isa::Row under the lock its release takes #763's with it.

Rounds 1 and 2's stay closed: nothing moved.

The body

The merge

git merge-tree --write-tree origin/main 3b942f1aa exits 0, tree 6d61e17e9, the tree round 3 read; origin/main has not moved since.

BLOCKER

None.

NOTE

  • Pull request body, "The T14", second table — its third column is still headed "rows owed" under a heading that says the rows were read — prose.

Arming

It may be armed as it stands.

LAND

@Japabu
Japabu added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 8a883a1 Oct 8, 2026
3 checks passed
@Japabu
Japabu deleted the wt/toyos-munmapsize branch October 8, 2026 12:42
Japabu added a commit that referenced this pull request Oct 8, 2026
Clean. #767's commits were already here (921b485), so the merge brings
#765's files alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A
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