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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions issues/diagnostics/the-kernel-keeps-nothing-it-enumerates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,6 @@ directory is `issues/isolation/every-program-sees-only-the-files-it-was-given.md

## Stages

0. **`SYS_PROCESS_OPEN` goes**. 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**. `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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.

This file was deleted.

34 changes: 8 additions & 26 deletions issues/kernel/the-capability-end-state-is-twelve-answers.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +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`); `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`).

**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`
Expand Down Expand Up @@ -100,20 +90,13 @@ 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
reused — 26 `SYS_WAITPID`, 33 `SYS_FIND_PID`, 37 `SYS_GRANT_SHARED`, 65
`SYS_KILL` (`4a98107f^:kernel/src/arch/syscall.rs:63`).

## 4. Can a process enumerate objects it lacks authority over? — RULED 2026-08-20, IMPLEMENTED 2026-08-22

Expand Down Expand Up @@ -291,11 +274,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`
Expand Down
3 changes: 0 additions & 3 deletions kernel/src/actuator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,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";

/// Have Ctrl+Alt+D's report painter go fatal holding the panel's latch: a
/// fatal path meeting a painter that will never let go.
panel_painter_stalls = "panel-painter-stalls";
Expand Down
50 changes: 28 additions & 22 deletions kernel/src/loader/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 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
Expand All @@ -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<H>(
argv: &[&str],
pending: PendingHandles,
commit: impl FnOnce(crate::object::HandleEntry) -> Result<(HandleTable, Endowments, H), crate::object::Refusal>,
cwd: String,
env: Vec<u8>,
image: Option<Arc<dyn crate::file_backing::FileBacking>>,
parent: Parent,
) -> Result<Arc<crate::object::process::ProcessObject>, 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());
Expand All @@ -366,7 +368,7 @@ pub fn spawn(
let backing: Arc<dyn crate::file_backing::FileBacking> = 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,
Expand Down Expand Up @@ -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(start::own_handle(&object))?;
let proc_data = Arc::new(Lock::new(ProcessData {
handles,
cwd,
Expand Down Expand Up @@ -671,13 +673,16 @@ 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,
(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.
Expand Down Expand Up @@ -876,7 +881,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)
Expand All @@ -886,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}"),
}
Expand Down
58 changes: 31 additions & 27 deletions kernel/src/loader/start.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
//! 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::sync::Arc;
use alloc::vec::Vec;

use crate::object::{HandleEntry, HandleTable, KObjectRef, Refusal};
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,
};
Expand Down Expand Up @@ -42,26 +45,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<EndowEntry>, labels: Vec<u8> },
/// A caller's request: `endow` has not left the caller's table yet.
Moving { table: HandleTable, endow: Vec<u8>, labels: Vec<u8> },
/// 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<u8>,
labels: Vec<u8>,
}

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 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();

Expand Down Expand Up @@ -94,7 +90,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());
}

Expand All @@ -109,17 +105,25 @@ impl PendingHandles {
.expect("a child table with verified room refused an endowment");
entries.push(entry);
}
// 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);
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<EndowEntry>, labels: &mut Vec<u8>, 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<ProcessObject>) -> 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<EndowEntry>, labels: &mut Vec<u8>, 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,
Expand Down Expand Up @@ -200,5 +204,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() })
}
Loading
Loading