Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions issues/kernel/a-process-faulting-in-128-gib-panics-the-kernel.md
Original file line number Diff line number Diff line change
@@ -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::<PageAlloc>()`, 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`.

This file was deleted.

29 changes: 29 additions & 0 deletions issues/kernel/every-mmap-walks-every-region-under-both-locks.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 3 additions & 4 deletions kernel/pure/proclife/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,6 +58,9 @@ impl Lifecycle for ModelProc {
f(tid, at);
}
}
fn thread_count(&self) -> usize {
self.threads.len()
}
fn node(&self) -> &Node {
&self.node
}
Expand Down
34 changes: 33 additions & 1 deletion kernel/pure/proclife/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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 {
Expand Down Expand Up @@ -70,6 +74,7 @@ fn admit<P: Lifecycle>(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,
}
}
Expand All @@ -78,7 +83,7 @@ fn admit<P: Lifecycle>(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() {
Expand Down Expand Up @@ -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() {
Expand Down
10 changes: 5 additions & 5 deletions kernel/pure/proclife/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -51,6 +47,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;
Expand Down
4 changes: 4 additions & 0 deletions kernel/src/arch/aarch64/paging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions kernel/src/arch/x86_64/paging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions kernel/src/id_map.rs
Original file line number Diff line number Diff line change
@@ -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<K, V> {
map: HashMap<K, V>,
/// A `BTreeMap`, whose nodes are fixed-size allocations: no count it holds grows one allocation towards `mm::MAX_HEAP_ALLOC`.
map: BTreeMap<K, V>,
next: K,
}

pub trait IdKey: Copy + Eq + Hash + Ord + Add<Output = Self> {
pub trait IdKey: Copy + Ord + Add<Output = Self> {
const ZERO: Self;
const ONE: Self;
}
Expand All @@ -22,7 +22,7 @@ impl IdKey for toyos_abi::Tid {
impl<K: IdKey, V> IdMap<K, V> {
pub fn new() -> Self {
Self {
map: HashMap::default(),
map: BTreeMap::new(),
next: K::ZERO,
}
}
Expand Down Expand Up @@ -57,6 +57,10 @@ impl<K: IdKey, V> IdMap<K, V> {
self.map.remove(&id)
}

pub fn len(&self) -> usize {
self.map.len()
}

pub fn iter(&self) -> impl Iterator<Item = (K, &V)> {
self.map.iter().map(|(&k, v)| (k, v))
}
Expand Down
16 changes: 14 additions & 2 deletions kernel/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

/// The lifecycle's decisions; this file only performs them.
pub use kernel::proclife::{ThreadLocation, Watch};
Expand Down Expand Up @@ -401,6 +401,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 }
}
Expand Down Expand Up @@ -508,6 +511,9 @@ pub struct ElfInfo {
pub lib_paths: Vec<String>,
}

// 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::<elf::LoadedLib>() <= 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 {
Expand Down Expand Up @@ -658,6 +664,12 @@ pub struct MmapRegion {
pub _pages: Option<PageAlloc>,
}

// One region per record, and a power of two: the ledger's doubling stops at it, inside one heap allocation.
const _: () = assert!(
MAX_REGIONS.is_power_of_two()
&& MAX_REGIONS * core::mem::size_of::<MmapRegion>() <= 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)]
Expand Down Expand Up @@ -921,7 +933,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")
}
Expand Down
10 changes: 10 additions & 0 deletions kernel/src/syscall/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ 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 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
Expand Down Expand Up @@ -349,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();
Expand Down
Loading
Loading