From a8acd2aaa2f914ca97ce826f0e90453681efb135 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 30 Sep 2026 22:50:24 +0200 Subject: [PATCH 1/8] SYS_PROCESS_OPEN goes: 110 is retired, and a pid reaches nothing Stage 0 of issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md, ruled by the owner: the `Process` handle is the only control, so the one call that turned a pid into one is deleted with every name only it reached. - `sys_process_open`, its dispatch arm, `toyos_abi::syscall::process_open` and `SysCap::open_process` go. 110 enters `retired_syscalls!`, so a call of it answers `NotSupported` and the kernel logs "syscall 110 is retired (formerly SYS_PROCESS_OPEN)"; the ABI keeps the number as a comment, as it does 8, 85, 87 and 107. - `Rights::MANAGE` leaves init's `SysCap`: on a `SysCap` it opened a process by pid and nothing else. It stays a `Process` handle's right to kill. - The `kobject!` `sealed`/`reopenable` column goes. `Process` was its one `reopenable` row, there only because 110 could mint a handle after the last one had gone; with nothing left that installs a `Process` object but the spawn that made it, every row retires on its last handle and `HandleEntry` is byte-identical to its form before 1ee9ec9ad. - `process::process_object`, `object::process::reopen_selftest`, `sched::kthread::open_selftest`, the `process-reopen-selftest` actuator, its `process_reopen_selftest` machine test and judge, its metal row, its `FLASHABLE` row and its recorded duration go. The two in-kernel controls measured `process_object`'s answers, and nothing asks that question now. - `process_lifecycle`'s pid arm, the only caller, now asks 110 with the arguments it took (this process's capability and its own pid) and asserts `NotSupported`; `check_process_lifecycle` reads the kernel's retired record. `wait_raw` becomes `raw(num, a1, a2)`, which both raw arms share. - `issues/kernel/the-capability-end-state-is-twelve-answers.md` (questions 3 and 10, and the enforced-rulings paragraph) and `issues/diagnostics/the-kernel-keeps-nothing-it-enumerates.md` (stage 2) no longer argue from 110, and the track's stage 0 is deleted. `git grep` for sys_process_open, process_open, open_process, reopenable, process_object, reopen_selftest, open_selftest, process-reopen, process_reopen and process-open-kthread finds only `toyos-symbols/tests/fixtures/input-test.bin`, whose symbols are frozen test data; `SYS_PROCESS_OPEN` is left only as the retired table's name for 110. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- .../the-kernel-keeps-nothing-it-enumerates.md | 3 +- ...nt-and-a-parent-takes-its-children-down.md | 14 ---- ...-capability-end-state-is-twelve-answers.md | 33 ++++---- kernel/src/actuator.rs | 3 - kernel/src/loader/mod.rs | 1 - kernel/src/main.rs | 12 --- kernel/src/object/handle.rs | 21 ++---- kernel/src/object/mod.rs | 55 ++++---------- kernel/src/object/process.rs | 39 ---------- kernel/src/process.rs | 11 --- kernel/src/sched/kthread.rs | 17 ----- kernel/src/syscall/dispatch.rs | 6 +- kernel/src/syscall/proc.rs | 18 +---- src/metal.rs | 1 - tests/test-durations | 1 - .../src/bin/process_lifecycle.rs | 39 +++++----- tests/toyos.rs | 75 +++++++------------ toyos-abi/src/handle.rs | 2 +- toyos-abi/src/syscall.rs | 27 ++----- toyos/src/syscap.rs | 22 +----- 20 files changed, 103 insertions(+), 297 deletions(-) diff --git a/issues/diagnostics/the-kernel-keeps-nothing-it-enumerates.md b/issues/diagnostics/the-kernel-keeps-nothing-it-enumerates.md index 77313587a3e..ab9fa611618 100644 --- a/issues/diagnostics/the-kernel-keeps-nothing-it-enumerates.md +++ b/issues/diagnostics/the-kernel-keeps-nothing-it-enumerates.md @@ -19,8 +19,7 @@ On top of retention, in dependency order: `Process` handle narrowed to `Rights::READ`. `SYS_PROCESS_STATS` takes a `Process` handle and a handle is the whole of the right, so nothing hands a diagnostic tool a way to sample a daemon: `/system/bin/init` holds the only `Process` - handles for what `[boot] start` names and the only `SysCap` carrying - `Rights::MANAGE`, which is what `SYS_PROCESS_OPEN` takes. So "where is + handles for what `[boot] start` names. So "where is soundd's / the compositor's / netd's time going?" is unanswerable from a shell, and `audio_idle_suspend` name-matches `SYS_SYSINFO` entries out of a byte buffer to sample a running daemon twice. Nothing in the kernel has to 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..b3936e2fc08 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 @@ -40,20 +40,6 @@ directory is `issues/isolation/every-program-sees-only-the-files-it-was-given.md ## Stages -0. **`SYS_PROCESS_OPEN` goes** (ruled). Deleted: `sys_process_open` and every - name only it reaches — `process_open`, `SysCap::open_process`, `MANAGE` in - init's `SysCap`, the `reopenable` column, `process::process_object`, - `reopen_selftest` and `sched::kthread::open_selftest` with their actuator, - guest test, judge and rows — and - `process_lifecycle`'s pid-open arm, its only caller; 110 enters - `retired_syscalls!`. `issues/kernel/the-capability-end-state-is-twelve-answers.md` - and `issues/diagnostics/the-kernel-keeps-nothing-it-enumerates.md`, which - argue from it, change in the same landing. *Exit*: a call of 110 answers - `NotSupported` and the log names it retired; `git grep` finds no deleted - name outside `toyos-symbols/tests/fixtures/input-test.bin`, a frozen binary - whose symbols are test data. Negative control: the stage reverted whole, - where `sys_process_open` answers 110. Oracle: rustc's name resolution, - which fails the build of any caller left. 1. **An end is an event** (ruled). `read_watch` and `has_data` answer for a `Process`, whose watch becomes an `Arc` as an `Acceptor`'s is, and `close_ends_polls` answers `false` for one; init's waiter threads go. diff --git a/issues/kernel/the-capability-end-state-is-twelve-answers.md b/issues/kernel/the-capability-end-state-is-twelve-answers.md index faf60ebea39..d9f9e767024 100644 --- a/issues/kernel/the-capability-end-state-is-twelve-answers.md +++ b/issues/kernel/the-capability-end-state-is-twelve-answers.md @@ -32,13 +32,10 @@ taken, not a queue waiting on anybody. Four of the rulings are enforced in code, each at a site that demands a right where none was demanded before: `SYS_SHUTDOWN` takes a `SysCap` carrying -`Rights::POWER` (`kernel/src/syscall/machine.rs:46-48`); `SYS_SYSINFO`'s +`Rights::POWER` (`kernel/src/syscall/machine.rs:46-48`), and `SYS_SYSINFO`'s roster takes `Rights::ROSTER`, demanded only once the buffer has room for an entry (`kernel/src/syscall/machine.rs:84`, `:94`), spelled `roster` in -`toyos-manifest/src/lib.rs:80`; and `SYS_PROCESS_OPEN` takes `Rights::MANAGE` -(`kernel/src/syscall/proc.rs:89-91`), which is what makes question 3's -"a pid is not authority" checkable — the ABI says so at the field -(`toyos-abi/src/syscall.rs:1882-1884`). +`toyos-manifest/src/lib.rs:80`. **Every `4a98107f^:kernel/src/arch/syscall.rs` citation below points into history**: that one-file syscall layer is what `4a98107f` split, and the @@ -100,20 +97,17 @@ closed. ## 3. Are PIDs and TIDs identity-only, or can naming one confer authority? — COMMITTED -Identity-only, with one named exception that is itself gated. Every arm taking a -pid: `SYS_GETPID` answers the caller's own -(`4a98107f^:kernel/src/arch/syscall.rs:490`), and `SYS_PROCESS_OPEN` turns a pid into a -`Process` handle only when the caller also presents a `SysCap` carrying -`Rights::MANAGE` (`:1602`), which the kernel mints once, for `/system/bin/init` -(`kernel/src/loader/mod.rs:938`). `ProcessStats.pid` says so at the field: "Not -authority — nothing takes a pid but `SYS_PROCESS_OPEN`, which takes a `SysCap` -beside it" (`toyos-abi/src/syscall.rs:1803`). Tids are process-local names: +Identity-only. No arm takes a pid, and `SYS_GETPID` answers the caller's own +(`4a98107f^:kernel/src/arch/syscall.rs:490`). `ProcessStats.pid` says so at the +field: "Not authority — no syscall takes a pid" (`toyos-abi/src/syscall.rs`'s +`ProcessStats`). Tids are process-local names: `SYS_THREAD_JOIN` resolves through `thread_sched(caller, tid)` and `collect_thread_zombie(table, tid, parent_pid)`, both keyed on the caller's own pid (`4a98107f^:kernel/src/arch/syscall.rs:2393`, `kernel/src/process.rs:1412`, `:848`). -Four pid-addressed syscalls were deleted and their numbers retired rather than +Five pid-addressed syscalls were deleted and their numbers retired rather than reused — 26 `SYS_WAITPID`, 33 `SYS_FIND_PID`, 37 `SYS_GRANT_SHARED`, 65 -`SYS_KILL` (`4a98107f^:kernel/src/arch/syscall.rs:63`). +`SYS_KILL` (`4a98107f^:kernel/src/arch/syscall.rs:63`), and 110 +`SYS_PROCESS_OPEN` (`kernel/src/syscall/dispatch.rs`'s `retired_syscalls!`). ## 4. Can a process enumerate objects it lacks authority over? — RULED 2026-08-20, IMPLEMENTED 2026-08-22 @@ -291,11 +285,10 @@ provably no longer holds it (`kernel/src/object/ops.rs:47`). Every device-driving syscall then presents that handle and the kernel checks the *class*, not merely the type: "a process holding the NIC has no more business setting the resolution than one holding nothing" -(`4a98107f^:kernel/src/arch/syscall.rs:895`). `SYS_RT_ENTER` and `SYS_PROCESS_OPEN` are -the same shape on `Rights::RT` and `Rights::MANAGE` (`:1655`, `:1602`), narrowed -per program by `toyos_manifest::syscap_rights` -(`toyos-manifest/src/lib.rs:73`) — `system.toml` grants exactly two, `logread` -to `logd` and `rt` to `soundd`. +(`4a98107f^:kernel/src/arch/syscall.rs:895`). `SYS_RT_ENTER` is the same shape +on `Rights::RT` (`:1655`), narrowed per program by +`toyos_manifest::syscap_rights` (`toyos-manifest/src/lib.rs:73`) — `system.toml` +grants exactly two, `logread` to `logd` and `rt` to `soundd`. One inconsistency, known and unobservable: three arms demand three different rights on the same claim handle — `Rights::WRITE` diff --git a/kernel/src/actuator.rs b/kernel/src/actuator.rs index 89cc9f2dc42..4de9db7e581 100644 --- a/kernel/src/actuator.rs +++ b/kernel/src/actuator.rs @@ -477,9 +477,6 @@ actuators! { /// Run the revoked-backing controls after mount. revoked_backing_selftest = "revoked-backing-selftest"; - /// Reopen init by pid once it is spawned, and open every kernel thread's pid, the way `SYS_PROCESS_OPEN` does. - process_reopen_selftest = "process-reopen-selftest"; - /// Refuse every read of device block 0 of each disk the kernel drives — its /// protective MBR and GPT header — once the boot has read its own tables, /// so a partition claim meets a disk that does not answer a read of its diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index f29c16ca715..a2975ca809e 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -874,7 +874,6 @@ pub fn spawn_init() -> Pid { .union(Rights::TRANSFER) .union(Rights::DEVICE) .union(Rights::RT) - .union(Rights::MANAGE) .union(Rights::LOG) .union(Rights::WAIT) .union(Rights::POWER) diff --git a/kernel/src/main.rs b/kernel/src/main.rs index e91c58580f8..9be7ee2c78c 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -492,12 +492,6 @@ pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! { let pid = process::spawn_init(); log!("spawned {} pid={pid}", process::INIT_PATH); - // Here and not beside the other controls: it needs a process the table answers for. - #[cfg(feature = "boot-actuators")] - if actuator::process_reopen_selftest() { - object::process::reopen_selftest(pid); - } - // The proof the boot up to here needed no disk: ROOT and init's image both // came out of memory. log!("{} {}", rootfs::INIT_WITHOUT_A_DISK, block::census::commands_issued()); @@ -623,12 +617,6 @@ pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! { // Last thing before enter_idle_loop: nothing can run before it, and a klogd spawned earlier would idle through phases 5-7 with no drainer. log::console::start(); - // Here: the last kernel thread is spawned. - #[cfg(feature = "boot-actuators")] - if actuator::process_reopen_selftest() { - sched::kthread::open_selftest(); - } - smp::set_ready(); // After the release, because a shootdown waits on CPUs that are not diff --git a/kernel/src/object/handle.rs b/kernel/src/object/handle.rs index f1d4048689b..4ffe21b7c2f 100644 --- a/kernel/src/object/handle.rs +++ b/kernel/src/object/handle.rs @@ -102,7 +102,7 @@ pub struct HandleEntry { } impl HandleEntry { - /// The only constructor; resurrecting an already-retired object is a kernel bug — which a `reopenable` row never is, since something outside every table still answers for it. + /// The only constructor; resurrecting an already-retired object is a kernel bug. pub fn new(object: KObjectRef, rights: Rights) -> Self { let core = object.core(); assert!( @@ -135,18 +135,13 @@ impl Drop for HandleEntry { fn drop(&mut self) { let core = self.object.core(); if core.handle_count.fetch_sub(1, Ordering::AcqRel) == 1 { - // A `reopenable` object is not retired by its last handle going: the process - // table still answers for it, and `SYS_PROCESS_OPEN` turns a pid — untrusted - // input — back into a handle, which may not assert. - if !self.object.reopenable() { - let first = !core.retired.swap(true, Ordering::AcqRel); - assert!( - first, - "handle_count resurrected after zero on {} (koid {})", - self.object.kind(), - core.koid().raw(), - ); - } + let first = !core.retired.swap(true, Ordering::AcqRel); + assert!( + first, + "handle_count resurrected after zero on {} (koid {})", + self.object.kind(), + core.koid().raw(), + ); // Deferred, not run inline: a release hook must never run under this lock. if self.object.defers_release() { super::enqueue_zero_handles(self.object.clone()); diff --git a/kernel/src/object/mod.rs b/kernel/src/object/mod.rs index 92dcb65bc27..e530fe80c0d 100644 --- a/kernel/src/object/mod.rs +++ b/kernel/src/object/mod.rs @@ -78,8 +78,7 @@ pub struct ObjectCore { koid: Koid, /// Table slots, in-flight transfers and spawn endowments — never the `Arc` strong count. handle_count: AtomicU32, - /// A `sealed` row only: set once when its last handle goes, and a second arrival - /// is a kernel bug caught by `HandleEntry`'s drop assert. Never set on a `reopenable` row. + /// Set once; a second arrival is a kernel bug caught by `HandleEntry`'s drop assert. retired: AtomicBool, /// This type's census counter; decremented by `ObjectCore`'s own drop. live: &'static AtomicU64, @@ -129,10 +128,9 @@ pub trait KObjectVariant: ZeroHandles + Send + Sync + Sized + 'static { } /// Declares the closed set of object types; each row says whether its last -/// handle is deferred ([`ZeroHandles`]) or immediate, and whether losing that -/// handle is the object's last name (`sealed`) or not (`reopenable`). +/// handle is deferred ([`ZeroHandles`]) or immediate. macro_rules! kobject { - ($($kind:ident $naming:ident $variant:ident => $ty:ty),+ $(,)?) => { + ($($kind:ident $variant:ident => $ty:ty),+ $(,)?) => { /// Every kind of thing a handle can name; matched exhaustively with no /// wildcard arm, so a new row is a compile error at every dispatch site. #[derive(Clone)] @@ -166,17 +164,9 @@ macro_rules! kobject { $(Self::$variant(_) => kobject!(@defers $kind),)+ } } - - /// Whether something outside every handle table answers for this object. - fn reopenable(&self) -> bool { - match self { - $(Self::$variant(_) => kobject!(@reopen $naming),)+ - } - } } $( - kobject!(@pair $kind $naming); kobject!(@empty_hook $kind $ty); impl KObjectVariant for $ty { @@ -224,19 +214,6 @@ macro_rules! kobject { (@defers deferred) => { true }; (@defers immediate) => { false }; - (@reopen sealed) => { false }; - (@reopen reopenable) => { true }; - - (@pair deferred sealed) => {}; - (@pair immediate sealed) => {}; - (@pair immediate reopenable) => {}; - (@pair deferred reopenable) => { - compile_error!( - "a `deferred reopenable` row would run its zero-handle hook once per \ - zero-crossing, and `ZeroHandles::on_zero_handles` runs exactly once" - ); - }; - (@empty_hook deferred $ty:ty) => {}; (@empty_hook immediate $ty:ty) => { impl ZeroHandles for $ty { @@ -246,26 +223,26 @@ macro_rules! kobject { } kobject! { - deferred sealed PipeRead => pipe::PipeReadEnd, - deferred sealed PipeWrite => pipe::PipeWriteEnd, - deferred sealed Connection => service::ConnectionEnd, - deferred sealed Device => device::DeviceClaim, - deferred sealed Acceptor => port::Acceptor, + deferred PipeRead => pipe::PipeReadEnd, + deferred PipeWrite => pipe::PipeWriteEnd, + deferred Connection => service::ConnectionEnd, + deferred Device => device::DeviceClaim, + deferred Acceptor => port::Acceptor, // Kind name is the ABI's; `CENSUS_KIND` asserts the two lists agree. - deferred sealed Inbox => inbox::InboxObject, + deferred Inbox => inbox::InboxObject, // The flush that makes releasing it safe waits for every CPU, so it runs from the queue, never inline. - deferred sealed SharedMem => shm::SharedMemObject, + deferred SharedMem => shm::SharedMemObject, // A service with no clients right now is not a service that has stopped. - immediate sealed Connector => port::Connector, + immediate Connector => port::Connector, // Immutable once built: its `Arc`s go with the last reference and nothing observes it. - immediate sealed Namespace => namespace::Namespace, + immediate Namespace => namespace::Namespace, // A file's flush and cache reference ride the last `Arc`; `read`/`write` on a file never park. - immediate sealed File => file::FileObject, - immediate sealed Console => device::ConsoleObject, + immediate File => file::FileObject, + immediate Console => device::ConsoleObject, // The authority is the rights on the handle; a handle going away *is* the whole event. - immediate sealed SysCap => syscap::SysCap, + immediate SysCap => syscap::SysCap, // The last handle's loss is the loss of the *ability to wait*, not proof the process should stop. - immediate reopenable Process => process::ProcessObject, + immediate Process => process::ProcessObject, } /// Objects whose last handle has gone, waiting for release with nothing held; diff --git a/kernel/src/object/process.rs b/kernel/src/object/process.rs index d8438b6ca7d..3c454160f05 100644 --- a/kernel/src/object/process.rs +++ b/kernel/src/object/process.rs @@ -85,42 +85,3 @@ impl ProcessObject { } } -/// Control for the `reopenable` row: a process the table still answers for takes -/// a fresh handle after its last one has gone. `sys_process_open`'s own two steps -/// with the `SysCap` demand left off; the second install is where it used to assert. -#[cfg(feature = "boot-actuators")] -pub(crate) fn reopen_selftest(pid: Pid) { - use super::handle::HandleTable; - use super::{ops, KObjectRef}; - use toyos_abi::handle::Rights; - - let Some(object) = crate::process::process_object(pid) else { - crate::log!("process-reopen: FAIL (pid {} names no process)", pid.raw()); - return; - }; - let mut table = HandleTable::new(); - let opened = match ops::install(&mut table, KObjectRef::Process(Arc::clone(&object))) { - Ok(h) => h, - Err(e) => { - crate::log!("process-reopen: FAIL (the first install was refused: {e:?})"); - return; - } - }; - match table.remove(opened) { - Ok(entry) => drop(entry), - Err(e) => { - crate::log!("process-reopen: FAIL (the first handle would not close: {e})"); - return; - } - } - let retired = object.core().retired(); - let reopened = ops::install(&mut table, KObjectRef::Process(Arc::clone(&object))) - .ok() - .and_then(|h| table.get::(h, Rights::WAIT).ok()) - .is_some_and(|reached| reached.pid() == pid); - let verdict = if reopened && !retired { "PASS" } else { "FAIL" }; - crate::log!( - "process-reopen: {verdict} (pid={} retired={retired} reopened={reopened})", - pid.raw(), - ); -} diff --git a/kernel/src/process.rs b/kernel/src/process.rs index c7ebfbdc078..147467f9126 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -709,17 +709,6 @@ pub fn try_for_each_thread(mut f: impl FnMut(ThreadCensus<'_>)) -> bool { true } -/// The object a handle to `pid` would name, for a process still in the table. -/// A kernel thread's pid names none: it has no Ring 3 boundary a kill could end it at. -pub fn process_object(pid: Pid) -> Option> { - let guard = PROCESS_TABLE.lock(); - let proc = guard.as_ref()?.get(pid)?; - if crate::sched::kthread::is_kernel_task(TaskId(pid, proc.main_tid())) { - return None; - } - Some(Arc::clone(proc.object())) -} - /// Accounting for a process. `None` only in the window between a live process and its published exit (the process being torn down right now). pub fn stats_of( object: &crate::object::process::ProcessObject, diff --git a/kernel/src/sched/kthread.rs b/kernel/src/sched/kthread.rs index 9f2eacb499f..41fd079fe9d 100644 --- a/kernel/src/sched/kthread.rs +++ b/kernel/src/sched/kthread.rs @@ -72,23 +72,6 @@ pub fn is_kernel_task(id: TaskId) -> bool { ROWS.iter().any(|row| row.load(Ordering::Relaxed) == packed) } -/// Control for `process::process_object`'s refusal: no kernel thread's pid names a process a handle could hold. -#[cfg(feature = "boot-actuators")] -pub fn open_selftest() { - let pids: Vec<_> = ROWS - .iter() - .map(|row| row.load(Ordering::Relaxed)) - .filter(|&task| task != NO_TASK) - .map(|task| { - assert_ne!(task, CLAIMING, "kthread: a row is still being claimed after the last spawn"); - TaskId::unpack(task).0 - }) - .collect(); - let opened = pids.iter().filter(|&&pid| crate::process::process_object(pid).is_some()).count(); - let verdict = if !pids.is_empty() && opened == 0 { "PASS" } else { "FAIL" }; - crate::log!("process-open-kthread: {verdict} ({} kernel threads, {opened} opened)", pids.len()); -} - /// Start a kernel thread running `body(arg)` on its own kernel stack and return its scheduler faces. pub fn spawn(name: &str, body: extern "C" fn(u64) -> !, arg: u64) -> ThreadSched { let (stack, entry_sp) = crate::loader::alloc_kernel_stack( diff --git a/kernel/src/syscall/dispatch.rs b/kernel/src/syscall/dispatch.rs index f3a5ed190e2..db3931b7cc8 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, + sys_endowments, sys_exit, sys_nanosleep, 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}; @@ -95,6 +95,7 @@ retired_syscalls! { 85 => "SYS_LISTEN", 87 => "SYS_CONNECT", 96 => "SYS_SET_RT_PRIORITY", + 110 => "SYS_PROCESS_OPEN", } /// `sched-operation-nesting`'s task half and `sysret-ss-probe`, run once a boot @@ -274,9 +275,6 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> Err(e) => e.refuse(), } } - SYS_PROCESS_OPEN => { - sys_process_open(RawHandle(a1 as u32), process::Pid::from_raw(a2 as u32)) - } // No right: the one caller marks both ends of a pair, so requiring one would refuse the other. SYS_MARK_TTY => with_object(RawHandle(a1 as u32), Rights::NONE, ops::mark_tty), diff --git a/kernel/src/syscall/proc.rs b/kernel/src/syscall/proc.rs index ec676756172..3c2ee148c73 100644 --- a/kernel/src/syscall/proc.rs +++ b/kernel/src/syscall/proc.rs @@ -1,7 +1,6 @@ //! Process and thread syscalls: spawn, wait, exit, and self-ops on a thread. //! -//! A handle is the authority over a process; a pid alone is not. Only -//! `sys_process_open` mints a handle from a pid, gated on a `SysCap`. +//! A handle is the authority over a process; a pid alone is not. //! //! The parking calls clone what they wait on out of the table before //! blocking, so no guard is held across a park. @@ -19,7 +18,7 @@ use toyos_abi::syscall::*; use toyos_sched::task::WaitClass; use super::cancelled; -use super::handles::{demand_syscap, handle_result}; +use super::handles::demand_syscap; pub(super) fn sys_thread_exit(code: i32) -> u64 { process::thread_exit(code); @@ -93,19 +92,6 @@ pub(super) fn sys_process_wait(h: RawHandle, flags: u64) -> u64 { } } -/// Mint a `Process` handle for a pid, gated on a `SysCap` carrying [`Rights::MANAGE`]. -pub(super) fn sys_process_open(syscap: RawHandle, pid: process::Pid) -> u64 { - if let Err(e) = demand_syscap(syscap, Rights::MANAGE) { - return e.refuse(); - } - let Some(object) = process::process_object(pid) else { - return SyscallError::NotFound.to_u64(); - }; - process::with_process_data(|data| { - handle_result(ops::install(&mut data.handles, KObjectRef::Process(object))) - }) -} - /// Enter the real-time band, gated on a `SysCap` carrying [`Rights::RT`]. pub(super) fn sys_rt_enter(syscap: RawHandle) -> u64 { // Gated by manifest, not audio ownership: winning the sound-card race would grant the band too. diff --git a/src/metal.rs b/src/metal.rs index 54a0bc8bded..94e11b833be 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -760,7 +760,6 @@ pub const FLASHABLE: &[(&str, Flash)] = &[ // the boot goes on to userland and ends the way an unarmed one does; what // an armed image leaves behind is a longer log. ("pci-cap-selftest", Flash::Ok), - ("process-reopen-selftest", Flash::Ok), ("revoked-backing-selftest", Flash::Ok), ("leak-rollback-selftest", Flash::Ok), ("lapic-spurious-selftest", Flash::Ok), diff --git a/tests/test-durations b/tests/test-durations index e92b25a72e1..7fb01811377 100644 --- a/tests/test-durations +++ b/tests/test-durations @@ -292,7 +292,6 @@ poller_capacity 36 port_poll_churn 84 pre_idle_wedge_speaks 3688 process_lifecycle 225 -process_reopen_selftest 5800 process_stats 221 query_modules_size 12 query_pci_agreement 7182 diff --git a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs index f47f83c593c..4a28748a638 100644 --- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs +++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs @@ -83,7 +83,7 @@ fn an_undefined_wait_flag_bit_is_refused() { assert_eq!(child.wait().expect("wait").code(), Some(5), "the child did not exit"); let handle = RawHandle(child.as_raw_handle()); - let refused = wait_raw(handle, syscall::WNOHANG | UNDEFINED); + let refused = raw(syscall::SYS_PROCESS_WAIT, handle.0 as u64, syscall::WNOHANG | UNDEFINED); assert_eq!( SyscallError::from_u64(refused), Some(SyscallError::InvalidArgument), @@ -93,18 +93,18 @@ fn an_undefined_wait_flag_bit_is_refused() { println!(" an undefined WNOHANG-word bit is InvalidArgument, and without it the code comes back"); } -/// The typed wrapper cannot spell a flag word the ABI does not define, so the -/// argument under test only exists at the raw boundary. -fn wait_raw(handle: RawHandle, flags: u64) -> u64 { +/// Syscall `num` on two arguments, past every typed wrapper: none spells a flag +/// word the ABI does not define, or a retired number. +fn raw(num: u64, a1: u64, a2: u64) -> u64 { let ret: u64; // SAFETY: a register-to-register `syscall`; neither argument is a pointer - // this call dereferences. + // either call dereferences. unsafe { core::arch::asm!( "syscall", - in("rdi") syscall::SYS_PROCESS_WAIT, - in("rsi") handle.0 as u64, - in("rdx") flags, + in("rdi") num, + in("rsi") a1, + in("rdx") a2, in("r8") 0u64, in("r9") 0u64, lateout("rax") ret, @@ -211,8 +211,7 @@ fn a_thread_of_mine_has_exited() -> bool { /// The estate's system capability, taken once. /// /// **Once, because taking is a swap**: a second `take` of the same label finds -/// `HANDLE_INVALID` and answers `None`, and two arms here want the same cap — -/// one for the `MANAGE` refusal, one for the roster below. +/// `HANDLE_INVALID` and answers `None`, and two arms here want the same cap. fn cap() -> &'static SysCap { static CAP: OnceLock = OnceLock::new(); CAP.get_or_init(|| { @@ -272,18 +271,20 @@ fn a_handle_is_the_whole_of_the_right() { println!(" a process that did not start the child waited for it, and so did the one that did"); } -/// A pid is a name everybody can say, and saying it is not a key. The one call -/// that turns one into a handle needs a capability carrying `MANAGE`, and the -/// kernel mints exactly one — `/system/bin/init`'s. The test estate's carries `DEVICE` -/// and `DUP`, which is what makes this refusal non-vacuous: the handle resolves, -/// and it is the right that is missing. +/// A pid is a name everybody can say, and no call takes one. 110 was the call +/// that turned a pid into a handle; it is asked here with the arguments it +/// took, this process's capability and its own pid, so a kernel that still +/// served it answers what it would have. `check_process_lifecycle` reads the +/// kernel's record of the refusal. fn a_pid_is_not_authority() { + const PROCESS_OPEN: u64 = 110; + let answer = raw(PROCESS_OPEN, cap().as_handle().0 as u64, syscall::getpid().0 as u64); assert_eq!( - syscall::process_open(cap().as_handle(), syscall::getpid()), - Err(SyscallError::PermissionDenied), - "a capability without MANAGE opened a process by pid", + SyscallError::from_u64(answer), + Some(SyscallError::NotSupported), + "syscall {PROCESS_OPEN}, retired, answered {answer:#x}", ); - println!(" a pid does not become a handle without MANAGE"); + println!(" syscall {PROCESS_OPEN} is retired: a pid becomes no handle"); } /// A child that exits with `code` when this process says so, and the write end diff --git a/tests/toyos.rs b/tests/toyos.rs index f031b536ad4..f3c589bf93e 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -1017,8 +1017,6 @@ const MACHINE_TESTS: &[(&str, Sched)] = &[ ("driver_wait_refused", Sched::Parallel), // One boot; the leak-rollback controls' two verdict lines. ("leak_rollback_selftest", Sched::Parallel), - // One boot; the reopen control's one verdict line. - ("process_reopen_selftest", Sched::Parallel), // One boot; three read-fault control verdicts. ("read_fault_selftests", Sched::Parallel), ("xhci_many_devices", Sched::Parallel), @@ -1928,7 +1926,7 @@ const METAL: &[(&str, metal::Metal)] = &[ "metal_sim_scanout_wc", metal::Metal::Runs { arms: METALCASE, judge: |b| scanout_wc(b[0].kernel().text()) }, ), - // ---- one image, eleven actuators, ten tests ---- + // ---- one image ---- // The cheapest cluster there is: every one of these arms a check that runs // at init, logs its verdict and does nothing else, so they cost one flash // between them. **Nothing had to be promoted into `kernel/src/params.rs`** @@ -1939,10 +1937,6 @@ const METAL: &[(&str, metal::Metal)] = &[ "pci_capability_walk", metal::Metal::Runs { arms: SELFTESTS, judge: |b| pci_cap_selftest(b[0].kernel().text()) }, ), - ( - "process_reopen_selftest", - metal::Metal::Runs { arms: SELFTESTS, judge: |b| process_reopen(b[0].kernel().text()) }, - ), ( "read_fault_selftests", metal::Metal::Runs { arms: SELFTESTS, judge: |b| read_fault_probes(b[0].kernel().text()) }, @@ -2139,11 +2133,11 @@ const LANSWAPCASE: &[metal::Arm] = &[metal::Arm { /// One boot for every in-kernel self-test that logs its verdict at init and /// does nothing else. /// -/// **Eleven actuators in one image.** They cost the machine one flash between -/// them because none of them changes what the machine *is*: each stages inputs -/// the hardware cannot produce — a crafted capability list, a malformed -/// descriptor, a vector nothing claims — runs a check over them and prints a -/// count. The three that do change the machine are not here: +/// They cost the machine one flash between them because none of them changes +/// what the machine *is*: each stages inputs the hardware cannot produce — a +/// crafted capability list, a malformed descriptor, a vector nothing claims — +/// runs a check over them and prints a count. The three that do change the +/// machine are not here: /// `no-ap-control-regs` leaves an AP without them, `smp-skip-ap` leaves one /// out and `test-tiny-va` shrinks the address space, and each would be /// answering for the boot every other row on it read. @@ -2152,7 +2146,6 @@ const SELFTESTS: &[metal::Arm] = &[metal::once( "tests/testcases", &[ "pci-cap-selftest", - "process-reopen-selftest", "revoked-backing-selftest", "leak-rollback-selftest", "lapic-spurious-selftest", @@ -3166,6 +3159,7 @@ fn check_for(name: &str) -> fn(&TestResult) -> bool { "dlopen_dedup" => check_dlopen_dedup, "abuse_elf_loader" => check_abuse_elf_loader, "exit_wait_storm" => check_exit_wait_storm, + "process_lifecycle" => check_process_lifecycle, _ => check_rust_result, } } @@ -3231,6 +3225,28 @@ fn check_dlopen_dedup(result: &TestResult) -> bool { true } +/// The kernel's record of `process_lifecycle`'s call of the number +/// `SYS_PROCESS_OPEN` had (`kernel/src/syscall/dispatch.rs`'s `retired_syscalls!`). +const PROCESS_OPEN_RETIRED: &str = "syscall 110 is retired (formerly SYS_PROCESS_OPEN)"; + +/// `process_lifecycle` plus the half no guest can see: the kernel refused 110 +/// as retired, not as a number it never had. +fn check_process_lifecycle(result: &TestResult) -> bool { + if !check_rust_result(result) { + return false; + } + let log = format!("{}{}", result.before, result.serial); + if !log.lines().any(|l| l.contains(PROCESS_OPEN_RETIRED)) { + eprintln!( + "FAIL rs::process_lifecycle: no {PROCESS_OPEN_RETIRED:?} record, so the kernel did not \ + refuse 110 as a retired number{}", + kernel_account(result) + ); + return false; + } + true +} + /// The name, formatted into every needle below rather than written beside a /// `test_rs_` literal: `suite_split` reads that spelling as a machine test /// *driving* the binary, and these only read console lines about it. @@ -12374,21 +12390,6 @@ fn run_machine_test( ); leak_rollback(qemu.boot_log()) } - "process_reopen_selftest" => { - // The kernel reopens init by pid after the only handle to it has gone; on - // the `sealed` row that install took the boot down, so a guest that never - // reaches the verdict line is the red. - let qemu = QemuInstance::boot_with_options( - test_config, - c_bins, - rust_bins, - BootOptions { - kernel_params: &["process-reopen-selftest"], - ..Default::default() - }, - ); - process_reopen(qemu.boot_log()) - } "driver_wait_refused" => { // The actuator blinds DEVICE_STATUS, staging a controller that // never answers; the boot must come up naming the refused register @@ -14360,24 +14361,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. -/// -/// Text in, a verdict out: every line it reads is a kernel record, so the -/// T14's readback and a QEMU boot log are judged by this one predicate. -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 { - return Err(format!("{control} never ran:\n{log}")); - }; - if !verdict.contains("PASS") { - return Err(format!("{}\n{log}", verdict.trim())); - } - eprintln!(" [process] {}", verdict.trim()); - } - Ok(()) -} - /// A backing read after deletion is refused on both writable mounts, and a page-cache slot whose fill the device refused is unbound. /// /// Text in, a verdict out: every line it reads is a kernel record, so the diff --git a/toyos-abi/src/handle.rs b/toyos-abi/src/handle.rs index a0e1a324cc0..400c89b405a 100644 --- a/toyos-abi/src/handle.rs +++ b/toyos-abi/src/handle.rs @@ -79,7 +79,7 @@ impl Rights { pub const MAP: Rights = Rights(1 << 4); /// Block on it, or name it in an [`OP_WATCH`](crate::inbox::OP_WATCH). pub const WAIT: Rights = Rights(1 << 5); - /// Kill a process; on a `SysCap`, open one by pid. + /// Kill a process. pub const MANAGE: Rights = Rights(1 << 6); /// On a `SysCap`: enter the RT band. pub const RT: Rights = Rights(1 << 7); diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 1ec384059a9..2ed89ee3845 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -263,15 +263,10 @@ pub const SYS_PROCESS_WAIT: u64 = 108; /// /// [`Rights::MANAGE`]: crate::handle::Rights::MANAGE pub const SYS_PROCESS_KILL: u64 = 109; -/// A `Process` handle for a pid, gated by [`Rights::MANAGE`] on a `SysCap`. -/// See [`process_open`]. -/// -/// The one place a pid becomes authority, and only `init` holds a cap that -/// carries the right — so the set of processes that can reach a process they -/// did not start is exactly what init endowed. -/// -/// [`Rights::MANAGE`]: crate::handle::Rights::MANAGE -pub const SYS_PROCESS_OPEN: u64 = 110; +// Syscall number 110 is retired and unused: it was `SYS_PROCESS_OPEN`, which +// turned a pid into a `Process` handle on a `SysCap` carrying +// `Rights::MANAGE`. A process is reached only through the handle its spawn +// answered, or one a holder of that handle moved. /// Mint a device claim for a class, gated by [`Rights::DEVICE`] on a `SysCap`. /// Only `init` holds such a cap, so the set of processes that can ever @@ -972,13 +967,6 @@ pub fn process_kill(proc: RawHandle) -> Result<(), SyscallError> { check_unit(syscall(SYS_PROCESS_KILL, proc.0 as u64, 0, 0, 0)) } -/// A `Process` handle for `pid`, presenting a `SysCap` that carries -/// `Rights::MANAGE`. -pub fn process_open(syscap: RawHandle, pid: Pid) -> Result { - check(syscall(SYS_PROCESS_OPEN, syscap.0 as u64, pid.0 as u64, 0, 0)) - .map(|h| RawHandle(h as u32)) -} - /// Copy records into `out`, oldest first and merged by `at_ns`, advancing /// `cursor`. Answers how many records were written, and `0` when there is /// nothing new. @@ -2312,10 +2300,9 @@ pub struct ProcessStats { pub fault_zero_count: u32, pub fault_ns: u64, pub io_read_ops: u32, - /// The process's own pid. Not authority — nothing takes a pid but - /// [`SYS_PROCESS_OPEN`], which takes a `SysCap` beside it — but it is the - /// name a diagnostic prints, and this is where a holder of a handle reads - /// it. + /// The process's own pid. Not authority — no syscall takes a pid — but it + /// is the name a diagnostic prints, and this is where a holder of a handle + /// reads it. pub pid: u32, pub io_read_bytes: u64, pub blocked_io_ns: u64, diff --git a/toyos/src/syscap.rs b/toyos/src/syscap.rs index 099c715847d..1501c2f5834 100644 --- a/toyos/src/syscap.rs +++ b/toyos/src/syscap.rs @@ -1,10 +1,9 @@ //! The capability whose whole authority is in the rights on the handle. //! //! Some things are reachable no other way — minting a device claim, entering -//! the real-time band, turning a pid into a process handle, listing every -//! process in the machine, reading what the machine is made of, and taking its -//! power away, off or back to firmware — and each is one bit on a handle to -//! this. The kernel makes exactly one at boot, for `init`, so the set of +//! the real-time band, listing every process in the machine, reading what the +//! machine is made of, and taking its power away, off or back to firmware — +//! and each is one bit on a handle to this. The kernel makes exactly one at boot, for `init`, so the set of //! processes that can ever do any of them is exactly what init endowed. use toyos_abi::handle::Rights; @@ -134,24 +133,11 @@ impl SysCap { /// A second handle to this capability carrying **less**. /// /// How init gives a program the RT band and nothing else: rights only - /// shrink, so the dup can never mint a claim or open a process however the - /// holder asks. + /// shrink, so the dup can never mint a claim however the holder asks. pub fn narrowed(&self, rights: Rights) -> Result { syscall::dup_narrowed(self.0.raw(), rights).map(|h| Self(OwnedHandle(h))) } - /// A `Process` handle for a pid. - /// - /// The one place a pid becomes authority over anything, and only a cap - /// carrying [`Rights::MANAGE`] reaches it — which in the whole system is - /// `init`'s. - pub fn open_process(&self, pid: toyos_abi::Pid) -> Result { - let raw = syscall::process_open(self.0.raw(), pid)?; - // SAFETY: the kernel installed this handle in this process's table for - // this call and no other. - Ok(unsafe { crate::process::Process::from_raw(raw) }) - } - /// Give up ownership, for a handle about to be endowed. pub fn into_raw(self) -> RawHandle { self.0.into_raw() From 6e9d6a4df8c285c242d08f460e02573bb0ca0510 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 00:10:02 +0200 Subject: [PATCH 2/8] SYS_PROCESS_OPEN is deleted outright: 110 is free, and no arm calls it The owner ruled that the ABI is completely unstable: no stable ABI exists yet, and toyos-abi and the SDK take breaking changes freely until ToyOS is adopted. A deleted syscall is deleted and its number is free, so nothing here retires 110. - 110 leaves `retired_syscalls!` and the ABI's retired-number comment goes. The table itself stays; removing it tree-wide is a separate change. - process_lifecycle's pid arm is deleted. A call that no longer exists needs no runtime test: every caller of the deleted names fails to compile. The other arms stay. `wait_raw` is its base form again, so no second raw-syscall entry is left in the binary (review B1). - check_process_lifecycle, its record constant and its check_for row are deleted (review B2). No run had seen the record half red, and the record could reach the wire after the runner's window had closed. - Review NOTEs and REMOVEs: the trailing blank line at the end of kernel/src/object/process.rs goes. In the-capability-end-state-is-twelve-answers.md, the paragraph on which rulings are enforced in code goes, and so does question 3's sentence on retired pid-addressed numbers. The test loses its count of the arms that take the cap, the arm's history comment, and the clauses of the module doc and the closing line that spoke for the deleted arm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- ...-capability-end-state-is-twelve-answers.md | 11 ----- kernel/src/object/process.rs | 1 - kernel/src/syscall/dispatch.rs | 1 - .../src/bin/process_lifecycle.rs | 43 +++++-------------- tests/toyos.rs | 23 ---------- toyos-abi/src/syscall.rs | 4 -- 6 files changed, 11 insertions(+), 72 deletions(-) diff --git a/issues/kernel/the-capability-end-state-is-twelve-answers.md b/issues/kernel/the-capability-end-state-is-twelve-answers.md index d9f9e767024..2054abe2438 100644 --- a/issues/kernel/the-capability-end-state-is-twelve-answers.md +++ b/issues/kernel/the-capability-end-state-is-twelve-answers.md @@ -30,13 +30,6 @@ committed, four are open" was this line's own summary and stopped being true when those four rulings landed; "The ruling set" below is a record of decisions taken, not a queue waiting on anybody. -Four of the rulings are enforced in code, each at a site that demands a right -where none was demanded before: `SYS_SHUTDOWN` takes a `SysCap` carrying -`Rights::POWER` (`kernel/src/syscall/machine.rs:46-48`), and `SYS_SYSINFO`'s -roster takes `Rights::ROSTER`, demanded only once the buffer has room for an -entry (`kernel/src/syscall/machine.rs:84`, `:94`), spelled `roster` in -`toyos-manifest/src/lib.rs:80`. - **Every `4a98107f^:kernel/src/arch/syscall.rs` citation below points into history**: that one-file syscall layer is what `4a98107f` split, and the syscalls are `kernel/src/syscall/` now, twelve files with `dispatch.rs` @@ -104,10 +97,6 @@ field: "Not authority — no syscall takes a pid" (`toyos-abi/src/syscall.rs`'s `SYS_THREAD_JOIN` resolves through `thread_sched(caller, tid)` and `collect_thread_zombie(table, tid, parent_pid)`, both keyed on the caller's own pid (`4a98107f^:kernel/src/arch/syscall.rs:2393`, `kernel/src/process.rs:1412`, `:848`). -Five pid-addressed syscalls were deleted and their numbers retired rather than -reused — 26 `SYS_WAITPID`, 33 `SYS_FIND_PID`, 37 `SYS_GRANT_SHARED`, 65 -`SYS_KILL` (`4a98107f^:kernel/src/arch/syscall.rs:63`), and 110 -`SYS_PROCESS_OPEN` (`kernel/src/syscall/dispatch.rs`'s `retired_syscalls!`). ## 4. Can a process enumerate objects it lacks authority over? — RULED 2026-08-20, IMPLEMENTED 2026-08-22 diff --git a/kernel/src/object/process.rs b/kernel/src/object/process.rs index 3c454160f05..3b651631146 100644 --- a/kernel/src/object/process.rs +++ b/kernel/src/object/process.rs @@ -84,4 +84,3 @@ impl ProcessObject { self.watch.post(); } } - diff --git a/kernel/src/syscall/dispatch.rs b/kernel/src/syscall/dispatch.rs index db3931b7cc8..644e2b934ac 100644 --- a/kernel/src/syscall/dispatch.rs +++ b/kernel/src/syscall/dispatch.rs @@ -95,7 +95,6 @@ retired_syscalls! { 85 => "SYS_LISTEN", 87 => "SYS_CONNECT", 96 => "SYS_SET_RT_PRIORITY", - 110 => "SYS_PROCESS_OPEN", } /// `sched-operation-nesting`'s task half and `sysret-ss-probe`, run once a boot diff --git a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs index 4a28748a638..50b9201bc39 100644 --- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs +++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs @@ -9,10 +9,7 @@ //! wait before it parks and is woken by the publish, and two holders both get //! the answer. //! -//! Each arm below is one sentence of that paragraph, and three of them assert -//! the *opposite* of what the pid-keyed shape did: reading the code does not -//! spend it, a process that never started the child can still wait for it, and -//! a pid on its own reaches nothing at all. +//! Each arm below is one sentence of that paragraph. //! //! One arm is about the wait rather than the shape. //! `an_unrelated_wake_does_not_end_the_wait` provokes a wake that is not this @@ -31,7 +28,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::OnceLock; use toyos::endow::{Endowments, SYSCAP_LABEL}; -use toyos::AsHandle; use toyos::process::Process; use toyos::syscap::SysCap; use toyos_abi::syscall::{self, SyscallError}; @@ -65,9 +61,8 @@ fn test() { two_handles_answer_the_same(); a_kill_publishes_like_an_exit(); a_handle_is_the_whole_of_the_right(); - a_pid_is_not_authority(); an_undefined_wait_flag_bit_is_refused(); - println!("a process is a handle: the code is read, not claimed, and a pid grants nothing"); + println!("a process is a handle: the code is read, not claimed"); } /// `WNOHANG` is the whole of `SYS_PROCESS_WAIT`'s flag word; the other 63 bits @@ -83,7 +78,7 @@ fn an_undefined_wait_flag_bit_is_refused() { assert_eq!(child.wait().expect("wait").code(), Some(5), "the child did not exit"); let handle = RawHandle(child.as_raw_handle()); - let refused = raw(syscall::SYS_PROCESS_WAIT, handle.0 as u64, syscall::WNOHANG | UNDEFINED); + let refused = wait_raw(handle, syscall::WNOHANG | UNDEFINED); assert_eq!( SyscallError::from_u64(refused), Some(SyscallError::InvalidArgument), @@ -93,18 +88,18 @@ fn an_undefined_wait_flag_bit_is_refused() { println!(" an undefined WNOHANG-word bit is InvalidArgument, and without it the code comes back"); } -/// Syscall `num` on two arguments, past every typed wrapper: none spells a flag -/// word the ABI does not define, or a retired number. -fn raw(num: u64, a1: u64, a2: u64) -> u64 { +/// The typed wrapper cannot spell a flag word the ABI does not define, so the +/// argument under test only exists at the raw boundary. +fn wait_raw(handle: RawHandle, flags: u64) -> u64 { let ret: u64; // SAFETY: a register-to-register `syscall`; neither argument is a pointer - // either call dereferences. + // this call dereferences. unsafe { core::arch::asm!( "syscall", - in("rdi") num, - in("rsi") a1, - in("rdx") a2, + in("rdi") syscall::SYS_PROCESS_WAIT, + in("rsi") handle.0 as u64, + in("rdx") flags, in("r8") 0u64, in("r9") 0u64, lateout("rax") ret, @@ -211,7 +206,7 @@ fn a_thread_of_mine_has_exited() -> bool { /// The estate's system capability, taken once. /// /// **Once, because taking is a swap**: a second `take` of the same label finds -/// `HANDLE_INVALID` and answers `None`, and two arms here want the same cap. +/// `HANDLE_INVALID` and answers `None`. fn cap() -> &'static SysCap { static CAP: OnceLock = OnceLock::new(); CAP.get_or_init(|| { @@ -271,22 +266,6 @@ fn a_handle_is_the_whole_of_the_right() { println!(" a process that did not start the child waited for it, and so did the one that did"); } -/// A pid is a name everybody can say, and no call takes one. 110 was the call -/// that turned a pid into a handle; it is asked here with the arguments it -/// took, this process's capability and its own pid, so a kernel that still -/// served it answers what it would have. `check_process_lifecycle` reads the -/// kernel's record of the refusal. -fn a_pid_is_not_authority() { - const PROCESS_OPEN: u64 = 110; - let answer = raw(PROCESS_OPEN, cap().as_handle().0 as u64, syscall::getpid().0 as u64); - assert_eq!( - SyscallError::from_u64(answer), - Some(SyscallError::NotSupported), - "syscall {PROCESS_OPEN}, retired, answered {answer:#x}", - ); - println!(" syscall {PROCESS_OPEN} is retired: a pid becomes no handle"); -} - /// A child that exits with `code` when this process says so, and the write end /// that says so: dropping it is what lets the child go. /// diff --git a/tests/toyos.rs b/tests/toyos.rs index e333889b7d4..83c76dfea4a 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -3157,7 +3157,6 @@ fn check_for(name: &str) -> fn(&TestResult) -> bool { "dlopen_dedup" => check_dlopen_dedup, "abuse_elf_loader" => check_abuse_elf_loader, "exit_wait_storm" => check_exit_wait_storm, - "process_lifecycle" => check_process_lifecycle, _ => check_rust_result, } } @@ -3223,28 +3222,6 @@ fn check_dlopen_dedup(result: &TestResult) -> bool { true } -/// The kernel's record of `process_lifecycle`'s call of the number -/// `SYS_PROCESS_OPEN` had (`kernel/src/syscall/dispatch.rs`'s `retired_syscalls!`). -const PROCESS_OPEN_RETIRED: &str = "syscall 110 is retired (formerly SYS_PROCESS_OPEN)"; - -/// `process_lifecycle` plus the half no guest can see: the kernel refused 110 -/// as retired, not as a number it never had. -fn check_process_lifecycle(result: &TestResult) -> bool { - if !check_rust_result(result) { - return false; - } - let log = format!("{}{}", result.before, result.serial); - if !log.lines().any(|l| l.contains(PROCESS_OPEN_RETIRED)) { - eprintln!( - "FAIL rs::process_lifecycle: no {PROCESS_OPEN_RETIRED:?} record, so the kernel did not \ - refuse 110 as a retired number{}", - kernel_account(result) - ); - return false; - } - true -} - /// The name, formatted into every needle below rather than written beside a /// `test_rs_` literal: `suite_split` reads that spelling as a machine test /// *driving* the binary, and these only read console lines about it. diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 2ed89ee3845..8fbedada304 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -263,10 +263,6 @@ pub const SYS_PROCESS_WAIT: u64 = 108; /// /// [`Rights::MANAGE`]: crate::handle::Rights::MANAGE pub const SYS_PROCESS_KILL: u64 = 109; -// Syscall number 110 is retired and unused: it was `SYS_PROCESS_OPEN`, which -// turned a pid into a `Process` handle on a `SysCap` carrying -// `Rights::MANAGE`. A process is reached only through the handle its spawn -// answered, or one a holder of that handle moved. /// Mint a device claim for a class, gated by [`Rights::DEVICE`] on a `SysCap`. /// Only `init` holds such a cap, so the set of processes that can ever From 7fe8c566de6d2faa288ffd1c3b95d1d69d6928db Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 18:35:08 +0200 Subject: [PATCH 3/8] A spawn can be held until its child has ended, and a guest binary asks for it `debug_action::HOLD_SPAWN_UNTIL_CHILD_ENDS` (23) marks the caller's next spawn whose child lands: its thread parks in `loader::spawn`, after the landing and its retires, until the child's exit is published. `spawn_child_ends_first` spawns a child that exits at once under that hold and reads the child's code off the handle the spawn answers. This is the window the merge before this commit left open: #659 installs the child's own `self` at the commit, schedules the child, and installs its spawner's handle only once `loader::spawn` has returned. A child whose table closes in between takes its object's handle count to zero and back, which `HandleEntry::new` asserts against on every row now that none is `reopenable`. The binary is the control for the commit that removes the window. `ProcessEntry::object` goes: `process::process_object` was its last caller on main. 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 | 30 +++++++- kernel/src/syscall/dispatch.rs | 4 ++ .../src/bin/spawn_child_ends_first.rs | 72 +++++++++++++++++++ tests/toyos.rs | 3 + toyos-abi/src/syscall.rs | 5 ++ 6 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index dfa904b251e..3b56122b11d 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -671,6 +671,9 @@ pub fn spawn( scheduler::post_retire(sched); } + #[cfg(feature = "test-actuators")] + crate::process::debug_hold_marked_spawn(parent, &object); + 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)", path, pid, tid, dst.0, base, entry, child_pt.lock().root().phys(), sym_bytes / 1024, diff --git a/kernel/src/process.rs b/kernel/src/process.rs index 9c267ba01f8..79a1a40c654 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -365,7 +365,6 @@ impl ProcessEntry { } } pub fn pid(&self) -> Pid { self.pid } - pub fn object(&self) -> &Arc { &self.object } pub fn name(&self) -> &[u8; THREAD_NAME_LEN] { &self.name } pub fn name_str(&self) -> &str { core::str::from_utf8(&self.name).unwrap_or("?").trim_end_matches('\0') @@ -1733,6 +1732,35 @@ pub fn debug_kill_marked_place(parent: Parent) { } } +/// The process whose next landed spawn `debug_action::HOLD_SPAWN_UNTIL_CHILD_ENDS` marked; `Pid::MAX` for none. +#[cfg(feature = "test-actuators")] +static HELD_SPAWNER: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(Pid::MAX.0); + +/// Mark the calling process's next spawn whose child lands. +#[cfg(feature = "test-actuators")] +pub fn debug_mark_spawn_hold() { + HELD_SPAWNER.store(current_process().0, core::sync::atomic::Ordering::Relaxed); +} + +/// Park the thread of a spawn its caller marked until `child`'s exit is published, and take the mark. The loader calls it once the child has landed. +#[cfg(feature = "test-actuators")] +pub fn debug_hold_marked_spawn(parent: Parent, child: &crate::object::process::ProcessObject) { + use core::sync::atomic::Ordering::Relaxed; + let Parent::Under(_) = parent else { return }; + if HELD_SPAWNER.compare_exchange(current_process().0, Pid::MAX.0, Relaxed, Relaxed).is_ok() { + let parkable = scheduler::Parkable::at_entry(); + // A cancelled wait is the spawner's own end, which the spawn's return meets. + let _ = crate::watch::wait_until( + &parkable, + child.watch(), + 0, + toyos_sched::task::WaitClass::Other, + crate::time::Deadline::never(), + || child.finished(), + ); + } +} + /// The shell convention for "died on SIGKILL"; kept because every test that reads one already spells it. pub const KILLED_EXIT_CODE: i32 = 137; diff --git a/kernel/src/syscall/dispatch.rs b/kernel/src/syscall/dispatch.rs index 218bcf59900..f8a777987df 100644 --- a/kernel/src/syscall/dispatch.rs +++ b/kernel/src/syscall/dispatch.rs @@ -632,6 +632,10 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> process::debug_mark_spawn(); 0 } + DA::HOLD_SPAWN_UNTIL_CHILD_ENDS => { + process::debug_mark_spawn_hold(); + 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_child_ends_first.rs b/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs new file mode 100644 index 00000000000..ff488950097 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs @@ -0,0 +1,72 @@ +//! A spawn whose child has ended before the spawn answers still answers a +//! handle to it, and the handle reads the child's code. +//! +//! A child's own table holds its `self` and closes when the child ends, so a +//! spawner's handle minted after that would be the first on an object whose +//! last had gone. No caller can order a child's end inside the spawn that +//! starts it: `debug_action::HOLD_SPAWN_UNTIL_CHILD_ENDS` has the kernel hold +//! this process's next spawn, once its child has landed, until the child's +//! exit is published. +//! +//! Every wait is unbounded: the runner's deadline is the only clock. + +use toyos_abi::syscall::{self, debug_action, SpawnArgs, SyscallError}; +use toyos_abi::RawHandle; + +const SELF_PATH: &str = "/system/bin/test_rs_spawn_child_ends_first"; + +/// What the child exits with. +const CODE: i32 = 7; + +fn main() { + match std::env::args().nth(1).as_deref() { + Some("exit") => std::process::exit(CODE), + Some(other) => panic!("unknown role {other:?}"), + None => test(), + } +} + +fn test() { + hold_the_next_spawn(); + let child = spawn("exit", &[]).expect("a spawn whose child ended inside it"); + assert_eq!( + syscall::process_wait_nonblock(child), + Ok(CODE), + "the child had not ended when its spawn answered, so the kernel held nothing" + ); + syscall::close(child); + println!("spawn_child_ends_first: the spawn answered a child that had already ended"); +} + +fn hold_the_next_spawn() { + assert_eq!( + syscall::debug(debug_action::HOLD_SPAWN_UNTIL_CHILD_ENDS), + 0, + "this kernel does not carry SYS_DEBUG, so nothing holds the spawn" + ); +} + +/// `SYS_SPAWN` of this binary in `role`, duplicating each `[child_slot, +/// parent_handle]` pair of `slot_map` into the child. +fn spawn(role: &str, slot_map: &[[u32; 2]]) -> Result { + let argv = format!("{SELF_PATH}\0{role}"); + let args = SpawnArgs { + argv_ptr: argv.as_ptr() as u64, + argv_len: argv.len() as u64, + slot_map_ptr: slot_map.as_ptr() as u64, + slot_map_count: slot_map.len() as u64, + 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(toyos_abi::HANDLE_INVALID.0), + }; + // SAFETY: every pointer names a buffer of this frame that outlives the call. + unsafe { syscall::spawn(&args) } +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 26c29ef2cc6..8fa6317722d 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -47,6 +47,9 @@ const ACTUATOR_TESTS: &[&str] = &[ // 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", + // Action 23: the kernel holds a spawn, once its child has landed, until + // the child has ended — a child's end no caller can order inside its spawn. + "spawn_child_ends_first", ]; /// 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 69cd2bc3c68..24d1fbc1688 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -878,6 +878,11 @@ pub mod debug_action { /// 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; + /// Mark the caller's next spawn whose child lands: its thread waits there, + /// before the spawn answers, until the child's exit is published. A child + /// ending inside the spawn that started it is a race no caller can order; + /// the landing and the exit either side of the wait are the shipped paths. + pub const HOLD_SPAWN_UNTIL_CHILD_ENDS: u64 = 23; } /// Every kind of kernel object, in the order the kernel's own `kobject!` From 6a26679e336001aa513bba7052a192d3e72adc30 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 18:41:57 +0200 Subject: [PATCH 4/8] A spawn's handle to its child is in its caller's table before the child lands Measured first, on the commit before this one: `spawn_child_ends_first` on the test kernel under QEMU ends in PANIC: panicked at src/object/handle.rs:108:9: a handle to a retired Process (koid 162) kernel::object::ops::install kernel::syscall::proc::sys_spawn The child's table had closed, taking its `self` and the object's count to zero, before `sys_spawn` installed the spawner's handle. `PendingHandles::commit` now installs the caller's handle in the hold that moves the endowments, before the child's own `self` and before the landing, and answers it; `sys_spawn` returns that handle and installs nothing. The room for it is checked with the child's table's, before anything moves, so the refusal a full table draws leaves the caller's table as it was and no child exists: the kill of a child that had landed and could not be named goes. `loader::spawn` takes the commit as a closure and answers what it left its caller holding beside the pid, so the boot's init, which no table but its own holds, answers `()` and the syscall a `RawHandle`: `PendingHandles` loses its `Ready` variant and is the caller's request alone. A handle the commit installed resolves before the syscall that will answer it has returned. Only a caller that guessed its number can name it there, and what it gets is what a process not yet in the table gives: a wait that parks, `NotFound` for its accounting, `Gone` for a spawn under it, and a kill that finds nothing to claim. toyos-proclife: the model's spawn mints both handles in the section that lands the child, a teardown closes its process's table, and L13 refuses a handle minted on an object whose last had gone. `mutate-spawner-handle-after-the-landing` restores the old order and reds `a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it` and `a_spawn_racing_the_kill_of_its_own_spawner`: the child lands claimed, is retired and closes its table, and then its spawner's handle is minted. Guest arms: `abuse_handle_table` spawns from its full table naming an endowment, and is refused with the endowment still its own; under the hold, `spawn_child_ends_first` spawns from a full table a child that would speak into a pipe, and the pipe ends empty. Closes issues/kernel/a-spawn-refused-for-its-callers-full-table-has-already-moved-its-endowments.md: its exit was the handle in the commit's hold with the room checked before anything moves, and a guest arm reading `ResourceExhausted` with no child started. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- ...-table-has-already-moved-its-endowments.md | 24 --------- kernel/src/loader/mod.rs | 46 +++++++++-------- kernel/src/loader/start.rs | 40 +++++++-------- kernel/src/syscall/proc.rs | 22 ++------- src/ci.rs | 4 ++ .../src/bin/abuse_handle_table.rs | 11 +++++ .../src/bin/spawn_child_ends_first.rs | 38 +++++++++++++- toyos-abi/src/syscall.rs | 4 +- toyos-proclife/Cargo.toml | 7 +++ toyos-proclife/src/interleave.rs | 49 +++++++++++++------ toyos-proclife/src/model.rs | 42 ++++++++++++++++ 11 files changed, 186 insertions(+), 101 deletions(-) delete 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 deleted file mode 100644 index f05cf31a534..00000000000 --- a/issues/kernel/a-spawn-refused-for-its-callers-full-table-has-already-moved-its-endowments.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -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/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index 3b56122b11d..1ffd00ac0b3 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -28,8 +28,8 @@ use crate::object::{ops, HandleTable, KObjectRef}; use crate::mm::policy::{CachePolicy, Prot}; use crate::mm::{PAGE_2M, PAGE_BYTES}; use crate::process::{ - Admission, ElfInfo, OwnedAlloc, PageAlloc, PageFaultTrace, PageTables, Parent, Pid, - ProcessAccounting, ProcessData, ProcessEntry, ThreadData, ThreadEntry, UserStack, + Admission, ElfInfo, Endowments, OwnedAlloc, PageAlloc, PageFaultTrace, PageTables, Parent, + Pid, ProcessAccounting, ProcessData, ProcessEntry, ThreadData, ThreadEntry, UserStack, PROCESS_TABLE, }; use crate::sync::Lock; @@ -335,8 +335,10 @@ fn rela_dyn_from_sections( } } -/// Load a program and place its main thread under `parent`, answering the object -/// a handle to the new process names. +/// Load a program and place its main thread under `parent`, answering its pid +/// and what `commit` left its caller holding of it. `commit` builds the +/// child's handle table around the child's own object, once nothing is left +/// to refuse. /// /// `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 @@ -346,14 +348,14 @@ fn rela_dyn_from_sections( /// `Refusal`, not `-> !`, is the error type: every failure below owns a /// partly built process (address space, stack, kernel stack), and nothing /// unwinds, so the error must travel out as a value rather than strand it. -pub fn spawn( +pub fn spawn( argv: &[&str], - pending: PendingHandles, + commit: impl FnOnce(KObjectRef) -> Result<(HandleTable, Endowments, H), crate::object::Refusal>, cwd: String, env: Vec, image: Option>, parent: Parent, -) -> Result, crate::object::Refusal> { +) -> Result<(Pid, H), 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()); @@ -366,7 +368,7 @@ pub fn spawn( let backing: Arc = match image { Some(image) => image, None => { - // Scoped, not held across the match: dropping `pending` on any `return` here takes the VFS lock. + // Scoped, not held across the match: dropping `commit` on any `return` here takes the VFS lock. let opened = vfs::lock().open_backing(path); match opened { Ok(b) => b, @@ -590,7 +592,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. - let (handles, endowments) = pending.commit(KObjectRef::Process(Arc::clone(&object)))?; + let (handles, endowments, held) = commit(KObjectRef::Process(Arc::clone(&object)))?; let proc_data = Arc::new(Lock::new(ProcessData { handles, cwd, @@ -680,7 +682,7 @@ pub fn spawn( (t1 - t0) / 1_000_000, (t2 - t1) / 1_000_000, (t_deps - t2) / 1_000_000, (t_tls - t_deps) / 1_000_000, (t3 - t0) / 1_000_000); - Ok(object) + Ok((pid, held)) } /// The libraries an executable's `DT_NEEDED` entries name, and the paths they were found at. @@ -888,18 +890,20 @@ 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 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, - }], - labels: label.as_bytes().to_vec(), + let mut entries = alloc::vec![toyos_abi::syscall::EndowEntry { + label_off: 0, + label_len: label.len() as u32, + handle: cap_handle, + _pad: 0, + }]; + let mut labels = label.as_bytes().to_vec(); + // Built by the kernel and owing nobody anything: no table but init's own holds it. + let commit = |own| { + start::endow_self(&mut handles, &mut entries, &mut labels, own); + Ok((handles, Endowments::new(entries, labels), ())) }; - match spawn(&[INIT_PATH], pending, String::from("/"), Vec::new(), None, Parent::Root) { - Ok(object) => object.pid(), + match spawn(&[INIT_PATH], commit, String::from("/"), Vec::new(), None, Parent::Root) { + Ok((pid, ())) => 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 33463f16302..d74b0930d43 100644 --- a/kernel/src/loader/start.rs +++ b/kernel/src/loader/start.rs @@ -4,7 +4,7 @@ use alloc::vec::Vec; -use crate::object::{HandleEntry, HandleTable, KObjectRef, Refusal}; +use crate::object::{ops, HandleEntry, HandleTable, KObjectRef, Refusal}; use crate::process::{ process_data, Endowments, OwnedAlloc, ENDOW_ENTRY_LEN, KERNEL_STACK_SIZE, }; @@ -42,26 +42,19 @@ pub(crate) fn make_name(path: &str) -> [u8; crate::process::THREAD_NAME_LEN] { name } -/// A child's table, and the endowments that have not left the parent yet. -// 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 { 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 }, +/// A caller's request for a child's table: `endow` has not left the caller's table yet. +// The move must be last: an earlier move leaves a failed spawn's caller holding handles that name nothing. +pub struct PendingHandles { + 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. - /// `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), - }; + /// Take the endowed handles out of the caller's table and put its handle to `own`, the child, in it, all under one lock hold: a refusal leaves the table unchanged. + /// The child's table holds `own` under [`SELF_LABEL`] beside the endowments, and the caller's handle is the third answer. + pub fn commit(self, own: KObjectRef) -> Result<(HandleTable, Endowments, RawHandle), Refusal> { + let Self { mut table, endow, mut labels } = self; let data_arc = process_data(); let mut data = data_arc.lock(); @@ -94,7 +87,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() + 1) { + if !table.has_room(moving.len() + 1) || !data.handles.has_room(1) { return Err(SyscallError::ResourceExhausted.into()); } @@ -109,14 +102,17 @@ impl PendingHandles { .expect("a child table with verified room refused an endowment"); entries.push(entry); } + // In this hold, before the child can run: its object's handle count never reaches zero while the spawn is in flight. + let held = ops::install(&mut data.handles, own.clone()) + .expect("a caller's table with verified room refused its child"); drop(data); endow_self(&mut table, &mut entries, &mut labels, own); - Ok((table, Endowments::new(entries, labels))) + Ok((table, Endowments::new(entries, labels), held)) } } /// 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) { +pub(super) 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)) @@ -200,5 +196,5 @@ pub fn build_child_handles( let mut raw = alloc::vec![0u8; endow.len()]; endow.read_at(0, &mut raw); - Ok(PendingHandles::Moving { table: handles, endow: raw, labels: labels.to_vec() }) + Ok(PendingHandles { table: handles, endow: raw, labels: labels.to_vec() }) } diff --git a/kernel/src/syscall/proc.rs b/kernel/src/syscall/proc.rs index 335b44da2cc..c54120404ca 100644 --- a/kernel/src/syscall/proc.rs +++ b/kernel/src/syscall/proc.rs @@ -8,7 +8,6 @@ use alloc::vec::Vec; use crate::watch; -use crate::object::{ops, KObjectRef}; use crate::time::{Deadline, Duration}; use crate::UserAddr; use crate::process; @@ -43,7 +42,7 @@ pub(super) fn spawn_place(place: u64) -> Result { .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. +/// Start a program in `cwd` under `parent` and return the handle to it the spawn's commit put in the caller's table. pub(super) fn sys_spawn( args: &[&str], pending: crate::loader::PendingHandles, @@ -52,21 +51,10 @@ pub(super) fn sys_spawn( 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, parent) { - Ok(object) => object, - Err(e) => return e.refuse(), - }; - let installed = process::with_process_data(|data| { - ops::install(&mut data.handles, KObjectRef::Process(object.clone())) - }); - match installed { - Ok(h) => h.0 as u64, - Err(e) => { - // Unnamed, it can be neither waited on nor killed later, so it is killed here. - process::kill_process(&object); - e.to_u64() - } + // Nothing to clean up: spawn's frame owns the child's resources on error. + match process::spawn(args, |own| pending.commit(own), cwd, env, image, parent) { + Ok((_, handle)) => u64::from(handle.0), + Err(e) => e.refuse(), } } diff --git a/src/ci.rs b/src/ci.rs index c1f90245836..5b7daed1f93 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -406,6 +406,10 @@ pub(crate) const CONTROLS: &[Control] = &[ red(PROCLIFE, "mutate-walk-in-one-hold", None, &[ Fails("interleave::tests::a_spawn_under_an_unrelated_process_lands_between_two_claims_of_one_walk"), ]), + red(PROCLIFE, "mutate-spawner-handle-after-the-landing", None, &[ + Fails("interleave::tests::a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it"), + Fails("interleave::tests::a_spawn_racing_the_kill_of_its_own_spawner"), + ]), red(SCHED_SIM, "placement-ignores-staleness", Some("policy"), &[ Fails("a_stopped_cpu_stops_taking_work"), ]), 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 c422ba0ea3c..5c7f6ffe526 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs @@ -17,6 +17,10 @@ //! each has an arm here and the last arm is what says the machine survived //! them. //! +//! **A spawn's answer is an insert into its caller's table**, whose room is +//! checked before the spawn's endowments move: at the cap the spawn is +//! refused with every handle it named still the caller's. +//! //! **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` @@ -154,6 +158,13 @@ fn main() { "handle table reached {n} slots, past the {MAX_HANDLES} cap" ); + // The handle this names is closed with the rest below, which ends this + // process if the refused spawn moved it. + let last = *filled.last().expect("the fill installed a handle"); + let entry = EndowEntry { label_off: 0, label_len: LABELS.len() as u32, handle: last, _pad: 0 }; + let err = spawn_endowed(&[entry], LABELS).expect_err("a spawn from a full table must be refused"); + assert_eq!(err, SyscallError::ResourceExhausted, "wrong error for a spawn from a full table"); + // The cap is a live limit, not a latched failure. for handle in filled { syscall::close(handle); diff --git a/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs b/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs index ff488950097..3e5555fcf84 100644 --- a/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs +++ b/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs @@ -8,6 +8,10 @@ //! this process's next spawn, once its child has landed, until the child's //! exit is published. //! +//! The same hold is what lets a refused spawn be asked whether it started a +//! child: one that had landed would have run to its end, and spoken, before +//! the spawn answered. +//! //! Every wait is unbounded: the runner's deadline is the only clock. use toyos_abi::syscall::{self, debug_action, SpawnArgs, SyscallError}; @@ -21,6 +25,9 @@ const CODE: i32 = 7; fn main() { match std::env::args().nth(1).as_deref() { Some("exit") => std::process::exit(CODE), + Some("speak") => { + assert_eq!(syscall::write(RawHandle(1), b"x"), Ok(1), "the child's one byte"); + } Some(other) => panic!("unknown role {other:?}"), None => test(), } @@ -35,7 +42,36 @@ fn test() { "the child had not ended when its spawn answered, so the kernel held nothing" ); syscall::close(child); - println!("spawn_child_ends_first: the spawn answered a child that had already ended"); + a_spawn_from_a_full_table_starts_no_child(); + println!("spawn_child_ends_first: the spawn answered a child that had already ended, and a refused one started none"); +} + +/// A spawn whose caller's table has no slot for its answer is refused with +/// nothing of the child started: the pipe its child would have spoken into +/// ends empty. The mark is left standing, since the spawn never lands. +fn a_spawn_from_a_full_table_starts_no_child() { + let ends = syscall::pipe().expect("a pipe for the child to speak into"); + let mut filled = Vec::new(); + let full = loop { + match syscall::dup(ends.read) { + Ok(handle) => filled.push(handle), + Err(refused) => break refused, + } + }; + assert_eq!(full, SyscallError::ResourceExhausted, "the table did not fill"); + hold_the_next_spawn(); + assert_eq!( + spawn("speak", &[[1, ends.write.0]]).err(), + Some(SyscallError::ResourceExhausted), + "a spawn from a full table was not refused" + ); + for handle in filled { + syscall::close(handle); + } + syscall::close(ends.write); + let mut byte = [0u8; 1]; + assert_eq!(syscall::read(ends.read, &mut byte), Ok(0), "the refused spawn's child started and spoke"); + syscall::close(ends.read); } fn hold_the_next_spawn() { diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index 24d1fbc1688..bf795b68983 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -941,7 +941,9 @@ pub fn get_env(buf: &mut [u8]) -> usize { /// Answers a `Process` handle carrying `WAIT|MANAGE|READ|DUP|TRANSFER`. A /// caller that wants nothing to do with the child closes it; a caller that /// wants to hand it on transfers it. There is no pid-addressed way back to a -/// process, so this handle is the whole of what a spawn confers. +/// process, so this handle is the whole of what a spawn confers. Its slot is +/// taken before an endowment moves: a caller whose table has none is refused +/// `ResourceExhausted` with its table as it was. /// /// # Safety /// The raw pointer fields in `SpawnArgs` must point to valid memory. diff --git a/toyos-proclife/Cargo.toml b/toyos-proclife/Cargo.toml index 42b91e0c64c..c410560e123 100644 --- a/toyos-proclife/Cargo.toml +++ b/toyos-proclife/Cargo.toml @@ -87,6 +87,13 @@ mutate-publish-before-the-children = [] # `interleave::tests::a_spawn_under_an_unrelated_process_lands_between_two_claims_of_one_walk` # must red under this. mutate-walk-in-one-hold = [] +# The model's spawn mints its caller's handle to the child once the spawn has +# landed and its retires are posted, as a kernel that installed it after +# `loader::spawn` returned would: a child claimed as it lands closes its table, +# its own `self` with it, before that. +# `interleave::tests::a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it` +# must red under this. +mutate-spawner-handle-after-the-landing = [] [dependencies] toyos-abi = { path = "../toyos-abi" } diff --git a/toyos-proclife/src/interleave.rs b/toyos-proclife/src/interleave.rs index 134a58dd2bb..3aea5bdda31 100644 --- a/toyos-proclife/src/interleave.rs +++ b/toyos-proclife/src/interleave.rs @@ -50,10 +50,11 @@ pub enum Op { /// 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` 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 the landing - /// answers. A build that `fails` lets the place go instead, and climbs - /// when that was the place's last hold. + /// whole of a process built with every lock given up, then the commit — + /// the caller's handles move, and its handle to the child and the child's + /// own are minted — 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. `child` is the process the landing made. SpawnUnder { place: Pid, by: (Pid, Tid), @@ -62,6 +63,7 @@ pub enum Op { admitted: Option, retire: Vec<(Pid, Tid)>, climb: Option, + child: 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. @@ -89,11 +91,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, fails: false, pc: 0, admitted: None, retire: Vec::new(), climb: None } + Op::SpawnUnder { place, by, fails: false, pc: 0, admitted: None, retire: Vec::new(), climb: None, child: 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 } + Op::SpawnUnder { place, by, fails: true, pc: 0, admitted: None, retire: Vec::new(), climb: None, child: None } } pub fn spawn(pid: Pid) -> Self { Op::Spawn { pid, pc: 0, block: None } @@ -217,7 +219,7 @@ impl Op { } } } - Op::SpawnUnder { place, by, fails, pc, admitted, retire, climb } => { + Op::SpawnUnder { place, by, fails, pc, admitted, retire, climb, child } => { match *pc { // The admission, under the table lock and before anything is built. 0 => match tree::admit_child(world, Some(*place)) { @@ -242,16 +244,23 @@ impl Op { None => *pc = DONE, } } - // The caller's handles move, and the child lands, in the - // hold that inserts it. + // The commit, then the child lands in the hold that + // inserts it. 1 => { world.move_handles(*by); let taken = admitted.take().expect("admitted at the first section"); - let child = taken.pid(); + let made = taken.pid(); + // The mutation this feature stages: the caller's + // handle waits for the spawn to land. + if !cfg!(feature = "mutate-spawner-handle-after-the-landing") { + world.mint(made, by.0); + } + world.mint(made, made); let ((), owed) = - tree::land_child(world, taken, KILLED, |world, node| world.insert(child, node)); - world.landed(*place, child); - *retire = owed.into_iter().map(|t| (child, t)).collect(); + tree::land_child(world, taken, KILLED, |world, node| world.insert(made, node)); + world.landed(*place, made); + *retire = owed.into_iter().map(|t| (made, t)).collect(); + *child = Some(made); *pc = 2; } // With the lock given up: the retires the landing answered. @@ -259,6 +268,12 @@ impl Op { for (victim, thread) in retire.drain(..) { world.post_retire(victim, thread); } + *pc = if cfg!(feature = "mutate-spawner-handle-after-the-landing") { LATE_HANDLE } else { DONE }; + } + // The mutation's install, once the spawn has landed and + // its retires are posted. + LATE_HANDLE => { + world.mint(child.expect("landed at the second section"), by.0); *pc = DONE; } // The failed build let its place's last hold go: this @@ -359,6 +374,8 @@ const POST: u32 = 1; const WALK: u32 = 2; /// `mutate-kill-waits-for-its-victims`' wait. const WAIT: u32 = 3; +/// `mutate-spawner-handle-after-the-landing`'s install. +const LATE_HANDLE: u32 = 4; /// 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 @@ -735,9 +752,11 @@ mod tests { /// every one. /// /// Reds under `mutate-place-skips-the-insert-recheck`, where a child lands - /// unclaimed under the claimed place and outlives it, and under + /// unclaimed under the claimed place and outlives it, under /// `mutate-refused-spawn-keeps-the-count`, where the place is never - /// published. + /// published, and under `mutate-spawner-handle-after-the-landing`, where a + /// child claimed as it lands closes its table before its spawner's handle + /// is minted. #[test] fn a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it() { let mut world = World::new(); diff --git a/toyos-proclife/src/model.rs b/toyos-proclife/src/model.rs index d29d78f8b43..d2f5fd6c864 100644 --- a/toyos-proclife/src/model.rs +++ b/toyos-proclife/src/model.rs @@ -143,6 +143,13 @@ pub struct World { moved: BTreeSet<(Pid, Tid)>, /// Spawning threads answered a refusal after their handles moved. refused_after_move: BTreeSet<(Pid, Tid)>, + /// Handles to a process's object: the object, and the process whose table + /// holds one. + handles: BTreeSet<(Pid, Pid)>, + /// Objects whose last handle has gone. + retired: BTreeSet, + /// Objects a handle was minted on after their last had gone. + minted_retired: BTreeSet, } impl Processes for World { @@ -193,6 +200,9 @@ impl World { inserted_at: Vec::new(), moved: BTreeSet::new(), refused_after_move: BTreeSet::new(), + handles: BTreeSet::new(), + retired: BTreeSet::new(), + minted_retired: BTreeSet::new(), } } @@ -231,11 +241,37 @@ impl World { panic!("World::land: {place:?} admits no child"); }; let pid = admitted.pid(); + // As a spawn's commit leaves them: its place's handle to it, and its own. + if let Some(place) = place { + self.mint(pid, place); + } + self.mint(pid, pid); let ((), retire) = tree::land_child(self, admitted, 137, |world, node| world.insert(pid, node)); assert_eq!(retire, [], "World::land: {place:?} was claimed"); pid } + /// `HandleEntry::new`: `holder`'s table takes a handle to `object`. + pub fn mint(&mut self, object: Pid, holder: Pid) { + if self.retired.contains(&object) { + self.minted_retired.insert(object); + } + self.handles.insert((object, holder)); + } + + /// `teardown_resources` draining `pid`'s table: an object left with no + /// handle is retired. + fn close_table(&mut self, pid: Pid) { + let closed: Vec = + self.handles.iter().filter(|&&(_, holder)| holder == pid).map(|&(object, _)| object).collect(); + for object in closed { + self.handles.remove(&(object, pid)); + if !self.handles.iter().any(|&(held, _)| held == object) { + self.retired.insert(object); + } + } + } + /// 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. @@ -407,6 +443,7 @@ impl World { Out::Free { code, mark } => { *self.frees.entry(pid).or_insert(0) += 1; self.mapped.retain(|&(p, _)| p != pid); + self.close_table(pid); Out::Mark { code, mark } } Out::Mark { code, mark } => { @@ -539,6 +576,11 @@ impl World { for pid in &self.landed_under_claimed { out.push(alloc::format!("pid {pid} landed unclaimed under a place whose end was already claimed")); } + // L13. No handle is minted on an object whose last one has gone: + // `HandleEntry::new` asserts it. + for pid in &self.minted_retired { + out.push(alloc::format!("pid {pid}: a handle to it was minted after its last one had gone")); + } out } From dad3d1677ef793d4e05f97d7f55acdb98d66437e Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 18:47:37 +0200 Subject: [PATCH 5/8] The full-table arms judge once the table has room again Under the control that lets a spawn from a full table succeed, `abuse_handle_table` ended with exit 134 and no message: its `expect_err` panicked with every slot of the table taken. Both arms now take the spawn's answer, close what filled the table, and assert after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- tests/toyos-rust-tests/src/bin/abuse_handle_table.rs | 11 ++++++----- .../src/bin/spawn_child_ends_first.rs | 8 +++----- 2 files changed, 9 insertions(+), 10 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 5c7f6ffe526..5042587c153 100644 --- a/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs +++ b/tests/toyos-rust-tests/src/bin/abuse_handle_table.rs @@ -158,17 +158,18 @@ fn main() { "handle table reached {n} slots, past the {MAX_HANDLES} cap" ); - // The handle this names is closed with the rest below, which ends this - // process if the refused spawn moved it. - let last = *filled.last().expect("the fill installed a handle"); + let last = filled.pop().expect("the fill installed a handle"); let entry = EndowEntry { label_off: 0, label_len: LABELS.len() as u32, handle: last, _pad: 0 }; - let err = spawn_endowed(&[entry], LABELS).expect_err("a spawn from a full table must be refused"); - assert_eq!(err, SyscallError::ResourceExhausted, "wrong error for a spawn from a full table"); + let spawned = spawn_endowed(&[entry], LABELS); // The cap is a live limit, not a latched failure. for handle in filled { syscall::close(handle); } + // Judged once the table has room again: a panic at the cap aborts with no report. + assert_eq!(spawned, Err(SyscallError::ResourceExhausted), "a spawn from a full table was not refused"); + // Still this process's: the close ends it if the refused spawn moved it. + syscall::close(last); let reused = syscall::dup2(RawHandle(1), 3) .expect("dup2 must work again after closing handles"); syscall::close(reused); diff --git a/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs b/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs index 3e5555fcf84..660f7cef2de 100644 --- a/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs +++ b/tests/toyos-rust-tests/src/bin/spawn_child_ends_first.rs @@ -60,14 +60,12 @@ fn a_spawn_from_a_full_table_starts_no_child() { }; assert_eq!(full, SyscallError::ResourceExhausted, "the table did not fill"); hold_the_next_spawn(); - assert_eq!( - spawn("speak", &[[1, ends.write.0]]).err(), - Some(SyscallError::ResourceExhausted), - "a spawn from a full table was not refused" - ); + let spawned = spawn("speak", &[[1, ends.write.0]]); for handle in filled { syscall::close(handle); } + // Judged once the table has room again: a panic at the cap aborts with no report. + assert_eq!(spawned, Err(SyscallError::ResourceExhausted), "a spawn from a full table was not refused"); syscall::close(ends.write); let mut byte = [0u8; 1]; assert_eq!(syscall::read(ends.read, &mut byte), Ok(0), "the refused spawn's child started and spoke"); From 35e5338415900fb2a4991f6324acee2e2f153956 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 18:51:40 +0200 Subject: [PATCH 6/8] The loader's and the model's headers name the spawner's handle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- kernel/src/loader/start.rs | 7 ++++--- toyos-proclife/src/model.rs | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/kernel/src/loader/start.rs b/kernel/src/loader/start.rs index d74b0930d43..d0e3a8ec31b 100644 --- a/kernel/src/loader/start.rs +++ b/kernel/src/loader/start.rs @@ -1,6 +1,7 @@ -//! Loads a built process onto a CPU and builds the handle table it starts -//! with. The frame a new stack starts from and the trampolines it returns into -//! are the architecture's (`arch::entry`). +//! Loads a built process onto a CPU, builds the handle table it starts with +//! and puts its spawner's handle to it in the spawner's table. The frame a new +//! stack starts from and the trampolines it returns into are the +//! architecture's (`arch::entry`). use alloc::vec::Vec; diff --git a/toyos-proclife/src/model.rs b/toyos-proclife/src/model.rs index d2f5fd6c864..0853981820a 100644 --- a/toyos-proclife/src/model.rs +++ b/toyos-proclife/src/model.rs @@ -3,8 +3,9 @@ //! //! `#[cfg(test)]`, so none of it reaches a kernel build. What it adds beyond //! the two traits is the *consequences* a decision hands back and the kernel -//! performs — a watch's post, a retire, a `publish_exit`, an idle -//! pass taking an entry — because the laws worth checking are about the order +//! performs — a watch's post, a retire, a `publish_exit`, a handle minted, a +//! table closed, an idle pass taking an entry — because the laws worth +//! checking are about the order //! those happen in, and a model that only held the two states could not see //! one. //! From 40d7842a8a4f64b8e2d734eca69c19cacbe9f9e7 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 19:48:28 +0200 Subject: [PATCH 7/8] The model's spawn is cut where the kernel gives up its caller's lock, and a sibling's close is a step `PendingHandles::commit` installs the caller's handle to the child in the caller's live table, drops that table's lock, and only then mints the child's own `self`. The model minted both in the section that lands the child and had no close but a teardown's, so it could not see a thread of the spawner closing that handle in the gap. The spawn's section is now two: the commit under the caller's lock, and the child's own handle with the landing once that lock is given up, in the order the kernel has them at this commit. `Op::Close` is `sys_close` by another thread of the spawner on the handle the spawn will answer. This commit is red, and is the measurement: `cargo test -p toyos-proclife` exits 101 on `a_sibling_closing_a_spawns_handle_before_the_spawn_returns`, pid 2: a handle to it was minted after its last one had gone schedule: spawn_under#0 -> spawn_under#0 -> close#1 -> spawn_under#0 which is `HandleEntry::new`'s assert in `endow_self`. The next commit changes the kernel's order and the model's with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- toyos-proclife/src/interleave.rs | 70 ++++++++++++++++++++++++++------ toyos-proclife/src/model.rs | 20 +++++---- 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/toyos-proclife/src/interleave.rs b/toyos-proclife/src/interleave.rs index 3aea5bdda31..01bfeabb617 100644 --- a/toyos-proclife/src/interleave.rs +++ b/toyos-proclife/src/interleave.rs @@ -50,9 +50,10 @@ pub enum Op { /// 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` by `by`'s thread: the admission, the - /// whole of a process built with every lock given up, then the commit — - /// the caller's handles move, and its handle to the child and the child's - /// own are minted — and the landing, whose retires the landing answers. + /// whole of a process built with every lock given up, then the commit + /// under the caller's own lock — its handles move, and its handle to the + /// child is minted — then, with that lock given up, the child's own + /// handle 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. `child` is the process the landing made. SpawnUnder { @@ -72,6 +73,11 @@ pub enum Op { ThreadExit { pid: Pid, tid: Tid, code: i32, pc: u32 }, /// `sys_thread_join`: collect or arm, then re-check. Join { pid: Pid, target: Tid, waiter: Tid, pc: u32 }, + /// `sys_close` by `by`'s thread on its process's handle to `object`, named + /// by the number a spawn will answer: one section, the table's own lock. + /// A close that finds no handle is its caller's own end, which + /// [`Op::Exit`] scripts, and does nothing here. + Close { by: (Pid, Tid), object: Pid, pc: u32 }, /// The idle loop's `reap_finished`. IdlePass { pc: u32 }, } @@ -106,6 +112,9 @@ impl Op { pub fn join(pid: Pid, target: Tid, waiter: Tid) -> Self { Op::Join { pid, target, waiter, pc: 0 } } + pub fn close(by: (Pid, Tid), object: Pid) -> Self { + Op::Close { by, object, pc: 0 } + } pub fn idle_pass() -> Self { Op::IdlePass { pc: 0 } } @@ -116,7 +125,7 @@ impl Op { Op::Exit { pid, tid, .. } | Op::ThreadExit { pid, tid, .. } => Some((pid, tid)), Op::Join { pid, waiter, .. } => Some((pid, waiter)), Op::Kill { by, .. } => by, - Op::SpawnUnder { by, .. } => Some(by), + Op::SpawnUnder { by, .. } | Op::Close { by, .. } => Some(by), Op::Spawn { .. } | Op::IdlePass { .. } => None, } } @@ -129,6 +138,7 @@ impl Op { | Op::Spawn { pc, .. } | Op::ThreadExit { pc, .. } | Op::Join { pc, .. } + | Op::Close { pc, .. } | Op::IdlePass { pc, .. } => *pc == DONE, } } @@ -150,6 +160,7 @@ impl Op { Op::Spawn { .. } => "spawn_thread", Op::ThreadExit { .. } => "thread_exit", Op::Join { .. } => "thread_join", + Op::Close { .. } => "close", Op::IdlePass { .. } => "idle pass", } } @@ -239,32 +250,37 @@ impl Op { match tree::refuse_child(world, taken) { Some(publish) => { *climb = Some(Climb::Publish(publish)); - *pc = 3; + *pc = CLIMB; } None => *pc = DONE, } } - // The commit, then the child lands in the hold that - // inserts it. + // The commit, under the caller's own lock. 1 => { world.move_handles(*by); - let taken = admitted.take().expect("admitted at the first section"); - let made = taken.pid(); // The mutation this feature stages: the caller's // handle waits for the spawn to land. if !cfg!(feature = "mutate-spawner-handle-after-the-landing") { + let made = admitted.as_ref().expect("admitted at the first section").pid(); world.mint(made, by.0); } + *pc = LAND; + } + // With that lock given up: the child's own handle, and + // the child lands in the hold that inserts it. + LAND => { + let taken = admitted.take().expect("admitted at the first section"); + let made = taken.pid(); world.mint(made, made); let ((), owed) = tree::land_child(world, taken, KILLED, |world, node| world.insert(made, node)); world.landed(*place, made); *retire = owed.into_iter().map(|t| (made, t)).collect(); *child = Some(made); - *pc = 2; + *pc = LANDED; } // With the lock given up: the retires the landing answered. - 2 => { + LANDED => { for (victim, thread) in retire.drain(..) { world.post_retire(victim, thread); } @@ -273,7 +289,7 @@ impl Op { // The mutation's install, once the spawn has landed and // its retires are posted. LATE_HANDLE => { - world.mint(child.expect("landed at the second section"), by.0); + world.mint(child.expect("landed two sections back"), by.0); *pc = DONE; } // The failed build let its place's last hold go: this @@ -357,6 +373,11 @@ impl Op { world.leave_kernel((*pid, *waiter)); } } + Op::Close { by, object, pc } => { + world.close(*object, by.0); + world.leave_kernel(*by); + *pc = DONE; + } Op::IdlePass { pc } => { for pid in reap::finished_pids(world) { world.reap(pid); @@ -376,6 +397,12 @@ const WALK: u32 = 2; const WAIT: u32 = 3; /// `mutate-spawner-handle-after-the-landing`'s install. const LATE_HANDLE: u32 = 4; +/// A spawn's landing. +const LAND: u32 = 2; +/// The section that posts the retires a spawn's landing answered. +const LANDED: u32 = 3; +/// A failed spawn's climb. +const CLIMB: u32 = 5; /// 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 @@ -783,6 +810,25 @@ mod tests { holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under_failing(place, own)]); } + /// **A spawner's other thread closes the spawn's handle the moment it is + /// in their table**, every ordering, alone and with the place's kill + /// racing both: a handle's number is its table's own arithmetic, so the + /// handle is nameable before the spawn that answers it has returned. + #[test] + fn a_sibling_closing_a_spawns_handle_before_the_spawn_returns() { + let mut world = World::new(); + let init = world.spawn_process(); + let place = world.spawn_child(init); + let sibling = (place, world.spawn_thread(place)); + let own = (place, world.main_tid(place)); + let Admit::Yes(next) = tree::admit_child(&mut world.clone(), Some(place)) else { + panic!("a live place admitted no child"); + }; + let made = next.pid(); + holds(&world, vec![Op::spawn_under(place, own), Op::close(sibling, made)]); + holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under(place, own), Op::close(sibling, made)]); + } + /// An exit takes a subtree two deep below it, and each end is published /// after every end below it. /// diff --git a/toyos-proclife/src/model.rs b/toyos-proclife/src/model.rs index 0853981820a..a1a09156731 100644 --- a/toyos-proclife/src/model.rs +++ b/toyos-proclife/src/model.rs @@ -3,8 +3,8 @@ //! //! `#[cfg(test)]`, so none of it reaches a kernel build. What it adds beyond //! the two traits is the *consequences* a decision hands back and the kernel -//! performs — a watch's post, a retire, a `publish_exit`, a handle minted, a -//! table closed, an idle pass taking an entry — because the laws worth +//! performs — a watch's post, a retire, a `publish_exit`, a handle minted or +//! closed, an idle pass taking an entry — because the laws worth //! checking are about the order //! those happen in, and a model that only held the two states could not see //! one. @@ -260,16 +260,20 @@ impl World { self.handles.insert((object, holder)); } - /// `teardown_resources` draining `pid`'s table: an object left with no - /// handle is retired. + /// `HandleEntry`'s drop: `holder`'s handle to `object` goes, if it has + /// one, and an object left with no handle is retired. + pub fn close(&mut self, object: Pid, holder: Pid) { + if self.handles.remove(&(object, holder)) && !self.handles.iter().any(|&(held, _)| held == object) { + self.retired.insert(object); + } + } + + /// `teardown_resources` draining `pid`'s table. fn close_table(&mut self, pid: Pid) { let closed: Vec = self.handles.iter().filter(|&&(_, holder)| holder == pid).map(|&(object, _)| object).collect(); for object in closed { - self.handles.remove(&(object, pid)); - if !self.handles.iter().any(|&(held, _)| held == object) { - self.retired.insert(object); - } + self.close(object, pid); } } From c8379761c7fa8d74decb03da290c2d97b825bfb1 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 19:53:25 +0200 Subject: [PATCH 8/8] A spawn mints its child's own handle with the object, and the commit takes it At 35e533841 `PendingHandles::commit` installed the caller's handle to the child in the caller's live table, released that table's lock, and minted the child's `self` after it. In between the object had one handle, in a table every other thread of the caller reaches, and `LockGuard::drop` is a preemption point: a close or a `dup2` over it there retired the object, and `endow_self` then panicked in `HandleEntry::new`. 40d7842a8 shows it in the model. `loader::spawn` now mints the child's `self` as it makes the object (`start::own_handle`) and `commit` takes that `HandleEntry`, not the object. The caller's handle is minted from the entry's object while the entry is held, and the entry then sits in the child's table, which no thread reaches until the child lands. `commit` has no object to mint from but the one inside a handle it holds, so the order that crossed zero is not one it can be written in. The model's commit section mints both, the child's own first. `mutate-spawner-handle-before-the-childs-own` restores 35e533841's order and `src/ci.rs`'s `CONTROLS` runs it: `a_sibling_closing_a_spawns_handle_before_the_spawn_returns` fails on L13. `stats_of`'s doc loses the sentence that named one window in which it answers `None`: a handle that resolves before its process lands is a second. `toyos_abi::syscall::spawn`'s doc says the room for the answer is counted before, and regardless of, what the endowments free. A kill on the handle before the child lands still claims nothing; that is filed as issues/kernel/a-kill-on-a-spawns-handle-before-its-child-lands-claims-nothing.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- ...e-before-its-child-lands-claims-nothing.md | 29 +++++++++++++++++++ kernel/src/loader/mod.rs | 8 ++--- kernel/src/loader/start.rs | 23 ++++++++++----- kernel/src/process.rs | 2 +- src/ci.rs | 3 ++ toyos-abi/src/syscall.rs | 3 +- toyos-proclife/Cargo.toml | 7 +++++ toyos-proclife/src/interleave.rs | 22 ++++++++++---- 8 files changed, 77 insertions(+), 20 deletions(-) create mode 100644 issues/kernel/a-kill-on-a-spawns-handle-before-its-child-lands-claims-nothing.md diff --git a/issues/kernel/a-kill-on-a-spawns-handle-before-its-child-lands-claims-nothing.md b/issues/kernel/a-kill-on-a-spawns-handle-before-its-child-lands-claims-nothing.md new file mode 100644 index 00000000000..3689e076bcc --- /dev/null +++ b/issues/kernel/a-kill-on-a-spawns-handle-before-its-child-lands-claims-nothing.md @@ -0,0 +1,29 @@ +--- +status: open +kind: defect +opened: 2026-10-02 +--- + +# A kill on a spawn's handle before its child lands claims nothing + +`PendingHandles::commit` (`kernel/src/loader/start.rs`) puts the spawner's +handle to its child in the spawner's table before `loader::spawn` lands the +child in the process table, and a handle's number is its table's own +arithmetic, so another thread of the spawner can name the handle in between. +A `SYS_PROCESS_KILL` on it there claims nothing: +`toyos_proclife::teardown::claim_teardown` answers `false` for a pid not in +the table, and `process::kill_process` answers `Ok`. The child then lands and +runs. `kill_process`'s "`Ok` for an already-gone process: the caller asked for +it to be dead and it is" does not cover a process not yet there. + +Read off the code; no test reaches it, and no caller can order a kill inside +a spawn. A close, a dup, a transfer, a wait and a stats read of the handle in +the same window are sound. + +**Owner**: the spawn's commit, `kernel/src/loader/start.rs`. + +*Exit*: a kill on a handle whose process has not landed ends that process — +the landing claims it, as it claims a child under a claimed place — or the +handle resolves only once the child has landed; `toyos-proclife` scripts the +kill as a step between the commit and the landing, and no schedule leaves the +child running. diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs index 1ffd00ac0b3..df9809060e7 100644 --- a/kernel/src/loader/mod.rs +++ b/kernel/src/loader/mod.rs @@ -337,8 +337,8 @@ fn rela_dyn_from_sections( /// Load a program and place its main thread under `parent`, answering its pid /// and what `commit` left its caller holding of it. `commit` builds the -/// child's handle table around the child's own object, once nothing is left -/// to refuse. +/// child's handle table around the child's handle to itself, once nothing is +/// left to refuse. /// /// `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 @@ -350,7 +350,7 @@ fn rela_dyn_from_sections( /// unwinds, so the error must travel out as a value rather than strand it. pub fn spawn( argv: &[&str], - commit: impl FnOnce(KObjectRef) -> Result<(HandleTable, Endowments, H), crate::object::Refusal>, + commit: impl FnOnce(crate::object::HandleEntry) -> Result<(HandleTable, Endowments, H), crate::object::Refusal>, cwd: String, env: Vec, image: Option>, @@ -592,7 +592,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. - let (handles, endowments, held) = commit(KObjectRef::Process(Arc::clone(&object)))?; + let (handles, endowments, held) = commit(start::own_handle(&object))?; let proc_data = Arc::new(Lock::new(ProcessData { handles, cwd, diff --git a/kernel/src/loader/start.rs b/kernel/src/loader/start.rs index d0e3a8ec31b..5d42b4c7ae8 100644 --- a/kernel/src/loader/start.rs +++ b/kernel/src/loader/start.rs @@ -3,8 +3,10 @@ //! stack starts from and the trampolines it returns into are the //! architecture's (`arch::entry`). +use alloc::sync::Arc; use alloc::vec::Vec; +use crate::object::process::ProcessObject; use crate::object::{ops, HandleEntry, HandleTable, KObjectRef, Refusal}; use crate::process::{ process_data, Endowments, OwnedAlloc, ENDOW_ENTRY_LEN, KERNEL_STACK_SIZE, @@ -52,9 +54,9 @@ pub struct PendingHandles { } impl PendingHandles { - /// Take the endowed handles out of the caller's table and put its handle to `own`, the child, in it, all under one lock hold: a refusal leaves the table unchanged. - /// The child's table holds `own` under [`SELF_LABEL`] beside the endowments, and the caller's handle is the third answer. - pub fn commit(self, own: KObjectRef) -> Result<(HandleTable, Endowments, RawHandle), Refusal> { + /// Take the endowed handles out of the caller's table and put its handle to the child in it, all under one lock hold: a refusal leaves the table unchanged. + /// `own` is the child's handle to itself, which its table holds under [`SELF_LABEL`] beside the endowments, and the caller's handle is the third answer. + pub fn commit(self, own: HandleEntry) -> Result<(HandleTable, Endowments, RawHandle), Refusal> { let Self { mut table, endow, mut labels } = self; let data_arc = process_data(); let mut data = data_arc.lock(); @@ -103,8 +105,8 @@ impl PendingHandles { .expect("a child table with verified room refused an endowment"); entries.push(entry); } - // In this hold, before the child can run: its object's handle count never reaches zero while the spawn is in flight. - let held = ops::install(&mut data.handles, own.clone()) + // Minted while `own` is held, and `own` goes into a table no thread reaches until the child lands: another thread of the caller closing this handle never closes the object's last. + let held = ops::install(&mut data.handles, own.object().clone()) .expect("a caller's table with verified room refused its child"); drop(data); endow_self(&mut table, &mut entries, &mut labels, own); @@ -112,11 +114,16 @@ impl PendingHandles { } } -/// 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. -pub(super) fn endow_self(table: &mut HandleTable, entries: &mut Vec, labels: &mut Vec, own: KObjectRef) { +/// A new process's handle to itself, the first its object has: `WRITE` to be named a spawn's place, `DUP` and `TRANSFER` to hand that on. +pub(super) fn own_handle(object: &Arc) -> HandleEntry { let rights = Rights::WRITE.union(Rights::DUP).union(Rights::TRANSFER); + HandleEntry::new(KObjectRef::Process(Arc::clone(object)), rights) +} + +/// Install `own` in its own table under [`SELF_LABEL`]. Its caller verified the room. +pub(super) fn endow_self(table: &mut HandleTable, entries: &mut Vec, labels: &mut Vec, own: HandleEntry) { let handle = table - .install(HandleEntry::new(own, rights)) + .install(own) .expect("a child table with verified room refused its own handle"); entries.push(EndowEntry { label_off: labels.len() as u32, diff --git a/kernel/src/process.rs b/kernel/src/process.rs index 79a1a40c654..54ef3ee91e4 100644 --- a/kernel/src/process.rs +++ b/kernel/src/process.rs @@ -751,7 +751,7 @@ pub fn try_for_each_thread(mut f: impl FnMut(ThreadCensus<'_>)) -> bool { true } -/// Accounting for a process. `None` only in the window between a live process and its published exit (the process being torn down right now). +/// Accounting for a process. pub fn stats_of( object: &crate::object::process::ProcessObject, ) -> Option { diff --git a/src/ci.rs b/src/ci.rs index 5b7daed1f93..4ff74000bfc 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -410,6 +410,9 @@ pub(crate) const CONTROLS: &[Control] = &[ Fails("interleave::tests::a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it"), Fails("interleave::tests::a_spawn_racing_the_kill_of_its_own_spawner"), ]), + red(PROCLIFE, "mutate-spawner-handle-before-the-childs-own", None, &[ + Fails("interleave::tests::a_sibling_closing_a_spawns_handle_before_the_spawn_returns"), + ]), red(SCHED_SIM, "placement-ignores-staleness", Some("policy"), &[ Fails("a_stopped_cpu_stops_taking_work"), ]), diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs index bf795b68983..8f07d596494 100644 --- a/toyos-abi/src/syscall.rs +++ b/toyos-abi/src/syscall.rs @@ -943,7 +943,8 @@ pub fn get_env(buf: &mut [u8]) -> usize { /// wants to hand it on transfers it. There is no pid-addressed way back to a /// process, so this handle is the whole of what a spawn confers. Its slot is /// taken before an endowment moves: a caller whose table has none is refused -/// `ResourceExhausted` with its table as it was. +/// `ResourceExhausted` with its table as it was, whatever its endowments +/// would have freed. /// /// # Safety /// The raw pointer fields in `SpawnArgs` must point to valid memory. diff --git a/toyos-proclife/Cargo.toml b/toyos-proclife/Cargo.toml index c410560e123..8c61bfca070 100644 --- a/toyos-proclife/Cargo.toml +++ b/toyos-proclife/Cargo.toml @@ -94,6 +94,13 @@ mutate-walk-in-one-hold = [] # `interleave::tests::a_spawn_racing_its_places_kill_leaves_nothing_under_it_and_publishes_it` # must red under this. mutate-spawner-handle-after-the-landing = [] +# The model's spawn mints its caller's handle to the child under the caller's +# lock and the child's own once that lock is given up, as a kernel whose commit +# installed the one before it minted the other would: another thread of the +# caller closes the only handle the object has. +# `interleave::tests::a_sibling_closing_a_spawns_handle_before_the_spawn_returns` +# must red under this. +mutate-spawner-handle-before-the-childs-own = [] [dependencies] toyos-abi = { path = "../toyos-abi" } diff --git a/toyos-proclife/src/interleave.rs b/toyos-proclife/src/interleave.rs index 01bfeabb617..494524afbea 100644 --- a/toyos-proclife/src/interleave.rs +++ b/toyos-proclife/src/interleave.rs @@ -52,8 +52,8 @@ pub enum Op { /// `loader::spawn` under `place` by `by`'s thread: the admission, the /// whole of a process built with every lock given up, then the commit /// under the caller's own lock — its handles move, and its handle to the - /// child is minted — then, with that lock given up, the child's own - /// handle and the landing, whose retires the landing answers. + /// child is minted beside the child's own — then, with that lock given + /// up, 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. `child` is the process the landing made. SpawnUnder { @@ -258,20 +258,27 @@ impl Op { // The commit, under the caller's own lock. 1 => { world.move_handles(*by); + let made = admitted.as_ref().expect("admitted at the first section").pid(); + // The mutation this feature stages: the child's own + // handle waits for the caller's lock to be given up. + if !cfg!(feature = "mutate-spawner-handle-before-the-childs-own") { + world.mint(made, made); + } // The mutation this feature stages: the caller's // handle waits for the spawn to land. if !cfg!(feature = "mutate-spawner-handle-after-the-landing") { - let made = admitted.as_ref().expect("admitted at the first section").pid(); world.mint(made, by.0); } *pc = LAND; } - // With that lock given up: the child's own handle, and - // the child lands in the hold that inserts it. + // With that lock given up: the child lands in the hold + // that inserts it. LAND => { let taken = admitted.take().expect("admitted at the first section"); let made = taken.pid(); - world.mint(made, made); + if cfg!(feature = "mutate-spawner-handle-before-the-childs-own") { + world.mint(made, made); + } let ((), owed) = tree::land_child(world, taken, KILLED, |world, node| world.insert(made, node)); world.landed(*place, made); @@ -814,6 +821,9 @@ mod tests { /// in their table**, every ordering, alone and with the place's kill /// racing both: a handle's number is its table's own arithmetic, so the /// handle is nameable before the spawn that answers it has returned. + /// + /// Reds under `mutate-spawner-handle-before-the-childs-own`, where that + /// handle is the object's only one until the caller's lock is given up. #[test] fn a_sibling_closing_a_spawns_handle_before_the_spawn_returns() { let mut world = World::new();