From 672f04b6893105700cf7a28028bce8bda3b9925c Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 05:30:21 +0200 Subject: [PATCH 01/16] A parent's end takes its children down Stage 4 of issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md, as ruled: every process has one parent, and an end -- exit, kill, CPU fault, handle fault -- takes its whole subtree. The decisions are toyos-proclife's new `tree` module. Each process carries a `Node`: its parent, its depth below init, the children placed under it, and a count of what holds its publication -- its own teardown and every child admitted under it and not yet published or refused. - A spawn is admitted under its place at the top of the loader, before anything is built: a place being torn down answers `Gone`, and a child more than MAX_DEPTH (64) below init `ResourceExhausted`, which the kernel logs with the depth. Admission raises the place's count. The insert asks again under the lock that inserts, and a place claimed since refuses it (`Gone`) and lowers the count, as every failed build does through the `Admission` guard's drop. - An end claims its top, then walks: one claim per hold of the table lock, each claim reading the children it owes in the same hold, so a child either landed before the claim and is walked or is refused at its insert. Between two claims a reschedule owed is served by `yield_now` with nothing held. The walk runs in `process::exit` (exit, CPU fault, handle fault) and in `kill_process`, on the ending or killing thread. A process another end claimed first is that end's to walk. - A teardown no longer publishes at once. It keeps its `Exit` on the entry and lowers its own share of the count; whoever lowers a count to zero publishes, then lowers the parent's: child before parent, at most MAX_DEPTH + 1 publications, with preemption off and the table lock given up for each publication. The ABI: `SpawnArgs` gains `place` (112 to 120 bytes): a handle carrying `WRITE` to the process the child goes under, or HANDLE_INVALID for the caller. Every process starts holding a handle to itself under `self` (`WRITE`, `DUP`, `TRANSFER`), installed by the commit that moves the endowments, so a table and a label blob hold one entry and four bytes more than a spawn may carry. The place and SYS_NAMESPACE_BUILD's connector are both handles a peer sent, and resolve through one lookup, `HandleTable::get_sent`, which answers a wrong type `InvalidArgument`; the connector's own match in ipc.rs goes. The launch wire names the parent: its header's ninth word is a place, which travels as the batch's last handle and counts against MAX_LAUNCH_EXTRAS, or init. A launch naming neither is refused. init spawns under the place (`CommandExt::under`) and answers a place being torn down with the new MSG_GONE. std launches with a copy of the caller's `self`; `under_init` asks for init and is a launch or nothing -- no launcher, a program no row declares, an endowment or an extra slot answers `PermissionDenied` and starts nothing. std's direct spawn maps the kernel's word through `to_io_error`, so init can tell `Gone` (BrokenPipe) from a refusal, and makes its endowment table after the routing: a launched spawn had leaked the namespace copy made for a direct one. The shell gains `detach`, which starts a program under init and does not wait for it. `compositor_client_death`'s relay outlived its creator; it is now placed under the test's root, through the root's `self`, so the creator's end does not take it down. Host: four new interleavings and a depth test, each with a feature control in src/ci.rs's CONTROLS. Guest: `process_tree` on tests/proctreecase, and two new arms in `launcher_refusals`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- ...nt-and-a-parent-takes-its-children-down.md | 53 +-- kernel/src/id_map.rs | 16 + kernel/src/loader/mod.rs | 48 +- kernel/src/loader/start.rs | 40 +- kernel/src/object/handle.rs | 14 +- kernel/src/process.rs | 179 ++++++-- kernel/src/sched/kthread.rs | 3 +- kernel/src/syscall/dispatch.rs | 14 +- kernel/src/syscall/ipc.rs | 6 +- kernel/src/syscall/proc.rs | 20 +- rust | 2 +- src/ci.rs | 12 + system.toml | 6 +- tests/proctreecase/system.toml | 59 +++ .../src/bin/abuse_elf_loader.rs | 2 + .../src/bin/abuse_elf_segments.rs | 1 + .../src/bin/abuse_handle_table.rs | 1 + .../src/bin/abuse_page_straddle.rs | 1 + .../src/bin/abuse_spawn_argv.rs | 1 + tests/toyos-rust-tests/src/bin/blockd_io.rs | 1 + .../src/bin/compositor_client_death.rs | 26 +- .../src/bin/device_claim_lifetime.rs | 1 + .../src/bin/handle_kill_policy.rs | 1 + .../src/bin/launcher_refusals.rs | 87 +++- .../src/bin/process_lifecycle.rs | 2 +- .../toyos-rust-tests/src/bin/process_tree.rs | 422 ++++++++++++++++++ tests/toyos-rust-tests/src/bin/spawn_cwd.rs | 1 + .../src/bin/spawn_image_object.rs | 1 + tests/toyos.rs | 63 +++ toyos-abi/src/syscall.rs | 21 +- toyos-proclife/Cargo.toml | 18 + toyos-proclife/src/interleave.rs | 286 ++++++++++-- toyos-proclife/src/lib.rs | 6 +- toyos-proclife/src/model.rs | 141 +++++- toyos-proclife/src/table.rs | 7 +- toyos-proclife/src/tree.rs | 240 ++++++++++ toyos-userbound/src/span.rs | 2 +- toyos/src/endow.rs | 19 +- toyos/src/launch.rs | 76 +++- userland/init/src/main.rs | 45 +- userland/shell/src/main.rs | 31 +- userland/toybox/src/ps.rs | 3 +- 42 files changed, 1763 insertions(+), 215 deletions(-) create mode 100644 tests/proctreecase/system.toml create mode 100644 tests/toyos-rust-tests/src/bin/process_tree.rs create mode 100644 toyos-proclife/src/tree.rs diff --git a/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index 8a725701db4..ba4ba9eb3e2 100644 --- a/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md +++ b/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md @@ -117,57 +117,6 @@ directory is `issues/isolation/every-program-sees-only-the-files-it-was-given.md the pipe answering (passed to the kernel, which ends the caller). Negative control: today's libc, against which the corpus does not link. Oracle: POSIX's `posix_spawn`, `waitpid`, `poll` and `SA_RESTART`, and signal(7). -4. **A parent takes its children down** (ruled). A spawn's parent is its - spawner. A launch names its parent in its request, by one of two words: the - caller, whose place is a copy of the handle to itself every process starts - holding under the label `self` (`WRITE`, `DUP`, `TRANSFER`), carried as one - of the batch's five extras (`toyos/src/launch.rs`); or init, the one way to - outlive a starter, which std's ToyOS `CommandExt` asks for and the shell - gains a way to use; std answers a request for init it cannot launch - `PermissionDenied`, as it answers a `provide` with no launcher, and spawns - nothing. init spawns under the place, which `SpawnArgs` gains (112 bytes to - 120). The place and `SYS_NAMESPACE_BUILD`'s connector are each a handle a - peer sent, and one lookup resolves both, answering a wrong type - `InvalidArgument` rather than ending the caller: `HandleError::WrongType` - declares that exception once, for that lookup, and the connector's own match - in `kernel/src/syscall/ipc.rs` goes. The kernel keeps each process's parent, - children and depth on its table entry, answered by no syscall. A spawn that - would put a process more than `toyos_proclife::MAX_DEPTH` (64) below init, - counted from the place, is refused `ResourceExhausted`, and the kernel logs - a depth refusal naming that depth. Every end — exit, kill, CPU fault, - handle fault — closes admission at its top and claims its subtree on the - ending or killing thread, one process per table-lock hold, calling - `scheduler::yield_now` between two claims, with nothing held, whenever a - reschedule is owed. An end is published once its teardown is done and every - child's end is, by a count admission raises and a child's publication or - refused insert lowers, so the climb from the last teardown, run with - preemption off, is at most `MAX_DEPTH` publications. Decided in - `toyos-proclife`; init's part goes where launch resolution is by then - (`issues/isolation/the-supervisor-is-host-tested-and-owns-the-stop.md` stage - 2). *Exit*: host — `toyos-proclife`'s interleavings of a spawn racing its - place's kill land nothing under it, end all under it and publish it in every - one; a spawn under an unrelated process lands between two claims of one - walk; a chain is refused at `MAX_DEPTH` + 1 below init. Guest — A starts B, - B starts C and launches D: killing B ends all three, read as killed, and A's - watch on B completes after C's and D's; so do B's exit, a CPU fault and a - handle fault in B; a spawn or launch under B after its kill answers `Gone`; - a chain alternating spawn and launch, each process starting the next, stops - where the kernel's depth refusal names `MAX_DEPTH` + 1, and dies whole with - its first; a launch that neither carries a place nor asks for init is - refused and nothing starts, and so is a request for init to start an - undeclared program or carrying an extra slot; a launch whose place is a - pipe is refused and init answers the next; a `MANAGE`-only place answers - `PermissionDenied`; sshd launched from a shell dies with it, and with init - as its parent outlives it. Negative control: the stage reverted whole, where - C and D outlive B. Mutations: a walk that skips a launched child (D); a - publication before the children's (A's watch); a walk from `sys_exit` alone - (the faults); the walk in one lock hold (the interleaved spawn); the count - kept on a refused insert (B unpublished); the place looked up as any handle - (init ends); no depth check, and a depth counted from the spawner rather - than the place (the chain, which no depth refusal stops); a launch with no - place spawned under init (it starts); std's `NotDeclared` fallback, or its - early return for an endowment or extra slot, kept for init (it starts). - Oracle: cgroup v2's `cgroup.kill` and `cgroup.max.depth`. 5. **A login is a session under init** (ruled; Q5a–Q5c). init starts a session process per login — the desktop's at boot, one per SSH connection at sshd's request — and the compositor and sshd start a login's programs under it. Its @@ -180,7 +129,7 @@ directory is `issues/isolation/every-program-sees-only-the-files-it-was-given.md process of a login. 6. **A program is asked to quit** (Q3b, Q6a–Q6f). `SYS_PROCESS_QUIT` (124, never assigned), `(process, reason)`, needs `MANAGE` as the kill does and - reaches the process's subtree by stage 4's walk; the reason is interrupt, + reaches the process's subtree as an end does; the reason is interrupt, hang-up or terminate. Each process is started holding its notice, a new object kind under the label `quit`, `READABLE` while a reason is asked and not yet read, whose read takes it. A quit kills a process that has never diff --git a/kernel/src/id_map.rs b/kernel/src/id_map.rs index 8ca5254e9e8..cd1885f56a2 100644 --- a/kernel/src/id_map.rs +++ b/kernel/src/id_map.rs @@ -49,6 +49,22 @@ impl IdMap { id } + /// Takes the next ID with no value yet, for a value that must name its ID + /// before the map holds it. The ID is never issued again; [`Self::fill`] + /// gives it its value, or nothing ever does. + pub fn reserve(&mut self) -> K { + let id = self.next; + self.next = self.next + K::ONE; + id + } + + /// Gives a reserved ID its value. Panics for an ID this map has not + /// issued, or one that already has a value. + pub fn fill(&mut self, id: K, value: V) { + assert!(id < self.next, "IdMap::fill: an ID this map never issued"); + assert!(self.map.insert(id, value).is_none(), "IdMap::fill: an ID filled twice"); + } + pub fn get(&self, id: K) -> Option<&V> { self.map.get(&id) } diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index f29c16ca715..d0607dfa547 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -29,7 +29,7 @@ use crate::object::{ops, HandleTable, KObjectRef}; use crate::mm::policy::{CachePolicy, Prot}; use crate::mm::{PAGE_2M, PAGE_BYTES}; use crate::process::{ - ElfInfo, Endowments, OwnedAlloc, PageAlloc, PageFaultTrace, PageTables, Pid, + Admission, ElfInfo, OwnedAlloc, PageAlloc, PageFaultTrace, PageTables, Parent, Pid, ProcessAccounting, ProcessData, ProcessEntry, ThreadData, ThreadEntry, UserStack, PROCESS_TABLE, }; @@ -343,11 +343,8 @@ fn rela_dyn_from_sections( } } -/// Load a program and place its main thread, answering the object a handle to -/// the new process names. -/// -/// No parent argument: a process has no parent, and the only thing the caller -/// contributes beyond its endowment is the working directory it passes in. +/// Load a program and place its main thread under `parent`, answering the object +/// a handle to the new process names. /// /// `image` is the program's bytes when the caller read them itself, and then /// `argv[0]` is only its name: nothing opens it, and its libraries come from @@ -363,11 +360,14 @@ pub fn spawn( cwd: String, env: Vec, image: Option>, + parent: Parent, ) -> Result, crate::object::Refusal> { // An argv of only separators survives sys_spawn's split as an empty slice. let Some(&path) = argv.first() else { return Err(SyscallError::InvalidArgument.into()); }; + // Before anything is built; dropped on every way out below but the insert. + let admission = Admission::ask(parent)?; let t0 = crate::clock::nanos_since_boot(); let from_image = image.is_some(); @@ -594,10 +594,15 @@ pub fn spawn( let NeededLibs { libs: loaded_libs, paths: lib_paths } = loaded_libs; + let pid = admission.pid(); + let object = crate::object::process::ProcessObject::new(pid); // The point of no return: every failure above answers the caller with its // table untouched. `commit`'s own `?` is different — reachable only if the - // caller raced its own spawn, and fatal to it, not a refusal. - let (handles, endowments) = pending.commit()?; + // caller raced its own spawn, and fatal to it, not a refusal — and so is a + // parent claimed while this was built, whose refused insert takes the + // endowments with a child that never ran, as its walk would have taken + // them with one that had. + let (handles, endowments) = pending.commit(KObjectRef::Process(Arc::clone(&object)))?; let proc_data = Arc::new(Lock::new(ProcessData { handles, cwd, @@ -646,15 +651,25 @@ pub fn spawn( let mut guard = PROCESS_TABLE.lock(); let table = guard.as_mut().unwrap(); - let pid = table.insert_with(|pid| ProcessEntry::new( - pid, + let node = match admission.land(table) { + Ok(node) => node, + // Everything built is dropped on the way out, with no lock held. + Err(refused) => { + drop(guard); + refused.publish(); + log!("spawn: {}: its parent ended while it was built", path); + return Err(SyscallError::Gone.into()); + } + }; + table.fill(pid, ProcessEntry::new( + Arc::clone(&object), start::make_name(path), proc_data, Arc::clone(&syms), ThreadEntry::new(thread_data), + node, )); let tid = table.get(pid).unwrap().main_tid(); - let object = Arc::clone(table.get(pid).unwrap().object()); // Placed while still holding the table lock: kill_process claims teardown // under it, so a retire sweep can never see the pid before its thread is scheduled. @@ -884,16 +899,17 @@ pub fn spawn_init() -> Pid { .install(crate::object::HandleEntry::new(cap, rights)) .expect("spawn_init: an empty table refused the system capability"); let label = toyos_abi::syscall::SYSCAP_LABEL; - let endowments = Endowments::new( - alloc::vec![toyos_abi::syscall::EndowEntry { + let pending = PendingHandles::Ready { + table: handles, + entries: alloc::vec![toyos_abi::syscall::EndowEntry { label_off: 0, label_len: label.len() as u32, handle: cap_handle, _pad: 0, }], - label.as_bytes().to_vec(), - ); - match spawn(&[INIT_PATH], PendingHandles::Ready(handles, endowments), String::from("/"), Vec::new(), None) { + labels: label.as_bytes().to_vec(), + }; + match spawn(&[INIT_PATH], pending, String::from("/"), Vec::new(), None, Parent::Root) { Ok(object) => object.pid(), Err(crate::object::Refusal::Error(e)) => panic!("spawn_init: failed to spawn: {e:?}"), Err(crate::object::Refusal::Handle(e)) => panic!("spawn_init: {e}"), diff --git a/kernel/src/loader/start.rs b/kernel/src/loader/start.rs index 57686d5b93a..d3c96796a22 100644 --- a/kernel/src/loader/start.rs +++ b/kernel/src/loader/start.rs @@ -4,14 +4,14 @@ use alloc::vec::Vec; -use crate::object::{HandleTable, Refusal}; +use crate::object::{HandleEntry, HandleTable, KObjectRef, Refusal}; use crate::process::{ process_data, Endowments, OwnedAlloc, ENDOW_ENTRY_LEN, KERNEL_STACK_SIZE, }; use crate::scheduler; use crate::user_ptr::UserBytes; use toyos_abi::handle::{RawHandle, Rights}; -use toyos_abi::syscall::{EndowEntry, SyscallError, MAX_ENDOWMENTS, MAX_LABELS_LEN, MAX_SLOT_MAP}; +use toyos_abi::syscall::{EndowEntry, SyscallError, MAX_ENDOWMENTS, MAX_LABELS_LEN, MAX_SLOT_MAP, SELF_LABEL}; /// One `[child_slot, parent_handle]` pair of `SpawnArgs::slot_map_ptr`, in bytes. pub const SLOT_PAIR_LEN: usize = 8; @@ -44,16 +44,20 @@ pub(crate) fn make_name(path: &str) -> [u8; crate::process::THREAD_NAME_LEN] { // The move must be last: an earlier move leaves a failed spawn's parent holding handles that name nothing. pub enum PendingHandles { /// Built by the kernel and owing nobody anything — the boot's `/system/bin/init`. - Ready(HandleTable, Endowments), + Ready { table: HandleTable, entries: Vec, labels: Vec }, /// A caller's request: `endow` has not left the caller's table yet. Moving { table: HandleTable, endow: Vec, labels: Vec }, } impl PendingHandles { /// Take the endowed handles out of the parent's table, all under one lock hold: a refusal leaves it unchanged. - pub fn commit(self) -> Result<(HandleTable, Endowments), Refusal> { - let (mut table, endow, labels) = match self { - Self::Ready(table, endowments) => return Ok((table, endowments)), + /// `own` is the child itself, which its table holds under [`SELF_LABEL`] beside them. + pub fn commit(self, own: KObjectRef) -> Result<(HandleTable, Endowments), Refusal> { + let (mut table, endow, mut labels) = match self { + Self::Ready { mut table, mut entries, mut labels } => { + endow_self(&mut table, &mut entries, &mut labels, own); + return Ok((table, Endowments::new(entries, labels))); + } Self::Moving { table, endow, labels } => (table, endow, labels), }; let data_arc = process_data(); @@ -84,7 +88,7 @@ impl PendingHandles { moving.push((EndowEntry { label_off, label_len, handle, _pad: 0 }, handle)); } // Checked before any removal, so a failed install can't strand a handle out of a table that never spawned. - if !table.has_room(moving.len()) { + if !table.has_room(moving.len() + 1) { return Err(SyscallError::ResourceExhausted.into()); } @@ -99,10 +103,27 @@ impl PendingHandles { .expect("a child table with verified room refused an endowment"); entries.push(entry); } + drop(data); + endow_self(&mut table, &mut entries, &mut labels, own); Ok((table, Endowments::new(entries, labels))) } } +/// Install `own` in its own table under [`SELF_LABEL`]: `WRITE` to be named a spawn's place, `DUP` and `TRANSFER` to hand that on. Its caller verified the room. +fn endow_self(table: &mut HandleTable, entries: &mut Vec, labels: &mut Vec, own: KObjectRef) { + let rights = Rights::WRITE.union(Rights::DUP).union(Rights::TRANSFER); + let handle = table + .install(HandleEntry::new(own, rights)) + .expect("a child table with verified room refused its own handle"); + entries.push(EndowEntry { + label_off: labels.len() as u32, + label_len: SELF_LABEL.len() as u32, + handle, + _pad: 0, + }); + labels.extend_from_slice(SELF_LABEL.as_bytes()); +} + /// Reads `SpawnArgs`'s two handle vectors into the child's pending handle state. // Every refusal here precedes any child state, so nothing is enqueued or orphaned. pub fn build_child_handles( @@ -110,7 +131,8 @@ pub fn build_child_handles( endow: &UserBytes, labels: &[u8], ) -> Result { - if endow.len() / ENDOW_ENTRY_LEN > MAX_ENDOWMENTS { + // One fewer than the table holds, and as many bytes fewer as its label: the kernel adds `self`. + if endow.len() / ENDOW_ENTRY_LEN >= MAX_ENDOWMENTS { return Err(SyscallError::InvalidArgument.into()); } // Checked before the loop: `install_at`'s cap misses a repeated slot, which would duplicate @@ -118,7 +140,7 @@ pub fn build_child_handles( if slot_map.len() / SLOT_PAIR_LEN > MAX_SLOT_MAP { return Err(SyscallError::InvalidArgument.into()); } - if labels.len() > MAX_LABELS_LEN { + if labels.len() + SELF_LABEL.len() > MAX_LABELS_LEN { return Err(SyscallError::InvalidArgument.into()); } let data_arc = process_data(); diff --git a/kernel/src/object/handle.rs b/kernel/src/object/handle.rs index f1d4048689b..6cb035aeb0f 100644 --- a/kernel/src/object/handle.rs +++ b/kernel/src/object/handle.rs @@ -27,7 +27,7 @@ pub enum HandleError { BadHandle, /// The slot has moved past this handle: closed, or its generations ran out. Stale, - /// Not the caller's bug at `SYS_NAMESPACE_BUILD`'s connector argument — the sole named exception. + /// Ends the caller like the two above, except through [`HandleTable::get_sent`], the one lookup of a handle a peer sent — `SYS_NAMESPACE_BUILD`'s connector and `SYS_SPAWN`'s place — where it is `InvalidArgument`. WrongType { held: &'static str, wanted: &'static str }, /// The handle is fine and does not carry what the call needs. Rights { held: Rights, needed: Rights }, @@ -274,6 +274,18 @@ impl HandleTable { .ok_or(HandleError::WrongType { held: entry.object.kind(), wanted: T::NAME }) } + /// [`get`](Self::get) for a handle a peer sent, whose type is that peer's claim rather than proof of a bug in this caller: a wrong type refuses `InvalidArgument`, and every other failure is `get`'s. + pub fn get_sent( + &self, + h: RawHandle, + need: Rights, + ) -> Result, Refusal> { + match self.get::(h, need) { + Err(HandleError::WrongType { .. }) => Err(SyscallError::InvalidArgument.into()), + other => other.map_err(Refusal::from), + } + } + /// The borrowing accessor, for a call that completes while still holding the guard. pub fn get_ref(&self, h: RawHandle, need: Rights) -> Result<&KObjectRef, HandleError> { let entry = self.entry_of(h)?; diff --git a/kernel/src/process.rs b/kernel/src/process.rs index c7ebfbdc078..8c7bc029358 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -26,11 +26,11 @@ 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; +use toyos_abi::syscall::{EndowEntry, SyscallError}; /// The lifecycle's decisions; this file only performs them. pub use toyos_proclife::{ThreadLocation, Watch}; -use toyos_proclife::{join, reap, spawn as proclife_spawn, teardown as proclife, Lifecycle, Processes}; +use toyos_proclife::{join, reap, spawn as proclife_spawn, teardown as proclife, tree, Lifecycle, Node, Processes}; /// One `EndowEntry` on the wire; `loader::start` and [`Endowments::encode`] both index by it. pub const ENDOW_ENTRY_LEN: usize = core::mem::size_of::(); @@ -333,28 +333,35 @@ pub struct ProcessEntry { threads: crate::id_map::IdMap, /// Set once, with the exit's code, by the exit or kill that claims teardown; checked by `spawn_thread` so no thread appears after the retire set. teardown_code: Option, + /// Its parent, children and depth, answered by no syscall; `toyos_proclife::tree` reads and writes it. + node: Node, + /// The exit its teardown made, kept until every end below it is published. + exit: Option, } impl ProcessEntry { - /// Create a new process with its main thread (always `Tid(0)`). + /// Create a new process with its main thread (always `Tid(0)`), at `object`'s pid. pub fn new( - pid: Pid, + object: Arc, name: [u8; THREAD_NAME_LEN], process_data: Arc>, symbols: Arc, main_thread: ThreadEntry, + node: Node, ) -> Self { let mut threads = crate::id_map::IdMap::new(); let main_tid = threads.insert(main_thread); Self { - pid, - object: crate::object::process::ProcessObject::new(pid), + pid: object.pid(), + object, name, process_data, symbols, main_tid, threads, teardown_code: None, + node, + exit: None, } } pub fn pid(&self) -> Pid { self.pid } @@ -395,6 +402,8 @@ impl Lifecycle for ProcessEntry { f(tid, thread.state); } } + fn node(&self) -> &Node { &self.node } + fn node_mut(&mut self) -> &mut Node { &mut self.node } } /// The lifecycle face of the table; `published_exit` comes off the [`ProcessObject`](crate::object::process::ProcessObject) rather than the table, since it outlives the entry. @@ -814,6 +823,76 @@ pub fn with_process_data(f: impl FnOnce(&mut ProcessData) -> R) -> R { f(&mut guard) } +/// Whom a new process is placed under. +#[derive(Clone, Copy)] +pub enum Parent { + /// No process: init, which the kernel starts. + Root, + /// The spawner, or the process whose `self` its place named. + Under(Pid), +} + +/// A spawn admitted under its parent, and the pid its child will have. +/// While it lives it holds the parent's publication: [`Admission::land`] hands that hold to the child, and a drop — every way a spawn fails between the two — lets the parent go. +pub struct Admission { + pid: Pid, + under: Option, +} + +impl Admission { + /// The question at the top of a spawn, before anything is built: `Gone` for a parent being torn down, `ResourceExhausted` for a child more than `MAX_DEPTH` below init, which the log names. + pub fn ask(parent: Parent) -> Result { + let mut guard = PROCESS_TABLE.lock(); + let table = guard.as_mut().unwrap(); + let under = match parent { + Parent::Root => None, + Parent::Under(place) => match tree::admit_child(table, place) { + tree::Admit::Yes(admitted) => Some(admitted), + tree::Admit::Gone => return Err(SyscallError::Gone), + tree::Admit::TooDeep { depth } => { + drop(guard); + log!("spawn: refused under pid {place} at depth {depth}, more than {} below init", + toyos_proclife::MAX_DEPTH); + return Err(SyscallError::ResourceExhausted); + } + }, + }; + Ok(Self { pid: table.reserve(), under }) + } + + pub fn pid(&self) -> Pid { + self.pid + } + + /// The insert's question, under the `PROCESS_TABLE` hold that fills this pid with the node answered; `Err` for a parent claimed since the admission, the hold on it let go. + pub fn land(mut self, table: &mut ProcessTable) -> Result { + let Some(admitted) = self.under.take() else { return Ok(Node::root()) }; + tree::insert_child(table, admitted, self.pid).map_err(|refused| Refused(refused.publish)) + } +} + +impl Drop for Admission { + fn drop(&mut self) { + let Some(admitted) = self.under.take() else { return }; + let ready = { + let mut guard = PROCESS_TABLE.lock(); + let table = guard.as_mut().unwrap(); + tree::refuse_child(table, admitted) + }; + publish_climb(ready); + } +} + +/// An insert refused because its parent was claimed since the admission; [`Refused::publish`] publishes the parent, with the table lock given up, when this spawn held its last hold. +#[must_use = "a refused insert may owe its parent's publication"] +pub struct Refused(Option); + +impl Refused { + pub fn publish(self) { + publish_climb(self.0); + } +} + /// Spawn a thread within the current process. pub fn spawn_thread(entry: u64, stack_ptr: u64, arg: u64, stack_base: u64) -> Option { // Phase 1: parent's data + address space (table lock dropped after). @@ -975,20 +1054,39 @@ fn teardown_resources( (syscall_total, syscall_total_ns) } -/// Table-side teardown bookkeeping: drop the symbol table, total the CPU time of every thread still in the table, and mark the last thread out dead with `mark`. -/// Caller must hold `PROCESS_TABLE`, be that thread and have freed the resources. Returns the object whose exit the caller publishes once the table lock is given up, with that total. -#[must_use = "the exit must be published on the object returned"] -fn teardown_bookkeeping(table: &mut ProcessTable, process_pid: Pid, tid: Tid, code: i32, mark: i32) - -> (Arc, u64) { +/// Table-side teardown bookkeeping: drop the symbol table and total the CPU time of every thread still in the table. +/// Caller must hold `PROCESS_TABLE`, be the last thread out and have freed the resources. +fn teardown_bookkeeping(table: &mut ProcessTable, process_pid: Pid, code: i32) -> u64 { let proc = table.get_mut(process_pid) .expect("teardown_bookkeeping: process not found"); proc.symbols = Arc::new(SymbolTable::empty()); let cpu_ns: u64 = proc.threads.iter().map(|(_, t)| t.sched().map_or(0, scheduler::task_cpu_ns)).sum(); let name = proc.name_str(); log!("exit: {name} pid={process_pid} code={code} cpu={}ms", cpu_ns / 1_000_000); - let object = Arc::clone(&proc.object); - proclife::torn_down(table, process_pid, tid, mark); - (object, cpu_ns) + cpu_ns +} + +/// Publish `first`, then every parent each publication lets go, child before parent: at most `toyos_proclife::MAX_DEPTH` + 1 publications, run with preemption off and each with the table lock given up. +/// Once published an entry is reapable, so only its parent's entry is read after. +fn publish_climb(first: Option) { + crate::sched::driver::preempt_off(|_| { + let mut ready = first; + while let Some(publish) = ready { + let (object, exit) = { + let mut guard = PROCESS_TABLE.lock(); + let proc = guard.as_mut().unwrap().get_mut(publish.pid) + .expect("publish_climb: a process is in the table until its exit is published"); + let exit = proc.exit.take() + .expect("publish_climb: a count reaches zero only after the teardown kept its exit"); + (Arc::clone(&proc.object), exit) + }; + object.publish_exit(exit); + ready = publish.parent.and_then(|parent| { + let mut guard = PROCESS_TABLE.lock(); + tree::published(guard.as_mut().unwrap(), parent, publish.pid) + }); + } + }); } /// One `ProcessStats`, from a process's own data; written once, since `SYS_PROCESS_STATS` samples a live process through the same fields the teardown snapshots. @@ -1023,10 +1121,10 @@ pub fn stats_from( } } -/// The last thread out's teardown of its process, on its own stack: free what the process holds, then publish its exit. Every other thread has left, so none can run in what is freed. -/// The thread is in the process until the bookkeeping marks it, so a machine stop waits for every record and release here. +/// The last thread out's teardown of its process, on its own stack: free what the process holds, then keep its exit until every end below it is published. Every other thread has left, so none can run in what is freed. +/// The thread is in the process until the last table section marks it, so a machine stop waits for every record and release here. /// Waits on nothing: it runs on a killed thread whose one cancel may be spent, and at the exit boundary's preempt depth, where a park asserts. -/// Publish happens after the table lock is released, and once published the entry is reapable, so nothing may read the table for this pid after. +/// The section that marks it also lets the teardown's own hold on the publication go: the exit is published here when nothing below it is left, and otherwise by the last end below it. fn teardown(pid: Pid, tid: Tid, code: i32, mark: i32, process_data: &Arc>) { let main_thread_data = { let guard = PROCESS_TABLE.lock(); @@ -1034,14 +1132,22 @@ fn teardown(pid: Pid, tid: Tid, code: i32, mark: i32, process_data: &Arc) { } } -/// Claim `pid`'s teardown for `code`: the winner gets the threads it retires, every one still in the process but `caller`; a loser, or a process already gone, gets none. -fn claim(pid: Pid, code: i32, caller: Option) -> Vec { +/// Claim `pid`'s teardown for `code`, in one hold of the table lock: the winner gets the threads it retires, every one still in the process but `caller`, and owes `pid`'s children to its walk; a loser, or a process already gone, gets none. +fn claim(pid: Pid, code: i32, caller: Option, owed: &mut Vec) -> Vec { let mut guard = PROCESS_TABLE.lock(); let table = guard.as_mut().unwrap(); - if !proclife::claim_teardown(table, pid, code) { + if !tree::claim(table, pid, code, owed) { return Vec::new(); } let proc = Processes::get(table, pid).expect("claim: the entry the claim just succeeded on"); @@ -1083,11 +1189,26 @@ fn claim(pid: Pid, code: i32, caller: Option) -> Vec { .collect() } +/// End every process an end owes, on the ending or killing thread: one claim per hold of the table lock, each claim's retires posted with it given up, and a reschedule owed between two claims served with nothing held. +fn walk(mut owed: Vec) { + while let Some(pid) = owed.pop() { + for sched in claim(pid, KILLED_EXIT_CODE, None, &mut owed) { + scheduler::post_retire(&sched); + } + if !owed.is_empty() && crate::preempt::need_resched() { + scheduler::yield_now(); + } + } +} + +/// End the calling thread's process with `code`, and everything below it. pub fn exit(code: i32) -> ! { let tid = current_tid(); - for sched in claim(current_process(), code, Some(tid)) { + let mut owed = Vec::new(); + for sched in claim(current_process(), code, Some(tid), &mut owed) { scheduler::post_retire(&sched); } + walk(owed); leave(None); scheduler::exit_current(); } @@ -1561,13 +1682,15 @@ fn with_current_symbols(f: impl FnOnce(&crate::symbols::SymbolTable) -> bool) -> } } -/// Kill the process an object names. +/// Kill the process an object names, and everything below it. /// The handle is the whole authorization, not the parent relationship: a `Process` handle carrying `Rights::MANAGE` says who may, and it can be narrowed away or handed on. `Ok` for an already-gone process: the caller asked for it to be dead and it is. -/// Returns once the victim's retires are posted, never waiting on it: the victim may be killing this caller. Its last thread out publishes the object's exit. +/// Returns once every retire is posted, never waiting on a victim: one may be killing this caller. The object's exit is published once every end below it is. pub fn kill_process(object: &crate::object::process::ProcessObject) -> u64 { - for sched in claim(object.pid(), KILLED_EXIT_CODE, None) { + let mut owed = Vec::new(); + for sched in claim(object.pid(), KILLED_EXIT_CODE, None, &mut owed) { scheduler::post_retire(&sched); } + walk(owed); 0 } diff --git a/kernel/src/sched/kthread.rs b/kernel/src/sched/kthread.rs index 9f2eacb499f..925c9a20a2a 100644 --- a/kernel/src/sched/kthread.rs +++ b/kernel/src/sched/kthread.rs @@ -114,11 +114,12 @@ pub fn spawn(name: &str, body: extern "C" fn(u64) -> !, arg: u64) -> ThreadSched let table = guard.as_mut().expect("kthread: spawned before process::init"); let pid = table.insert_with(|pid| { ProcessEntry::new( - pid, + crate::object::process::ProcessObject::new(pid), short, Arc::new(Lock::new(kernel_process_data(name))), Arc::clone(&syms), ThreadEntry::new(Arc::new(Lock::new(kernel_thread_data()))), + toyos_proclife::Node::root(), ) }); let tid = table.get(pid).expect("kthread: the entry just inserted is gone").main_tid(); diff --git a/kernel/src/syscall/dispatch.rs b/kernel/src/syscall/dispatch.rs index f3a5ed190e2..21efa88a2cf 100644 --- a/kernel/src/syscall/dispatch.rs +++ b/kernel/src/syscall/dispatch.rs @@ -47,7 +47,7 @@ use super::machine::{ #[cfg(feature = "test-actuators")] use super::machine::lower_sysinfo_bound; use super::proc::{ - sys_endowments, sys_exit, sys_nanosleep, sys_process_open, sys_process_stats, + spawn_place, sys_endowments, sys_exit, sys_nanosleep, sys_process_open, sys_process_stats, sys_process_wait, sys_rt_enter, sys_spawn, sys_thread_exit, sys_thread_join, sys_thread_spawn, }; use super::vm::{shared_image, sys_dlopen, sys_dlsym, sys_mmap, sys_munmap, sys_query_modules, sys_tls_alloc_block}; @@ -216,13 +216,19 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> Err(refused) => return refused, }, }; + // Before an endowment can move the handle that names it. + let parent = match spawn_place(args.place) { + Ok(parent) => parent, + Err(refused) => return refused, + }; let text = match ctx.user_str(UserAddr::new(args.argv_ptr), args.argv_len) { Ok(s) => s, Err(e) => return e.to_u64() }; let cwd = match ctx.user_str(UserAddr::new(args.cwd_ptr), args.cwd_len).and_then(|p| spawn_cwd(&p)) { Ok(cwd) => cwd, Err(e) => return e.to_u64(), }; - if args.endow_count as usize > toyos_abi::syscall::MAX_ENDOWMENTS - || args.labels_len > toyos_abi::syscall::MAX_LABELS_LEN as u64 + // One fewer, and its label's bytes fewer, than a table holds: the kernel adds `self`. + if args.endow_count as usize >= toyos_abi::syscall::MAX_ENDOWMENTS + || args.labels_len > (toyos_abi::syscall::MAX_LABELS_LEN - SELF_LABEL.len()) as u64 { return SyscallError::InvalidArgument.to_u64(); } @@ -262,7 +268,7 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> alloc::vec::Vec::new() }; let argv: alloc::vec::Vec<&str> = text.split('\0').filter(|s| !s.is_empty()).collect(); - sys_spawn(&argv, pending, cwd, env, image) + sys_spawn(&argv, pending, cwd, env, image, parent) } SYS_PROCESS_WAIT => sys_process_wait(RawHandle(a1 as u32), a2), SYS_PROCESS_KILL => { diff --git a/kernel/src/syscall/ipc.rs b/kernel/src/syscall/ipc.rs index b0b573e77fc..475d60a6e8c 100644 --- a/kernel/src/syscall/ipc.rs +++ b/kernel/src/syscall/ipc.rs @@ -192,13 +192,9 @@ pub(super) fn sys_namespace_build(ctx: &SyscallContext, args: &NamespaceBuild) - return SyscallError::InvalidArgument.to_u64(); }; let connector = match process::with_process_data(|data| { - data.handles.get::(handle, Rights::TRANSFER) + data.handles.get_sent::(handle, Rights::TRANSFER) }) { Ok(c) => c, - // WrongType returns InvalidArgument here instead of ending the caller: an added connector is often one a peer transferred, not proof of a caller bug. - Err(crate::object::HandleError::WrongType { .. }) => { - return SyscallError::InvalidArgument.to_u64() - } Err(e) => return e.refuse(), }; entries.push((name, connector)); diff --git a/kernel/src/syscall/proc.rs b/kernel/src/syscall/proc.rs index ec676756172..e4343514548 100644 --- a/kernel/src/syscall/proc.rs +++ b/kernel/src/syscall/proc.rs @@ -29,16 +29,32 @@ pub(super) fn sys_exit(code: i32) -> u64 { process::exit(code); } -/// Start a program in `cwd` and return a handle to it; kill the child if the handle can't be installed. +/// `SpawnArgs::place`: the caller itself, or the process a handle carrying `WRITE` names — a handle a peer sent, so a wrong type is refused rather than ending the caller. +pub(super) fn spawn_place(place: u64) -> Result { + if place == u64::from(toyos_abi::HANDLE_INVALID.0) { + return Ok(process::Parent::Under(process::current_process())); + } + let Ok(raw) = u32::try_from(place) else { + return Err(SyscallError::InvalidArgument.to_u64()); + }; + process::with_process_data(|data| { + data.handles.get_sent::(RawHandle(raw), Rights::WRITE) + }) + .map(|object| process::Parent::Under(object.pid())) + .map_err(|refused| refused.refuse()) +} + +/// Start a program in `cwd` under `parent` and return a handle to it; kill the child if the handle can't be installed. pub(super) fn sys_spawn( args: &[&str], pending: crate::loader::PendingHandles, cwd: alloc::string::String, env: Vec, image: Option>, + parent: process::Parent, ) -> u64 { // Nothing to clean up yet: spawn's frame owns the child's resources on error. - let object = match process::spawn(args, pending, cwd, env, image) { + let object = match process::spawn(args, pending, cwd, env, image, parent) { Ok(object) => object, Err(e) => return e.refuse(), }; diff --git a/rust b/rust index aca5f527fcb..336c8a8e141 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit aca5f527fcb5dc3b694e33a0f0a7312d8d29aa60 +Subproject commit 336c8a8e14188d7415e3fa246702e25a2c64d511 diff --git a/src/ci.rs b/src/ci.rs index 08e533522d4..33b8afa29e2 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -347,6 +347,18 @@ pub(crate) const CONTROLS: &[Control] = &[ "the_last_one_out_is_in_its_process_until_its_teardown_is_done ... FAILED", "only_the_thread_that_empties_a_claimed_process_tears_it_down ... FAILED", ]), + red(PROCLIFE, "mutate-place-skips-the-insert-recheck", None, &[ + "a_spawn_racing_its_places_kill_lands_nothing_under_it_and_publishes_it ... FAILED", + ]), + red(PROCLIFE, "mutate-refused-insert-keeps-the-count", None, &[ + "a_spawn_racing_its_places_kill_lands_nothing_under_it_and_publishes_it ... FAILED", + ]), + red(PROCLIFE, "mutate-publish-before-the-children", None, &[ + "an_exit_publishes_after_every_end_below_it ... FAILED", + ]), + red(PROCLIFE, "mutate-walk-in-one-hold", None, &[ + "a_spawn_under_an_unrelated_process_lands_between_two_claims_of_one_walk ... FAILED", + ]), red(SCHED_SIM, "placement-ignores-staleness", Some("policy"), &[ "a_stopped_cpu_stops_taking_work ... FAILED", ]), diff --git a/system.toml b/system.toml index 4ad79cf0815..027e8e3b305 100644 --- a/system.toml +++ b/system.toml @@ -1,7 +1,7 @@ # sshd is deliberately not in `[boot] start`. It is built into the image and -# started by hand (`/system/bin/sshd &`) on a machine that wants remote access: it -# listens on every interface, so carrying it in every boot is a listening port -# nobody asked for. +# started by hand (`detach /system/bin/sshd`, so it outlives the shell) on a +# machine that wants remote access: it listens on every interface, so carrying +# it in every boot is a listening port nobody asked for. # `src/build.rs`'s `no_shipped_boot_config_starts_sshd` is the gate. hosted-rustc = false diff --git a/tests/proctreecase/system.toml b/tests/proctreecase/system.toml new file mode 100644 index 00000000000..578f92150bf --- /dev/null +++ b/tests/proctreecase/system.toml @@ -0,0 +1,59 @@ +# The boot `process_tree` runs on: a parent's end takes its children down. +# +# A `launcher`, because the subtree under test has children init launches; a +# declared `cat`, which runs until its input ends; a declared shell, which a +# depth chain launches and which starts and detaches sshd; and sshd, with netd +# and a NIC under it and a key the harness stages, so that it runs. + +[boot] +start = ["logd", "blockd", "fsd", "netd", "test-runner"] + +# `every_boot_config_runs_logd` is what refuses a boot config without it: the +# kernel keeps the record ring and writes no file, so such a boot's `/log` is +# empty. It claims no device and serves no port. +[programs.logd] +service = true +syscap = ["logread"] + +# netd holds the NIC's PCI function and drives it: the virtqueues, the register +# window and the interrupt are its own, and the kernel keeps only the claim. +[programs.netd] +service = true +serves = ["netd"] +devices = ["pci:1af4:1041"] + +# Started by the guest from a shell, never at boot. +[programs.sshd] +service = true +receives = ["netd", "launcher"] + +# `roster` because the guest asks which of its tree still runs, and `dup` +# because test-runner hands each binary a duplicate of its capability. +[programs.test-runner] +receives = ["launcher"] +syscap = ["dup", "roster"] + +[programs.toybox] + +[programs.shell] +receives = ["launcher"] + +[symlinks] +"bin/cat" = "/system/bin/toybox" + +# The block service: the NVMe controller this machine's DATA is on, driven +# from userland through its claim. Started again when it ends; the claim goes +# back with the process and is minted again for the next. +[programs.blockd] +service = true +restart = true +serves = ["block"] +devices = ["pci:1b36:0010"] + +# The file servers: DATA, the log and the running slot's volume, one process +# each, serving every program the directories of its role. Started again when +# one ends, on the same ports. +[programs.fsd] +restart = true +roles = ["data", "log", "boot"] +receives = ["block"] diff --git a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs index 864d46c4ff5..0244faec25b 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs @@ -244,6 +244,7 @@ fn spawn_path(path: &str) -> Result { cwd_len: CWD.len() as u64, image: 0, image_len: 0, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }) } .map(|pid| pid.0 as u64) @@ -296,6 +297,7 @@ fn spawn_image(path: &str, bytes: &[u8]) -> Result { cwd_len: CWD.len() as u64, image: toyos::AsHandle::as_handle(&object).0 as u64, image_len: bytes.len() as u64, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }) } .map(|pid| pid.0 as u64) diff --git a/tests/toyos-rust-tests/src/bin/abuse_elf_segments.rs b/tests/toyos-rust-tests/src/bin/abuse_elf_segments.rs index 35c44595173..fb9fcc85375 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_elf_segments.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_elf_segments.rs @@ -158,6 +158,7 @@ fn spawn_as(path: &str, image: &[u8]) -> SyscallError { cwd_len: CWD.len() as u64, image: toyos::AsHandle::as_handle(&object).0 as u64, image_len: image.len() as u64, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }) } .map(|pid| panic!("{path}: spawn succeeded (pid {pid:?}) — the header is malformed")) diff --git a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs index d251bc39fff..835f99f9dfd 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs @@ -73,6 +73,7 @@ fn main() { cwd_len: CWD.len() as u64, image: 0, image_len: 0, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }; unsafe { syscall::spawn(&args) } }; diff --git a/tests/toyos-rust-tests/src/bin/abuse_page_straddle.rs b/tests/toyos-rust-tests/src/bin/abuse_page_straddle.rs index d35a56bd2aa..f892b92dc5a 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_page_straddle.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_page_straddle.rs @@ -130,6 +130,7 @@ fn main() { cwd_len: CWD.len() as u64, image: 0, image_len: 0, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }; let placed = (boundary - 8) as *mut SpawnArgs; unsafe { placed.write_volatile(args) }; diff --git a/tests/toyos-rust-tests/src/bin/abuse_spawn_argv.rs b/tests/toyos-rust-tests/src/bin/abuse_spawn_argv.rs index 34b14f0c892..e55964e2448 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_spawn_argv.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_spawn_argv.rs @@ -50,6 +50,7 @@ fn main() { cwd_len: CWD.len() as u64, image: 0, image_len: 0, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }; let err = unsafe { diff --git a/tests/toyos-rust-tests/src/bin/blockd_io.rs b/tests/toyos-rust-tests/src/bin/blockd_io.rs index d0f53a4ae1d..ce40c046404 100644 --- a/tests/toyos-rust-tests/src/bin/blockd_io.rs +++ b/tests/toyos-rust-tests/src/bin/blockd_io.rs @@ -692,6 +692,7 @@ fn spawn_unreadable_argv(image: toyos::RawHandle, len: u64) -> Result connect_and_go(), @@ -65,12 +70,16 @@ fn run() { // Racing a dying creator against the compositor's own dispatch is what // this used to do, and under a loaded host the compositor won all eight // heats and the run proved nothing. Instead the request is *completed by a - // third process*: the creator hands its socket to a grandchild and exits, - // this process reaps it — which is what takes the pid out of the process - // table — and only then closes the pipe that releases the grandchild to + // third process*: the creator hands its socket to a relay it places under + // this process, so that its own end does not take the relay down, and + // exits; this process reaps it — which is what takes the pid out of the + // process table — and only then closes the pipe that releases the relay to // send the frame. Every step waits on the one before it. + let root = endow::this_process().expect("every process holds itself"); + let place = syscall::dup(root.as_handle()).expect("a copy of the root's self for the creator"); let mut creator = Command::new(SELF_PATH) .arg("connect") + .endow(ROOT_LABEL, place.0) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -84,7 +93,7 @@ fn run() { fail(&format!("[a reaped creator] the creator said {said:?}")); } creator.wait().expect("reap the creator"); - // The reap is what makes the pid unknown; this is what tells the grandchild + // The reap is what makes the pid unknown; this is what tells the relay // the reap has happened. drop(go); probe("a creator reaped before its window"); @@ -158,19 +167,22 @@ fn run() { println!("compositor client death: 6 deaths survived, compositor still serving"); } -/// The creator: connect, hand the connection to a process that will outlive -/// this one, and go. +/// The creator: connect, hand the connection to a process placed under the +/// root, which this one's end does not take down, and go. /// /// Nothing is sent here. The compositor's record of who this connection /// belongs to is made at `connect`, and that is the only thing this role has /// to establish before dying. fn connect_and_go() { let conn = endow::service("compositor").expect("the compositor is not serving"); + let root: toyos::process::Process = + endow::Endowments::get().take(ROOT_LABEL).expect("the root endowed its own self"); // The kernel clones the handle into the child's table // (`loader::build_child_handles`), so the socket — and the pipes under it — // outlive this process. Command::new(SELF_PATH) .arg("finish") + .under(root.as_handle().0) .inherit_handle(RELAY_SOCKET.0, conn.as_handle().0) .inherit_handle(RELAY_GO.0, 0) .spawn() @@ -178,7 +190,7 @@ fn connect_and_go() { println!("connected"); } -/// The grandchild: send the request its creator never sent, once that creator +/// The relay: send the request its creator never sent, once that creator /// has been reaped. fn finish() { let mut byte = [0u8; 1]; diff --git a/tests/toyos-rust-tests/src/bin/device_claim_lifetime.rs b/tests/toyos-rust-tests/src/bin/device_claim_lifetime.rs index b85157f5ca8..844a5cb767c 100644 --- a/tests/toyos-rust-tests/src/bin/device_claim_lifetime.rs +++ b/tests/toyos-rust-tests/src/bin/device_claim_lifetime.rs @@ -217,6 +217,7 @@ fn spawn_with_slot_map(handle: toyos_abi::RawHandle) -> Result Result { cwd_len: CWD.len() as u64, image: 0, image_len: 0, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }) } } diff --git a/tests/toyos-rust-tests/src/bin/launcher_refusals.rs b/tests/toyos-rust-tests/src/bin/launcher_refusals.rs index cbf2f97973f..266786a06b0 100644 --- a/tests/toyos-rust-tests/src/bin/launcher_refusals.rs +++ b/tests/toyos-rust-tests/src/bin/launcher_refusals.rs @@ -34,12 +34,18 @@ //! holder of the connector — the compositor, every terminal, every shell, sshd //! — parked the machine's only way to start a process for ever, with init alive //! and looking healthy. +//! +//! **A launch names its parent**, and two more shapes are about that: a frame +//! naming neither a place nor init, which init must refuse rather than take as +//! asking for it — the program would outlive a caller that never asked — and a +//! place that is a pipe, a peer's claim the kernel answers with a word, since +//! init is the spawn's caller and a wrong type that ended it would end init. use std::process::Command; use toyos::census::Census; use toyos::ipc::{Connection, FrameRx, RxStep}; -use toyos::launch::{self, Launch}; +use toyos::launch::{self, Launch, Parent}; use toyos::poller::{Poller, READABLE}; use toyos::{namespace, port, AsHandle}; use toyos_abi::handle::Rights; @@ -67,6 +73,8 @@ fn main() { a_quiet_client_does_not_wedge_the_launcher(); not_a_connector(); a_connector_it_cannot_duplicate(); + a_launch_naming_no_parent(); + a_place_that_is_a_pipe(); a_working_directory_that_is_not_absolute(); let before = churn(); @@ -131,6 +139,7 @@ fn a_quiet_client_does_not_wedge_the_launcher() { cwd: "/", extras: &[], slots: &[], + parent: Parent::Init, }; let len = request.encode(&mut buf).expect("encode a launch"); conn.send_bytes_with_handles(&[], launch::MSG_LAUNCH, &buf[..len]) @@ -159,8 +168,15 @@ fn a_frame_that_lies() { let second = syscall::dup(write.as_handle()).expect("a second duplicate to send"); let mut buf = [0u8; 512]; - let request = - Launch { program: DECLARED, argv: b"", env: b"", cwd: "/", extras: &[], slots: &[] }; + let request = Launch { + program: DECLARED, + argv: b"", + env: b"", + cwd: "/", + extras: &[], + slots: &[], + parent: Parent::Init, + }; let len = request.encode(&mut buf).expect("encode a launch"); let conn = launcher(); @@ -220,7 +236,8 @@ fn refused_with(extras: &[(&str, RawHandle)]) -> u32 { /// Send one launch from `cwd` carrying `extras`, and answer init's reply. fn answer_to(cwd: &str, extras: &[(&str, RawHandle)]) -> u32 { let mut buf = [0u8; 512]; - let request = Launch { program: DECLARED, argv: b"", env: b"", cwd, extras, slots: &[] }; + let request = + Launch { program: DECLARED, argv: b"", env: b"", cwd, extras, slots: &[], parent: Parent::Init }; let (handles, count) = request.handles(); let len = request.encode(&mut buf).expect("encode a launch"); @@ -253,11 +270,10 @@ fn the_launcher_still_works() { /// **The half of it that is the kernel's**, asserted from a process that can /// afford to die so that init does not have to. /// -/// `SYS_NAMESPACE_BUILD`'s added connector is the one handle argument in the -/// ABI that routinely crossed a trust boundary — a `provides` name is exactly -/// a connector somebody else made — so a wrong type there answers a word. Every -/// other `WrongType` in the table still ends the caller, and if this one goes -/// back to doing that, this arm never returns and the test reds on exit 139. +/// `SYS_NAMESPACE_BUILD`'s added connector routinely crosses a trust boundary — +/// a `provides` name is exactly a connector somebody else made — so a wrong +/// type there answers a word, and if it goes back to ending the caller, this +/// arm never returns and the test reds on exit 139. fn the_kernel_answers_rather_than_faults() { let (_read, write) = toyos::pipe_pair().expect("a pipe of our own"); // SAFETY: it is not a connector, which is the point — the call must answer @@ -267,3 +283,56 @@ fn the_kernel_answers_rather_than_faults() { let _ = pretend.into_raw(); assert_eq!(refused.err(), Some(SyscallError::InvalidArgument)); } + +/// The frame `toyos::launch` encodes for a launch under init, with its header's +/// last word — the parent — cleared: no SDK call can spell a launch that names +/// neither. Refused, and nothing starts: a grant would answer `MSG_LAUNCHED`. +fn a_launch_naming_no_parent() { + let mut buf = [0u8; 512]; + let request = Launch { + program: DECLARED, + argv: b"", + env: b"", + cwd: "/", + extras: &[], + slots: &[], + parent: Parent::Init, + }; + let len = request.encode(&mut buf).expect("encode a launch"); + // Nine little-endian words, the parent last. + buf[32..36].copy_from_slice(&0u32.to_le_bytes()); + let conn = launcher(); + conn.send_bytes_with_handles(&[], launch::MSG_LAUNCH, &buf[..len]) + .expect("the launcher took the frame"); + assert_eq!( + answer(&conn), + Ok(launch::MSG_REFUSED), + "a launch naming no parent was not refused: init chose one for it", + ); + println!(" a launch naming no parent: refused, and nothing started"); +} + +/// A place that is a pipe's write end, which carries `WRITE`, so the kernel +/// reaches the type it is not. The arms after this one are init answering the +/// next launch. +fn a_place_that_is_a_pipe() { + let (_read, write) = toyos::pipe_pair().expect("a pipe of our own"); + let place = syscall::dup(write.as_handle()).expect("a duplicate to send"); + let mut buf = [0u8; 512]; + let request = Launch { + program: DECLARED, + argv: b"", + env: b"", + cwd: "/", + extras: &[], + slots: &[], + parent: Parent::Place(place), + }; + let (handles, count) = request.handles(); + let len = request.encode(&mut buf).expect("encode a launch"); + let conn = launcher(); + conn.send_bytes_with_handles(&handles[..count], launch::MSG_LAUNCH, &buf[..len]) + .expect("the launcher took the frame"); + assert_eq!(answer(&conn), Ok(launch::MSG_REFUSED), "a pipe named as a launch's place was not refused"); + println!(" a place that is a pipe: refused, and init is still here"); +} diff --git a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs index f47f83c593c..4c85b6bde62 100644 --- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs +++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs @@ -1,7 +1,7 @@ //! A process is something you hold, and holding it is the whole of the right to //! wait for it. //! -//! **There is no zombie here and no parent.** A pid-keyed wait needed the +//! **There is no zombie here.** A pid-keyed wait needed the //! process table to keep a corpse until somebody claimed it, and rules for who //! was allowed to claim one and what happened when nobody did. The exit code //! lives on the object instead, published once by whichever of exit or kill diff --git a/tests/toyos-rust-tests/src/bin/process_tree.rs b/tests/toyos-rust-tests/src/bin/process_tree.rs new file mode 100644 index 00000000000..10e430bbd39 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/process_tree.rs @@ -0,0 +1,422 @@ +//! A parent's end takes its children down. +//! +//! **Every end takes the whole subtree, and is published after every end +//! below it.** A starts B; B spawns C, launches D through init and asks init +//! for E; B hands A a handle to each and a copy of its own `self`. Then B ends +//! — killed by A, by its own exit, by a CPU fault and by a handle fault, one B +//! per arm — and the arm asserts, at the instant A's wait on B answers: +//! +//! - C and D have already published their ends, as killed: a non-blocking wait +//! on each answers, so B's end was published after theirs; +//! - E, which init started, runs on: init is the one way to outlive a starter; +//! - a spawn and a launch under B's `self` answer `Gone`. +//! +//! B's first act is a spawn the loader refuses once it is admitted under B: its +//! hold on B goes with it, or B is never published and the arm's wait never +//! answers. +//! +//! **The other arms.** A `MANAGE`-only handle is no place. init starts a +//! child only by a launch, so std refuses `under_init` for a program no row +//! declares and for a command carrying an endowment or an extra slot. A chain +//! alternating spawn and launch — each link launches a shell, and the shell +//! spawns the next link — stops where the kernel refuses a process more than +//! `MAX_DEPTH` below init, and dies whole with its first link. And sshd, +//! started by a shell, dies with the shell, while one the shell `detach`es +//! outlives it. +//! +//! Every wait is unbounded: the harness ceiling is the only clock. + +use std::io::{BufRead, BufReader}; +use std::os::toyos::process::{ChildExt, CommandExt}; +use std::process::{Child, ChildStdin, Command, Stdio}; + +use toyos::endow::{self, Endowments, SVC_LABEL, SYSCAP_LABEL}; +use toyos::ipc::Connection; +use toyos::launch::{self, Launch, Outcome, Parent}; +use toyos::process::Process; +use toyos::syscap::SysCap; +use toyos::{namespace, port, AsHandle}; +use toyos_abi::handle::Rights; +use toyos_abi::syscall::{self, SpawnArgs, SyscallError}; +use toyos_abi::RawHandle; + +const SELF_PATH: &str = "/system/bin/test_rs_process_tree"; +/// `/system/bin/toybox` under the name of an applet that reads its input until +/// it ends: a declared program, so a launch, and one that runs until killed +/// while A holds its input open. +const HELD: &str = "/system/bin/cat"; +const SHELL: &str = "/system/bin/shell"; +const SSHD: &str = "/system/bin/sshd"; + +/// The name B's namespace carries the port back to A under. +const BACK: &str = "back"; + +/// B to A: the handles to C, D, B's own `self` and E, in that order. +const MSG_GROWN: u32 = 1; +/// A to B: how to end, one byte. +const MSG_END: u32 = 2; + +/// `process::KILLED_EXIT_CODE`. +const KILLED: i32 = 137; +/// `process::HANDLE_FAULT_EXIT_CODE`. +const HANDLE_FAULT: i32 = 139; +/// What `syscall::kill_process(-1)` publishes for a Ring 3 CPU fault. +const CPU_FAULT: i32 = -1; + +/// A slot no process in this tree reaches, as `handle_kill_policy`'s. +const UNHELD_SLOT: u32 = 3000; + +/// `toyos_proclife::MAX_DEPTH`, which the kernel refuses a process past. +const MAX_DEPTH: u32 = 64; + +/// The links a chain may grow before a refusal must have stopped it: each +/// adds two levels, so from any start below init the refusal comes sooner. +const LINK_BOUND: u32 = MAX_DEPTH / 2 + 1; + +/// `sched::payload`'s state for a thread whose entry is a zombie, which +/// `sys_sysinfo` also answers for an exited one. +const ZOMBIE: u8 = 3; + +#[derive(Clone, Copy, Debug)] +#[repr(u8)] +enum End { + /// B parks; A kills it. + Killed = 0, + Exit = 1, + CpuFault = 2, + HandleFault = 3, +} + +fn main() { + let args: Vec = std::env::args().collect(); + match args.get(1).map(String::as_str) { + Some("b") => b(), + Some("c") => park(), + Some("link") => link(args[2].parse().expect("a link's index")), + Some(other) => panic!("unknown role {other:?}"), + None => test(), + } +} + +fn test() { + for end in [End::Killed, End::Exit, End::CpuFault, End::HandleFault] { + an_end_takes_its_subtree(end); + } + a_manage_only_handle_is_no_place(); + init_is_asked_only_by_a_launch(); + a_chain_stops_at_max_depth_and_dies_whole(); + sshd_dies_with_its_shell_and_outlives_it_under_init(); + println!("process_tree: PASS"); +} + +/// The estate's system capability, for the roster. +fn cap() -> &'static SysCap { + static CAP: std::sync::OnceLock = std::sync::OnceLock::new(); + CAP.get_or_init(|| { + Endowments::get().take(SYSCAP_LABEL).expect("test-runner endows every binary it spawns a system capability") + }) +} + +/// What A holds of one B and its subtree. +struct Grown { + b: Child, + conn: Connection, + c: Process, + d: Process, + b_self: RawHandle, + e: Process, + /// The input D and E read, kept open so neither ends by itself. + _held: ChildStdin, +} + +/// Start B, holding a port back to A beside this process's own namespace, and +/// take what it sends once its subtree is grown. +fn grow() -> Grown { + let (acceptor, connector) = port::create().expect("a port of our own"); + let own = endow::namespace().expect("test-runner endows its namespace, `launcher` in it"); + let ns = namespace::build() + .keep_all(own) + .add(BACK, &connector) + .finish() + .expect("a namespace with the port back beside ours"); + let mut b = Command::new(SELF_PATH) + .arg("b") + .endow(SVC_LABEL, ns.into_raw().0) + .stdin(Stdio::piped()) + .spawn() + .expect("spawn B"); + let held = b.stdin.take().expect("B's input"); + let conn = acceptor.accept().expect("B connected back"); + let header = conn.recv_header().expect("B's word that its subtree is grown"); + assert_eq!(header.msg_type, MSG_GROWN, "B said something else"); + let [c, d, b_self, e] = conn.recv_handles_exact::<4>().expect("B sent four handles"); + // SAFETY: B moved each into this table with the frame, and nothing else + // answers for them. + let (c, d, e) = unsafe { (Process::from_raw(c), Process::from_raw(d), Process::from_raw(e)) }; + Grown { b, conn, c, d, b_self, e, _held: held } +} + +fn an_end_takes_its_subtree(end: End) { + let mut grown = grow(); + assert_eq!(grown.c.try_wait(), Err(SyscallError::WouldBlock), "C ended before B did"); + assert_eq!(grown.d.try_wait(), Err(SyscallError::WouldBlock), "D ended before B did"); + + let b_code = match end { + End::Killed => { + grown.b.kill().expect("kill B"); + // Claimed, so admission under it is closed, whether or not its + // teardown is done. + under_b_is_gone(&grown, "after its kill"); + KILLED + } + End::Exit => 5, + End::CpuFault => CPU_FAULT, + End::HandleFault => HANDLE_FAULT, + }; + if !matches!(end, End::Killed) { + grown.conn.send_bytes(MSG_END, &[end as u8]).expect("tell B how to end"); + } + + let status = grown.b.wait().expect("wait for B"); + assert_eq!(status.code(), Some(b_code), "B ended {end:?} and read {:?}", status.code()); + // The instant B's end is published: C's and D's already are. + assert_eq!(grown.c.try_wait(), Ok(KILLED), "C was not ended, as killed, before B's end was published ({end:?})"); + assert_eq!(grown.d.try_wait(), Ok(KILLED), "D, which init launched under B, was not ended before B's end ({end:?})"); + assert_eq!(grown.e.try_wait(), Err(SyscallError::WouldBlock), "E, started under init, ended with B ({end:?})"); + under_b_is_gone(&grown, "after its end was published"); + + grown.e.kill().expect("kill E"); + assert_eq!(grown.e.wait(), Ok(KILLED)); + syscall::close(grown.b_self); + println!(" B {end:?}: C and D ended first, as killed; E, under init, ran on"); +} + +/// A spawn and a launch placed under B's `self` both answer `Gone`. +fn under_b_is_gone(grown: &Grown, when: &str) { + match spawn_under(grown.b_self) { + Err(SyscallError::Gone) => {} + Err(other) => panic!("a spawn under B {when} answered {other:?}, not Gone"), + Ok(_) => panic!("a spawn under B {when} started"), + } + let place = syscall::dup(grown.b_self).expect("a copy of B's self to send"); + let conn = endow::service("launcher").expect("this process holds a launcher connector"); + let request = Launch { + program: HELD, + argv: b"/system/bin/cat", + env: b"", + cwd: "/", + extras: &[], + slots: &[], + parent: Parent::Place(place), + }; + let mut home = [0u8; 256]; + match launch::launch(&conn, &request, &mut home) { + Ok(Outcome::Gone) => {} + Ok(Outcome::Started(child)) => panic!("a launch under B {when} started {child:?}"), + Ok(_) => panic!("a launch under B {when} was answered as something other than gone"), + Err(_) => panic!("the launcher did not answer a launch under B {when}"), + } +} + +/// `SYS_SPAWN` of this binary as a C under `place`, killed again if it starts. +fn spawn_under(place: RawHandle) -> Result { + let argv = format!("{SELF_PATH}\0c"); + let args = SpawnArgs { + argv_ptr: argv.as_ptr() as u64, + argv_len: argv.len() as u64, + slot_map_ptr: 0, + slot_map_count: 0, + env_ptr: 0, + env_len: 0, + endow_ptr: 0, + endow_count: 0, + labels_ptr: 0, + labels_len: 0, + cwd_ptr: "/".as_ptr() as u64, + cwd_len: 1, + image: 0, + image_len: 0, + place: u64::from(place.0), + }; + // SAFETY: every pointer names a buffer of this frame that outlives the call. + let child = unsafe { syscall::spawn(&args) }?; + // SAFETY: the kernel installed it for this call. + Ok(unsafe { Process::from_raw(child) }) +} + +/// A handle to a live process that carries `MANAGE` and not `WRITE` names no +/// place: the right is refused, and the caller lives. +fn a_manage_only_handle_is_no_place() { + let mut child = Command::new(SELF_PATH).arg("c").spawn().expect("spawn a C"); + let manage = syscall::dup_narrowed(RawHandle(child.as_raw_handle()), Rights::MANAGE) + .expect("a MANAGE-only copy"); + match spawn_under(manage) { + Err(SyscallError::PermissionDenied) => {} + Err(other) => panic!("a MANAGE-only place answered {other:?}"), + Ok(_) => panic!("a MANAGE-only place took a child"), + } + syscall::close(manage); + child.kill().expect("kill the C"); + assert_eq!(child.wait().expect("wait the C").code(), Some(KILLED)); + println!(" a MANAGE-only handle is no place: PermissionDenied"); +} + +/// init is reached only by a launch: std refuses `under_init` for what the +/// launcher would not start under init, and starts nothing. +fn init_is_asked_only_by_a_launch() { + let undeclared = Command::new(SELF_PATH).arg("c").under_init().spawn(); + refused(undeclared, "a program no row declares"); + + let (_read, write) = toyos::pipe_pair().expect("a pipe of our own"); + let extra = Command::new(HELD).inherit_handle(5, write.as_handle().0).under_init().spawn(); + refused(extra, "a command carrying an extra slot"); + + let endowed = syscall::dup(write.as_handle()).expect("a handle to endow"); + let with_endowment = Command::new(HELD).endow("extra", endowed.0).under_init().spawn(); + refused(with_endowment, "a command carrying an endowment"); + // A refused spawn moves nothing. + syscall::close(endowed); + println!(" init is asked only by a launch: the three others are refused"); +} + +fn refused(spawned: std::io::Result, what: &str) { + match spawned { + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {} + Err(e) => panic!("under_init for {what} was refused as {e:?}, not PermissionDenied"), + Ok(mut child) => { + let _ = child.kill(); + panic!("under_init for {what} started it"); + } + } +} + +/// Each link launches a shell, and the shell spawns the next link: until a +/// start is refused for its depth, which the link or the shell says on the +/// chain's one output. Killing the first link then ends every one. +fn a_chain_stops_at_max_depth_and_dies_whole() { + let mut first = Command::new(SELF_PATH) + .args(["link", "1"]) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn the chain's first link"); + let mut said = BufReader::new(first.stdout.take().expect("the chain's output")); + let mut started = 0; + let stop = loop { + let mut line = String::new(); + assert!(said.read_line(&mut line).expect("read the chain") > 0, "the chain ended without a refusal"); + let line = line.trim().to_string(); + if let Some(k) = line.strip_prefix("link ") { + started = k.parse().expect("a link's index"); + } else if line.starts_with("unbounded") { + panic!("the chain passed link {LINK_BOUND} with no depth refusal: {line}"); + } else if line.contains("refused") || line.ends_with(": not found") { + break line; + } + }; + assert!(started >= 2, "the chain stopped at link {started}: {stop}"); + + first.kill().expect("kill the first link"); + assert_eq!(first.wait().expect("wait the first link").code(), Some(KILLED)); + let live = live_named(&["test_rs_process_tree", "shell"]); + let me = syscall::getpid().raw(); + let others: Vec<_> = live.iter().filter(|(pid, _)| *pid != me).collect(); + assert!(others.is_empty(), "the chain's first link ended and these of it run on: {others:?}"); + println!(" a chain of {started} links stopped at a depth refusal ({stop}) and died whole"); +} + +fn link(k: u32) -> ! { + println!("link {k}"); + if k > LINK_BOUND { + println!("unbounded at link {k}"); + park(); + } + let next = Command::new(SHELL).arg("-c").arg(format!("{SELF_PATH} link {}", k + 1)).spawn(); + if let Err(e) = next { + println!("link {k}: its shell was refused: {e}"); + } + park(); +} + +/// sshd started by a shell is the shell's child and dies with it; one the +/// shell `detach`es is init's and outlives it. +fn sshd_dies_with_its_shell_and_outlives_it_under_init() { + let mut shell = Command::new(SHELL).args(["-c", SSHD]).spawn().expect("start a shell running sshd"); + while live_named(&["sshd"]).is_empty() { + std::thread::yield_now(); + } + shell.kill().expect("kill the shell"); + assert_eq!(shell.wait().expect("wait the shell").code(), Some(KILLED)); + let live = live_named(&["sshd"]); + assert!(live.is_empty(), "sshd runs on after the shell that started it was killed: {live:?}"); + + let detaching = Command::new(SHELL) + .args(["-c", &format!("detach {SSHD}")]) + .status() + .expect("run a shell that detaches sshd"); + assert!(detaching.success(), "the shell's detach failed: {detaching:?}"); + let live = live_named(&["sshd"]); + assert!(!live.is_empty(), "sshd, detached under init, ended with the shell that started it"); + println!(" sshd died with its shell, and outlived the shell that detached it"); +} + +/// Every process whose main thread is named one of `names` and has not +/// ended, as `(pid, name)`. +fn live_named(names: &[&str]) -> Vec<(u32, String)> { + const HEADER: usize = toyos::system::SYSINFO_HEADER_SIZE; + const ENTRY: usize = toyos::system::SYSINFO_ENTRY_SIZE; + let mut buf = vec![0u8; HEADER + ENTRY * 1024]; + let n = cap().roster(&mut buf); + assert!((HEADER..=buf.len()).contains(&n), "sysinfo answered {n}"); + (HEADER..) + .step_by(ENTRY) + .take_while(|pos| pos + ENTRY <= n) + .map(|pos| &buf[pos..pos + ENTRY]) + .filter(|entry| entry[9] == 0 && entry[8] != ZOMBIE) + .map(|entry| { + let pid = u32::from_le_bytes(entry[0..4].try_into().unwrap()); + let name = String::from_utf8_lossy(&entry[32..60]).trim_end_matches('\0').to_string(); + (pid, name) + }) + .filter(|(_, name)| names.contains(&name.as_str())) + .collect() +} + +fn park() -> ! { + loop { + std::thread::park(); + } +} + +fn b() -> ! { + let conn = endow::service(BACK).expect("A endowed a port back"); + // Admitted under B, then refused by the loader: its hold on B goes. + assert!(Command::new("/system/bin/no_such_program").spawn().is_err(), "a program that is not there started"); + let c = Command::new(SELF_PATH).arg("c").stdin(Stdio::null()).spawn().expect("B spawns C"); + let d = Command::new(HELD).spawn().expect("B launches D"); + let e = Command::new(HELD).under_init().spawn().expect("B asks init for E"); + let own = endow::this_process().expect("every process holds itself"); + let handles = [ + syscall::dup(RawHandle(c.as_raw_handle())).expect("a copy of C"), + syscall::dup(RawHandle(d.as_raw_handle())).expect("a copy of D"), + syscall::dup(own.as_handle()).expect("a copy of B's self"), + syscall::dup(RawHandle(e.as_raw_handle())).expect("a copy of E"), + ]; + conn.send_bytes_with_handles(&handles, MSG_GROWN, &[]).expect("send A the subtree"); + + let header = conn.recv_header().expect("A's word for how to end"); + assert_eq!(header.msg_type, MSG_END); + let mut how = [0u8; 1]; + conn.recv_bytes(&header, &mut how).expect("how to end"); + match how[0] { + x if x == End::Exit as u8 => syscall::exit(5), + // SAFETY: an invalid opcode in Ring 3, which the kernel answers by + // ending this process; nothing after it runs. + x if x == End::CpuFault as u8 => unsafe { core::arch::asm!("ud2", options(noreturn)) }, + x if x == End::HandleFault as u8 => { + let mut buf = [0u8; 8]; + let n = syscall::read_nonblock(RawHandle(UNHELD_SLOT), &mut buf); + panic!("a slot this process never held answered {n:?}"); + } + other => panic!("A asked B to end as {other}"), + } +} diff --git a/tests/toyos-rust-tests/src/bin/spawn_cwd.rs b/tests/toyos-rust-tests/src/bin/spawn_cwd.rs index eac826b3a5a..5a09511a7eb 100644 --- a/tests/toyos-rust-tests/src/bin/spawn_cwd.rs +++ b/tests/toyos-rust-tests/src/bin/spawn_cwd.rs @@ -141,6 +141,7 @@ fn spawn_in(cwd: &str) -> Result { cwd_len: cwd.len() as u64, image: 0, image_len: 0, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }; // SAFETY: every pointer names a live local for the length beside it. let child = unsafe { syscall::spawn(&args) }?; diff --git a/tests/toyos-rust-tests/src/bin/spawn_image_object.rs b/tests/toyos-rust-tests/src/bin/spawn_image_object.rs index 28b728fd5a4..a4c86de3b8f 100644 --- a/tests/toyos-rust-tests/src/bin/spawn_image_object.rs +++ b/tests/toyos-rust-tests/src/bin/spawn_image_object.rs @@ -47,6 +47,7 @@ fn spawn(image: RawHandle, len: u64) -> Result { cwd_len: CWD.len() as u64, image: image.0 as u64, image_len: len, + place: u64::from(toyos_abi::HANDLE_INVALID.0), }) } } diff --git a/tests/toyos.rs b/tests/toyos.rs index 52ce975d3e7..b1129849c21 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -326,6 +326,9 @@ const RUST_SKIP: &[&str] = &[ // Needs a launcher to tell its two roads apart, and a declared shell and // toybox to take it: `spawn_cwd` runs it on tests/netcase. "spawn_cwd", + // Needs a launcher, a declared `cat` and shell, and sshd with a NIC under it: + // `process_tree` runs it on tests/proctreecase. + "process_tree", // Needs a boot image the harness staged a file into before the machine // started, which only `esp_filesystem` builds. "esp_files", @@ -846,6 +849,8 @@ const MACHINE_TESTS: &[(&str, Sched)] = &[ ("launcher_refusals", Sched::Parallel), // Paths a child prints and the kernel's refusals by name; no clock in any of them. ("spawn_cwd", Sched::Parallel), + // Exit codes, publication order and a kernel line; no clock in any of them. + ("process_tree", Sched::Parallel), ("foreign_disk_untouched", Sched::Parallel), ("volume_from_another_disk", Sched::Parallel), ("broken_data_volume_is_absent", Sched::Parallel), @@ -1393,6 +1398,7 @@ const CARRIES: &[(&str, &[&str])] = &[ ("netd_hostile_peer", &["test_rs_netd_hostile_peer"]), ("launcher_refusals", &["test_rs_launcher_refusals"]), ("spawn_cwd", &["test_rs_spawn_cwd"]), + ("process_tree", &["test_rs_process_tree"]), ("input_claim_absent", &["test_rs_input_absent"]), ("gpu_set_resolution", &["test_rs_gpu_set_resolution"]), ("iommu_gpu_scanout_swap", &["test_rs_gpu_scanout_swap"]), @@ -13816,6 +13822,63 @@ fn run_machine_test( eprintln!(" [netcase] every child started in the directory its spawn named"); Ok(()) } + "process_tree" => { + // A parent's end takes its children down, on `tests/proctreecase`: + // a launcher, a declared `cat` and shell, and sshd with a NIC under + // it and a key staged so it runs. The guest carries every verdict + // but two the kernel and sshd speak: the depth refusal names + // `MAX_DEPTH` + 1, and both of the guest's sshds bound their port — + // the one killed with its shell and the one that outlived its own. + let config = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/proctreecase"); + let bins: Vec<(String, Vec)> = + rust_bins.iter().filter(|(name, _)| name == "process_tree").cloned().collect(); + if bins.is_empty() { + return Err("process_tree was not built".to_string()); + } + let identity = common::ssh::Identity::mint("proctreecase")?; + let mut qemu = QemuInstance::boot_with_options( + &config, + &[], + &bins, + BootOptions { + profile: qemu::Profile::Headless, + extra_root_files: vec![( + common::ssh::KEYS_ON_ROOT.to_string(), + identity.authorized_line().into_bytes(), + )], + ..Default::default() + }, + ); + let mut console = qemu.boot_log().to_string(); + let _ = await_marker(&mut qemu, &mut console, "===READY===", "test-runner to come up"); + + let result = qemu.run_test("test_rs_process_tree", Duration::from_secs(600)); + if let Some(err) = &result.error { + return Err(format!("{err}\n{}{}", result.stdout, result.serial)); + } + console.push_str(&result.serial); + if result.exit_code != Some(0) || !result.stdout.contains("process_tree: PASS") { + return Err(format!( + "process_tree exited {:?}:\n{}\nconsole:\n{console}", + result.exit_code, result.stdout + )); + } + let refused = console.lines().filter(|l| l.contains("spawn: refused under pid ")).collect::>(); + if refused.len() != 1 || !refused[0].contains("at depth 65, more than 64 below init") { + return Err(format!( + "the kernel's depth refusals were {refused:?}, not one naming depth 65:\n{console}" + )); + } + let listening = console.matches("sshd: listening on port 22").count(); + if listening != 2 || console.contains("sshd: cannot bind") { + return Err(format!( + "sshd said it was listening {listening} times, not once per sshd the guest \ + started — an arm about one that never ran asserts nothing:\n{console}" + )); + } + eprintln!(" [proctreecase] every end took its subtree, and the chain stopped at depth 65"); + Ok(()) + } "input_claim_absent" => { // The one bootable machine with no input source: no xHCI, the // i8042 taken away. Three channels must agree — the argv stages diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 1ec384059a9..4eb3ba826f7 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -404,9 +404,19 @@ pub struct SpawnArgs { /// `/system/lib` alone. pub image: u64, pub image_len: u64, + /// The process the child is placed under, whose end takes it down: a + /// handle carrying [`Rights::WRITE`] to it — a copy of that process's + /// [`SELF_LABEL`] — or [`HANDLE_INVALID`] for the caller itself. + /// `PermissionDenied` for a handle without `WRITE` and `InvalidArgument` + /// for one to no process, since a place is a handle a peer sent; + /// `Gone` for a process being torn down, and `ResourceExhausted` for a + /// child more than `toyos_proclife::MAX_DEPTH` below init. + /// + /// [`Rights::WRITE`]: crate::handle::Rights::WRITE + pub place: u64, } -const _: () = assert!(core::mem::size_of::() == 112); +const _: () = assert!(core::mem::size_of::() == 120); /// One `(label, handle)` pair of a process's endowment table. /// @@ -434,6 +444,10 @@ const _: () = assert!(core::mem::size_of::() == 16); pub const SYSCAP_LABEL: &str = "syscap"; /// The label for a program's namespace — what its manifest `receives` becomes. pub const SVC_LABEL: &str = "svc"; +/// The label every process starts holding a handle to itself under, carrying +/// `WRITE`, `DUP` and `TRANSFER`: what it hands on for a child to be placed +/// under it ([`SpawnArgs::place`]). The kernel puts it in every table. +pub const SELF_LABEL: &str = "self"; /// `serve:`: the acceptor of a machine-wide port this program serves. pub const SERVE_PREFIX: &str = "serve:"; /// `dev:`: the claim for a device class this program was given. @@ -449,7 +463,8 @@ pub const DEV_PREFIX: &str = "dev:"; /// everything else it gets. pub const PROVIDE_PREFIX: &str = "provide:"; -/// Endowed `(label, handle)` pairs one spawn may carry. Policy on the +/// `(label, handle)` pairs one endowment table holds, the kernel's own +/// [`SELF_LABEL`] among them, so a spawn carries one fewer. Policy on the /// primitive, refused by name, never truncated — the widest manifest row plus /// stdio. pub const MAX_ENDOWMENTS: usize = 32; @@ -462,7 +477,7 @@ pub const MAX_ENDOWMENTS: usize = 32; /// pair is a `duplicate_entry` under the parent's own lock — enough of them to /// pass `MAX_HEAP_ALLOC`, where the allocator's refusal is a kernel panic. pub const MAX_SLOT_MAP: usize = RawHandle::MAX_SLOTS; -/// Bytes of label blob one endowment table may carry. +/// Bytes of label blob one endowment table holds, [`SELF_LABEL`]'s among them. pub const MAX_LABELS_LEN: usize = 4096; use crate::handle::Rights; diff --git a/toyos-proclife/Cargo.toml b/toyos-proclife/Cargo.toml index 8a1e7811fe0..e72724534cd 100644 --- a/toyos-proclife/Cargo.toml +++ b/toyos-proclife/Cargo.toml @@ -63,6 +63,24 @@ mutate-last-out-leaves-before-its-teardown = [] # `interleave::tests::a_join_racing_the_kill_that_takes_its_target` must red # under this. mutate-join-collects-in-a-teardown = [] +# `tree::insert_child` stops re-reading its place's claim, so a spawn admitted +# before a kill lands under the place after the walk read its children. +# `interleave::tests::a_spawn_racing_its_places_kill_lands_nothing_under_it_and_publishes_it` +# must red under this. +mutate-place-skips-the-insert-recheck = [] +# `tree::refuse_child` keeps the refused spawn's hold on its place, which then +# waits for a child that will never be and is never published. The same test +# must red under this. +mutate-refused-insert-keeps-the-count = [] +# `tree::teardown_done` publishes an end with its own teardown, whatever is +# still unpublished below it. `interleave::tests::an_exit_publishes_after_every_end_below_it` +# must red under this. +mutate-publish-before-the-children = [] +# The model's walk claims everything it owes in one lock section, as a kernel +# that held `PROCESS_TABLE` across its walk would. +# `interleave::tests::a_spawn_under_an_unrelated_process_lands_between_two_claims_of_one_walk` +# must red under this. +mutate-walk-in-one-hold = [] [dependencies] toyos-abi = { path = "../toyos-abi" } diff --git a/toyos-proclife/src/interleave.rs b/toyos-proclife/src/interleave.rs index 0edb88edb84..b0a29000725 100644 --- a/toyos-proclife/src/interleave.rs +++ b/toyos-proclife/src/interleave.rs @@ -22,24 +22,37 @@ use alloc::vec; use alloc::vec::Vec; use std::collections::HashSet; -use crate::model::World; +use crate::model::{Climb, World}; use crate::table::Processes; +use crate::tree::{self, Admit, Admitted}; use crate::{join, reap, spawn, teardown, Pid, Tid, Watch}; +/// `process::KILLED_EXIT_CODE`, which a walk claims every process below an end +/// with. +const KILLED: i32 = 137; + /// One kernel path, mid-flight. /// /// Each variant's `pc` is the number of lock sections it has completed, and /// every field beside it is a value the real path carries in a local across a /// lock release — which is the whole reason a window exists to explore. A /// thread's own way out after its operation is the [`World`]'s: `depart_step`. +/// +/// An exit's and a kill's `owed` is its walk: the processes below its end not +/// yet claimed, one claim per lock section, each claim's retires posted with +/// the lock given up. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Op { /// `process::exit`: a thread ending its own process — claim, retire the - /// rest, leave. - Exit { pid: Pid, tid: Tid, code: i32, pc: u32, retire: Vec }, - /// `process::kill_process`: claim, retire every thread, return. `by` is the - /// killing thread when the model holds it. - Kill { pid: Pid, code: i32, pc: u32, retire: Vec, by: Option<(Pid, Tid)> }, + /// rest, walk the subtree, leave. + Exit { pid: Pid, tid: Tid, code: i32, pc: u32, retire: Vec<(Pid, Tid)>, owed: Vec }, + /// `process::kill_process`: claim, retire every thread, walk the subtree, + /// return. `by` is the killing thread when the model holds it. + Kill { pid: Pid, code: i32, pc: u32, retire: Vec<(Pid, Tid)>, owed: Vec, by: Option<(Pid, Tid)> }, + /// `loader::spawn` under `place`: the admission, the whole of a process + /// built with every lock given up, then the insert — and a refused one's + /// climb, when it let the place's last hold go. + SpawnUnder { place: Pid, by: Option<(Pid, Tid)>, pc: u32, admitted: Option, climb: Option }, /// `process::spawn_thread`: two lock sections with the whole of a thread /// built between them; `block` is the mapped TLS the build carries across. Spawn { pid: Pid, pc: u32, block: Option }, @@ -53,15 +66,20 @@ pub enum Op { impl Op { pub fn exit(pid: Pid, tid: Tid, code: i32) -> Self { - Op::Exit { pid, tid, code, pc: 0, retire: Vec::new() } + Op::Exit { pid, tid, code, pc: 0, retire: Vec::new(), owed: Vec::new() } } pub fn kill(pid: Pid, code: i32) -> Self { - Op::Kill { pid, code, pc: 0, retire: Vec::new(), by: None } + Op::Kill { pid, code, pc: 0, retire: Vec::new(), owed: Vec::new(), by: None } } /// A kill issued by a thread the model holds, which is in the kernel until /// the kill returns. pub fn kill_by(pid: Pid, code: i32, by: (Pid, Tid)) -> Self { - Op::Kill { pid, code, pc: 0, retire: Vec::new(), by: Some(by) } + Op::Kill { pid, code, pc: 0, retire: Vec::new(), owed: Vec::new(), by: Some(by) } + } + /// A spawn under `place` by `by`'s thread, which is in the kernel until it + /// returns. + pub fn spawn_under(place: Pid, by: (Pid, Tid)) -> Self { + Op::SpawnUnder { place, by: Some(by), pc: 0, admitted: None, climb: None } } pub fn spawn(pid: Pid) -> Self { Op::Spawn { pid, pc: 0, block: None } @@ -81,7 +99,7 @@ impl Op { match *self { Op::Exit { pid, tid, .. } | Op::ThreadExit { pid, tid, .. } => Some((pid, tid)), Op::Join { pid, waiter, .. } => Some((pid, waiter)), - Op::Kill { by, .. } => by, + Op::Kill { by, .. } | Op::SpawnUnder { by, .. } => by, Op::Spawn { .. } | Op::IdlePass { .. } => None, } } @@ -90,6 +108,7 @@ impl Op { match self { Op::Exit { pc, .. } | Op::Kill { pc, .. } + | Op::SpawnUnder { pc, .. } | Op::Spawn { pc, .. } | Op::ThreadExit { pc, .. } | Op::Join { pc, .. } @@ -101,7 +120,7 @@ impl Op { /// cannot run until that process publishes its exit. fn awaits_teardown(&self, world: &World) -> Option { match *self { - Op::Kill { pid, pc: 2, .. } if world.published(pid).is_none() => Some(pid), + Op::Kill { pid, pc: WAIT, .. } if world.published(pid).is_none() => Some(pid), _ => None, } } @@ -110,6 +129,7 @@ impl Op { match self { Op::Exit { .. } => "exit", Op::Kill { .. } => "kill", + Op::SpawnUnder { .. } => "spawn_under", Op::Spawn { .. } => "spawn_thread", Op::ThreadExit { .. } => "thread_exit", Op::Join { .. } => "thread_join", @@ -120,38 +140,58 @@ impl Op { /// Run one lock section. fn step(&mut self, world: &mut World) { match self { - Op::Exit { pid, tid, code, pc, retire } => match *pc { - // The claim and the threads its winner retires, under the table lock. + Op::Exit { pid, tid, code, pc, retire, owed } => match *pc { + // The claim, the threads its winner retires and the children + // it owes, under the table lock. 0 => { - if teardown::claim_teardown(world, *pid, *code) { - *retire = teardown::retire_set(world.get(*pid).expect("just claimed"), Some(*tid)); + if tree::claim(world, *pid, *code, owed) { + let proc = world.get(*pid).expect("just claimed"); + *retire = teardown::retire_set(proc, Some(*tid)).into_iter().map(|t| (*pid, t)).collect(); } - *pc = 1; + *pc = POST; } - // With the lock given up: the retires, then this thread's own way out. - _ => { - for &other in retire.iter() { - world.post_retire(*pid, other); + // With the lock given up: the retires, then the walk's next + // claim, or this thread's own way out. + POST => { + for (victim, thread) in retire.drain(..) { + world.post_retire(victim, thread); + } + if owed.is_empty() { + world.depart(*pid, *tid, None); + *pc = DONE; + } else { + *pc = WALK; } - world.depart(*pid, *tid, None); - *pc = DONE; + } + _ => { + walk_claim(world, owed, retire); + *pc = POST; } }, - Op::Kill { pid, code, pc, retire, by } => { + Op::Kill { pid, code, pc, retire, owed, by } => { match *pc { 0 => { - if teardown::claim_teardown(world, *pid, *code) { - *retire = teardown::retire_set(world.get(*pid).expect("just claimed"), None); - *pc = 1; + if tree::claim(world, *pid, *code, owed) { + let proc = world.get(*pid).expect("just claimed"); + *retire = teardown::retire_set(proc, None).into_iter().map(|t| (*pid, t)).collect(); + *pc = POST; } else { *pc = DONE; } } - 1 => { - for &tid in retire.iter() { - world.post_retire(*pid, tid); + POST => { + for (victim, thread) in retire.drain(..) { + world.post_retire(victim, thread); } - *pc = if cfg!(feature = "mutate-kill-waits-for-its-victims") { 2 } else { DONE }; + *pc = match owed.is_empty() { + false => WALK, + true if cfg!(feature = "mutate-kill-waits-for-its-victims") => WAIT, + true => DONE, + }; + } + WALK => { + walk_claim(world, owed, retire); + *pc = POST; } // The mutation's wait, which `enabled` holds until the victim's exit is published. _ => *pc = DONE, @@ -162,6 +202,51 @@ impl Op { } } } + Op::SpawnUnder { place, by, pc, admitted, climb } => { + match *pc { + // The admission, under the table lock and before anything is built. + 0 => match tree::admit_child(world, *place) { + Admit::Yes(taken) => { + *admitted = Some(taken); + *pc = 1; + } + Admit::Gone | Admit::TooDeep { .. } => *pc = DONE, + }, + // The build happened with every lock given up; the insert + // decides under the lock that inserts. + 1 => { + let child = world.reserve_pid(); + let taken = admitted.take().expect("admitted at the first section"); + match tree::insert_child(world, taken, child) { + Ok(node) => { + world.land(*place, child, node); + *pc = DONE; + } + Err(refused) => match refused.publish { + Some(publish) => { + *climb = Some(Climb::Publish(publish)); + *pc = 2; + } + None => *pc = DONE, + }, + } + } + // The refused insert let its place's last hold go: this + // spawner publishes it, and climbs. + _ => { + let at = climb.take().expect("a climb is owed here"); + *climb = world.climb_step(at); + if climb.is_none() { + *pc = DONE; + } + } + } + if *pc == DONE { + if let Some(by) = *by { + world.leave_kernel(by); + } + } + } Op::Spawn { pid, pc, block } => match *pc { // Phase 1, under the table lock. 0 => { @@ -240,6 +325,28 @@ impl Op { } const DONE: u32 = u32::MAX; +/// An exit's or a kill's section that posts the retires its last claim owes. +const POST: u32 = 1; +/// The walk's next claim. +const WALK: u32 = 2; +/// `mutate-kill-waits-for-its-victims`' wait. +const WAIT: u32 = 3; + +/// The walk's next claim, under the table lock: one process, its retires and +/// the children it owes. Under `mutate-walk-in-one-hold`, every process the +/// walk owes, in this one section. +fn walk_claim(world: &mut World, owed: &mut Vec, retire: &mut Vec<(Pid, Tid)>) { + loop { + let Some(next) = owed.pop() else { return }; + if tree::claim(world, next, KILLED, owed) { + let proc = world.get(next).expect("just claimed"); + retire.extend(teardown::retire_set(proc, None).into_iter().map(|t| (next, t))); + } + if !cfg!(feature = "mutate-walk-in-one-hold") { + return; + } + } +} /// One move the explorer can make: an op's next section, or a thread's next /// step out. @@ -258,6 +365,19 @@ enum Move { /// before is not walked again: every law is a property of the state alone. The /// returned string is the schedule that produced it, in the order it ran. pub fn explore(initial: &World, ops: &[Op]) -> Result { + explore_leaves(initial, ops, &mut |_| {}) +} + +/// [`explore`], answering whether some schedule ends in a world `pred` holds +/// of: a property one ordering has, where [`explore`]'s laws are ones every +/// ordering keeps. +pub fn explore_any(initial: &World, ops: &[Op], pred: impl Fn(&World) -> bool) -> Result { + let mut found = false; + explore_leaves(initial, ops, &mut |leaf| found |= pred(leaf))?; + Ok(found) +} + +fn explore_leaves(initial: &World, ops: &[Op], leaf: &mut dyn FnMut(&World)) -> Result { let mut world = initial.clone(); for op in ops { if let Some(actor) = op.actor() { @@ -266,13 +386,19 @@ pub fn explore(initial: &World, ops: &[Op]) -> Result { } let mut trace = Vec::new(); let mut seen = HashSet::new(); - match walk(world, ops.to_vec(), &mut trace, &mut seen) { + match walk(world, ops.to_vec(), &mut trace, &mut seen, leaf) { Some(found) => Err(found), None => Ok(seen.len()), } } -fn walk(world: World, ops: Vec, trace: &mut Vec, seen: &mut HashSet<(World, Vec)>) -> Option { +fn walk( + world: World, + ops: Vec, + trace: &mut Vec, + seen: &mut HashSet<(World, Vec)>, + leaf: &mut dyn FnMut(&World), +) -> Option { if !seen.insert((world.clone(), ops.clone())) { return None; } @@ -284,6 +410,7 @@ fn walk(world: World, ops: Vec, trace: &mut Vec, seen: &mut HashSet< if moves.is_empty() { let stuck: Vec<&str> = ops.iter().filter(|op| !op.done()).map(Op::label).collect(); if stuck.is_empty() { + leaf(&world); return report(&world.final_faults(), trace); } return report(&[alloc::format!("deadlock: {} each wait and none can move", stuck.join(", "))], trace); @@ -312,7 +439,7 @@ fn walk(world: World, ops: Vec, trace: &mut Vec, seen: &mut HashSet< } } } - if let Some(found) = report(&faults, trace).or_else(|| walk(next_world, next_ops, trace, seen)) { + if let Some(found) = report(&faults, trace).or_else(|| walk(next_world, next_ops, trace, seen, leaf)) { trace.pop(); return Some(found); } @@ -570,4 +697,97 @@ mod tests { let sibling = world.spawn_thread(p); holds(&world, vec![Op::kill_by(p, 137, (p, sibling))]); } + + /// **A spawn racing its place's kill**, every ordering, with the spawner a + /// process that holds the place's `self` — init, serving a launch. The + /// child lands before the claim and the walk takes it, or its insert is + /// refused: nothing lands under the place after its claim, everything under + /// it ends, and the place is published in every one. + /// + /// Reds under `mutate-place-skips-the-insert-recheck`, where a child lands + /// under the claimed place and outlives it, and under + /// `mutate-refused-insert-keeps-the-count`, where the place is never + /// published. + #[test] + fn a_spawn_racing_its_places_kill_lands_nothing_under_it_and_publishes_it() { + let mut world = World::new(); + let init = world.spawn_process(); + let place = world.spawn_child(init); + world.spawn_child(place); + let launcher = (init, world.main_tid(init)); + let states = holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under(place, launcher)]); + std::println!("a spawn racing its place's kill: {states} states"); + } + + /// The same race with the place spawning under itself: its own thread is + /// in the spawn when the kill claims it, and its teardown waits for that + /// thread to come out. + #[test] + fn a_spawn_racing_the_kill_of_its_own_spawner() { + let mut world = World::new(); + let init = world.spawn_process(); + let place = world.spawn_child(init); + world.spawn_child(place); + let own = (place, world.main_tid(place)); + holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under(place, own)]); + } + + /// An exit takes a subtree two deep below it, and each end is published + /// after every end below it. + /// + /// Reds under `mutate-publish-before-the-children`. + #[test] + fn an_exit_publishes_after_every_end_below_it() { + let mut world = World::new(); + let init = world.spawn_process(); + let p = world.spawn_child(init); + let c = world.spawn_child(p); + world.spawn_child(c); + world.spawn_child(p); + let main = world.main_tid(p); + holds(&world, vec![Op::exit(p, main, 0)]); + } + + /// A child's own exit racing its parent's kill: two walks over one + /// subtree, one claim per process whichever walk makes it. + #[test] + fn a_childs_exit_racing_its_parents_kill() { + let mut world = World::new(); + let init = world.spawn_process(); + let p = world.spawn_child(init); + let c = world.spawn_child(p); + world.spawn_child(c); + let main = world.main_tid(c); + holds(&world, vec![Op::kill(p, KILLED), Op::exit(c, main, 3)]); + } + + /// **The walk gives the table up between two claims.** A spawn under a + /// process the walk does not reach lands, in some ordering, after the + /// walk's first claim and before its last: an end of any size holds the + /// table one process at a time. + /// + /// Reds under `mutate-walk-in-one-hold`. + #[test] + fn a_spawn_under_an_unrelated_process_lands_between_two_claims_of_one_walk() { + let mut world = World::new(); + let init = world.spawn_process(); + let p = world.spawn_child(init); + world.spawn_child(p); + world.spawn_child(p); + let unrelated = world.spawn_child(init); + let launcher = (init, world.main_tid(init)); + let ops = vec![Op::kill(p, KILLED), Op::spawn_under(unrelated, launcher)]; + // Two claimed when it landed: the end's own, and one of the two below it. + let between = match explore_any(&world, &ops, |leaf| { + leaf.inserted_at().iter().any(|&(_, claimed)| claimed == 2) + }) { + Ok(between) => between, + Err(found) => panic!("a lifecycle law broke:\n{found}"), + }; + assert!( + between, + "no ordering landed the unrelated spawn between the walk's two claims below the end: \ + the walk holds the table across its claims", + ); + } } diff --git a/toyos-proclife/src/lib.rs b/toyos-proclife/src/lib.rs index 85967980e66..a4fc0481c5c 100644 --- a/toyos-proclife/src/lib.rs +++ b/toyos-proclife/src/lib.rs @@ -2,7 +2,9 @@ //! //! The states are two and the questions about them are the whole of this //! crate: a thread is [`Scheduled`] or it is a [`Zombie`] with a code, and a -//! process is either being torn down by somebody or it is not. What is hard +//! process is either being torn down by somebody or it is not — and, in +//! [`tree`], where a process stands under the others, which its end takes +//! down with it and when that end is published. What is hard //! about the subject is not either state — it is that **two CPUs are inside one //! process's lifecycle at once**, and the defects it has are the interleavings //! rather than the arithmetic. A spawn builds a thread's TLS block, its kernel @@ -56,6 +58,7 @@ pub mod reap; pub mod spawn; pub mod table; pub mod teardown; +pub mod tree; #[cfg(test)] mod interleave; @@ -63,6 +66,7 @@ mod interleave; mod model; pub use table::{Lifecycle, Processes}; +pub use tree::{Node, MAX_DEPTH}; pub use toyos_abi::{Pid, Tid}; diff --git a/toyos-proclife/src/model.rs b/toyos-proclife/src/model.rs index 1a8704691f1..fee288fa3ff 100644 --- a/toyos-proclife/src/model.rs +++ b/toyos-proclife/src/model.rs @@ -18,7 +18,8 @@ use alloc::vec::Vec; use crate::table::{Lifecycle, Processes}; use crate::teardown::{self, Leave}; -use crate::{Pid, ThreadLocation, Tid, Watch}; +use crate::tree::{self, Admit, Publish}; +use crate::{Node, Pid, ThreadLocation, Tid, Watch}; /// One process, as its lifecycle sees it. #[derive(Clone, PartialEq, Eq, Hash)] @@ -30,6 +31,7 @@ pub struct ModelProc { /// How many times a claim was raised on this process. Counted here because /// `begin_teardown` has exactly one caller and the count is the law. claims: u32, + node: Node, } impl Lifecycle for ModelProc { @@ -59,6 +61,12 @@ impl Lifecycle for ModelProc { f(tid, at); } } + fn node(&self) -> &Node { + &self.node + } + fn node_mut(&mut self) -> &mut Node { + &mut self.node + } } /// Where a thread is on its way out, one lock section or pass per step. @@ -68,16 +76,25 @@ enum Out { Leave, /// The last one out: `teardown_resources`, the process's mappings and handles. Free { code: i32, mark: i32 }, - /// The last one out: `teardown_bookkeeping`'s table section, which marks it dead. + /// The last one out: `teardown`'s last table section, which marks it dead, + /// stashes its exit and lets its own hold on its publication go. Mark { code: i32, mark: i32 }, - /// The last one out: `ProcessObject::publish_exit`. - Publish { code: i32 }, + /// A publication that hold owed, and the climb behind it. + Climb(Climb), /// `thread_exit`'s post on its own watch. Post, /// The exit pass: the thread never runs again. Gone, } +/// A publication and the climb behind it, one step each: `publish_exit` with +/// no lock held, then the parent's hold lowered under the lock. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Climb { + Publish(Publish), + Lower { parent: Pid, child: Pid }, +} + /// A thread leaving: the code it chose, and how far it is. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] struct Departure { @@ -113,6 +130,14 @@ pub struct World { /// TLS blocks a spawn's phase 2 mapped and no thread owns yet. tls_mapped: BTreeSet, next_tls: u32, + /// Exits torn down and not yet published: the kernel keeps each on its + /// entry until its count lets it go. + stash: BTreeMap, + /// Processes inserted under a place whose end was already claimed. + landed_under_claimed: BTreeSet, + /// Each process a spawn op inserted, and how many processes had been + /// claimed when it landed. + inserted_at: Vec<(Pid, usize)>, } impl Processes for World { @@ -150,6 +175,9 @@ impl World { frees: BTreeMap::new(), tls_mapped: BTreeSet::new(), next_tls: 0, + stash: BTreeMap::new(), + landed_under_claimed: BTreeSet::new(), + inserted_at: Vec::new(), } } @@ -171,11 +199,57 @@ impl World { self.tls_mapped.remove(&block); } - /// A process with one thread, which is its main one — what - /// `ProcessEntry::new` builds. + /// A root process with one thread, which is its main one — what + /// `ProcessEntry::new` builds for init. pub fn spawn_process(&mut self) -> Pid { + let pid = self.reserve_pid(); + self.insert_proc(pid, Node::root()); + pid + } + + /// A process placed under `place`, admitted and inserted with nothing + /// between: how a test builds the tree its ops start from. + pub fn spawn_child(&mut self, place: Pid) -> Pid { + let Admit::Yes(admitted) = tree::admit_child(self, place) else { + panic!("spawn_child: pid {place} admits no child"); + }; + let pid = self.reserve_pid(); + let node = tree::insert_child(self, admitted, pid).expect("spawn_child: nothing came between"); + self.insert_proc(pid, node); + pid + } + + /// The pid a spawn's admission takes before it builds anything. + pub fn reserve_pid(&mut self) -> Pid { let pid = self.next_pid; self.next_pid = Pid(pid.0 + 1); + pid + } + + /// A spawn op's insert, which `tree::insert_child` has just answered with + /// `node`: records a landing under a claimed place, and how many + /// processes had been claimed when it landed. + pub fn land(&mut self, place: Pid, pid: Pid, node: Node) { + if self.procs.get(&place).is_some_and(Lifecycle::tearing_down) { + self.landed_under_claimed.insert(pid); + } + let claimed = self.claimed_count(); + self.inserted_at.push((pid, claimed)); + self.insert_proc(pid, node); + } + + /// Each process a spawn op inserted, and how many had been claimed then. + pub fn inserted_at(&self) -> &[(Pid, usize)] { + &self.inserted_at + } + + /// Every process claimed so far, those already reaped included. + pub fn claimed_count(&self) -> usize { + let reaped = self.published.keys().filter(|pid| !self.procs.contains_key(pid)).count(); + self.procs.values().filter(|p| p.claims > 0).count() + reaped + } + + fn insert_proc(&mut self, pid: Pid, node: Node) { let mut threads = BTreeMap::new(); threads.insert(Tid(0), ThreadLocation::Scheduled); self.procs.insert( @@ -186,10 +260,10 @@ impl World { threads, next_tid: Tid(1), claims: 0, + node, }, ); self.mapped.insert((pid, Tid(0))); - pid } /// Insert a thread the way `spawn_thread`'s phase 3 does — in the table and @@ -314,12 +388,16 @@ impl World { } Out::Mark { code, mark } => { teardown::torn_down(self, pid, tid, mark); - Out::Publish { code } - } - Out::Publish { code } => { - self.publish_exit(pid, code); - Out::Post + self.stash.insert(pid, code); + match tree::teardown_done(self, pid) { + Some(publish) => Out::Climb(Climb::Publish(publish)), + None => Out::Post, + } } + Out::Climb(climb) => match self.climb_step(climb) { + Some(next) => Out::Climb(next), + None => Out::Post, + }, Out::Post => { if chosen.is_some() { self.post(Watch::Thread(pid, tid)); @@ -344,6 +422,21 @@ impl World { .map(|(&(_, tid), _)| tid) } + /// One step of a climb, by whichever thread's lowering owed it. + pub fn climb_step(&mut self, climb: Climb) -> Option { + match climb { + Climb::Publish(publish) => { + let code = self + .stash + .remove(&publish.pid) + .expect("a publication takes the exit its teardown stashed"); + self.publish_exit(publish.pid, code); + publish.parent.map(|parent| Climb::Lower { parent, child: publish.pid }) + } + Climb::Lower { parent, child } => tree::published(self, parent, child).map(Climb::Publish), + } + } + /// `ProcessObject::publish_exit`, assertion and all: two publishes mean two /// teardowns claimed one process. pub fn publish_exit(&mut self, pid: Pid, code: i32) { @@ -409,6 +502,20 @@ impl World { "pid {pid} tid {tid}: its entry and its mapped TLS went while a sibling was still in the process", )); } + // L9. An end is published after every end below it. + for (&pid, proc) in &self.procs { + if let Some(parent) = proc.node.parent() { + if self.published.contains_key(&parent) && !self.published.contains_key(&pid) { + out.push(alloc::format!( + "pid {parent} was published with pid {pid} still unpublished below it", + )); + } + } + } + // L10. Nothing lands under a place once its end is claimed. + for pid in &self.landed_under_claimed { + out.push(alloc::format!("pid {pid} landed under a place whose end was already claimed")); + } out } @@ -418,13 +525,21 @@ impl World { /// case, so checking it at every state would report every schedule. /// And **L5** — every TLS block a spawn mapped ends owned or released. /// And **L6** — every claimed process is torn down: its exit published, - /// every killed thread gone. + /// every killed thread gone. And **L11** — an end takes every process + /// below it. pub fn final_faults(&self) -> Vec { let mut out = self.faults(); for (&pid, proc) in &self.procs { if proc.claims > 0 && !self.published.contains_key(&pid) { out.push(alloc::format!("pid {pid} was claimed for teardown and never published an exit")); } + if let Some(parent) = proc.node.parent() { + let ended = self.published.contains_key(&parent) + || self.procs.get(&parent).is_some_and(|p| p.claims > 0); + if ended && !self.published.contains_key(&pid) { + out.push(alloc::format!("pid {pid} outlived the end of pid {parent} above it")); + } + } } for &(pid, tid) in &self.killed { let gone = self.departing.get(&(pid, tid)).is_some_and(|d| d.at == Out::Gone); diff --git a/toyos-proclife/src/table.rs b/toyos-proclife/src/table.rs index 15780b940d5..6e94960675e 100644 --- a/toyos-proclife/src/table.rs +++ b/toyos-proclife/src/table.rs @@ -14,7 +14,7 @@ //! an order sorts what it collected, and the two that do //! ([`crate::teardown::retire_set`] and [`crate::reap::finished_pids`]) say so. -use crate::{Pid, ThreadLocation, Tid}; +use crate::{Node, Pid, ThreadLocation, Tid}; /// One process's lifecycle state: the whole of what a spawn, an exit, a kill, a /// join or a reap reads or writes about it. @@ -50,6 +50,11 @@ pub trait Lifecycle { /// Every thread of this process, in whatever order the container has. fn each_thread(&self, f: &mut dyn FnMut(Tid, ThreadLocation)); + + /// Where this process stands in the tree. Read and written by + /// [`crate::tree`] alone. + fn node(&self) -> &Node; + fn node_mut(&mut self) -> &mut Node; } /// The lifecycle face of the process table. diff --git a/toyos-proclife/src/tree.rs b/toyos-proclife/src/tree.rs new file mode 100644 index 00000000000..477665d9408 --- /dev/null +++ b/toyos-proclife/src/tree.rs @@ -0,0 +1,240 @@ +//! Where a process stands under the others, and what its end takes with it. +//! +//! **Every process has one parent and any number of children, and an end takes +//! the whole subtree.** A spawn's parent is its *place*: the spawner, or a +//! process whose `self` the spawner was handed. The kernel starts init with no +//! parent, init starts everything else, and nothing reparents. +//! +//! Three questions, each asked under `PROCESS_TABLE` and each one lock hold: +//! +//! - **May a child be placed here?** [`admit_child`] at the top of a spawn, +//! before anything is built, and [`insert_child`] under the lock that +//! inserts it — the second decides, because a kill can claim the place +//! between the two. A place being torn down takes nothing more, and a child +//! deeper than [`MAX_DEPTH`] below init is refused. +//! - **Who does an end take?** [`claim`] one process at a time: its claim is +//! what closes admission under it, and the same hold reads its children, +//! so a child either landed before and is owed to the walk or is refused. +//! - **When is an end published?** Once its own teardown is done and every +//! child's end is published: a count admission raises, and a child's +//! publication, a refused spawn or the process's own teardown lowers +//! ([`teardown_done`], [`published`], [`refuse_child`]). The thread that +//! lowers it to zero publishes, then climbs to the parent: at most +//! [`MAX_DEPTH`] + 1 publications, child before parent. + +use alloc::vec::Vec; + +use crate::table::{Lifecycle, Processes}; +use crate::{teardown, Pid}; + +/// How far below init a process may be. Bounds the climb one teardown can +/// owe, which runs with preemption off. +pub const MAX_DEPTH: u32 = 64; + +/// One process's place in the tree. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct Node { + parent: Option, + depth: u32, + /// Placed under it and not yet published. + children: Vec, + /// Its own teardown, and each child admitted under it and not yet + /// published or refused. + holds: u32, +} + +impl Node { + /// A process placed under none: init at depth 0, and a kernel thread's. + pub fn root() -> Self { + Self { parent: None, depth: 0, children: Vec::new(), holds: 1 } + } + + pub fn parent(&self) -> Option { + self.parent + } + + pub fn depth(&self) -> u32 { + self.depth + } +} + +/// A spawn's first answer. +#[must_use = "an admitted spawn holds its place's publication until it lands or is refused"] +#[derive(PartialEq, Eq, Debug)] +pub enum Admit { + Yes(Admitted), + /// The place is being torn down or has gone. + Gone, + /// The child would be `depth` below init, past [`MAX_DEPTH`]. + TooDeep { depth: u32 }, +} + +/// A spawn admitted under its place, which it keeps unpublished until +/// [`insert_child`] lands it or [`refuse_child`] lets the place go. +#[must_use = "an admitted spawn holds its place's publication until it lands or is refused"] +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct Admitted { + place: Pid, + depth: u32, +} + +impl Admitted { + pub fn place(&self) -> Pid { + self.place + } +} + +/// The next publication a lowered count owes: `pid`, then its `parent`'s turn. +#[must_use = "a process whose count reached zero is published by whoever lowered it"] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct Publish { + pub pid: Pid, + pub parent: Option, +} + +/// The insert's refusal: the place was claimed since the admission. +#[must_use = "a refused insert may leave its place for this spawner to publish"] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Refused { + pub publish: Option, +} + +/// The question at the top of a spawn under `place`, before anything is built. +pub fn admit_child(table: &mut T, place: Pid) -> Admit { + let Some(proc) = table.get_mut(place) else { return Admit::Gone }; + if proc.tearing_down() { + return Admit::Gone; + } + let depth = proc.node().depth + 1; + if depth > MAX_DEPTH { + return Admit::TooDeep { depth }; + } + proc.node_mut().holds += 1; + Admit::Yes(Admitted { place, depth }) +} + +/// The same question under the lock that inserts `child`, and the one that +/// decides. `Ok` is the child's node: the caller inserts it in this hold. +pub fn insert_child(table: &mut T, admitted: Admitted, child: Pid) -> Result { + let place = table + .get_mut(admitted.place) + .expect("insert_child: an admitted spawn keeps its place unpublished, so in the table"); + // The mutation this feature stages is the whole of the second check. + #[cfg(not(feature = "mutate-place-skips-the-insert-recheck"))] + if place.tearing_down() { + return Err(Refused { publish: refuse_child(table, admitted) }); + } + place.node_mut().children.push(child); + Ok(Node { parent: Some(admitted.place), depth: admitted.depth, children: Vec::new(), holds: 1 }) +} + +/// A spawn that will not land — its build failed or its insert was refused: +/// its hold on the place goes. +pub fn refuse_child(table: &mut T, admitted: Admitted) -> Option { + // The mutation this feature stages: the hold stays, and the place waits + // for a child that will never be. + if cfg!(feature = "mutate-refused-insert-keeps-the-count") { + return None; + } + lower(table, admitted.place) +} + +/// Claim `pid` for `code` and owe its children to the walk, all in this one +/// hold. `false` for a process gone or claimed by another end, whose own walk +/// owes its children. +#[must_use = "a caller that did not win the claim must not retire anything"] +pub fn claim(table: &mut T, pid: Pid, code: i32, owed: &mut Vec) -> bool { + if !teardown::claim_teardown(table, pid, code) { + return false; + } + let proc = table.get(pid).expect("claim: the entry the claim just succeeded on"); + owed.extend_from_slice(&proc.node().children); + true +} + +/// The last one out of `pid` has torn it down: its own hold goes. +pub fn teardown_done(table: &mut T, pid: Pid) -> Option { + // The mutation this feature stages: the end is published with its own + // teardown, whatever is still unpublished below it, and no child's + // publication climbs. + if cfg!(feature = "mutate-publish-before-the-children") { + let parent = table.get(pid).expect("teardown_done: a process torn down is in the table").node().parent; + return Some(Publish { pid, parent }); + } + lower(table, pid) +} + +/// `child` is published: it leaves `parent`'s children, and its hold goes. +pub fn published(table: &mut T, parent: Pid, child: Pid) -> Option { + if cfg!(feature = "mutate-publish-before-the-children") { + if let Some(proc) = table.get_mut(parent) { + proc.node_mut().children.retain(|&c| c != child); + } + return None; + } + let proc = table + .get_mut(parent) + .expect("published: a parent is unpublished, so in the table, while a child holds it"); + proc.node_mut().children.retain(|&c| c != child); + lower(table, parent) +} + +fn lower(table: &mut T, pid: Pid) -> Option { + let node = table + .get_mut(pid) + .expect("lower: a process is in the table until it is published, and a hold keeps it unpublished") + .node_mut(); + node.holds = node.holds.checked_sub(1).expect("lower: a process lowered past its last hold"); + (node.holds == 0).then_some(Publish { pid, parent: node.parent }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::World; + + /// A chain from init down to `MAX_DEPTH`: the last is admitted, and one + /// more below it is refused naming the depth it would have had. + #[test] + fn a_chain_is_refused_at_max_depth_plus_one_below_init() { + let mut world = World::new(); + let init = world.spawn_process(); + let mut at = init; + for depth in 1..=MAX_DEPTH { + at = world.spawn_child(at); + assert_eq!(world.get(at).unwrap().node().depth(), depth); + } + assert_eq!(admit_child(&mut world, at), Admit::TooDeep { depth: MAX_DEPTH + 1 }); + let parent = world.get(at).unwrap().node().parent().unwrap(); + let Admit::Yes(admitted) = admit_child(&mut world, parent) else { + panic!("a child at MAX_DEPTH itself was refused"); + }; + assert_eq!(refuse_child(&mut world, admitted), None); + } + + #[test] + fn a_place_being_torn_down_admits_nothing() { + let mut world = World::new(); + let init = world.spawn_process(); + let place = world.spawn_child(init); + assert!(claim(&mut world, place, 137, &mut Vec::new())); + assert_eq!(admit_child(&mut world, place), Admit::Gone); + assert_eq!(admit_child(&mut world, Pid(99)), Admit::Gone); + } + + #[cfg(not(feature = "mutate-place-skips-the-insert-recheck"))] + #[cfg(not(feature = "mutate-refused-insert-keeps-the-count"))] + #[cfg(not(feature = "mutate-publish-before-the-children"))] + #[test] + fn an_insert_under_a_place_claimed_since_the_admission_is_refused_and_lets_it_go() { + let mut world = World::new(); + let init = world.spawn_process(); + let place = world.spawn_child(init); + let Admit::Yes(admitted) = admit_child(&mut world, place) else { panic!("admitted") }; + assert!(claim(&mut world, place, 137, &mut Vec::new())); + // The place's own teardown is done first: only this spawn holds it. + assert_eq!(teardown_done(&mut world, place), None); + let refused = insert_child(&mut world, admitted, Pid(50)).expect_err("a claimed place took a child"); + assert_eq!(refused.publish, Some(Publish { pid: place, parent: Some(init) })); + } +} diff --git a/toyos-userbound/src/span.rs b/toyos-userbound/src/span.rs index fb4299bf3bf..0462f49a543 100644 --- a/toyos-userbound/src/span.rs +++ b/toyos-userbound/src/span.rs @@ -107,7 +107,7 @@ mod tests { ("Stat", 24, 8), ("SchedInfo", 24, 8), ("FramebufferInfo", 32, 4), - ("SpawnArgs", 112, 8), + ("SpawnArgs", 120, 8), ("NamespaceBuild", 56, 8), ("InboxSetup", 16, 8), ("ProcessStats", 128, 8), diff --git a/toyos/src/endow.rs b/toyos/src/endow.rs index 33d0d51f00c..e58d3885b74 100644 --- a/toyos/src/endow.rs +++ b/toyos/src/endow.rs @@ -22,10 +22,11 @@ use toyos_abi::syscall::{ use crate::ipc::Connection; use crate::namespace::Namespace; use crate::port::{Acceptor, Connector}; +use crate::process::Process; use crate::syscap::SysCap; use crate::{Device, OwnedHandle, RawHandle}; -pub use toyos_abi::syscall::{DEV_PREFIX, PROVIDE_PREFIX, SERVE_PREFIX, SVC_LABEL, SYSCAP_LABEL}; +pub use toyos_abi::syscall::{DEV_PREFIX, PROVIDE_PREFIX, SELF_LABEL, SERVE_PREFIX, SVC_LABEL, SYSCAP_LABEL}; /// Why a service name did not become a connection. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -143,6 +144,14 @@ pub fn namespace() -> Option<&'static Namespace> { NAMESPACE.get().as_ref() } +/// This process's handle to itself, which it hands on for a child to be +/// placed under it: `WRITE`, `DUP` and `TRANSFER`, from the kernel, under +/// [`SELF_LABEL`]. Borrowed rather than taken, as the namespace is: every +/// launch duplicates it. +pub fn this_process() -> Option<&'static Process> { + THIS_PROCESS.get().as_ref() +} + /// Make `ns` this process's namespace, for the one process no parent endows /// one: init, which builds the machine's ports itself and resolves its own /// files through them as every program does. Refused when this process already @@ -289,9 +298,11 @@ impl Once { static TABLE: EndowTable = EndowTable(Once::new()); static NAMESPACE: NamespaceCell = NamespaceCell(Once::new()); +static THIS_PROCESS: ProcessCell = ProcessCell(Once::new()); struct EndowTable(Once); struct NamespaceCell(Once>); +struct ProcessCell(Once>); impl EndowTable { fn get(&'static self) -> &'static Endowments { @@ -305,6 +316,12 @@ impl NamespaceCell { } } +impl ProcessCell { + fn get(&'static self) -> &'static Option { + self.0.get_or_init(|| Endowments::get().take::(SELF_LABEL)) + } +} + /// One `SYS_ENDOWMENTS` call: a count, that many entries, then the label blob. fn read_table() -> Endowments { const ENTRY: usize = 16; diff --git a/toyos/src/launch.rs b/toyos/src/launch.rs index bbc4a7b06e9..e0743ef2e7a 100644 --- a/toyos/src/launch.rs +++ b/toyos/src/launch.rs @@ -13,6 +13,11 @@ //! same bound `SYS_SPAWN` has. What it *adds* is that the child's namespace is //! its own manifest row rather than a narrowing of its parent's. //! +//! **A launch names its parent**, by one of two words: a place, which is a +//! handle to a process carrying `WRITE` — the caller's own `self`, duplicated — +//! that init spawns the program under, so the caller's end takes it down; or +//! init, the one way for a program to outlive whoever started it. +//! //! The wire is a single frame plus one handle batch, and this module is both //! halves of it — std's `Command` encodes and `init` decodes. @@ -34,8 +39,12 @@ pub const MSG_NOT_DECLARED: u32 = 3; /// endowment. The reason is in init's log, not in this frame — a caller can do /// nothing differently about any of them. pub const MSG_REFUSED: u32 = 4; +/// The place it named is a process whose end has begun: nothing started, and +/// nothing will under it. +pub const MSG_GONE: u32 = 5; -/// Connectors one launch may carry from its caller. +/// Handles beyond stdio one launch may carry from its caller: the connectors +/// it transfers, and the place it names. /// /// Policy on the primitive, refused by name. Five and not more because the /// batch also carries the three stdio handles and @@ -48,7 +57,22 @@ pub const MAX_LAUNCH_EXTRAS: usize = 5; /// caller that wants anything else in a child's table spawns it directly. pub const MAX_LAUNCH_SLOTS: usize = 3; -const HEADER: usize = 32; +const HEADER: usize = 36; + +/// The header's last word for a launch that names a place. +const PARENT_PLACE: u32 = 1; +/// The header's last word for a launch that asks init to be the parent. +const PARENT_INIT: u32 = 2; + +/// Whom a launch asks init to place the program under. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Parent { + /// The process a handle carrying `WRITE` names, moved as the batch's last + /// handle. + Place(H), + /// init itself. + Init, +} /// What a caller asks init to start. /// @@ -79,6 +103,9 @@ pub struct Launch<'a> { /// first — which is the same rule `SpawnArgs`'s two vectors state, with the /// duplication moved to the caller because a launch has only the one verb. pub slots: &'a [(u32, RawHandle)], + /// Whom the program is placed under. A place is **moved** with the batch, + /// and counts against [`MAX_LAUNCH_EXTRAS`]. + pub parent: Parent, } /// Why a request would not go on the wire. @@ -91,15 +118,17 @@ pub enum EncodeError { } impl Launch<'_> { - /// The handles this launch moves, slots first. + /// The handles this launch moves: slots, then extras, then the place. pub fn handles(&self) -> ([RawHandle; MAX_LAUNCH_EXTRAS + MAX_LAUNCH_SLOTS], usize) { let mut out = [RawHandle(0); MAX_LAUNCH_EXTRAS + MAX_LAUNCH_SLOTS]; let mut n = 0; - for (_, handle) in self.slots { - out[n] = *handle; - n += 1; - } - for (_, handle) in self.extras { + let place = match self.parent { + Parent::Place(place) => Some(place), + Parent::Init => None, + }; + let slots = self.slots.iter().map(|(_, h)| h); + let extras = self.extras.iter().map(|(_, h)| h); + for handle in slots.chain(extras).chain(&place) { out[n] = *handle; n += 1; } @@ -108,7 +137,11 @@ impl Launch<'_> { /// Write the request blob into `buf`, answering its length. pub fn encode(&self, buf: &mut [u8]) -> Result { - if self.extras.len() > MAX_LAUNCH_EXTRAS || self.slots.len() > MAX_LAUNCH_SLOTS { + let (place, word) = match self.parent { + Parent::Place(_) => (1, PARENT_PLACE), + Parent::Init => (0, PARENT_INIT), + }; + if self.extras.len() + place > MAX_LAUNCH_EXTRAS || self.slots.len() > MAX_LAUNCH_SLOTS { return Err(EncodeError::TooMany); } let names_len: usize = self.extras.iter().map(|(n, _)| n.len() + 1).sum(); @@ -132,6 +165,7 @@ impl Launch<'_> { self.env.len() as u32, self.cwd.len() as u32, names_len as u32, + word, ]; for (i, v) in lens.iter().enumerate() { buf[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes()); @@ -169,6 +203,7 @@ pub struct Request<'a> { pub extra_count: usize, slots: &'a [u8], names: &'a [u8], + parent: u32, } impl<'a> Request<'a> { @@ -183,7 +218,8 @@ impl<'a> Request<'a> { let (slot_count, extra_count) = (field(0), field(1)); let (slots_len, program_len, argv_len, env_len, cwd_len, names_len) = (field(2), field(3), field(4), field(5), field(6), field(7)); - if extra_count > MAX_LAUNCH_EXTRAS + let parent = field(8) as u32; + if extra_count + usize::from(parent == PARENT_PLACE) > MAX_LAUNCH_EXTRAS || slot_count > MAX_LAUNCH_SLOTS || slots_len != slot_count * 4 { @@ -214,7 +250,22 @@ impl<'a> Request<'a> { if names.iter().filter(|&&b| b == 0).count() != extra_count { return None; } - Some(Self { program, argv, env, cwd, extra_count, slots, names }) + Some(Self { program, argv, env, cwd, extra_count, slots, names, parent }) + } + + /// Whom the request names as the parent: `None` for neither word, which + /// is a launch init refuses. + pub fn parent(&self) -> Option> { + match self.parent { + PARENT_PLACE => Some(Parent::Place(())), + PARENT_INIT => Some(Parent::Init), + _ => None, + } + } + + /// The handles the batch carries: the slots, the extras, then the place. + pub fn handle_count(&self) -> usize { + self.slot_count() + self.extra_count + usize::from(self.parent == PARENT_PLACE) } /// The child slots the first handles of the batch are for, in order. @@ -259,6 +310,8 @@ pub enum Outcome<'a> { NotDeclared { home: &'a str }, /// Declared, and it did not start. Refused, + /// The place it named has begun to end. + Gone, } /// Send one launch and read its answer, a `HOME` in `answer`. @@ -295,6 +348,7 @@ pub fn launch<'a>( Ok(Outcome::NotDeclared { home }) } MSG_REFUSED => Ok(Outcome::Refused), + MSG_GONE => Ok(Outcome::Gone), _ => Err(LaunchError::Sent(IpcError::Malformed)), } } diff --git a/userland/init/src/main.rs b/userland/init/src/main.rs index 7fa2469fc46..3b8d1fdac41 100644 --- a/userland/init/src/main.rs +++ b/userland/init/src/main.rs @@ -46,6 +46,11 @@ //! before it runs, and everything else the session user's home, made at boot. //! A launch of a program no row names is answered with the session's, which //! the caller's direct spawn carries in place of its own. +//! +//! **A launched program is spawned under the place its request names** — a +//! copy of the caller's `self` — so the caller's end takes it down; a request +//! that asks for init is the one way to outlive the caller, and one naming +//! neither is refused. use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, VecDeque}; @@ -61,7 +66,7 @@ use toyos_manifest::{Manifest, Program}; use toyos::endow::Endowments; use toyos::fs::CAPABILITY_PREFIX; use toyos::ipc::{self, Connection, RxStep}; -use toyos::launch::{self, Request}; +use toyos::launch::{self, Parent, Request}; use toyos::namespace::{self, Namespace}; use toyos::poller::{Poller, READABLE}; use toyos::port::{self, Acceptor, Connector}; @@ -1426,22 +1431,22 @@ impl Init<'_> { // will actually be paired with a handle. A frame whose two counts // disagree would otherwise leave the unpaired handles behind. let names: Vec<&str> = request.extra_names().collect(); - if received != request.slot_count() + request.extra_count - || names.len() != request.extra_count - { + if received != request.handle_count() || names.len() != request.extra_count { say!( "init: launcher: a frame promising {} handles under {} names carried {received}", - request.slot_count() + request.extra_count, + request.handle_count(), names.len(), ); return; } // Past every refusal that does not know which handle is which, so ownership - // can be split. Both halves still release on every path below. + // can be split. Every part still releases on every path below. let all = held.take(); - let (slot_handles, extra_handles) = all.split_at(request.slot_count()); + let (slot_handles, rest) = all.split_at(request.slot_count()); + let (extra_handles, place_handle) = rest.split_at(request.extra_count); let slots = Moved(slot_handles.to_vec()); + let place = Moved(place_handle.to_vec()); // Owned, so they close when this call returns: `SYS_NAMESPACE_BUILD` copies // a connector into the namespace and leaves the caller's handle, and init's // copy of a client's connector has no life beyond this launch. @@ -1463,6 +1468,19 @@ impl Init<'_> { return; } + // **A launch names its parent**: the place it carries, spawned under, or + // init. One that names neither is refused rather than given to init, + // which would let it outlive a caller that never asked it to. + let under = match request.parent() { + Some(Parent::Place(())) => Some(place.0[0]), + Some(Parent::Init) => None, + None => { + say!("init: launcher: refused a launch that names no parent"); + let _ = conn.try_signal(launch::MSG_REFUSED); + return; + } + }; + // **The caller's path, not the row's, and `argv[0]` is why.** `declared` // has already established that the two name one binary, so this grants // nothing extra — and `/system/bin/echo` spawned as `/system/bin/toybox` is a toybox that @@ -1481,6 +1499,9 @@ impl Init<'_> { } } command.current_dir(request.cwd); + if let Some(place) = under { + command.under(place.0); + } for arg in request.argv.split(|&b| b == 0).skip(1).filter(|a| !a.is_empty()) { if let Ok(arg) = std::str::from_utf8(arg) { command.arg(arg); @@ -1574,6 +1595,12 @@ impl Init<'_> { Err(_) => toyos_abi::syscall::close(handle), } } + // std's word for the kernel's `Gone`, which a spawn answers only for + // its place. + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe && under.is_some() => { + say!("init: launcher: {} was not started: the place it names is ending", program.name); + let _ = conn.try_signal(launch::MSG_GONE); + } Err(e) => { say!("init: launcher: cannot start {}: {e}", program.name); let _ = conn.try_signal(launch::MSG_REFUSED); @@ -1584,8 +1611,8 @@ impl Init<'_> { /// Handles a launch moved into init, released on every path out of it. /// -/// A `Drop` and not a close at each `return`: there are seven ways out of -/// `serve_launch` and a client picks which one by what it sends. +/// A `Drop` and not a close at each `return`: a client picks which way out of +/// `serve_launch` it takes by what it sends. struct Moved(Vec); impl Moved { diff --git a/userland/shell/src/main.rs b/userland/shell/src/main.rs index 4e88747e6dd..74aeebd14b0 100644 --- a/userland/shell/src/main.rs +++ b/userland/shell/src/main.rs @@ -629,6 +629,7 @@ fn execute_simple(cmd: &SimpleCommand, piped_stdin: Option { print_help(); set_status_code(0); return true; } + "detach" => return detach(cmd), _ => {} } @@ -706,6 +707,33 @@ fn provided() -> &'static [(&'static str, Connector)] { }) } +/// `detach [args...]`: start it with init as its parent — the one way +/// for a program to outlive this shell — and do not wait for it. Nothing this +/// shell was provided goes with it: that belongs to the session it outlives. +fn detach(cmd: &SimpleCommand) -> bool { + let Some(program) = cmd.args.get(1) else { + println!("detach: which program?"); + set_status_code(2); + return false; + }; + let cwd = + env::current_dir().map(|p| p.display().to_string()).unwrap_or_else(|_| String::from("/")); + let mut command = Command::new(toyos_manifest::package::launch_path(program, &cwd)); + command.args(&cmd.args[2..]).under_init(); + apply_redirect(&mut command, cmd); + match command.spawn() { + Ok(_) => { + set_status_code(0); + true + } + Err(e) => { + println!("detach: {program}: {e}"); + set_status_code(126); + false + } + } +} + fn build_command(cmd: &SimpleCommand) -> Option { if cmd.args.is_empty() { return None; } // The launcher refuses a path it would have to normalize, and the shell is @@ -785,7 +813,8 @@ fn set_status_code(code: i32) { } fn print_help() { - println!("Builtins: cd, clear, exit, export, help"); + println!("Builtins: cd, clear, detach, exit, export, help"); + println!("detach : start it under init, so it outlives this shell"); println!("Operators: | (pipe), && (and), || (or), ; (sequence)"); println!("Redirects: > (truncate), >> (append)"); println!("Variables: $VAR, ${{VAR}}, $? (exit status)"); diff --git a/userland/toybox/src/ps.rs b/userland/toybox/src/ps.rs index 754d678b557..5543bc621a1 100644 --- a/userland/toybox/src/ps.rs +++ b/userland/toybox/src/ps.rs @@ -111,8 +111,7 @@ pub fn main(_args: Vec) { }; let window_ns = uptime_ns.saturating_sub(first_uptime); - // No PPID column: a process has no parent. What started it gave it what it - // holds and kept a handle, and neither of those is a number the table has. + // No PPID column: no syscall answers a process's parent. println!("{:>5} {:>3} {:>2} {:>8} {:>5} {:>5} {}", "PID", "TID", "S", "CPU", "%CPU", "MEM", "NAME"); From b04de483325a3f67b1f83db362276df27af57d50 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 05:55:36 +0200 Subject: [PATCH 02/16] process_tree judges sshd's end by the kernel's exit line The first sshd is killed as soon as the roster shows it, which can be before it binds its port, so a count of its listening lines is not a fact the guest arranged. What makes the arm about a kill rather than an sshd that ended by itself is the kernel's line for its end: one, and code 137. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- tests/toyos.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/toyos.rs b/tests/toyos.rs index b1129849c21..111152bf485 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -13826,9 +13826,9 @@ fn run_machine_test( // A parent's end takes its children down, on `tests/proctreecase`: // a launcher, a declared `cat` and shell, and sshd with a NIC under // it and a key staged so it runs. The guest carries every verdict - // but two the kernel and sshd speak: the depth refusal names - // `MAX_DEPTH` + 1, and both of the guest's sshds bound their port — - // the one killed with its shell and the one that outlived its own. + // but two the kernel speaks: the depth refusal names `MAX_DEPTH` + 1, + // and the one sshd that ended, the one under the killed shell, was + // killed rather than ending by itself. let config = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/proctreecase"); let bins: Vec<(String, Vec)> = rust_bins.iter().filter(|(name, _)| name == "process_tree").cloned().collect(); @@ -13869,11 +13869,15 @@ fn run_machine_test( "the kernel's depth refusals were {refused:?}, not one naming depth 65:\n{console}" )); } - let listening = console.matches("sshd: listening on port 22").count(); - if listening != 2 || console.contains("sshd: cannot bind") { + // The kernel names an end once its teardown is done, which is before + // the shell's end the guest waited on, and its line may still be on + // its way to the console. + await_marker(&mut qemu, &mut console, "exit: sshd pid=", "the kernel to name sshd's end")?; + let ended: Vec<_> = console.lines().filter(|l| l.contains("exit: sshd pid=")).collect(); + if ended.len() != 1 || !ended[0].contains(" code=137 ") { return Err(format!( - "sshd said it was listening {listening} times, not once per sshd the guest \ - started — an arm about one that never ran asserts nothing:\n{console}" + "the sshd ends were {ended:?}, not the one under the killed shell, killed — \ + an sshd that ended by itself makes its arm assert nothing:\n{console}" )); } eprintln!(" [proctreecase] every end took its subtree, and the chain stopped at depth 65"); From 81ff55022bca83bc25316792022f677a1769732b Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 06:50:35 +0200 Subject: [PATCH 03/16] build: the boot-config gates cover tests/proctreecase `every_shipped_boot_config_is_covered` found the new case config and no row for it in ALL_CONFIGS, so none of the gates over configs read it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- src/build.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/build.rs b/src/build.rs index 15d9f5386ea..7cb0982b9f2 100644 --- a/src/build.rs +++ b/src/build.rs @@ -3156,6 +3156,7 @@ mod tests { "tests/netcase/system.toml", "tests/partclaimcase/system.toml", "tests/pkgcase/system.toml", + "tests/proctreecase/system.toml", "tests/quiescecase/system.toml", "tests/quiescelastcase/system.toml", "tests/quiescetwicecase/system.toml", From 2d53ac30907183de30eec76ebb085ca5bc97d6ea Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 07:53:28 +0200 Subject: [PATCH 04/16] Answer the first review of #659: a spawn past its admission lands, and the tree's tests reach what they claim - A spawn that answers a refusal leaves its caller's table as it was. Admission is the last refusal: past it the loader moves the caller's handles, and `tree::land_child` lands the child whatever happened since. A child whose place was claimed since its admission is claimed in the hold that inserts it, its retires posted by its spawner, and the spawn answers it. `tree::insert_child`, its `Refused` and the kernel's wrapper go. The model moves the caller's handles before the landing, as the kernel does, and gains L12: a refused spawn moved none of them. At 81ff55022, with only that law added, the race test reds on it. - The race test runs a failed build beside a landing, which is what `mutate-refused-spawn-keeps-the-count` (renamed from the refused insert's) now reds through. No test is gated on a control feature. - `tree::tests` gains a published child leaving its parent's children, and a child landed under a place claimed since its admission. - std (fork 40470840283): a launch that cannot carry its place is refused, never spawned directly. `toyos::endow::this_process` answers the handle and panics when it is gone. - The kernel refuses a spawn endowing `self`; `MAX_SPAWN_ENDOWMENTS` and `MAX_SPAWN_LABELS_LEN` are what a spawn may carry, read at both sites. - The climb runs without `preempt_off`. - The toyos SDK host-tests `Request::parent`. - process_tree keeps the killed and CPU-fault arms, the MANAGE-only place, two `under_init` refusals, the chain, and a detached `cat`; the harness requires the loader's refusal of each B's first spawn. sshd, netd and the staged key leave proctreecase. launcher_refusals loses the no-parent arm. - Prose the review flagged is deleted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- ...nt-and-a-parent-takes-its-children-down.md | 4 +- kernel/src/loader/mod.rs | 65 ++++---- kernel/src/loader/start.rs | 13 +- kernel/src/process.rs | 72 ++++----- kernel/src/syscall/dispatch.rs | 5 +- rust | 2 +- src/ci.rs | 6 +- tests/proctreecase/system.toml | 19 +-- .../src/bin/compositor_client_death.rs | 2 +- .../src/bin/launcher_refusals.rs | 40 ----- .../toyos-rust-tests/src/bin/process_tree.rs | 140 +++++++----------- tests/toyos.rs | 45 ++---- toyos-abi/src/syscall.rs | 9 +- toyos-proclife/Cargo.toml | 10 +- toyos-proclife/src/interleave.rs | 106 ++++++++----- toyos-proclife/src/model.rs | 52 +++++-- toyos-proclife/src/teardown.rs | 8 +- toyos-proclife/src/tree.rs | 100 +++++++++---- toyos/src/endow.rs | 15 +- toyos/src/launch.rs | 30 ++++ userland/init/src/main.rs | 6 +- userland/toybox/src/ps.rs | 1 - 22 files changed, 392 insertions(+), 358 deletions(-) diff --git a/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index ba4ba9eb3e2..3a489bd5217 100644 --- a/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md +++ b/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md @@ -128,8 +128,8 @@ directory is `issues/isolation/every-program-sees-only-the-files-it-was-given.md programs die with it. Oracle: systemd-logind, whose session scope ends every process of a login. 6. **A program is asked to quit** (Q3b, Q6a–Q6f). `SYS_PROCESS_QUIT` (124, - never assigned), `(process, reason)`, needs `MANAGE` as the kill does and - reaches the process's subtree as an end does; the reason is interrupt, + never assigned), `(process, reason)`, needs `MANAGE` as the kill does; the + reason is interrupt, hang-up or terminate. Each process is started holding its notice, a new object kind under the label `quit`, `READABLE` while a reason is asked and not yet read, whose read takes it. A quit kills a process that has never diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index d0607dfa547..962ff6719f2 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -598,10 +598,7 @@ pub fn spawn( let object = crate::object::process::ProcessObject::new(pid); // The point of no return: every failure above answers the caller with its // table untouched. `commit`'s own `?` is different — reachable only if the - // caller raced its own spawn, and fatal to it, not a refusal — and so is a - // parent claimed while this was built, whose refused insert takes the - // endowments with a child that never ran, as its walk would have taken - // them with one that had. + // caller raced its own spawn, and fatal to it, not a refusal. let (handles, endowments) = pending.commit(KObjectRef::Process(Arc::clone(&object)))?; let proc_data = Arc::new(Lock::new(ProcessData { handles, @@ -650,39 +647,35 @@ pub fn spawn( let syms = Arc::new(syms); let mut guard = PROCESS_TABLE.lock(); - let table = guard.as_mut().unwrap(); - let node = match admission.land(table) { - Ok(node) => node, - // Everything built is dropped on the way out, with no lock held. - Err(refused) => { - drop(guard); - refused.publish(); - log!("spawn: {}: its parent ended while it was built", path); - return Err(SyscallError::Gone.into()); - } - }; - table.fill(pid, ProcessEntry::new( - Arc::clone(&object), - start::make_name(path), - proc_data, - Arc::clone(&syms), - ThreadEntry::new(thread_data), - node, - )); - let tid = table.get(pid).unwrap().main_tid(); - - // Placed while still holding the table lock: kill_process claims teardown - // under it, so a retire sweep can never see the pid before its thread is scheduled. - let (sched, dst) = scheduler::enqueue_new( - scheduler::TaskId(pid, tid), - ks_alloc, - ks_sp, - child_pt.clone(), - thread_pointer, - syms, - ); - table.get_mut(pid).unwrap().threads_mut().get_mut(tid).unwrap().set_sched(sched); + let ((tid, dst), retire) = admission.land(guard.as_mut().unwrap(), |table, node| { + table.fill(pid, ProcessEntry::new( + Arc::clone(&object), + start::make_name(path), + proc_data, + Arc::clone(&syms), + ThreadEntry::new(thread_data), + node, + )); + let tid = table.get(pid).unwrap().main_tid(); + // Placed while still holding the table lock: kill_process claims teardown + // under it, so a retire sweep can never see the pid before its thread is scheduled. + let (sched, dst) = scheduler::enqueue_new( + scheduler::TaskId(pid, tid), + ks_alloc, + ks_sp, + child_pt.clone(), + thread_pointer, + syms, + ); + table.get_mut(pid).unwrap().threads_mut().get_mut(tid).unwrap().set_sched(sched); + (tid, dst) + }); drop(guard); + // Its parent was claimed while it was built, and its walk has passed: the + // child is ended as that walk would have ended it, and the spawn answers it. + for sched in &retire { + scheduler::post_retire(sched); + } let t3 = crate::clock::nanos_since_boot(); log!("spawn: {} pid={} tid={} dst={} base={:#x} entry={:#x} root={:#x} symbols={}KiB (layout={}ms relocs={}ms deps={}ms tls={}ms total={}ms)", diff --git a/kernel/src/loader/start.rs b/kernel/src/loader/start.rs index d3c96796a22..33463f16302 100644 --- a/kernel/src/loader/start.rs +++ b/kernel/src/loader/start.rs @@ -11,7 +11,9 @@ use crate::process::{ use crate::scheduler; use crate::user_ptr::UserBytes; use toyos_abi::handle::{RawHandle, Rights}; -use toyos_abi::syscall::{EndowEntry, SyscallError, MAX_ENDOWMENTS, MAX_LABELS_LEN, MAX_SLOT_MAP, SELF_LABEL}; +use toyos_abi::syscall::{ + EndowEntry, SyscallError, MAX_SLOT_MAP, MAX_SPAWN_ENDOWMENTS, MAX_SPAWN_LABELS_LEN, SELF_LABEL, +}; /// One `[child_slot, parent_handle]` pair of `SpawnArgs::slot_map_ptr`, in bytes. pub const SLOT_PAIR_LEN: usize = 8; @@ -75,6 +77,10 @@ impl PendingHandles { if end > labels.len() { return Err(SyscallError::InvalidArgument.into()); } + // The kernel's own, which a caller's of that name would shadow in the child's lookup. + if &labels[label_off as usize..end] == SELF_LABEL.as_bytes() { + return Err(SyscallError::InvalidArgument.into()); + } // Checked before any removal, so a missing `TRANSFER` refuses the spawn instead of leaving a hole. let rights = data.handles.rights_of(handle)?; if !rights.contains(Rights::TRANSFER) { @@ -131,8 +137,7 @@ pub fn build_child_handles( endow: &UserBytes, labels: &[u8], ) -> Result { - // One fewer than the table holds, and as many bytes fewer as its label: the kernel adds `self`. - if endow.len() / ENDOW_ENTRY_LEN >= MAX_ENDOWMENTS { + if endow.len() / ENDOW_ENTRY_LEN > MAX_SPAWN_ENDOWMENTS { return Err(SyscallError::InvalidArgument.into()); } // Checked before the loop: `install_at`'s cap misses a repeated slot, which would duplicate @@ -140,7 +145,7 @@ pub fn build_child_handles( if slot_map.len() / SLOT_PAIR_LEN > MAX_SLOT_MAP { return Err(SyscallError::InvalidArgument.into()); } - if labels.len() + SELF_LABEL.len() > MAX_LABELS_LEN { + if labels.len() > MAX_SPAWN_LABELS_LEN { return Err(SyscallError::InvalidArgument.into()); } let data_arc = process_data(); diff --git a/kernel/src/process.rs b/kernel/src/process.rs index 8c7bc029358..019679c7b5c 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -833,7 +833,7 @@ pub enum Parent { } /// A spawn admitted under its parent, and the pid its child will have. -/// While it lives it holds the parent's publication: [`Admission::land`] hands that hold to the child, and a drop — every way a spawn fails between the two — lets the parent go. +/// While it lives it holds the parent's publication: [`Admission::land`] hands that hold to the child, and a drop — every way a spawn's build fails — lets the parent go. pub struct Admission { pid: Pid, under: Option, @@ -864,10 +864,17 @@ impl Admission { self.pid } - /// The insert's question, under the `PROCESS_TABLE` hold that fills this pid with the node answered; `Err` for a parent claimed since the admission, the hold on it let go. - pub fn land(mut self, table: &mut ProcessTable) -> Result { - let Some(admitted) = self.under.take() else { return Ok(Node::root()) }; - tree::insert_child(table, admitted, self.pid).map_err(|refused| Refused(refused.publish)) + /// Land the child in the `PROCESS_TABLE` hold `insert` fills this pid and schedules its thread in. A parent claimed since the admission has walked its children, so the child is claimed in this hold too, and the retires answered are the spawner's to post with the lock given up. + pub fn land( + mut self, + table: &mut ProcessTable, + insert: impl FnOnce(&mut ProcessTable, Node) -> R, + ) -> (R, Vec) { + let Some(admitted) = self.under.take() else { return (insert(table, Node::root()), Vec::new()) }; + match tree::land_child(table, admitted, self.pid, KILLED_EXIT_CODE, insert) { + (inserted, tree::Landed::Placed) => (inserted, Vec::new()), + (inserted, tree::Landed::Claimed) => (inserted, retires(table, self.pid, None)), + } } } @@ -883,16 +890,6 @@ impl Drop for Admission { } } -/// An insert refused because its parent was claimed since the admission; [`Refused::publish`] publishes the parent, with the table lock given up, when this spawn held its last hold. -#[must_use = "a refused insert may owe its parent's publication"] -pub struct Refused(Option); - -impl Refused { - pub fn publish(self) { - publish_climb(self.0); - } -} - /// Spawn a thread within the current process. pub fn spawn_thread(entry: u64, stack_ptr: u64, arg: u64, stack_base: u64) -> Option { // Phase 1: parent's data + address space (table lock dropped after). @@ -1066,27 +1063,25 @@ fn teardown_bookkeeping(table: &mut ProcessTable, process_pid: Pid, code: i32) - cpu_ns } -/// Publish `first`, then every parent each publication lets go, child before parent: at most `toyos_proclife::MAX_DEPTH` + 1 publications, run with preemption off and each with the table lock given up. +/// Publish `first`, then every parent each publication lets go, child before parent: at most `toyos_proclife::MAX_DEPTH` + 1 publications, each with the table lock given up. /// Once published an entry is reapable, so only its parent's entry is read after. fn publish_climb(first: Option) { - crate::sched::driver::preempt_off(|_| { - let mut ready = first; - while let Some(publish) = ready { - let (object, exit) = { - let mut guard = PROCESS_TABLE.lock(); - let proc = guard.as_mut().unwrap().get_mut(publish.pid) - .expect("publish_climb: a process is in the table until its exit is published"); - let exit = proc.exit.take() - .expect("publish_climb: a count reaches zero only after the teardown kept its exit"); - (Arc::clone(&proc.object), exit) - }; - object.publish_exit(exit); - ready = publish.parent.and_then(|parent| { - let mut guard = PROCESS_TABLE.lock(); - tree::published(guard.as_mut().unwrap(), parent, publish.pid) - }); - } - }); + let mut ready = first; + while let Some(publish) = ready { + let (object, exit) = { + let mut guard = PROCESS_TABLE.lock(); + let proc = guard.as_mut().unwrap().get_mut(publish.pid) + .expect("publish_climb: a process is in the table until its exit is published"); + let exit = proc.exit.take() + .expect("publish_climb: a count reaches zero only after the teardown kept its exit"); + (Arc::clone(&proc.object), exit) + }; + object.publish_exit(exit); + ready = publish.parent.and_then(|parent| { + let mut guard = PROCESS_TABLE.lock(); + tree::published(guard.as_mut().unwrap(), parent, publish.pid) + }); + } } /// One `ProcessStats`, from a process's own data; written once, since `SYS_PROCESS_STATS` samples a live process through the same fields the teardown snapshots. @@ -1179,12 +1174,17 @@ fn claim(pid: Pid, code: i32, caller: Option, owed: &mut Vec) -> Vec) -> Vec { + let proc = Processes::get(table, pid).expect("retires: a claimed process is in the table"); proclife::retire_set(proc, caller) .into_iter() .map(|tid| { proc.threads.get(tid).and_then(ThreadEntry::sched).cloned() - .expect("claim: a thread in the table has its scheduler record") + .expect("retires: a thread in the table has its scheduler record") }) .collect() } diff --git a/kernel/src/syscall/dispatch.rs b/kernel/src/syscall/dispatch.rs index 21efa88a2cf..0b1ecbcbf69 100644 --- a/kernel/src/syscall/dispatch.rs +++ b/kernel/src/syscall/dispatch.rs @@ -226,9 +226,8 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> Ok(cwd) => cwd, Err(e) => return e.to_u64(), }; - // One fewer, and its label's bytes fewer, than a table holds: the kernel adds `self`. - if args.endow_count as usize >= toyos_abi::syscall::MAX_ENDOWMENTS - || args.labels_len > (toyos_abi::syscall::MAX_LABELS_LEN - SELF_LABEL.len()) as u64 + if args.endow_count as usize > toyos_abi::syscall::MAX_SPAWN_ENDOWMENTS + || args.labels_len > toyos_abi::syscall::MAX_SPAWN_LABELS_LEN as u64 { return SyscallError::InvalidArgument.to_u64(); } diff --git a/rust b/rust index 336c8a8e141..40470840283 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit 336c8a8e14188d7415e3fa246702e25a2c64d511 +Subproject commit 4047084028319fd0f7d499285c80bbed4a7eb5b9 diff --git a/src/ci.rs b/src/ci.rs index 33b8afa29e2..e01be59d57b 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -348,10 +348,10 @@ pub(crate) const CONTROLS: &[Control] = &[ "only_the_thread_that_empties_a_claimed_process_tears_it_down ... FAILED", ]), red(PROCLIFE, "mutate-place-skips-the-insert-recheck", None, &[ - "a_spawn_racing_its_places_kill_lands_nothing_under_it_and_publishes_it ... FAILED", + "a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it ... FAILED", ]), - red(PROCLIFE, "mutate-refused-insert-keeps-the-count", None, &[ - "a_spawn_racing_its_places_kill_lands_nothing_under_it_and_publishes_it ... FAILED", + red(PROCLIFE, "mutate-refused-spawn-keeps-the-count", None, &[ + "a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it ... FAILED", ]), red(PROCLIFE, "mutate-publish-before-the-children", None, &[ "an_exit_publishes_after_every_end_below_it ... FAILED", diff --git a/tests/proctreecase/system.toml b/tests/proctreecase/system.toml index 578f92150bf..04237bf85c7 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -1,12 +1,11 @@ # The boot `process_tree` runs on: a parent's end takes its children down. # # A `launcher`, because the subtree under test has children init launches; a -# declared `cat`, which runs until its input ends; a declared shell, which a -# depth chain launches and which starts and detaches sshd; and sshd, with netd -# and a NIC under it and a key the harness stages, so that it runs. +# declared `cat`, which runs until its input ends; and a declared shell, which a +# depth chain launches and which detaches a `cat`. [boot] -start = ["logd", "blockd", "fsd", "netd", "test-runner"] +start = ["logd", "blockd", "fsd", "test-runner"] # `every_boot_config_runs_logd` is what refuses a boot config without it: the # kernel keeps the record ring and writes no file, so such a boot's `/log` is @@ -15,18 +14,6 @@ start = ["logd", "blockd", "fsd", "netd", "test-runner"] service = true syscap = ["logread"] -# netd holds the NIC's PCI function and drives it: the virtqueues, the register -# window and the interrupt are its own, and the kernel keeps only the claim. -[programs.netd] -service = true -serves = ["netd"] -devices = ["pci:1af4:1041"] - -# Started by the guest from a shell, never at boot. -[programs.sshd] -service = true -receives = ["netd", "launcher"] - # `roster` because the guest asks which of its tree still runs, and `dup` # because test-runner hands each binary a duplicate of its capability. [programs.test-runner] diff --git a/tests/toyos-rust-tests/src/bin/compositor_client_death.rs b/tests/toyos-rust-tests/src/bin/compositor_client_death.rs index 504f98051ee..689f93a39d7 100644 --- a/tests/toyos-rust-tests/src/bin/compositor_client_death.rs +++ b/tests/toyos-rust-tests/src/bin/compositor_client_death.rs @@ -75,7 +75,7 @@ fn run() { // exits; this process reaps it — which is what takes the pid out of the // process table — and only then closes the pipe that releases the relay to // send the frame. Every step waits on the one before it. - let root = endow::this_process().expect("every process holds itself"); + let root = endow::this_process(); let place = syscall::dup(root.as_handle()).expect("a copy of the root's self for the creator"); let mut creator = Command::new(SELF_PATH) .arg("connect") diff --git a/tests/toyos-rust-tests/src/bin/launcher_refusals.rs b/tests/toyos-rust-tests/src/bin/launcher_refusals.rs index 266786a06b0..92fd43dcf4c 100644 --- a/tests/toyos-rust-tests/src/bin/launcher_refusals.rs +++ b/tests/toyos-rust-tests/src/bin/launcher_refusals.rs @@ -34,12 +34,6 @@ //! holder of the connector — the compositor, every terminal, every shell, sshd //! — parked the machine's only way to start a process for ever, with init alive //! and looking healthy. -//! -//! **A launch names its parent**, and two more shapes are about that: a frame -//! naming neither a place nor init, which init must refuse rather than take as -//! asking for it — the program would outlive a caller that never asked — and a -//! place that is a pipe, a peer's claim the kernel answers with a word, since -//! init is the spawn's caller and a wrong type that ended it would end init. use std::process::Command; @@ -73,7 +67,6 @@ fn main() { a_quiet_client_does_not_wedge_the_launcher(); not_a_connector(); a_connector_it_cannot_duplicate(); - a_launch_naming_no_parent(); a_place_that_is_a_pipe(); a_working_directory_that_is_not_absolute(); @@ -269,11 +262,6 @@ fn the_launcher_still_works() { /// **The half of it that is the kernel's**, asserted from a process that can /// afford to die so that init does not have to. -/// -/// `SYS_NAMESPACE_BUILD`'s added connector routinely crosses a trust boundary — -/// a `provides` name is exactly a connector somebody else made — so a wrong -/// type there answers a word, and if it goes back to ending the caller, this -/// arm never returns and the test reds on exit 139. fn the_kernel_answers_rather_than_faults() { let (_read, write) = toyos::pipe_pair().expect("a pipe of our own"); // SAFETY: it is not a connector, which is the point — the call must answer @@ -284,34 +272,6 @@ fn the_kernel_answers_rather_than_faults() { assert_eq!(refused.err(), Some(SyscallError::InvalidArgument)); } -/// The frame `toyos::launch` encodes for a launch under init, with its header's -/// last word — the parent — cleared: no SDK call can spell a launch that names -/// neither. Refused, and nothing starts: a grant would answer `MSG_LAUNCHED`. -fn a_launch_naming_no_parent() { - let mut buf = [0u8; 512]; - let request = Launch { - program: DECLARED, - argv: b"", - env: b"", - cwd: "/", - extras: &[], - slots: &[], - parent: Parent::Init, - }; - let len = request.encode(&mut buf).expect("encode a launch"); - // Nine little-endian words, the parent last. - buf[32..36].copy_from_slice(&0u32.to_le_bytes()); - let conn = launcher(); - conn.send_bytes_with_handles(&[], launch::MSG_LAUNCH, &buf[..len]) - .expect("the launcher took the frame"); - assert_eq!( - answer(&conn), - Ok(launch::MSG_REFUSED), - "a launch naming no parent was not refused: init chose one for it", - ); - println!(" a launch naming no parent: refused, and nothing started"); -} - /// A place that is a pipe's write end, which carries `WRITE`, so the kernel /// reaches the type it is not. The arms after this one are init answering the /// next launch. diff --git a/tests/toyos-rust-tests/src/bin/process_tree.rs b/tests/toyos-rust-tests/src/bin/process_tree.rs index 10e430bbd39..f7146e4b438 100644 --- a/tests/toyos-rust-tests/src/bin/process_tree.rs +++ b/tests/toyos-rust-tests/src/bin/process_tree.rs @@ -1,15 +1,13 @@ //! A parent's end takes its children down. //! -//! **Every end takes the whole subtree, and is published after every end -//! below it.** A starts B; B spawns C, launches D through init and asks init -//! for E; B hands A a handle to each and a copy of its own `self`. Then B ends -//! — killed by A, by its own exit, by a CPU fault and by a handle fault, one B -//! per arm — and the arm asserts, at the instant A's wait on B answers: +//! **Every end takes the whole subtree.** A starts B; B spawns C, launches D +//! through init and asks init for E; B hands A a handle to each and a copy of +//! its own `self`. Then B ends — killed by A, and by a CPU fault, one B per +//! arm — and the arm asserts, once A's wait on B answers: //! -//! - C and D have already published their ends, as killed: a non-blocking wait -//! on each answers, so B's end was published after theirs; +//! - C and D have ended, as killed; //! - E, which init started, runs on: init is the one way to outlive a starter; -//! - a spawn and a launch under B's `self` answer `Gone`. +//! - after the kill, a spawn and a launch under B's `self` answer `Gone`. //! //! B's first act is a spawn the loader refuses once it is admitted under B: its //! hold on B goes with it, or B is never published and the arm's wait never @@ -17,12 +15,11 @@ //! //! **The other arms.** A `MANAGE`-only handle is no place. init starts a //! child only by a launch, so std refuses `under_init` for a program no row -//! declares and for a command carrying an endowment or an extra slot. A chain -//! alternating spawn and launch — each link launches a shell, and the shell -//! spawns the next link — stops where the kernel refuses a process more than -//! `MAX_DEPTH` below init, and dies whole with its first link. And sshd, -//! started by a shell, dies with the shell, while one the shell `detach`es -//! outlives it. +//! declares and for a command carrying an extra slot. A chain alternating spawn +//! and launch — each link launches a shell, and the shell spawns the next link +//! — stops where the kernel refuses a process more than `MAX_DEPTH` below +//! init, and dies whole with its first link. And a `cat` a shell `detach`es +//! outlives the shell. //! //! Every wait is unbounded: the harness ceiling is the only clock. @@ -46,25 +43,23 @@ const SELF_PATH: &str = "/system/bin/test_rs_process_tree"; /// while A holds its input open. const HELD: &str = "/system/bin/cat"; const SHELL: &str = "/system/bin/shell"; -const SSHD: &str = "/system/bin/sshd"; /// The name B's namespace carries the port back to A under. const BACK: &str = "back"; /// B to A: the handles to C, D, B's own `self` and E, in that order. const MSG_GROWN: u32 = 1; -/// A to B: how to end, one byte. -const MSG_END: u32 = 2; +/// A to B: fault. +const MSG_FAULT: u32 = 2; /// `process::KILLED_EXIT_CODE`. const KILLED: i32 = 137; -/// `process::HANDLE_FAULT_EXIT_CODE`. -const HANDLE_FAULT: i32 = 139; /// What `syscall::kill_process(-1)` publishes for a Ring 3 CPU fault. const CPU_FAULT: i32 = -1; -/// A slot no process in this tree reaches, as `handle_kill_policy`'s. -const UNHELD_SLOT: u32 = 3000; +/// An address no region of this process covers. Not null: this profile's +/// debug assertions refuse a null write before it reaches the CPU. +const UNMAPPED: usize = 8; /// `toyos_proclife::MAX_DEPTH`, which the kernel refuses a process past. const MAX_DEPTH: u32 = 64; @@ -78,13 +73,10 @@ const LINK_BOUND: u32 = MAX_DEPTH / 2 + 1; const ZOMBIE: u8 = 3; #[derive(Clone, Copy, Debug)] -#[repr(u8)] enum End { - /// B parks; A kills it. - Killed = 0, - Exit = 1, - CpuFault = 2, - HandleFault = 3, + /// B waits on A; A kills it. + Killed, + CpuFault, } fn main() { @@ -99,13 +91,13 @@ fn main() { } fn test() { - for end in [End::Killed, End::Exit, End::CpuFault, End::HandleFault] { + for end in [End::Killed, End::CpuFault] { an_end_takes_its_subtree(end); } a_manage_only_handle_is_no_place(); init_is_asked_only_by_a_launch(); a_chain_stops_at_max_depth_and_dies_whole(); - sshd_dies_with_its_shell_and_outlives_it_under_init(); + a_detached_program_outlives_its_shell(); println!("process_tree: PASS"); } @@ -166,33 +158,30 @@ fn an_end_takes_its_subtree(end: End) { grown.b.kill().expect("kill B"); // Claimed, so admission under it is closed, whether or not its // teardown is done. - under_b_is_gone(&grown, "after its kill"); + under_b_is_gone(&grown); KILLED } - End::Exit => 5, - End::CpuFault => CPU_FAULT, - End::HandleFault => HANDLE_FAULT, + End::CpuFault => { + grown.conn.send_bytes(MSG_FAULT, &[]).expect("tell B to fault"); + CPU_FAULT + } }; - if !matches!(end, End::Killed) { - grown.conn.send_bytes(MSG_END, &[end as u8]).expect("tell B how to end"); - } let status = grown.b.wait().expect("wait for B"); assert_eq!(status.code(), Some(b_code), "B ended {end:?} and read {:?}", status.code()); - // The instant B's end is published: C's and D's already are. - assert_eq!(grown.c.try_wait(), Ok(KILLED), "C was not ended, as killed, before B's end was published ({end:?})"); - assert_eq!(grown.d.try_wait(), Ok(KILLED), "D, which init launched under B, was not ended before B's end ({end:?})"); + assert_eq!(grown.c.try_wait(), Ok(KILLED), "C was not ended, as killed, once B's end was published ({end:?})"); + assert_eq!(grown.d.try_wait(), Ok(KILLED), "D, which init launched under B, was not ended with B ({end:?})"); assert_eq!(grown.e.try_wait(), Err(SyscallError::WouldBlock), "E, started under init, ended with B ({end:?})"); - under_b_is_gone(&grown, "after its end was published"); grown.e.kill().expect("kill E"); assert_eq!(grown.e.wait(), Ok(KILLED)); syscall::close(grown.b_self); - println!(" B {end:?}: C and D ended first, as killed; E, under init, ran on"); + println!(" B {end:?}: C and D ended, as killed; E, under init, ran on"); } /// A spawn and a launch placed under B's `self` both answer `Gone`. -fn under_b_is_gone(grown: &Grown, when: &str) { +fn under_b_is_gone(grown: &Grown) { + let when = "after its kill"; match spawn_under(grown.b_self) { Err(SyscallError::Gone) => {} Err(other) => panic!("a spawn under B {when} answered {other:?}, not Gone"), @@ -270,13 +259,7 @@ fn init_is_asked_only_by_a_launch() { let (_read, write) = toyos::pipe_pair().expect("a pipe of our own"); let extra = Command::new(HELD).inherit_handle(5, write.as_handle().0).under_init().spawn(); refused(extra, "a command carrying an extra slot"); - - let endowed = syscall::dup(write.as_handle()).expect("a handle to endow"); - let with_endowment = Command::new(HELD).endow("extra", endowed.0).under_init().spawn(); - refused(with_endowment, "a command carrying an endowment"); - // A refused spawn moves nothing. - syscall::close(endowed); - println!(" init is asked only by a launch: the three others are refused"); + println!(" init is asked only by a launch: the two others are refused"); } fn refused(spawned: std::io::Result, what: &str) { @@ -337,26 +320,21 @@ fn link(k: u32) -> ! { park(); } -/// sshd started by a shell is the shell's child and dies with it; one the -/// shell `detach`es is init's and outlives it. -fn sshd_dies_with_its_shell_and_outlives_it_under_init() { - let mut shell = Command::new(SHELL).args(["-c", SSHD]).spawn().expect("start a shell running sshd"); - while live_named(&["sshd"]).is_empty() { - std::thread::yield_now(); - } - shell.kill().expect("kill the shell"); - assert_eq!(shell.wait().expect("wait the shell").code(), Some(KILLED)); - let live = live_named(&["sshd"]); - assert!(live.is_empty(), "sshd runs on after the shell that started it was killed: {live:?}"); - - let detaching = Command::new(SHELL) - .args(["-c", &format!("detach {SSHD}")]) - .status() - .expect("run a shell that detaches sshd"); - assert!(detaching.success(), "the shell's detach failed: {detaching:?}"); - let live = live_named(&["sshd"]); - assert!(!live.is_empty(), "sshd, detached under init, ended with the shell that started it"); - println!(" sshd died with its shell, and outlived the shell that detached it"); +/// A `cat` a shell `detach`es is init's child, so it runs on once the shell's +/// end is published: it reads the input this process holds open. +fn a_detached_program_outlives_its_shell() { + let mut shell = Command::new(SHELL) + .args(["-c", &format!("detach {HELD}")]) + .stdin(Stdio::piped()) + .spawn() + .expect("run a shell that detaches cat"); + let input = shell.stdin.take().expect("the input the shell and cat read"); + let status = shell.wait().expect("wait the shell"); + assert!(status.success(), "the shell's detach failed: {status:?}"); + let live = live_named(&["cat"]); + assert_eq!(live.len(), 1, "the cat the shell detached ended with the shell: {live:?}"); + drop(input); + println!(" a detached cat outlived its shell"); } /// Every process whose main thread is named one of `names` and has not @@ -394,7 +372,7 @@ fn b() -> ! { let c = Command::new(SELF_PATH).arg("c").stdin(Stdio::null()).spawn().expect("B spawns C"); let d = Command::new(HELD).spawn().expect("B launches D"); let e = Command::new(HELD).under_init().spawn().expect("B asks init for E"); - let own = endow::this_process().expect("every process holds itself"); + let own = endow::this_process(); let handles = [ syscall::dup(RawHandle(c.as_raw_handle())).expect("a copy of C"), syscall::dup(RawHandle(d.as_raw_handle())).expect("a copy of D"), @@ -403,20 +381,10 @@ fn b() -> ! { ]; conn.send_bytes_with_handles(&handles, MSG_GROWN, &[]).expect("send A the subtree"); - let header = conn.recv_header().expect("A's word for how to end"); - assert_eq!(header.msg_type, MSG_END); - let mut how = [0u8; 1]; - conn.recv_bytes(&header, &mut how).expect("how to end"); - match how[0] { - x if x == End::Exit as u8 => syscall::exit(5), - // SAFETY: an invalid opcode in Ring 3, which the kernel answers by - // ending this process; nothing after it runs. - x if x == End::CpuFault as u8 => unsafe { core::arch::asm!("ud2", options(noreturn)) }, - x if x == End::HandleFault as u8 => { - let mut buf = [0u8; 8]; - let n = syscall::read_nonblock(RawHandle(UNHELD_SLOT), &mut buf); - panic!("a slot this process never held answered {n:?}"); - } - other => panic!("A asked B to end as {other}"), - } + let header = conn.recv_header().expect("A's word to fault"); + assert_eq!(header.msg_type, MSG_FAULT, "A said something else"); + // SAFETY: no region of this process covers the address, so the write + // faults and the kernel ends this process before anything after it runs. + unsafe { core::ptr::without_provenance_mut::(UNMAPPED).write_volatile(1) }; + panic!("a write to {UNMAPPED:#x} did not fault"); } diff --git a/tests/toyos.rs b/tests/toyos.rs index 111152bf485..2138bda3508 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -326,8 +326,8 @@ const RUST_SKIP: &[&str] = &[ // Needs a launcher to tell its two roads apart, and a declared shell and // toybox to take it: `spawn_cwd` runs it on tests/netcase. "spawn_cwd", - // Needs a launcher, a declared `cat` and shell, and sshd with a NIC under it: - // `process_tree` runs it on tests/proctreecase. + // Needs a launcher and a declared `cat` and shell: `process_tree` runs it on + // tests/proctreecase. "process_tree", // Needs a boot image the harness staged a file into before the machine // started, which only `esp_filesystem` builds. @@ -849,7 +849,7 @@ const MACHINE_TESTS: &[(&str, Sched)] = &[ ("launcher_refusals", Sched::Parallel), // Paths a child prints and the kernel's refusals by name; no clock in any of them. ("spawn_cwd", Sched::Parallel), - // Exit codes, publication order and a kernel line; no clock in any of them. + // Exit codes and kernel lines; no clock in any of them. ("process_tree", Sched::Parallel), ("foreign_disk_untouched", Sched::Parallel), ("volume_from_another_disk", Sched::Parallel), @@ -13823,31 +13823,21 @@ fn run_machine_test( Ok(()) } "process_tree" => { - // A parent's end takes its children down, on `tests/proctreecase`: - // a launcher, a declared `cat` and shell, and sshd with a NIC under - // it and a key staged so it runs. The guest carries every verdict - // but two the kernel speaks: the depth refusal names `MAX_DEPTH` + 1, - // and the one sshd that ended, the one under the killed shell, was - // killed rather than ending by itself. + // A parent's end takes its children down, on `tests/proctreecase`: a + // launcher and a declared `cat` and shell. The guest carries every + // verdict but two the kernel speaks: each B's first spawn reached the + // loader under B, and the depth refusal names `MAX_DEPTH` + 1. let config = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/proctreecase"); let bins: Vec<(String, Vec)> = rust_bins.iter().filter(|(name, _)| name == "process_tree").cloned().collect(); if bins.is_empty() { return Err("process_tree was not built".to_string()); } - let identity = common::ssh::Identity::mint("proctreecase")?; let mut qemu = QemuInstance::boot_with_options( &config, &[], &bins, - BootOptions { - profile: qemu::Profile::Headless, - extra_root_files: vec![( - common::ssh::KEYS_ON_ROOT.to_string(), - identity.authorized_line().into_bytes(), - )], - ..Default::default() - }, + BootOptions { profile: qemu::Profile::Headless, ..Default::default() }, ); let mut console = qemu.boot_log().to_string(); let _ = await_marker(&mut qemu, &mut console, "===READY===", "test-runner to come up"); @@ -13863,21 +13853,18 @@ fn run_machine_test( result.exit_code, result.stdout )); } - let refused = console.lines().filter(|l| l.contains("spawn: refused under pid ")).collect::>(); - if refused.len() != 1 || !refused[0].contains("at depth 65, more than 64 below init") { + // One per B: its first act's refusal came from the loader, past the + // admission that holds B, and not from std before the kernel. + let unloaded = console.lines().filter(|l| l.contains("spawn: /system/bin/no_such_program: ")).count(); + if unloaded != 2 { return Err(format!( - "the kernel's depth refusals were {refused:?}, not one naming depth 65:\n{console}" + "the loader refused /system/bin/no_such_program {unloaded} times, not once per B:\n{console}" )); } - // The kernel names an end once its teardown is done, which is before - // the shell's end the guest waited on, and its line may still be on - // its way to the console. - await_marker(&mut qemu, &mut console, "exit: sshd pid=", "the kernel to name sshd's end")?; - let ended: Vec<_> = console.lines().filter(|l| l.contains("exit: sshd pid=")).collect(); - if ended.len() != 1 || !ended[0].contains(" code=137 ") { + let refused = console.lines().filter(|l| l.contains("spawn: refused under pid ")).collect::>(); + if refused.len() != 1 || !refused[0].contains("at depth 65, more than 64 below init") { return Err(format!( - "the sshd ends were {ended:?}, not the one under the killed shell, killed — \ - an sshd that ended by itself makes its arm assert nothing:\n{console}" + "the kernel's depth refusals were {refused:?}, not one naming depth 65:\n{console}" )); } eprintln!(" [proctreecase] every end took its subtree, and the chain stopped at depth 65"); diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 4eb3ba826f7..595e08667b2 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -464,10 +464,11 @@ pub const DEV_PREFIX: &str = "dev:"; pub const PROVIDE_PREFIX: &str = "provide:"; /// `(label, handle)` pairs one endowment table holds, the kernel's own -/// [`SELF_LABEL`] among them, so a spawn carries one fewer. Policy on the -/// primitive, refused by name, never truncated — the widest manifest row plus -/// stdio. +/// [`SELF_LABEL`] among them. Policy on the primitive, refused by name, never +/// truncated — the widest manifest row plus stdio. pub const MAX_ENDOWMENTS: usize = 32; +/// `(label, handle)` pairs one spawn may carry: the kernel adds [`SELF_LABEL`]. +pub const MAX_SPAWN_ENDOWMENTS: usize = MAX_ENDOWMENTS - 1; /// `(child slot, parent handle)` pairs one spawn may carry. /// /// **Derived rather than chosen.** A slot map installs into the child's table, @@ -479,6 +480,8 @@ pub const MAX_ENDOWMENTS: usize = 32; pub const MAX_SLOT_MAP: usize = RawHandle::MAX_SLOTS; /// Bytes of label blob one endowment table holds, [`SELF_LABEL`]'s among them. pub const MAX_LABELS_LEN: usize = 4096; +/// Bytes of label blob one spawn may carry: the kernel adds [`SELF_LABEL`]'s. +pub const MAX_SPAWN_LABELS_LEN: usize = MAX_LABELS_LEN - SELF_LABEL.len(); use crate::handle::Rights; use crate::pci::{DmaGrant, DmaMapping}; diff --git a/toyos-proclife/Cargo.toml b/toyos-proclife/Cargo.toml index e72724534cd..cc16a11c74f 100644 --- a/toyos-proclife/Cargo.toml +++ b/toyos-proclife/Cargo.toml @@ -63,15 +63,15 @@ mutate-last-out-leaves-before-its-teardown = [] # `interleave::tests::a_join_racing_the_kill_that_takes_its_target` must red # under this. mutate-join-collects-in-a-teardown = [] -# `tree::insert_child` stops re-reading its place's claim, so a spawn admitted -# before a kill lands under the place after the walk read its children. -# `interleave::tests::a_spawn_racing_its_places_kill_lands_nothing_under_it_and_publishes_it` +# `tree::land_child` stops re-reading its place's claim, so a spawn admitted +# before a kill lands unclaimed under the place after the walk read its children. +# `interleave::tests::a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it` # must red under this. mutate-place-skips-the-insert-recheck = [] -# `tree::refuse_child` keeps the refused spawn's hold on its place, which then +# `tree::refuse_child` keeps a failed spawn's hold on its place, which then # waits for a child that will never be and is never published. The same test # must red under this. -mutate-refused-insert-keeps-the-count = [] +mutate-refused-spawn-keeps-the-count = [] # `tree::teardown_done` publishes an end with its own teardown, whatever is # still unpublished below it. `interleave::tests::an_exit_publishes_after_every_end_below_it` # must red under this. diff --git a/toyos-proclife/src/interleave.rs b/toyos-proclife/src/interleave.rs index b0a29000725..aa9d7ccb13c 100644 --- a/toyos-proclife/src/interleave.rs +++ b/toyos-proclife/src/interleave.rs @@ -24,7 +24,7 @@ use std::collections::HashSet; use crate::model::{Climb, World}; use crate::table::Processes; -use crate::tree::{self, Admit, Admitted}; +use crate::tree::{self, Admit, Admitted, Landed}; use crate::{join, reap, spawn, teardown, Pid, Tid, Watch}; /// `process::KILLED_EXIT_CODE`, which a walk claims every process below an end @@ -49,10 +49,20 @@ pub enum Op { /// `process::kill_process`: claim, retire every thread, walk the subtree, /// return. `by` is the killing thread when the model holds it. Kill { pid: Pid, code: i32, pc: u32, retire: Vec<(Pid, Tid)>, owed: Vec, by: Option<(Pid, Tid)> }, - /// `loader::spawn` under `place`: the admission, the whole of a process - /// built with every lock given up, then the insert — and a refused one's - /// climb, when it let the place's last hold go. - SpawnUnder { place: Pid, by: Option<(Pid, Tid)>, pc: u32, admitted: Option, climb: Option }, + /// `loader::spawn` under `place` by `by`'s thread: the admission, the + /// whole of a process built with every lock given up, then the move of + /// the caller's handles and the landing, whose retires a child claimed as + /// it landed owes. A build that `fails` lets the place go instead, and + /// climbs when that was the place's last hold. + SpawnUnder { + place: Pid, + by: (Pid, Tid), + fails: bool, + pc: u32, + admitted: Option, + retire: Vec<(Pid, Tid)>, + climb: Option, + }, /// `process::spawn_thread`: two lock sections with the whole of a thread /// built between them; `block` is the mapped TLS the build carries across. Spawn { pid: Pid, pc: u32, block: Option }, @@ -79,7 +89,11 @@ impl Op { /// A spawn under `place` by `by`'s thread, which is in the kernel until it /// returns. pub fn spawn_under(place: Pid, by: (Pid, Tid)) -> Self { - Op::SpawnUnder { place, by: Some(by), pc: 0, admitted: None, climb: None } + Op::SpawnUnder { place, by, fails: false, pc: 0, admitted: None, retire: Vec::new(), climb: None } + } + /// The same spawn, whose build fails once it is admitted. + pub fn spawn_under_failing(place: Pid, by: (Pid, Tid)) -> Self { + Op::SpawnUnder { place, by, fails: true, pc: 0, admitted: None, retire: Vec::new(), climb: None } } pub fn spawn(pid: Pid) -> Self { Op::Spawn { pid, pc: 0, block: None } @@ -99,7 +113,8 @@ impl Op { match *self { Op::Exit { pid, tid, .. } | Op::ThreadExit { pid, tid, .. } => Some((pid, tid)), Op::Join { pid, waiter, .. } => Some((pid, waiter)), - Op::Kill { by, .. } | Op::SpawnUnder { by, .. } => by, + Op::Kill { by, .. } => by, + Op::SpawnUnder { by, .. } => Some(by), Op::Spawn { .. } | Op::IdlePass { .. } => None, } } @@ -202,7 +217,7 @@ impl Op { } } } - Op::SpawnUnder { place, by, pc, admitted, climb } => { + Op::SpawnUnder { place, by, fails, pc, admitted, retire, climb } => { match *pc { // The admission, under the table lock and before anything is built. 0 => match tree::admit_child(world, *place) { @@ -210,28 +225,49 @@ impl Op { *admitted = Some(taken); *pc = 1; } - Admit::Gone | Admit::TooDeep { .. } => *pc = DONE, + Admit::Gone | Admit::TooDeep { .. } => { + world.refuse_spawn(*by); + *pc = DONE; + } }, - // The build happened with every lock given up; the insert - // decides under the lock that inserts. + // The build happened with every lock given up, and failed. + 1 if *fails => { + let taken = admitted.take().expect("admitted at the first section"); + world.refuse_spawn(*by); + match tree::refuse_child(world, taken) { + Some(publish) => { + *climb = Some(Climb::Publish(publish)); + *pc = 3; + } + None => *pc = DONE, + } + } + // The caller's handles move, and the child lands, in the + // hold that inserts it. 1 => { - let child = world.reserve_pid(); + world.move_handles(*by); let taken = admitted.take().expect("admitted at the first section"); - match tree::insert_child(world, taken, child) { - Ok(node) => { - world.land(*place, child, node); - *pc = DONE; + let child = world.reserve_pid(); + let ((), landed) = + tree::land_child(world, taken, child, KILLED, |world, node| world.insert(child, node)); + world.landed(*place, child); + match landed { + Landed::Placed => *pc = DONE, + Landed::Claimed => { + let proc = world.get(child).expect("just landed"); + *retire = teardown::retire_set(proc, None).into_iter().map(|t| (child, t)).collect(); + *pc = 2; } - Err(refused) => match refused.publish { - Some(publish) => { - *climb = Some(Climb::Publish(publish)); - *pc = 2; - } - None => *pc = DONE, - }, } } - // The refused insert let its place's last hold go: this + // With the lock given up: the retires of a child claimed as it landed. + 2 => { + for (victim, thread) in retire.drain(..) { + world.post_retire(victim, thread); + } + *pc = DONE; + } + // The failed build let its place's last hold go: this // spawner publishes it, and climbs. _ => { let at = climb.take().expect("a climb is owed here"); @@ -242,9 +278,7 @@ impl Op { } } if *pc == DONE { - if let Some(by) = *by { - world.leave_kernel(by); - } + world.leave_kernel(*by); } } Op::Spawn { pid, pc, block } => match *pc { @@ -699,17 +733,19 @@ mod tests { } /// **A spawn racing its place's kill**, every ordering, with the spawner a - /// process that holds the place's `self` — init, serving a launch. The - /// child lands before the claim and the walk takes it, or its insert is - /// refused: nothing lands under the place after its claim, everything under - /// it ends, and the place is published in every one. + /// process that holds the place's `self` — init, serving a launch — and + /// its build landing or failing. A child lands before the claim and the + /// walk takes it, or after it and is claimed as it lands; a failed build + /// lets the place go. Nothing runs on under the place, a refused spawn + /// moved none of its caller's handles, and the place is published in + /// every one. /// /// Reds under `mutate-place-skips-the-insert-recheck`, where a child lands - /// under the claimed place and outlives it, and under - /// `mutate-refused-insert-keeps-the-count`, where the place is never + /// unclaimed under the claimed place and outlives it, and under + /// `mutate-refused-spawn-keeps-the-count`, where the place is never /// published. #[test] - fn a_spawn_racing_its_places_kill_lands_nothing_under_it_and_publishes_it() { + fn a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it() { let mut world = World::new(); let init = world.spawn_process(); let place = world.spawn_child(init); @@ -717,6 +753,7 @@ mod tests { let launcher = (init, world.main_tid(init)); let states = holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under(place, launcher)]); std::println!("a spawn racing its place's kill: {states} states"); + holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under_failing(place, launcher)]); } /// The same race with the place spawning under itself: its own thread is @@ -730,6 +767,7 @@ mod tests { world.spawn_child(place); let own = (place, world.main_tid(place)); holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under(place, own)]); + holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under_failing(place, own)]); } /// An exit takes a subtree two deep below it, and each end is published diff --git a/toyos-proclife/src/model.rs b/toyos-proclife/src/model.rs index fee288fa3ff..d3ebf3ff9d1 100644 --- a/toyos-proclife/src/model.rs +++ b/toyos-proclife/src/model.rs @@ -133,11 +133,16 @@ pub struct World { /// Exits torn down and not yet published: the kernel keeps each on its /// entry until its count lets it go. stash: BTreeMap, - /// Processes inserted under a place whose end was already claimed. + /// Processes that landed unclaimed under a place whose end was already + /// claimed. landed_under_claimed: BTreeSet, /// Each process a spawn op inserted, and how many processes had been /// claimed when it landed. inserted_at: Vec<(Pid, usize)>, + /// Spawning threads whose caller's handles a spawn moved. + moved: BTreeSet<(Pid, Tid)>, + /// Spawning threads answered a refusal after their handles moved. + refused_after_move: BTreeSet<(Pid, Tid)>, } impl Processes for World { @@ -178,6 +183,8 @@ impl World { stash: BTreeMap::new(), landed_under_claimed: BTreeSet::new(), inserted_at: Vec::new(), + moved: BTreeSet::new(), + refused_after_move: BTreeSet::new(), } } @@ -203,7 +210,7 @@ impl World { /// `ProcessEntry::new` builds for init. pub fn spawn_process(&mut self) -> Pid { let pid = self.reserve_pid(); - self.insert_proc(pid, Node::root()); + self.insert(pid, Node::root()); pid } @@ -214,8 +221,8 @@ impl World { panic!("spawn_child: pid {place} admits no child"); }; let pid = self.reserve_pid(); - let node = tree::insert_child(self, admitted, pid).expect("spawn_child: nothing came between"); - self.insert_proc(pid, node); + let ((), landed) = tree::land_child(self, admitted, pid, 137, |world, node| world.insert(pid, node)); + assert_eq!(landed, tree::Landed::Placed, "spawn_child: pid {place} was claimed"); pid } @@ -226,16 +233,28 @@ impl World { pid } - /// A spawn op's insert, which `tree::insert_child` has just answered with - /// `node`: records a landing under a claimed place, and how many - /// processes had been claimed when it landed. - pub fn land(&mut self, place: Pid, pid: Pid, node: Node) { - if self.procs.get(&place).is_some_and(Lifecycle::tearing_down) { + /// A spawn op's landing, at the end of the hold that made it: records a + /// process unclaimed under a claimed place, and how many processes had + /// been claimed when it landed. + pub fn landed(&mut self, place: Pid, pid: Pid) { + let unclaimed = !self.procs[&pid].tearing_down(); + if unclaimed && self.procs.get(&place).is_some_and(Lifecycle::tearing_down) { self.landed_under_claimed.insert(pid); } let claimed = self.claimed_count(); self.inserted_at.push((pid, claimed)); - self.insert_proc(pid, node); + } + + /// A spawn by `by` taking its caller's endowed handles out of its table. + pub fn move_handles(&mut self, by: (Pid, Tid)) { + self.moved.insert(by); + } + + /// A spawn by `by` answering a refusal. + pub fn refuse_spawn(&mut self, by: (Pid, Tid)) { + if self.moved.contains(&by) { + self.refused_after_move.insert(by); + } } /// Each process a spawn op inserted, and how many had been claimed then. @@ -249,7 +268,8 @@ impl World { self.procs.values().filter(|p| p.claims > 0).count() + reaped } - fn insert_proc(&mut self, pid: Pid, node: Node) { + /// `pid`'s entry, with one thread, which is its main one. + pub fn insert(&mut self, pid: Pid, node: Node) { let mut threads = BTreeMap::new(); threads.insert(Tid(0), ThreadLocation::Scheduled); self.procs.insert( @@ -512,9 +532,9 @@ impl World { } } } - // L10. Nothing lands under a place once its end is claimed. + // L10. Nothing lands unclaimed under a place once its end is claimed. for pid in &self.landed_under_claimed { - out.push(alloc::format!("pid {pid} landed under a place whose end was already claimed")); + out.push(alloc::format!("pid {pid} landed unclaimed under a place whose end was already claimed")); } out } @@ -526,9 +546,13 @@ impl World { /// And **L5** — every TLS block a spawn mapped ends owned or released. /// And **L6** — every claimed process is torn down: its exit published, /// every killed thread gone. And **L11** — an end takes every process - /// below it. + /// below it. And **L12** — a spawn that answers a refusal moved none of + /// its caller's handles. pub fn final_faults(&self) -> Vec { let mut out = self.faults(); + for (pid, tid) in &self.refused_after_move { + out.push(alloc::format!("pid {pid} tid {tid}: a refused spawn moved its caller's handles")); + } for (&pid, proc) in &self.procs { if proc.claims > 0 && !self.published.contains_key(&pid) { out.push(alloc::format!("pid {pid} was claimed for teardown and never published an exit")); diff --git a/toyos-proclife/src/teardown.rs b/toyos-proclife/src/teardown.rs index 61397cdddb8..6c95f3e3375 100644 --- a/toyos-proclife/src/teardown.rs +++ b/toyos-proclife/src/teardown.rs @@ -10,7 +10,7 @@ //! //! **The last thread out tears the process down.** Every thread leaves by its //! own hand ([`leave`]), and the one whose leaving empties a claimed process -//! frees what the process holds and publishes its exit, on its own stack. +//! frees what the process holds, on its own stack. //! Nothing a thread can still run in is freed before then, and no thread waits //! for another to leave. A second publish is an assertion failure in //! `ProcessObject::publish_exit`, by design. @@ -57,9 +57,9 @@ pub fn retire_set(proc: &P, caller: Option) -> Vec { #[must_use = "the last thread out owes its process's teardown"] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Leave { - /// It is emptying a claimed process: it tears the process down, publishes - /// `code`, and stays in the process until [`torn_down`] marks it dead with - /// `mark`, so the machine's stop counts its teardown as running. + /// It is emptying a claimed process: it tears the process down, and stays + /// in the process until [`torn_down`] marks it dead with `mark`, so the + /// machine's stop counts its teardown as running. Last { code: i32, mark: i32 }, /// Some thread is still in the process, or nobody has claimed it. NotLast, diff --git a/toyos-proclife/src/tree.rs b/toyos-proclife/src/tree.rs index 477665d9408..dc5d55b80e4 100644 --- a/toyos-proclife/src/tree.rs +++ b/toyos-proclife/src/tree.rs @@ -8,13 +8,13 @@ //! Three questions, each asked under `PROCESS_TABLE` and each one lock hold: //! //! - **May a child be placed here?** [`admit_child`] at the top of a spawn, -//! before anything is built, and [`insert_child`] under the lock that -//! inserts it — the second decides, because a kill can claim the place -//! between the two. A place being torn down takes nothing more, and a child -//! deeper than [`MAX_DEPTH`] below init is refused. +//! before anything is built: a place being torn down takes nothing more, +//! and a child deeper than [`MAX_DEPTH`] below init is refused. It is the +//! last refusal: past it the spawn moves its caller's handles, so it lands. //! - **Who does an end take?** [`claim`] one process at a time: its claim is -//! what closes admission under it, and the same hold reads its children, -//! so a child either landed before and is owed to the walk or is refused. +//! what closes admission under it, and the same hold reads its children. +//! A child admitted before the claim and landed after it is claimed in the +//! hold that lands it ([`land_child`]), and its spawner ends it. //! - **When is an end published?** Once its own teardown is done and every //! child's end is published: a count admission raises, and a child's //! publication, a refused spawn or the process's own teardown lowers @@ -70,7 +70,7 @@ pub enum Admit { } /// A spawn admitted under its place, which it keeps unpublished until -/// [`insert_child`] lands it or [`refuse_child`] lets the place go. +/// [`land_child`] lands it or [`refuse_child`] lets the place go. #[must_use = "an admitted spawn holds its place's publication until it lands or is refused"] #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct Admitted { @@ -92,11 +92,16 @@ pub struct Publish { pub parent: Option, } -/// The insert's refusal: the place was claimed since the admission. -#[must_use = "a refused insert may leave its place for this spawner to publish"] +/// Whom a landed child's end is owed to. +#[must_use = "a child claimed as it landed is its spawner's to end"] #[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct Refused { - pub publish: Option, +pub enum Landed { + /// Its place's, which is not claimed: that end's walk will take it. + Placed, + /// Its spawner's: the place was claimed since the admission and its walk + /// has read its children, so the child was claimed in the hold that + /// landed it. + Claimed, } /// The question at the top of a spawn under `place`, before anything is built. @@ -113,27 +118,40 @@ pub fn admit_child(table: &mut T, place: Pid) -> Admit { Admit::Yes(Admitted { place, depth }) } -/// The same question under the lock that inserts `child`, and the one that -/// decides. `Ok` is the child's node: the caller inserts it in this hold. -pub fn insert_child(table: &mut T, admitted: Admitted, child: Pid) -> Result { +/// Land `child` under its admitted place, in one hold: `insert` puts its +/// entry in the table with the node it is handed, and a child whose place was +/// claimed since the admission is claimed for `code` before the hold ends. +pub fn land_child( + table: &mut T, + admitted: Admitted, + child: Pid, + code: i32, + insert: impl FnOnce(&mut T, Node) -> R, +) -> (R, Landed) { let place = table .get_mut(admitted.place) - .expect("insert_child: an admitted spawn keeps its place unpublished, so in the table"); - // The mutation this feature stages is the whole of the second check. - #[cfg(not(feature = "mutate-place-skips-the-insert-recheck"))] - if place.tearing_down() { - return Err(Refused { publish: refuse_child(table, admitted) }); - } + .expect("land_child: an admitted spawn keeps its place unpublished, so in the table"); + // The mutation this feature stages: the child lands as if its place were + // live, after the walk read its children. + let claimed = place.tearing_down() && !cfg!(feature = "mutate-place-skips-the-insert-recheck"); place.node_mut().children.push(child); - Ok(Node { parent: Some(admitted.place), depth: admitted.depth, children: Vec::new(), holds: 1 }) + let node = Node { parent: Some(admitted.place), depth: admitted.depth, children: Vec::new(), holds: 1 }; + let inserted = insert(table, node); + if !claimed { + return (inserted, Landed::Placed); + } + assert!( + teardown::claim_teardown(table, child, code), + "land_child: pid {child}, inserted in this hold, was claimed by another", + ); + (inserted, Landed::Claimed) } -/// A spawn that will not land — its build failed or its insert was refused: -/// its hold on the place goes. +/// A spawn whose build failed after its admission: its hold on the place goes. pub fn refuse_child(table: &mut T, admitted: Admitted) -> Option { // The mutation this feature stages: the hold stays, and the place waits // for a child that will never be. - if cfg!(feature = "mutate-refused-insert-keeps-the-count") { + if cfg!(feature = "mutate-refused-spawn-keeps-the-count") { return None; } lower(table, admitted.place) @@ -222,19 +240,37 @@ mod tests { assert_eq!(admit_child(&mut world, Pid(99)), Admit::Gone); } - #[cfg(not(feature = "mutate-place-skips-the-insert-recheck"))] - #[cfg(not(feature = "mutate-refused-insert-keeps-the-count"))] - #[cfg(not(feature = "mutate-publish-before-the-children"))] + /// A child admitted before its place's claim and landed after it is + /// claimed in the hold that lands it, and holds the place's publication + /// until its own. #[test] - fn an_insert_under_a_place_claimed_since_the_admission_is_refused_and_lets_it_go() { + fn a_child_landed_under_a_place_claimed_since_its_admission_is_claimed_with_it() { let mut world = World::new(); let init = world.spawn_process(); let place = world.spawn_child(init); let Admit::Yes(admitted) = admit_child(&mut world, place) else { panic!("admitted") }; assert!(claim(&mut world, place, 137, &mut Vec::new())); - // The place's own teardown is done first: only this spawn holds it. - assert_eq!(teardown_done(&mut world, place), None); - let refused = insert_child(&mut world, admitted, Pid(50)).expect_err("a claimed place took a child"); - assert_eq!(refused.publish, Some(Publish { pid: place, parent: Some(init) })); + assert_eq!(teardown_done(&mut world, place), None, "published with a child admitted under it"); + let child = world.reserve_pid(); + let ((), landed) = land_child(&mut world, admitted, child, 137, |world, node| world.insert(child, node)); + assert_eq!(landed, Landed::Claimed); + assert_eq!(world.get(child).unwrap().teardown_code(), Some(137)); + assert_eq!(teardown_done(&mut world, child), Some(Publish { pid: child, parent: Some(place) })); + assert_eq!(published(&mut world, place, child), Some(Publish { pid: place, parent: Some(init) })); + } + + /// A published child leaves its parent's children, so a parent's walk + /// reads only what is still unpublished below it. + #[test] + fn a_published_child_leaves_its_parents_children() { + let mut world = World::new(); + let init = world.spawn_process(); + let child = world.spawn_child(init); + assert!(claim(&mut world, child, 0, &mut Vec::new())); + assert_eq!(teardown_done(&mut world, child), Some(Publish { pid: child, parent: Some(init) })); + assert_eq!(published(&mut world, init, child), None); + let mut owed = Vec::new(); + assert!(claim(&mut world, init, 137, &mut owed)); + assert_eq!(owed, [], "init's walk owes pid {child}, which was published"); } } diff --git a/toyos/src/endow.rs b/toyos/src/endow.rs index e58d3885b74..fdbd966b076 100644 --- a/toyos/src/endow.rs +++ b/toyos/src/endow.rs @@ -147,9 +147,10 @@ pub fn namespace() -> Option<&'static Namespace> { /// This process's handle to itself, which it hands on for a child to be /// placed under it: `WRITE`, `DUP` and `TRANSFER`, from the kernel, under /// [`SELF_LABEL`]. Borrowed rather than taken, as the namespace is: every -/// launch duplicates it. -pub fn this_process() -> Option<&'static Process> { - THIS_PROCESS.get().as_ref() +/// launch duplicates it. Panics for a process whose `self` was taken before +/// this first ran: the kernel starts every process holding it. +pub fn this_process() -> &'static Process { + THIS_PROCESS.get() } /// Make `ns` this process's namespace, for the one process no parent endows @@ -302,7 +303,7 @@ static THIS_PROCESS: ProcessCell = ProcessCell(Once::new()); struct EndowTable(Once); struct NamespaceCell(Once>); -struct ProcessCell(Once>); +struct ProcessCell(Once); impl EndowTable { fn get(&'static self) -> &'static Endowments { @@ -317,8 +318,10 @@ impl NamespaceCell { } impl ProcessCell { - fn get(&'static self) -> &'static Option { - self.0.get_or_init(|| Endowments::get().take::(SELF_LABEL)) + fn get(&'static self) -> &'static Process { + self.0.get_or_init(|| { + Endowments::get().take::(SELF_LABEL).expect("this process's `self` was taken by another") + }) } } diff --git a/toyos/src/launch.rs b/toyos/src/launch.rs index e0743ef2e7a..1a1b3204408 100644 --- a/toyos/src/launch.rs +++ b/toyos/src/launch.rs @@ -352,3 +352,33 @@ pub fn launch<'a>( _ => Err(LaunchError::Sent(IpcError::Malformed)), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The header's last word names the parent: a place, init, or — any other + /// word — neither, which init refuses. + #[test] + fn a_request_names_its_parent_by_one_word() { + let mut buf = [0u8; 256]; + let request = |parent| Launch { + program: "/system/bin/cat", + argv: b"", + env: b"", + cwd: "/", + extras: &[], + slots: &[], + parent, + }; + for (parent, named) in [(Parent::Place(RawHandle(9)), Parent::Place(())), (Parent::Init, Parent::Init)] { + let len = request(parent).encode(&mut buf).expect("encode a launch"); + assert_eq!(Request::decode(&buf[..len]).expect("decode it").parent(), Some(named)); + } + let len = request(Parent::Init).encode(&mut buf).expect("encode a launch"); + for word in [0, 3, u32::MAX] { + buf[HEADER - 4..HEADER].copy_from_slice(&word.to_le_bytes()); + assert_eq!(Request::decode(&buf[..len]).expect("decode it").parent(), None, "word {word}"); + } + } +} diff --git a/userland/init/src/main.rs b/userland/init/src/main.rs index 3b8d1fdac41..3e55817b735 100644 --- a/userland/init/src/main.rs +++ b/userland/init/src/main.rs @@ -1595,8 +1595,10 @@ impl Init<'_> { Err(_) => toyos_abi::syscall::close(handle), } } - // std's word for the kernel's `Gone`, which a spawn answers only for - // its place. + // std's word for the kernel's `Gone` for the place, and nothing else + // here answers it: the command is prepared, so its spawn calls no + // file server, and every refusal `start` makes before the spawn is + // `Other`. Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe && under.is_some() => { say!("init: launcher: {} was not started: the place it names is ending", program.name); let _ = conn.try_signal(launch::MSG_GONE); diff --git a/userland/toybox/src/ps.rs b/userland/toybox/src/ps.rs index 5543bc621a1..6005bd1a08b 100644 --- a/userland/toybox/src/ps.rs +++ b/userland/toybox/src/ps.rs @@ -111,7 +111,6 @@ pub fn main(_args: Vec) { }; let window_ns = uptime_ns.saturating_sub(first_uptime); - // No PPID column: no syscall answers a process's parent. println!("{:>5} {:>3} {:>2} {:>8} {:>5} {:>5} {}", "PID", "TID", "S", "CPU", "%CPU", "MEM", "NAME"); From 6cb8071c7eba3ded87fd912768289f76b41e987d Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 08:37:33 +0200 Subject: [PATCH 05/16] process_tree: B faults by a volatile write to address 0 `ptr::write_volatile` checks alignment only, so a null write reaches the CPU under the guest profile's debug assertions; the unmapped constant and its claim otherwise go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- tests/toyos-rust-tests/src/bin/process_tree.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/process_tree.rs b/tests/toyos-rust-tests/src/bin/process_tree.rs index f7146e4b438..20ee3ed4386 100644 --- a/tests/toyos-rust-tests/src/bin/process_tree.rs +++ b/tests/toyos-rust-tests/src/bin/process_tree.rs @@ -57,10 +57,6 @@ const KILLED: i32 = 137; /// What `syscall::kill_process(-1)` publishes for a Ring 3 CPU fault. const CPU_FAULT: i32 = -1; -/// An address no region of this process covers. Not null: this profile's -/// debug assertions refuse a null write before it reaches the CPU. -const UNMAPPED: usize = 8; - /// `toyos_proclife::MAX_DEPTH`, which the kernel refuses a process past. const MAX_DEPTH: u32 = 64; @@ -383,8 +379,9 @@ fn b() -> ! { let header = conn.recv_header().expect("A's word to fault"); assert_eq!(header.msg_type, MSG_FAULT, "A said something else"); - // SAFETY: no region of this process covers the address, so the write - // faults and the kernel ends this process before anything after it runs. - unsafe { core::ptr::without_provenance_mut::(UNMAPPED).write_volatile(1) }; - panic!("a write to {UNMAPPED:#x} did not fault"); + // SAFETY: a volatile write to address 0, which no region of this process + // covers: it faults, and the kernel ends this process before anything + // after it runs. + unsafe { core::ptr::null_mut::().write_volatile(1) }; + panic!("a write to address 0 did not fault"); } From cc49e1302b7ec40d774c236517ce6c1044e354d3 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 09:32:44 +0200 Subject: [PATCH 06/16] A refused spawn spends no pid, the last pid is refused by name, and a landing answers what its spawner retires Answers BLOCKERs 1 and 3 of the second review of #659. **Pids (BLOCKER 3).** `toyos_proclife::pids::Pids` issues every pid the kernel hands out, init's, every spawn's and the kernel thread's, and the process table holds it beside its entries: `ProcessTable` becomes a struct, and `IdMap::reserve` and `IdMap::fill` go back out. - A spawn takes its pid at admission (`tree::admit_child`), with the other refusals and before anything is built. A spawn refused after that gives it back (`tree::refuse_child`), and the next admission takes it. A refused spawn spends none, and the pids given back at once are at most the spawns in flight at once. - `Pid::MAX` is never issued: it is the per-CPU word for no process (`percpu::current_pid`). Once every pid below it is issued, admission answers `Admit::NoPid`, the spawn answers `ResourceExhausted`, and the log says `spawn: refused, every pid below 4294967295 is issued`. Before this, the counter issued `Pid(u32::MAX)` and the next `Pid + Pid` panicked the kernel under `overflow-checks`. - Why give back, rather than take the pid at the point of no return as the review proposed: `PendingHandles::commit` itself refuses (a `self` label, a missing `TRANSFER`, no room), so a pid taken before the commit is still spent by every refused commit, without bound. Taking it after the commit leaves exhaustion to refuse after the caller's handles moved, unless admission also counts the spawns owed a pid. A 64-bit pid was not taken: std's `process::id()` and `Child::id()` answer `u32`. - Giving back is sound because no entry ever held a refused spawn's pid. The `ProcessObject` the loader makes before the commit dies with the refused spawn, so no handle, no node's children and no walk names that pid. A pid an entry held is never issued again, so a pid a walk carries across a lock release still names its process or nothing. **The landing answers the retires (BLOCKER 1).** `tree::land_child` now answers the threads its spawner retires: every thread of a child claimed as it lands, and none of any other. `tree::Landed` goes. The model's spawner posts what the landing answers instead of computing its own, so the decision is the host's to check. `Admission::land` has no branch left: it maps the answer through `scheds`, which every claim's retires also go through, and the loader posts the result after the table lock is given up. - New control `mutate-landed-child-retires-nothing`: the landing claims the child and answers nothing to retire. `cargo test -p toyos-proclife --features mutate-landed-child-retires-nothing` exits 101, with three FAILED: `a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it` and `a_spawn_racing_the_kill_of_its_own_spawner` ("pid 1 was claimed for teardown and never published an exit", and pid 3), and `a_child_landed_under_a_place_claimed_since_its_admission_is_claimed_with_it`. `src/ci.rs`'s `CONTROLS` row demands the first. - The review's kernel patch has no site left. Its nearest forms are measured as checked patches against `cd kernel && cargo check`, which builds with `-Dwarnings`: `(inserted, Vec::new())` in `Admission::land` exits 101 (unused `retire` and `pid`), and the loader's post loop deleted exits 101 (unused `retire`). The tree was restored clean after each. **Tests.** `pids::tests`: pids from 0 in order; the last is `u32::MAX - 1` and none follows it; a pid given back is the next taken. `tree::tests`' `a_spawn_past_the_last_pid_is_refused_and_a_refused_spawn_spends_none`: with two pids left, a refusal after the last gives it back three times, the landing takes it, `NoPid` raises no hold, and init publishes once its child does. As checked patches, each restored: - `refuse_child` without its `give_back`: `cargo test -p toyos-proclife` exits 101, that test FAILED. - `Pids::take` without its `Pid::MAX` stop: exit 101, both `pids` tests and that test FAILED, each on the overflow panic. **REMOVE.** The review's three lines go: `tree.rs`'s "It is the last refusal" sentence and "which runs with preemption off", and the loader's clause on `commit`'s `?`. `tree::Admitted::place` (NOTE, no caller) goes; `Admitted` gains `pid`. `issues/kernel/a-process-that-starts-four-billion-threads-panics-the-kernel.md` records the same overflow for a thread id, as the review asked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- ...-four-billion-threads-panics-the-kernel.md | 26 +++ kernel/src/id_map.rs | 16 -- kernel/src/loader/mod.rs | 5 +- kernel/src/process.rs | 121 +++++++----- kernel/src/sched/kthread.rs | 21 ++- src/ci.rs | 3 + toyos-proclife/Cargo.toml | 6 + toyos-proclife/src/interleave.rs | 30 ++- toyos-proclife/src/lib.rs | 2 + toyos-proclife/src/model.rs | 41 ++-- toyos-proclife/src/pids.rs | 94 +++++++++ toyos-proclife/src/table.rs | 5 +- toyos-proclife/src/tree.rs | 178 +++++++++++------- 13 files changed, 373 insertions(+), 175 deletions(-) create mode 100644 issues/kernel/a-process-that-starts-four-billion-threads-panics-the-kernel.md create mode 100644 toyos-proclife/src/pids.rs diff --git a/issues/kernel/a-process-that-starts-four-billion-threads-panics-the-kernel.md b/issues/kernel/a-process-that-starts-four-billion-threads-panics-the-kernel.md new file mode 100644 index 00000000000..121776c2d33 --- /dev/null +++ b/issues/kernel/a-process-that-starts-four-billion-threads-panics-the-kernel.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-10-01 +--- + +# A process that starts four billion threads panics the kernel + +`process::spawn_thread` takes a thread's id from its process's +`IdMap` (`kernel/src/id_map.rs`), whose `insert` steps its +counter with `Tid + Tid` (`toyos-abi/src/lib.rs`). The kernel builds with +`overflow-checks = true` (`kernel/Cargo.toml`), so the 2^32nd thread a single +process starts panics the kernel, and it does so under `PROCESS_TABLE`, which +hangs the machine instead of reporting. The thread before it is issued +`Tid(u32::MAX)`, which is the per-CPU word for no thread +(`arch::x86_64::percpu::current_tid`), so `handle_page_fault` refuses every +fault that thread takes. + +Userland reaches it by starting and joining threads in a loop; only a +successful start spends an id, so it costs 2^32 thread starts, never measured. +A pid had the same shape until `toyos_proclife::pids` (stops below `Pid::MAX` +and refuses the next spawn by name); a thread id needs the same: never +`Tid::MAX`, and a refused start once the ids are spent. + +*Exit*: a host test of the thread-id allocator answering a refusal after +`Tid(u32::MAX - 1)`, and `SYS_THREAD_SPAWN` answering it as a refusal. diff --git a/kernel/src/id_map.rs b/kernel/src/id_map.rs index cd1885f56a2..8ca5254e9e8 100644 --- a/kernel/src/id_map.rs +++ b/kernel/src/id_map.rs @@ -49,22 +49,6 @@ impl IdMap { id } - /// Takes the next ID with no value yet, for a value that must name its ID - /// before the map holds it. The ID is never issued again; [`Self::fill`] - /// gives it its value, or nothing ever does. - pub fn reserve(&mut self) -> K { - let id = self.next; - self.next = self.next + K::ONE; - id - } - - /// Gives a reserved ID its value. Panics for an ID this map has not - /// issued, or one that already has a value. - pub fn fill(&mut self, id: K, value: V) { - assert!(id < self.next, "IdMap::fill: an ID this map never issued"); - assert!(self.map.insert(id, value).is_none(), "IdMap::fill: an ID filled twice"); - } - pub fn get(&self, id: K) -> Option<&V> { self.map.get(&id) } diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index 962ff6719f2..547d7171547 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -597,8 +597,7 @@ pub fn spawn( let pid = admission.pid(); let object = crate::object::process::ProcessObject::new(pid); // The point of no return: every failure above answers the caller with its - // table untouched. `commit`'s own `?` is different — reachable only if the - // caller raced its own spawn, and fatal to it, not a refusal. + // table untouched. let (handles, endowments) = pending.commit(KObjectRef::Process(Arc::clone(&object)))?; let proc_data = Arc::new(Lock::new(ProcessData { handles, @@ -648,7 +647,7 @@ pub fn spawn( let mut guard = PROCESS_TABLE.lock(); let ((tid, dst), retire) = admission.land(guard.as_mut().unwrap(), |table, node| { - table.fill(pid, ProcessEntry::new( + table.insert(ProcessEntry::new( Arc::clone(&object), start::make_name(path), proc_data, diff --git a/kernel/src/process.rs b/kernel/src/process.rs index a3d9c763adf..62a7be96e98 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -410,21 +410,23 @@ impl Lifecycle for ProcessEntry { impl Processes for ProcessTable { type Proc = ProcessEntry; - // Via `IdMap::get`, not `self.get(pid)`: the inherent method and a trait method of the same name resolve to the inherent one, so renaming this would turn it into infinite recursion. fn get(&self, pid: Pid) -> Option<&ProcessEntry> { - crate::id_map::IdMap::get(self, pid) + self.entries.get(&pid) } fn get_mut(&mut self, pid: Pid) -> Option<&mut ProcessEntry> { - crate::id_map::IdMap::get_mut(self, pid) + self.entries.get_mut(&pid) } fn published_exit(&self, pid: Pid) -> bool { - crate::id_map::IdMap::get(self, pid).is_some_and(|p| p.object.finished()) + self.entries.get(&pid).is_some_and(|p| p.object.finished()) } fn each_pid(&self, f: &mut dyn FnMut(Pid)) { - for (pid, _) in self.iter() { + for &pid in self.entries.keys() { f(pid); } } + fn pids(&mut self) -> &mut toyos_proclife::Pids { + &mut self.pids + } } impl Drop for ProcessEntry { @@ -669,7 +671,39 @@ impl IdleProof { } -pub type ProcessTable = crate::id_map::IdMap; +/// Every process by pid, and the pids not yet issued (`toyos_proclife::pids`). +pub struct ProcessTable { + entries: crate::hasher::HashMap, + pids: toyos_proclife::Pids, +} + +impl ProcessTable { + fn new() -> Self { + Self { entries: crate::hasher::HashMap::default(), pids: toyos_proclife::Pids::default() } + } + + pub fn get(&self, pid: Pid) -> Option<&ProcessEntry> { + self.entries.get(&pid) + } + + pub fn get_mut(&mut self, pid: Pid) -> Option<&mut ProcessEntry> { + self.entries.get_mut(&pid) + } + + pub fn remove(&mut self, pid: Pid) -> Option { + self.entries.remove(&pid) + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter().map(|(&pid, proc)| (pid, proc)) + } + + /// `entry`, at the pid it was admitted with, which no entry has held. + pub fn insert(&mut self, entry: ProcessEntry) { + let pid = entry.pid; + assert!(self.entries.insert(pid, entry).is_none(), "ProcessTable::insert: pid {pid} held twice"); + } +} pub static PROCESS_TABLE: Lock> = Lock::new(None); @@ -832,59 +866,58 @@ pub enum Parent { Under(Pid), } -/// A spawn admitted under its parent, and the pid its child will have. -/// While it lives it holds the parent's publication: [`Admission::land`] hands that hold to the child, and a drop — every way a spawn's build fails — lets the parent go. -pub struct Admission { - pid: Pid, - under: Option, -} +/// A spawn admitted under its parent, holding the pid its child will have and the parent's publication: [`Admission::land`] hands both to the child, and a drop — every way a spawn's build fails — gives both back. +pub struct Admission(Option); impl Admission { - /// The question at the top of a spawn, before anything is built: `Gone` for a parent being torn down, `ResourceExhausted` for a child more than `MAX_DEPTH` below init, which the log names. + /// The question at the top of a spawn, before anything is built: `Gone` for a parent being torn down, and `ResourceExhausted`, which the log names, for a child more than `MAX_DEPTH` below init or no pid left to issue. pub fn ask(parent: Parent) -> Result { - let mut guard = PROCESS_TABLE.lock(); - let table = guard.as_mut().unwrap(); - let under = match parent { + let place = match parent { Parent::Root => None, - Parent::Under(place) => match tree::admit_child(table, place) { - tree::Admit::Yes(admitted) => Some(admitted), - tree::Admit::Gone => return Err(SyscallError::Gone), - tree::Admit::TooDeep { depth } => { - drop(guard); - log!("spawn: refused under pid {place} at depth {depth}, more than {} below init", - toyos_proclife::MAX_DEPTH); - return Err(SyscallError::ResourceExhausted); - } - }, + Parent::Under(place) => Some(place), }; - Ok(Self { pid: table.reserve(), under }) + let admit = { + let mut guard = PROCESS_TABLE.lock(); + tree::admit_child(guard.as_mut().unwrap(), place) + }; + match admit { + tree::Admit::Yes(admitted) => Ok(Self(Some(admitted))), + tree::Admit::Gone => Err(SyscallError::Gone), + tree::Admit::TooDeep { depth } => { + log!("spawn: refused under pid {} at depth {depth}, more than {} below init", + place.expect("spawn: init is admitted at depth 0"), toyos_proclife::MAX_DEPTH); + Err(SyscallError::ResourceExhausted) + } + tree::Admit::NoPid => { + log!("spawn: refused, every pid below {} is issued", Pid::MAX); + Err(SyscallError::ResourceExhausted) + } + } } pub fn pid(&self) -> Pid { - self.pid + self.0.as_ref().expect("Admission: taken only by land, which consumes it").pid() } - /// Land the child in the `PROCESS_TABLE` hold `insert` fills this pid and schedules its thread in. A parent claimed since the admission has walked its children, so the child is claimed in this hold too, and the retires answered are the spawner's to post with the lock given up. + /// Land the child in the `PROCESS_TABLE` hold `insert` puts its entry in and schedules its thread in. A parent claimed since the admission has walked its children, so the child is claimed in this hold too, and the retires answered are the spawner's to post with the lock given up. pub fn land( mut self, table: &mut ProcessTable, insert: impl FnOnce(&mut ProcessTable, Node) -> R, ) -> (R, Vec) { - let Some(admitted) = self.under.take() else { return (insert(table, Node::root()), Vec::new()) }; - match tree::land_child(table, admitted, self.pid, KILLED_EXIT_CODE, insert) { - (inserted, tree::Landed::Placed) => (inserted, Vec::new()), - (inserted, tree::Landed::Claimed) => (inserted, retires(table, self.pid, None)), - } + let admitted = self.0.take().expect("Admission: landed once"); + let pid = admitted.pid(); + let (inserted, retire) = tree::land_child(table, admitted, KILLED_EXIT_CODE, insert); + (inserted, scheds(table, pid, retire)) } } impl Drop for Admission { fn drop(&mut self) { - let Some(admitted) = self.under.take() else { return }; + let Some(admitted) = self.0.take() else { return }; let ready = { let mut guard = PROCESS_TABLE.lock(); - let table = guard.as_mut().unwrap(); - tree::refuse_child(table, admitted) + tree::refuse_child(guard.as_mut().unwrap(), admitted) }; publish_climb(ready); } @@ -1170,17 +1203,17 @@ fn claim(pid: Pid, code: i32, caller: Option, owed: &mut Vec) -> Vec) -> Vec { - let proc = Processes::get(table, pid).expect("retires: a claimed process is in the table"); - proclife::retire_set(proc, caller) - .into_iter() +/// The scheduler records of `pid`'s threads `tids`, which a claim of it retires. +fn scheds(table: &ProcessTable, pid: Pid, tids: Vec) -> Vec { + let proc = table.get(pid).expect("scheds: a claimed process is in the table"); + tids.into_iter() .map(|tid| { proc.threads.get(tid).and_then(ThreadEntry::sched).cloned() - .expect("retires: a thread in the table has its scheduler record") + .expect("scheds: a thread in the table has its scheduler record") }) .collect() } diff --git a/kernel/src/sched/kthread.rs b/kernel/src/sched/kthread.rs index 925c9a20a2a..2bba7e7cada 100644 --- a/kernel/src/sched/kthread.rs +++ b/kernel/src/sched/kthread.rs @@ -15,6 +15,7 @@ use crate::process::{ use crate::scheduler::{self, TaskId}; use crate::symbols::SymbolTable; use crate::sync::Lock; +use toyos_proclife::Processes; use super::payload::ThreadSched; @@ -101,6 +102,8 @@ pub fn spawn(name: &str, body: extern "C" fn(u64) -> !, arg: u64) -> ThreadSched // Before the table lock: a panic holding the process table hangs the machine. let claim = Claim::take(name); + let taken = PROCESS_TABLE.lock().as_mut().expect("kthread: spawned before process::init").pids().take(); + let pid = taken.unwrap_or_else(|| panic!("kthread: {name} found every pid issued")); let mut short = [0u8; THREAD_NAME_LEN]; let len = name.len().min(THREAD_NAME_LEN - 1); @@ -112,16 +115,14 @@ pub fn spawn(name: &str, body: extern "C" fn(u64) -> !, arg: u64) -> ThreadSched // One hold across insert and place: a visible pid already has its thread scheduled. let mut guard = PROCESS_TABLE.lock(); let table = guard.as_mut().expect("kthread: spawned before process::init"); - let pid = table.insert_with(|pid| { - ProcessEntry::new( - crate::object::process::ProcessObject::new(pid), - short, - Arc::new(Lock::new(kernel_process_data(name))), - Arc::clone(&syms), - ThreadEntry::new(Arc::new(Lock::new(kernel_thread_data()))), - toyos_proclife::Node::root(), - ) - }); + table.insert(ProcessEntry::new( + crate::object::process::ProcessObject::new(pid), + short, + Arc::new(Lock::new(kernel_process_data(name))), + Arc::clone(&syms), + ThreadEntry::new(Arc::new(Lock::new(kernel_thread_data()))), + toyos_proclife::Node::root(), + )); let tid = table.get(pid).expect("kthread: the entry just inserted is gone").main_tid(); claim.publish(TaskId(pid, tid)); // The kernel address space, named so one declaration decides every task's `cr3`. diff --git a/src/ci.rs b/src/ci.rs index b020142e34f..47211894fe2 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -353,6 +353,9 @@ pub(crate) const CONTROLS: &[Control] = &[ red(PROCLIFE, "mutate-refused-spawn-keeps-the-count", None, &[ "a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it ... FAILED", ]), + red(PROCLIFE, "mutate-landed-child-retires-nothing", None, &[ + "a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it ... FAILED", + ]), red(PROCLIFE, "mutate-publish-before-the-children", None, &[ "an_exit_publishes_after_every_end_below_it ... FAILED", ]), diff --git a/toyos-proclife/Cargo.toml b/toyos-proclife/Cargo.toml index cc16a11c74f..42b91e0c64c 100644 --- a/toyos-proclife/Cargo.toml +++ b/toyos-proclife/Cargo.toml @@ -72,6 +72,12 @@ mutate-place-skips-the-insert-recheck = [] # waits for a child that will never be and is never published. The same test # must red under this. mutate-refused-spawn-keeps-the-count = [] +# `tree::land_child` claims a child landed under a place claimed since its +# admission and answers no thread to retire, so the child runs on claimed, is +# never torn down, and holds its place unpublished. +# `interleave::tests::a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it` +# must red under this. +mutate-landed-child-retires-nothing = [] # `tree::teardown_done` publishes an end with its own teardown, whatever is # still unpublished below it. `interleave::tests::an_exit_publishes_after_every_end_below_it` # must red under this. diff --git a/toyos-proclife/src/interleave.rs b/toyos-proclife/src/interleave.rs index aa9d7ccb13c..134a58dd2bb 100644 --- a/toyos-proclife/src/interleave.rs +++ b/toyos-proclife/src/interleave.rs @@ -24,7 +24,7 @@ use std::collections::HashSet; use crate::model::{Climb, World}; use crate::table::Processes; -use crate::tree::{self, Admit, Admitted, Landed}; +use crate::tree::{self, Admit, Admitted}; use crate::{join, reap, spawn, teardown, Pid, Tid, Watch}; /// `process::KILLED_EXIT_CODE`, which a walk claims every process below an end @@ -51,9 +51,9 @@ pub enum Op { Kill { pid: Pid, code: i32, pc: u32, retire: Vec<(Pid, Tid)>, owed: Vec, by: Option<(Pid, Tid)> }, /// `loader::spawn` under `place` by `by`'s thread: the admission, the /// whole of a process built with every lock given up, then the move of - /// the caller's handles and the landing, whose retires a child claimed as - /// it landed owes. A build that `fails` lets the place go instead, and - /// climbs when that was the place's last hold. + /// the caller's handles and the landing, whose retires the landing + /// answers. A build that `fails` lets the place go instead, and climbs + /// when that was the place's last hold. SpawnUnder { place: Pid, by: (Pid, Tid), @@ -220,12 +220,12 @@ impl Op { Op::SpawnUnder { place, by, fails, pc, admitted, retire, climb } => { match *pc { // The admission, under the table lock and before anything is built. - 0 => match tree::admit_child(world, *place) { + 0 => match tree::admit_child(world, Some(*place)) { Admit::Yes(taken) => { *admitted = Some(taken); *pc = 1; } - Admit::Gone | Admit::TooDeep { .. } => { + Admit::Gone | Admit::TooDeep { .. } | Admit::NoPid => { world.refuse_spawn(*by); *pc = DONE; } @@ -247,20 +247,14 @@ impl Op { 1 => { world.move_handles(*by); let taken = admitted.take().expect("admitted at the first section"); - let child = world.reserve_pid(); - let ((), landed) = - tree::land_child(world, taken, child, KILLED, |world, node| world.insert(child, node)); + let child = taken.pid(); + let ((), owed) = + tree::land_child(world, taken, KILLED, |world, node| world.insert(child, node)); world.landed(*place, child); - match landed { - Landed::Placed => *pc = DONE, - Landed::Claimed => { - let proc = world.get(child).expect("just landed"); - *retire = teardown::retire_set(proc, None).into_iter().map(|t| (child, t)).collect(); - *pc = 2; - } - } + *retire = owed.into_iter().map(|t| (child, t)).collect(); + *pc = 2; } - // With the lock given up: the retires of a child claimed as it landed. + // With the lock given up: the retires the landing answered. 2 => { for (victim, thread) in retire.drain(..) { world.post_retire(victim, thread); diff --git a/toyos-proclife/src/lib.rs b/toyos-proclife/src/lib.rs index a4fc0481c5c..ce8977ce966 100644 --- a/toyos-proclife/src/lib.rs +++ b/toyos-proclife/src/lib.rs @@ -54,6 +54,7 @@ extern crate alloc; extern crate std; pub mod join; +pub mod pids; pub mod reap; pub mod spawn; pub mod table; @@ -65,6 +66,7 @@ mod interleave; #[cfg(test)] mod model; +pub use pids::Pids; pub use table::{Lifecycle, Processes}; pub use tree::{Node, MAX_DEPTH}; diff --git a/toyos-proclife/src/model.rs b/toyos-proclife/src/model.rs index d3ebf3ff9d1..d29d78f8b43 100644 --- a/toyos-proclife/src/model.rs +++ b/toyos-proclife/src/model.rs @@ -19,7 +19,7 @@ use alloc::vec::Vec; use crate::table::{Lifecycle, Processes}; use crate::teardown::{self, Leave}; use crate::tree::{self, Admit, Publish}; -use crate::{Node, Pid, ThreadLocation, Tid, Watch}; +use crate::{Node, Pid, Pids, ThreadLocation, Tid, Watch}; /// One process, as its lifecycle sees it. #[derive(Clone, PartialEq, Eq, Hash)] @@ -106,7 +106,7 @@ struct Departure { #[derive(Clone, PartialEq, Eq, Hash)] pub struct World { procs: BTreeMap, - next_pid: Pid, + pids: Pids, /// The exit each process published, which is what `published_exit` reads — /// on the object in the kernel, and never on the entry. published: BTreeMap, @@ -162,13 +162,21 @@ impl Processes for World { f(pid); } } + fn pids(&mut self) -> &mut Pids { + &mut self.pids + } } impl World { pub fn new() -> Self { + Self::with_pids(Pids::default()) + } + + /// A world whose spawns take their pids from `pids`. + pub fn with_pids(pids: Pids) -> Self { Self { procs: BTreeMap::new(), - next_pid: Pid(1), + pids, published: BTreeMap::new(), waiters: BTreeSet::new(), released: BTreeSet::new(), @@ -206,30 +214,25 @@ impl World { self.tls_mapped.remove(&block); } - /// A root process with one thread, which is its main one — what - /// `ProcessEntry::new` builds for init. + /// A process under none with one thread, which is its main one — what + /// the loader builds for init. pub fn spawn_process(&mut self) -> Pid { - let pid = self.reserve_pid(); - self.insert(pid, Node::root()); - pid + self.land(None) } /// A process placed under `place`, admitted and inserted with nothing /// between: how a test builds the tree its ops start from. pub fn spawn_child(&mut self, place: Pid) -> Pid { - let Admit::Yes(admitted) = tree::admit_child(self, place) else { - panic!("spawn_child: pid {place} admits no child"); - }; - let pid = self.reserve_pid(); - let ((), landed) = tree::land_child(self, admitted, pid, 137, |world, node| world.insert(pid, node)); - assert_eq!(landed, tree::Landed::Placed, "spawn_child: pid {place} was claimed"); - pid + self.land(Some(place)) } - /// The pid a spawn's admission takes before it builds anything. - pub fn reserve_pid(&mut self) -> Pid { - let pid = self.next_pid; - self.next_pid = Pid(pid.0 + 1); + fn land(&mut self, place: Option) -> Pid { + let Admit::Yes(admitted) = tree::admit_child(self, place) else { + panic!("World::land: {place:?} admits no child"); + }; + let pid = admitted.pid(); + let ((), retire) = tree::land_child(self, admitted, 137, |world, node| world.insert(pid, node)); + assert_eq!(retire, [], "World::land: {place:?} was claimed"); pid } diff --git a/toyos-proclife/src/pids.rs b/toyos-proclife/src/pids.rs new file mode 100644 index 00000000000..ab1345ccbb0 --- /dev/null +++ b/toyos-proclife/src/pids.rs @@ -0,0 +1,94 @@ +//! Which pid a new process gets. +//! +//! **A pid names one process for the life of the machine.** Once an entry +//! holds a pid it is never issued again, so a pid carried across a lock +//! release names that process or nothing. [`Pid::MAX`] is never issued: it is +//! the per-CPU word for no process. +//! +//! A spawn takes its pid at admission, before anything is built, and a spawn +//! refused after that gives it back: no entry held it, so nothing can name +//! it, and a refused spawn spends none. Once every pid below [`Pid::MAX`] is +//! issued, admission refuses by name. + +use alloc::vec::Vec; + +use crate::Pid; + +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct Pids { + /// The lowest pid never taken; [`Pid::MAX`] once every one has been. + next: Pid, + /// Taken by a spawn refused since, held by no entry: the next taken. + /// At most one per spawn in flight at once. + returned: Vec, +} + +impl Default for Pids { + fn default() -> Self { + Self { next: Pid(0), returned: Vec::new() } + } +} + +impl Pids { + /// A table whose next new pid is `next`, as one that has issued every + /// pid below it. + #[cfg(test)] + pub fn issued_below(next: Pid) -> Self { + Self { next, returned: Vec::new() } + } + + /// A pid no entry has held, or `None` once every pid below [`Pid::MAX`] + /// has been issued. + pub fn take(&mut self) -> Option { + if let Some(pid) = self.returned.pop() { + return Some(pid); + } + if self.next == Pid::MAX { + return None; + } + let pid = self.next; + self.next = Pid(pid.0 + 1); + Some(pid) + } + + /// `pid`, which [`Self::take`] answered to a spawn refused before any + /// entry held it. + pub fn give_back(&mut self, pid: Pid) { + assert!(pid < self.next, "Pids::give_back: pid {pid} was never taken"); + assert!(!self.returned.contains(&pid), "Pids::give_back: pid {pid} given back twice"); + self.returned.push(pid); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pids_are_issued_in_order_from_zero() { + let mut pids = Pids::default(); + assert_eq!([pids.take(), pids.take(), pids.take()], [Some(Pid(0)), Some(Pid(1)), Some(Pid(2))]); + } + + /// The last pid issued is the one below [`Pid::MAX`], and the next take + /// is refused rather than overflowing into it. + #[test] + fn the_last_pid_is_one_below_max_and_none_follows_it() { + let mut pids = Pids::issued_below(Pid(u32::MAX - 1)); + assert_eq!(pids.take(), Some(Pid(u32::MAX - 1))); + assert_eq!(pids.take(), None); + assert_eq!(pids.take(), None); + } + + /// A pid given back is the next one taken, after every pid is issued too. + #[test] + fn a_pid_given_back_is_taken_again() { + let mut pids = Pids::issued_below(Pid(u32::MAX - 2)); + let first = pids.take().unwrap(); + let last = pids.take().unwrap(); + assert_eq!(pids.take(), None); + pids.give_back(first); + pids.give_back(last); + assert_eq!([pids.take(), pids.take(), pids.take()], [Some(last), Some(first), None]); + } +} diff --git a/toyos-proclife/src/table.rs b/toyos-proclife/src/table.rs index 6e94960675e..a225a21bcfe 100644 --- a/toyos-proclife/src/table.rs +++ b/toyos-proclife/src/table.rs @@ -14,7 +14,7 @@ //! an order sorts what it collected, and the two that do //! ([`crate::teardown::retire_set`] and [`crate::reap::finished_pids`]) say so. -use crate::{Node, Pid, ThreadLocation, Tid}; +use crate::{Node, Pid, Pids, ThreadLocation, Tid}; /// One process's lifecycle state: the whole of what a spawn, an exit, a kill, a /// join or a reap reads or writes about it. @@ -74,4 +74,7 @@ pub trait Processes { /// Every process in the table, in whatever order the container has. fn each_pid(&self, f: &mut dyn FnMut(Pid)); + + /// The pids not yet issued. + fn pids(&mut self) -> &mut Pids; } diff --git a/toyos-proclife/src/tree.rs b/toyos-proclife/src/tree.rs index dc5d55b80e4..71ee6165282 100644 --- a/toyos-proclife/src/tree.rs +++ b/toyos-proclife/src/tree.rs @@ -9,8 +9,8 @@ //! //! - **May a child be placed here?** [`admit_child`] at the top of a spawn, //! before anything is built: a place being torn down takes nothing more, -//! and a child deeper than [`MAX_DEPTH`] below init is refused. It is the -//! last refusal: past it the spawn moves its caller's handles, so it lands. +//! and a child deeper than [`MAX_DEPTH`] below init is refused. It takes +//! the child's pid ([`crate::pids`]). //! - **Who does an end take?** [`claim`] one process at a time: its claim is //! what closes admission under it, and the same hold reads its children. //! A child admitted before the claim and landed after it is claimed in the @@ -25,10 +25,10 @@ use alloc::vec::Vec; use crate::table::{Lifecycle, Processes}; -use crate::{teardown, Pid}; +use crate::{teardown, Pid, Tid}; /// How far below init a process may be. Bounds the climb one teardown can -/// owe, which runs with preemption off. +/// owe. pub const MAX_DEPTH: u32 = 64; /// One process's place in the tree. @@ -59,7 +59,7 @@ impl Node { } /// A spawn's first answer. -#[must_use = "an admitted spawn holds its place's publication until it lands or is refused"] +#[must_use = "an admitted spawn holds its place's publication and its pid until it lands or is refused"] #[derive(PartialEq, Eq, Debug)] pub enum Admit { Yes(Admitted), @@ -67,20 +67,25 @@ pub enum Admit { Gone, /// The child would be `depth` below init, past [`MAX_DEPTH`]. TooDeep { depth: u32 }, + /// Every pid below [`Pid::MAX`] is issued. + NoPid, } -/// A spawn admitted under its place, which it keeps unpublished until -/// [`land_child`] lands it or [`refuse_child`] lets the place go. -#[must_use = "an admitted spawn holds its place's publication until it lands or is refused"] +/// A spawn admitted under its place, which it keeps unpublished, and the pid +/// it took, until [`land_child`] lands it or [`refuse_child`] gives both back. +#[must_use = "an admitted spawn holds its place's publication and its pid until it lands or is refused"] #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct Admitted { - place: Pid, + /// `None` for init, which the kernel starts under no process. + place: Option, depth: u32, + pid: Pid, } impl Admitted { - pub fn place(&self) -> Pid { - self.place + /// The pid the child will have. + pub fn pid(&self) -> Pid { + self.pid } } @@ -92,69 +97,81 @@ pub struct Publish { pub parent: Option, } -/// Whom a landed child's end is owed to. -#[must_use = "a child claimed as it landed is its spawner's to end"] -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Landed { - /// Its place's, which is not claimed: that end's walk will take it. - Placed, - /// Its spawner's: the place was claimed since the admission and its walk - /// has read its children, so the child was claimed in the hold that - /// landed it. - Claimed, -} - -/// The question at the top of a spawn under `place`, before anything is built. -pub fn admit_child(table: &mut T, place: Pid) -> Admit { - let Some(proc) = table.get_mut(place) else { return Admit::Gone }; - if proc.tearing_down() { - return Admit::Gone; - } - let depth = proc.node().depth + 1; - if depth > MAX_DEPTH { - return Admit::TooDeep { depth }; +/// The question at the top of a spawn under `place`, or of init's under +/// none, before anything is built. +pub fn admit_child(table: &mut T, place: Option) -> Admit { + let depth = match place { + None => 0, + Some(place) => { + let Some(proc) = table.get(place) else { return Admit::Gone }; + if proc.tearing_down() { + return Admit::Gone; + } + let depth = proc.node().depth + 1; + if depth > MAX_DEPTH { + return Admit::TooDeep { depth }; + } + depth + } + }; + let Some(pid) = table.pids().take() else { return Admit::NoPid }; + if let Some(place) = place { + table.get_mut(place).expect("admit_child: the place read above").node_mut().holds += 1; } - proc.node_mut().holds += 1; - Admit::Yes(Admitted { place, depth }) + Admit::Yes(Admitted { place, depth, pid }) } -/// Land `child` under its admitted place, in one hold: `insert` puts its -/// entry in the table with the node it is handed, and a child whose place was -/// claimed since the admission is claimed for `code` before the hold ends. +/// Land the admitted child, in one hold: `insert` puts its entry in the table +/// at its pid with the node it is handed. A child whose place was claimed since +/// the admission is claimed for `code` before the hold ends, and the threads +/// answered are its, for its spawner to retire: none for any other child. +#[must_use = "a child claimed as it landed is its spawner's to retire"] pub fn land_child( table: &mut T, admitted: Admitted, - child: Pid, code: i32, insert: impl FnOnce(&mut T, Node) -> R, -) -> (R, Landed) { - let place = table - .get_mut(admitted.place) - .expect("land_child: an admitted spawn keeps its place unpublished, so in the table"); - // The mutation this feature stages: the child lands as if its place were - // live, after the walk read its children. - let claimed = place.tearing_down() && !cfg!(feature = "mutate-place-skips-the-insert-recheck"); - place.node_mut().children.push(child); - let node = Node { parent: Some(admitted.place), depth: admitted.depth, children: Vec::new(), holds: 1 }; - let inserted = insert(table, node); +) -> (R, Vec) { + let Admitted { place, depth, pid } = admitted; + let claimed = match place { + None => false, + Some(place) => { + let proc = table + .get_mut(place) + .expect("land_child: an admitted spawn keeps its place unpublished, so in the table"); + proc.node_mut().children.push(pid); + // The mutation this feature stages: the child lands as if its + // place were live, after the walk read its children. + proc.tearing_down() && !cfg!(feature = "mutate-place-skips-the-insert-recheck") + } + }; + let inserted = insert(table, Node { parent: place, depth, children: Vec::new(), holds: 1 }); if !claimed { - return (inserted, Landed::Placed); + return (inserted, Vec::new()); } assert!( - teardown::claim_teardown(table, child, code), - "land_child: pid {child}, inserted in this hold, was claimed by another", + teardown::claim_teardown(table, pid, code), + "land_child: pid {pid}, inserted in this hold, was claimed by another", ); - (inserted, Landed::Claimed) + // The mutation this feature stages: the child is claimed and nobody + // retires it. + if cfg!(feature = "mutate-landed-child-retires-nothing") { + return (inserted, Vec::new()); + } + let child = table.get(pid).expect("land_child: inserted in this hold"); + (inserted, teardown::retire_set(child, None)) } -/// A spawn whose build failed after its admission: its hold on the place goes. +/// A spawn refused after its admission: its pid goes back, and its hold on +/// the place goes. pub fn refuse_child(table: &mut T, admitted: Admitted) -> Option { + table.pids().give_back(admitted.pid); // The mutation this feature stages: the hold stays, and the place waits // for a child that will never be. if cfg!(feature = "mutate-refused-spawn-keeps-the-count") { return None; } - lower(table, admitted.place) + lower(table, admitted.place?) } /// Claim `pid` for `code` and owe its children to the walk, all in this one @@ -210,6 +227,7 @@ fn lower(table: &mut T, pid: Pid) -> Option { mod tests { use super::*; use crate::model::World; + use crate::Pids; /// A chain from init down to `MAX_DEPTH`: the last is admitted, and one /// more below it is refused naming the depth it would have had. @@ -222,9 +240,9 @@ mod tests { at = world.spawn_child(at); assert_eq!(world.get(at).unwrap().node().depth(), depth); } - assert_eq!(admit_child(&mut world, at), Admit::TooDeep { depth: MAX_DEPTH + 1 }); + assert_eq!(admit_child(&mut world, Some(at)), Admit::TooDeep { depth: MAX_DEPTH + 1 }); let parent = world.get(at).unwrap().node().parent().unwrap(); - let Admit::Yes(admitted) = admit_child(&mut world, parent) else { + let Admit::Yes(admitted) = admit_child(&mut world, Some(parent)) else { panic!("a child at MAX_DEPTH itself was refused"); }; assert_eq!(refuse_child(&mut world, admitted), None); @@ -236,24 +254,24 @@ mod tests { let init = world.spawn_process(); let place = world.spawn_child(init); assert!(claim(&mut world, place, 137, &mut Vec::new())); - assert_eq!(admit_child(&mut world, place), Admit::Gone); - assert_eq!(admit_child(&mut world, Pid(99)), Admit::Gone); + assert_eq!(admit_child(&mut world, Some(place)), Admit::Gone); + assert_eq!(admit_child(&mut world, Some(Pid(99))), Admit::Gone); } /// A child admitted before its place's claim and landed after it is - /// claimed in the hold that lands it, and holds the place's publication - /// until its own. + /// claimed in the hold that lands it, its spawner is answered its thread + /// to retire, and it holds the place's publication until its own. #[test] fn a_child_landed_under_a_place_claimed_since_its_admission_is_claimed_with_it() { let mut world = World::new(); let init = world.spawn_process(); let place = world.spawn_child(init); - let Admit::Yes(admitted) = admit_child(&mut world, place) else { panic!("admitted") }; + let Admit::Yes(admitted) = admit_child(&mut world, Some(place)) else { panic!("admitted") }; assert!(claim(&mut world, place, 137, &mut Vec::new())); assert_eq!(teardown_done(&mut world, place), None, "published with a child admitted under it"); - let child = world.reserve_pid(); - let ((), landed) = land_child(&mut world, admitted, child, 137, |world, node| world.insert(child, node)); - assert_eq!(landed, Landed::Claimed); + let child = admitted.pid(); + let ((), retire) = land_child(&mut world, admitted, 137, |world, node| world.insert(child, node)); + assert_eq!(retire, [world.main_tid(child)], "the child's spawner was not answered its thread to retire"); assert_eq!(world.get(child).unwrap().teardown_code(), Some(137)); assert_eq!(teardown_done(&mut world, child), Some(Publish { pid: child, parent: Some(place) })); assert_eq!(published(&mut world, place, child), Some(Publish { pid: place, parent: Some(init) })); @@ -273,4 +291,36 @@ mod tests { assert!(claim(&mut world, init, 137, &mut owed)); assert_eq!(owed, [], "init's walk owes pid {child}, which was published"); } + + /// Once every pid below `Pid::MAX` is issued a spawn is refused by name + /// and holds its place by nothing, and a refused spawn's pid is the next + /// one admitted: refused spawns spend none. + #[test] + fn a_spawn_past_the_last_pid_is_refused_and_a_refused_spawn_spends_none() { + let mut world = World::with_pids(Pids::issued_below(Pid(u32::MAX - 2))); + let init = world.spawn_process(); + let Admit::Yes(admitted) = admit_child(&mut world, Some(init)) else { panic!("the last pid was refused") }; + let last = admitted.pid(); + assert_eq!(last, Pid(u32::MAX - 1)); + assert_eq!(admit_child(&mut world, Some(init)), Admit::NoPid); + assert_eq!(refuse_child(&mut world, admitted), None); + for _ in 0..3 { + let Admit::Yes(again) = admit_child(&mut world, Some(init)) else { + panic!("a refused spawn spent pid {last}"); + }; + assert_eq!(again.pid(), last); + assert_eq!(refuse_child(&mut world, again), None); + } + let Admit::Yes(lands) = admit_child(&mut world, Some(init)) else { panic!("pid {last} was spent") }; + let ((), retire) = land_child(&mut world, lands, 137, |world, node| world.insert(last, node)); + assert_eq!(retire, []); + assert_eq!(admit_child(&mut world, Some(init)), Admit::NoPid); + assert_eq!(admit_child(&mut world, None), Admit::NoPid); + // init's own hold and its child's are all that hold it. + assert!(claim(&mut world, last, 0, &mut Vec::new())); + assert_eq!(teardown_done(&mut world, last), Some(Publish { pid: last, parent: Some(init) })); + assert!(claim(&mut world, init, 0, &mut Vec::new())); + assert_eq!(teardown_done(&mut world, init), None); + assert_eq!(published(&mut world, init, last), Some(Publish { pid: init, parent: None })); + } } From 11e57e20bf01850f39e2667744bc90257f10e614 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 09:33:15 +0200 Subject: [PATCH 07/16] Guest arms for the endowment vector's refusals and a launch's place, and std refuses a launch whose stdio it cannot duplicate Answers BLOCKER 2 and the NOTEs of the second review of #659. **`abuse_handle_table` (BLOCKER 2).** Two arms beside the repeated-endowment refusal, each spawning a copy of the test binary that exits at once: - An entry labelled `self` naming a fresh pipe's write end answers `InvalidArgument`, and that end is still this process's: a byte written to it is read from the other end. Under the review's patch (`start.rs`'s `self` check deleted) the spawn starts and `expect_err` panics. - `MAX_ENDOWMENTS` entries naming 32 distinct pipe ends, which carry `TRANSFER`, answer `InvalidArgument`; the first `MAX_ENDOWMENTS - 1` of them start a child that exits 0. Under the review's patch (`MAX_SPAWN_ENDOWMENTS = MAX_ENDOWMENTS`) the 32 start and `expect_err` panics. The arm counts with `MAX_ENDOWMENTS`, the table's size, so that patch cannot move the test with it. **`process_tree`.** `a_place_without_dup_is_refused` (NOTE on fork `toyos.rs:515`): a launch under this process's `self` narrowed to `WRITE` answers `PermissionDenied` and starts nothing. With `direct(None)` back for a refused place duplicate, the child would be spawned directly under the place, which the kernel accepts, and the arm panics. **std** (fork `wt-toyos-proclife4`, d1b9f2eae3c on 40470840283): - A stdio slot whose duplicate the kernel refuses answers that refusal instead of turning the launch into a direct spawn (NOTE on `toyos.rs:528-535`). No arm tells the two apart: for a stdio handle without `DUP` the direct spawn's own slot duplicate is refused `PermissionDenied` too, so the difference shows only to a caller whose table is full, where the direct spawn started a child holding the caller's namespace. - The `slot_map.len() > MAX_LAUNCH_SLOTS` fallback goes (NOTE on `toyos.rs:503-505`): `launch` is reached only with no extra slot. - `CommandExt::under` says a launched child's place needs `DUP` too. `issues/kernel/a-spawn-refused-for-its-callers-full-table-has-already-moved-its-endowments.md` records the NOTE on `proc.rs:64-70`: a pre-existing refusal after the commit, off this stage's path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- ...-table-has-already-moved-its-endowments.md | 24 +++++ rust | 2 +- .../src/bin/abuse_handle_table.rs | 91 ++++++++++++++++++- .../toyos-rust-tests/src/bin/process_tree.rs | 23 ++++- 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 issues/kernel/a-spawn-refused-for-its-callers-full-table-has-already-moved-its-endowments.md diff --git a/issues/kernel/a-spawn-refused-for-its-callers-full-table-has-already-moved-its-endowments.md b/issues/kernel/a-spawn-refused-for-its-callers-full-table-has-already-moved-its-endowments.md new file mode 100644 index 00000000000..f05cf31a534 --- /dev/null +++ b/issues/kernel/a-spawn-refused-for-its-callers-full-table-has-already-moved-its-endowments.md @@ -0,0 +1,24 @@ +--- +status: open +kind: defect +opened: 2026-10-01 +--- + +# A spawn refused for its caller's full table has already moved its endowments + +`sys_spawn` (`kernel/src/syscall/proc.rs`) installs the new child's `Process` +handle in its caller's table after `loader::spawn` returns, which is after +`PendingHandles::commit` (`kernel/src/loader/start.rs`) moved the endowed +handles out of that table and the child landed. When the install finds the +table full, the spawn kills the child and answers `ResourceExhausted`: a +refusal after the caller's handles left, and a child that may have run user +code before its retire landed. Every other refusal of a spawn leaves the +caller's table as it was. + +The moves free one slot per endowment, so with endowments it takes another +thread of the caller filling the table between the commit and the install; +with none, a caller whose table is full when it spawns. Neither is measured. + +*Exit*: the child's handle goes into the caller's table in the commit's hold, +with the room checked before anything moves; a guest arm spawning from a +full table reads `ResourceExhausted` and no child started. diff --git a/rust b/rust index 40470840283..d1b9f2eae3c 160000 --- a/rust +++ b/rust @@ -1 +1 @@ -Subproject commit 4047084028319fd0f7d499285c80bbed4a7eb5b9 +Subproject commit d1b9f2eae3c35eeb743db53969aa0a3e8815c73c diff --git a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs index 835f99f9dfd..f45ce85a512 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs @@ -16,12 +16,21 @@ //! one argument and both ended in the kernel rather than in an error word, so //! each has an arm here and the last arm is what says the machine survived //! them. +//! +//! **An endowment vector is one entry and one label short of a table**, which +//! the kernel's own `self` fills. So a caller's entry labelled `self` is +//! refused before anything moves, and so is a vector of `MAX_ENDOWMENTS` +//! entries, while one entry fewer starts. use toyos_abi::syscall::{ - self, EndowEntry, MmapFlags, MmapProt, SpawnArgs, SyscallError, MAX_SLOT_MAP, + self, EndowEntry, MmapFlags, MmapProt, SpawnArgs, SyscallError, MAX_ENDOWMENTS, MAX_SLOT_MAP, SELF_LABEL, }; use toyos_abi::RawHandle; +const SELF: &str = "/system/bin/test_rs_abuse_handle_table"; +/// The role a spawned copy of this binary takes: it exits at once. +const CHILD: &str = "endowed"; + /// Where the child starts: `SpawnArgs` names a working directory or the spawn is refused. const CWD: &str = "/"; @@ -38,6 +47,9 @@ const PAIRS: usize = 100_000; const LABELS: &[u8] = b"twice"; fn main() { + if std::env::args().nth(1).as_deref() == Some(CHILD) { + return; + } let region = unsafe { syscall::mmap( core::ptr::null_mut(), @@ -119,6 +131,9 @@ fn main() { let err = spawn_with(0, 2).expect_err("an endowment vector naming one handle twice must be rejected"); assert_eq!(err, SyscallError::InvalidArgument, "wrong error for a repeated endowment"); + a_caller_endows_no_self(); + a_spawn_carries_one_entry_fewer_than_a_table(); + // dup2 picks the slot, so it never went through the allocating path that // carried the cap. let mut refused = None; @@ -147,3 +162,77 @@ fn main() { unsafe { syscall::munmap(region, REGION) }.expect("munmap"); println!("handle table capped at {MAX_HANDLES} on every insert path (refused at {n})"); } + +/// `SYS_SPAWN` of this binary as a child that exits at once, carrying +/// `entries` into the blob `labels`. +fn spawn_endowed(entries: &[EndowEntry], labels: &[u8]) -> Result { + let argv = format!("{SELF}\0{CHILD}"); + // SAFETY: every pointer names a live local for the whole call. + unsafe { + syscall::spawn(&SpawnArgs { + argv_ptr: argv.as_ptr() as u64, + argv_len: argv.len() as u64, + slot_map_ptr: 0, + slot_map_count: 0, + env_ptr: 0, + env_len: 0, + endow_ptr: entries.as_ptr() as u64, + endow_count: entries.len() as u64, + labels_ptr: labels.as_ptr() as u64, + labels_len: labels.len() as u64, + cwd_ptr: CWD.as_ptr() as u64, + cwd_len: CWD.len() as u64, + image: 0, + image_len: 0, + place: u64::from(toyos_abi::HANDLE_INVALID.0), + }) + } +} + +/// An entry labelled `self` would shadow the kernel's in the child's lookup: +/// refused before anything moves, so the pipe end it named is still this +/// process's and still carries a byte. +fn a_caller_endows_no_self() { + let ends = syscall::pipe().expect("a pipe for the self arm"); + let entry = EndowEntry { label_off: 0, label_len: SELF_LABEL.len() as u32, handle: ends.write, _pad: 0 }; + let err = spawn_endowed(&[entry], SELF_LABEL.as_bytes()).expect_err("an endowment labelled self must be refused"); + assert_eq!(err, SyscallError::InvalidArgument, "wrong error for an endowment labelled self"); + assert_eq!(syscall::write(ends.write, b"x"), Ok(1), "the end an endowment labelled self named is gone"); + let mut byte = [0u8; 1]; + assert_eq!(syscall::read(ends.read, &mut byte), Ok(1), "the byte did not arrive"); + syscall::close(ends.read); + syscall::close(ends.write); +} + +/// `MAX_ENDOWMENTS` entries naming distinct `TRANSFER` handles, so the count +/// is the only refusal: refused, and one fewer starts. +fn a_spawn_carries_one_entry_fewer_than_a_table() { + let mut handles = Vec::with_capacity(MAX_ENDOWMENTS); + while handles.len() < MAX_ENDOWMENTS { + let ends = syscall::pipe().expect("a pipe for the count arm"); + handles.extend([ends.read, ends.write]); + } + let mut labels = Vec::new(); + let entries: Vec = handles + .iter() + .enumerate() + .map(|(i, &handle)| { + let label = format!("e{i:02}"); + let entry = + EndowEntry { label_off: labels.len() as u32, label_len: label.len() as u32, handle, _pad: 0 }; + labels.extend_from_slice(label.as_bytes()); + entry + }) + .collect(); + let err = spawn_endowed(&entries, &labels).expect_err("a spawn carrying MAX_ENDOWMENTS entries must be refused"); + assert_eq!(err, SyscallError::InvalidArgument, "wrong error for MAX_ENDOWMENTS entries"); + + let fewer = MAX_ENDOWMENTS - 1; + let used = entries[fewer].label_off as usize; + let child = spawn_endowed(&entries[..fewer], &labels[..used]) + .unwrap_or_else(|e| panic!("a spawn carrying {fewer} entries was refused: {e:?}")); + assert_eq!(syscall::process_wait(child), Ok(0), "the child carrying {fewer} entries"); + syscall::close(child); + syscall::close(handles[fewer]); + println!("an endowment labelled self and {MAX_ENDOWMENTS} entries are refused, and {fewer} start"); +} diff --git a/tests/toyos-rust-tests/src/bin/process_tree.rs b/tests/toyos-rust-tests/src/bin/process_tree.rs index f93dad3e767..10c72441667 100644 --- a/tests/toyos-rust-tests/src/bin/process_tree.rs +++ b/tests/toyos-rust-tests/src/bin/process_tree.rs @@ -14,7 +14,9 @@ //! answers. //! //! **The other arms.** A `MANAGE`-only handle is no place, and init refuses a -//! launch whose place is a pipe and answers the launches after it. init starts a +//! launch whose place is a pipe and answers the launches after it. A launch +//! carries a copy of its place, so std refuses one under a place this process +//! cannot duplicate, rather than spawning it directly. init starts a //! child only by a launch, so std refuses `under_init` for a program no row //! declares and for a command carrying an extra slot. A chain alternating spawn //! and launch — each link launches a shell, and the shell spawns the next link @@ -93,6 +95,7 @@ fn test() { } a_manage_only_handle_is_no_place(); a_pipe_is_no_place(); + a_place_without_dup_is_refused(); init_is_asked_only_by_a_launch(); a_chain_stops_at_max_depth_and_dies_whole(); a_detached_program_outlives_its_shell(); @@ -274,6 +277,24 @@ fn a_pipe_is_no_place() { println!(" a pipe is no place: init refused the launch"); } +/// This process's `self` narrowed to `WRITE`: a place the kernel takes and a +/// launch cannot carry. The duplicate's refusal is the spawn's, and nothing +/// starts in place of the launch. +fn a_place_without_dup_is_refused() { + let place = syscall::dup_narrowed(endow::this_process().as_handle(), Rights::WRITE) + .expect("a WRITE-only copy of this process's self"); + match Command::new(HELD).under(place.0).stdin(Stdio::null()).spawn() { + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {} + Err(e) => panic!("a launch under a place without DUP was refused as {e:?}, not PermissionDenied"), + Ok(mut child) => { + let _ = child.kill(); + panic!("a launch under a place without DUP started its child directly"); + } + } + syscall::close(place); + println!(" a place without DUP: the launch is refused, and nothing starts"); +} + /// init is reached only by a launch: std refuses `under_init` for what the /// launcher would not start under init, and starts nothing. fn init_is_asked_only_by_a_launch() { From f3b6d22ae1a3edcfdd2dd74b01dd42830f799bf8 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 09:34:34 +0200 Subject: [PATCH 08/16] toyos-proclife: the crate doc names pids among its questions The doc says its questions are the whole of the crate; `pids` is one now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- toyos-proclife/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toyos-proclife/src/lib.rs b/toyos-proclife/src/lib.rs index ce8977ce966..08c5f89c6ab 100644 --- a/toyos-proclife/src/lib.rs +++ b/toyos-proclife/src/lib.rs @@ -4,7 +4,8 @@ //! crate: a thread is [`Scheduled`] or it is a [`Zombie`] with a code, and a //! process is either being torn down by somebody or it is not — and, in //! [`tree`], where a process stands under the others, which its end takes -//! down with it and when that end is published. What is hard +//! down with it and when that end is published, and in [`pids`], which pid it +//! has. What is hard //! about the subject is not either state — it is that **two CPUs are inside one //! process's lifecycle at once**, and the defects it has are the interleavings //! rather than the arithmetic. A spawn builds a thread's TLS block, its kernel From d69b80a75d28eae8c37ca4bbcba1c7db409bfcfc Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 09:47:12 +0200 Subject: [PATCH 09/16] kernelkeys: the process table's own HashMap is declared, keyed by kernel-issued pids `ProcessTable` holds its entries in a `HashMap` of its own now, where the `IdMap` it replaced was declared in `id_map.rs`. `every_hashed_kernel_container_is_declared_with_a_kernel_minted_key` was red on it in `cargo run -- --ci host` (exit 1, "kernel/src/process.rs holds [\"HashMap\"] and src/kernelkeys.rs declares no hashed container there"). The trace the row owes: `ProcessTable::insert` keys by the entry's pid, which is its `ProcessObject`'s. The two `ProcessObject::new` calls take that pid from `toyos_proclife::Pids::take`: `loader::spawn` from its admission (`tree::admit_child`), and `sched::kthread::spawn` directly. No caller chooses one; a pid given back is one `take` issued. `cargo test --lib kernelkeys`: exit 0, 4 passed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- src/kernelkeys.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/kernelkeys.rs b/src/kernelkeys.rs index 6181def2267..2da0203b72a 100644 --- a/src/kernelkeys.rs +++ b/src/kernelkeys.rs @@ -58,6 +58,12 @@ pub const DECLARED: &[Declared] = &[ ty: "HashMap>", keys: "`process::Pid`, minted by the process table", }, + Declared { + file: "kernel/src/process.rs", + ty: "HashMap", + keys: "the pid of the entry's `ProcessObject`, which only `toyos_proclife::Pids::take` issues, \ + at a spawn's admission or a kernel thread's start", + }, ]; /// The one file the scan does not read, by exact path: it *defines* the alias From d62803b0af1e25c9bda42d8e78a7662b0e47ae2c Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 10:10:02 +0200 Subject: [PATCH 10/16] abuse_handle_table closes what dup2 answered, not the bare slot index The dup2 arm filled every slot from 3 up and then closed `RawHandle(slot)`, on the premise that each of those slots was still at generation 0. The two endowment arms added before it (a `self` label refused, MAX_ENDOWMENTS entries refused and one fewer started) open and close pipe ends in exactly those slots, so the slots dup2 fills are past generation 0: dup2 answers the slot's own generation (`HandleTable::install_at`), the bare index named an earlier one, and the first close was a handle fault, exit 139. The arm now keeps each handle dup2 answers and closes those. Measured under QEMU, `cargo test --test toyos-build -- abuse_handle_table`: exit 1 at d69b80a75 (the guest's exit 139, "handle fault: ... syscall=10 a handle closed at an earlier generation"), exit 0 with this commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- .../src/bin/abuse_handle_table.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs index f45ce85a512..82535741f68 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs @@ -136,11 +136,15 @@ fn main() { // dup2 picks the slot, so it never went through the allocating path that // carried the cap. + let mut filled = Vec::new(); let mut refused = None; for n in 3..40_000u16 { - if let Err(e) = syscall::dup2(RawHandle(1), n) { - refused = Some((n, e)); - break; + match syscall::dup2(RawHandle(1), n) { + Ok(handle) => filled.push(handle), + Err(e) => { + refused = Some((n, e)); + break; + } } } let (n, e) = refused.expect("dup2 must eventually refuse to grow the handle table"); @@ -150,10 +154,10 @@ fn main() { "handle table reached {n} slots, past the {MAX_HANDLES} cap" ); - // The cap is a live limit, not a latched failure. Every slot below `n` is - // at generation 0, so its handle is the bare slot index. - for slot in 3..n { - syscall::close(RawHandle(u32::from(slot))); + // The cap is a live limit, not a latched failure. A slot the arms above + // closed is past generation 0, so each is closed by the handle dup2 answered. + for handle in filled { + syscall::close(handle); } let reused = syscall::dup2(RawHandle(1), 3) .expect("dup2 must work again after closing handles"); From 0ee4b444f508ee1ac7de2fa250a54f53fe9b1608 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 10:27:22 +0200 Subject: [PATCH 11/16] spawn_lands_claimed: a guest test reaches the kernel's claimed-as-it-lands path The round-2 review's first BLOCKER: nothing booted reaches the path where a spawn's place is claimed between the spawn's commit and its landing, so the kernel's half of it (`Admission::land` mapping the answered threads to their scheduler records, and the loader posting those retires) could be deleted with every test green. The decision is `toyos_proclife::tree::land_child`'s and is host-tested; the kernel performing it was not. That window is the loader's own and no caller can order a kill inside it, so a test actuator does: `debug_action::KILL_PLACE_AS_SPAWN_LANDS` (22, never assigned) marks the caller's next spawn, and on the `test-actuators` kernel the loader kills that spawn's place after the commit and before the landing. The kill is `kill_process`'s own body, now `process::kill(pid)`, and the landing is the shipped one. `spawn_lands_claimed` (an `ACTUATOR_TESTS` member, so it rides the shared boot on the kernel that carries `SYS_DEBUG`): the test starts a place that hands it its `self`, marks its next spawn, and spawns a child that parks under that place. The spawn answers the child; a second spawn under the place answers `Gone`, which is the kill having happened; the wait on the child and the wait on the place both answer 137. A child claimed and never retired parks on, and holds its place unpublished, so neither wait answers. Measured under QEMU: `cargo test --test toyos-build -- spawn_lands_claimed` exit 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- kernel/src/loader/mod.rs | 3 + kernel/src/process.rs | 28 ++++- kernel/src/syscall/dispatch.rs | 4 + .../src/bin/spawn_lands_claimed.rs | 112 ++++++++++++++++++ tests/toyos.rs | 3 + toyos-abi/src/syscall.rs | 5 + 6 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tests/toyos-rust-tests/src/bin/spawn_lands_claimed.rs diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index 547d7171547..f086cfb3769 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -645,6 +645,9 @@ pub fn spawn( // One table, two holders: cloned so a crash report on this thread reads names without the process table. let syms = Arc::new(syms); + #[cfg(feature = "test-actuators")] + crate::process::debug_kill_marked_place(parent); + let mut guard = PROCESS_TABLE.lock(); let ((tid, dst), retire) = admission.land(guard.as_mut().unwrap(), |table, node| { table.insert(ProcessEntry::new( diff --git a/kernel/src/process.rs b/kernel/src/process.rs index 62a7be96e98..45576943124 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -1715,12 +1715,36 @@ fn with_current_symbols(f: impl FnOnce(&crate::symbols::SymbolTable) -> bool) -> /// The handle is the whole authorization, not the parent relationship: a `Process` handle carrying `Rights::MANAGE` says who may, and it can be narrowed away or handed on. `Ok` for an already-gone process: the caller asked for it to be dead and it is. /// Returns once every retire is posted, never waiting on a victim: one may be killing this caller. The object's exit is published once every end below it is. pub fn kill_process(object: &crate::object::process::ProcessObject) -> u64 { + kill(object.pid()); + 0 +} + +fn kill(pid: Pid) { let mut owed = Vec::new(); - for sched in claim(object.pid(), KILLED_EXIT_CODE, None, &mut owed) { + for sched in claim(pid, KILLED_EXIT_CODE, None, &mut owed) { scheduler::post_retire(&sched); } walk(owed); - 0 +} + +/// The process whose next spawn `debug_action::KILL_PLACE_AS_SPAWN_LANDS` marked; `Pid::MAX`, which is never issued, for none. +#[cfg(feature = "test-actuators")] +static MARKED_SPAWNER: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(Pid::MAX.0); + +/// Mark the calling process's next spawn that reaches its commit. +#[cfg(feature = "test-actuators")] +pub fn debug_mark_spawn() { + MARKED_SPAWNER.store(current_process().0, core::sync::atomic::Ordering::Relaxed); +} + +/// Kill the place of a spawn its caller marked, and take the mark. The loader calls it between the spawn's commit and its landing. +#[cfg(feature = "test-actuators")] +pub fn debug_kill_marked_place(parent: Parent) { + use core::sync::atomic::Ordering::Relaxed; + let Parent::Under(place) = parent else { return }; + if MARKED_SPAWNER.compare_exchange(current_process().0, Pid::MAX.0, Relaxed, Relaxed).is_ok() { + kill(place); + } } /// The shell convention for "died on SIGKILL"; kept because every test that reads one already spells it. diff --git a/kernel/src/syscall/dispatch.rs b/kernel/src/syscall/dispatch.rs index add82fb6ff9..6983dc7135c 100644 --- a/kernel/src/syscall/dispatch.rs +++ b/kernel/src/syscall/dispatch.rs @@ -631,6 +631,10 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> crate::log::storm::emit_patterned(0, a2); 0 } + DA::KILL_PLACE_AS_SPAWN_LANDS => { + process::debug_mark_spawn(); + 0 + } _ => SyscallError::InvalidArgument.to_u64(), }, SYS_SCHED_INFO => match ctx.copy_out(UserAddr::new(a1), &sys_sched_info()) { diff --git a/tests/toyos-rust-tests/src/bin/spawn_lands_claimed.rs b/tests/toyos-rust-tests/src/bin/spawn_lands_claimed.rs new file mode 100644 index 00000000000..7cdd26b0aa5 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/spawn_lands_claimed.rs @@ -0,0 +1,112 @@ +//! A spawn whose place is claimed between its commit and its landing lands +//! claimed: the spawn answers its child, the spawner retires it, and the place +//! is published once the child is. +//! +//! That window is the loader's own, so no caller can order a kill inside it: +//! `debug_action::KILL_PLACE_AS_SPAWN_LANDS` has the kernel kill the place of +//! this process's next spawn there. The child parks, so only the retire its +//! spawner posts ends it, and the place's end is published only after the +//! child's: a child claimed and never retired leaves both waits unanswered. +//! +//! Every wait is unbounded: the runner's deadline is the only clock. + +use std::os::toyos::process::CommandExt; +use std::process::{Command, Stdio}; + +use toyos::endow::{self, SVC_LABEL}; +use toyos::process::Process; +use toyos::{namespace, port, AsHandle}; +use toyos_abi::syscall::{self, debug_action, SpawnArgs, SyscallError}; +use toyos_abi::RawHandle; + +const SELF_PATH: &str = "/system/bin/test_rs_spawn_lands_claimed"; + +/// The name the place's namespace carries the port back to the test under. +const BACK: &str = "back"; + +/// The place to the test: a copy of its own `self`. +const MSG_SELF: u32 = 1; + +/// `process::KILLED_EXIT_CODE`. +const KILLED: i32 = 137; + +fn main() { + match std::env::args().nth(1).as_deref() { + Some("place") => place(), + Some("child") => park(), + Some(other) => panic!("unknown role {other:?}"), + None => test(), + } +} + +fn test() { + let (acceptor, connector) = port::create().expect("a port of our own"); + let ns = namespace::build().add(BACK, &connector).finish().expect("a namespace carrying the port back"); + let mut place = Command::new(SELF_PATH) + .arg("place") + .endow(SVC_LABEL, ns.into_raw().0) + .stdin(Stdio::null()) + .spawn() + .expect("spawn the place"); + let conn = acceptor.accept().expect("the place connected back"); + let header = conn.recv_header().expect("the place's word"); + assert_eq!(header.msg_type, MSG_SELF, "the place said something else"); + let [place_self] = conn.recv_handles_exact::<1>().expect("the place sent its self"); + assert!(place.try_wait().expect("ask after the place").is_none(), "the place ended before the marked spawn"); + + assert_eq!( + syscall::debug(debug_action::KILL_PLACE_AS_SPAWN_LANDS), + 0, + "this kernel does not carry SYS_DEBUG, so nothing kills the place" + ); + let child = spawn_under(place_self).expect("a spawn past its commit lands, whatever became of its place"); + match spawn_under(place_self) { + Err(SyscallError::Gone) => {} + Err(other) => panic!("a second spawn under the place answered {other:?}, not Gone"), + Ok(_) => panic!("the kernel did not kill the place of the marked spawn"), + } + assert_eq!(child.wait(), Ok(KILLED), "the child that landed under a claimed place was not ended, as killed"); + let status = place.wait().expect("wait for the place"); + assert_eq!(status.code(), Some(KILLED), "the place was not ended, as killed"); + syscall::close(place_self); + println!("spawn_lands_claimed: the child landed claimed and was retired, and its place was published after it"); +} + +/// `SYS_SPAWN` of this binary as a child under `place`. +fn spawn_under(place: RawHandle) -> Result { + let argv = format!("{SELF_PATH}\0child"); + let args = SpawnArgs { + argv_ptr: argv.as_ptr() as u64, + argv_len: argv.len() as u64, + slot_map_ptr: 0, + slot_map_count: 0, + env_ptr: 0, + env_len: 0, + endow_ptr: 0, + endow_count: 0, + labels_ptr: 0, + labels_len: 0, + cwd_ptr: "/".as_ptr() as u64, + cwd_len: 1, + image: 0, + image_len: 0, + place: u64::from(place.0), + }; + // SAFETY: every pointer names a buffer of this frame that outlives the call. + let child = unsafe { syscall::spawn(&args) }?; + // SAFETY: the kernel installed it for this call. + Ok(unsafe { Process::from_raw(child) }) +} + +fn place() -> ! { + let conn = endow::service(BACK).expect("the test endowed a port back"); + let own = syscall::dup(endow::this_process().as_handle()).expect("a copy of this process's self"); + conn.send_bytes_with_handles(&[own], MSG_SELF, &[]).expect("send the test this process's self"); + park() +} + +fn park() -> ! { + loop { + std::thread::park(); + } +} diff --git a/tests/toyos.rs b/tests/toyos.rs index efc0edf00e8..50b5c74b29f 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -122,6 +122,9 @@ const ACTUATOR_TESTS: &[&str] = &[ // memory, which every other binary in a shared boot moves under it. "handle_lifetime", "shm_release_reclaims", + // Action 22: the kernel kills a spawn's place between the spawn's commit + // and its landing, a window no caller can order a kill inside. + "spawn_lands_claimed", ]; /// What [`ACTUATOR_TESTS`] boots: the one kernel that carries `SYS_DEBUG`, with diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 4493d6b9a22..905cc33be66 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -882,6 +882,11 @@ pub mod debug_action { /// Emit one patterned kernel log record, `logstorm t=0 i= …`, whose /// text the reader regenerates from its two numbers. pub const LOG_PATTERNED: u64 = 21; + /// Mark the caller's next spawn that reaches its commit: the kernel kills + /// the process it is placed under after the commit and before the child + /// lands. That window is the loader's own, so no caller can order a kill + /// inside it; the kill and the landing that follow are the shipped paths. + pub const KILL_PLACE_AS_SPAWN_LANDS: u64 = 22; } /// Every kind of kernel object, in the order the kernel's own `kobject!` From ad21ebf3a7f8cf9c430faa269b68082262e7c72d Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 10:39:23 +0200 Subject: [PATCH 12/16] pkg_launch_gbae asks init to be gbae's parent `pkg_install_gbae`'s client launched gbae and exited at once, on the premise that a launched program outlives the program that started it. Under this branch a launch's child is its launcher's, so gbae ended (137) with the client and the compositor never counted its window. The client now launches with `CommandExt::under_init`, the one way to outlive a starter. The launch still goes through init's launcher and the `/apps` row, so the test's subject and its refusal arms are as they were. Measured under QEMU, `cargo test --test toyos-build -- pkg_install_gbae`: exit 1 at d62803b0a ("gbae started and the compositor never counted a window", `exit: gbae pid=21 code=137`), exit 0 with the launch under init. Whether the shipped launchers (the compositor, the terminal, sshd) should do the same until the track's stage 5 gives a login its session is the owner's question, and nothing of theirs changes here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- tests/toyos-rust-tests/src/bin/pkg_launch_gbae.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/pkg_launch_gbae.rs b/tests/toyos-rust-tests/src/bin/pkg_launch_gbae.rs index ac154f706a9..082ac2fe00c 100644 --- a/tests/toyos-rust-tests/src/bin/pkg_launch_gbae.rs +++ b/tests/toyos-rust-tests/src/bin/pkg_launch_gbae.rs @@ -6,9 +6,11 @@ //! row init built out of `/apps/gbae/manifest.toml`, because inheritance //! carries nothing that would draw one. //! -//! It does not wait: gbae runs until the machine goes down, and the compositor -//! census on the host's side of the serial says the window exists. +//! It does not wait, and a launch's child ends with its launcher, so it asks +//! init to be gbae's parent: gbae runs until the machine goes down, and the +//! compositor census on the host's side of the serial says the window exists. +use std::os::toyos::process::CommandExt; use std::process::Command; const PROGRAM: &str = "/apps/gbae/gbae"; @@ -26,7 +28,7 @@ fn main() -> std::process::ExitCode { // The refusal arm is a first-class outcome and not a panic: the same // binary runs before the package is installed and after it is removed, // where init answering "no" is the assertion. - match Command::new(PROGRAM).spawn() { + match Command::new(PROGRAM).under_init().spawn() { Ok(child) => { println!("pkg-launch: started {PROGRAM} as pid {}", child.id()); std::process::ExitCode::SUCCESS From 1e9cb8c77996eab36a8fb2cbdd4b88d18dd0e9e3 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 11:08:54 +0200 Subject: [PATCH 13/16] issues: pkg_install_gbae's client is recorded in a file of its own The first merge of this round wrote what the restored `pkg_install_gbae` row needs into the guest-suite track's item for it. That track is the orchestrator's, and `issues/README.md` files a new fact as a new file: the track is as main has it again, and `issues/build/pkg-install-gbaes-launch-client-exits-over-a-child-that-ends-with-it.md` carries the fact, its two QEMU exits and the commit that holds the fixed client. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- ...nt-exits-over-a-child-that-ends-with-it.md | 21 +++++++++++++++++++ ...-runs-only-what-no-cheaper-tier-reaches.md | 4 +--- 2 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 issues/build/pkg-install-gbaes-launch-client-exits-over-a-child-that-ends-with-it.md diff --git a/issues/build/pkg-install-gbaes-launch-client-exits-over-a-child-that-ends-with-it.md b/issues/build/pkg-install-gbaes-launch-client-exits-over-a-child-that-ends-with-it.md new file mode 100644 index 00000000000..e9b5b283778 --- /dev/null +++ b/issues/build/pkg-install-gbaes-launch-client-exits-over-a-child-that-ends-with-it.md @@ -0,0 +1,21 @@ +--- +status: open +kind: tooling +opened: 2026-10-02 +--- + +# pkg_install_gbae's launch client exits over a child that ends with it + +`issues/build/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md` brings +`pkg_install_gbae` back as a metal row, from `main` before the cut. Its client +there, `pkg_launch_gbae`, launches gbae and exits at once. A launched program +ends with its launcher, so gbae ends as killed before the compositor counts its +window. + +Evidence: under QEMU before the cut, `cargo test --test toyos-build -- +pkg_install_gbae` exited 1 with the kernel's `exit: gbae pid=21 code=137` +beside the client's own exit, and 0 with the client's launch made +`Command::new(PROGRAM).under_init()` (commit ad21ebf3a). + +Exit: the row's client starts gbae under init, and the row is green on the T14. +Owner: the guest-suite track's stage D. diff --git a/issues/build/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md b/issues/build/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md index 201feb22a48..6d5c436518e 100644 --- a/issues/build/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md +++ b/issues/build/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md @@ -122,9 +122,7 @@ and NVMe. Exit: as stage A. clients is restarted, or refused while its claim is held. - metal `home_overwrite_reads_back`, `apps_and_home_are_one_filesystem`, `layout_fresh_boot`: `/home`'s bytes and layout on the disk. -- metal `pkg_install_gbae`: `pkg install` lands a package that runs. Its launch client exits - before the window is counted, and a launched program ends with its launcher, so the client - starts gbae under init (`CommandExt::under_init`). +- metal `pkg_install_gbae`: `pkg install` lands a package that runs. - metal `partition_claim`: a partition claimed as a device, and its refusals. - metal `kernel_log_file`, `log_partition_layout`, `log_partition_identity`: `/log` on the stick and its layout. From 6b483b740410181aef85f3eb98ae9582ae0822ab Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 11:47:46 +0200 Subject: [PATCH 14/16] Pid is no IdKey and adds to nothing No IdMap is keyed by a pid since the process table took its pids from toyos_proclife::Pids, and IdMap's `next + K::ONE` was the only place two pids were added. `impl IdKey for Pid` (kernel/src/id_map.rs) and `impl Add for Pid` (toyos-abi/src/lib.rs) have no user left and go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- kernel/src/id_map.rs | 4 ---- toyos-abi/src/lib.rs | 5 ----- 2 files changed, 9 deletions(-) diff --git a/kernel/src/id_map.rs b/kernel/src/id_map.rs index 8ca5254e9e8..90e03c97230 100644 --- a/kernel/src/id_map.rs +++ b/kernel/src/id_map.rs @@ -14,10 +14,6 @@ pub trait IdKey: Copy + Eq + Hash + Ord + Add { } // No impl for u32/u64/usize: that would let a bare integer key an IdMap. -impl IdKey for toyos_abi::Pid { - const ZERO: Self = Self(0); - const ONE: Self = Self(1); -} impl IdKey for toyos_abi::Tid { const ZERO: Self = Self(0); const ONE: Self = Self(1); diff --git a/toyos-abi/src/lib.rs b/toyos-abi/src/lib.rs index 276eb4dbf1b..ee3b40de068 100644 --- a/toyos-abi/src/lib.rs +++ b/toyos-abi/src/lib.rs @@ -48,11 +48,6 @@ impl core::fmt::Display for Pid { } } -impl core::ops::Add for Pid { - type Output = Self; - fn add(self, rhs: Self) -> Self { Pid(self.0 + rhs.0) } -} - /// A thread ID. Identifies a schedulable entity — goes in run queues. /// Every process has at least one thread (the main thread). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] From 757fe9f3b63b701b87044488940371f0c162055a Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 11:47:46 +0200 Subject: [PATCH 15/16] tests: process_reopen's doc heads process_reopen again, and the proctreecase boot is recorded The merge 54c375ad5 put process_tree's judge between process_reopen and its doc, so the doc headed process_tree and process_reopen had none. process_tree, with its own doc, now stands above it. abuse_handle_table's comment on the closing loop loses the sentence about generations: the loop closes what dup2 answered and says so itself. The record gains the proctreecase boot's three numbers. The harness wrote them judging process_tree over the T14's readback of 1e9cb8c77 (`cargo test --test toyos-build -- --metal --metal-readback process_tree`, exit 0): complete_ms 1153, panel_max_us 3773, panel_us 21279. This branch is what adds that boot, so its record lands here. The judging of abuse_handle_table over the same head's `shared` readback also wrote three `boot.shared` rows, off a boot carrying that one member; they are not this branch's and are left out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- tests/metal/lenovo-20w0003amz.toml | 3 +++ tests/toyos-rust-tests/src/bin/abuse_handle_table.rs | 3 +-- tests/toyos.rs | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/metal/lenovo-20w0003amz.toml b/tests/metal/lenovo-20w0003amz.toml index cbae0141135..501e12cc8cc 100644 --- a/tests/metal/lenovo-20w0003amz.toml +++ b/tests/metal/lenovo-20w0003amz.toml @@ -37,6 +37,9 @@ bios = "N34ET71W (1.71 )" "boot.metaldevicecase.complete_ms" = 1165 "boot.metaldevicecase.panel_max_us" = 3837 "boot.metaldevicecase.panel_us" = 23002 +"boot.proctreecase.complete_ms" = 1153 +"boot.proctreecase.panel_max_us" = 3773 +"boot.proctreecase.panel_us" = 21279 "boot.selftests.complete_ms" = 1172 "boot.selftests.panel_max_us" = 3950 "boot.selftests.panel_us" = 22805 diff --git a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs index 82535741f68..c422ba0ea3c 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs @@ -154,8 +154,7 @@ fn main() { "handle table reached {n} slots, past the {MAX_HANDLES} cap" ); - // The cap is a live limit, not a latched failure. A slot the arms above - // closed is past generation 0, so each is closed by the handle dup2 answered. + // The cap is a live limit, not a latched failure. for handle in filled { syscall::close(handle); } diff --git a/tests/toyos.rs b/tests/toyos.rs index bce9ce4597f..db3d987a60e 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -2236,8 +2236,6 @@ fn pci_cap_selftest(log: &str) -> Result<(), String> { Ok(()) } -/// The kernel reopens init by pid after the last handle to it has gone, and -/// no kernel thread's pid opens. /// `process_tree` passed, and the kernel said the two things the guest cannot /// see: each B's first spawn was refused by the loader, past the admission that /// holds B, and the chain's one depth refusal names `MAX_DEPTH` + 1. @@ -2258,6 +2256,8 @@ fn process_tree(back: &metal::Readback) -> Result<(), String> { Ok(()) } +/// The kernel reopens init by pid after the last handle to it has gone, and +/// no kernel thread's pid opens. fn process_reopen(log: &str) -> Result<(), String> { for control in ["process-reopen:", "process-open-kthread:"] { let Some(verdict) = log.lines().find(|l| l.contains(control)) else { From 517d8c16c1e6c64f3ccb7942fea2f83df3aba3b1 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 11:47:46 +0200 Subject: [PATCH 16/16] issues: a launch past one frame becomes a direct spawn, and the shell says "not found" for every spawn error Both are main's behaviour, found by the third review of #659 and not changed here. std answers `LaunchError::NotSent` with the direct spawn, and the one `encode` refusal std can reach is a request past MAX_FRAME_LEN: that child holds its caller's namespace and not its row. The shell discards the error of every spawn and prints `not found`. The T14's proctreecase boot at 1e9cb8c77 printed it for the depth refusal the kernel recorded beside it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- ...ll-says-not-found-for-every-spawn-error.md | 24 +++++++++++++++ ...ge-for-one-frame-becomes-a-direct-spawn.md | 30 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 issues/diagnostics/the-shell-says-not-found-for-every-spawn-error.md create mode 100644 issues/isolation/a-launch-too-large-for-one-frame-becomes-a-direct-spawn.md diff --git a/issues/diagnostics/the-shell-says-not-found-for-every-spawn-error.md b/issues/diagnostics/the-shell-says-not-found-for-every-spawn-error.md new file mode 100644 index 00000000000..c31367004c5 --- /dev/null +++ b/issues/diagnostics/the-shell-says-not-found-for-every-spawn-error.md @@ -0,0 +1,24 @@ +--- +status: open +kind: defect +opened: 2026-10-02 +--- + +# The shell says "not found" for every spawn error + +`execute_simple` and `execute_pipeline` (`userland/shell/src/main.rs`) match +the error of `Command::spawn` and `Command::status` as `Err(_)` and print +`: not found`. A spawn refused for its depth, one under a place being +torn down and one refused `PermissionDenied` all read to the user as a program +that is not there. + +Evidence: the T14's `proctreecase` boot at 1e9cb8c77. The kernel recorded +`spawn: refused under pid 79 at depth 65, more than 64 below init`, and the +shell at that depth printed `/system/bin/test_rs_process_tree: not found`. +`process_tree`'s chain (`tests/toyos-rust-tests/src/bin/process_tree.rs`) +accepts that line as its stop, and the row's judge reads the kernel's record +for the depth. + +Exit: the shell prints the error the spawn answered, as its `detach` does, and +says `not found` for `ErrorKind::NotFound` alone; `process_tree`'s chain no +longer accepts `: not found` as its stop. Owner: the shell. diff --git a/issues/isolation/a-launch-too-large-for-one-frame-becomes-a-direct-spawn.md b/issues/isolation/a-launch-too-large-for-one-frame-becomes-a-direct-spawn.md new file mode 100644 index 00000000000..69e65c618a0 --- /dev/null +++ b/issues/isolation/a-launch-too-large-for-one-frame-becomes-a-direct-spawn.md @@ -0,0 +1,30 @@ +--- +status: open +kind: defect +opened: 2026-10-02 +--- + +# A launch too large for one frame becomes a direct spawn + +`toyos::launch::launch` (`toyos/src/launch.rs`) answers `LaunchError::NotSent` +when `Launch::encode` refuses the request, and std's `Command::launch` +(`rust/library/std/src/sys/process/toyos.rs`) answers `NotSent` with the direct +spawn. `encode` refuses a request whose header, program, argv, environment, +working directory and connector names do not fit `MAX_FRAME_LEN` +(`toyos/src/ipc.rs`). Its other refusal, too many connectors or slots, std +never reaches: it refuses the connectors itself, and a launch carries stdio +alone. + +So a declared program started with an argv and environment past one frame is +not launched. Its caller spawns it: it holds its caller's namespace and not its +manifest row, a connector the caller `provide`d does not reach it, and it +carries no `HOME` unless its caller named one. Neither side is told. A child +asked of init (`under_init`) is refused `PermissionDenied` instead. + +Evidence: by reading. No test sends a launch past one frame. + +Exit: a launch `encode` refuses answers its caller an error and starts nothing, +or the wire carries every argv and environment `SYS_SPAWN` takes; a test +launching a declared program with an environment past one frame reads that +error, or finds the child holding its row. Owner: the std lane, with +`toyos/src/launch.rs`.