From 750af80283fd1152ea3d877366798fd2ce1b377b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:21:37 +0000 Subject: [PATCH 1/3] File servers share by session: every launch made in a session spends its one share A file server's shares were per instance: the supervisor minted a fresh grant number on every start, so a session took a whole server by launching four programs (a shell launches shells). Now a grant names the session it was minted in, and every launch made in a session is minted that session's number. - toyos_manifest::launch: `Session::Login` carries the session's number, which only `Sessions::open` makes; the launcher's badge carries it, and `Authority::decode` refuses a number no session has. `may_start` answers `Starts::In(caller's session)` or `Starts::Opening` for a `login` caller, and `Session::share` is what a grant names: 0 the supervisor's own files, 1 the machine's session, 2 on login sessions. - supervisor: mints each start's grants with its session's share (the machine's for every boot row, the caller's or a newly opened one for a launch); the per-start counter is gone. - toyos::fs::Grant: `instance` is `session`, and the format's version is 2, because the number means something else: a file server swapped in from either side of this change refuses the other's grants by name. - fileserver: shares keyed by session; same quarter of each bound. - fs_share (metal row on tests/proctreecase): with the machine's session at its stream share, a shell launched in it is refused its redirect's stream; at its connection share, its redirect's connection; and a shell that shell launches, which opens a login session, connects and streams. proctreecase's shell row lists `shell` so it can. Closes issues/a-file-servers-shares-are-per-instance-and-one-session-launches-instances.md (deleted). What its close leaves is filed: issues/four-login-sessions-at-their-shares-take-a-file-server.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015tYBoMwh9xUcBBLG35wTFr --- ...ance-and-one-session-launches-instances.md | 37 --- ...ions-at-their-shares-take-a-file-server.md | 32 +++ tests/proctreecase/system.toml | 7 +- tests/toyos-rust-tests/src/bin/fs_share.rs | 84 ++++--- tests/toyos.rs | 15 +- toyos-manifest/src/launch.rs | 212 ++++++++++++++---- toyos/src/fs.rs | 30 +-- userland/fileserver/src/main.rs | 44 ++-- userland/supervisor/src/main.rs | 60 +++-- 9 files changed, 330 insertions(+), 191 deletions(-) delete mode 100644 issues/a-file-servers-shares-are-per-instance-and-one-session-launches-instances.md create mode 100644 issues/four-login-sessions-at-their-shares-take-a-file-server.md diff --git a/issues/a-file-servers-shares-are-per-instance-and-one-session-launches-instances.md b/issues/a-file-servers-shares-are-per-instance-and-one-session-launches-instances.md deleted file mode 100644 index a2a68cbf184..00000000000 --- a/issues/a-file-servers-shares-are-per-instance-and-one-session-launches-instances.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -status: assigned -kind: defect -opened: 2026-10-04 ---- - -# A file server's shares are per instance, and one session launches instances - -Held by the orchestrator. - -`userland/fileserver/src/main.rs` gives each instance a grant names a quarter -of each machine-wide bound: 32 of `MAX_SERVED`'s 128 served connections, 8 of -`MAX_HANDSHAKES`' 32 waiting on their hello, 16 of `MAX_STREAMS`' 64 streams. -An instance is one start and the children it spawns directly, and the -supervisor mints a fresh one on every start. So four instances at their shares -take each bound whole, and every other program's first connection or `STREAM` -on that server is refused `ResourceExhausted`. - -Four instances are cheap to have: a program whose row lists `starts` (the -compositor, terminal, shell, toybox and sshserver rows of `system.toml`) gets -a new instance on every launch, and a shell launches shells. One session can -therefore take DATA's server from every other session. - -The shares are sized against what was measured, not against a session's need. -With the shares in place, every boot of the guest suite, the shipping -desktop booted under QEMU, and every shipping Rust job run one after another in -one `tests/testcases` boot never had an instance past 3 served connections, 1 -waiting on its hello or 0 streams, nor more than 5 of DATA's connections served -at once; the desktop at rest holds 3. A parallel build (cargo with rustc as its -direct children) is the obvious unmeasured load. - -## Exit condition - -A server's bounds are shared per session, so that one session holding all its -programs may hold — through as many launches as its `starts` allow — leaves -another session's first connection and first stream on that server answered, -shown by a test that does exactly that. diff --git a/issues/four-login-sessions-at-their-shares-take-a-file-server.md b/issues/four-login-sessions-at-their-shares-take-a-file-server.md new file mode 100644 index 00000000000..70812ef82f7 --- /dev/null +++ b/issues/four-login-sessions-at-their-shares-take-a-file-server.md @@ -0,0 +1,32 @@ +--- +status: open +kind: defect +opened: 2026-10-05 +--- + +# Four login sessions at their shares take a file server + +`userland/fileserver/src/main.rs` gives each session a grant names a quarter of +each machine-wide bound: 32 of `MAX_SERVED`'s 128 served connections, 8 of +`MAX_HANDSHAKES`' 32 waiting on their hello, 16 of `MAX_STREAMS`' 64 streams. +So four sessions at their shares take each bound whole, and a fifth session's +first connection or `STREAM` on that server is refused `ResourceExhausted`. + +A session no longer gets more of a server by launching: every launch made in +it spends its share (`toyos_manifest::launch`). But sessions are cheap to +open. Every launch a `login` row makes opens one: each program the desktop's +taskbar starts (the compositor's row), and each program sshserver starts for a +client. A session reaches a `login` row of its own through `starts`: the +shell's and toybox's rows in `system.toml` list `sshserver`, and any process can +append a key to its list (`issues/sshserver-authorized-keys-unprotected.md`). + +Nothing bounds how many sessions are open, so no fixed share answers every +session's first connection. Stage 4 of +`issues/every-program-sees-only-the-files-it-was-given.md` owns per-session +bounds. + +## Exit condition + +However many sessions are open, each holding all a server lets it hold, the +next session's first connection and first stream on that server are answered, +or opening it is refused by name; shown by a test that does exactly that. diff --git a/tests/proctreecase/system.toml b/tests/proctreecase/system.toml index c24edd7bfe8..7cc4c78479a 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -13,8 +13,9 @@ # session it was launched in: refused in the machine's, started in the one the # shell opens. # -# And `fs_share`: the shell test-runner's row lists is launched with grants of -# its own, so it is another instance on DATA's server. +# And `fs_share`: a shell test-runner launches is in the machine's session, as +# test-runner is, and spends its share of DATA's server; a shell that shell +# launches opens a login session, which has a share of its own. [boot] start = ["logkeeper", "diskserver", "fileserver", "test-runner"] @@ -39,7 +40,7 @@ syscap = ["roster"] starts = ["swap"] [programs.shell] -starts = ["toybox"] +starts = ["toybox", "shell"] login = true [programs.swap] diff --git a/tests/toyos-rust-tests/src/bin/fs_share.rs b/tests/toyos-rust-tests/src/bin/fs_share.rs index d47331c7577..3d3262d627e 100644 --- a/tests/toyos-rust-tests/src/bin/fs_share.rs +++ b/tests/toyos-rust-tests/src/bin/fs_share.rs @@ -1,27 +1,31 @@ -//! One program holding all a file server lets it hold leaves the server -//! answering another. +//! One session holding all a file server lets it hold, through every program +//! launched in it, leaves the server answering another session. //! -//! This job holds test-runner's grants, so it and every child it spawns -//! directly are one instance. On DATA's server: +//! This job holds test-runner's grants and launcher, in the machine's session. +//! On DATA's server: //! //! - each of DATA's directories is its grant's root: a file made under //! `/home` is under none of the others; -//! - it takes every stream the server will give it, until one is refused, and -//! every connection the server will serve it, until one is refused; -//! - holding all that, it launches a shell, whose row test-runner's lists, so -//! the shell is an instance of its own: it opens a file under `/home` and -//! streams a child's output into it, and the bytes are read back here; -//! - last, it opens more connections than any one instance may have waiting -//! on their hello: the ones past its share are answered `ResourceExhausted` +//! - it takes every stream the server will give its session, until one is +//! refused; a shell it launches, in this session, then has its redirect's +//! stream refused; +//! - it takes every connection the server will serve its session, until one +//! is refused; a shell it launches then has its redirect's connection +//! refused; +//! - holding all that, a shell it launches launches another, whose row opens +//! a login session: that shell connects to `/home` and streams a child's +//! output into a file, and the bytes are read back here; +//! - last, it opens more connections than one session may have waiting on +//! their hello: the ones past its share are answered `ResourceExhausted` //! and let go as the server takes them, so the first to end is not the //! first opened, which the server would otherwise let go first, at its //! handshake timeout; a hello on it, which finds the server gone, reads //! why, and every later one keeps none of the windows it could not lend. //! Last, because the server reaps the ones this job drops only when it -//! next reads them, and until then they are this instance's share. +//! next reads them, and until then they are this session's share. use std::fs; -use std::process::Command; +use std::process::{Command, Output}; use toyos::endow; use toyos::fs::{hello, Dir, O_CREATE, O_WRITE, WINDOW_BYTES}; @@ -33,10 +37,12 @@ use toyos_abi::RawHandle; const DIR: &str = "/home/fs_share"; const OTHER: &str = "/home/fs_share/other"; +const SAME_STREAM: &str = "/home/fs_share/same_stream"; +const SAME_CONNECTION: &str = "/home/fs_share/same_connection"; const MARK: &str = "/home/fs_share/mark"; const SAID: &str = "answered"; -/// More connections than one instance's share of a server's handshakes: the +/// More connections than one session's share of a server's handshakes: the /// server's own machine-wide bound on them. const UNANSWERED: usize = 32; @@ -53,7 +59,9 @@ fn main() { // std's own connection to /home, made before anything is held, is what // the shell's file is read back through. fs::create_dir_all(DIR).expect("make the test's directory"); - let _ = fs::remove_file(OTHER); + for file in [OTHER, SAME_STREAM, SAME_CONNECTION] { + let _ = fs::remove_file(file); + } // Every arm runs, so one run says each one that is red. let mut red = Vec::new(); @@ -66,7 +74,7 @@ fn main() { } } - // Every stream the server gives this instance. + // Every stream the server gives this session. let mut dir = Dir::connect(names, "fs:/home").expect("a client of /home"); let file = dir.open("fs_share/streams", O_WRITE | O_CREATE).expect("open a file to stream into"); let mut streams = Vec::new(); @@ -80,11 +88,18 @@ fn main() { } }; match refused { - Some(SyscallError::ResourceExhausted) => println!(" this instance holds {} streams", streams.len()), + Some(SyscallError::ResourceExhausted) => println!(" this session holds {} streams", streams.len()), other => red.push(format!("after {} streams, the next was answered {other:?}", streams.len())), } + // A launch in this session: its redirect connects and opens the file, and + // the stream into it is refused, so it stays empty. + let shell = run_shell(&format!("/system/bin/toybox echo {SAID} > {SAME_STREAM}")); + match fs::read_to_string(SAME_STREAM) { + Ok(text) if text.is_empty() => println!(" a shell launched in this session was refused a stream"), + other => red.push(format!("a shell launched in this session streamed {other:?}: {shell:?}")), + } - // Every connection the server serves this instance, all lent one window. + // Every connection the server serves this session, all lent one window. let window = SharedMemory::create(WINDOW_BYTES).expect("a window"); let mut served = Vec::new(); let refused = loop { @@ -99,22 +114,30 @@ fn main() { }; match refused { Some(SyscallError::ResourceExhausted) => { - println!(" this instance is served {} more connections", served.len()) + println!(" this session is served {} more connections", served.len()) } other => red.push(format!("after {} connections, the next hello was answered {other:?}", served.len())), } + // A launch in this session: its redirect's connection is refused, so the + // shell says so and its child echoes to the shell's own output instead. + let shell = run_shell(&format!("/system/bin/toybox echo {SAID} > {SAME_CONNECTION}")); + let echoed = String::from_utf8_lossy(&shell.stdout).trim_end() == SAID; + match fs::metadata(SAME_CONNECTION) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound && echoed => { + println!(" a shell launched in this session was refused a connection") + } + other => red.push(format!("a shell launched in this session made {SAME_CONNECTION}: {other:?}, {shell:?}")), + } - // Another instance, while this one holds all that. - let shell = Command::new("/system/bin/shell") - .args(["-c", &format!("/system/bin/toybox echo {SAID} > {OTHER}")]) - .output() - .expect("launch a shell"); + // Another session, while this one holds all that: the shell this job + // launches is in this session, and the one it launches opens a session. + let shell = run_shell(&format!("/system/bin/shell -c '/system/bin/toybox echo {SAID} > {OTHER}'")); if !shell.status.success() { - red.push(format!("the other instance's shell failed: {shell:?}")); + red.push(format!("the other session's shells failed: {shell:?}")); } match fs::read_to_string(OTHER) { - Ok(text) if text.trim_end() == SAID => println!(" another instance connected and streamed"), - other => red.push(format!("another instance's stream wrote {other:?}")), + Ok(text) if text.trim_end() == SAID => println!(" another session connected and streamed"), + other => red.push(format!("another session's stream wrote {other:?}")), } // Connections that never say hello. @@ -157,3 +180,10 @@ fn main() { assert!(red.is_empty(), "fs_share:\n {}", red.join("\n ")); println!("fs_share: PASS"); } + +/// A shell launched through test-runner's launcher, in this job's session, +/// running `line`, from `/`: no file server's directory, so the redirect is +/// the only file it opens. +fn run_shell(line: &str) -> Output { + Command::new("/system/bin/shell").args(["-c", line]).current_dir("/").output().expect("launch a shell") +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 781e5bdec42..39c386a13f7 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -127,9 +127,10 @@ const RUST_SKIP: &[&str] = &[ // A kernel primitive with no use for any one boot's devices: the // `port_badge` metal row runs it on tests/proctreecase. "port_badge", - // Needs a launcher whose row lists a program that streams into a file, so - // that another instance asks DATA's server while this one holds its - // shares: the `fs_share` metal row runs it on tests/proctreecase. + // Needs a launcher whose row lists a shell, and a shell whose row opens a + // login session and lists a shell, so that another session asks DATA's + // server while this one holds its shares: the `fs_share` metal row runs it + // on tests/proctreecase. "fs_share", // It asserts nothing at all: it holds a `tests/lanleasecase` boot open for // twenty seconds. On a shared boot it would be twenty seconds of nothing. @@ -691,8 +692,8 @@ const METAL: &[(&str, metal::Metal)] = &[ metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_port_badge") }, ), ( - // One instance holding all a file server lets it hold leaves the - // server answering another. + // One session holding all a file server lets it hold, through every + // launch made in it, leaves the server answering another session. "fs_share", metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_fs_share") }, ), @@ -921,8 +922,8 @@ const METALCASE: &[metal::Arm] = &[metal::once("metalcase", "tests/metalcase", & /// A launcher and a declared `cat` and shell, which `process_tree`'s subtree /// launches, a `toybox` row holding `roster`, which `launch_toctou` races, the -/// rows `launch_authority` is refused and started, and the shell `fs_share` -/// asks DATA's server through. +/// rows `launch_authority` is refused and started, and the shells `fs_share` +/// asks DATA's server through, in its session and in another. const PROCTREECASE: &[metal::Arm] = &[metal::once( "proctreecase", "tests/proctreecase", diff --git a/toyos-manifest/src/launch.rs b/toyos-manifest/src/launch.rs index c93347b1cbb..8a8570ff540 100644 --- a/toyos-manifest/src/launch.rs +++ b/toyos-manifest/src/launch.rs @@ -4,8 +4,12 @@ //! keys, and [`APPS`] for any installed package. **A row that replaces what the //! machine runs starts only in a login session** ([`Program::login_only`]). Both //! are the owner's ruling. A launch by a row marked `login` opens a login -//! session; every other launch is in its caller's session, and a row the -//! supervisor starts at boot is in the machine's. +//! session of its own ([`Sessions`]); every other launch is in its caller's +//! session, and a row the supervisor starts at boot is in the machine's. +//! +//! **A session is what a file server shares by** ([`Session::share`]): every +//! process in one spends one share of each server's bounds, so no number of +//! launches made in a session gives it more of a server. //! //! **The caller is the badge on its connection, not its word** ([`Authority`]): //! the supervisor mints it on the launcher it endows a row, the kernel stamps it @@ -30,19 +34,59 @@ pub const APPS: &str = package::DIR; pub enum Session { /// A row the supervisor started at boot, and every launch made in it. Machine, - /// One a `login` row opened, and every launch made in it. - Login, + /// One a `login` row's launch opened, and every launch made in it. + Login(Login), +} + +/// Which login session: a number only [`Sessions::open`] makes and a badge it +/// was encoded into gives back, so it is no other session's and no other share's. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Login(u64); + +/// The share the supervisor's own files spend, which is no session's. +pub const SUPERVISOR_SHARE: u64 = 0; +const MACHINE_SHARE: u64 = 1; +const FIRST_LOGIN: u64 = 2; + +impl Session { + /// The number a file grant names this session's share of a server by. + pub fn share(self) -> u64 { + match self { + Self::Machine => MACHINE_SHARE, + Self::Login(Login(n)) => n, + } + } } impl fmt::Display for Session { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Machine => f.write_str("the machine's session"), - Self::Login => f.write_str("a login session"), + Self::Login(_) => f.write_str("a login session"), } } } +/// The login sessions the supervisor has opened. +pub struct Sessions { + next: u64, +} + +impl Default for Sessions { + fn default() -> Self { + Self { next: FIRST_LOGIN } + } +} + +impl Sessions { + /// A login session none before it was. + pub fn open(&mut self) -> Session { + let n = self.next; + self.next = n.checked_add(1).expect("2^64 login sessions opened in one boot"); + Session::Login(Login(n)) + } +} + /// What the supervisor mints on the launcher it endows `row`: the row a /// connection's caller holds, and the session it runs in. #[derive(Clone, Debug, PartialEq, Eq)] @@ -54,16 +98,19 @@ pub struct Authority { const MACHINE: u8 = 0; const LOGIN: u8 = 1; -const _: () = assert!(MAX_PROGRAM_NAME < MAX_BADGE, "an authority, a kind byte and a row, must fit one badge"); +const _: () = assert!(1 + 8 + MAX_PROGRAM_NAME <= MAX_BADGE, "an authority, a kind byte, a login's number and a row, must fit one badge"); impl Authority { - /// The session's kind, then the row's key. + /// The session's kind, a login session's number, then the row's key. pub fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(1 + self.row.len()); - out.push(match self.session { - Session::Machine => MACHINE, - Session::Login => LOGIN, - }); + let mut out = Vec::with_capacity(1 + 8 + self.row.len()); + match self.session { + Session::Machine => out.push(MACHINE), + Session::Login(Login(n)) => { + out.push(LOGIN); + out.extend_from_slice(&n.to_le_bytes()); + } + } out.extend_from_slice(self.row.as_bytes()); out } @@ -71,12 +118,18 @@ impl Authority { /// `None` for bytes [`Self::encode`] cannot have written. pub fn decode(bytes: &[u8]) -> Option { let (&kind, rest) = bytes.split_first()?; - let session = match kind { - MACHINE => Session::Machine, - LOGIN => Session::Login, + let (session, row) = match kind { + MACHINE => (Session::Machine, rest), + LOGIN if rest.len() >= 8 => { + let (n, row) = rest.split_at(8); + let n = u64::from_le_bytes(n.try_into().expect("eight bytes")); + if n < FIRST_LOGIN { + return None; + } + (Session::Login(Login(n)), row) + } _ => return None, }; - let row = rest; if row.is_empty() || row.len() > MAX_PROGRAM_NAME { return None; } @@ -108,9 +161,28 @@ impl fmt::Display for Refusal { } } -/// The session `target` starts in when `caller`, running in `session`, asks -/// for it, or why it does not start. -pub fn may_start(caller: &Program, session: Session, target: Target<'_>) -> Result { +/// Which session a launch [`may_start`] allowed runs in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Starts { + /// Its caller's. + In(Session), + /// A login session it opens. + Opening, +} + +impl Starts { + /// The session, opened from `sessions` when the launch opens one. + pub fn session(self, sessions: &mut Sessions) -> Session { + match self { + Self::In(session) => session, + Self::Opening => sessions.open(), + } + } +} + +/// Where `target` starts when `caller`, running in `session`, asks for it, or +/// why it does not start. +pub fn may_start(caller: &Program, session: Session, target: Target<'_>) -> Result { let (listed, program) = match target { Target::Row(row) => (row.name.as_str(), row), Target::Package(row) => (APPS, row), @@ -118,14 +190,14 @@ pub fn may_start(caller: &Program, session: Session, target: Target<'_>) -> Resu if !caller.starts.iter().any(|key| key == listed) { return Err(Refusal::NotListed); } - let session = match caller.login { - true => Session::Login, - false => session, + let starts = match caller.login { + true => Starts::Opening, + false => Starts::In(session), }; - if program.login_only() && session != Session::Login { + if program.login_only() && starts == Starts::In(Session::Machine) { return Err(Refusal::OutsideLogin); } - Ok(session) + Ok(starts) } /// The supervisor's line for a refused launch, which the metal judge reads whole. @@ -162,40 +234,72 @@ mod tests { fn a_caller_starts_what_its_row_lists_and_swap_and_update_only_in_a_login_session() { let (ordinary, unlisted, own, package) = (row("ordinary"), row("unlisted"), caller(false), row("gbae")); let (swap, update) = (swap(), update()); - let (machine, login) = (Session::Machine, Session::Login); + let (machine, login) = (Session::Machine, Sessions::default().open()); use Refusal::*; + use Starts::*; #[rustfmt::skip] - let table: [(bool, Session, Target, Result); 24] = [ - (false, machine, Target::Row(&ordinary), Ok(machine)), - (false, login, Target::Row(&ordinary), Ok(login)), + let table: [(bool, Session, Target, Result); 24] = [ + (false, machine, Target::Row(&ordinary), Ok(In(machine))), + (false, login, Target::Row(&ordinary), Ok(In(login))), (false, machine, Target::Row(&unlisted), Err(NotListed)), (false, login, Target::Row(&unlisted), Err(NotListed)), - (false, machine, Target::Row(&own), Ok(machine)), - (false, login, Target::Row(&own), Ok(login)), - (false, machine, Target::Package(&package), Ok(machine)), - (false, login, Target::Package(&package), Ok(login)), + (false, machine, Target::Row(&own), Ok(In(machine))), + (false, login, Target::Row(&own), Ok(In(login))), + (false, machine, Target::Package(&package), Ok(In(machine))), + (false, login, Target::Package(&package), Ok(In(login))), (false, machine, Target::Row(&swap), Err(OutsideLogin)), - (false, login, Target::Row(&swap), Ok(login)), + (false, login, Target::Row(&swap), Ok(In(login))), (false, machine, Target::Row(&update), Err(OutsideLogin)), - (false, login, Target::Row(&update), Ok(login)), - (true, machine, Target::Row(&ordinary), Ok(login)), - (true, login, Target::Row(&ordinary), Ok(login)), + (false, login, Target::Row(&update), Ok(In(login))), + (true, machine, Target::Row(&ordinary), Ok(Opening)), + (true, login, Target::Row(&ordinary), Ok(Opening)), (true, machine, Target::Row(&unlisted), Err(NotListed)), (true, login, Target::Row(&unlisted), Err(NotListed)), - (true, machine, Target::Row(&own), Ok(login)), - (true, login, Target::Row(&own), Ok(login)), - (true, machine, Target::Package(&package), Ok(login)), - (true, login, Target::Package(&package), Ok(login)), - (true, machine, Target::Row(&swap), Ok(login)), - (true, login, Target::Row(&swap), Ok(login)), - (true, machine, Target::Row(&update), Ok(login)), - (true, login, Target::Row(&update), Ok(login)), + (true, machine, Target::Row(&own), Ok(Opening)), + (true, login, Target::Row(&own), Ok(Opening)), + (true, machine, Target::Package(&package), Ok(Opening)), + (true, login, Target::Package(&package), Ok(Opening)), + (true, machine, Target::Row(&swap), Ok(Opening)), + (true, login, Target::Row(&swap), Ok(Opening)), + (true, machine, Target::Row(&update), Ok(Opening)), + (true, login, Target::Row(&update), Ok(Opening)), ]; for (i, (opens, session, target, want)) in table.into_iter().enumerate() { assert_eq!(may_start(&caller(opens), session, target), want, "row {i}"); } } + /// The shipping desktop's chain: the compositor, a `login` row, launches + /// terminals; a terminal launches a shell, and a shell launches shells. + /// Every launch down one chain spends the share its terminal's launch + /// opened, and no two of the compositor's launches share one, nor either + /// with the machine or the supervisor. + #[test] + fn a_sessions_launches_spend_its_one_share() { + let compositor = Program { starts: vec!["terminal".into()], login: true, ..row("compositor") }; + let terminal = Program { starts: vec!["shell".into()], ..row("terminal") }; + let shell = Program { starts: vec!["shell".into()], ..row("shell") }; + let mut sessions = Sessions::default(); + let mut opened = Vec::new(); + for _ in 0..2 { + let mut launch = |caller: &Program, session, target| { + may_start(caller, session, Target::Row(target)).expect("listed").session(&mut sessions) + }; + let first = launch(&compositor, Session::Machine, &terminal); + let mut session = launch(&terminal, first, &shell); + for _ in 0..8 { + session = launch(&shell, session, &shell); + } + assert_eq!(session.share(), first.share(), "a shell's launches left its session"); + opened.push(first.share()); + } + assert_ne!(opened[0], opened[1], "two launches of a login row share one session"); + for share in opened { + assert!(share != Session::Machine.share() && share != SUPERVISOR_SHARE, "{share}"); + } + assert_ne!(Session::Machine.share(), SUPERVISOR_SHARE); + } + /// A row that lists nothing starts nothing, a package included, and `/apps` /// lists no row: a key has no `/`. #[test] @@ -207,15 +311,18 @@ mod tests { } let apps_only = Program { starts: vec![APPS.into()], ..row("apps") }; assert_eq!(may_start(&apps_only, Session::Machine, Target::Row(&ordinary)), Err(Refusal::NotListed)); - assert_eq!(may_start(&apps_only, Session::Machine, Target::Package(&package)), Ok(Session::Machine)); + assert_eq!( + may_start(&apps_only, Session::Machine, Target::Package(&package)), + Ok(Starts::In(Session::Machine)) + ); } #[test] fn an_authority_reads_back_as_written() { for authority in [ Authority { row: "terminal".into(), session: Session::Machine }, - Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login }, - Authority { row: "s".into(), session: Session::Login }, + Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login(Login(u64::MAX)) }, + Authority { row: "s".into(), session: Sessions::default().open() }, ] { let bytes = authority.encode(); assert!(bytes.len() <= MAX_BADGE); @@ -227,7 +334,7 @@ mod tests { /// authority reads back as it. #[test] fn what_encode_cannot_have_written_is_refused() { - let whole = Authority { row: "shell".into(), session: Session::Login }; + let whole = Authority { row: "shell".into(), session: Sessions::default().open() }; let bytes = whole.encode(); for end in 0..bytes.len() { assert_ne!(Authority::decode(&bytes[..end]).as_ref(), Some(&whole), "a prefix of {end} bytes"); @@ -237,6 +344,15 @@ mod tests { assert_eq!(Authority::decode(&[MACHINE]), None); assert_eq!(Authority::decode(&[LOGIN]), None); assert_eq!(Authority::decode(&[MACHINE, 0xff]), None); + // A login session numbered as no session is. + for n in [SUPERVISOR_SHARE, MACHINE_SHARE] { + let mut bytes = vec![LOGIN]; + bytes.extend_from_slice(&n.to_le_bytes()); + bytes.push(b's'); + assert_eq!(Authority::decode(&bytes), None, "{n}"); + } + // A login session's number cut short. + assert_eq!(Authority::decode(&[LOGIN, 2, 0, 0, 0, 0, 0, 0]), None); let mut long = vec![MACHINE]; long.extend(std::iter::repeat_n(b'x', MAX_PROGRAM_NAME + 1)); assert_eq!(Authority::decode(&long), None); diff --git a/toyos/src/fs.rs b/toyos/src/fs.rs index b86cf109f39..13566e5d1cd 100644 --- a/toyos/src/fs.rs +++ b/toyos/src/fs.rs @@ -148,10 +148,10 @@ impl Reply { /// supervisor mints the connector with. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Grant<'a> { - /// The start it was minted for. The supervisor counts its starts, so a - /// process it started and every child that process spawned directly, - /// holding the same connectors, are one instance, and spend one share. - pub instance: u64, + /// The session whose share of the server it spends: every process the + /// supervisor starts in one session, through every launch made in it, and + /// every child those spawn directly is minted the same number. + pub session: u64, /// The directory, as a path on the role's volume, every path on the /// connection is resolved beneath: `home`, or the empty path for a volume /// served whole. [`canonical`], and at most [`MAX_GRANT_ROOT`] bytes. @@ -161,21 +161,21 @@ pub struct Grant<'a> { /// The format [`Grant::encode`] writes. Carried because a swap replaces a file /// server and not the supervisor, so one server reads grants another build /// minted, and an older one is refused by name rather than read as this one. -const GRANT_VERSION: u8 = 1; +const GRANT_VERSION: u8 = 2; /// The longest root a grant carries: what one badge holds past the version and -/// the instance. +/// the session. pub const MAX_GRANT_ROOT: usize = MAX_BADGE - 1 - 8; impl<'a> Grant<'a> { - /// The version, the instance, then the root: `None` for a root no grant + /// The version, the session, then the root: `None` for a root no grant /// can carry. pub fn encode<'b>(&self, out: &'b mut [u8; MAX_BADGE]) -> Option<&'b [u8]> { if self.root.len() > MAX_GRANT_ROOT || !canonical(self.root) { return None; } out[0] = GRANT_VERSION; - out[1..9].copy_from_slice(&self.instance.to_le_bytes()); + out[1..9].copy_from_slice(&self.session.to_le_bytes()); let end = 9 + self.root.len(); out[9..end].copy_from_slice(self.root.as_bytes()); Some(&out[..end]) @@ -187,9 +187,9 @@ impl<'a> Grant<'a> { if version != GRANT_VERSION || rest.len() < 8 || rest.len() - 8 > MAX_GRANT_ROOT { return None; } - let instance = u64::from_le_bytes(rest[..8].try_into().expect("eight bytes")); + let session = u64::from_le_bytes(rest[..8].try_into().expect("eight bytes")); let root = core::str::from_utf8(&rest[8..]).ok()?; - canonical(root).then_some(Self { instance, root }) + canonical(root).then_some(Self { session, root }) } } @@ -631,8 +631,8 @@ mod tests { #[test] fn a_grant_round_trips_at_every_bound() { let longest = "r".repeat(MAX_GRANT_ROOT); - for (instance, root) in [(0, ""), (1, "home"), (u64::MAX, "home/toy/Documents"), (7, longest.as_str())] { - let grant = Grant { instance, root }; + for (session, root) in [(0, ""), (1, "home"), (u64::MAX, "home/toy/Documents"), (7, longest.as_str())] { + let grant = Grant { session, root }; let mut out = [0u8; MAX_BADGE]; let bytes = grant.encode(&mut out).expect("a root a grant carries"); assert_eq!(Grant::decode(bytes), Some(grant), "{root:?}"); @@ -644,15 +644,15 @@ mod tests { let mut out = [0u8; MAX_BADGE]; let past = "r".repeat(MAX_GRANT_ROOT + 1); for root in ["/home", "home/", "a//b", ".", "a/../b", past.as_str()] { - assert_eq!(Grant { instance: 1, root }.encode(&mut out), None, "{root:?}"); + assert_eq!(Grant { session: 1, root }.encode(&mut out), None, "{root:?}"); } } #[test] fn bytes_no_grant_was_encoded_as_are_refused() { let mut out = [0u8; MAX_BADGE]; - let good = Grant { instance: 3, root: "home" }.encode(&mut out).unwrap().to_vec(); - // Shorter than a version and an instance. + let good = Grant { session: 3, root: "home" }.encode(&mut out).unwrap().to_vec(); + // Shorter than a version and a session. for n in 0..9 { assert_eq!(Grant::decode(&good[..n]), None, "{n} bytes"); } diff --git a/userland/fileserver/src/main.rs b/userland/fileserver/src/main.rs index 83d1882e0c5..b861ba25866 100644 --- a/userland/fileserver/src/main.rs +++ b/userland/fileserver/src/main.rs @@ -16,11 +16,11 @@ //! the grant's root (`fileserver::resolve`); a write on a read-only volume is //! refused before the volume sees it. //! -//! **One instance cannot take the server.** Beneath each machine-wide bound — +//! **One session cannot take the server.** Beneath each machine-wide bound — //! connections waiting on their hello, connections served, streams — each -//! instance a grant names has a share of its own, so one holding all it may -//! leaves the rest of each bound to the others. Four at their shares take the -//! server (`issues/a-file-servers-shares-are-per-instance-and-one-session-launches-instances.md`). +//! session a grant names has a share of its own, so one holding all it may, +//! through every program launched in it, leaves the rest of each bound to the +//! others. //! //! **A server never blocks on a client.** Accept and the first frame are two //! events; a request is buffered until whole; every reply is one `try_send`, @@ -69,11 +69,11 @@ const MAX_FIDS: usize = 1024; /// Streams served at once, machine-wide. const MAX_STREAMS: usize = 64; -/// One instance's shares of [`MAX_SERVED`], [`MAX_HANDSHAKES`] and +/// One session's shares of [`MAX_SERVED`], [`MAX_HANDSHAKES`] and /// [`MAX_STREAMS`]. Past its share of served clients a hello is answered /// `ResourceExhausted`, and past its share of streams a `STREAM` is; past its /// share of handshakes a connection is, as it is taken, and let go. A quarter -/// of each bound, so an instance at its share leaves the bound to three more. +/// of each bound, so a session at its share leaves the bound to three more. const SERVED_SHARE: usize = MAX_SERVED / 4; const HANDSHAKE_SHARE: usize = MAX_HANDSHAKES / 4; const STREAM_SHARE: usize = MAX_STREAMS / 4; @@ -133,8 +133,8 @@ struct Client { rx: ipc::FrameRx<{ core::mem::size_of::() }>, /// Its grant's root, beneath which every path it names is resolved. root: String, - /// Its grant's instance, whose shares it spends. - instance: u64, + /// Its grant's session, whose shares it spends. + session: u64, window: Option, fids: BTreeMap, next_fid: u64, @@ -146,8 +146,8 @@ struct Stream { pipe: Pipe, node: Node, offset: u64, - /// The instance of the client that asked for it, whose share it spends. - instance: u64, + /// The session of the client that asked for it, whose share it spends. + session: u64, } fn main() { @@ -421,7 +421,7 @@ impl Server { /// Take the next connection and read its grant. Only the supervisor mints /// on this port, so a connection without one this server reads is let go - /// by name; one whose instance already has its share of handshakes is + /// by name; one whose session already has its share of handshakes is /// answered `ResourceExhausted` and let go. fn accept(&mut self) { let conn = match self.acceptor.accept() { @@ -436,13 +436,13 @@ impl Server { }, Err(why) => return println!("fileserver: letting a connection go: it carries no grant ({why:?})"), }; - let mine = self.clients.values().filter(|c| c.instance == grant.instance && c.window.is_none()).count(); + let mine = self.clients.values().filter(|c| c.session == grant.session && c.window.is_none()).count(); if mine >= HANDSHAKE_SHARE { // Let go whether or not the refusal went: either way it is said. let _ = conn.try_send(REPLY, &Reply::refused(SyscallError::ResourceExhausted)); return println!( - "fileserver: letting a connection go: instance {} has {HANDSHAKE_SHARE} waiting on their hello", - grant.instance + "fileserver: letting a connection go: session {} has {HANDSHAKE_SHARE} waiting on their hello", + grant.session ); } let id = self.next_client; @@ -451,7 +451,7 @@ impl Server { conn, rx: ipc::FrameRx::new(), root: grant.root.to_string(), - instance: grant.instance, + session: grant.session, window: None, fids: BTreeMap::new(), next_fid: 1, @@ -463,7 +463,7 @@ impl Server { fn drop_client(&mut self, id: u64, why: &str) { let Some(client) = self.clients.remove(&id) else { return }; if !why.is_empty() { - println!("fileserver: dropping client {id} of {:?}, instance {}: {why}", client.root, client.instance); + println!("fileserver: dropping client {id} of {:?}, session {}: {why}", client.root, client.session); } for fid in client.fids.values() { // Nobody is left to answer: a refused close is said, and its node @@ -553,9 +553,9 @@ impl Server { fn answer(&mut self, id: u64, op: u32, r: Request) -> Answer { if op == HELLO { - let instance = self.clients[&id].instance; + let session = self.clients[&id].session; let served = self.clients.values().filter(|c| c.window.is_some()); - let (all, mine) = served.fold((0, 0), |(all, mine), c| (all + 1, mine + usize::from(c.instance == instance))); + let (all, mine) = served.fold((0, 0), |(all, mine), c| (all + 1, mine + usize::from(c.session == session))); let client = self.clients.get_mut(&id).expect("pumped"); if client.window.is_some() { return Answer::Drop("it lent a second window"); @@ -565,7 +565,7 @@ impl Server { return Answer::Refuse(SyscallError::ResourceExhausted, why); } if mine >= SERVED_SHARE { - let why = format!("it is refused, since its instance is served {SERVED_SHARE} clients already"); + let why = format!("it is refused, since its session is served {SERVED_SHARE} clients already"); return Answer::Refuse(SyscallError::ResourceExhausted, why); } let Some([lent]) = client.conn.recv_handles_exact::<1>() else { @@ -721,8 +721,8 @@ impl Server { return Err(SyscallError::InvalidArgument); } let node = f.node; - let instance = self.clients[&id].instance; - let mine = self.streams.values().filter(|s| s.instance == instance).count(); + let session = self.clients[&id].session; + let mine = self.streams.values().filter(|s| s.session == session).count(); if self.streams.len() >= MAX_STREAMS || mine >= STREAM_SHARE { return Err(SyscallError::ResourceExhausted); } @@ -730,7 +730,7 @@ impl Server { self.volume.hold(node); let sid = self.next_stream; self.next_stream += 1; - self.streams.insert(sid, Stream { pipe: read, node, offset: r.offset, instance }); + self.streams.insert(sid, Stream { pipe: read, node, offset: r.offset, session }); Ok(Answer::WithHandle(Reply::ok(), write.into_raw())) } STAT | LSTAT => { diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index ed35fbd0d11..87a88a18b7d 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -68,7 +68,7 @@ use std::time::{Duration, Instant}; use toyos_swap::{Refusal, Request as SwapRequest, Word}; -use toyos_manifest::launch::{self as authority, Authority, Session, Target}; +use toyos_manifest::launch::{self as authority, Authority, Session, Sessions, Target}; use toyos_manifest::package::{self, Package}; use toyos_manifest::{Manifest, Program}; use toyos::endow::Endowments; @@ -380,20 +380,20 @@ fn main() { // One port per file-server role, made here for the same reason: a // program's first open works whether or not its server runs yet. Its // connector goes at once: every connector a program holds to it is a - // grant minted for that program's start (`Grants`). + // grant minted for that program's session (`Grants`). let mut role_acceptors: BTreeMap<&str, Acceptor> = BTreeMap::new(); for role in system.programs.iter().flat_map(|p| p.roles.iter()) { let (acceptor, _) = port::create().unwrap_or_else(|e| panic!("supervisor: no port for the `{role}` role: {e:?}")); role_acceptors.insert(role, acceptor); } // The supervisor's own files are resolved through grants like every - // program's, under the instance no start is given, and it is the one + // program's, under a share no session is given, and it is the one // process nobody endows a namespace: std resolves through this one, and // the stop's syncs through the second. let files: &'static Namespace = { let own: Vec<(String, Connector)> = role_acceptors .iter() - .flat_map(|(role, acceptor)| mint_grants(role, acceptor, SUPERVISOR_INSTANCE)) + .flat_map(|(role, acceptor)| mint_grants(role, acceptor, authority::SUPERVISOR_SHARE)) .collect(); let build = || { let mut builder = namespace::build(); @@ -419,7 +419,8 @@ fn main() { syscap: &syscap, acceptors, connectors, - grants: Grants { roles: Vec::new(), next: Cell::new(SUPERVISOR_INSTANCE + 1) }, + grants: Grants { roles: Vec::new() }, + sessions: Sessions::default(), files, services: Vec::new(), log, @@ -524,6 +525,8 @@ struct Supervisor<'a> { connectors: BTreeMap<&'a str, Connector>, /// What each program's view is minted from. grants: Grants<'a>, + /// The login sessions launches have opened. + sessions: Sessions, /// The same directories as a namespace of the supervisor's own, for the stop's syncs. files: &'static Namespace, /// What `[boot] start` named, a file server once per role. @@ -1548,10 +1551,10 @@ impl Supervisor<'_> { let (row, session) = (caller.row, caller.session); let found = self.files("a launch's files", move || { resolve(system, &path, |target| -> Result<_, authority::Refusal> { - let session = authority::may_start(row, session, target)?; + let starts = authority::may_start(row, session, target)?; let (Target::Row(program) | Target::Package(program)) = target; let prepared = command.image_from(Path::new(&program.path)).prepare().map(drop); - Ok((session, prepared.map(|()| command))) + Ok((starts, prepared.map(|()| command))) }) }); let resolved = match found { @@ -1585,7 +1588,7 @@ impl Supervisor<'_> { }; // **`MSG_REFUSED`, never `MSG_NOT_DECLARED`**: the latter is std's cue to // spawn the program itself. - let (session, prepared) = match verdict { + let (starts, prepared) = match verdict { Ok(judged) => judged, Err(why) => { say!("{}", authority::refused(&caller.row.name, caller.session, &program.name, why)); @@ -1601,6 +1604,7 @@ impl Supervisor<'_> { return; } }; + let session = starts.session(&mut self.sessions); let caller_slots: Vec<(u32, toyos::RawHandle)> = request.slot_numbers().zip(slots.0.iter().copied()).collect(); @@ -1919,7 +1923,7 @@ fn start<'a>( command.endow(&label, raw.0); held.0.push(raw); } - if let Some(ns) = build_namespace(program, system, connectors, grants, extras)? { + if let Some(ns) = build_namespace(program, system, connectors, grants.view(program, launcher.1), extras)? { let raw = ns.into_raw(); command.endow(SVC_LABEL, raw.0); held.0.push(raw); @@ -2212,13 +2216,12 @@ fn build_namespace( program: &Program, system: &Manifest, connectors: &BTreeMap<&str, Connector>, - grants: &Grants<'_>, + view: Vec<(String, Connector)>, extras: &[(&str, Connector)], ) -> std::io::Result> { // Never the swap port: [`swap_namespace`] says why. let receives: Vec<&String> = program.receives.iter().filter(|name| *name != toyos_swap::PORT).collect(); - let view = grants.view(program); if receives.is_empty() && extras.is_empty() && view.is_empty() { return Ok(None); } @@ -2250,27 +2253,22 @@ fn build_namespace( } } -/// The instance of the supervisor's own grants, which no start is given. -const SUPERVISOR_INSTANCE: u64 = 0; - /// What each program's directory capabilities are minted from: each -/// file-server role's port, and the count of starts. +/// file-server role's port. /// -/// **Each start is minted grants of its own** (`toyos::fs::Grant`), one per -/// directory of every role, all naming one instance, so the servers count the -/// process and every child it spawns directly, which holds the same -/// connectors, against one share. A role whose ports closed for good is -/// minted nothing. +/// **Each start is minted grants naming its session** (`toyos::fs::Grant`, +/// [`Session::share`]), one per directory of every role, so the servers count +/// every process of a session, every child each spawns directly and every +/// program launched in it against one share. A role whose ports closed for +/// good is minted nothing. struct Grants<'a> { /// Each role's service, whose kept acceptor is the role's port. roles: Vec<(&'a str, Arc>)>, - /// The instance the next start is minted. - next: Cell, } impl Grants<'_> { - /// The directory capabilities `program` is endowed for one start, by - /// namespace name. + /// The directory capabilities `program` is endowed for one start in + /// `session`, by namespace name. /// /// **Every program sees the whole tree the file servers serve**, which is /// the kernel's old view kept whole until each row declares its own @@ -2278,17 +2276,15 @@ impl Grants<'_> { /// with one exception: a storage row sees none, since a file server /// resolving a path of its own through itself waits for ever. Asked before /// anything is locked, since a storage row's start holds its own kept state. - fn view(&self, program: &Program) -> Vec<(String, Connector)> { + fn view(&self, program: &Program, session: Session) -> Vec<(String, Connector)> { if is_storage(program) { return Vec::new(); } - let instance = self.next.get(); - self.next.set(instance + 1); let mut view = Vec::new(); for (role, kept) in &self.roles { let kept = kept.lock().expect("supervisor: a service's state is poisoned"); if let Some((_, acceptor)) = kept.acceptors.first() { - view.extend(mint_grants(role, acceptor, instance)); + view.extend(mint_grants(role, acceptor, session.share())); } } view @@ -2296,18 +2292,18 @@ impl Grants<'_> { } /// A grant on `acceptor`, the `role`'s port, for each of its directories, -/// naming `instance`: each by the namespace name a program opens it under. -fn mint_grants(role: &str, acceptor: &Acceptor, instance: u64) -> Vec<(String, Connector)> { +/// naming `share`: each by the namespace name a program opens it under. +fn mint_grants(role: &str, acceptor: &Acceptor, share: u64) -> Vec<(String, Connector)> { let dirs = toyos_manifest::role_dirs(role).expect("supervisor: the build refuses a role it does not know"); dirs.iter() .map(|dir| { let mut badge = [0u8; MAX_BADGE]; - let badge = Grant { instance, root: dir.root } + let badge = Grant { session: share, root: dir.root } .encode(&mut badge) .unwrap_or_else(|| panic!("supervisor: {}'s root {:?} is no grant's", dir.dir, dir.root)); let connector = acceptor .mint(badge) - .unwrap_or_else(|e| panic!("supervisor: no grant on {} for instance {instance}: {e:?}", dir.dir)); + .unwrap_or_else(|e| panic!("supervisor: no grant on {} for share {share}: {e:?}", dir.dir)); (format!("{CAPABILITY_PREFIX}{}", dir.dir), connector) }) .collect() From 212473dc96c868c2ddbfc80ae13c3f05af8f7860 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 7 Oct 2026 14:37:37 +0200 Subject: [PATCH 2/3] A share is a service or a login session, and a login session opens none Review round 1 of #742 sent the branch back on three counts. A session reached a `login` row through `starts` (the shell's and toybox's rows list sshserver) and every launch that row then made opened a fresh session, so the closed issue's exit was not met. `may_start` now opens a session only for a `login` row's launch made in the machine's session; in a login session a `login` row's launches stay in it, like every other row's. sshserver started by hand from a desktop shell therefore serves its clients in that shell's session. Every boot row spent the one machine share, where on main each start had its own. `Session::Machine` now carries a share as `Session::Login` does, and the supervisor numbers one per service when it makes the service, so every start of it, a restart or a swap included, spends that one: a service that ends and is started again holds no more of a server than before, which main's share-per-start did not say of a service whose detached launches outlive it. fs_share's launch arms are rewritten around that. The shells it launches under its own exhausted share are refused, judged on the file alone and no longer on the shell's fallback of running a command whose redirect it could not open (filed). Then a chain of shells in one login session, each holding its next one's output stream: the first streams and is read back, and the shell one past the session's share of streams is refused. Before this commit each link was a session of its own and the last one streamed. The residual, that any four shares at their parts take a server, is refiled under a slug that says so, with the file server as its owner; it no longer claims a stage of the files track owns it. Co-Authored-By: Claude Opus 5.5 --- ...ions-at-their-shares-take-a-file-server.md | 32 --- issues/four-shares-take-a-file-server.md | 39 ++++ ...ommand-whose-redirect-it-could-not-open.md | 27 +++ src/build.rs | 3 +- system.toml | 5 +- tests/proctreecase/system.toml | 7 +- tests/toyos-rust-tests/src/bin/fs_share.rs | 125 ++++++----- tests/toyos.rs | 16 +- toyos-manifest/src/launch.rs | 211 ++++++++++-------- toyos-manifest/src/lib.rs | 5 +- toyos/src/fs.rs | 28 +-- userland/fileserver/src/main.rs | 53 ++--- userland/supervisor/src/main.rs | 26 ++- 13 files changed, 334 insertions(+), 243 deletions(-) delete mode 100644 issues/four-login-sessions-at-their-shares-take-a-file-server.md create mode 100644 issues/four-shares-take-a-file-server.md create mode 100644 issues/the-shell-runs-a-command-whose-redirect-it-could-not-open.md diff --git a/issues/four-login-sessions-at-their-shares-take-a-file-server.md b/issues/four-login-sessions-at-their-shares-take-a-file-server.md deleted file mode 100644 index 70812ef82f7..00000000000 --- a/issues/four-login-sessions-at-their-shares-take-a-file-server.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-05 ---- - -# Four login sessions at their shares take a file server - -`userland/fileserver/src/main.rs` gives each session a grant names a quarter of -each machine-wide bound: 32 of `MAX_SERVED`'s 128 served connections, 8 of -`MAX_HANDSHAKES`' 32 waiting on their hello, 16 of `MAX_STREAMS`' 64 streams. -So four sessions at their shares take each bound whole, and a fifth session's -first connection or `STREAM` on that server is refused `ResourceExhausted`. - -A session no longer gets more of a server by launching: every launch made in -it spends its share (`toyos_manifest::launch`). But sessions are cheap to -open. Every launch a `login` row makes opens one: each program the desktop's -taskbar starts (the compositor's row), and each program sshserver starts for a -client. A session reaches a `login` row of its own through `starts`: the -shell's and toybox's rows in `system.toml` list `sshserver`, and any process can -append a key to its list (`issues/sshserver-authorized-keys-unprotected.md`). - -Nothing bounds how many sessions are open, so no fixed share answers every -session's first connection. Stage 4 of -`issues/every-program-sees-only-the-files-it-was-given.md` owns per-session -bounds. - -## Exit condition - -However many sessions are open, each holding all a server lets it hold, the -next session's first connection and first stream on that server are answered, -or opening it is refused by name; shown by a test that does exactly that. diff --git a/issues/four-shares-take-a-file-server.md b/issues/four-shares-take-a-file-server.md new file mode 100644 index 00000000000..8c04e873eab --- /dev/null +++ b/issues/four-shares-take-a-file-server.md @@ -0,0 +1,39 @@ +--- +status: open +kind: defect +opened: 2026-10-07 +--- + +# Four shares take a file server + +`userland/fileserver/src/main.rs` gives each share a grant names a quarter of +each machine-wide bound: 32 of `MAX_SERVED`'s 128 served connections, 8 of +`MAX_HANDSHAKES`' 32 waiting on their hello, 16 of `MAX_STREAMS`' 64 streams. +So four shares at their parts take each bound whole, and a fifth's first +connection or `STREAM` on that server is refused `ResourceExhausted`. + +A share is a service the supervisor started or a login session +(`toyos_manifest::launch`), and neither gets a second by launching: a launch +opens a session only when a `login` row makes it from the machine's session. +What is left is how many shares there are. The shipping boot starts more than +four services that hold DATA's directories, and the compositor's row opens a +session for each program its taskbar starts, so four of them, each at its +parts, refuse the rest. Nothing counts the sessions open, and no session ever +ends (`issues/every-program-sees-only-the-files-it-was-given.md`, "Known +weaknesses"). + +The parts are sized against what was measured, not against a share's need: +every boot of the guest suite, the shipping desktop booted under QEMU, and +every shipping Rust job run one after another in one `tests/testcases` boot +never had one start past 3 served connections, 1 waiting on its hello or 0 +streams, nor more than 5 of DATA's connections served at once. A parallel +build (cargo with rustc as its direct children) is the unmeasured load. + +Owner: the file server. + +## Exit condition + +However many shares are held, each at all a server lets it hold, the next +share's first connection and first stream on that server are answered, or +opening the session that would hold it is refused by name; shown by a test +that does exactly that. diff --git a/issues/the-shell-runs-a-command-whose-redirect-it-could-not-open.md b/issues/the-shell-runs-a-command-whose-redirect-it-could-not-open.md new file mode 100644 index 00000000000..ddde1e8f523 --- /dev/null +++ b/issues/the-shell-runs-a-command-whose-redirect-it-could-not-open.md @@ -0,0 +1,27 @@ +--- +status: open +kind: defect +opened: 2026-10-07 +--- + +# The shell runs a command whose redirect it could not open + +`apply_redirect` (`userland/shell/src/main.rs`) answers a redirect target it +could not open by printing `cannot open: ` and returning, and its +callers spawn the command all the same, with the shell's own output in place +of the file. `echo secret > /somewhere/refused` therefore writes `secret` to +the terminal, or into whatever the shell's output is, and the line's status is +the command's. The `2>` arms do the same without the line. + +Read from the source, not measured. `tests/toyos-rust-tests/src/bin/fs_share.rs` +launches a shell whose redirect's connection is refused and judges only that +the file was never made, so it does not hold this behaviour in place. + +Owner: the shell. + +## Exit condition + +A command whose redirect could not be opened is not run, the shell says the +error the open answered, and the line's status is a failure; a guest probe +redirects into a path that does not open and reads back neither the command's +output nor a zero status. diff --git a/src/build.rs b/src/build.rs index 832cfe5b4ab..00719714713 100644 --- a/src/build.rs +++ b/src/build.rs @@ -171,7 +171,8 @@ struct ProgramConfig { restart: bool, /// The rows it may start through the launcher (`toyos_manifest::Program::starts`). starts: Vec, - /// A launch it makes opens a login session (`toyos_manifest::Program::login`). + /// A launch it makes from the machine's session opens a login session + /// (`toyos_manifest::Program::login`). login: bool, } diff --git a/system.toml b/system.toml index d93d01f23fa..9b1481308f9 100644 --- a/system.toml +++ b/system.toml @@ -77,8 +77,9 @@ service = true serves = ["netstack"] devices = ["pci:1af4:1041"] -# Every launch it makes opens a login session, the one `update` and `swap` -# below start in. +# Every launch it makes is in a login session, the one `update` and `swap` +# below start in: one it opens when it runs in the machine's session, and the +# one it was launched in otherwise, as when a desktop shell starts it. [programs.sshserver] service = true receives = ["netstack"] diff --git a/tests/proctreecase/system.toml b/tests/proctreecase/system.toml index 7cc4c78479a..4c53742d095 100644 --- a/tests/proctreecase/system.toml +++ b/tests/proctreecase/system.toml @@ -13,9 +13,10 @@ # session it was launched in: refused in the machine's, started in the one the # shell opens. # -# And `fs_share`: a shell test-runner launches is in the machine's session, as -# test-runner is, and spends its share of DATA's server; a shell that shell -# launches opens a login session, which has a share of its own. +# And `fs_share`: a shell test-runner launches opens no session and spends +# test-runner's share of DATA's server; a shell that shell launches is in a +# login session its row opens, which has a share of its own, and so is every +# shell launched down from it, whose `login` row opens none there. [boot] start = ["logkeeper", "diskserver", "fileserver", "test-runner"] diff --git a/tests/toyos-rust-tests/src/bin/fs_share.rs b/tests/toyos-rust-tests/src/bin/fs_share.rs index 3d3262d627e..cf74f947b03 100644 --- a/tests/toyos-rust-tests/src/bin/fs_share.rs +++ b/tests/toyos-rust-tests/src/bin/fs_share.rs @@ -1,31 +1,36 @@ -//! One session holding all a file server lets it hold, through every program -//! launched in it, leaves the server answering another session. +//! A file server's bounds are shared out by share: one holding all it may +//! leaves the server answering another, and no launch made in a login session +//! gives that session a second. //! -//! This job holds test-runner's grants and launcher, in the machine's session. -//! On DATA's server: +//! This job holds test-runner's grants and launcher: test-runner's share, in +//! the machine's session. On DATA's server: //! //! - each of DATA's directories is its grant's root: a file made under //! `/home` is under none of the others; -//! - it takes every stream the server will give its session, until one is -//! refused; a shell it launches, in this session, then has its redirect's -//! stream refused; -//! - it takes every connection the server will serve its session, until one -//! is refused; a shell it launches then has its redirect's connection -//! refused; -//! - holding all that, a shell it launches launches another, whose row opens -//! a login session: that shell connects to `/home` and streams a child's -//! output into a file, and the bytes are read back here; -//! - last, it opens more connections than one session may have waiting on -//! their hello: the ones past its share are answered `ResourceExhausted` +//! - it takes every stream the server will give its share, until one is +//! refused; a shell it launches, which opens no session, then has its +//! redirect's stream refused; +//! - it takes every connection the server will serve its share, until one is +//! refused; a shell it launches then has its redirect's connection refused; +//! - holding all that, a shell it launches launches another, which that +//! shell's `login` row opens a login session for. Down a chain of shells +//! each launching the next, every one in that session, each streams a +//! child's output into a file and holds one more stream, its next shell's +//! output. The first's file shows another session's first connection and +//! first stream answered; every one's up to the session's share of streams +//! is written, and the next shell's is refused: a `login` row's launches in +//! a login session opened none; +//! - last, it opens more connections than one share may have waiting on +//! their hello: the ones past it are answered `ResourceExhausted` //! and let go as the server takes them, so the first to end is not the //! first opened, which the server would otherwise let go first, at its //! handshake timeout; a hello on it, which finds the server gone, reads //! why, and every later one keeps none of the windows it could not lend. //! Last, because the server reaps the ones this job drops only when it -//! next reads them, and until then they are this session's share. +//! next reads them, and until then they are this share's. use std::fs; -use std::process::{Command, Output}; +use std::process::Command; use toyos::endow; use toyos::fs::{hello, Dir, O_CREATE, O_WRITE, WINDOW_BYTES}; @@ -36,14 +41,15 @@ use toyos_abi::syscall::SyscallError; use toyos_abi::RawHandle; const DIR: &str = "/home/fs_share"; -const OTHER: &str = "/home/fs_share/other"; const SAME_STREAM: &str = "/home/fs_share/same_stream"; const SAME_CONNECTION: &str = "/home/fs_share/same_connection"; +const FIRST: &str = "/home/fs_share/first"; +const PAST: &str = "/home/fs_share/past"; const MARK: &str = "/home/fs_share/mark"; const SAID: &str = "answered"; -/// More connections than one session's share of a server's handshakes: the -/// server's own machine-wide bound on them. +/// More connections than one share of a server's handshakes: the server's own +/// machine-wide bound on them. const UNANSWERED: usize = 32; /// Far past any bound a server keeps, so a server that refuses nothing ends the @@ -59,7 +65,7 @@ fn main() { // std's own connection to /home, made before anything is held, is what // the shell's file is read back through. fs::create_dir_all(DIR).expect("make the test's directory"); - for file in [OTHER, SAME_STREAM, SAME_CONNECTION] { + for file in [SAME_STREAM, SAME_CONNECTION, FIRST, PAST] { let _ = fs::remove_file(file); } // Every arm runs, so one run says each one that is red. @@ -74,7 +80,7 @@ fn main() { } } - // Every stream the server gives this session. + // Every stream the server gives this share. let mut dir = Dir::connect(names, "fs:/home").expect("a client of /home"); let file = dir.open("fs_share/streams", O_WRITE | O_CREATE).expect("open a file to stream into"); let mut streams = Vec::new(); @@ -88,18 +94,18 @@ fn main() { } }; match refused { - Some(SyscallError::ResourceExhausted) => println!(" this session holds {} streams", streams.len()), + Some(SyscallError::ResourceExhausted) => println!(" this share holds {} streams", streams.len()), other => red.push(format!("after {} streams, the next was answered {other:?}", streams.len())), } - // A launch in this session: its redirect connects and opens the file, and - // the stream into it is refused, so it stays empty. - let shell = run_shell(&format!("/system/bin/toybox echo {SAID} > {SAME_STREAM}")); + // A launch that opens no session: its redirect connects and opens the + // file, and the stream into it is refused, so it stays empty. + let shell = run_shell(&format!("/system/bin/toybox echo {SAID} > {SAME_STREAM}")).output().expect("launch a shell"); match fs::read_to_string(SAME_STREAM) { - Ok(text) if text.is_empty() => println!(" a shell launched in this session was refused a stream"), - other => red.push(format!("a shell launched in this session streamed {other:?}: {shell:?}")), + Ok(text) if text.is_empty() => println!(" a shell launched under this share was refused a stream"), + other => red.push(format!("a shell launched under this share streamed {other:?}: {shell:?}")), } - // Every connection the server serves this session, all lent one window. + // Every connection the server serves this share, all lent one window. let window = SharedMemory::create(WINDOW_BYTES).expect("a window"); let mut served = Vec::new(); let refused = loop { @@ -114,30 +120,47 @@ fn main() { }; match refused { Some(SyscallError::ResourceExhausted) => { - println!(" this session is served {} more connections", served.len()) + println!(" this share is served {} more connections", served.len()) } other => red.push(format!("after {} connections, the next hello was answered {other:?}", served.len())), } - // A launch in this session: its redirect's connection is refused, so the - // shell says so and its child echoes to the shell's own output instead. - let shell = run_shell(&format!("/system/bin/toybox echo {SAID} > {SAME_CONNECTION}")); - let echoed = String::from_utf8_lossy(&shell.stdout).trim_end() == SAID; + // A launch that opens no session: its redirect's connection is refused, + // so the file is never made. + let shell = run_shell(&format!("/system/bin/toybox echo {SAID} > {SAME_CONNECTION}")).output().expect("launch a shell"); match fs::metadata(SAME_CONNECTION) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound && echoed => { - println!(" a shell launched in this session was refused a connection") + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + println!(" a shell launched under this share was refused a connection") } - other => red.push(format!("a shell launched in this session made {SAME_CONNECTION}: {other:?}, {shell:?}")), + other => red.push(format!("a shell launched under this share made {SAME_CONNECTION}: {other:?}, {shell:?}")), } - // Another session, while this one holds all that: the shell this job - // launches is in this session, and the one it launches opens a session. - let shell = run_shell(&format!("/system/bin/shell -c '/system/bin/toybox echo {SAID} > {OTHER}'")); - if !shell.status.success() { - red.push(format!("the other session's shells failed: {shell:?}")); + // A login session, while this share holds all that: the shell this job + // launches opens none, and the one it launches is in one its row opens. + // The first shell in it streams, and every shell holds its next one's + // output, one stream more, until the session holds as many as this share + // does; the last asks for one past that. Each link's line is a variable + // the next shell expands, so no line is quoted inside another. + let links = streams.len() + 1; + let mut chain = run_shell("/system/bin/shell -c $FS_SHARE_1"); + for link in 1..=links { + let next = format!("/system/bin/shell -c $FS_SHARE_{} > {DIR}/next_{link}", link + 1); + let line = match link { + 1 => format!("/system/bin/toybox echo {SAID} > {FIRST} ; {next}"), + _ if link == links => format!("/system/bin/toybox echo {SAID} > {PAST}"), + _ => next, + }; + chain.env(format!("FS_SHARE_{link}"), line); } - match fs::read_to_string(OTHER) { - Ok(text) if text.trim_end() == SAID => println!(" another session connected and streamed"), - other => red.push(format!("another session's stream wrote {other:?}")), + let chain = chain.output(); + match fs::read_to_string(FIRST) { + Ok(text) if text.trim_end() == SAID => println!(" a login session connected and streamed"), + other => red.push(format!("a login session's first stream wrote {other:?}: {chain:?}")), + } + match fs::read_to_string(PAST) { + Ok(text) if text.is_empty() => { + println!(" a login session's launches held {} streams and the next was refused", links - 1) + } + other => red.push(format!("past {} streams, a login session's launch streamed {other:?}: {chain:?}", links - 1)), } // Connections that never say hello. @@ -181,9 +204,11 @@ fn main() { println!("fs_share: PASS"); } -/// A shell launched through test-runner's launcher, in this job's session, -/// running `line`, from `/`: no file server's directory, so the redirect is -/// the only file it opens. -fn run_shell(line: &str) -> Output { - Command::new("/system/bin/shell").args(["-c", line]).current_dir("/").output().expect("launch a shell") +/// A shell launched through test-runner's launcher, under this job's share, +/// running `line`, from `/`: no file server's directory, so a redirect is the +/// only file it opens. +fn run_shell(line: &str) -> Command { + let mut shell = Command::new("/system/bin/shell"); + shell.args(["-c", line]).current_dir("/"); + shell } diff --git a/tests/toyos.rs b/tests/toyos.rs index 39c386a13f7..2c078c18d0c 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -128,9 +128,9 @@ const RUST_SKIP: &[&str] = &[ // `port_badge` metal row runs it on tests/proctreecase. "port_badge", // Needs a launcher whose row lists a shell, and a shell whose row opens a - // login session and lists a shell, so that another session asks DATA's - // server while this one holds its shares: the `fs_share` metal row runs it - // on tests/proctreecase. + // login session and lists a shell, so that a login session and its + // launches ask DATA's server while this job's share holds all it may: the + // `fs_share` metal row runs it on tests/proctreecase. "fs_share", // It asserts nothing at all: it holds a `tests/lanleasecase` boot open for // twenty seconds. On a shared boot it would be twenty seconds of nothing. @@ -692,8 +692,8 @@ const METAL: &[(&str, metal::Metal)] = &[ metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_port_badge") }, ), ( - // One session holding all a file server lets it hold, through every - // launch made in it, leaves the server answering another session. + // One share holding all a file server lets it hold leaves the server + // answering a login session, whose launches all spend its one share. "fs_share", metal::Metal { arms: PROCTREECASE, judge: |b| b[0].job_passed("test_rs_fs_share") }, ), @@ -923,7 +923,7 @@ const METALCASE: &[metal::Arm] = &[metal::once("metalcase", "tests/metalcase", & /// A launcher and a declared `cat` and shell, which `process_tree`'s subtree /// launches, a `toybox` row holding `roster`, which `launch_toctou` races, the /// rows `launch_authority` is refused and started, and the shells `fs_share` -/// asks DATA's server through, in its session and in another. +/// asks DATA's server through, under its share and in a login session. const PROCTREECASE: &[metal::Arm] = &[metal::once( "proctreecase", "tests/proctreecase", @@ -2911,7 +2911,7 @@ fn process_tree(back: &metal::Readback) -> Result<(), String> { /// session, for test-runner and for the toybox it launched. The guest sees /// only that each was refused. fn launch_authority(back: &metal::Readback) -> Result<(), String> { - use toyos_manifest::launch::{refused, Refusal, Session}; + use toyos_manifest::launch::{refused, Refusal, Sessions}; back.job_passed("test_rs_launch_authority")?; let log = back.log(); for (caller, target, why) in [ @@ -2920,7 +2920,7 @@ fn launch_authority(back: &metal::Readback) -> Result<(), String> { ("test-runner", "update", Refusal::OutsideLogin), ("toybox", "swap", Refusal::OutsideLogin), ] { - let line = refused(caller, Session::Machine, target, why); + let line = refused(caller, Sessions::default().machine(), target, why); if !log.text().lines().any(|l| l.contains(&line)) { return Err(format!("the supervisor never said `{line}`\n{}", log.text())); } diff --git a/toyos-manifest/src/launch.rs b/toyos-manifest/src/launch.rs index 8a8570ff540..870b372e0e7 100644 --- a/toyos-manifest/src/launch.rs +++ b/toyos-manifest/src/launch.rs @@ -3,13 +3,17 @@ //! **A caller starts only the rows its own row's `starts` lists**: program //! keys, and [`APPS`] for any installed package. **A row that replaces what the //! machine runs starts only in a login session** ([`Program::login_only`]). Both -//! are the owner's ruling. A launch by a row marked `login` opens a login -//! session of its own ([`Sessions`]); every other launch is in its caller's -//! session, and a row the supervisor starts at boot is in the machine's. +//! are the owner's ruling. A launch by a row marked `login`, made in the +//! machine's session, opens a login session of its own ([`Sessions`]); every +//! other launch, a `login` row's in a login session included, is in its +//! caller's session, and a row the supervisor starts itself is in the machine's. //! -//! **A session is what a file server shares by** ([`Session::share`]): every -//! process in one spends one share of each server's bounds, so no number of -//! launches made in a session gives it more of a server. +//! **A share is what a file server bounds** ([`Session::share`]): each service +//! the supervisor starts itself has one of its own, each login session has +//! one, and every launch that opens no session spends its caller's. So no +//! number of launches a service or a login session makes, through whatever +//! its rows' `starts` reach, gives it more of a server: only the machine's +//! session opens one. //! //! **The caller is the badge on its connection, not its word** ([`Authority`]): //! the supervisor mints it on the launcher it endows a row, the kernel stamps it @@ -29,31 +33,32 @@ use crate::{package, Program, MAX_PROGRAM_NAME}; /// `/`, so it names no key. pub const APPS: &str = package::DIR; -/// The session a launched program runs in. +/// The session a launched program runs in, and the share it spends. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Session { - /// A row the supervisor started at boot, and every launch made in it. - Machine, - /// One a `login` row's launch opened, and every launch made in it. - Login(Login), + /// A service the supervisor started itself, and every launch made from it + /// that opens no session. + Machine(Share), + /// One a `login` row's launch from the machine's session opened, and every + /// launch made in it. + Login(Share), } -/// Which login session: a number only [`Sessions::open`] makes and a badge it -/// was encoded into gives back, so it is no other session's and no other share's. +/// Which share of each file server's bounds: a number only [`Sessions`] makes +/// and a badge it was encoded into gives back, so it is no other service's or +/// session's, nor the supervisor's. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Login(u64); +pub struct Share(u64); /// The share the supervisor's own files spend, which is no session's. pub const SUPERVISOR_SHARE: u64 = 0; -const MACHINE_SHARE: u64 = 1; -const FIRST_LOGIN: u64 = 2; +const FIRST_SHARE: u64 = 1; impl Session { /// The number a file grant names this session's share of a server by. pub fn share(self) -> u64 { match self { - Self::Machine => MACHINE_SHARE, - Self::Login(Login(n)) => n, + Self::Machine(Share(n)) | Self::Login(Share(n)) => n, } } } @@ -61,29 +66,39 @@ impl Session { impl fmt::Display for Session { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Machine => f.write_str("the machine's session"), + Self::Machine(_) => f.write_str("the machine's session"), Self::Login(_) => f.write_str("a login session"), } } } -/// The login sessions the supervisor has opened. +/// The shares the supervisor has handed out. pub struct Sessions { next: u64, } impl Default for Sessions { fn default() -> Self { - Self { next: FIRST_LOGIN } + Self { next: FIRST_SHARE } } } impl Sessions { + /// A service the supervisor starts itself: in the machine's session, under + /// a share of its own that every start of it spends. + pub fn machine(&mut self) -> Session { + Session::Machine(self.share()) + } + /// A login session none before it was. pub fn open(&mut self) -> Session { + Session::Login(self.share()) + } + + fn share(&mut self) -> Share { let n = self.next; - self.next = n.checked_add(1).expect("2^64 login sessions opened in one boot"); - Session::Login(Login(n)) + self.next = n.checked_add(1).expect("2^64 shares handed out in one boot"); + Share(n) } } @@ -98,19 +113,17 @@ pub struct Authority { const MACHINE: u8 = 0; const LOGIN: u8 = 1; -const _: () = assert!(1 + 8 + MAX_PROGRAM_NAME <= MAX_BADGE, "an authority, a kind byte, a login's number and a row, must fit one badge"); +const _: () = assert!(1 + 8 + MAX_PROGRAM_NAME <= MAX_BADGE, "an authority, a kind byte, a share and a row, must fit one badge"); impl Authority { - /// The session's kind, a login session's number, then the row's key. + /// The session's kind, its share, then the row's key. pub fn encode(&self) -> Vec { let mut out = Vec::with_capacity(1 + 8 + self.row.len()); - match self.session { - Session::Machine => out.push(MACHINE), - Session::Login(Login(n)) => { - out.push(LOGIN); - out.extend_from_slice(&n.to_le_bytes()); - } - } + out.push(match self.session { + Session::Machine(_) => MACHINE, + Session::Login(_) => LOGIN, + }); + out.extend_from_slice(&self.session.share().to_le_bytes()); out.extend_from_slice(self.row.as_bytes()); out } @@ -118,16 +131,14 @@ impl Authority { /// `None` for bytes [`Self::encode`] cannot have written. pub fn decode(bytes: &[u8]) -> Option { let (&kind, rest) = bytes.split_first()?; - let (session, row) = match kind { - MACHINE => (Session::Machine, rest), - LOGIN if rest.len() >= 8 => { - let (n, row) = rest.split_at(8); - let n = u64::from_le_bytes(n.try_into().expect("eight bytes")); - if n < FIRST_LOGIN { - return None; - } - (Session::Login(Login(n)), row) - } + let (share, row) = rest.split_first_chunk::<8>()?; + let share = match u64::from_le_bytes(*share) { + n if n < FIRST_SHARE => return None, + n => Share(n), + }; + let session = match kind { + MACHINE => Session::Machine(share), + LOGIN => Session::Login(share), _ => return None, }; if row.is_empty() || row.len() > MAX_PROGRAM_NAME { @@ -190,11 +201,13 @@ pub fn may_start(caller: &Program, session: Session, target: Target<'_>) -> Resu if !caller.starts.iter().any(|key| key == listed) { return Err(Refusal::NotListed); } - let starts = match caller.login { - true => Starts::Opening, - false => Starts::In(session), + // Only from the machine's session: a login session reaching a `login` row + // through `starts` would otherwise hold a share per launch of it. + let starts = match (caller.login, session) { + (true, Session::Machine(_)) => Starts::Opening, + _ => Starts::In(session), }; - if program.login_only() && starts == Starts::In(Session::Machine) { + if program.login_only() && matches!(starts, Starts::In(Session::Machine(_))) { return Err(Refusal::OutsideLogin); } Ok(starts) @@ -207,6 +220,8 @@ pub fn refused(caller: &str, session: Session, target: &str, why: Refusal) -> St #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use super::*; fn row(name: &str) -> Program { @@ -234,7 +249,8 @@ mod tests { fn a_caller_starts_what_its_row_lists_and_swap_and_update_only_in_a_login_session() { let (ordinary, unlisted, own, package) = (row("ordinary"), row("unlisted"), caller(false), row("gbae")); let (swap, update) = (swap(), update()); - let (machine, login) = (Session::Machine, Sessions::default().open()); + let mut sessions = Sessions::default(); + let (machine, login) = (sessions.machine(), sessions.open()); use Refusal::*; use Starts::*; #[rustfmt::skip] @@ -252,52 +268,58 @@ mod tests { (false, machine, Target::Row(&update), Err(OutsideLogin)), (false, login, Target::Row(&update), Ok(In(login))), (true, machine, Target::Row(&ordinary), Ok(Opening)), - (true, login, Target::Row(&ordinary), Ok(Opening)), + (true, login, Target::Row(&ordinary), Ok(In(login))), (true, machine, Target::Row(&unlisted), Err(NotListed)), (true, login, Target::Row(&unlisted), Err(NotListed)), (true, machine, Target::Row(&own), Ok(Opening)), - (true, login, Target::Row(&own), Ok(Opening)), + (true, login, Target::Row(&own), Ok(In(login))), (true, machine, Target::Package(&package), Ok(Opening)), - (true, login, Target::Package(&package), Ok(Opening)), + (true, login, Target::Package(&package), Ok(In(login))), (true, machine, Target::Row(&swap), Ok(Opening)), - (true, login, Target::Row(&swap), Ok(Opening)), + (true, login, Target::Row(&swap), Ok(In(login))), (true, machine, Target::Row(&update), Ok(Opening)), - (true, login, Target::Row(&update), Ok(Opening)), + (true, login, Target::Row(&update), Ok(In(login))), ]; for (i, (opens, session, target, want)) in table.into_iter().enumerate() { assert_eq!(may_start(&caller(opens), session, target), want, "row {i}"); } } - /// The shipping desktop's chain: the compositor, a `login` row, launches - /// terminals; a terminal launches a shell, and a shell launches shells. - /// Every launch down one chain spends the share its terminal's launch - /// opened, and no two of the compositor's launches share one, nor either - /// with the machine or the supervisor. + fn launch(sessions: &mut Sessions, caller: &Program, session: Session, target: &Program) -> Session { + may_start(caller, session, Target::Row(target)).expect("listed").session(sessions) + } + + /// The shipping rows' chains. Each start the supervisor makes itself has a + /// share of its own, which a launch by a row that is not `login` spends. + /// The compositor, a `login` row in the machine's session, opens a session + /// on each launch; every launch in that session spends its share, down a + /// chain of shells and through sshserver, a `login` row it reaches, and + /// the shell sshserver launches. No two of those shares are one, nor any + /// the supervisor's. #[test] fn a_sessions_launches_spend_its_one_share() { + let runner = Program { starts: vec!["toybox".into()], ..row("test-runner") }; let compositor = Program { starts: vec!["terminal".into()], login: true, ..row("compositor") }; let terminal = Program { starts: vec!["shell".into()], ..row("terminal") }; - let shell = Program { starts: vec!["shell".into()], ..row("shell") }; + let shell = Program { starts: vec!["shell".into(), "sshserver".into()], ..row("shell") }; + let sshserver = Program { starts: vec!["shell".into()], login: true, ..row("sshserver") }; let mut sessions = Sessions::default(); - let mut opened = Vec::new(); + let (desktop, tests) = (sessions.machine(), sessions.machine()); + assert_eq!(launch(&mut sessions, &runner, tests, &row("toybox")), tests, "a boot row's launch left its share"); + let mut shares = vec![SUPERVISOR_SHARE, desktop.share(), tests.share()]; for _ in 0..2 { - let mut launch = |caller: &Program, session, target| { - may_start(caller, session, Target::Row(target)).expect("listed").session(&mut sessions) - }; - let first = launch(&compositor, Session::Machine, &terminal); - let mut session = launch(&terminal, first, &shell); + let opened = launch(&mut sessions, &compositor, desktop, &terminal); + let mut session = launch(&mut sessions, &terminal, opened, &shell); for _ in 0..8 { - session = launch(&shell, session, &shell); + session = launch(&mut sessions, &shell, session, &shell); } - assert_eq!(session.share(), first.share(), "a shell's launches left its session"); - opened.push(first.share()); - } - assert_ne!(opened[0], opened[1], "two launches of a login row share one session"); - for share in opened { - assert!(share != Session::Machine.share() && share != SUPERVISOR_SHARE, "{share}"); + session = launch(&mut sessions, &shell, session, &sshserver); + session = launch(&mut sessions, &sshserver, session, &shell); + assert_eq!(session, opened, "a login session's launches left it"); + shares.push(opened.share()); } - assert_ne!(Session::Machine.share(), SUPERVISOR_SHARE); + let distinct: BTreeSet = shares.iter().copied().collect(); + assert_eq!(distinct.len(), shares.len(), "two shares are one: {shares:?}"); } /// A row that lists nothing starts nothing, a package included, and `/apps` @@ -306,23 +328,23 @@ mod tests { fn nothing_listed_is_nothing_started_and_apps_names_no_row() { let none = row("none"); let (ordinary, package) = (row("ordinary"), row("gbae")); + let machine = Sessions::default().machine(); for target in [Target::Row(&ordinary), Target::Package(&package)] { - assert_eq!(may_start(&none, Session::Machine, target), Err(Refusal::NotListed)); + assert_eq!(may_start(&none, machine, target), Err(Refusal::NotListed)); } let apps_only = Program { starts: vec![APPS.into()], ..row("apps") }; - assert_eq!(may_start(&apps_only, Session::Machine, Target::Row(&ordinary)), Err(Refusal::NotListed)); - assert_eq!( - may_start(&apps_only, Session::Machine, Target::Package(&package)), - Ok(Starts::In(Session::Machine)) - ); + assert_eq!(may_start(&apps_only, machine, Target::Row(&ordinary)), Err(Refusal::NotListed)); + assert_eq!(may_start(&apps_only, machine, Target::Package(&package)), Ok(Starts::In(machine))); } #[test] fn an_authority_reads_back_as_written() { + let mut sessions = Sessions::default(); for authority in [ - Authority { row: "terminal".into(), session: Session::Machine }, - Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login(Login(u64::MAX)) }, - Authority { row: "s".into(), session: Sessions::default().open() }, + Authority { row: "terminal".into(), session: sessions.machine() }, + Authority { row: "x".repeat(MAX_PROGRAM_NAME), session: Session::Login(Share(u64::MAX)) }, + Authority { row: "s".into(), session: sessions.open() }, + Authority { row: "m".into(), session: Session::Machine(Share(u64::MAX)) }, ] { let bytes = authority.encode(); assert!(bytes.len() <= MAX_BADGE); @@ -339,22 +361,19 @@ mod tests { for end in 0..bytes.len() { assert_ne!(Authority::decode(&bytes[..end]).as_ref(), Some(&whole), "a prefix of {end} bytes"); } + let with = |kind: u8, share: u64, row: &[u8]| [&[kind][..], &share.to_le_bytes(), row].concat(); assert_eq!(Authority::decode(&[]), None); - assert_eq!(Authority::decode(&[2, b's']), None); - assert_eq!(Authority::decode(&[MACHINE]), None); - assert_eq!(Authority::decode(&[LOGIN]), None); - assert_eq!(Authority::decode(&[MACHINE, 0xff]), None); - // A login session numbered as no session is. - for n in [SUPERVISOR_SHARE, MACHINE_SHARE] { - let mut bytes = vec![LOGIN]; - bytes.extend_from_slice(&n.to_le_bytes()); - bytes.push(b's'); - assert_eq!(Authority::decode(&bytes), None, "{n}"); + assert_eq!(Authority::decode(&with(2, FIRST_SHARE, b"s")), None); + for kind in [MACHINE, LOGIN] { + assert_eq!(Authority::decode(&[kind]), None); + // Its share cut short. + assert_eq!(Authority::decode(&[kind, 1, 0, 0, 0, 0, 0, 0]), None); + // No row. + assert_eq!(Authority::decode(&with(kind, FIRST_SHARE, b"")), None); + assert_eq!(Authority::decode(&with(kind, FIRST_SHARE, &[0xff])), None); + // The supervisor's own share, which no session is. + assert_eq!(Authority::decode(&with(kind, SUPERVISOR_SHARE, b"s")), None); + assert_eq!(Authority::decode(&with(kind, FIRST_SHARE, &[b'x'; MAX_PROGRAM_NAME + 1])), None); } - // A login session's number cut short. - assert_eq!(Authority::decode(&[LOGIN, 2, 0, 0, 0, 0, 0, 0]), None); - let mut long = vec![MACHINE]; - long.extend(std::iter::repeat_n(b'x', MAX_PROGRAM_NAME + 1)); - assert_eq!(Authority::decode(&long), None); } } diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index c9845e57a76..00ef5335115 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -28,7 +28,7 @@ //! start the supervisor starts this program at boot //! app-receive a connector every program launched from /apps holds //! starts a row this program may start through the launcher, or `/apps` -//! login a launch this program makes opens a login session +//! login a launch it makes from the machine's session opens a login session //! ``` //! //! [`package`] is the other half: what an installed package says about itself, @@ -209,7 +209,8 @@ pub struct Program { /// and [`launch::APPS`] for any installed package. Non-empty is what /// endows it a launcher at all. pub starts: Vec, - /// A launch it makes opens a login session ([`launch`]). + /// A launch it makes from the machine's session opens a login session + /// ([`launch`]). pub login: bool, } diff --git a/toyos/src/fs.rs b/toyos/src/fs.rs index 13566e5d1cd..e20a07165af 100644 --- a/toyos/src/fs.rs +++ b/toyos/src/fs.rs @@ -148,10 +148,10 @@ impl Reply { /// supervisor mints the connector with. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Grant<'a> { - /// The session whose share of the server it spends: every process the - /// supervisor starts in one session, through every launch made in it, and - /// every child those spawn directly is minted the same number. - pub session: u64, + /// The share of the server it spends: the supervisor mints one number for + /// one service it starts itself or one login session, and for every + /// launch made from it that opens no session. + pub share: u64, /// The directory, as a path on the role's volume, every path on the /// connection is resolved beneath: `home`, or the empty path for a volume /// served whole. [`canonical`], and at most [`MAX_GRANT_ROOT`] bytes. @@ -164,18 +164,18 @@ pub struct Grant<'a> { const GRANT_VERSION: u8 = 2; /// The longest root a grant carries: what one badge holds past the version and -/// the session. +/// the share. pub const MAX_GRANT_ROOT: usize = MAX_BADGE - 1 - 8; impl<'a> Grant<'a> { - /// The version, the session, then the root: `None` for a root no grant + /// The version, the share, then the root: `None` for a root no grant /// can carry. pub fn encode<'b>(&self, out: &'b mut [u8; MAX_BADGE]) -> Option<&'b [u8]> { if self.root.len() > MAX_GRANT_ROOT || !canonical(self.root) { return None; } out[0] = GRANT_VERSION; - out[1..9].copy_from_slice(&self.session.to_le_bytes()); + out[1..9].copy_from_slice(&self.share.to_le_bytes()); let end = 9 + self.root.len(); out[9..end].copy_from_slice(self.root.as_bytes()); Some(&out[..end]) @@ -187,9 +187,9 @@ impl<'a> Grant<'a> { if version != GRANT_VERSION || rest.len() < 8 || rest.len() - 8 > MAX_GRANT_ROOT { return None; } - let session = u64::from_le_bytes(rest[..8].try_into().expect("eight bytes")); + let share = u64::from_le_bytes(rest[..8].try_into().expect("eight bytes")); let root = core::str::from_utf8(&rest[8..]).ok()?; - canonical(root).then_some(Self { session, root }) + canonical(root).then_some(Self { share, root }) } } @@ -631,8 +631,8 @@ mod tests { #[test] fn a_grant_round_trips_at_every_bound() { let longest = "r".repeat(MAX_GRANT_ROOT); - for (session, root) in [(0, ""), (1, "home"), (u64::MAX, "home/toy/Documents"), (7, longest.as_str())] { - let grant = Grant { session, root }; + for (share, root) in [(0, ""), (1, "home"), (u64::MAX, "home/toy/Documents"), (7, longest.as_str())] { + let grant = Grant { share, root }; let mut out = [0u8; MAX_BADGE]; let bytes = grant.encode(&mut out).expect("a root a grant carries"); assert_eq!(Grant::decode(bytes), Some(grant), "{root:?}"); @@ -644,15 +644,15 @@ mod tests { let mut out = [0u8; MAX_BADGE]; let past = "r".repeat(MAX_GRANT_ROOT + 1); for root in ["/home", "home/", "a//b", ".", "a/../b", past.as_str()] { - assert_eq!(Grant { session: 1, root }.encode(&mut out), None, "{root:?}"); + assert_eq!(Grant { share: 1, root }.encode(&mut out), None, "{root:?}"); } } #[test] fn bytes_no_grant_was_encoded_as_are_refused() { let mut out = [0u8; MAX_BADGE]; - let good = Grant { session: 3, root: "home" }.encode(&mut out).unwrap().to_vec(); - // Shorter than a version and a session. + let good = Grant { share: 3, root: "home" }.encode(&mut out).unwrap().to_vec(); + // Shorter than a version and a share. for n in 0..9 { assert_eq!(Grant::decode(&good[..n]), None, "{n} bytes"); } diff --git a/userland/fileserver/src/main.rs b/userland/fileserver/src/main.rs index b861ba25866..bf9edcde800 100644 --- a/userland/fileserver/src/main.rs +++ b/userland/fileserver/src/main.rs @@ -16,11 +16,14 @@ //! the grant's root (`fileserver::resolve`); a write on a read-only volume is //! refused before the volume sees it. //! -//! **One session cannot take the server.** Beneath each machine-wide bound — +//! **One share cannot take the server.** Beneath each machine-wide bound — //! connections waiting on their hello, connections served, streams — each -//! session a grant names has a share of its own, so one holding all it may, -//! through every program launched in it, leaves the rest of each bound to the -//! others. +//! share a grant names has a part of its own, so one holding all it may leaves +//! the rest of each bound to the others. A share is one service the +//! supervisor started itself or one login session, with every child spawned +//! in it and every launch made from it that opens no session +//! (`toyos_manifest::launch`). Four at their parts take the server +//! (`issues/four-shares-take-a-file-server.md`). //! //! **A server never blocks on a client.** Accept and the first frame are two //! events; a request is buffered until whole; every reply is one `try_send`, @@ -69,11 +72,11 @@ const MAX_FIDS: usize = 1024; /// Streams served at once, machine-wide. const MAX_STREAMS: usize = 64; -/// One session's shares of [`MAX_SERVED`], [`MAX_HANDSHAKES`] and -/// [`MAX_STREAMS`]. Past its share of served clients a hello is answered -/// `ResourceExhausted`, and past its share of streams a `STREAM` is; past its -/// share of handshakes a connection is, as it is taken, and let go. A quarter -/// of each bound, so a session at its share leaves the bound to three more. +/// One share's parts of [`MAX_SERVED`], [`MAX_HANDSHAKES`] and +/// [`MAX_STREAMS`]. Past its part of served clients a hello is answered +/// `ResourceExhausted`, and past its part of streams a `STREAM` is; past its +/// part of handshakes a connection is, as it is taken, and let go. A quarter +/// of each bound, so a share at its parts leaves the bound to three more. const SERVED_SHARE: usize = MAX_SERVED / 4; const HANDSHAKE_SHARE: usize = MAX_HANDSHAKES / 4; const STREAM_SHARE: usize = MAX_STREAMS / 4; @@ -133,8 +136,8 @@ struct Client { rx: ipc::FrameRx<{ core::mem::size_of::() }>, /// Its grant's root, beneath which every path it names is resolved. root: String, - /// Its grant's session, whose shares it spends. - session: u64, + /// Its grant's share, which it spends. + share: u64, window: Option, fids: BTreeMap, next_fid: u64, @@ -146,8 +149,8 @@ struct Stream { pipe: Pipe, node: Node, offset: u64, - /// The session of the client that asked for it, whose share it spends. - session: u64, + /// The share of the client that asked for it, which it spends. + share: u64, } fn main() { @@ -421,7 +424,7 @@ impl Server { /// Take the next connection and read its grant. Only the supervisor mints /// on this port, so a connection without one this server reads is let go - /// by name; one whose session already has its share of handshakes is + /// by name; one whose share already holds its part of the handshakes is /// answered `ResourceExhausted` and let go. fn accept(&mut self) { let conn = match self.acceptor.accept() { @@ -436,13 +439,13 @@ impl Server { }, Err(why) => return println!("fileserver: letting a connection go: it carries no grant ({why:?})"), }; - let mine = self.clients.values().filter(|c| c.session == grant.session && c.window.is_none()).count(); + let mine = self.clients.values().filter(|c| c.share == grant.share && c.window.is_none()).count(); if mine >= HANDSHAKE_SHARE { // Let go whether or not the refusal went: either way it is said. let _ = conn.try_send(REPLY, &Reply::refused(SyscallError::ResourceExhausted)); return println!( - "fileserver: letting a connection go: session {} has {HANDSHAKE_SHARE} waiting on their hello", - grant.session + "fileserver: letting a connection go: share {} has {HANDSHAKE_SHARE} waiting on their hello", + grant.share ); } let id = self.next_client; @@ -451,7 +454,7 @@ impl Server { conn, rx: ipc::FrameRx::new(), root: grant.root.to_string(), - session: grant.session, + share: grant.share, window: None, fids: BTreeMap::new(), next_fid: 1, @@ -463,7 +466,7 @@ impl Server { fn drop_client(&mut self, id: u64, why: &str) { let Some(client) = self.clients.remove(&id) else { return }; if !why.is_empty() { - println!("fileserver: dropping client {id} of {:?}, session {}: {why}", client.root, client.session); + println!("fileserver: dropping client {id} of {:?}, share {}: {why}", client.root, client.share); } for fid in client.fids.values() { // Nobody is left to answer: a refused close is said, and its node @@ -553,9 +556,9 @@ impl Server { fn answer(&mut self, id: u64, op: u32, r: Request) -> Answer { if op == HELLO { - let session = self.clients[&id].session; + let share = self.clients[&id].share; let served = self.clients.values().filter(|c| c.window.is_some()); - let (all, mine) = served.fold((0, 0), |(all, mine), c| (all + 1, mine + usize::from(c.session == session))); + let (all, mine) = served.fold((0, 0), |(all, mine), c| (all + 1, mine + usize::from(c.share == share))); let client = self.clients.get_mut(&id).expect("pumped"); if client.window.is_some() { return Answer::Drop("it lent a second window"); @@ -565,7 +568,7 @@ impl Server { return Answer::Refuse(SyscallError::ResourceExhausted, why); } if mine >= SERVED_SHARE { - let why = format!("it is refused, since its session is served {SERVED_SHARE} clients already"); + let why = format!("it is refused, since its share is served {SERVED_SHARE} clients already"); return Answer::Refuse(SyscallError::ResourceExhausted, why); } let Some([lent]) = client.conn.recv_handles_exact::<1>() else { @@ -721,8 +724,8 @@ impl Server { return Err(SyscallError::InvalidArgument); } let node = f.node; - let session = self.clients[&id].session; - let mine = self.streams.values().filter(|s| s.session == session).count(); + let share = self.clients[&id].share; + let mine = self.streams.values().filter(|s| s.share == share).count(); if self.streams.len() >= MAX_STREAMS || mine >= STREAM_SHARE { return Err(SyscallError::ResourceExhausted); } @@ -730,7 +733,7 @@ impl Server { self.volume.hold(node); let sid = self.next_stream; self.next_stream += 1; - self.streams.insert(sid, Stream { pipe: read, node, offset: r.offset, session }); + self.streams.insert(sid, Stream { pipe: read, node, offset: r.offset, share }); Ok(Answer::WithHandle(Reply::ok(), write.into_raw())) } STAT | LSTAT => { diff --git a/userland/supervisor/src/main.rs b/userland/supervisor/src/main.rs index 87a88a18b7d..951af0bfe27 100644 --- a/userland/supervisor/src/main.rs +++ b/userland/supervisor/src/main.rs @@ -380,7 +380,7 @@ fn main() { // One port per file-server role, made here for the same reason: a // program's first open works whether or not its server runs yet. Its // connector goes at once: every connector a program holds to it is a - // grant minted for that program's session (`Grants`). + // grant minted for that program's share (`Grants`). let mut role_acceptors: BTreeMap<&str, Acceptor> = BTreeMap::new(); for role in system.programs.iter().flat_map(|p| p.roles.iter()) { let (acceptor, _) = port::create().unwrap_or_else(|e| panic!("supervisor: no port for the `{role}` role: {e:?}")); @@ -525,7 +525,7 @@ struct Supervisor<'a> { connectors: BTreeMap<&'a str, Connector>, /// What each program's view is minted from. grants: Grants<'a>, - /// The login sessions launches have opened. + /// The shares its services and the login sessions launches opened spend. sessions: Sessions, /// The same directories as a namespace of the supervisor's own, for the stop's syncs. files: &'static Namespace, @@ -557,6 +557,9 @@ struct Service<'a> { /// started in its place is owed. devices: Vec, kept: Arc>, + /// The machine's session under this service's own share, which every + /// start of it spends: one started again holds no more of a file server. + session: Session, /// When each recent start after an end happened, for [`toyos_manifest::RESTARTS`]. restarts: VecDeque, } @@ -585,6 +588,7 @@ impl<'a> Service<'a> { program: &'a Program, role: Option<&'a str>, acceptors: Vec<(String, Acceptor)>, + session: Session, wake: toyos::RawHandle, ) -> Self { Self { @@ -601,6 +605,7 @@ impl<'a> Service<'a> { ended: false, wake, })), + session, restarts: VecDeque::new(), } } @@ -649,7 +654,7 @@ impl<'a> Service<'a> { grants, &[], storage, - (launcher, Session::Machine), + (launcher, self.session), Output::Boot(log), )?; kept.generation += 1; @@ -800,7 +805,7 @@ impl<'a> Supervisor<'a> { } }; self.make_home(program); - let mut service = Service::new(program, role, kept, wake); + let mut service = Service::new(program, role, kept, self.sessions.machine(), wake); if let Some(role) = role { self.grants.roles.push((role, Arc::clone(&service.kept))); } @@ -2256,11 +2261,12 @@ fn build_namespace( /// What each program's directory capabilities are minted from: each /// file-server role's port. /// -/// **Each start is minted grants naming its session** (`toyos::fs::Grant`, -/// [`Session::share`]), one per directory of every role, so the servers count -/// every process of a session, every child each spawns directly and every -/// program launched in it against one share. A role whose ports closed for -/// good is minted nothing. +/// **Each start is minted grants naming its session's share** (`toyos::fs::Grant`, +/// [`Session::share`]), one per directory of every role: a service has a +/// share of its own through every start of it, so the servers count it, every +/// child it spawns directly and every launch made from it that opens no +/// session against one share, and a login session's processes against one +/// more. A role whose ports closed for good is minted nothing. struct Grants<'a> { /// Each role's service, whose kept acceptor is the role's port. roles: Vec<(&'a str, Arc>)>, @@ -2298,7 +2304,7 @@ fn mint_grants(role: &str, acceptor: &Acceptor, share: u64) -> Vec<(String, Conn dirs.iter() .map(|dir| { let mut badge = [0u8; MAX_BADGE]; - let badge = Grant { session: share, root: dir.root } + let badge = Grant { share, root: dir.root } .encode(&mut badge) .unwrap_or_else(|| panic!("supervisor: {}'s root {:?} is no grant's", dir.dir, dir.root)); let connector = acceptor From aa322baa05b0b6d378fb5f60c5a7c2e8670ddd02 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 7 Oct 2026 15:22:46 +0200 Subject: [PATCH 3/3] The shared session of a hand-started sshserver is recorded, with its ruling Review round 2's NOTE: since a `login` row opens a session only from the machine's session, an sshserver detached from a desktop shell runs in that shell's login session, and every remote login spends that one share. No issue said so. issues/four-shares-take-a-file-server.md now records it as a weakness, with the owner's ruling of 2026-10-07 (one session per login with its own share and view; the right to open one is the machine's to hand out, the supervisor starting sshserver as a machine service; sessions counted, ended and capped; nothing inside a login session opens another), its owner, stage 3 of the files track, and an exit a test can fail. Co-Authored-By: Claude Opus 5.5 --- issues/four-shares-take-a-file-server.md | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/issues/four-shares-take-a-file-server.md b/issues/four-shares-take-a-file-server.md index 8c04e873eab..4ef7ec523b4 100644 --- a/issues/four-shares-take-a-file-server.md +++ b/issues/four-shares-take-a-file-server.md @@ -37,3 +37,33 @@ However many shares are held, each at all a server lets it hold, the next share's first connection and first stream on that server are answered, or opening the session that would hold it is refused by name; shown by a test that does exactly that. + +## Every remote login shares the session of the shell that started sshserver + +No shipped image starts sshserver at boot (`src/build.rs`, +`no_shipped_boot_config_starts_sshserver`), so on a shipped machine it is +detached from a desktop shell and runs in that shell's login session. Its +`login` row opens no session there, so every remote login's programs spend +one share with each other and with the terminal that started the server: one +login at its parts has every other's next connection or stream on that server +refused, and that terminal's. Only where a boot config starts sshserver as a +service (`tests/lantalkcase`, `tests/metalcase`) does each launch it makes +open a session of its own. + +**Ruled** (owner, 2026-10-07): + +- The goal is one session per login, each with its own share and its own view + of the files. +- The right to open a session is a capability the machine hands out: the + supervisor starts sshserver as a machine service. +- Sessions are counted, they end, and their number is capped. +- Nothing inside a login session opens another. +- Until then the shared session stands, recorded here as a weakness. + +Owner: stage 3 of `issues/every-program-sees-only-the-files-it-was-given.md`, +which removes it. + +**Exit**: on a shipped machine sshserver runs as a service the supervisor +started, in the machine's session; two logins through it hold two shares, shown by a test in which one at its parts leaves the +other's first connection and first stream answered; a login past the cap is +refused by name; and a login's end ends its session.