From a8609f7a4d454ceef4a52eef7cc295d6a7dfc184 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 3 Oct 2026 23:56:39 +0200 Subject: [PATCH 1/5] A process's region count is bounded and refused by name before its mmap ledger panics the kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `mmap` registered a region in the address space and an `MmapRegion` in `ProcessData::mmap_regions`, and nothing bounded how many one process held but the placement window. A `PROT_NONE` mapping pins no physical page, so a loop of them cost the process nothing and the kernel one 40-byte record each; past 32,768 records the `mmap_regions` `Vec` doubled to a 2,621,440-byte allocation, past `mm::MAX_HEAP_ALLOC` (2,093,056), where the kernel allocator asserts — a kernel panic from one unprivileged process, against the rule that the kernel never crashes from userland. The bound belongs on the region ledger, the one declaration both arches' address spaces read and both `mmap` and `dlopen` and a thread's TLS block grow through. `vma::MAX_REGIONS` (32,768, the largest power of two whose records fit one heap allocation) caps `vma::Regions`; `alloc`, `alloc_mapped` and the FIXED arm of `sys_mmap` refuse a new region past it with `ResourceExhausted` before any allocation grows, and `insert` asserts the room a userland-driven caller must check first. A compile-time assertion ties the cap, the record's size and the heap ceiling together, so a later edit to any of the three that would let the ledger `Vec` double past the ceiling fails the build — power-of-two included, because a non-power-of-two cap would let the `Vec` round its capacity up past the ceiling before the cap refused. The other per-process collections a syscall grows are already bounded: handle tables at `MAX_HANDLES`, a port's pending queue at `MAX_PENDING_CONNECTIONS`, an inbox ring's polls at `MAX_PENDING_WATCHES`, and a thread at its own 2 MiB TLS region (now region-capped) plus a 128 KiB kernel stack. One more is not — `dlopen`'s `loaded_libs`/`lib_paths` ledger — and it is a distinct owner with a lower threshold, filed in `issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md`. The test drives the cheapest grower, `mmap(PROT_NONE)`, until refused, and asserts the refusal lands near the cap, the kernel stays alive and allocating, and freeing reservations reopens placement (the two ledgers agree). It joins the `abuse_*` family, which runs on the T14 shared boot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- ...ed-library-ledger-past-the-heap-ceiling.md | 44 ++++++ kernel/src/arch/aarch64/paging.rs | 4 + kernel/src/arch/x86_64/paging.rs | 4 + kernel/src/process.rs | 6 + kernel/src/syscall/vm.rs | 6 + kernel/src/vma.rs | 19 ++- .../src/bin/abuse_mmap_regions.rs | 146 ++++++++++++++++++ 7 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md create mode 100644 tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs diff --git a/issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md b/issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md new file mode 100644 index 00000000000..9cd57d59755 --- /dev/null +++ b/issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md @@ -0,0 +1,44 @@ +--- +status: open +kind: defect +opened: 2026-10-03 +--- + +# A process's loaded-library ledger is bounded only by its address window + +`sys_dlopen` (`kernel/src/syscall/vm.rs`) appends an `elf::LoadedLib` to +`ProcessData::elf.loaded_libs` and a `String` to `elf.lib_paths` for every +distinct resolved path, and nothing bounds how many one process holds. The +dedup is by the normalized path string (`vfs::resolve_absolute`), not by the +file behind it, so a process that makes N symlinks to one real shared object in +`/tmp` and `dlopen`s each resolves N distinct paths and keeps N ledger entries +for one file. + +`loaded_libs` is a `Vec` of 544-byte records. Its capacity doubles, so the push +from 2,048 to 2,049 entries grows it to a 4,096-capacity allocation of +2,228,224 bytes, past `mm::MAX_HEAP_ALLOC` (2,093,056), where +`KernelAllocator::alloc` asserts (`kernel/src/mm/alloc.rs`): a kernel panic from +one unprivileged process, the same shape as +`a-processs-mapping-count-is-bounded-only-by-its-address-window.md`. + +The per-process region cap added for that defect +(`kernel/src/vma.rs`'s `MAX_REGIONS`, 32,768) does **not** cover this: each +`dlopen` maps its image through `AddressSpace::alloc_region`, so a load is +refused once 32,768 regions are registered — but the `loaded_libs` `Vec` +doubling panics at 2,049 entries, far below that. Each of the 2,048 prior loads +costs real memory (a cached image shares physical pages, but the shared-object +cache's `BUDGET_BYTES` is 256 MiB and past it a load is `Owned`, a private 2 MiB +allocation), so on a guest with a few GiB of RAM the ledger reaches 2,049 before +the PMM runs dry. + +By reading, unmeasured here: the 544 bytes are `size_of::` and the +threshold is that over the doubling against `MAX_HEAP_ALLOC`. The sibling +defect's test (`tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs`) is the +pattern a test here would follow, driving `SYS_SYMLINK` + `SYS_DLOPEN` instead +of `mmap`. + +Owner: orchestrator. Exit condition: `dlopen` refuses by name the load past a +per-process library bound (as spawn-time `load_needed_libs` already bounds its +distinct `DT_NEEDED` set by `MAX_NEEDED_LIBS`), and a test that `dlopen`s one +shared object through distinct paths until refused reads that refusal and a live +kernel; it reds today on the panic. diff --git a/kernel/src/arch/aarch64/paging.rs b/kernel/src/arch/aarch64/paging.rs index 4d40e40dc3b..b748967202b 100644 --- a/kernel/src/arch/aarch64/paging.rs +++ b/kernel/src/arch/aarch64/paging.rs @@ -517,6 +517,10 @@ impl AddressSpace { self.regions.insert(addr, region); } + pub fn has_region_room(&self) -> bool { + self.regions.has_room() + } + pub fn find_region(&self, addr: UserAddr) -> Option<(UserAddr, &Region)> { self.regions.find(addr) } diff --git a/kernel/src/arch/x86_64/paging.rs b/kernel/src/arch/x86_64/paging.rs index b8d0d70e859..4cec675d1c4 100644 --- a/kernel/src/arch/x86_64/paging.rs +++ b/kernel/src/arch/x86_64/paging.rs @@ -639,6 +639,10 @@ impl AddressSpace { self.regions.insert(addr, region); } + pub fn has_region_room(&self) -> bool { + self.regions.has_room() + } + /// Find the region containing `addr`. Returns (start_addr, region). pub fn find_region(&self, addr: UserAddr) -> Option<(UserAddr, &Region)> { self.regions.find(addr) diff --git a/kernel/src/process.rs b/kernel/src/process.rs index 88c157fe60d..aa4956ec318 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -658,6 +658,12 @@ pub struct MmapRegion { pub _pages: Option, } +// One region per record, and a power of two: the ledger's doubling stops at it, inside one heap allocation. +const _: () = assert!( + crate::vma::MAX_REGIONS.is_power_of_two() + && crate::vma::MAX_REGIONS * core::mem::size_of::() <= crate::mm::MAX_HEAP_ALLOC +); + /// Zero-sized proof of running on the per-CPU idle stack; required by `collect_orphan_zombies` so it never drops the thread entry it runs on. #[derive(Clone, Copy)] diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs index 1c6f0a5665a..75314fa1401 100644 --- a/kernel/src/syscall/vm.rs +++ b/kernel/src/syscall/vm.rs @@ -93,6 +93,12 @@ pub(super) fn sys_mmap(req_addr: u64, size: u64, prot: MmapProt, flags: MmapFlag // a partial overlap is refused — `map_range` would otherwise // assert on an already-present PDE. let replacing = match as_guard.occupancy(start, aligned as u64) { + // A new region past the per-process bound is refused by name; + // the non-FIXED arm's `alloc_region` refuses the same way. A + // replacement (`Whole`) grows no ledger and is never refused here. + Occupancy::Free if !as_guard.has_region_room() => { + return Err(SyscallError::ResourceExhausted) + } Occupancy::Free => None, Occupancy::Whole => { let mine = data diff --git a/kernel/src/vma.rs b/kernel/src/vma.rs index 394c7772c46..da6c78ea234 100644 --- a/kernel/src/vma.rs +++ b/kernel/src/vma.rs @@ -24,6 +24,10 @@ pub fn window() -> Window { WINDOW } +/// The most regions one address space registers; a placement past it is +/// refused, so every ledger keyed by one region is bounded by it. +pub const MAX_REGIONS: usize = 32_768; + /// `Mapped` has no `prot`: its pages are already installed, so nothing reads one. pub enum RegionKind { @@ -74,10 +78,18 @@ impl Regions { window().gap(span, taken).map(UserAddr::new) } + /// Whether one more region may be registered. + pub fn has_room(&self) -> bool { + self.0.len() < MAX_REGIONS + } + /// Allocate a virtual address range and register the region. `size` is /// made a [`PageSpan`] before anything is summed on it. pub fn alloc(&mut self, size: u64, kind: RegionKind) -> Option { let span = window().span(size)?; + if !self.has_room() { + return None; + } let addr = self.find_gap(span)?; self.0.insert(addr, Region { size: span.bytes(), kind }); Some(addr) @@ -87,6 +99,9 @@ impl Regions { /// size in whole pages, which the caller maps. pub fn alloc_mapped(&mut self, size: u64) -> Option<(UserAddr, u64)> { let span = window().span(size)?; + if !self.has_room() { + return None; + } let addr = self.find_gap(span)?; let aligned = span.bytes(); self.0.insert(addr, Region { size: aligned, kind: RegionKind::Mapped }); @@ -98,9 +113,11 @@ impl Regions { Some(self.0.remove(&addr)?.size) } - /// Insert a region at a specific address (for ELF segments, stack, etc.) + /// Insert a region at a specific address (for ELF segments, stack, etc.); + /// a caller userland drives asks [`has_room`](Self::has_room) first. pub fn insert(&mut self, addr: UserAddr, region: Region) { assert!(self.find(addr).is_none(), "insert_region: address {:#x} already occupied", addr.raw()); + assert!(self.has_room(), "insert_region: {MAX_REGIONS} regions are registered already"); self.0.insert(addr, region); } diff --git a/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs new file mode 100644 index 00000000000..e14d898329d --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs @@ -0,0 +1,146 @@ +//! A process's count of mapped regions must not grow without bound. +//! +//! Every `mmap` registers a region in the address space and an `MmapRegion` in +//! the process's `mmap_regions` ledger (`kernel/src/syscall/vm.rs`). A +//! `PROT_NONE` mapping pins no physical page, so a loop of them costs the +//! process nothing and the kernel one 40-byte record each. Nothing but the +//! placement window bounded the count: past 32,768 records the `mmap_regions` +//! `Vec` doubled to 65,536, a 2,621,440-byte allocation past the kernel heap's +//! single-allocation ceiling (`mm::MAX_HEAP_ALLOC`, 2,093,056), where the +//! allocator asserts — a kernel panic from one unprivileged process. +//! +//! The bound belongs on the region ledger, not on `mmap` alone: the same +//! ledger backs `dlopen` and a thread's TLS block. So this drives the cheapest +//! grower, `mmap(PROT_NONE)`, and asserts the kernel refuses by name before the +//! ledger can cross the ceiling, and stays alive and allocating afterwards. +//! +//! Roughly thirty-three thousand syscalls from plain std, no crafted ELF, no +//! large-RAM guest (a `PROT_NONE` reservation owns no memory). Before the fix +//! this panics the kernel; after it, the mapping past the bound is a clean +//! `ResourceExhausted` and the count never crosses it. + +use toyos_abi::syscall::{mmap, munmap, MmapFlags, MmapProt}; + +/// `kernel/src/vma.rs`'s `MAX_REGIONS`, by value. The assertions below are +/// against the bound's *consequences* — a refusal near this count, and a live +/// kernel — not against the number, so moving it there does not make this test +/// vacuous; the band only needs to know roughly where the wall is. +const MAX_REGIONS: usize = 32_768; + +const PAGE_2M: usize = 2 * 1024 * 1024; + +/// How far below `MAX_REGIONS` the refusal may land and still be the region +/// cap: the process's own regions (its ELF segments, stack, clock page and TLS +/// block) take a few dozen slots the `PROT_NONE` loop never gets. Generous, so +/// a handful more never makes the test flaky; still tight enough that an early +/// spurious refusal — the shape that would also "pass" against a kernel that +/// refused everything — fails it. +const SLACK: usize = 4096; + +/// Map one `PROT_NONE` region, or `None` when the kernel refused. A refusal is +/// a null return (`mmap`'s wrapper collapses every error to null, and no valid +/// mapping is at address 0 — the window floor is far above it), never a dead +/// kernel: on the defect the kernel panics instead of returning here, which is +/// what the harness reads as a guest that died. +fn map_none() -> Option<*mut u8> { + let p = unsafe { + mmap( + core::ptr::null_mut(), + PAGE_2M, + MmapProt::NONE, + MmapFlags::ANONYMOUS | MmapFlags::PRIVATE, + ) + }; + (!p.is_null()).then_some(p) +} + +fn main() { + // A fixed-size store, so the loop's own bookkeeping adds no heap region + // that would perturb the count it is measuring. These are the first (and so + // highest) reservations; freeing them reopens the top of the window. + const FREED: usize = 32; + let mut top: [*mut u8; FREED] = [core::ptr::null_mut(); FREED]; + + let mut count = 0usize; + let mut refused = false; + // One attempt past the bound is enough to be refused; the margin only keeps + // a few dozen process-owned regions from making the loop stop one short. + for _ in 0..(MAX_REGIONS + 64) { + match map_none() { + Some(addr) => { + if count < FREED { + top[count] = addr; + } + count += 1; + } + None => { + refused = true; + break; + } + } + } + + // The whole point: the kernel said no rather than dying. On the defect the + // loop never reaches a refusal — the kernel panics mid-loop and this line + // is never printed. + assert!( + refused, + "the region count was never bounded: {count} PROT_NONE mappings all succeeded", + ); + // Below the documented cap (the process already holds a few non-mmap + // regions), and near it (so the refusal is the region cap and not an early + // failure a broken kernel would also give). + assert!( + count < MAX_REGIONS && count >= MAX_REGIONS - SLACK, + "refused after {count} mappings; expected within {SLACK} below MAX_REGIONS {MAX_REGIONS}", + ); + + // The cap is a live limit, not a latched failure, and the two ledgers agree: + // freeing regions reopens placement. A kernel that refused permanently, or + // whose address-space ledger disagreed with `mmap_regions`, fails here. + for &addr in &top { + unsafe { munmap(addr, PAGE_2M) }.expect("a PROT_NONE region could not be freed"); + } + + // A real RW mapping now fits where a reservation was freed: this allocates + // physical pages and maps them, so a dead heap or PMM shows here. Written + // and read back, because a mapping that cannot be touched is not one. + let live = unsafe { + mmap( + core::ptr::null_mut(), + PAGE_2M, + MmapProt::READ | MmapProt::WRITE, + MmapFlags::ANONYMOUS | MmapFlags::PRIVATE, + ) + }; + assert!(!live.is_null(), "no RW mapping fit after freeing {FREED} reservations"); + unsafe { + live.write_volatile(0x5A); + live.add(PAGE_2M - 1).write_volatile(0xA5); + assert_eq!(live.read_volatile(), 0x5A, "the live mapping's first byte did not stick"); + assert_eq!( + live.add(PAGE_2M - 1).read_volatile(), + 0xA5, + "the live mapping's last byte did not stick", + ); + } + unsafe { munmap(live, PAGE_2M) }.expect("the live mapping could not be freed"); + + // And a fresh reservation is admitted again, in a slot the frees reopened: + // the bound tracks the current count, it does not remember having refused. + let again = map_none().expect("a PROT_NONE mapping was refused after room was freed"); + unsafe { munmap(again, PAGE_2M) }.expect("the reopened reservation could not be freed"); + + // The kernel heap is intact: the defect's signature is a panic taken inside + // the allocator's lock, so "the kernel is still allocating" is the claim + // that matters, and the guest on the other end is proof it never paniced. + let mut blocks: Vec> = Vec::new(); + for i in 0..64 { + blocks.push(vec![(i % 251) as u8; 4096]); + } + for (i, b) in blocks.iter().enumerate() { + assert!(b.iter().all(|&x| x == (i % 251) as u8), "kernel heap corrupted block {i}"); + } + + println!("mmap region count bounded: refused at {count} PROT_NONE mappings, kernel alive"); +} From 192d613e317bc49c73df1e8bdccf6b42b5529fb1 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 01:00:05 +0200 Subject: [PATCH 2/5] Answer review round 1: a process's threads and libraries are bounded too, and the region probes reach every placement path The review found a second ledger with the region ledger's defect: an exited thread keeps its ThreadEntry until a join collects it, so a spawn/exit loop that never joins grows the thread table's hashbrown map to 32,768 buckets at its 14,337th entry, a 2,392,080-byte allocation past MAX_HEAP_ALLOC. The orchestrator ruled that the sweep bounds what it finds, so this bounds both it and the dlopen ledger filed last round. - toyos_abi::syscall declares MAX_REGIONS, MAX_THREADS (4096) and MAX_LIBRARIES (1024), each read by the kernel and by its test, as MAX_HANDLES is RawHandle::MAX_SLOTS. - Threads: kernel::proclife::spawn's admission answers Full at MAX_THREADS, zombies counted, at both of its moments, so the insert that decides refuses a spawn a sibling filled the process under. A const assertion in process.rs holds MAX_THREADS to hashbrown 0.16's resize rule (a table grows only once its live entries pass 7/16 of its buckets): 16,384 buckets at most, 1,196,048 bytes. - Libraries: sys_dlopen refuses a name it does not hold past MAX_LIBRARIES under the guard that registers, beside the recheck a sibling's registration needs; the refused mapping goes down with the guard. 2 x 1024 x 544 bytes fits one allocation from any starting capacity, which a const assertion holds. - Regions: alloc_mapped is alloc with RegionKind::Mapped, so the bound has one placement site; the comment and doc the review named go. - abuse_mmap_regions reads the SyscallError off the raw syscall and, at the bound, refuses a FIXED mapping at a free address and an RW mapping, maps a FIXED replacement, and shows each refusal the bound's by serving the same request once a region is freed. - abuse_thread_table and abuse_dlopen_ledger are the other two bounds' tests. The three ride one T14 boot of their own (testcases-bounds) on the process_bound_* metal rows, off the shared boot. - #697's mapping-count issue is closed by this branch and goes, with the dlopen issue this branch filed. Filed: every mmap walks every region under both locks; demand_pages outgrows one allocation at 128 GiB; a thread std detaches holds its place for good. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- ...holds-its-place-in-the-process-for-good.md | 23 +++ ...ed-library-ledger-past-the-heap-ceiling.md | 44 ---- ...s-faulting-in-128-gib-panics-the-kernel.md | 23 +++ ...t-is-bounded-only-by-its-address-window.md | 30 --- ...map-walks-every-region-under-both-locks.md | 29 +++ kernel/pure/proclife/model.rs | 3 + kernel/pure/proclife/spawn.rs | 34 ++- kernel/pure/proclife/table.rs | 4 + kernel/src/id_map.rs | 4 + kernel/src/process.rs | 22 +- kernel/src/syscall/vm.rs | 10 +- kernel/src/vma.rs | 16 +- .../src/bin/abuse_dlopen_ledger.rs | 84 ++++++++ .../src/bin/abuse_mmap_regions.rs | 195 +++++++----------- .../src/bin/abuse_thread_table.rs | 120 +++++++++++ tests/toyos.rs | 27 +++ toyos-abi/src/syscall.rs | 12 ++ 17 files changed, 462 insertions(+), 218 deletions(-) create mode 100644 issues/build/a-thread-std-detaches-holds-its-place-in-the-process-for-good.md delete mode 100644 issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md create mode 100644 issues/kernel/a-process-faulting-in-128-gib-panics-the-kernel.md delete mode 100644 issues/kernel/a-processs-mapping-count-is-bounded-only-by-its-address-window.md create mode 100644 issues/kernel/every-mmap-walks-every-region-under-both-locks.md create mode 100644 tests/toyos-rust-tests/src/bin/abuse_dlopen_ledger.rs create mode 100644 tests/toyos-rust-tests/src/bin/abuse_thread_table.rs diff --git a/issues/build/a-thread-std-detaches-holds-its-place-in-the-process-for-good.md b/issues/build/a-thread-std-detaches-holds-its-place-in-the-process-for-good.md new file mode 100644 index 00000000000..7abe5d449f1 --- /dev/null +++ b/issues/build/a-thread-std-detaches-holds-its-place-in-the-process-for-good.md @@ -0,0 +1,23 @@ +--- +status: open +kind: defect +opened: 2026-10-04 +--- + +# A thread std detaches holds its place in the process for good + +The kernel keeps an exited thread in its process's table until +`SYS_THREAD_JOIN` collects it, and counts it against +`toyos_abi::syscall::MAX_THREADS` until then +(`kernel::proclife::spawn::Admit::Full`). std's `Thread` +(`rust/library/std/src/sys/thread/toyos.rs`) has no `Drop`, and a `JoinHandle` +dropped without `join` detaches, so nothing ever collects a thread std +detached: a program that detaches threads over its life has `thread::spawn` +refused once those that exited and those still running are +`MAX_THREADS - 1`, though not one of the exited ones runs. libc's +`pthread_detach` joins the thread itself once it has exited +(`userland/libc/src/pthread.rs`, `reap`); std has no such path. + +Owner: orchestrator. Exit: a thread std detaches is collected once it exits, +and a test that spawns and drops more than `MAX_THREADS` handles, each thread +exiting, is refused none of them. diff --git a/issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md b/issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md deleted file mode 100644 index 9cd57d59755..00000000000 --- a/issues/kernel/a-dlopen-loop-grows-the-loaded-library-ledger-past-the-heap-ceiling.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-03 ---- - -# A process's loaded-library ledger is bounded only by its address window - -`sys_dlopen` (`kernel/src/syscall/vm.rs`) appends an `elf::LoadedLib` to -`ProcessData::elf.loaded_libs` and a `String` to `elf.lib_paths` for every -distinct resolved path, and nothing bounds how many one process holds. The -dedup is by the normalized path string (`vfs::resolve_absolute`), not by the -file behind it, so a process that makes N symlinks to one real shared object in -`/tmp` and `dlopen`s each resolves N distinct paths and keeps N ledger entries -for one file. - -`loaded_libs` is a `Vec` of 544-byte records. Its capacity doubles, so the push -from 2,048 to 2,049 entries grows it to a 4,096-capacity allocation of -2,228,224 bytes, past `mm::MAX_HEAP_ALLOC` (2,093,056), where -`KernelAllocator::alloc` asserts (`kernel/src/mm/alloc.rs`): a kernel panic from -one unprivileged process, the same shape as -`a-processs-mapping-count-is-bounded-only-by-its-address-window.md`. - -The per-process region cap added for that defect -(`kernel/src/vma.rs`'s `MAX_REGIONS`, 32,768) does **not** cover this: each -`dlopen` maps its image through `AddressSpace::alloc_region`, so a load is -refused once 32,768 regions are registered — but the `loaded_libs` `Vec` -doubling panics at 2,049 entries, far below that. Each of the 2,048 prior loads -costs real memory (a cached image shares physical pages, but the shared-object -cache's `BUDGET_BYTES` is 256 MiB and past it a load is `Owned`, a private 2 MiB -allocation), so on a guest with a few GiB of RAM the ledger reaches 2,049 before -the PMM runs dry. - -By reading, unmeasured here: the 544 bytes are `size_of::` and the -threshold is that over the doubling against `MAX_HEAP_ALLOC`. The sibling -defect's test (`tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs`) is the -pattern a test here would follow, driving `SYS_SYMLINK` + `SYS_DLOPEN` instead -of `mmap`. - -Owner: orchestrator. Exit condition: `dlopen` refuses by name the load past a -per-process library bound (as spawn-time `load_needed_libs` already bounds its -distinct `DT_NEEDED` set by `MAX_NEEDED_LIBS`), and a test that `dlopen`s one -shared object through distinct paths until refused reads that refusal and a live -kernel; it reds today on the panic. diff --git a/issues/kernel/a-process-faulting-in-128-gib-panics-the-kernel.md b/issues/kernel/a-process-faulting-in-128-gib-panics-the-kernel.md new file mode 100644 index 00000000000..3e1ea6ea87a --- /dev/null +++ b/issues/kernel/a-process-faulting-in-128-gib-panics-the-kernel.md @@ -0,0 +1,23 @@ +--- +status: open +kind: defect +opened: 2026-10-04 +--- + +# A process that faults in 128 GiB panics the kernel + +Every demand fault pushes the 2 MiB page it filled onto +`ProcessData::demand_pages` (`kernel/src/process.rs`, `handle_page_fault`), a +`Vec` of 24-byte `PageAlloc`s that nothing bounds but physical memory. The push +from 65,536 entries to 65,537 doubles it to a 3,145,728-byte allocation, past +`mm::MAX_HEAP_ALLOC` (2,093,056), where `KernelAllocator::alloc` asserts: one +unprivileged process touching 128 GiB of its own mappings panics the kernel. + +No machine this tree boots has that much memory — the T14's PMM manages +16,020 MiB — so it is unreached, not unreachable. The 24 bytes are +`size_of::()`, read off a build of this tree; the rest is +arithmetic over them. + +Owner: orchestrator. Exit: the ledger is held in pieces no larger than one +heap allocation, or a fault past a per-process bound is refused by name, and a +constant assertion ties whichever it is to `MAX_HEAP_ALLOC`. diff --git a/issues/kernel/a-processs-mapping-count-is-bounded-only-by-its-address-window.md b/issues/kernel/a-processs-mapping-count-is-bounded-only-by-its-address-window.md deleted file mode 100644 index 2eded65a612..00000000000 --- a/issues/kernel/a-processs-mapping-count-is-bounded-only-by-its-address-window.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-03 ---- - -# A process's mapping count is bounded only by its address window - -Every `mmap` adds a `Region` to the address space's map and an `MmapRegion` to -`ProcessData::mmap_regions` (`sys_mmap`, `kernel/src/syscall/vm.rs`), and -nothing bounds how many one process holds but the placement window -(`kernel/src/vma.rs`): 8 GiB to `STACK_BASE` in 2 MiB pages, 260,094 -placements with the 2 MiB guard and 520,188 `FIXED` ones without it. A -`PROT_NONE` mapping pins no physical page, so a loop of them costs the process -nothing and the kernel heap one record in each ledger. - -`mmap_regions` is a `Vec` of 40-byte records (`UserAddr`, `usize`, -`Option`). The push past 32,768 records grows it to 65,536, an -allocation of 2,621,440 bytes, past `mm::MAX_HEAP_ALLOC` (2,093,056), where -`KernelAllocator::alloc` (`kernel/src/mm/alloc.rs`) asserts: a kernel panic from -one unprivileged process. Below that point the records of many such processes -are kernel heap charged to nobody, and a heap that cannot grow answers `alloc` -with a null, which panics too. - -By reading, unmeasured: the 40 bytes are read off the struct, and the counts -are that arithmetic over `vma.rs`'s constants. - -Owner: orchestrator. Exit condition: `mmap` refuses by name the mapping past a -per-process bound, and a test that maps `PROT_NONE` until refused reads that -refusal and a live kernel; it reds today on the panic. diff --git a/issues/kernel/every-mmap-walks-every-region-under-both-locks.md b/issues/kernel/every-mmap-walks-every-region-under-both-locks.md new file mode 100644 index 00000000000..c1ddacfd230 --- /dev/null +++ b/issues/kernel/every-mmap-walks-every-region-under-both-locks.md @@ -0,0 +1,29 @@ +--- +status: open +kind: defect +opened: 2026-10-04 +--- + +# Every `mmap` walks every region its process holds, under both of its locks + +`sys_mmap` (`kernel/src/syscall/vm.rs`) does work in proportion to the regions +the calling process holds, with its process-data lock and its address-space +lock both taken: + +- after each placement, `peak_memory` is a sum over all of + `ProcessData::mmap_regions`; +- `Regions::find_gap` (`kernel/src/vma.rs`) walks the region map from the top + until a gap fits, which in a top-down fill is every region; +- the FIXED arm's `Regions::occupancy` filters every region below the end of + the range it asks about (`overlapping`'s `range(..end)`). + +`toyos_abi::syscall::MAX_REGIONS` (32,768) bounds each walk, and so the cost; +nothing makes it small. A fill to the bound is quadratic: one process mapping +`PROT_NONE` until refused took 70 s of a TCG guest +(`tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs`). And a sibling +thread's page fault spins on that address-space lock with interrupts off for +as long as a walk holds it. + +Owner: orchestrator. Exit: no walk of a process's regions is taken under +either lock in `sys_mmap` — the peak is a running total, and placement and +occupancy are logarithmic in the region count. diff --git a/kernel/pure/proclife/model.rs b/kernel/pure/proclife/model.rs index f2334e80399..70060a644de 100644 --- a/kernel/pure/proclife/model.rs +++ b/kernel/pure/proclife/model.rs @@ -62,6 +62,9 @@ impl Lifecycle for ModelProc { f(tid, at); } } + fn thread_count(&self) -> usize { + self.threads.len() + } fn node(&self) -> &Node { &self.node } diff --git a/kernel/pure/proclife/spawn.rs b/kernel/pure/proclife/spawn.rs index 354731ff1fb..dcea4978c83 100644 --- a/kernel/pure/proclife/spawn.rs +++ b/kernel/pure/proclife/spawn.rs @@ -18,6 +18,7 @@ use crate::proclife::table::{Lifecycle, Processes}; use crate::proclife::Pid; +use toyos_abi::syscall::MAX_THREADS; /// Whether a new thread may join a process. #[must_use = "a refused spawn must answer its caller, not fall through"] @@ -30,6 +31,9 @@ pub enum Admit { /// Somebody owns this process's teardown. A thread admitted now would be /// invisible to their retire sweep. TearingDown, + /// The process holds [`MAX_THREADS`] threads. A thread that exits stays + /// one until it is joined, so only a join makes room. + Full, } impl Admit { @@ -70,6 +74,7 @@ fn admit(proc: Option<&P>) -> Admit { match proc { None => Admit::NoSuchProcess, Some(proc) if proc.tearing_down() => Admit::TearingDown, + Some(proc) if proc.thread_count() >= MAX_THREADS => Admit::Full, Some(_) => Admit::Yes, } } @@ -78,7 +83,7 @@ fn admit(proc: Option<&P>) -> Admit { mod tests { use super::*; use crate::proclife::model::World; - use crate::proclife::teardown; + use crate::proclife::{join, teardown, ThreadLocation}; #[test] fn a_live_process_admits_a_thread_at_both_moments() { @@ -117,6 +122,33 @@ mod tests { assert_eq!(admit_thread_insert(&world, pid), Admit::TearingDown); } + /// A zombie holds its place until a join collects it, so the collection is + /// what admits again. + #[test] + fn a_full_process_refuses_at_the_start_until_a_join_collects() { + let mut world = World::new(); + let pid = world.spawn_process(); + let last = (1..MAX_THREADS).map(|_| world.spawn_thread(pid)).last().unwrap(); + assert_eq!(admit_thread_start(&world, pid), Admit::Full); + world.set_location(pid, last, ThreadLocation::Zombie(0)); + assert_eq!(admit_thread_start(&world, pid), Admit::Full); + assert_eq!(join::collect_zombie(&mut world, pid, last), Ok(Some(0))); + assert_eq!(admit_thread_start(&world, pid), Admit::Yes); + } + + /// Two spawns pass the start one thread short of the bound; the one whose + /// sibling inserted first is refused at the insert. + #[cfg(not(feature = "mutate-spawn-skips-the-insert-recheck"))] + #[test] + fn the_insert_refuses_the_spawn_a_sibling_filled_the_process_under() { + let mut world = World::new(); + let pid = world.spawn_process(); + (2..MAX_THREADS).for_each(|_| _ = world.spawn_thread(pid)); + assert_eq!(admit_thread_start(&world, pid), Admit::Yes); + world.spawn_thread(pid); + assert_eq!(admit_thread_insert(&world, pid), Admit::Full); + } + #[cfg(feature = "mutate-spawn-skips-the-insert-recheck")] #[test] fn the_mutation_really_admits_into_a_claimed_teardown() { diff --git a/kernel/pure/proclife/table.rs b/kernel/pure/proclife/table.rs index 6add75acaee..701fa3888b5 100644 --- a/kernel/pure/proclife/table.rs +++ b/kernel/pure/proclife/table.rs @@ -51,6 +51,10 @@ pub trait Lifecycle { /// Every thread of this process, in whatever order the container has. fn each_thread(&self, f: &mut dyn FnMut(Tid, ThreadLocation)); + /// How many threads the process holds, zombies a join has not collected + /// among them. + fn thread_count(&self) -> usize; + /// Where this process stands in the tree. Read and written by /// [`crate::proclife::tree`] alone. fn node(&self) -> &Node; diff --git a/kernel/src/id_map.rs b/kernel/src/id_map.rs index 90e03c97230..3757824ee3c 100644 --- a/kernel/src/id_map.rs +++ b/kernel/src/id_map.rs @@ -57,6 +57,10 @@ impl IdMap { self.map.remove(&id) } + pub fn len(&self) -> usize { + self.map.len() + } + pub fn iter(&self) -> impl Iterator { self.map.iter().map(|(&k, v)| (k, v)) } diff --git a/kernel/src/process.rs b/kernel/src/process.rs index a12131d8e26..6585159e5e0 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -26,7 +26,7 @@ use crate::loader::{alloc_kernel_stack, thread_start, TlsBlock}; pub use toyos_abi::{Pid, Tid}; pub use crate::scheduler::TaskId; -use toyos_abi::syscall::{EndowEntry, SyscallError}; +use toyos_abi::syscall::{EndowEntry, SyscallError, MAX_LIBRARIES, MAX_REGIONS, MAX_THREADS}; /// The lifecycle's decisions; this file only performs them. pub use kernel::proclife::{ThreadLocation, Watch}; @@ -375,6 +375,14 @@ impl ProcessEntry { pub fn threads_mut(&mut self) -> &mut crate::id_map::IdMap { &mut self.threads } } +// hashbrown grows a table only once its live entries pass 7/16 of its buckets, so one never holding +// more than `MAX_THREADS` stays one heap allocation: its entries, then a control byte per bucket and a group. +const _: () = { + let buckets = (16 * (MAX_THREADS + 1)).div_ceil(7).next_power_of_two(); + let entries = (buckets * core::mem::size_of::<(Tid, ThreadEntry)>()).next_multiple_of(16); + assert!(entries + buckets + 16 <= crate::mm::MAX_HEAP_ALLOC); +}; + impl ProcessEntry { /// Mirrors [`Lifecycle::tearing_down`], usable without the trait in scope. pub fn tearing_down(&self) -> bool { self.teardown_code.is_some() } @@ -401,6 +409,9 @@ impl Lifecycle for ProcessEntry { f(tid, thread.state); } } + fn thread_count(&self) -> usize { + self.threads.len() + } fn node(&self) -> &Node { &self.node } fn node_mut(&mut self) -> &mut Node { &mut self.node } } @@ -508,6 +519,9 @@ pub struct ElfInfo { pub lib_paths: Vec, } +// The ledger stops at `MAX_LIBRARIES`, and a doubling from below it stays inside one heap allocation. +const _: () = assert!(2 * MAX_LIBRARIES * core::mem::size_of::() <= crate::mm::MAX_HEAP_ALLOC); + impl ElfInfo { /// The state of a process with no ELF at all (a kernel thread). Not `Default`: `next_tls_module_id`'s only honest default is 1, not 0; written here, not at the one call site, so a field added to [`ElfInfo`] stops this build too. pub fn none() -> Self { @@ -660,8 +674,8 @@ pub struct MmapRegion { // One region per record, and a power of two: the ledger's doubling stops at it, inside one heap allocation. const _: () = assert!( - crate::vma::MAX_REGIONS.is_power_of_two() - && crate::vma::MAX_REGIONS * core::mem::size_of::() <= crate::mm::MAX_HEAP_ALLOC + MAX_REGIONS.is_power_of_two() + && MAX_REGIONS * core::mem::size_of::() <= crate::mm::MAX_HEAP_ALLOC ); @@ -927,7 +941,7 @@ pub fn spawn_thread(entry: u64, stack_ptr: u64, arg: u64, stack_base: u64) -> Op // Not `is_yes()`: a missing entry here means this thread's own process was reaped out from under it, which panics rather than refuses. match proclife_spawn::admit_thread_start(table, parent_process) { proclife_spawn::Admit::Yes => {} - proclife_spawn::Admit::TearingDown => return None, + proclife_spawn::Admit::TearingDown | proclife_spawn::Admit::Full => return None, proclife_spawn::Admit::NoSuchProcess => { panic!("spawn_thread: pid {parent_process} is spawning and is not in the table") } diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs index 75314fa1401..9c6edf668e4 100644 --- a/kernel/src/syscall/vm.rs +++ b/kernel/src/syscall/vm.rs @@ -93,9 +93,7 @@ pub(super) fn sys_mmap(req_addr: u64, size: u64, prot: MmapProt, flags: MmapFlag // a partial overlap is refused — `map_range` would otherwise // assert on an already-present PDE. let replacing = match as_guard.occupancy(start, aligned as u64) { - // A new region past the per-process bound is refused by name; - // the non-FIXED arm's `alloc_region` refuses the same way. A - // replacement (`Whole`) grows no ledger and is never refused here. + // A replacement (`Whole`) grows no ledger and is never refused here. Occupancy::Free if !as_guard.has_region_room() => { return Err(SyscallError::ResourceExhausted) } @@ -355,6 +353,12 @@ pub(super) fn sys_dlopen(ctx: &crate::user_ptr::SyscallContext, path: &str, init } return idx as u64; } + // Asked here for the same reason, so a load past the bound is refused + // having registered nothing; its mapping goes down with the guard. + if data.elf.loaded_libs.len() >= MAX_LIBRARIES { + drop(data); + return SyscallError::ResourceExhausted.to_u64(); + } mapping.commit(); let idx = data.elf.loaded_libs.len(); diff --git a/kernel/src/vma.rs b/kernel/src/vma.rs index da6c78ea234..bcd8572edb0 100644 --- a/kernel/src/vma.rs +++ b/kernel/src/vma.rs @@ -4,6 +4,7 @@ use alloc::sync::Arc; use crate::file_backing::FileBacking; use crate::mm::policy::Prot; use crate::mm::{UserAddr, PAGE_2M}; +use toyos_abi::syscall::MAX_REGIONS; use toyos_userbound::{PageSpan, Window}; /// The stack extends upward to the PIE base, so no usable VA space exists above it. @@ -24,10 +25,6 @@ pub fn window() -> Window { WINDOW } -/// The most regions one address space registers; a placement past it is -/// refused, so every ledger keyed by one region is bounded by it. -pub const MAX_REGIONS: usize = 32_768; - /// `Mapped` has no `prot`: its pages are already installed, so nothing reads one. pub enum RegionKind { @@ -78,7 +75,6 @@ impl Regions { window().gap(span, taken).map(UserAddr::new) } - /// Whether one more region may be registered. pub fn has_room(&self) -> bool { self.0.len() < MAX_REGIONS } @@ -98,14 +94,8 @@ impl Regions { /// A [`RegionKind::Mapped`] region for `size` bytes: its address and its /// size in whole pages, which the caller maps. pub fn alloc_mapped(&mut self, size: u64) -> Option<(UserAddr, u64)> { - let span = window().span(size)?; - if !self.has_room() { - return None; - } - let addr = self.find_gap(span)?; - let aligned = span.bytes(); - self.0.insert(addr, Region { size: aligned, kind: RegionKind::Mapped }); - Some((addr, aligned)) + let addr = self.alloc(size, RegionKind::Mapped)?; + Some((addr, self.0[&addr].size)) } /// Unregister the region at `addr`, answering its size. diff --git a/tests/toyos-rust-tests/src/bin/abuse_dlopen_ledger.rs b/tests/toyos-rust-tests/src/bin/abuse_dlopen_ledger.rs new file mode 100644 index 00000000000..424464239d2 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/abuse_dlopen_ledger.rs @@ -0,0 +1,84 @@ +//! A process's libraries are bounded at `MAX_LIBRARIES`: past it a load of a +//! name it does not hold is refused by name having registered nothing, and a +//! name it holds still answers. + +use toyos_abi::syscall::{self, SyscallError, MAX_LIBRARIES}; + +const DIR: &str = "/tmp/dlopen-ledger"; + +/// Loads attempted: past where a library ledger with no bound outgrows one +/// heap allocation. +const ATTEMPTS: usize = 4 * MAX_LIBRARIES; + +/// One `PT_LOAD`, read-only and exactly 2 MiB long, so the image has no +/// writable window and the shared-object cache keeps none of it: every load is +/// its own, and nothing of this test outlives it. +fn image() -> Vec { + const LEN: usize = 0x1000; + const SPAN: u64 = 2 * 1024 * 1024; + let mut elf = vec![0u8; LEN]; + elf[..8].copy_from_slice(&[0x7f, b'E', b'L', b'F', 2, 1, 1, 0]); + elf[16..18].copy_from_slice(&3u16.to_le_bytes()); // ET_DYN + elf[18..20].copy_from_slice(&62u16.to_le_bytes()); // EM_X86_64 + elf[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version + elf[32..40].copy_from_slice(&64u64.to_le_bytes()); // e_phoff + elf[52..54].copy_from_slice(&64u16.to_le_bytes()); // e_ehsize + elf[54..56].copy_from_slice(&56u16.to_le_bytes()); // e_phentsize + elf[56..58].copy_from_slice(&1u16.to_le_bytes()); // e_phnum + let ph = &mut elf[64..120]; + ph[0..4].copy_from_slice(&1u32.to_le_bytes()); // PT_LOAD + ph[4..8].copy_from_slice(&5u32.to_le_bytes()); // PF_R | PF_X + ph[32..40].copy_from_slice(&(LEN as u64).to_le_bytes()); // p_filesz + ph[40..48].copy_from_slice(&SPAN.to_le_bytes()); // p_memsz + ph[48..56].copy_from_slice(&0x1000u64.to_le_bytes()); // p_align + elf +} + +/// The libraries this process holds, the executable not among them. +fn libraries() -> usize { + let need = syscall::query_modules(&mut []).expect("the modules' size"); + let mut answer = vec![0u8; need]; + let got = syscall::query_modules(&mut answer).expect("the modules"); + syscall::modules(&answer[..got]).count() - 1 +} + +fn main() { + std::fs::create_dir_all(DIR).unwrap_or_else(|e| panic!("make {DIR}: {e}")); + let lib = format!("{DIR}/lib.so"); + std::fs::write(&lib, image()).unwrap_or_else(|e| panic!("write {lib}: {e}")); + let held = libraries(); + + let mut first = None; + let mut loaded = 0usize; + let mut refusal = None; + for i in 0..ATTEMPTS { + // A name of its own for one file: the ledger is keyed by name. + let name = format!("{DIR}/{i}.so"); + syscall::symlink(lib.as_bytes(), name.as_bytes()).unwrap_or_else(|e| panic!("link {name}: {e:?}")); + match syscall::dl_open(name.as_bytes()) { + Ok(handle) => { + first.get_or_insert((name, handle)); + loaded += 1; + } + Err(e) => { + refusal = Some(e); + break; + } + } + } + let refusal = refusal.unwrap_or_else(|| { + panic!("{loaded} names of one library all loaded: the library count is unbounded") + }); + assert_eq!( + refusal, + SyscallError::ResourceExhausted, + "the load after {loaded} was refused for another reason", + ); + assert_eq!(held + loaded, MAX_LIBRARIES, "refused after {loaded} loads beside the {held} it started with"); + assert_eq!(libraries(), MAX_LIBRARIES, "the refused load is registered"); + + let (name, handle) = first.expect("a library was loaded"); + assert_eq!(syscall::dl_open(name.as_bytes()), Ok(handle), "a name already held was refused at the bound"); + + println!("{loaded} libraries loaded and the next name refused; a name held still answers"); +} diff --git a/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs index e14d898329d..942161ff801 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs @@ -1,146 +1,95 @@ -//! A process's count of mapped regions must not grow without bound. -//! -//! Every `mmap` registers a region in the address space and an `MmapRegion` in -//! the process's `mmap_regions` ledger (`kernel/src/syscall/vm.rs`). A -//! `PROT_NONE` mapping pins no physical page, so a loop of them costs the -//! process nothing and the kernel one 40-byte record each. Nothing but the -//! placement window bounded the count: past 32,768 records the `mmap_regions` -//! `Vec` doubled to 65,536, a 2,621,440-byte allocation past the kernel heap's -//! single-allocation ceiling (`mm::MAX_HEAP_ALLOC`, 2,093,056), where the -//! allocator asserts — a kernel panic from one unprivileged process. -//! -//! The bound belongs on the region ledger, not on `mmap` alone: the same -//! ledger backs `dlopen` and a thread's TLS block. So this drives the cheapest -//! grower, `mmap(PROT_NONE)`, and asserts the kernel refuses by name before the -//! ledger can cross the ceiling, and stays alive and allocating afterwards. -//! -//! Roughly thirty-three thousand syscalls from plain std, no crafted ELF, no -//! large-RAM guest (a `PROT_NONE` reservation owns no memory). Before the fix -//! this panics the kernel; after it, the mapping past the bound is a clean -//! `ResourceExhausted` and the count never crosses it. +//! A process's regions are bounded at `MAX_REGIONS`: past it every way a +//! mapping enters the address space is refused by name, a replacement — which +//! adds no region — is not, and one region freed is room for one. -use toyos_abi::syscall::{mmap, munmap, MmapFlags, MmapProt}; +use toyos_abi::syscall::{munmap, MmapFlags, MmapProt, SyscallError, MAX_REGIONS, SYS_MMAP}; +use SyscallError::ResourceExhausted; -/// `kernel/src/vma.rs`'s `MAX_REGIONS`, by value. The assertions below are -/// against the bound's *consequences* — a refusal near this count, and a live -/// kernel — not against the number, so moving it there does not make this test -/// vacuous; the band only needs to know roughly where the wall is. -const MAX_REGIONS: usize = 32_768; +const PAGE_2M: u64 = 2 * 1024 * 1024; -const PAGE_2M: usize = 2 * 1024 * 1024; +/// The process's own regions — its segments, stack, TLS block and heap — that +/// the fill below never gets. +const OWN_REGIONS: usize = 64; -/// How far below `MAX_REGIONS` the refusal may land and still be the region -/// cap: the process's own regions (its ELF segments, stack, clock page and TLS -/// block) take a few dozen slots the `PROT_NONE` loop never gets. Generous, so -/// a handful more never makes the test flaky; still tight enough that an early -/// spurious refusal — the shape that would also "pass" against a kernel that -/// refused everything — fails it. -const SLACK: usize = 4096; +/// `syscall::mmap` answers every refusal with a null, so the error is read here. +fn map(addr: u64, prot: MmapProt, flags: MmapFlags) -> Result { + let ret: u64; + // SAFETY: a register-to-register `syscall`; no argument here is a pointer + // this call dereferences. + unsafe { + core::arch::asm!( + "syscall", + in("rdi") SYS_MMAP, + in("rsi") addr, + in("rdx") PAGE_2M, + in("r8") prot.0, + in("r9") flags.0, + lateout("rax") ret, + out("rcx") _, + out("r11") _, + ); + } + SyscallError::from_u64(ret).map_or(Ok(ret), Err) +} -/// Map one `PROT_NONE` region, or `None` when the kernel refused. A refusal is -/// a null return (`mmap`'s wrapper collapses every error to null, and no valid -/// mapping is at address 0 — the window floor is far above it), never a dead -/// kernel: on the defect the kernel panics instead of returning here, which is -/// what the harness reads as a guest that died. -fn map_none() -> Option<*mut u8> { - let p = unsafe { - mmap( - core::ptr::null_mut(), - PAGE_2M, - MmapProt::NONE, - MmapFlags::ANONYMOUS | MmapFlags::PRIVATE, - ) - }; - (!p.is_null()).then_some(p) +fn unmap(addr: u64) { + unsafe { munmap(addr as *mut u8, PAGE_2M as usize) } + .unwrap_or_else(|e| panic!("unmapping {addr:#x} was refused: {e:?}")); } fn main() { - // A fixed-size store, so the loop's own bookkeeping adds no heap region - // that would perturb the count it is measuring. These are the first (and so - // highest) reservations; freeing them reopens the top of the window. - const FREED: usize = 32; - let mut top: [*mut u8; FREED] = [core::ptr::null_mut(); FREED]; + let anywhere = MmapFlags::ANONYMOUS | MmapFlags::PRIVATE; + let placed = anywhere | MmapFlags::FIXED; + let rw = MmapProt::READ | MmapProt::WRITE; let mut count = 0usize; - let mut refused = false; - // One attempt past the bound is enough to be refused; the margin only keeps - // a few dozen process-owned regions from making the loop stop one short. - for _ in 0..(MAX_REGIONS + 64) { - match map_none() { - Some(addr) => { - if count < FREED { - top[count] = addr; + let mut kept = [0u64; 2]; + let mut lowest = u64::MAX; + let mut refusal = None; + for _ in 0..=MAX_REGIONS { + match map(0, MmapProt::NONE, anywhere) { + Ok(addr) => { + if let Some(slot) = kept.get_mut(count) { + *slot = addr; } + lowest = lowest.min(addr); count += 1; } - None => { - refused = true; + Err(e) => { + refusal = Some(e); break; } } } - - // The whole point: the kernel said no rather than dying. On the defect the - // loop never reaches a refusal — the kernel panics mid-loop and this line - // is never printed. + let refusal = refusal.unwrap_or_else(|| { + panic!("{count} PROT_NONE mappings were all placed: the region count is unbounded") + }); + assert_eq!(refusal, ResourceExhausted, "the mapping after {count} was refused for another reason"); assert!( - refused, - "the region count was never bounded: {count} PROT_NONE mappings all succeeded", + count > MAX_REGIONS - OWN_REGIONS, + "refused after {count} mappings, short of {MAX_REGIONS} by more than this process's own regions", ); - // Below the documented cap (the process already holds a few non-mmap - // regions), and near it (so the refusal is the region cap and not an early - // failure a broken kernel would also give). - assert!( - count < MAX_REGIONS && count >= MAX_REGIONS - SLACK, - "refused after {count} mappings; expected within {SLACK} below MAX_REGIONS {MAX_REGIONS}", - ); - - // The cap is a live limit, not a latched failure, and the two ledgers agree: - // freeing regions reopens placement. A kernel that refused permanently, or - // whose address-space ledger disagreed with `mmap_regions`, fails here. - for &addr in &top { - unsafe { munmap(addr, PAGE_2M) }.expect("a PROT_NONE region could not be freed"); - } - // A real RW mapping now fits where a reservation was freed: this allocates - // physical pages and maps them, so a dead heap or PMM shows here. Written - // and read back, because a mapping that cannot be touched is not one. - let live = unsafe { - mmap( - core::ptr::null_mut(), - PAGE_2M, - MmapProt::READ | MmapProt::WRITE, - MmapFlags::ANONYMOUS | MmapFlags::PRIVATE, - ) - }; - assert!(!live.is_null(), "no RW mapping fit after freeing {FREED} reservations"); - unsafe { - live.write_volatile(0x5A); - live.add(PAGE_2M - 1).write_volatile(0xA5); - assert_eq!(live.read_volatile(), 0x5A, "the live mapping's first byte did not stick"); - assert_eq!( - live.add(PAGE_2M - 1).read_volatile(), - 0xA5, - "the live mapping's last byte did not stick", - ); - } - unsafe { munmap(live, PAGE_2M) }.expect("the live mapping could not be freed"); - - // And a fresh reservation is admitted again, in a slot the frees reopened: - // the bound tracks the current count, it does not remember having refused. - let again = map_none().expect("a PROT_NONE mapping was refused after room was freed"); - unsafe { munmap(again, PAGE_2M) }.expect("the reopened reservation could not be freed"); + // Placement runs top-down, so nothing is registered below the fill. + let free = lowest - 2 * PAGE_2M; + assert_eq!( + map(free, MmapProt::NONE, placed), + Err(ResourceExhausted), + "a placed mapping at a free address was not refused at the bound", + ); + assert_eq!(map(0, rw, anywhere), Err(ResourceExhausted), "an RW mapping was not refused at the bound"); + assert_eq!( + map(kept[0], rw, placed), + Ok(kept[0]), + "a placed mapping over one of this process's own was refused at the bound, though it adds no region", + ); - // The kernel heap is intact: the defect's signature is a panic taken inside - // the allocator's lock, so "the kernel is still allocating" is the claim - // that matters, and the guest on the other end is proof it never paniced. - let mut blocks: Vec> = Vec::new(); - for i in 0..64 { - blocks.push(vec![(i % 251) as u8; 4096]); - } - for (i, b) in blocks.iter().enumerate() { - assert!(b.iter().all(|&x| x == (i % 251) as u8), "kernel heap corrupted block {i}"); - } + unmap(kept[1]); + assert_eq!(map(free, MmapProt::NONE, placed), Ok(free), "the placed mapping found no room a free region made"); + assert_eq!(map(0, rw, anywhere), Err(ResourceExhausted), "an RW mapping was placed past the bound"); + unmap(free); + let served = map(0, rw, anywhere).expect("an RW mapping found no room a free region made"); + unmap(served); - println!("mmap region count bounded: refused at {count} PROT_NONE mappings, kernel alive"); + println!("{count} mappings placed and the next refused; at the bound a replacement maps, and a free region is room for one"); } diff --git a/tests/toyos-rust-tests/src/bin/abuse_thread_table.rs b/tests/toyos-rust-tests/src/bin/abuse_thread_table.rs new file mode 100644 index 00000000000..679420e8313 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/abuse_thread_table.rs @@ -0,0 +1,120 @@ +//! A process's threads are bounded at `MAX_THREADS`, exited ones not yet +//! joined among them: past it a spawn is refused by name, and a join is room +//! for one. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use toyos_abi::syscall::{self, MmapFlags, MmapProt, SyscallError, MAX_THREADS}; + +const PAGE_2M: usize = 2 * 1024 * 1024; + +/// One thread's stack. Each gets its own: a thread runs on after it says it +/// ran, so the next one never starts on a stack still in use. +const STACK: usize = 16 * 1024; + +/// Spawns attempted: past where a thread table with no bound outgrows one +/// heap allocation. +const ATTEMPTS: usize = 4 * MAX_THREADS; + +/// The hang ceiling on one thread getting to run. +const RUN_BOUND: Duration = Duration::from_secs(10); + +/// Raw, so a thread costs its kernel record and nothing of std's. +extern "C" fn body(ran: u64) { + // SAFETY: `ran` is the address of `main`'s flag, which outlives every thread. + let ran = unsafe { &*(ran as *const AtomicU32) }; + ran.store(1, Ordering::Release); + // SAFETY: an aligned `u32` the flag owns. + unsafe { syscall::futex_wake(ran.as_ptr(), 1) }; + syscall::thread_exit(0); +} + +/// Fresh stacks carved out of 2 MiB mappings. +struct Stacks { + chunk: usize, + used: usize, +} + +impl Stacks { + /// `(base, top)` of a stack no thread has run on. + fn next(&mut self) -> (u64, u64) { + if self.chunk == 0 || self.used == PAGE_2M { + let chunk = unsafe { + syscall::mmap( + core::ptr::null_mut(), + PAGE_2M, + MmapProt::READ | MmapProt::WRITE, + MmapFlags::ANONYMOUS | MmapFlags::PRIVATE, + ) + }; + assert!(!chunk.is_null(), "no memory for thread stacks"); + self.chunk = chunk as usize; + self.used = 0; + } + let base = (self.chunk + self.used) as u64; + self.used += STACK; + (base, base + STACK as u64) + } +} + +/// Start `body` on a fresh stack and wait for it to run; the refusal if the +/// kernel gave none. +fn spawn(stacks: &mut Stacks, ran: &AtomicU32) -> Result { + let (base, top) = stacks.next(); + ran.store(0, Ordering::Relaxed); + let tid = unsafe { syscall::thread_spawn(body as *const () as u64, top, ran as *const _ as u64, base) }; + if let Some(e) = SyscallError::from_u64(tid) { + return Err(e); + } + let deadline = Instant::now() + RUN_BOUND; + while ran.load(Ordering::Acquire) == 0 { + let left = deadline.checked_duration_since(Instant::now()).unwrap_or_else(|| { + panic!("thread {tid} did not run within {RUN_BOUND:?}"); + }); + unsafe { syscall::futex_wait(ran.as_ptr(), 0, Some(left.as_nanos() as u64)) }; + } + Ok(tid) +} + +fn main() { + let ran = AtomicU32::new(0); + let mut stacks = Stacks { chunk: 0, used: 0 }; + + let mut first = None; + let mut spawned = 0usize; + let mut refusal = None; + for _ in 0..ATTEMPTS { + match spawn(&mut stacks, &ran) { + Ok(tid) => { + first.get_or_insert(tid); + spawned += 1; + } + Err(e) => { + refusal = Some(e); + break; + } + } + } + let refusal = refusal.unwrap_or_else(|| { + panic!("{spawned} threads exited unjoined and none was refused: the thread count is unbounded") + }); + assert_eq!( + refusal, + SyscallError::ResourceExhausted, + "the spawn after {spawned} was refused for another reason", + ); + // This thread is the one more. + assert_eq!(spawned, MAX_THREADS - 1, "refused after {spawned} exited threads beside this one"); + + let first = first.expect("a thread was spawned"); + assert_eq!(syscall::thread_join(first), 0, "joining the first exited thread failed"); + spawn(&mut stacks, &ran).expect("a spawn found no room the join made"); + assert_eq!( + spawn(&mut stacks, &ran), + Err(SyscallError::ResourceExhausted), + "a spawn past the bound was admitted", + ); + + println!("{spawned} threads exited unjoined and the next was refused; a join is room for one"); +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 486edcdcd90..8ffdaa7bba6 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -110,6 +110,12 @@ const RUST_SKIP: &[&str] = &[ "readdir_bound", // Fills the VFS `created_dirs` cap and leaves it there. `mkdir_cap` runs it. "mkdir_cap", + // Each fills a bound of its own process — tens of thousands of mappings, + // thousands of threads, a thousand 2 MiB images — which no shared member's + // allowance is sized for: the `process_bound_*` metal rows run them. + "abuse_mmap_regions", + "abuse_thread_table", + "abuse_dlopen_ledger", // Audio is judged on the T14 and nowhere else: the `hda_client_stall`, // `hda_tone`, `audio_idle_suspend`, `shipped_client_departures` and // `soundserver_log_stall` metal rows run these. @@ -275,6 +281,18 @@ const METAL: &[(&str, metal::Metal)] = &[ judge: |b| b[0].job_passed("test_rs_readdir_bound"), }, ), + ( + "process_bound_regions", + metal::Metal { arms: BOUNDS, judge: |b| b[0].job_passed("test_rs_abuse_mmap_regions") }, + ), + ( + "process_bound_threads", + metal::Metal { arms: BOUNDS, judge: |b| b[0].job_passed("test_rs_abuse_thread_table") }, + ), + ( + "process_bound_libraries", + metal::Metal { arms: BOUNDS, judge: |b| b[0].job_passed("test_rs_abuse_dlopen_ledger") }, + ), ( "wake_storm_cost", metal::Metal { @@ -713,6 +731,15 @@ const TESTCASES_MKDIR: &[metal::Arm] = const TESTCASES_READDIR: &[metal::Arm] = &[metal::once("testcases-readdir", "tests/testcases", &[], &["test_rs_readdir_bound"])]; +/// One boot for the three, which can share it: each fills a bound of its own +/// process, and its exit gives all of it back. +const BOUNDS: &[metal::Arm] = &[metal::once( + "testcases-bounds", + "tests/testcases", + &[], + &["test_rs_abuse_mmap_regions", "test_rs_abuse_thread_table", "test_rs_abuse_dlopen_ledger"], +)]; + const JOBCASE: &[metal::Arm] = &[metal::once("jobcase", "tests/jobcase", &[], &[])]; /// The shipping kernel with the windows' instrument and nothing else, so what diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 61e5e1a8272..f5cc926a9d5 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -936,6 +936,10 @@ pub fn mark_tty(handle: RawHandle) { syscall(SYS_MARK_TTY, handle.0 as u64, 0, 0, 0); } +/// Threads one process holds, its main thread and every exited one not yet +/// joined among them; [`thread_spawn`] past it is `ResourceExhausted`. +pub const MAX_THREADS: usize = 4096; + /// Spawn a new thread with the given entry point, stack pointer, argument, and stack base. /// `stack_base` is the bottom of the user stack (for stack info queries). /// @@ -2027,6 +2031,10 @@ pub fn readlink(path: &[u8], buf: &mut [u8]) -> Result { check(syscall(SYS_READLINK, path.as_ptr() as u64, path.len() as u64, buf.as_mut_ptr() as u64, buf.len() as u64)).map(|n| n as usize) } +/// Libraries one process holds, `DT_NEEDED` and loaded alike; [`dl_open`] of a +/// name it does not hold past it is `ResourceExhausted`. +pub const MAX_LIBRARIES: usize = 1024; + /// Load a shared library (.so) into the current process. /// Runs .init_array constructors after loading. pub fn dl_open(path: &[u8]) -> Result { @@ -2102,6 +2110,10 @@ pub fn cpu_count() -> u32 { syscall(SYS_CPU_COUNT, 0, 0, 0, 0) as u32 } +/// Regions one address space holds — every mapping, ELF segment, stack, TLS +/// block and library image; a placement past it is `ResourceExhausted`. +pub const MAX_REGIONS: usize = 32_768; + /// Map anonymous memory. Returns pointer on success, null on failure. /// /// If `addr` is null, the kernel chooses the address — and so it does for From 61a1189138ecf5a51a28d6658119e16af1346700 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 01:22:20 +0200 Subject: [PATCH 3/5] abuse_mmap_regions judges its probes once it has made room to judge them in At the bound a failed assertion's message needs a heap the process has no region left to grow: under the guard-ahead-of-the-match mutation the test aborted with 134 and said nothing. The probes are now taken at the bound, the fill's first mappings freed, and only then judged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- .../src/bin/abuse_mmap_regions.rs | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs index 942161ff801..9126e948698 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs @@ -11,6 +11,10 @@ const PAGE_2M: u64 = 2 * 1024 * 1024; /// the fill below never gets. const OWN_REGIONS: usize = 64; +/// The fill's first mappings, kept by address: one replaced and one freed at +/// the bound, the rest freed before anything is judged. +const KEPT: usize = 8; + /// `syscall::mmap` answers every refusal with a null, so the error is read here. fn map(addr: u64, prot: MmapProt, flags: MmapFlags) -> Result { let ret: u64; @@ -43,7 +47,7 @@ fn main() { let rw = MmapProt::READ | MmapProt::WRITE; let mut count = 0usize; - let mut kept = [0u64; 2]; + let mut kept = [0u64; KEPT]; let mut lowest = u64::MAX; let mut refusal = None; for _ in 0..=MAX_REGIONS { @@ -64,32 +68,35 @@ fn main() { let refusal = refusal.unwrap_or_else(|| { panic!("{count} PROT_NONE mappings were all placed: the region count is unbounded") }); + + // Placement runs top-down, so nothing is registered below the fill. + let free = lowest - 2 * PAGE_2M; + let placed_free = map(free, MmapProt::NONE, placed); + let anywhere_rw = map(0, rw, anywhere); + let replaced = map(kept[0], rw, placed); + unmap(kept[1]); + let placed_free_again = map(free, MmapProt::NONE, placed); + let anywhere_rw_again = map(0, rw, anywhere); + placed_free_again.into_iter().chain(anywhere_rw_again).for_each(unmap); + let served = map(0, rw, anywhere); + // Room for this process's own heap, which a failed assertion's message needs. + served.into_iter().chain(kept[2..].iter().copied()).for_each(unmap); + assert_eq!(refusal, ResourceExhausted, "the mapping after {count} was refused for another reason"); assert!( count > MAX_REGIONS - OWN_REGIONS, "refused after {count} mappings, short of {MAX_REGIONS} by more than this process's own regions", ); - - // Placement runs top-down, so nothing is registered below the fill. - let free = lowest - 2 * PAGE_2M; - assert_eq!( - map(free, MmapProt::NONE, placed), - Err(ResourceExhausted), - "a placed mapping at a free address was not refused at the bound", - ); - assert_eq!(map(0, rw, anywhere), Err(ResourceExhausted), "an RW mapping was not refused at the bound"); + assert_eq!(placed_free, Err(ResourceExhausted), "a placed mapping at a free address was not refused at the bound"); + assert_eq!(anywhere_rw, Err(ResourceExhausted), "an RW mapping was not refused at the bound"); assert_eq!( - map(kept[0], rw, placed), + replaced, Ok(kept[0]), "a placed mapping over one of this process's own was refused at the bound, though it adds no region", ); - - unmap(kept[1]); - assert_eq!(map(free, MmapProt::NONE, placed), Ok(free), "the placed mapping found no room a free region made"); - assert_eq!(map(0, rw, anywhere), Err(ResourceExhausted), "an RW mapping was placed past the bound"); - unmap(free); - let served = map(0, rw, anywhere).expect("an RW mapping found no room a free region made"); - unmap(served); + assert_eq!(placed_free_again, Ok(free), "the placed mapping found no room a free region made"); + assert_eq!(anywhere_rw_again, Err(ResourceExhausted), "an RW mapping was placed past the bound"); + assert!(served.is_ok(), "an RW mapping found no room a free region made: {served:?}"); println!("{count} mappings placed and the next refused; at the bound a replacement maps, and a free region is room for one"); } From debee71e118a5000e5da1dd1d9c48f747050f235 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 02:30:33 +0200 Subject: [PATCH 4/5] Answer review round 2: IdMap is a BTreeMap, and the raw mmap lives in the test crate's arch module The thread table's bound rested on a hand copy of hashbrown 0.16's private resize rule (a table grows only past 7/16 of its buckets), held by a const assertion that a `cargo update` could leave stale with every gate green. `IdMap` now keeps a `BTreeMap`: its nodes are fixed-size allocations, so no count it holds grows one allocation towards `mm::MAX_HEAP_ALLOC`, and the thread table's bound rests on `MAX_THREADS` alone. The assertion and its comment go, and so do the two proclife comments that said the kernel's container is a `hashbrown::HashMap`. The swap also takes `pipe::PIPES`, the machine-wide pipe table and the other `IdMap`, off a single growing allocation. Both `IdMap`s are looked up on hot paths: every syscall reads the calling thread's entry twice (`with_current_data` in `syscall_dispatch`), and every pipe read, write and poll reads `PIPES` once. On the development host, one `get` costs 1.1 to 6.2 ns on a `BTreeMap` of 1 to 64 entries against 2.0 to 3.8 ns on hashbrown with the kernel's hasher, and 13.6 ns against 2.4 ns at 4096 entries. `abuse_mmap_regions` and `mmap_prot` each carried an inline `syscall` for `SYS_MMAP`; both now call `arch/mmap.rs`'s `raw`, which answers `Result` and is `unsafe` because a `FIXED` mapping replaces what the process has there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- kernel/pure/proclife/model.rs | 4 -- kernel/pure/proclife/table.rs | 6 +-- kernel/src/id_map.rs | 10 ++-- kernel/src/process.rs | 10 +--- tests/toyos-rust-tests/src/arch/mmap.rs | 35 +++++++++++++ .../src/bin/abuse_mmap_regions.rs | 49 ++++++++----------- tests/toyos-rust-tests/src/bin/mmap_prot.rs | 49 ++++++------------- 7 files changed, 78 insertions(+), 85 deletions(-) create mode 100644 tests/toyos-rust-tests/src/arch/mmap.rs diff --git a/kernel/pure/proclife/model.rs b/kernel/pure/proclife/model.rs index 70060a644de..97c181e11bc 100644 --- a/kernel/pure/proclife/model.rs +++ b/kernel/pure/proclife/model.rs @@ -8,10 +8,6 @@ //! checking are about the order //! those happen in, and a model that only held the two states could not see //! one. -//! -//! A `BTreeMap` where the kernel has a `hashbrown::HashMap`: nothing here -//! depends on the order, and a model whose counter-example is different every -//! run is a model nobody can bisect. use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::String; diff --git a/kernel/pure/proclife/table.rs b/kernel/pure/proclife/table.rs index 701fa3888b5..f5ce7e039ab 100644 --- a/kernel/pure/proclife/table.rs +++ b/kernel/pure/proclife/table.rs @@ -7,11 +7,7 @@ //! of them — which is what makes the host model in `model.rs` a `BTreeMap` and //! not a simulated kernel. //! -//! `each_thread` and `each_pid` take a `&mut dyn FnMut` rather than answering an -//! iterator, because the kernel's two containers are a `hashbrown::HashMap` and -//! this module's model is a `BTreeMap`: an associated iterator type would put -//! both spellings in the trait for no decision's benefit. A caller that needs -//! an order sorts what it collected, and the two that do +//! A caller that needs an order sorts what it collected, and the two that do //! ([`crate::proclife::teardown::retire_set`] and [`crate::proclife::reap::finished_pids`]) say so. use crate::proclife::{Node, Pid, Pids, ThreadLocation, Tid}; diff --git a/kernel/src/id_map.rs b/kernel/src/id_map.rs index 3757824ee3c..8c9621f6819 100644 --- a/kernel/src/id_map.rs +++ b/kernel/src/id_map.rs @@ -1,14 +1,14 @@ -use core::hash::Hash; +use alloc::collections::BTreeMap; use core::ops::Add; -use crate::hasher::HashMap; /// Map with auto-incrementing, never-reused keys. pub struct IdMap { - map: HashMap, + /// A `BTreeMap`, whose nodes are fixed-size allocations: no count it holds grows one allocation towards `mm::MAX_HEAP_ALLOC`. + map: BTreeMap, next: K, } -pub trait IdKey: Copy + Eq + Hash + Ord + Add { +pub trait IdKey: Copy + Ord + Add { const ZERO: Self; const ONE: Self; } @@ -22,7 +22,7 @@ impl IdKey for toyos_abi::Tid { impl IdMap { pub fn new() -> Self { Self { - map: HashMap::default(), + map: BTreeMap::new(), next: K::ZERO, } } diff --git a/kernel/src/process.rs b/kernel/src/process.rs index 6585159e5e0..b963957716b 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -26,7 +26,7 @@ use crate::loader::{alloc_kernel_stack, thread_start, TlsBlock}; pub use toyos_abi::{Pid, Tid}; pub use crate::scheduler::TaskId; -use toyos_abi::syscall::{EndowEntry, SyscallError, MAX_LIBRARIES, MAX_REGIONS, MAX_THREADS}; +use toyos_abi::syscall::{EndowEntry, SyscallError, MAX_LIBRARIES, MAX_REGIONS}; /// The lifecycle's decisions; this file only performs them. pub use kernel::proclife::{ThreadLocation, Watch}; @@ -375,14 +375,6 @@ impl ProcessEntry { pub fn threads_mut(&mut self) -> &mut crate::id_map::IdMap { &mut self.threads } } -// hashbrown grows a table only once its live entries pass 7/16 of its buckets, so one never holding -// more than `MAX_THREADS` stays one heap allocation: its entries, then a control byte per bucket and a group. -const _: () = { - let buckets = (16 * (MAX_THREADS + 1)).div_ceil(7).next_power_of_two(); - let entries = (buckets * core::mem::size_of::<(Tid, ThreadEntry)>()).next_multiple_of(16); - assert!(entries + buckets + 16 <= crate::mm::MAX_HEAP_ALLOC); -}; - impl ProcessEntry { /// Mirrors [`Lifecycle::tearing_down`], usable without the trait in scope. pub fn tearing_down(&self) -> bool { self.teardown_code.is_some() } diff --git a/tests/toyos-rust-tests/src/arch/mmap.rs b/tests/toyos-rust-tests/src/arch/mmap.rs new file mode 100644 index 00000000000..dc4aa7cb12e --- /dev/null +++ b/tests/toyos-rust-tests/src/arch/mmap.rs @@ -0,0 +1,35 @@ +//! `SYS_MMAP` past `toyos_abi::syscall::mmap`, which answers every refusal with +//! a null: here the kernel's error comes back by name. + +#[cfg(target_arch = "x86_64")] +pub use x86_64::*; + +#[cfg(target_arch = "x86_64")] +mod x86_64 { + use toyos_abi::syscall::{MmapFlags, MmapProt, SyscallError, SYS_MMAP}; + + /// The mapping's address, or the error the kernel refused it with. + /// + /// # Safety + /// A `FIXED` mapping replaces whatever this process has at `addr`. + pub unsafe fn raw(addr: u64, len: u64, prot: MmapProt, flags: MmapFlags) -> Result { + let ret: u64; + // SAFETY: the caller's for what a `FIXED` mapping replaces; no argument + // is a pointer this call dereferences, and the `syscall` is the ABI's + // (`toyos_abi::syscall`), which clobbers rax, rcx and r11. + unsafe { + core::arch::asm!( + "syscall", + in("rdi") SYS_MMAP, + in("rsi") addr, + in("rdx") len, + in("r8") prot.0, + in("r9") flags.0, + lateout("rax") ret, + out("rcx") _, + out("r11") _, + ); + } + SyscallError::from_u64(ret).map_or(Ok(ret), Err) + } +} diff --git a/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs index 9126e948698..4f517619491 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_mmap_regions.rs @@ -2,7 +2,10 @@ //! mapping enters the address space is refused by name, a replacement — which //! adds no region — is not, and one region freed is room for one. -use toyos_abi::syscall::{munmap, MmapFlags, MmapProt, SyscallError, MAX_REGIONS, SYS_MMAP}; +#[path = "../arch/mmap.rs"] +mod mmap; + +use toyos_abi::syscall::{munmap, MmapFlags, MmapProt, SyscallError, MAX_REGIONS}; use SyscallError::ResourceExhausted; const PAGE_2M: u64 = 2 * 1024 * 1024; @@ -15,27 +18,6 @@ const OWN_REGIONS: usize = 64; /// the bound, the rest freed before anything is judged. const KEPT: usize = 8; -/// `syscall::mmap` answers every refusal with a null, so the error is read here. -fn map(addr: u64, prot: MmapProt, flags: MmapFlags) -> Result { - let ret: u64; - // SAFETY: a register-to-register `syscall`; no argument here is a pointer - // this call dereferences. - unsafe { - core::arch::asm!( - "syscall", - in("rdi") SYS_MMAP, - in("rsi") addr, - in("rdx") PAGE_2M, - in("r8") prot.0, - in("r9") flags.0, - lateout("rax") ret, - out("rcx") _, - out("r11") _, - ); - } - SyscallError::from_u64(ret).map_or(Ok(ret), Err) -} - fn unmap(addr: u64) { unsafe { munmap(addr as *mut u8, PAGE_2M as usize) } .unwrap_or_else(|e| panic!("unmapping {addr:#x} was refused: {e:?}")); @@ -51,7 +33,8 @@ fn main() { let mut lowest = u64::MAX; let mut refusal = None; for _ in 0..=MAX_REGIONS { - match map(0, MmapProt::NONE, anywhere) { + // SAFETY: not `FIXED`, so nothing of this process's is replaced. + match unsafe { mmap::raw(0, PAGE_2M, MmapProt::NONE, anywhere) } { Ok(addr) => { if let Some(slot) = kept.get_mut(count) { *slot = addr; @@ -71,14 +54,22 @@ fn main() { // Placement runs top-down, so nothing is registered below the fill. let free = lowest - 2 * PAGE_2M; - let placed_free = map(free, MmapProt::NONE, placed); - let anywhere_rw = map(0, rw, anywhere); - let replaced = map(kept[0], rw, placed); + // SAFETY: a `FIXED` mapping lands at `free`, where nothing is, or over + // `kept[0]`, which this process reads nothing through. + let (placed_free, anywhere_rw, replaced) = unsafe { + ( + mmap::raw(free, PAGE_2M, MmapProt::NONE, placed), + mmap::raw(0, PAGE_2M, rw, anywhere), + mmap::raw(kept[0], PAGE_2M, rw, placed), + ) + }; unmap(kept[1]); - let placed_free_again = map(free, MmapProt::NONE, placed); - let anywhere_rw_again = map(0, rw, anywhere); + // SAFETY: as above. + let (placed_free_again, anywhere_rw_again) = + unsafe { (mmap::raw(free, PAGE_2M, MmapProt::NONE, placed), mmap::raw(0, PAGE_2M, rw, anywhere)) }; placed_free_again.into_iter().chain(anywhere_rw_again).for_each(unmap); - let served = map(0, rw, anywhere); + // SAFETY: not `FIXED`. + let served = unsafe { mmap::raw(0, PAGE_2M, rw, anywhere) }; // Room for this process's own heap, which a failed assertion's message needs. served.into_iter().chain(kept[2..].iter().copied()).for_each(unmap); diff --git a/tests/toyos-rust-tests/src/bin/mmap_prot.rs b/tests/toyos-rust-tests/src/bin/mmap_prot.rs index e592f4e7038..4cb8b82f4f4 100644 --- a/tests/toyos-rust-tests/src/bin/mmap_prot.rs +++ b/tests/toyos-rust-tests/src/bin/mmap_prot.rs @@ -25,7 +25,10 @@ use std::process::{Command, Stdio}; use std::sync::mpsc; use std::thread; -use toyos_abi::syscall::{mmap, munmap, MmapFlags, MmapProt, SyscallError, SYS_MMAP}; +use toyos_abi::syscall::{mmap, munmap, MmapFlags, MmapProt, SyscallError}; + +#[path = "../arch/mmap.rs"] +mod mmap; const SIZE: usize = 4096; /// Well inside the 2 MiB page every mapping is rounded up to. @@ -127,44 +130,23 @@ fn an_undefined_bit_in_either_word_is_refused() { (MmapProt(PROT.0 | UNDEFINED_PROT), FLAGS, "a prot"), (PROT, MmapFlags(FLAGS.0 | UNDEFINED_FLAG), "a flags"), ] { - let refused = mmap_raw(SIZE, prot, flags); + // SAFETY: not `FIXED`, so nothing of this process's is replaced. + let refused = unsafe { mmap::raw(0, SIZE as u64, prot, flags) }; assert_eq!( - SyscallError::from_u64(refused), - Some(SyscallError::InvalidArgument), - "{what} bit this ABI does not define was served: {refused:#x}", + refused, + Err(SyscallError::InvalidArgument), + "{what} bit this ABI does not define was served: {refused:#x?}", ); } // The control: the same request without either bit is the mapping the // refusals above must not have been about. - let served = mmap_raw(SIZE, PROT, FLAGS); - assert_eq!(SyscallError::from_u64(served), None, "the defined request was refused"); + // SAFETY: not `FIXED`. + let served = unsafe { mmap::raw(0, SIZE as u64, PROT, FLAGS) }.expect("the defined request was refused"); unsafe { munmap(served as *mut u8, SIZE) }.expect("unmap the served request"); println!(" PASS: an undefined mmap prot or flags bit is InvalidArgument, and without it the same call maps"); } -/// `syscall::mmap` reports a refusal as a null pointer, which cannot tell -/// `InvalidArgument` from any other error; the raw return can. -fn mmap_raw(size: usize, prot: MmapProt, flags: MmapFlags) -> u64 { - let ret: u64; - // SAFETY: a register-to-register `syscall`; no argument here is a pointer - // this call dereferences. - unsafe { - core::arch::asm!( - "syscall", - in("rdi") SYS_MMAP, - in("rsi") 0u64, - in("rdx") size as u64, - in("r8") prot.0, - in("r9") flags.0, - lateout("rax") ret, - out("rcx") _, - out("r11") _, - ); - } - ret -} - /// Lengths past every placement window. const UNPLACEABLE: [u64; 4] = [u64::MAX - PAGE_2M, u64::MAX - (PAGE_2M - 1), u64::MAX, 1 << 47]; @@ -204,7 +186,8 @@ fn a_length_no_window_holds_is_refused_and_the_space_still_answers() { let mut answers = Vec::with_capacity(2 * UNPLACEABLE.len()); for prot in [MmapProt::NONE, MmapProt::READ | MmapProt::WRITE] { for size in UNPLACEABLE { - answers.push((prot, size, mmap_raw(size as usize, prot, MmapFlags::ANONYMOUS | MmapFlags::PRIVATE))); + // SAFETY: not `FIXED`. + answers.push((prot, size, unsafe { mmap::raw(0, size, prot, MmapFlags::ANONYMOUS | MmapFlags::PRIVATE) })); } } @@ -215,9 +198,9 @@ fn a_length_no_window_holds_is_refused_and_the_space_still_answers() { for (prot, size, ret) in answers { assert_eq!( - SyscallError::from_u64(ret), - Some(SyscallError::InvalidArgument), - "mmap(len={size:#x}, prot={:#x}) answered {ret:#x}", + ret, + Err(SyscallError::InvalidArgument), + "mmap(len={size:#x}, prot={:#x}) answered {ret:#x?}", prot.0, ); } From c8e17edfca2303c6e8367a202fcade8bfab865ff Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 4 Oct 2026 02:42:16 +0200 Subject: [PATCH 5/5] kernelkeys: IdMap holds no hashed container, so its row retires `IdMap` keeps a `BTreeMap` since the previous commit; the scan reds on a row whose file holds no `HashMap`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8 --- src/kernelkeys.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/kernelkeys.rs b/src/kernelkeys.rs index 4d28b91a29e..b7a86be6fd9 100644 --- a/src/kernelkeys.rs +++ b/src/kernelkeys.rs @@ -43,11 +43,6 @@ pub struct Declared { /// Every hashed container in `kernel/src`, with the origin of its keys. pub const DECLARED: &[Declared] = &[ - Declared { - file: "kernel/src/id_map.rs", - ty: "HashMap", - keys: "`IdKey`, which no integer implements: every key is an id this kernel issued", - }, Declared { file: "kernel/src/arch/x86_64/paging.rs", ty: "HashMap",