From 60ec86df3edb338709f90defe7c873eebbfc87a3 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 30 Sep 2026 23:34:28 +0200 Subject: [PATCH 1/9] A child's end is readiness on its Process handle Stage 1 of issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md, as ruled: an OP_WATCH READABLE on a Process completes once the exit is published, closing one handle ends no other's watch, and the ABI gains nothing. - ProcessObject's watch becomes an Arc, as a port's is, so ops::read_watch can answer a share of it; SYS_PROCESS_WAIT arms on the same watch and the exit's publish is still its one post. - ops::read_watch answers a Process with that watch and ops::has_data with ProcessObject::finished, so a registration on a child already gone completes in the submit that makes it and one before the end is completed by the publish. - ops::close_ends_polls answers false for a Process: a process is ended by its own end, never by one holder letting a handle go. process_lifecycle gains four arms: three held children in one poller, let go one at a time, each completion naming the child just released and its code there for try_wait; a watch on a child already gone completing at once beside a held one that does not; a kill completing a watch; and a second handle closed while the first is watched, a non-blocking submit finding nothing until the child is let go. init's service waiters still park a thread each on SYS_PROCESS_WAIT; the sentence giving the reason, that no poll could watch a process's end, is no longer true and goes. Moving them onto init's poller is the rest of stage 1. Negative control: the change reverted whole, where a watch on a Process is refused NotSupported and every watch in the first arm completes at the first submit. Mutations: close_ends_polls answering true for a Process reds the close arm; has_data answering false reds the already-gone arm. Oracle: pidfd_open(2), whose descriptor polls readable once the process it refers to terminates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- kernel/src/object/ops.rs | 14 ++- kernel/src/object/process.rs | 15 +-- .../src/bin/process_lifecycle.rs | 92 +++++++++++++++++++ userland/init/src/main.rs | 4 +- 4 files changed, 111 insertions(+), 14 deletions(-) diff --git a/kernel/src/object/ops.rs b/kernel/src/object/ops.rs index 1d4265044b9..fabf8b07854 100644 --- a/kernel/src/object/ops.rs +++ b/kernel/src/object/ops.rs @@ -272,6 +272,8 @@ pub fn read_watch(object: &KObjectRef) -> Option { KObjectRef::PipeRead(r) => pipe::read_watch(r.id()).map(WatchRef::Shared), KObjectRef::Connection(c) => pipe::read_watch(c.rx()).map(WatchRef::Shared), KObjectRef::Acceptor(a) => Some(WatchRef::Shared(a.watch().clone())), + // Readable once its exit is published, the one post its watch gets. + KObjectRef::Process(p) => Some(WatchRef::Shared(p.watch().clone())), KObjectRef::Console(_) => Some(WatchRef::Static(&keyboard::WATCH)), KObjectRef::Device(d) => match d.class() { device_registry::DeviceType::Keyboard => Some(WatchRef::Static(&keyboard::WATCH)), @@ -290,7 +292,7 @@ pub fn read_watch(object: &KObjectRef) -> Option { KObjectRef::SysCap(_) => Some(WatchRef::Static(&crate::log::user::WATCH)), KObjectRef::PipeWrite(_) | KObjectRef::File(_) | KObjectRef::Inbox(_) | KObjectRef::Connector(_) | KObjectRef::Namespace(_) - | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => None, + | KObjectRef::SharedMem(_) => None, } } @@ -311,10 +313,13 @@ pub fn write_watch(object: &KObjectRef) -> Option { /// Whether closing one handle to this object ends what its watches watch, so /// every poll on them — in any ring — is answered as gone. `false` for the log /// and the keyboard, which the machine ends on its own and which other handles -/// share: a console closing is not every console's keyboard going away. +/// share: a console closing is not every console's keyboard going away. `false` +/// for a process, which only its own end ends: closing one handle to it ends no +/// other's watch. fn close_ends_polls(object: &KObjectRef) -> bool { match object { KObjectRef::SysCap(_) => crate::actuator::log_close_cancels_any_syscap(), + KObjectRef::Process(_) => false, // A keyboard *claim* closing is the stimulus, not a `SysCap`. KObjectRef::Console(_) => crate::actuator::keyboard_close_cancels_every_console(), KObjectRef::Device(d) => match d.class() { @@ -331,7 +336,7 @@ fn close_ends_polls(object: &KObjectRef) -> bool { KObjectRef::PipeRead(_) | KObjectRef::PipeWrite(_) | KObjectRef::Connection(_) | KObjectRef::Acceptor(_) | KObjectRef::File(_) | KObjectRef::Inbox(_) | KObjectRef::Connector(_) | KObjectRef::Namespace(_) - | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => true, + | KObjectRef::SharedMem(_) => true, } } @@ -799,6 +804,7 @@ pub fn has_data(object: &KObjectRef) -> bool { KObjectRef::Connection(c) => pipe::has_data(c.rx()), KObjectRef::Console(_) => serial::has_data(), KObjectRef::Acceptor(a) => a.has_pending(), + KObjectRef::Process(p) => p.finished(), KObjectRef::File(_) => true, KObjectRef::Device(d) => match d.class() { device_registry::DeviceType::Keyboard => keyboard::has_data(), @@ -817,7 +823,7 @@ pub fn has_data(object: &KObjectRef) -> bool { }, KObjectRef::PipeWrite(_) | KObjectRef::Inbox(_) | KObjectRef::SysCap(_) | KObjectRef::Connector(_) | KObjectRef::Namespace(_) - | KObjectRef::SharedMem(_) | KObjectRef::Process(_) => false, + | KObjectRef::SharedMem(_) => false, } } diff --git a/kernel/src/object/process.rs b/kernel/src/object/process.rs index d8438b6ca7d..8fe56756fff 100644 --- a/kernel/src/object/process.rs +++ b/kernel/src/object/process.rs @@ -1,9 +1,9 @@ //! A process, as something a handle can name. //! //! The exit code lives on the object, not a table entry: no zombie, no reap, -//! no orphan adoption. A wait after the fact reads a value; a wait before it -//! parks and is woken by the publish. A process nobody holds a handle to -//! disappears. +//! no orphan adoption. A wait after the fact reads a value and an `OP_WATCH` +//! completes at once; before it, the wait parks, the watch registers, and the +//! publish answers both. A process nobody holds a handle to disappears. use alloc::sync::Arc; use core::sync::atomic::{AtomicBool, Ordering}; @@ -29,8 +29,9 @@ pub struct ProcessObject { exit: Lock>, /// The same fact, without the lock, for a waiter's per-wake predicate. finished: AtomicBool, - /// What `SYS_PROCESS_WAIT` arms on; holding the `Arc` across the park keeps the watch from outliving its subject. - watch: Watch, + /// What `SYS_PROCESS_WAIT` arms on and an `OP_WATCH` registers on; an `Arc` + /// so a poll's registration holds it with no object borrowed. + watch: Arc, } impl ProcessObject { @@ -40,7 +41,7 @@ impl ProcessObject { pid, exit: Lock::new(None), finished: AtomicBool::new(false), - watch: Watch::new(), + watch: Arc::new(Watch::new()), }) } @@ -61,7 +62,7 @@ impl ProcessObject { self.exit.lock().as_ref().map(|e| e.stats) } - pub fn watch(&self) -> &Watch { + pub fn watch(&self) -> &Arc { &self.watch } diff --git a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs index f47f83c593c..19aca58cf89 100644 --- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs +++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs @@ -19,6 +19,12 @@ //! child's exit while the wait is parked on it, which used to return the wait //! and panic the kernel on the exit code that was not there yet. //! +//! **An end is also an event**: an `OP_WATCH` on the handle completes once the +//! exit is published, so one poller waits for any number of children beside +//! whatever else it watches. Four arms are this paragraph's: each completion +//! names the child that ended, a watch on one already gone completes at once, a +//! kill completes one, and closing one handle ends no other's watch. +//! //! Two roles besides the test. `held` exits with a code of the parent's //! choosing, but not until its stdin closes — which is what lets every arm here //! order the exit against the wait without a clock deciding anything. `waiter` @@ -32,6 +38,7 @@ use std::sync::OnceLock; use toyos::endow::{Endowments, SYSCAP_LABEL}; use toyos::AsHandle; +use toyos::poller::{Poller, READABLE}; use toyos::process::Process; use toyos::syscap::SysCap; use toyos_abi::syscall::{self, SyscallError}; @@ -64,6 +71,10 @@ fn test() { an_unrelated_wake_does_not_end_the_wait(); two_handles_answer_the_same(); a_kill_publishes_like_an_exit(); + each_end_completes_its_own_watch(); + a_watch_on_an_ended_child_completes_at_once(); + a_kill_completes_a_watch(); + closing_one_handle_ends_no_other_watch(); a_handle_is_the_whole_of_the_right(); a_pid_is_not_authority(); an_undefined_wait_flag_bit_is_refused(); @@ -247,6 +258,87 @@ fn a_kill_publishes_like_an_exit() { println!(" a kill publishes {KILLED} once, and a second kill changes nothing"); } +/// Three held children in one poller, each watched under its index, let go +/// one at a time and out of order. Before each release nothing is ready; after +/// it the one completion names that child, and its code is there without a +/// wait. +fn each_end_completes_its_own_watch() { + const CODES: [i32; 3] = [21, 22, 23]; + let mut held: Vec<(Child, Option)> = + CODES.iter().map(|&code| start(code)).map(|(child, stdin)| (child, Some(stdin))).collect(); + let poller = Poller::new(CODES.len() as u32); + let mut running: Vec = (0..CODES.len()).collect(); + for released in [1, 2, 0] { + for &i in &running { + poller.watch_raw(RawHandle(held[i].0.as_raw_handle()), READABLE, i as u64); + } + poller.wait(0, 0, |token| panic!("child {token} is held, and its watch completed")); + drop(held[released].1.take()); + let mut ended = Vec::new(); + poller.wait(1, u64::MAX, |token| ended.push(token)); + assert_eq!(ended, [released as u64], "child {released} was let go"); + let status = held[released].0.try_wait().expect("try_wait"); + assert_eq!(status.and_then(|s| s.code()), Some(CODES[released]), "child {released}'s code"); + running.retain(|&i| i != released); + } + println!(" one poller: each end completes the watch of the child that ended, and only it"); +} + +/// The registration finds the end already published and completes in the +/// submit that makes it; a held child watched beside it does not. +fn a_watch_on_an_ended_child_completes_at_once() { + const GONE: u64 = 0; + const HELD: u64 = 1; + let (mut gone, release) = start(13); + drop(release); + assert_eq!(gone.wait().expect("wait").code(), Some(13), "the child that ends first"); + let (mut held, keep) = start(0); + let poller = Poller::new(2); + poller.watch_raw(RawHandle(gone.as_raw_handle()), READABLE, GONE); + poller.watch_raw(RawHandle(held.as_raw_handle()), READABLE, HELD); + let mut ready = Vec::new(); + poller.wait(0, 0, |token| ready.push(token)); + assert_eq!(ready, [GONE], "a non-blocking submit of both watches"); + drop(keep); + assert_eq!(held.wait().expect("wait").code(), Some(0), "the held child"); + println!(" a watch on a child already gone completes at once"); +} + +/// A kill ends the child without its cooperation, and its end completes a +/// watch as an exit's does. +fn a_kill_completes_a_watch() { + let (mut child, _release) = start(0); + let poller = Poller::new(1); + poller.watch_raw(RawHandle(child.as_raw_handle()), READABLE, 0); + poller.wait(0, 0, |token| panic!("the held child's watch completed ({token})")); + child.kill().expect("kill"); + let mut ended = Vec::new(); + poller.wait(1, u64::MAX, |token| ended.push(token)); + assert_eq!(ended, [0], "the killed child's watch"); + let status = child.try_wait().expect("try_wait"); + assert_eq!(status.and_then(|s| s.code()), Some(KILLED), "the killed child's code"); + println!(" a kill completes a watch"); +} + +/// A second handle closed while the first is watched: the child did not end, +/// so nothing completes, and the watch still answers the end when it comes. +fn closing_one_handle_ends_no_other_watch() { + let (mut child, release) = start(17); + let second = syscall::dup(RawHandle(child.as_raw_handle())).expect("a Process handle duplicates"); + let poller = Poller::new(1); + poller.watch_raw(RawHandle(child.as_raw_handle()), READABLE, 0); + poller.wait(0, 0, |token| panic!("the held child's watch completed ({token})")); + syscall::close(second); + poller.wait(0, 0, |token| panic!("closing a second handle answered the first's watch ({token})")); + drop(release); + let mut ended = Vec::new(); + poller.wait(1, u64::MAX, |token| ended.push(token)); + assert_eq!(ended, [0], "the watch that outlived the close"); + let status = child.try_wait().expect("try_wait"); + assert_eq!(status.and_then(|s| s.code()), Some(17), "the child's code"); + println!(" closing one handle to a child ends no other handle's watch"); +} + /// **The arm the pid-keyed shape could not have.** The waiter did not spawn the /// subject, is not its parent by any spelling, and holds nothing but a handle /// somebody moved into its table — and that is enough. diff --git a/userland/init/src/main.rs b/userland/init/src/main.rs index 7fa2469fc46..d28acdd0ec8 100644 --- a/userland/init/src/main.rs +++ b/userland/init/src/main.rs @@ -688,9 +688,7 @@ impl std::fmt::Display for StartError { /// Wait for one start of a service to end, and close its ports if nothing was /// expecting it to, or wake the loop to start it again if its row says so. /// -/// **A thread, because the kernel answers a process's end to a wait and to -/// nothing a poll can watch.** It parks in the kernel for the process's life -/// and costs nothing until then. +/// It parks in the kernel for the process's life and costs nothing until then. fn close_when_it_ends(kept: &Mutex, generation: u64, process: toyos::RawHandle) { let _ = toyos_abi::syscall::process_wait(process); toyos_abi::syscall::close(process); From 1a3636989f350c980d77cb23356ff8dd02d0e776 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 23:16:17 +0200 Subject: [PATCH 2/9] A Process's readiness is what its watch's look reads, on #655's contract 60ec86df3 was written against a kernel where an object's post completed every poll on its watch: the publish's post was the answer to a watch on a child, and has_data's Process arm mattered only to a registration made after the end. #655 took the answer from the post. A post fires a poll, which owes it a look, and the ring's own submitter writes the completion after reading ops::has_data again; an object not ready at the look is armed again (kernel/src/inbox/mod.rs, Submitter::look). Nothing this branch added became unnecessary. What each line is for moved: - has_data's Process arm, ProcessObject::finished, is what the look of every watch on a process reads, not the late registration's alone. - read_watch's Process arm is what lets a watch on a live child register: inbox::arm refuses NotSupported a poll with no readiness and no watch. - read_posts_are_readiness keeps a Process in main's false arm, with no change here: the kernel holds the fact the look reads, so the post is not the readiness. - close_ends_polls answers false for a Process, as before: ops::close cancels every poll on the read watch of a kind it answers true for. publish_exit stores `finished` before it posts, as it already did for SYS_PROCESS_WAIT's predicate; the look rests on the same order. The prose that had the post answer goes: read_watch's comment, and the sentence of process.rs's header in which the publish answered the watch. The watch's field doc is main's sentence with the reason for its own Arc. process_lifecycle's first arm watches each child once, not again each round. On #655's kernel a watch replaces its handle's earlier poll, so the re-watch made every completion the newest registration's; watched once, the arm also holds that a standing poll outlives two other children's ends. The track's stage 1 is cut to what is left of it: init's waiter threads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- ...event-and-a-parent-takes-its-children-down.md | 14 ++++---------- kernel/src/object/ops.rs | 1 - kernel/src/object/process.rs | 11 ++++++----- .../src/bin/process_lifecycle.rs | 16 +++++++--------- 4 files changed, 17 insertions(+), 25 deletions(-) diff --git a/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index cae76ce5baa..42b6a2ffa14 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 @@ -37,16 +37,10 @@ directory is `issues/isolation/every-program-sees-only-the-files-it-was-given.md ## Stages -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. - *Exit*: children held on their stdin (`process_lifecycle`'s `held` role), - watched in one poller and released one at a time, each completion naming the - child just released, whose code `try_wait` then reads; a watch on a child - already gone completes at once; a kill completes one; after one of two - handles to a held child closes, a non-blocking submit finds nothing. - Negative control: the stage reverted whole (`NotSupported`); mutation: - `close_ends_polls` at `true` reds the last arm. Oracle: pidfd_open(2). +1. **init's waiter threads go**. A child's end is readiness on its handle, + and init still parks one thread per service on `SYS_PROCESS_WAIT` + (`close_when_it_ends`, `userland/init/src/main.rs`). *Exit*: init starts no + thread to wait for a service's end, and `close_when_it_ends` is gone. 2. **An end says how**. An end reads as an exit, a kill or a fault kind alike on every architecture, and a bare code reads the last two as failures. No end reads as a quit's reason. *Exit*: exited 137, killed, and each fault diff --git a/kernel/src/object/ops.rs b/kernel/src/object/ops.rs index b528730e87d..d734e59602f 100644 --- a/kernel/src/object/ops.rs +++ b/kernel/src/object/ops.rs @@ -272,7 +272,6 @@ pub fn read_watch(object: &KObjectRef) -> Option { KObjectRef::PipeRead(r) => pipe::read_watch(r.id()).map(WatchRef::Shared), KObjectRef::Connection(c) => pipe::read_watch(c.rx()).map(WatchRef::Shared), KObjectRef::Acceptor(a) => Some(WatchRef::Shared(a.watch().clone())), - // Readable once its exit is published, the one post its watch gets. KObjectRef::Process(p) => Some(WatchRef::Shared(p.watch().clone())), KObjectRef::Console(_) => Some(WatchRef::Static(&keyboard::WATCH)), KObjectRef::Device(d) => match d.class() { diff --git a/kernel/src/object/process.rs b/kernel/src/object/process.rs index 9713650a662..45ec53263db 100644 --- a/kernel/src/object/process.rs +++ b/kernel/src/object/process.rs @@ -1,9 +1,10 @@ //! A process, as something a handle can name. //! //! The exit code lives on the object, not a table entry: no zombie, no reap, -//! no orphan adoption. A wait after the fact reads a value and an `OP_WATCH` -//! completes at once; before it, the wait parks, the watch registers, and the -//! publish answers both. A process nobody holds a handle to disappears. +//! no orphan adoption. A wait after the fact reads a value; a wait before it +//! parks and is woken by the publish. An `OP_WATCH` reads the same fact at its +//! look, so a handle is readable from the publish on. A process nobody holds a +//! handle to disappears. use alloc::sync::Arc; use core::sync::atomic::{AtomicBool, Ordering}; @@ -29,8 +30,8 @@ pub struct ProcessObject { exit: Lock>, /// The same fact, without the lock, for a waiter's per-wake predicate. finished: AtomicBool, - /// What `SYS_PROCESS_WAIT` arms on and an `OP_WATCH` registers on; an `Arc` - /// so a poll's registration holds it with no object borrowed. + /// What `SYS_PROCESS_WAIT` arms on; holding the `Arc` across the park keeps the watch from outliving its subject. + /// An `Arc` of its own, as a port's is, for the share `ops::read_watch` answers. watch: Arc, } diff --git a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs index 5b5c51a0d05..d450026f0fa 100644 --- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs +++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs @@ -252,20 +252,19 @@ fn a_kill_publishes_like_an_exit() { println!(" a kill publishes {KILLED} once, and a second kill changes nothing"); } -/// Three held children in one poller, each watched under its index, let go -/// one at a time and out of order. Before each release nothing is ready; after -/// it the one completion names that child, and its code is there without a -/// wait. +/// Three held children in one poller, each watched once under its index, let +/// go one at a time and out of order. Before each release nothing is ready; +/// after it the one completion names that child, and its code is there without +/// a wait. fn each_end_completes_its_own_watch() { const CODES: [i32; 3] = [21, 22, 23]; let mut held: Vec<(Child, Option)> = CODES.iter().map(|&code| start(code)).map(|(child, stdin)| (child, Some(stdin))).collect(); let poller = Poller::new(CODES.len() as u32); - let mut running: Vec = (0..CODES.len()).collect(); + for (i, (child, _)) in held.iter().enumerate() { + poller.watch_raw(RawHandle(child.as_raw_handle()), READABLE, i as u64); + } for released in [1, 2, 0] { - for &i in &running { - poller.watch_raw(RawHandle(held[i].0.as_raw_handle()), READABLE, i as u64); - } poller.wait(0, 0, |token| panic!("child {token} is held, and its watch completed")); drop(held[released].1.take()); let mut ended = Vec::new(); @@ -273,7 +272,6 @@ fn each_end_completes_its_own_watch() { assert_eq!(ended, [released as u64], "child {released} was let go"); let status = held[released].0.try_wait().expect("try_wait"); assert_eq!(status.and_then(|s| s.code()), Some(CODES[released]), "child {released}'s code"); - running.retain(|&i| i != released); } println!(" one poller: each end completes the watch of the child that ended, and only it"); } From 352d2df07687ea7a44ff8fe1a6e26d6c55f7f2db Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 23:16:17 +0200 Subject: [PATCH 3/9] toyos-proclife: a watch on a process is answered by its published end and by no close The model held a process's watch and its handles and scripted nothing that armed on one: publish_exit posted Watch::Process to no waiter, so no law could red on what answers a watch on a child. Op::Watch is OP_WATCH on a holder's handle to a process, cut where the kernel's registration is: the poll registered on the object's watch, then the look inbox::arm owes it, which answers an end published in the window. L14, checked at every state: a wait or a watch on a process is answered only with that process's exit published. a_watch_on_a_process_is_answered_by_its_end_and_by_no_close runs every ordering of a parent's watch on its child, the child closing its own `self`, and a kill of the child. L4 holds the watch answered once the end is published, registered before it or after; L14 holds it unanswered until then. mutate-close-ends-a-process-watch is its control, in src/ci.rs's CONTROLS: the model's close posts the watch of the process its handle named, as ops::close does for a kind close_ends_polls answers true for, which is what main answers for a Process. Under it the child's close answers its parent's watch with the child still running. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- src/ci.rs | 3 ++ toyos-proclife/Cargo.toml | 7 +++++ toyos-proclife/src/interleave.rs | 50 +++++++++++++++++++++++++++++++- toyos-proclife/src/lib.rs | 3 +- toyos-proclife/src/model.rs | 10 +++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/ci.rs b/src/ci.rs index 2538f7a4bb5..c3c8c7d09d4 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -417,6 +417,9 @@ pub(crate) const CONTROLS: &[Control] = &[ red(PROCLIFE, "mutate-spawner-handle-before-the-childs-own", None, &[ Fails("interleave::tests::a_sibling_closing_a_spawns_handle_before_the_spawn_returns"), ]), + red(PROCLIFE, "mutate-close-ends-a-process-watch", None, &[ + Fails("interleave::tests::a_watch_on_a_process_is_answered_by_its_end_and_by_no_close"), + ]), red(SCHED_SIM, "placement-ignores-staleness", Some("policy"), &[ Fails("a_stopped_cpu_stops_taking_work"), ]), diff --git a/toyos-proclife/Cargo.toml b/toyos-proclife/Cargo.toml index 8c61bfca070..9d46b2666ee 100644 --- a/toyos-proclife/Cargo.toml +++ b/toyos-proclife/Cargo.toml @@ -101,6 +101,13 @@ mutate-spawner-handle-after-the-landing = [] # `interleave::tests::a_sibling_closing_a_spawns_handle_before_the_spawn_returns` # must red under this. mutate-spawner-handle-before-the-childs-own = [] +# The model's close ends every poll on the watch of the process its handle +# named, as a kernel whose `ops::close_ends_polls` answered `true` for a +# process would: one holder letting a handle go answers every other holder's +# watch, with the process still running. +# `interleave::tests::a_watch_on_a_process_is_answered_by_its_end_and_by_no_close` +# must red under this. +mutate-close-ends-a-process-watch = [] [dependencies] toyos-abi = { path = "../toyos-abi" } diff --git a/toyos-proclife/src/interleave.rs b/toyos-proclife/src/interleave.rs index 494524afbea..8e2718f4f79 100644 --- a/toyos-proclife/src/interleave.rs +++ b/toyos-proclife/src/interleave.rs @@ -78,6 +78,10 @@ pub enum Op { /// 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 }, + /// `OP_WATCH` by `by`'s thread on its process's handle to `object`, and + /// the `inbox_submit` that waits on it: the poll registered on the + /// object's watch, then the look the registration owes it. + Watch { by: (Pid, Tid), object: Pid, pc: u32 }, /// The idle loop's `reap_finished`. IdlePass { pc: u32 }, } @@ -115,6 +119,9 @@ impl Op { pub fn close(by: (Pid, Tid), object: Pid) -> Self { Op::Close { by, object, pc: 0 } } + pub fn watch(by: (Pid, Tid), object: Pid) -> Self { + Op::Watch { by, object, pc: 0 } + } pub fn idle_pass() -> Self { Op::IdlePass { pc: 0 } } @@ -125,7 +132,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, .. } | Op::Close { by, .. } => Some(by), + Op::SpawnUnder { by, .. } | Op::Close { by, .. } | Op::Watch { by, .. } => Some(by), Op::Spawn { .. } | Op::IdlePass { .. } => None, } } @@ -139,6 +146,7 @@ impl Op { | Op::ThreadExit { pc, .. } | Op::Join { pc, .. } | Op::Close { pc, .. } + | Op::Watch { pc, .. } | Op::IdlePass { pc, .. } => *pc == DONE, } } @@ -161,6 +169,7 @@ impl Op { Op::ThreadExit { .. } => "thread_exit", Op::Join { .. } => "thread_join", Op::Close { .. } => "close", + Op::Watch { .. } => "watch", Op::IdlePass { .. } => "idle pass", } } @@ -382,9 +391,29 @@ impl Op { } Op::Close { by, object, pc } => { world.close(*object, by.0); + // The mutation this feature stages: the close ends every poll + // on the object's watch, another handle's included. + if cfg!(feature = "mutate-close-ends-a-process-watch") { + world.post(Watch::Process(*object)); + } world.leave_kernel(*by); *pc = DONE; } + Op::Watch { by, object, pc } => match *pc { + 0 => { + world.arm(Watch::Process(*object), *by); + *pc = 1; + } + // `inbox::arm` reads the object again once the poll is + // registered, so an end published in the window answers it. + _ => { + if world.published(*object).is_some() { + world.post(Watch::Process(*object)); + } + world.leave_kernel(*by); + *pc = DONE; + } + }, Op::IdlePass { pc } => { for pid in reap::finished_pids(world) { world.reap(pid); @@ -839,6 +868,25 @@ mod tests { holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under(place, own), Op::close(sibling, made)]); } + /// **A watch on a child, a close of another handle to it, and its end**, + /// every ordering: the child closes its own `self` while its parent + /// registers a watch on it and a kill ends it. The child's published end + /// answers the watch, registered before it or after, and nothing earlier + /// does. + /// + /// Reds under `mutate-close-ends-a-process-watch`, where the child's close + /// answers its parent's watch with the child still running. + #[test] + fn a_watch_on_a_process_is_answered_by_its_end_and_by_no_close() { + let mut world = World::new(); + let init = world.spawn_process(); + let parent = world.spawn_child(init); + let child = world.spawn_child(parent); + let watcher = (parent, world.main_tid(parent)); + let own = (child, world.main_tid(child)); + holds(&world, vec![Op::watch(watcher, child), Op::close(own, child), Op::kill(child, KILLED)]); + } + /// 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/lib.rs b/toyos-proclife/src/lib.rs index 08c5f89c6ab..52bc8178a11 100644 --- a/toyos-proclife/src/lib.rs +++ b/toyos-proclife/src/lib.rs @@ -117,7 +117,8 @@ impl ThreadLocation { pub enum Watch { /// One thread's exit. `SYS_THREAD_JOIN` arms here. Thread(Pid, Tid), - /// A process's exit. `SYS_PROCESS_WAIT` arms here. + /// A process's exit. `SYS_PROCESS_WAIT` arms here, and an `OP_WATCH` on a + /// handle to it registers here. Process(Pid), } diff --git a/toyos-proclife/src/model.rs b/toyos-proclife/src/model.rs index a1a09156731..93803160c42 100644 --- a/toyos-proclife/src/model.rs +++ b/toyos-proclife/src/model.rs @@ -586,6 +586,16 @@ impl World { for pid in &self.minted_retired { out.push(alloc::format!("pid {pid}: a handle to it was minted after its last one had gone")); } + // L14. Only a process's published end answers a wait or a watch on it. + for &(watch, waiter, tid) in &self.released { + if let Watch::Process(pid) = watch { + if !self.published.contains_key(&pid) { + out.push(alloc::format!( + "pid {waiter} tid {tid}: its watch on pid {pid} was answered with pid {pid}'s exit unpublished", + )); + } + } + } out } From 99da9719b0e54713f14061c4ffae8c6ae3838d20 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 23:37:15 +0200 Subject: [PATCH 4/9] process_lifecycle: the kill arm reads its completion's result word `Poller` hands a completion's token to its caller and never its result, and hands out a refusal's token as it does a ready one's. So an end that answered a watch on a child `-NotFound`, as a watch whose source is gone, passed every arm: each saw a token at the right time. The kill arm takes one raw ring of its own (`watch_result_across`), submits the watch, sees nothing complete, kills the child, and reads the one completion whole: its result is `READABLE`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- .../src/bin/process_lifecycle.rs | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs index d450026f0fa..12a917240c4 100644 --- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs +++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs @@ -30,13 +30,17 @@ use std::io::Read; use std::os::toyos::process::{ChildExt, CommandExt}; use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::OnceLock; use toyos::endow::{Endowments, SYSCAP_LABEL}; use toyos::poller::{Poller, READABLE}; use toyos::process::Process; use toyos::syscap::SysCap; +use toyos_abi::inbox::{ + Completion, RingHeader, Submission, COMPLETION_RING_OFF, OP_WATCH, RING_TAIL_OFF, SUBMISSIONS_OFF, + SUBMISSION_RING_OFF, +}; use toyos_abi::syscall::{self, SyscallError}; use toyos_abi::RawHandle; @@ -296,20 +300,51 @@ fn a_watch_on_an_ended_child_completes_at_once() { println!(" a watch on a child already gone completes at once"); } -/// A kill ends the child without its cooperation, and its end completes a -/// watch as an exit's does. +/// A kill ends the child without its cooperation, and its end answers a watch +/// as an exit's does: as readable, and not as a watch whose source is gone. fn a_kill_completes_a_watch() { let (mut child, _release) = start(0); - let poller = Poller::new(1); - poller.watch_raw(RawHandle(child.as_raw_handle()), READABLE, 0); - poller.wait(0, 0, |token| panic!("the held child's watch completed ({token})")); - child.kill().expect("kill"); - let mut ended = Vec::new(); - poller.wait(1, u64::MAX, |token| ended.push(token)); - assert_eq!(ended, [0], "the killed child's watch"); + let handle = RawHandle(child.as_raw_handle()); + let result = watch_result_across(handle, || child.kill().expect("kill")); + assert_eq!(result, READABLE as i32, "the killed child's watch"); let status = child.try_wait().expect("try_wait"); assert_eq!(status.and_then(|s| s.code()), Some(KILLED), "the killed child's code"); - println!(" a kill completes a watch"); + println!(" a kill completes a watch, as readable"); +} + +/// One `OP_WATCH` `READABLE` on `handle` in a ring of its own: nothing +/// completes before `end` runs, and the answer is the result word of the one +/// completion after it, which `Poller` does not hand out. +fn watch_result_across(handle: RawHandle, end: impl FnOnce()) -> i32 { + const TOKEN: u64 = 0x5EED; + // SAFETY: the page is this function's own ring, mapped until the close below. + let (inbox, base) = unsafe { syscall::inbox_setup(1) }.expect("inbox_setup"); + // SAFETY: slot 0 of a fresh ring's submission array and its tail word, both + // inside the page and aligned; the kernel reads the slot only once the tail + // publishes it, and the tail is reached as the atomic it is. + unsafe { + (base.add(SUBMISSIONS_OFF as usize) as *mut Submission).write(Submission { + op: OP_WATCH, + handle, + op_flags: READABLE, + token: TOKEN, + ..Submission::default() + }); + AtomicU32::from_ptr(base.add(SUBMISSION_RING_OFF as usize + RING_TAIL_OFF) as *mut u32) + .store(1, Ordering::Release); + } + assert_eq!(syscall::inbox_submit(inbox, 1, 0, 0), Ok(0), "the held child's watch completed"); + end(); + assert_eq!(syscall::inbox_submit(inbox, 0, 1, u64::MAX), Ok(1), "the watch's one completion"); + // SAFETY: entry 0 of the completion ring, which the kernel wrote before it + // answered the submit above; copied out whole. + let completion = unsafe { + (base.add(COMPLETION_RING_OFF as usize + core::mem::size_of::()) as *const Completion) + .read_volatile() + }; + syscall::close(inbox); + assert_eq!(completion.token, TOKEN, "the completion's token"); + completion.result } /// A second handle closed while the first is watched: the child did not end, From bf18b67889796100f82a35c4f960f60197c63986 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 23:50:22 +0200 Subject: [PATCH 5/9] process_lifecycle's header names its watch arms without counting them And says what the kill arm now reads: the completion's result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- tests/toyos-rust-tests/src/bin/process_lifecycle.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs index 12a917240c4..1992e5e4249 100644 --- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs +++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs @@ -18,9 +18,9 @@ //! //! **An end is also an event**: an `OP_WATCH` on the handle completes once the //! exit is published, so one poller waits for any number of children beside -//! whatever else it watches. Four arms are this paragraph's: each completion -//! names the child that ended, a watch on one already gone completes at once, a -//! kill completes one, and closing one handle ends no other's watch. +//! whatever else it watches. Its arms: each completion names the child that +//! ended, a watch on one already gone completes at once, a kill completes one +//! as readable, and closing one handle ends no other's watch. //! //! Two roles besides the test. `held` exits with a code of the parent's //! choosing, but not until its stdin closes — which is what lets every arm here From 7bf868ef44ca30ed15a449c5ca73ecb5501fa9b0 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 2 Oct 2026 23:50:22 +0200 Subject: [PATCH 6/9] Filed: a watch outlives the close of the Process handle it was made through close_ends_polls answers false for a Process so that one holder's close ends no other's watch, and a close has no way to end only the polls made through its own handle. A watch whose handle closes is therefore kept until the child ends, and answered -NotFound then. Measured at 81e68535c in one QEMU guest, with a scratch arm that is in no commit (x-scratch-closed-handle.patch, posted on the pull request): a watch on a duplicate of a held child's handle, the duplicate closed: the watch on handle 4102: Ok(0) after its close: Ok(0) after the child's end: Ok(1), token 7 result -1 The same arm with a pipe made after the close read the same -1: a handle carries its slot's generation, so the pipe's ends were 16392 and 11 where the closed handle was 12296, and the look found the closed one stale. A poll does not move to whatever takes its slot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- ...-the-process-handle-it-was-made-through.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md diff --git a/issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md b/issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md new file mode 100644 index 00000000000..1f44c4c6099 --- /dev/null +++ b/issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-10-02 +--- + +# A watch outlives the close of the Process handle it was made through + +`ops::close` ends no poll for a `Process` (`close_ends_polls`): closing one +handle may end no other handle's watch, and a close cannot tell the polls made +through its own handle from another's. So a watch made through a handle that +then closes stays armed on the process's watch and kept by its ring, where it +counts against `MAX_PENDING_WATCHES`, until the process ends. The look then +finds the handle stale and answers `-NotFound` under the watch's token. + +Measured at 81e68535c in one QEMU guest, with an arm that is in no commit: a +ring holding one watch on a duplicate of a held child's handle answers nothing +at the submit after the duplicate's close, and answers token 7 with result -1 +once the child ends. + +By `close_ends_polls`, a console's, the log's and a keyboard claim's polls are +kept the same way. + +**Exit**: a handle's close answers the polls its own process made through it, +and no other handle's; a test watches a held child through a duplicate, closes +the duplicate, and reads `-NotFound` before the child ends. From e7777747523a7e2806cbf8a31369c15416ae40c0 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 3 Oct 2026 00:49:13 +0200 Subject: [PATCH 7/9] toyos-proclife: the process watch's op, law, test and control go Review of #648 at 7bf868ef4, BLOCKER: `Op::Watch`, L14, `a_watch_on_a_process_is_answered_by_its_end_and_by_no_close`, the `mutate-close-ends-a-process-watch` feature and its `CONTROLS` row modelled the change and compiled none of it. `toyos-proclife` depends on `toyos-abi` alone: only `Op::Watch` armed a `Watch::Process`, and only the model's own `publish_exit`, its own look and the feature's arm released one. So the QEMU mutations m1-m5 and the whole-change control each left that test green, and its control redded only when the model itself was edited. What the deleted test claimed is held where the kernel's lines run: `process_lifecycle`'s four watch arms, their QEMU mutations and the T14. `toyos-proclife/src/lib.rs` loses the `OP_WATCH` clause of `Watch::Process`'s doc, which nothing in the crate names any more. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- src/ci.rs | 3 -- toyos-proclife/Cargo.toml | 7 ----- toyos-proclife/src/interleave.rs | 50 +------------------------------- toyos-proclife/src/lib.rs | 3 +- toyos-proclife/src/model.rs | 10 ------- 5 files changed, 2 insertions(+), 71 deletions(-) diff --git a/src/ci.rs b/src/ci.rs index c3c8c7d09d4..2538f7a4bb5 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -417,9 +417,6 @@ pub(crate) const CONTROLS: &[Control] = &[ red(PROCLIFE, "mutate-spawner-handle-before-the-childs-own", None, &[ Fails("interleave::tests::a_sibling_closing_a_spawns_handle_before_the_spawn_returns"), ]), - red(PROCLIFE, "mutate-close-ends-a-process-watch", None, &[ - Fails("interleave::tests::a_watch_on_a_process_is_answered_by_its_end_and_by_no_close"), - ]), red(SCHED_SIM, "placement-ignores-staleness", Some("policy"), &[ Fails("a_stopped_cpu_stops_taking_work"), ]), diff --git a/toyos-proclife/Cargo.toml b/toyos-proclife/Cargo.toml index 9d46b2666ee..8c61bfca070 100644 --- a/toyos-proclife/Cargo.toml +++ b/toyos-proclife/Cargo.toml @@ -101,13 +101,6 @@ mutate-spawner-handle-after-the-landing = [] # `interleave::tests::a_sibling_closing_a_spawns_handle_before_the_spawn_returns` # must red under this. mutate-spawner-handle-before-the-childs-own = [] -# The model's close ends every poll on the watch of the process its handle -# named, as a kernel whose `ops::close_ends_polls` answered `true` for a -# process would: one holder letting a handle go answers every other holder's -# watch, with the process still running. -# `interleave::tests::a_watch_on_a_process_is_answered_by_its_end_and_by_no_close` -# must red under this. -mutate-close-ends-a-process-watch = [] [dependencies] toyos-abi = { path = "../toyos-abi" } diff --git a/toyos-proclife/src/interleave.rs b/toyos-proclife/src/interleave.rs index 8e2718f4f79..494524afbea 100644 --- a/toyos-proclife/src/interleave.rs +++ b/toyos-proclife/src/interleave.rs @@ -78,10 +78,6 @@ pub enum Op { /// 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 }, - /// `OP_WATCH` by `by`'s thread on its process's handle to `object`, and - /// the `inbox_submit` that waits on it: the poll registered on the - /// object's watch, then the look the registration owes it. - Watch { by: (Pid, Tid), object: Pid, pc: u32 }, /// The idle loop's `reap_finished`. IdlePass { pc: u32 }, } @@ -119,9 +115,6 @@ impl Op { pub fn close(by: (Pid, Tid), object: Pid) -> Self { Op::Close { by, object, pc: 0 } } - pub fn watch(by: (Pid, Tid), object: Pid) -> Self { - Op::Watch { by, object, pc: 0 } - } pub fn idle_pass() -> Self { Op::IdlePass { pc: 0 } } @@ -132,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, .. } | Op::Close { by, .. } | Op::Watch { by, .. } => Some(by), + Op::SpawnUnder { by, .. } | Op::Close { by, .. } => Some(by), Op::Spawn { .. } | Op::IdlePass { .. } => None, } } @@ -146,7 +139,6 @@ impl Op { | Op::ThreadExit { pc, .. } | Op::Join { pc, .. } | Op::Close { pc, .. } - | Op::Watch { pc, .. } | Op::IdlePass { pc, .. } => *pc == DONE, } } @@ -169,7 +161,6 @@ impl Op { Op::ThreadExit { .. } => "thread_exit", Op::Join { .. } => "thread_join", Op::Close { .. } => "close", - Op::Watch { .. } => "watch", Op::IdlePass { .. } => "idle pass", } } @@ -391,29 +382,9 @@ impl Op { } Op::Close { by, object, pc } => { world.close(*object, by.0); - // The mutation this feature stages: the close ends every poll - // on the object's watch, another handle's included. - if cfg!(feature = "mutate-close-ends-a-process-watch") { - world.post(Watch::Process(*object)); - } world.leave_kernel(*by); *pc = DONE; } - Op::Watch { by, object, pc } => match *pc { - 0 => { - world.arm(Watch::Process(*object), *by); - *pc = 1; - } - // `inbox::arm` reads the object again once the poll is - // registered, so an end published in the window answers it. - _ => { - if world.published(*object).is_some() { - world.post(Watch::Process(*object)); - } - world.leave_kernel(*by); - *pc = DONE; - } - }, Op::IdlePass { pc } => { for pid in reap::finished_pids(world) { world.reap(pid); @@ -868,25 +839,6 @@ mod tests { holds(&world, vec![Op::kill(place, KILLED), Op::spawn_under(place, own), Op::close(sibling, made)]); } - /// **A watch on a child, a close of another handle to it, and its end**, - /// every ordering: the child closes its own `self` while its parent - /// registers a watch on it and a kill ends it. The child's published end - /// answers the watch, registered before it or after, and nothing earlier - /// does. - /// - /// Reds under `mutate-close-ends-a-process-watch`, where the child's close - /// answers its parent's watch with the child still running. - #[test] - fn a_watch_on_a_process_is_answered_by_its_end_and_by_no_close() { - let mut world = World::new(); - let init = world.spawn_process(); - let parent = world.spawn_child(init); - let child = world.spawn_child(parent); - let watcher = (parent, world.main_tid(parent)); - let own = (child, world.main_tid(child)); - holds(&world, vec![Op::watch(watcher, child), Op::close(own, child), Op::kill(child, KILLED)]); - } - /// 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/lib.rs b/toyos-proclife/src/lib.rs index 52bc8178a11..08c5f89c6ab 100644 --- a/toyos-proclife/src/lib.rs +++ b/toyos-proclife/src/lib.rs @@ -117,8 +117,7 @@ impl ThreadLocation { pub enum Watch { /// One thread's exit. `SYS_THREAD_JOIN` arms here. Thread(Pid, Tid), - /// A process's exit. `SYS_PROCESS_WAIT` arms here, and an `OP_WATCH` on a - /// handle to it registers here. + /// A process's exit. `SYS_PROCESS_WAIT` arms here. Process(Pid), } diff --git a/toyos-proclife/src/model.rs b/toyos-proclife/src/model.rs index 93803160c42..a1a09156731 100644 --- a/toyos-proclife/src/model.rs +++ b/toyos-proclife/src/model.rs @@ -586,16 +586,6 @@ impl World { for pid in &self.minted_retired { out.push(alloc::format!("pid {pid}: a handle to it was minted after its last one had gone")); } - // L14. Only a process's published end answers a wait or a watch on it. - for &(watch, waiter, tid) in &self.released { - if let Watch::Process(pid) = watch { - if !self.published.contains_key(&pid) { - out.push(alloc::format!( - "pid {waiter} tid {tid}: its watch on pid {pid} was answered with pid {pid}'s exit unpublished", - )); - } - } - } out } From 552c4e45895e2b4ed0488b49cdf2ee51e21705c1 Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 3 Oct 2026 00:49:14 +0200 Subject: [PATCH 8/9] init and process_lifecycle: a remnant sentence and an arm list go Review of #648 at 7bf868ef4, REMOVE: `close_when_it_ends`'s "It parks in the kernel for the process's life and costs nothing until then" was a rewrapped remnant of the deleted reason, and none of the three comment kinds; `process_lifecycle`'s header listed the four watch arms that their own doc comments already state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- tests/toyos-rust-tests/src/bin/process_lifecycle.rs | 4 +--- userland/init/src/main.rs | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs index 1992e5e4249..454b969689d 100644 --- a/tests/toyos-rust-tests/src/bin/process_lifecycle.rs +++ b/tests/toyos-rust-tests/src/bin/process_lifecycle.rs @@ -18,9 +18,7 @@ //! //! **An end is also an event**: an `OP_WATCH` on the handle completes once the //! exit is published, so one poller waits for any number of children beside -//! whatever else it watches. Its arms: each completion names the child that -//! ended, a watch on one already gone completes at once, a kill completes one -//! as readable, and closing one handle ends no other's watch. +//! whatever else it watches. //! //! Two roles besides the test. `held` exits with a code of the parent's //! choosing, but not until its stdin closes — which is what lets every arm here diff --git a/userland/init/src/main.rs b/userland/init/src/main.rs index b2344e0c785..1901f662fde 100644 --- a/userland/init/src/main.rs +++ b/userland/init/src/main.rs @@ -692,8 +692,6 @@ impl std::fmt::Display for StartError { /// Wait for one start of a service to end, and close its ports if nothing was /// expecting it to, or wake the loop to start it again if its row says so. -/// -/// It parks in the kernel for the process's life and costs nothing until then. fn close_when_it_ends(kept: &Mutex, generation: u64, process: toyos::RawHandle) { let _ = toyos_abi::syscall::process_wait(process); toyos_abi::syscall::close(process); From 02a83a91a54e116cecd11b6866cb2e58a7e0899b Mon Sep 17 00:00:00 2001 From: japabu Date: Sat, 3 Oct 2026 00:49:14 +0200 Subject: [PATCH 9/9] Issues: stage 1's exit keeps what close_when_it_ends does, and the watch defect names its twin Review of #648 at 7bf868ef4, NOTE 1: the remainder's exit named only the deletion, which a conversion dropping what `close_when_it_ends` decides would still meet. It now names each behaviour: a `restart` row started again on the ports it kept; a row without one having its acceptors closed so a client's next connect is `Gone`; a swap's expected end leaving them open. And what reads each: `src/metalswap.rs`'s `judge` reads the swap through `toyos-metal --swap`; `git grep` over `tests/` and `src/` for init's restart and close lines ("started again", "its ports are closed", `ServerGone`, "sshd: no network") finds no test of the other two. `netd_gone_mid_bind` closes a port of its own and never reaches init's supervision. NOTE 2: `a-watch-outlives-the-close-of-the-process-handle-it-was-made-through` names `a-close-of-one-handle-ends-every-rings-poll-on-its-object`, whose first exit alternative would keep the very poll this one's exit ends: one change, a close ending the polls made through its own handle and no other's, answers both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm --- ...end-is-an-event-and-a-parent-takes-its-children-down.md | 7 ++++++- ...-the-close-of-the-process-handle-it-was-made-through.md | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/kernel/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index 42b6a2ffa14..9d51c4fa901 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,7 +40,12 @@ directory is `issues/isolation/every-program-sees-only-the-files-it-was-given.md 1. **init's waiter threads go**. A child's end is readiness on its handle, and init still parks one thread per service on `SYS_PROCESS_WAIT` (`close_when_it_ends`, `userland/init/src/main.rs`). *Exit*: init starts no - thread to wait for a service's end, and `close_when_it_ends` is gone. + thread to wait for a service's end, and `close_when_it_ends` is gone with + all it does kept: a `restart` row that ends is started again on the ports + it kept; a row without one has its acceptors closed, so a client's next + connect is `Gone`; a swap's expected end leaves them open for the binary + after it. `src/metalswap.rs`'s `judge` reads the third, on the T14 through + `toyos-metal --swap`; no test reads the first two. 2. **An end says how**. An end reads as an exit, a kill or a fault kind alike on every architecture, and a bare code reads the last two as failures. No end reads as a quit's reason. *Exit*: exited 137, killed, and each fault diff --git a/issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md b/issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md index 1f44c4c6099..9b8af636d7b 100644 --- a/issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md +++ b/issues/kernel/a-watch-outlives-the-close-of-the-process-handle-it-was-made-through.md @@ -23,4 +23,7 @@ kept the same way. **Exit**: a handle's close answers the polls its own process made through it, and no other handle's; a test watches a held child through a duplicate, closes -the duplicate, and reads `-NotFound` before the child ends. +the duplicate, and reads `-NotFound` before the child ends. That change +answers `issues/kernel/a-close-of-one-handle-ends-every-rings-poll-on-its-object.md` +too, whose first exit, a poll ending only when the last handle to its source +closes, would keep the very poll this exit ends at its own handle's close.