From 4cecac6aade603d7399ad3f26b37e8bb60c452e5 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 03:45:49 +0200 Subject: [PATCH 1/5] session: a login is a session under its owner Login sessions were opened by a launch: a `login` row's launch from the machine's session opened one, so the compositor opened a session for each program its taskbar started, nothing ended one, and a detached app was the supervisor's child whatever session it was in. Now each login session has one owner, `/system/bin/session`, which the supervisor starts as its child for it. - The owner accepts each connection on the session's own launcher port and hands it to the supervisor over its link with the connection's badge (`toyos_session::MSG_HANDOVER`). The supervisor serves the launch as it serves its own launcher's, in the link's session, never the badge's (`toyos_supervisor::sessions::handed_over`). - A detached app in a login session is spawned under the place the owner gives the supervisor first on its link, so the owner's end takes it down; only in the machine's session is it the supervisor's. - The supervisor watches every app started in a session, on threads of its own a poller's width each, and tells the owner of each start, with a handle that may quit and kill it, and of each end. - Sign-out (`session::Owner`) asks every app to quit with a hang-up and reports what stays to the session's controls and the supervisor; a cancel keeps the session, a force kills what stays, and the owner exits once nothing is left. Nothing in it reads a clock. - The supervisor opens the desktop's session as it starts the screen's row, and endows it the session's launcher and a control connection, again on a restart while the session lives. Any `login` row may open one on the new `login` port; the compositor does when its session ends, and sshserver for each connection, launching its shell and `shell -c` there detached through `CommandExt::launcher`, and signing it out when the connection goes. - An owner's end other than its sign-out's is the session's failure, and only the next open by the same login authority after one spends the restart budget (`toyos_supervisor::sessions::Reopens`). - `/system/bin/logout`, the one row the build lets receive `session-admin`, lists its user's sessions and signs one out, the supervisor checking whose session it is (`sign_out_target`). - The compositor signs its session out on Ctrl+Alt+Q, shows what the sign-out waits for above the taskbar, and takes Escape as cancel and Enter as force. - `may_start` is a permission check alone; `login` names a login authority. An image with a `login` row declares `session`, which only the supervisor starts. sshserver starts only `shell`. The SDK gains `Connection::into_raw`, an endowable `Connection` and a safe `Namespace::received`; std gains `CommandExt::launcher`, and `toyos::spawn` refuses a helper one. `session_lifecycle` (QEMU x86-64, `tests/images/sessioncase.toml`) holds the desktop's sign-out to its keys and a job's session, opened through test-runner's `login` connector, to the owner's rules; `app_view` opens its grants' session the same way. The owner's unasked end has no guest arm, since nothing kills a process by its number; the kernel's parent rule and the supervisor's host laws cover it. The SSH arms wait for a T14 row. The `lifecycle` port moves to the permission service's stage with its first consumer. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q --- Cargo.lock | 37 ++ Cargo.toml | 5 + build/build.rs | 57 +- images/system.toml | 28 +- ...nt-and-a-parent-takes-its-children-down.md | 63 ++- ...rogram-sees-only-the-files-it-was-given.md | 10 +- issues/four-shares-take-a-file-server.md | 26 +- lib/manifest/src/launch.rs | 183 ++----- lib/manifest/src/lib.rs | 5 +- lib/session/Cargo.toml | 16 + lib/session/src/lib.rs | 325 ++++++++++++ sdk/libc/src/spawn.rs | 1 + sdk/std/os/process.rs | 13 + sdk/std/sys/process.rs | 10 + sdk/toyos/src/endow.rs | 1 + sdk/toyos/src/ipc.rs | 5 + sdk/toyos/src/namespace.rs | 7 + sdk/toyos/src/spawn.rs | 33 +- system/compositor/Cargo.toml | 2 + system/compositor/desktop/src/input.rs | 44 ++ system/compositor/desktop/src/lib.rs | 5 +- system/compositor/desktop/src/signout.rs | 82 +++ system/compositor/src/main.rs | 6 +- system/compositor/src/session.rs | 55 +- system/compositor/src/signout.rs | 117 +++++ system/logout/Cargo.toml | 13 + system/logout/src/main.rs | 76 +++ system/session/Cargo.toml | 17 + system/session/src/lib.rs | 152 ++++++ system/session/src/main.rs | 212 ++++++++ system/sshserver/Cargo.toml | 5 + system/sshserver/src/spawn.rs | 88 +++- system/supervisor/Cargo.toml | 2 + system/supervisor/lifecycle/src/lib.rs | 4 +- system/supervisor/lifecycle/src/sessions.rs | 182 +++++++ system/supervisor/src/main.rs | 214 ++++++-- system/supervisor/src/sessions.rs | 497 ++++++++++++++++++ tests/common/mod.rs | 1 + tests/common/sessionlifecycle.rs | 112 ++++ tests/guest/hangupholder/Cargo.toml | 9 + tests/guest/hangupholder/src/main.rs | 16 + tests/guest/rust-tests/Cargo.lock | 8 + tests/guest/rust-tests/Cargo.toml | 2 + tests/guest/rust-tests/src/bin/app_view.rs | 23 +- .../rust-tests/src/bin/launch_unsent_stdio.rs | 1 + .../rust-tests/src/bin/session_lifecycle.rs | 189 +++++++ tests/guest/test-runner/Cargo.toml | 2 + tests/guest/test-runner/src/main.rs | 20 +- tests/images/proctreecase.toml | 10 +- tests/images/sessioncase.toml | 57 ++ tests/toyos.rs | 7 + 51 files changed, 2774 insertions(+), 281 deletions(-) create mode 100644 lib/session/Cargo.toml create mode 100644 lib/session/src/lib.rs create mode 100644 system/compositor/desktop/src/signout.rs create mode 100644 system/compositor/src/signout.rs create mode 100644 system/logout/Cargo.toml create mode 100644 system/logout/src/main.rs create mode 100644 system/session/Cargo.toml create mode 100644 system/session/src/lib.rs create mode 100644 system/session/src/main.rs create mode 100644 system/supervisor/lifecycle/src/sessions.rs create mode 100644 system/supervisor/src/sessions.rs create mode 100644 tests/common/sessionlifecycle.rs create mode 100644 tests/guest/hangupholder/Cargo.toml create mode 100644 tests/guest/hangupholder/src/main.rs create mode 100644 tests/guest/rust-tests/src/bin/session_lifecycle.rs create mode 100644 tests/images/sessioncase.toml diff --git a/Cargo.lock b/Cargo.lock index c6dae6f85d7..32af096d9bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,6 +451,7 @@ dependencies = [ "toyos-inspect", "toyos-manifest", "toyos-pci-claim", + "toyos-session", "toyos-virtio", "toyos-virtio-gpu", "toyos-wallclock", @@ -1202,6 +1203,13 @@ dependencies = [ "subtle", ] +[[package]] +name = "hangupholder" +version = "0.1.0" +dependencies = [ + "toyos", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1631,6 +1639,14 @@ dependencies = [ "toyos-logstream", ] +[[package]] +name = "logout" +version = "0.1.0" +dependencies = [ + "toyos", + "toyos-session", +] + [[package]] name = "loom" version = "0.7.2" @@ -2896,6 +2912,15 @@ dependencies = [ "serde", ] +[[package]] +name = "session" +version = "0.1.0" +dependencies = [ + "toyos", + "toyos-abi", + "toyos-session", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3110,7 +3135,9 @@ name = "sshserver" version = "0.0.0" dependencies = [ "getrandom 0.3.4", + "toyos", "toyos-crypto", + "toyos-session", "toyos-ssh", "toyos-tmpdir", ] @@ -3136,6 +3163,7 @@ dependencies = [ "toyos-osrelease", "toyos-pci-claim", "toyos-quiesce", + "toyos-session", "toyos-supervisor", "toyos-swap", "toyos-update", @@ -3233,6 +3261,7 @@ version = "0.1.0" dependencies = [ "toyos", "toyos-abi", + "toyos-session", "toyos-tco", ] @@ -3518,6 +3547,7 @@ dependencies = [ "toyos-osrelease", "toyos-parity", "toyos-quiesce", + "toyos-session", "toyos-ssh", "toyos-swap", "toyos-symbols", @@ -3875,6 +3905,13 @@ version = "0.1.0" name = "toyos-scsi" version = "0.1.0" +[[package]] +name = "toyos-session" +version = "0.1.0" +dependencies = [ + "toyos", +] + [[package]] name = "toyos-smmu" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 29b65c3b198..b6bb7035d33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ members = [ "lib/random", "lib/rootimage", "lib/scsi", + "lib/session", "lib/ssh", "lib/swap", "lib/symbols", @@ -99,6 +100,7 @@ members = [ "system/host", "system/inspect", "system/logkeeper", + "system/logout", "system/netstack", "system/netstack/dhcp", "system/netstack/dns", @@ -117,6 +119,7 @@ members = [ "system/ps2server/ps2", "system/hda", "system/hda/driver", + "system/session", "system/shell", "system/soundserver", "system/soundserver/mixer", @@ -138,6 +141,7 @@ members = [ "system/usbd/xhci", "system/vdagent", "system/vdagent/bridge", + "tests/guest/hangupholder", "tests/guest/kernelprobe", "tests/guest/metalprobe", "tests/guest/osbench", @@ -243,6 +247,7 @@ toyos-osrelease = { path = "sdk/osrelease" } # The swap port's name and the one program the image may give it to, so the # gate on every manifest reads what the supervisor reads. toyos-swap = { path = "lib/swap" } +toyos-session = { path = "lib/session" } # QEMU's clipboard channel's port name, id and port count, so the launch reads # the declaration the guest's agent drives. toyos-vdagent = { path = "lib/vdagent" } diff --git a/build/build.rs b/build/build.rs index b2a6427b9cc..60d4775a1a2 100644 --- a/build/build.rs +++ b/build/build.rs @@ -196,7 +196,7 @@ struct ProgramConfig { restart: bool, /// The rows it may start through the launcher (`toyos_manifest::Program::starts`). starts: Vec, - /// A launch it makes from the machine's session opens a login session + /// A login authority, for which the supervisor opens login sessions /// (`toyos_manifest::Program::login`). login: bool, } @@ -642,17 +642,18 @@ impl Userland { /// rather than a constant here and a string in the supervisor. /// /// Not `launcher`: a row holds it by its `starts`, badged with its row, and no -/// row receives it. -const SUPERVISOR_SERVED: &[&str] = &[toyos_swap::PORT, "power", toyos_manifest::grants::PORT]; +/// row receives it. Nor `login`: a `login` row holds it, badged with its key. +const SUPERVISOR_SERVED: &[&str] = &[toyos_swap::PORT, "power", toyos_manifest::grants::PORT, toyos_session::ADMIN_PORT]; /// The ports that named rows alone may receive, each with those rows: the /// swap port, whose holder replaces any service's binary, the grants port, /// whose holder writes which folder of the home a package sees, the prompt /// port, whose holder draws above every window and has every key while it /// does, the input port, whose holders type into the focused window, the -/// clipboard port, whose holder is sent every copy made in the session, and -/// the display port, whose holder sets the mode and the scale every window is -/// drawn at. +/// clipboard port, whose holder is sent every copy made in the session, the +/// display port, whose holder sets the mode and the scale every window is +/// drawn at, and the session-admin port, whose holder signs its user's +/// sessions out. const HELD_ALONE: &[(&str, &[&str])] = &[ (toyos_swap::PORT, &[toyos_swap::HOLDER]), (toyos_manifest::grants::PORT, &[toyos_manifest::grants::HOLDER]), @@ -660,6 +661,7 @@ const HELD_ALONE: &[(&str, &[&str])] = &[ (toyos_input::wire::PORT, toyos_input::wire::HOLDERS), (toyos_manifest::clipboard::PORT, &[toyos_manifest::clipboard::HOLDER]), (toyos_manifest::display::PORT, &[toyos_manifest::display::HOLDER]), + (toyos_session::ADMIN_PORT, &[toyos_session::ADMIN_HOLDER]), ]; /// Rows, as a refusal names them. @@ -771,13 +773,37 @@ fn receives_reach_a_running_server(config: &SystemConfig) -> Result<(), String> } } +/// An image with a `login` row declares the session owner's row, which the +/// supervisor starts for each session it opens, and starts it nowhere else: +/// not at boot, and not through a launch. +fn logins_have_their_owner(config: &SystemConfig) -> Result<(), String> { + let owner = toyos_session::OWNER; + if let Some((name, _)) = config.programs.iter().find(|(_, row)| row.login) { + if !config.programs.contains_key(owner) { + return Err(format!("`{name}` is a `login` row, and no `{owner}` row owns its sessions")); + } + } + if config.boot.start.iter().any(|key| key == owner) { + return Err(format!("`{owner}` is in `[boot] start`, and the supervisor starts it for a session alone")); + } + match config.programs.iter().find(|(_, row)| row.starts.iter().any(|key| key == owner)) { + Some((name, _)) => Err(format!("`{name}` starts `{owner}`, which the supervisor starts for a session alone")), + None => Ok(()), + } +} + /// The resolved config as the records the supervisor reads. /// /// The format, the renderer and the parser are `lib/manifest/`, whose /// round-trip test is what makes "what the build writes is what the supervisor reads" a /// fact rather than two hand-matched implementations. fn render_manifest(config: &SystemConfig) -> Vec { - for gate in [held_by_their_holders_alone, starts_name_what_a_launch_can_start, receives_reach_a_running_server] { + for gate in [ + held_by_their_holders_alone, + starts_name_what_a_launch_can_start, + receives_reach_a_running_server, + logins_have_their_owner, + ] { if let Err(why) = gate(config) { panic!("the image config cannot be rendered as a manifest: {why}"); } @@ -3158,6 +3184,7 @@ mod tests { "tests/images/latencycase.toml", "tests/images/launchcase.toml", "tests/images/libccase.toml", + "tests/images/sessioncase.toml", "tests/images/metalcase.toml", "tests/images/metaldevicecase.toml", "tests/images/netcase.toml", @@ -3213,6 +3240,22 @@ mod tests { } } + /// Every committed config with a `login` row declares the session owner, + /// and none starts it but the supervisor, for a session. + #[test] + fn a_login_row_has_a_session_owner_only_the_supervisor_starts() { + for cfg in ALL_CONFIGS { + logins_have_their_owner(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}")); + } + let gate = |text: &str| logins_have_their_owner(&toml::from_str(text).unwrap()); + assert!(gate("[programs.compositor]\nlogin = true\n[programs.session]\n").is_ok()); + assert!(gate("[programs.compositor]\nlogin = true\n").unwrap_err().contains("no `session` row")); + assert!(gate("[boot]\nstart = [\"session\"]\n[programs.session]\n").is_err()); + assert!(gate("[programs.shell]\nstarts = [\"session\"]\n[programs.session]\n").is_err()); + let admin: SystemConfig = toml::from_str("[programs.toybox]\nreceives = [\"session-admin\"]\n").unwrap(); + assert!(held_by_their_holders_alone(&admin).unwrap_err().contains("only `logout`")); + } + /// The swap port reaches `swap` and nothing else, and the idle slot /// `update` and nothing else, in every committed config and in a config /// that tries any other door. diff --git a/images/system.toml b/images/system.toml index 2bf75fa95c7..16d1057e11f 100644 --- a/images/system.toml +++ b/images/system.toml @@ -71,10 +71,11 @@ devices = ["acpi"] receives = ["power"] # `starts` is what the desktop's taskbar and launcher start. `login = true` -# makes the local desktop a login session, so `swap` and `update` run from its -# shell. `prompt` is the layer above every window, which `filepicker` alone -# reaches: serving it makes the sessions its launches open the screen's, whose -# packages may ask the person at it for a folder. `clipboard` is sent every copy +# makes it the desktop's login authority: the supervisor opens the desktop +# session as it starts it, and its launches are that session's, so `swap` and +# `update` run from its shell. `prompt` is the layer above every window, which +# `filepicker` alone reaches: serving it makes the session opened for it the +# screen's, whose packages may ask the person at it for a folder. `clipboard` is sent every copy # made in the session, and `vdagent` alone receives it. `input` is where a source # of keys and pointer reports reaches the owner of input, which it is. # `display` sets the mode and the scale, and `toybox` alone receives it, for @@ -135,15 +136,24 @@ devices = ["pci:02:00:00/1af4:1041"] drives = ["pci:07:80:00/1af4:1043"] receives = ["clipboard"] -# 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. +# A login authority: each authenticated connection opens a login session, and +# its shell, and a command as `shell -c`, is launched in it, the one `update` +# and `swap` below start in. [programs.sshserver] service = true receives = ["netstack"] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "sshserver", "swap", "toyfetch", "update", "grants", "/apps"] +starts = ["shell"] login = true +# A login session's owner: the supervisor starts one for each session it +# opens, and it parents that session's detached apps and signs it out. +[programs.session] + +# Its user's login sessions, listed or signed out: the one row the build lets +# receive `session-admin`. `launch logout 3 --force` ends session 3. +[programs.logout] +receives = ["session-admin"] + # `swap` is the authority to replace a running service's binary; the build # refuses it to any other program (`build/build.rs`). `ssh swap netstack # < netstack` runs it for a login. @@ -201,7 +211,7 @@ starts = ["shell"] [programs.shell] receives = ["surface"] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "sshserver", "swap", "toyfetch", "update", "grants", "/apps"] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "sshserver", "swap", "toyfetch", "update", "grants", "logout", "/apps"] [programs.doom] receives = ["compositor", "soundserver"] diff --git a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index bad14fa26c4..3f4dbf94181 100644 --- a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md +++ b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md @@ -37,6 +37,11 @@ kill reads it. What names a child's image and working directory is cleanup without cooperation, run on the causing path in bounded steps. init never dies and stays tiny. + Since stage 5, init is the supervisor and, for each login session, its + owner: "keep running after I close this" in a login session places the app + under that session's owner, and only in the machine's session under the + supervisor. + **Ruled** (owner, 2026-10-21): libc's signals are the focused set this track plans, over native process events: a child's end (stage 4) and an interrupt, hang-up or terminate request (stage 6), and no POSIX signal subsystem up @@ -172,23 +177,51 @@ an explicit force issues it, and never a timer's. control pipe, so it watches at most 254 children: the 255th is refused `EAGAIN`, a `poll` takes 255 descriptors beside its wake pipe, and `SIG_IGN` is shown over 255 children. -5. **A login is a session under its owner**. Per login the supervisor starts - one session owner, which provides the session's launcher and parents what - its user starts: the desktop's at boot, one per authenticated SSH - connection. `launch ` asks it for an independent app, which outlives - the shell that asked; a bare command is the shell's helper, and the shell's - `detach`, which starts a program under the supervisor today, goes. Sign-out - asks the session's tree to quit, shows what stays with cancel and force, and - ends the subtree only on force. Its namespace is +5. **A login is a session under its owner**. **In review** (the P1b pull + request). Per login the supervisor starts one session owner, + `/system/bin/session`, which accepts on the session's launcher and hands each + connection to the supervisor with its badge; the supervisor serves the + launch in the session of the owner's link, never the request's. It parents + the session's detached apps, which the supervisor spawns under the place + the owner gives it, and the supervisor tells it of each app's start and + end. The desktop's session opens with the screen's row, endowed to it, and + again when it ends; any other `login` row opens one on the supervisor's + `login` port, sshserver one per connection. `launch ` asks the shell's + session for an independent app; a bare command is the shell's helper. + Sign-out asks every app to quit with a hang-up, reports what stays to the + session's controls, keeps the session on a cancel and ends what stays only + on a force; `logout`, the one row holding `session-admin`, lists its user's + sessions and signs one out. An owner's end other than its sign-out's is a + failure, and only the next open after one is budgeted. Its namespace is `issues/a-user-is-a-home-tree-and-a-login-row.md`'s login row; the SSH - session's shell holds no host key and no SSH service authority. *Exit*: - ending the desktop's session owner ends every program of that session and - leaves the compositor and every other session running; closing a shell - leaves an app it launched running under the session owner; a dropped SSH - connection hangs up its session's tree, says what stays and ends it only on - a force; the SSH session's shell can name no host key. Negative control: the - stage reverted whole, where a launched app dies with its shell. Oracle: + session's shell holds no host key and no SSH service authority. + *Exit*: ending the desktop's session owner ends every program of that + session and leaves the compositor and every other session running; closing + a shell leaves an app it launched running under the session owner; a + dropped SSH connection hangs up its session's tree, says what stays and ends + it only on a force; the SSH session's shell can name no host key. Oracle: systemd-logind, whose session scope ends every process of a login. + - Met in `session_lifecycle`'s QEMU x86-64 boot (`tests/images/sessioncase.toml`): + a detached app outlives its shell; the desktop's sign-out, by its keys, + waits for an app that ignores a hang-up, Escape keeps it and Enter kills + it, and the compositor opens the desktop again; a job's session, opened + on `login`, signs out the same way, `logout` lists it active and ending, + and ten sessions open and sign out in a row. + - The owner's unasked end ending its apps has no guest arm, since nothing + may kill a process by its number: the kernel's parent rule is + `process_tree`'s, and the supervisor's half, placing detached apps under + the owner and reading its unasked end as the session's failure, is + host-tested (`a_detached_app_is_placed_under_its_sessions_owner`, + `an_owner_that_ends_unasked_ends_its_session_as_a_failure`). + - The SSH arms wait for a T14 row of sshserver, which this stage owes: + until then a connection's session and its sign-out are only built. + - Negative control: the stage reverted builds neither the job nor its + image, so the controls are mutations, each red by name: a graceful + sign-out that kills (`session_lifecycle` stalls at Escape's cancel, and + the owner's host laws red); a handed-over launch in its badge's session; + ordinary opens spending the budget; `session-admin` ignoring whose + session it is; a detached app placed under the supervisor; any row + receiving `session-admin`. 6. **A program is asked to quit**. - 6.1 **Done** (#995, the kernel's half): `SYS_PROCESS_QUIT` (135: the 124 this stage named went to `SYS_COUNTERS`), `(process, reason)`, needs `MANAGE` diff --git a/issues/every-program-sees-only-the-files-it-was-given.md b/issues/every-program-sees-only-the-files-it-was-given.md index b858eed4e47..2951b52c1de 100644 --- a/issues/every-program-sees-only-the-files-it-was-given.md +++ b/issues/every-program-sees-only-the-files-it-was-given.md @@ -129,11 +129,11 @@ nameable by every program and make confused-deputy bugs structural. ## Known weaknesses -- **A login session never ends.** A program launched in one keeps the session - in its launcher's badge after the login that opened it is gone, so a - detached program can start `swap` after logout. The exit is a session's end - ending its launches, which the per-session `/tmp`'s wait on the session's - first process provides. +- **A login session's `/tmp` is the machine's.** A session ends with its + owner once every app in it has ended (stage 5 of + `issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md`), + and its launcher's port with it, but what it wrote under `/tmp` stays for + every other session to read. The exit is the per-session `/tmp`. ## Ordering diff --git a/issues/four-shares-take-a-file-server.md b/issues/four-shares-take-a-file-server.md index 04ad8ba0e80..088a8d0b895 100644 --- a/issues/four-shares-take-a-file-server.md +++ b/issues/four-shares-take-a-file-server.md @@ -13,14 +13,12 @@ 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"). +(`toyos_manifest::launch`), and neither gets a second by launching: the +supervisor opens a session only for a `login` row, the desktop's as it starts +the screen's row and any other on its `login` port, at most 16 at once, each +ending with its owner. What is left is how many shares there are. The shipping +boot starts more than four services that hold DATA's directories, so four of +them, each at its parts, refuse the rest. 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 @@ -53,13 +51,11 @@ that does exactly that. No shipped image starts sshserver at boot (`build/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/images/metalcase.toml`) does each launch it makes -open a session of its own. +detached from a desktop shell and runs in that shell's login session. Each +connection it serves opens a login session of its own on the supervisor's +`login` port, so two logins hold two shares; but that is a session opened from +inside another, which the ruling below forbids, since a `login` row is endowed +the port however it was started. **Ruled** (owner, 2026-10-07): diff --git a/lib/manifest/src/launch.rs b/lib/manifest/src/launch.rs index 2c573c70e1e..95a2d8b7649 100644 --- a/lib/manifest/src/launch.rs +++ b/lib/manifest/src/launch.rs @@ -3,24 +3,20 @@ //! **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`, 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. +//! are the owner's ruling. A launch is in its caller's session: a row the +//! supervisor starts itself is in the machine's, and a login session is opened +//! for a `login` row ([`Sessions::open`]), never by a launch. //! //! **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. +//! one, and every launch 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. //! //! **A login session holds the screen only where the screen's row opened it** //! ([`Screen`]): the row serving [`consent::PROMPT`], the one whose launches a -//! person at the screen made. Every launch in that session holds it, but for -//! one made by another `login` row, whose launches are never that person's: -//! sshserver's shells, even one a desktop shell started, never hold it. Only a -//! launch in a session holding it may ask the person at the screen anything. +//! person at the screen made. Only a launch in a session holding it may ask +//! the person at the screen anything. //! //! **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 @@ -46,8 +42,8 @@ pub enum Session { /// 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. + /// One the supervisor opened for a `login` row, and every launch made in + /// it. Login(Share, Screen), } @@ -60,8 +56,8 @@ pub enum Screen { } impl Screen { - /// What a session `opener`'s launch opens holds: the screen where `opener` - /// serves the prompt layer. + /// What a session `opener` opens holds: the screen where `opener` serves + /// the prompt layer. pub fn of(opener: &Program) -> Self { match opener.serves.iter().any(|name| name == consent::PROMPT) { true => Self::Held, @@ -206,28 +202,8 @@ impl fmt::Display for Refusal { } } -/// 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, holding the screen or not. - Opening(Screen), -} - -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(screen) => sessions.open(screen), - } - } -} - -/// 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 { +/// Whether `caller`, running in `session`, may start `target`. +pub fn may_start(caller: &Program, session: Session, target: Target<'_>) -> Result<(), Refusal> { let (listed, program) = match target { Target::Row(row) => (row.name.as_str(), row), Target::Package(row) => (APPS, row), @@ -235,21 +211,10 @@ pub fn may_start(caller: &Program, session: Session, target: Target<'_>) -> Resu if !caller.starts.iter().any(|key| key == listed) { return Err(Refusal::NotListed); } - // Only from the machine's session: a login session reaching a `login` row - // through `starts` would otherwise hold a share per launch of it. A - // `login` row's launch in one keeps its share and holds the screen only - // where the row is the screen's. - let starts = match (caller.login, session) { - (true, Session::Machine(_)) => Starts::Opening(Screen::of(caller)), - (true, Session::Login(share, Screen::Held)) if Screen::of(caller) == Screen::Not => { - Starts::In(Session::Login(share, Screen::Not)) - } - _ => Starts::In(session), - }; - if program.login_only() && matches!(starts, Starts::In(Session::Machine(_))) { + if program.login_only() && matches!(session, Session::Machine(_)) { return Err(Refusal::OutsideLogin); } - Ok(starts) + Ok(()) } #[cfg(test)] @@ -270,106 +235,54 @@ mod tests { Program { slots: true, ..row("update") } } - fn caller(login: bool) -> Program { - Program { - starts: ["ordinary", "swap", "update", "caller", APPS].map(String::from).to_vec(), - login, - ..row("caller") - } + fn caller() -> Program { + Program { starts: ["ordinary", "swap", "update", "caller", APPS].map(String::from).to_vec(), ..row("caller") } } - /// Every target kind against every session and both kinds of caller. + /// Every target kind against both kinds of session. #[test] 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 (ordinary, unlisted, own, package) = (row("ordinary"), row("unlisted"), caller(), row("gbae")); let (swap, update) = (swap(), update()); let mut sessions = Sessions::default(); let (machine, login) = (sessions.machine(), sessions.open(Screen::Not)); use Refusal::*; - use Starts::*; #[rustfmt::skip] - 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(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(In(login))), - (false, machine, Target::Row(&update), Err(OutsideLogin)), - (false, login, Target::Row(&update), Ok(In(login))), - (true, machine, Target::Row(&ordinary), Ok(Opening(Screen::Not))), - (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(Screen::Not))), - (true, login, Target::Row(&own), Ok(In(login))), - (true, machine, Target::Package(&package), Ok(Opening(Screen::Not))), - (true, login, Target::Package(&package), Ok(In(login))), - (true, machine, Target::Row(&swap), Ok(Opening(Screen::Not))), - (true, login, Target::Row(&swap), Ok(In(login))), - (true, machine, Target::Row(&update), Ok(Opening(Screen::Not))), - (true, login, Target::Row(&update), Ok(In(login))), + let table: [(Session, Target, Result<(), Refusal>); 12] = [ + (machine, Target::Row(&ordinary), Ok(())), + (login, Target::Row(&ordinary), Ok(())), + (machine, Target::Row(&unlisted), Err(NotListed)), + (login, Target::Row(&unlisted), Err(NotListed)), + (machine, Target::Row(&own), Ok(())), + (login, Target::Row(&own), Ok(())), + (machine, Target::Package(&package), Ok(())), + (login, Target::Package(&package), Ok(())), + (machine, Target::Row(&swap), Err(OutsideLogin)), + (login, Target::Row(&swap), Ok(())), + (machine, Target::Row(&update), Err(OutsideLogin)), + (login, Target::Row(&update), Ok(())), ]; - for (i, (opens, session, target, want)) in table.into_iter().enumerate() { - assert_eq!(may_start(&caller(opens), session, target), want, "row {i}"); + for (i, (session, target, want)) in table.into_iter().enumerate() { + assert_eq!(may_start(&own, session, target), want, "row {i}"); } } - 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. The session the compositor, the screen's row, opens - /// holds the screen down the chain of shells, and sshserver's launches in - /// it never do. + /// Each service the supervisor starts and each session it opens has a share + /// of its own, none the supervisor's; only the screen's row's session + /// holds the screen. #[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, - serves: vec![consent::PROMPT.into()], - ..row("compositor") - }; - let terminal = Program { starts: vec!["shell".into()], ..row("terminal") }; - let shell = Program { starts: vec!["shell".into(), "sshserver".into()], ..row("shell") }; - let sshserver = Program { starts: vec!["shell".into()], login: true, ..row("sshserver") }; + fn every_service_and_session_has_a_share_of_its_own() { + let compositor = Program { login: true, serves: vec![consent::PROMPT.into()], ..row("compositor") }; + let sshserver = Program { login: true, ..row("sshserver") }; let mut sessions = Sessions::default(); - 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 opened = launch(&mut sessions, &compositor, desktop, &terminal); - assert!(opened.at_screen(), "the compositor's launch opened {opened:?}"); - let mut session = launch(&mut sessions, &terminal, opened, &shell); - for _ in 0..8 { - session = launch(&mut sessions, &shell, session, &shell); - } - assert_eq!(session, opened, "a login session's launches left it"); - session = launch(&mut sessions, &shell, session, &sshserver); - assert_eq!(session, opened, "sshserver was not started in the desktop's session"); - session = launch(&mut sessions, &sshserver, session, &shell); - assert_eq!(session.share(), opened.share(), "sshserver's launch left the session's share"); - assert!(!session.at_screen(), "sshserver's launch holds the screen: {session:?}"); - assert!(!launch(&mut sessions, &shell, session, &shell).at_screen(), "a shell regained the screen"); - shares.push(opened.share()); - } + let (a, b) = (sessions.machine(), sessions.machine()); + let desktop = sessions.open(Screen::of(&compositor)); + let remote = sessions.open(Screen::of(&sshserver)); + assert!(desktop.at_screen() && !remote.at_screen()); + assert!(!a.at_screen() && !b.at_screen()); + let shares = [SUPERVISOR_SHARE, a.share(), b.share(), desktop.share(), remote.share()]; let distinct: BTreeSet = shares.iter().copied().collect(); assert_eq!(distinct.len(), shares.len(), "two shares are one: {shares:?}"); - // sshserver started by the machine, and any `login` row but the - // screen's, opens a session that never holds it. - let ssh = launch(&mut sessions, &sshserver, tests, &shell); - assert!(matches!(ssh, Session::Login(_, Screen::Not)), "{ssh:?}"); } /// A row that lists nothing starts nothing, a package included, and `/apps` @@ -384,7 +297,7 @@ mod tests { } let apps_only = Program { starts: vec![APPS.into()], ..row("apps") }; 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))); + assert_eq!(may_start(&apps_only, machine, Target::Package(&package)), Ok(())); } #[test] diff --git a/lib/manifest/src/lib.rs b/lib/manifest/src/lib.rs index 23563d90ba5..b498005ca36 100644 --- a/lib/manifest/src/lib.rs +++ b/lib/manifest/src/lib.rs @@ -32,7 +32,7 @@ //! app-receive a connector every program launched from /apps holds //! app-folder the most of a folder of the home a package may be granted //! starts a row this program may start through the launcher, or `/apps` -//! login a launch it makes from the machine's session opens a login session +//! login a login authority: the supervisor opens login sessions for it //! ``` //! //! [`device`] is what a `devices` or `drives` entry names. [`package`] is the @@ -333,7 +333,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 from the machine's session opens a login session + /// A login authority: the supervisor opens login sessions for it, the + /// screen's row's at its start and any on `toyos_session::LOGIN_PORT`. /// ([`launch`]). pub login: bool, } diff --git a/lib/session/Cargo.toml b/lib/session/Cargo.toml new file mode 100644 index 00000000000..5e3263d8bb5 --- /dev/null +++ b/lib/session/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "toyos-session" +description = "A login session's protocols: opening one, signing it out, and its owner's link to the supervisor." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[lib] +doctest = false + +[dependencies] +# A login authority's open of a session, through its namespace. +toyos = { path = "../../sdk/toyos" } + +[lints.rust] +warnings = "deny" diff --git a/lib/session/src/lib.rs b/lib/session/src/lib.rs new file mode 100644 index 00000000000..8e899a89cbc --- /dev/null +++ b/lib/session/src/lib.rs @@ -0,0 +1,325 @@ +//! A login session's protocols: the words a login authority, the session's +//! owner, the supervisor and `logout` say to one another, and a login +//! authority's [`open`]. +//! +//! **One session owner per login, `/system/bin/session`**, which the supervisor +//! starts and parents and which parents its session's detached apps. It +//! accepts each connection on its session's launcher and hands it to the +//! supervisor over its [`LINK_LABEL`] connection with the caller's badge +//! ([`MSG_HANDOVER`]); the supervisor serves the launch and takes the session +//! from the link, never from the request. It signs the session out +//! ([`SignOut`]) on its [`CONTROL_LABEL`] connections, from the session's login +//! authority, and on the link, from `logout` through [`ADMIN_PORT`]. +//! +//! **No clock ends anything.** A graceful sign-out asks every app to quit with +//! a hang-up and reports what stays ([`MSG_WAITING`]) until each has ended; +//! only a forced one kills what remains. + +#![forbid(unsafe_code)] + +/// The supervisor's port a login authority opens a session on, which only a +/// `login` row receives. +pub const LOGIN_PORT: &str = "login"; + +/// The supervisor's port `logout` acts on its user's sessions through, which +/// only [`ADMIN_HOLDER`] receives, minted with its user. +pub const ADMIN_PORT: &str = "session-admin"; + +/// The one row a build may let receive [`ADMIN_PORT`]: its own binary, so no +/// toybox applet holds it. +pub const ADMIN_HOLDER: &str = "logout"; + +/// The session owner's row. +pub const OWNER: &str = "session"; + +/// The label of the session owner's connection to the supervisor. +pub const LINK_LABEL: &str = "session-link"; + +/// The label of the session launcher's acceptor in the session owner. +pub const LAUNCHER_ACCEPTOR_LABEL: &str = "session-launcher"; + +/// The label of the acceptor a session's control connections arrive on, in +/// the session owner. +pub const CONTROL_ACCEPTOR_LABEL: &str = "session-controls"; + +/// The label a login authority the supervisor started with its session holds +/// that session's control connection under. +pub const CONTROL_LABEL: &str = "session-control"; + +/// On [`LOGIN_PORT`]: open a remote session. No payload. +pub const MSG_OPEN: u32 = 1; +/// The answer: the session's launcher, as a namespace holding +/// `toyos::launch::LAUNCHER`, then its control connection. +pub const MSG_OPENED: u32 = 2; +/// The answer to a request refused: the reason, UTF-8. +pub const MSG_REFUSED: u32 = 3; + +/// On a control connection, and the supervisor's on the link: a [`SignOut`]. +pub const MSG_SIGN_OUT: u32 = 10; +/// On a control connection: a graceful sign-out is not to go on. +pub const MSG_CANCEL: u32 = 11; +/// The owner's report, on every control connection and on the link: the +/// apps a sign-out waits for, [`Names`]. +pub const MSG_WAITING: u32 = 12; +/// The owner's word that a sign-out was cancelled. +pub const MSG_CANCELLED: u32 = 13; + +/// Owner to supervisor: a connection accepted on the session's launcher, its +/// badge the payload and the connection the handle. +pub const MSG_HANDOVER: u32 = 20; +/// Supervisor to owner: an app started in the session, a [`Started`], and a +/// handle to it that may quit and kill it. +pub const MSG_STARTED: u32 = 21; +/// Supervisor to owner: an app of the session ended, its instance as eight +/// little-endian bytes. +pub const MSG_ENDED: u32 = 22; +/// Owner to supervisor, its first word: a handle to itself that places a +/// child under it, which the session's detached apps are spawned under. +pub const MSG_PLACE: u32 = 23; + +/// On [`ADMIN_PORT`]: the sessions of the badge's user. No payload. +pub const MSG_LIST: u32 = 30; +/// The answer to [`MSG_LIST`]: [`Listed`] lines. +pub const MSG_SESSIONS: u32 = 31; +/// On [`ADMIN_PORT`]: a [`SignOutSession`]. +pub const MSG_SIGN_OUT_SESSION: u32 = 32; +/// The answer to an admin request done. +pub const MSG_DONE: u32 = 33; + +/// Whether a sign-out forces: kills whatever still runs, rather than asking +/// and waiting. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SignOut { + pub force: bool, +} + +impl SignOut { + pub fn encode(self) -> [u8; 1] { + [self.force as u8] + } + + pub fn decode(bytes: &[u8]) -> Option { + match bytes { + [0] => Some(Self { force: false }), + [1] => Some(Self { force: true }), + _ => None, + } + } +} + +/// The longest name a [`Names`] or [`Started`] carries: a program key's +/// bound. +pub const MAX_NAME: usize = 32; + +/// The most names one [`MSG_WAITING`] carries; a list past it says how many +/// more it leaves out. +pub const MAX_NAMES: usize = 64; + +/// Program names, one per line. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Names { + pub names: Vec, + /// How many more there were than [`MAX_NAMES`]. + pub more: u32, +} + +impl Names { + /// `names`, kept to [`MAX_NAMES`] and each to [`MAX_NAME`] bytes. + pub fn of<'a>(names: impl IntoIterator) -> Self { + let mut out = Self::default(); + for name in names { + match out.names.len() < MAX_NAMES { + true => out.names.push(clipped(name).to_string()), + false => out.more += 1, + } + } + out + } + + /// The count of names left out, then each name after a newline. + pub fn encode(&self) -> Vec { + let mut out = self.more.to_le_bytes().to_vec(); + for name in &self.names { + out.push(b'\n'); + out.extend_from_slice(name.as_bytes()); + } + out + } + + /// `None` for bytes [`Self::encode`] cannot have written. + pub fn decode(bytes: &[u8]) -> Option { + let (more, rest) = bytes.split_first_chunk::<4>()?; + let text = std::str::from_utf8(rest).ok()?; + let names: Vec = match text.is_empty() { + true => Vec::new(), + false => text.strip_prefix('\n')?.split('\n').map(String::from).collect(), + }; + let valid = |n: &String| !n.is_empty() && n.len() <= MAX_NAME; + (names.len() <= MAX_NAMES && names.iter().all(valid)).then(|| Self { names, more: u32::from_le_bytes(*more) }) + } +} + +/// `name` to at most [`MAX_NAME`] bytes, on a character boundary. +fn clipped(name: &str) -> &str { + let mut end = name.len().min(MAX_NAME); + while !name.is_char_boundary(end) { + end -= 1; + } + &name[..end] +} + +/// An app started in a session: the instance the supervisor numbers it by, +/// never reused in a boot, and its row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Started { + pub instance: u64, + pub name: String, +} + +impl Started { + pub fn encode(&self) -> Vec { + let mut out = self.instance.to_le_bytes().to_vec(); + out.extend_from_slice(clipped(&self.name).as_bytes()); + out + } + + pub fn decode(bytes: &[u8]) -> Option { + let (instance, name) = bytes.split_first_chunk::<8>()?; + let name = std::str::from_utf8(name).ok()?; + (!name.is_empty() && name.len() <= MAX_NAME) + .then(|| Self { instance: u64::from_le_bytes(*instance), name: name.to_string() }) + } +} + +/// An instance, as [`MSG_ENDED`] carries it. +pub fn decode_instance(bytes: &[u8]) -> Option { + Some(u64::from_le_bytes(bytes.try_into().ok()?)) +} + +/// `logout`'s sign-out of one of its user's sessions, by its share. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SignOutSession { + pub share: u64, + pub sign_out: SignOut, +} + +impl SignOutSession { + pub fn encode(self) -> [u8; 9] { + let mut out = [0u8; 9]; + out[..8].copy_from_slice(&self.share.to_le_bytes()); + out[8] = self.sign_out.force as u8; + out + } + + pub fn decode(bytes: &[u8]) -> Option { + let (share, force) = bytes.split_first_chunk::<8>()?; + Some(Self { share: u64::from_le_bytes(*share), sign_out: SignOut::decode(force)? }) + } +} + +/// Whether a session is in use, or signing out and waiting for its apps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Active, + Ending, +} + +/// One session, as [`MSG_SESSIONS`] lists it: its share, whether the screen's +/// row opened it, its state and what its sign-out waits for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Listed { + pub share: u64, + pub desktop: bool, + pub state: State, + pub waiting: Names, +} + +impl Listed { + /// One line: ` desktop|remote active|ending [name ...]`. + pub fn line(&self) -> String { + let kind = if self.desktop { "desktop" } else { "remote" }; + let state = match self.state { + State::Active => "active", + State::Ending => "ending", + }; + let mut line = format!("{} {kind} {state}", self.share); + for name in &self.waiting.names { + line.push(' '); + line.push_str(name); + } + if self.waiting.more > 0 { + line.push_str(&format!(" (+{})", self.waiting.more)); + } + line + } +} + +/// A session opened on [`LOGIN_PORT`] through `login`, the namespace a +/// login authority is endowed under that name: its launcher, as a namespace +/// holding `toyos::launch::LAUNCHER`, and its control connection. +pub fn open( + login: &toyos::namespace::Namespace, +) -> Result<(toyos::namespace::Namespace, toyos::ipc::Connection), String> { + let conn = login.open(LOGIN_PORT).map_err(|e| format!("the supervisor's login port refused: {e:?}"))?; + conn.signal(MSG_OPEN).map_err(|e| format!("the open was not sent: {e:?}"))?; + let header = conn.recv_header().map_err(|e| format!("the open was not answered: {e:?}"))?; + if header.msg_type != MSG_OPENED { + let mut why = vec![0u8; header.len() as usize]; + let n = conn.recv_bytes(&header, &mut why).unwrap_or(0); + return Err(format!("the supervisor refused a session: {}", String::from_utf8_lossy(&why[..n]))); + } + let [launcher, control] = conn.recv_handles_exact::<2>(&header).ok_or("the session came without its handles")?; + Ok((toyos::namespace::Namespace::received(launcher), toyos::ipc::Connection::received(control))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_word_reads_back_as_written() { + for force in [false, true] { + assert_eq!(SignOut::decode(&SignOut { force }.encode()), Some(SignOut { force })); + let session = SignOutSession { share: 7, sign_out: SignOut { force } }; + assert_eq!(SignOutSession::decode(&session.encode()), Some(session)); + } + let started = Started { instance: u64::MAX, name: "terminal".into() }; + assert_eq!(Started::decode(&started.encode()), Some(started)); + for names in [Names::of([]), Names::of(["doom", "calc"])] { + assert_eq!(Names::decode(&names.encode()), Some(names)); + } + assert_eq!(decode_instance(&9u64.to_le_bytes()), Some(9)); + } + + /// A list past its bound keeps its first names and counts the rest, and a + /// name past a key's bound is cut, so a report always fits its frame. + #[test] + fn a_report_is_bounded() { + let many: Vec = (0..MAX_NAMES + 3).map(|i| format!("app{i}")).collect(); + let names = Names::of(many.iter().map(String::as_str)); + assert_eq!((names.names.len(), names.more), (MAX_NAMES, 3)); + let long = "x".repeat(MAX_NAME + 5); + assert_eq!(Names::of([long.as_str()]).names[0].len(), MAX_NAME); + assert!(Names::of([long.as_str()]).encode().len() <= 4 + MAX_NAMES * (1 + MAX_NAME)); + } + + #[test] + fn bytes_no_encoder_writes_are_refused() { + assert_eq!(SignOut::decode(&[2]), None); + assert_eq!(SignOut::decode(&[]), None); + assert_eq!(SignOutSession::decode(&[0; 8]), None); + assert_eq!(Started::decode(&[0; 8]), None); + assert_eq!(Started::decode(&[0; 7]), None); + assert_eq!(Names::decode(&[0, 0, 0]), None); + assert_eq!(Names::decode(b"\0\0\0\0x"), None); + assert_eq!(Names::decode(b"\0\0\0\0\n"), None); + assert_eq!(Names::decode(b"\0\0\0\0\na\n"), None); + assert_eq!(decode_instance(&[0; 7]), None); + } + + #[test] + fn a_listed_session_is_one_line() { + let listed = Listed { share: 3, desktop: false, state: State::Ending, waiting: Names::of(["holder"]) }; + assert_eq!(listed.line(), "3 remote ending holder"); + } +} diff --git a/sdk/libc/src/spawn.rs b/sdk/libc/src/spawn.rs index 7bbe9339100..42d90691b1c 100644 --- a/sdk/libc/src/spawn.rs +++ b/sdk/libc/src/spawn.rs @@ -331,6 +331,7 @@ fn start(path: &str, argv: &[u8], env: &[u8], fds: &[(u32, RawHandle, Kind)]) -> program: path, image: None, image_held: false, + launcher: None, argv, env, cwd, diff --git a/sdk/std/os/process.rs b/sdk/std/os/process.rs index 8d0901cbcf4..24a64bb64d6 100644 --- a/sdk/std/os/process.rs +++ b/sdk/std/os/process.rs @@ -116,6 +116,14 @@ pub trait CommandExt { /// not independent is refused `InvalidInput`. #[stable(feature = "toyos_ext", since = "1.0.0")] fn detached(&mut self) -> &mut Self; + + /// Make an [`independent`](CommandExt::independent) launch through the + /// launcher `namespace` names, a namespace holding `launcher` that this + /// process holds and keeps, in place of the one it was endowed: a login + /// authority's for a session it opened. It reaches only what that launcher + /// does. A command that is not independent is refused `InvalidInput`. + #[stable(feature = "toyos_ext", since = "1.0.0")] + fn launcher(&mut self, namespace: u32) -> &mut Self; } #[stable(feature = "toyos_ext", since = "1.0.0")] @@ -173,6 +181,11 @@ impl CommandExt for crate::process::Command { self.as_inner_mut().detached(); self } + + fn launcher(&mut self, namespace: u32) -> &mut Self { + self.as_inner_mut().launcher(namespace); + self + } } /// ToyOS-specific extensions to [`process::ExitStatus`]. diff --git a/sdk/std/sys/process.rs b/sdk/std/sys/process.rs index 6c3bc26a6e8..305ca23e88c 100644 --- a/sdk/std/sys/process.rs +++ b/sdk/std/sys/process.rs @@ -29,6 +29,9 @@ pub struct Command { parent: toyos::spawn::Parent, /// An independent launch, where a plain spawn is a helper. independent: bool, + /// The launcher an independent launch goes through, where it is not the + /// endowed one: a namespace handle the caller keeps. + launcher: Option, /// The program the child runs, when it is not the one `argv[0]` names. image_path: Option, prepared: Option, @@ -86,6 +89,7 @@ impl Command { provided: Vec::new(), parent: toyos::spawn::Parent::Caller, independent: false, + launcher: None, image_path: None, prepared: None, image: None, @@ -234,6 +238,11 @@ impl Command { self.independent = true; } + /// Launch through the launcher `namespace` holds, which this process keeps. + pub fn launcher(&mut self, namespace: u32) { + self.launcher = Some(toyos_abi::RawHandle(namespace)); + } + /// Place a launched child under the launcher's owner, so it outlives this process. pub fn detached(&mut self) { self.parent = toyos::spawn::Parent::Detached; @@ -351,6 +360,7 @@ impl Command { provided: &provided, parent: self.parent, independent: self.independent, + launcher: self.launcher, }; let pipes = |stdin, stdout, stderr| StdioPipes { stdin, stdout, stderr }; let helper = match request.start().map_err(refused)? { diff --git a/sdk/toyos/src/endow.rs b/sdk/toyos/src/endow.rs index 60bb7dcbc89..1f4ee318bcc 100644 --- a/sdk/toyos/src/endow.rs +++ b/sdk/toyos/src/endow.rs @@ -74,6 +74,7 @@ from_handle! { Device => Device, crate::Console => crate::Console, crate::Pipe => crate::Pipe, + crate::ipc::Connection => crate::ipc::Connection, crate::FramebufferDev => |h| crate::FramebufferDev(Device(h)), crate::PciDev => |h| crate::PciDev(Device(h)), crate::AcpiDev => |h| crate::AcpiDev(Device(h)), diff --git a/sdk/toyos/src/ipc.rs b/sdk/toyos/src/ipc.rs index 1723fc9c021..3c901891489 100644 --- a/sdk/toyos/src/ipc.rs +++ b/sdk/toyos/src/ipc.rs @@ -205,6 +205,11 @@ impl Connection { self.0 } + /// Give up ownership, for an end about to be endowed. + pub fn into_raw(self) -> RawHandle { + self.0.into_raw() + } + pub fn send(&self, msg_type: u32, payload: &T) -> Result<(), IpcError> { send(self.0.raw(), msg_type, payload) } diff --git a/sdk/toyos/src/namespace.rs b/sdk/toyos/src/namespace.rs index 980f6073872..82babceea30 100644 --- a/sdk/toyos/src/namespace.rs +++ b/sdk/toyos/src/namespace.rs @@ -30,6 +30,13 @@ impl Namespace { self.0.into_raw() } + /// A namespace a peer sent, consumed. Its type is the sender's word: the + /// kernel refuses each namespace call on a handle of another type, as it + /// does a connection's ([`Connection::received`]). + pub fn received(raw: RawHandle) -> Self { + Self(OwnedHandle(raw)) + } + /// # Safety /// `raw` must be a live namespace handle this process owns and nothing else /// answers for. diff --git a/sdk/toyos/src/spawn.rs b/sdk/toyos/src/spawn.rs index ad330af5acf..81821d5fd98 100644 --- a/sdk/toyos/src/spawn.rs +++ b/sdk/toyos/src/spawn.rs @@ -75,6 +75,10 @@ pub struct Spawn<'a> { /// An independent launch through this process's launcher, where a helper /// is not. pub independent: bool, + /// A launcher this process holds, a namespace holding [`launch::LAUNCHER`], + /// which a launch goes through in place of the one it was endowed: + /// borrowed, and refused a helper. + pub launcher: Option, } /// Why nothing was started. @@ -117,6 +121,7 @@ struct Shape { extra_fds: bool, image_named: bool, provided: bool, + launcher_named: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -129,9 +134,10 @@ enum Kind { /// of a helper, or what a helper alone carries asked of a launch. fn kind(shape: Shape) -> Result { if !shape.independent { - return match (shape.detached, shape.provided) { - (true, _) => Err("only an independent launch is detached"), - (_, true) => Err("only an independent launch carries a provided connector"), + return match (shape.detached, shape.provided, shape.launcher_named) { + (true, _, _) => Err("only an independent launch is detached"), + (_, true, _) => Err("only an independent launch carries a provided connector"), + (_, _, true) => Err("only an independent launch goes through a launcher"), _ => Ok(Kind::Helper), }; } @@ -155,6 +161,7 @@ impl<'a> Spawn<'a> { extra_fds: self.fds.iter().any(|&(fd, _, _)| fd > 2), image_named: self.image.is_some() || self.image_held, provided: !self.provided.is_empty(), + launcher_named: self.launcher.is_some(), }; match kind(shape).map_err(Refused::Invalid)? { Kind::Helper => Ok(Start::Helper(Helper(self))), @@ -279,8 +286,13 @@ impl Independent<'_, '_> { /// carries. A job the spawn endowed goes itself, moved as an endowment is. pub fn launch(self) -> Result { let s = self.0; - let held = crate::endow::launcher().ok_or(Refused::Permission("this process holds no launcher"))?; - let conn = held.open(launch::LAUNCHER).map_err(|_| Refused::Permission("the launcher this process holds will not open"))?; + let held = match s.launcher { + Some(named) => named, + None => crate::endow::launcher().ok_or(Refused::Permission("this process holds no launcher"))?.as_handle(), + }; + let conn = syscall::namespace_open(held, launch::LAUNCHER) + .map(|h| crate::ipc::Connection(crate::OwnedHandle(h))) + .map_err(|_| Refused::Permission("the launcher this process holds will not open"))?; let place = match s.parent { Parent::Detached => None, Parent::Place(place) => Some(place), @@ -396,10 +408,15 @@ mod tests { /// carries is refused a launch: neither becomes the other. #[test] fn each_kind_refuses_what_the_other_alone_takes() { - const LAUNCH: Shape = Shape { independent: true, detached: false, endowed: false, extra_fds: false, image_named: false, provided: false }; + const LAUNCH: Shape = + Shape { independent: true, detached: false, endowed: false, extra_fds: false, image_named: false, provided: false, launcher_named: false }; assert_eq!(kind(LAUNCH), Ok(Kind::Launch)); - assert_eq!(kind(Shape { detached: true, provided: true, ..LAUNCH }), Ok(Kind::Launch)); - for shape in [Shape { detached: true, ..Shape::default() }, Shape { provided: true, ..Shape::default() }] { + assert_eq!(kind(Shape { detached: true, provided: true, launcher_named: true, ..LAUNCH }), Ok(Kind::Launch)); + for shape in [ + Shape { detached: true, ..Shape::default() }, + Shape { provided: true, ..Shape::default() }, + Shape { launcher_named: true, ..Shape::default() }, + ] { assert!(kind(shape).is_err(), "{shape:?}"); } for shape in [ diff --git a/system/compositor/Cargo.toml b/system/compositor/Cargo.toml index 88c3807f050..12305f08197 100644 --- a/system/compositor/Cargo.toml +++ b/system/compositor/Cargo.toml @@ -15,6 +15,8 @@ toyos-inspect = { path = "../../lib/inspect" } toyos-font = { path = "../../sdk/font" } toyos-icon = { path = "../../sdk/icon" } toyos-manifest = { path = "../../lib/manifest" } +# Its desktop session: signing it out, and opening it again. +toyos-session = { path = "../../lib/session" } toyos-pci-claim = { path = "../../lib/pci-claim" } toyos-virtio = { path = "../../lib/virtio" } toyos-virtio-gpu = { path = "virtio-gpu" } diff --git a/system/compositor/desktop/src/input.rs b/system/compositor/desktop/src/input.rs index 364cfd8709b..98428ef4c4f 100644 --- a/system/compositor/desktop/src/input.rs +++ b/system/compositor/desktop/src/input.rs @@ -17,6 +17,7 @@ mod usage { pub const N: u8 = 0x11; pub const Q: u8 = 0x14; pub const V: u8 = 0x19; + pub const ENTER: u8 = 0x28; pub const ESCAPE: u8 = 0x29; pub const TAB: u8 = 0x2B; pub const RIGHT: u8 = 0x4F; @@ -122,6 +123,32 @@ pub fn key_action(ev: RawKeyEvent, focused: Option, launcher_open: b KeyAction::Forward } +/// What a key does to the desktop session's sign-out. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SignOutKey { + /// Ctrl+Alt+Q: sign the session out. + Ask, + /// Escape, while a sign-out waits: it does not go on. + Cancel, + /// Enter, while a sign-out waits: end what it waits for. + Force, +} + +/// The sign-out a key press asks for, ahead of [`key_action`]; `None` for +/// every other transition, and for every key while a prompt is up, whose keys +/// are the prompt's. +pub fn sign_out_key(ev: RawKeyEvent, signing_out: bool, prompt: bool) -> Option { + if prompt || !ev.pressed() { + return None; + } + match ev.keycode { + usage::Q if ev.ctrl() && ev.alt() => Some(SignOutKey::Ask), + usage::ESCAPE if signing_out => Some(SignOutKey::Cancel), + usage::ENTER if signing_out => Some(SignOutKey::Force), + _ => None, + } +} + /// What the one pointer did over a batch of its states, folded into the one /// thing that happened. /// @@ -569,6 +596,23 @@ mod tests { assert_eq!(key_action(key(usage::N, 0), Some(WindowMode::Normal), false, false), KeyAction::Forward); } + /// Ctrl+Alt+Q asks; Escape and Enter cancel and force only while a + /// sign-out waits, and are the window's otherwise; a prompt takes them all. + #[test] + fn a_sign_out_is_asked_cancelled_and_forced_by_its_keys() { + let chord = key(usage::Q, MOD_CTRL | MOD_ALT); + assert_eq!(sign_out_key(chord, false, false), Some(SignOutKey::Ask)); + assert_eq!(sign_out_key(key(usage::Q, MOD_CTRL), false, false), None); + assert_eq!(sign_out_key(key(usage::ESCAPE, 0), true, false), Some(SignOutKey::Cancel)); + assert_eq!(sign_out_key(key(usage::ENTER, 0), true, false), Some(SignOutKey::Force)); + for code in [usage::ESCAPE, usage::ENTER] { + assert_eq!(sign_out_key(key(code, 0), false, false), None); + assert_eq!(sign_out_key(key(code, MOD_RELEASED), true, false), None); + assert_eq!(sign_out_key(key(code, 0), true, true), None); + } + assert_eq!(sign_out_key(chord, false, true), None); + } + /// **While a prompt is up no chord is the desktop's**: closing, cycling, /// snapping, minimizing, a terminal, the launcher's Escape and paste are /// all the prompt's keys, pressed or released. diff --git a/system/compositor/desktop/src/lib.rs b/system/compositor/desktop/src/lib.rs index 585f7bd168a..57a22f527c8 100644 --- a/system/compositor/desktop/src/lib.rs +++ b/system/compositor/desktop/src/lib.rs @@ -33,6 +33,7 @@ pub mod mode; pub mod plan; pub mod rect; pub mod scale; +pub mod signout; pub mod stack; pub mod taskbar; pub mod volume; @@ -43,13 +44,15 @@ pub use damage::{Damage, MAX_DAMAGE_RECTS}; pub use hit::{hit_test, Hit}; pub use input::{ cursor_from_abs, cursor_style, edge_snap, fold_mouse, key_action, tab_action, CursorStyle, Grab, - Held, KeyAction, MouseSample, PromptGate, Released, TabAction, DRAG_THRESHOLD, + Held, KeyAction, MouseSample, PromptGate, Released, SignOutKey, TabAction, DRAG_THRESHOLD, + sign_out_key, }; pub use layout::{relayout, set_mode, Chrome, Desk, TEXT_CELL}; pub use mode::Mode; pub use plan::{compose, content_blit, Blit, Layer}; pub use rect::{Point, Rect}; pub use scale::Scale; +pub use signout::{sign_out_rect, sign_out_text, SIGN_OUT_CHARS}; pub use stack::Stack; pub use taskbar::{Taskbar, MAX_STATUS_CHARS}; pub use window::{Buffers, Level, Presented, Window, WindowId, WindowMode}; diff --git a/system/compositor/desktop/src/signout.rs b/system/compositor/desktop/src/signout.rs new file mode 100644 index 00000000000..cfdc36592c8 --- /dev/null +++ b/system/compositor/desktop/src/signout.rs @@ -0,0 +1,82 @@ +//! The desktop session's sign-out view: one line above the taskbar while a +//! sign-out waits, naming what it waits for and the two keys that decide. + +use alloc::format; +use alloc::string::String; + +use crate::layout::Desk; +use crate::rect::Rect; + +/// The widest line the view shows: its words, then as many names as fit. +pub const SIGN_OUT_CHARS: usize = 72; + +/// What the view says for a sign-out waiting on `waiting`, and `more` it was +/// not told the names of, each clipped to [`SIGN_OUT_CHARS`]. +pub fn sign_out_text<'a>(waiting: impl IntoIterator, more: u32) -> String { + const KEYS: &str = " - Esc cancels, Enter ends them"; + let mut names = String::new(); + let mut left = more as usize; + let mut waiting = waiting.into_iter().peekable(); + if waiting.peek().is_none() && more == 0 { + return String::from("Signing out"); + } + for name in waiting.by_ref() { + let next = if names.is_empty() { String::from(name) } else { format!(", {name}") }; + // Room kept for the count of names left out, ` +` and five digits. + if "Waiting for: ".len() + names.len() + next.len() + " +99999".len() + KEYS.len() > SIGN_OUT_CHARS { + left += 1; + continue; + } + names.push_str(&next); + } + let rest = if left > 0 { format!(" +{left}") } else { String::new() }; + let text = format!("Waiting for: {names}{rest}{KEYS}"); + text.chars().take(SIGN_OUT_CHARS).collect() +} + +/// Where the view is: centred above the taskbar, one taskbar high. +pub fn sign_out_rect(desk: &Desk) -> Rect { + let strip = desk.taskbar(0).strip(); + let w = (SIGN_OUT_CHARS as i32 * desk.font_w + desk.chrome.taskbar).min(strip.w()); + let h = desk.chrome.taskbar; + let x0 = strip.x0 + (strip.w() - w) / 2; + let y1 = strip.y0 - desk.chrome.taskbar_padding * 2; + Rect::corners(x0, y1 - h, x0 + w, y1) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec::Vec; + + #[test] + fn the_view_names_what_waits_and_the_keys() { + assert_eq!(sign_out_text([], 0), "Signing out"); + assert_eq!(sign_out_text(["holder"], 0), "Waiting for: holder - Esc cancels, Enter ends them"); + assert_eq!(sign_out_text(["a", "b"], 2), "Waiting for: a, b +2 - Esc cancels, Enter ends them"); + } + + /// However many names, the line fits its box and still says the keys. + #[test] + fn a_long_list_is_cut_to_the_box_and_keeps_its_keys() { + let names: Vec = (0..40).map(|i| format!("application{i}")).collect(); + let text = sign_out_text(names.iter().map(String::as_str), 0); + assert!(text.chars().count() <= SIGN_OUT_CHARS, "{text}"); + assert!(text.ends_with("Enter ends them"), "{text}"); + assert!(text.contains('+'), "{text}"); + let long = "x".repeat(200); + assert!(sign_out_text([long.as_str()], 0).chars().count() <= SIGN_OUT_CHARS); + } + + #[test] + fn the_view_sits_on_the_screen_above_the_taskbar() { + let desk = Desk::new(Rect::new(0, 0, 1920, 1080), crate::Scale::ONE, 2); + let rect = sign_out_rect(&desk); + assert!(!rect.is_empty()); + assert!(rect.y1 <= desk.taskbar(0).strip().y0); + assert!(rect.x0 >= 0 && rect.x1 <= 1920); + let narrow = Desk::new(Rect::new(0, 0, 320, 240), crate::Scale::ONE, 2); + let rect = sign_out_rect(&narrow); + assert!(rect.x0 >= 0 && rect.x1 <= 320, "{rect:?}"); + } +} diff --git a/system/compositor/src/main.rs b/system/compositor/src/main.rs index 8378dee554f..c67b669f5d9 100644 --- a/system/compositor/src/main.rs +++ b/system/compositor/src/main.rs @@ -20,6 +20,7 @@ mod client; mod display; mod render; mod session; +mod signout; mod stats; use std::time::Duration; @@ -40,8 +41,9 @@ pub const FRAME_INTERVAL: Duration = Duration::from_nanos(16_666_667); // ~60fps pub const DRAIN_BUDGET: Duration = FRAME_INTERVAL; /// Handles the compositor watches that are not windows: keyboard, mouse, the -/// two listeners, `compositor` and `prompt`, and the display's claim. -pub const FIXED_POLL_HANDLES: u32 = 5; +/// two listeners, `compositor` and `prompt`, the display's claim, and the +/// desktop session's control connection. +pub const FIXED_POLL_HANDLES: u32 = 6; /// The `input` port's listener and each of its sources. pub const INPUT_POLL_HANDLES: u32 = 1 + toyos_input::wire::CONNECTIONS as u32; diff --git a/system/compositor/src/session.rs b/system/compositor/src/session.rs index 8ea4e6c9bd9..62b49149bef 100644 --- a/system/compositor/src/session.rs +++ b/system/compositor/src/session.rs @@ -32,7 +32,8 @@ use toyos_desktop::clipboard::{Landing, Watch}; use toyos_desktop::volume::{overlay_rect, Overlay, Request, Requests, Shown}; use toyos::audio::{Volume, VolumeStep, MSG_VOLUME, MSG_VOLUME_MUTE, MSG_VOLUME_STEP, MSG_VOLUME_UNAVAILABLE}; use toyos_desktop::{ - cursor, cursor_from_abs, cursor_style, fold_mouse, hit_test, key_action, relayout, set_mode, tab_action, + cursor, cursor_from_abs, cursor_style, fold_mouse, hit_test, key_action, relayout, set_mode, sign_out_key, + sign_out_rect, sign_out_text, tab_action, SignOutKey, Buffers, CursorStyle, Presented, Requested, Damage, Desk, Scale, Grab, Held, Hit, KeyAction, Level, Mode, Point, Port, PromptGate, Rect, Released, Stack, TabAction, Verdict, Window, WindowId, WindowMode, }; @@ -308,6 +309,8 @@ pub struct Session { prev_busy_ticks: u64, prev_total_ticks: u64, last_taskbar_update: Instant, + /// The desktop session this compositor signs out and launches in. + desktop: crate::signout::Desktop, } impl Session { @@ -328,6 +331,7 @@ impl Session { let inputs = endow::acceptor(wire::PORT) .expect("the manifest declares this program serves `input`"); let (mut display, panel) = Display::claim(); + let desktop = crate::signout::Desktop::take(); let mode = display.mode(); let (image, kept) = (image_scale(), kept_scale()); let scale = scale_for(kept, image.unwrap_or(panel), mode); @@ -387,6 +391,9 @@ impl Session { if let Some(handle) = display.handle() { poller.watch_raw(handle, READABLE, handle.0 as u64); } + if let Some(control) = desktop.conn() { + poller.watch(control, READABLE, control.as_handle().0 as u64); + } let cursor = Point { x: desk.screen.w() / 2, y: desk.screen.h() / 2 }; if display.hw_cursor() && cursor::in_hardware(scale) { @@ -397,7 +404,7 @@ impl Session { let mut damage = Damage::default(); damage.add(desk.screen); - let launcher = launcher(); + let launcher = launcher(std::sync::Arc::clone(&desktop.launcher)); println!("compositor: ready"); let now = Instant::now(); @@ -452,6 +459,7 @@ impl Session { volume_requests: Requests::default(), overlay: Overlay::default(), epoch: now, + desktop, } } @@ -503,6 +511,7 @@ impl Session { let settings_ready = self.is_ready(self.settings.as_handle()); let volume_ready = self.volume.as_ref().is_some_and(|(conn, _)| self.is_ready(conn.as_handle())); let display_ready = self.display.handle().is_some_and(|handle| self.is_ready(handle)); + let session_ready = self.desktop.conn().is_some_and(|c| self.is_ready(c.as_handle())); let client_ready = self.stack.iter().any(|w| self.is_ready(w.client.conn.as_handle())) || self.pending.iter().any(|p| self.is_ready(p.conn.as_handle())) || self.watcher.as_ref().is_some_and(|w| self.is_ready(w.conn.as_handle())); @@ -530,6 +539,7 @@ impl Session { && !client_ready && !volume_ready && !display_ready + && !session_ready { return false; } @@ -559,6 +569,9 @@ impl Session { if volume_ready { self.volume_answers(); } + if session_ready { + self.session_said(); + } let frames = self.take_frames(); self.dispatch(frames); self.watched(); @@ -670,6 +683,15 @@ impl Session { /// One transition of the merged keyboard. fn key(&mut self, raw: RawKeyEvent) { + if let Some(asked) = sign_out_key(raw, self.desktop.waiting.is_some(), self.prompt.is_some()) { + match asked { + SignOutKey::Ask => self.desktop.sign_out(false), + SignOutKey::Force => self.desktop.sign_out(true), + SignOutKey::Cancel => self.desktop.cancel(), + } + self.damage.add(sign_out_rect(&self.desk)); + return; + } let event = window::KeyEvent::from(raw); let focused = self.stack.focused(); let action = key_action( @@ -1848,6 +1870,17 @@ impl Session { } } + /// What the desktop session's owner said, and its control connection + /// watched again, which a session opened again replaces. + fn session_said(&mut self) { + if self.desktop.said() { + self.damage.add(sign_out_rect(&self.desk)); + } + if let Some(control) = self.desktop.conn() { + self.poller.watch(control, READABLE, control.as_handle().0 as u64); + } + } + /// Re-arm the one-shot poll registrations for every handle that fired. fn rearm( &mut self, @@ -1980,6 +2013,13 @@ impl Session { render::draw_overlay(&self.back.surface, &self.font, rect, &shown.text()); } } + if let Some(waiting) = &self.desktop.waiting { + let rect = sign_out_rect(&self.desk); + if regions.iter().any(|r| r.overlaps(rect)) { + let text = sign_out_text(waiting.names.iter().map(String::as_str), waiting.more); + render::draw_overlay(&self.back.surface, &self.font, rect, &text); + } + } // Into the back buffer, so a region containing the cursor carries it // over with everything else rather than the panel being touched twice. @@ -2145,7 +2185,7 @@ fn text(from: RawHandle, bytes: Vec) -> Option { /// **Everything the desktop starts is a job of its own**, made with the /// `jobs` right this row's system capability carries: a program started from /// the taskbar is no helper of the compositor's, and must not share its CPU. -fn launcher() -> mpsc::Sender { +fn launcher(session: crate::signout::Launcher) -> mpsc::Sender { let (launches, queue) = mpsc::channel::(); std::thread::Builder::new() .name("launcher".into()) @@ -2163,7 +2203,14 @@ fn launcher() -> mpsc::Sender { continue; } }; - if let Err(e) = Command::new(&program).independent().detached().endow(JOB_LABEL, job.into_raw().0).spawn() { + let mut command = Command::new(&program); + command.independent().detached().endow(JOB_LABEL, job.into_raw().0); + // A session this compositor opened again, in place of the endowed one. + let opened = session.lock().expect("compositor: its launcher lock is whole"); + if let Some(launcher) = &*opened { + command.launcher(launcher.as_handle().0); + } + if let Err(e) = command.spawn() { eprintln!("compositor: {program} did not start: {e}"); } } diff --git a/system/compositor/src/signout.rs b/system/compositor/src/signout.rs new file mode 100644 index 00000000000..63f2673e810 --- /dev/null +++ b/system/compositor/src/signout.rs @@ -0,0 +1,117 @@ +//! The desktop session this compositor is the login authority of: its control +//! connection, which signs the session out, and the launcher its launches go +//! through. +//! +//! The supervisor starts this row holding the desktop session's launcher and +//! its control connection. **A session that ends is opened again** on the +//! `login` port, which the supervisor budgets only after a failure: a +//! session signed out comes back empty, there being no greeter yet, and one +//! whose owner failed comes back unless its failures are past the budget. +//! Until then a launch goes nowhere, and says so. + +use std::sync::{Arc, Mutex}; + +use toyos::endow::Endowments; +use toyos::ipc::{self, Connection, RxStep}; +use toyos::namespace::Namespace; +use toyos_session::{self as wire, Names, SignOut}; + +/// The launcher a launch goes through: the endowed one while `None`, and +/// one this compositor opened a session for once that one ended. +pub type Launcher = Arc>>; + +pub struct Desktop { + control: Option, + rx: ipc::FrameRx<{ ipc::MAX_FRAME_LEN as usize }>, + /// What a sign-out waits for, while one does. + pub waiting: Option, + login: Option, + pub launcher: Launcher, +} + +impl Desktop { + pub fn take() -> Self { + let endowed = Endowments::get(); + let control = endowed.take::(wire::CONTROL_LABEL); + if control.is_none() { + eprintln!("compositor: started with no desktop session; its launches go nowhere"); + } + Self { + control, + rx: ipc::FrameRx::new(), + waiting: None, + login: endowed.take(wire::LOGIN_PORT), + launcher: Arc::new(Mutex::new(None)), + } + } + + pub fn conn(&self) -> Option<&Connection> { + self.control.as_ref() + } + + /// Sign the session out: ask what runs in it to quit, or end it by `force`. + pub fn sign_out(&mut self, force: bool) { + let Some(control) = &self.control else { return }; + if let Err(e) = control.try_send_bytes(wire::MSG_SIGN_OUT, &SignOut { force }.encode()) { + return eprintln!("compositor: the sign-out was not asked: {e:?}"); + } + println!("compositor: signing out{}", if force { ", ending what still runs" } else { "" }); + self.waiting.get_or_insert_with(Names::default); + } + + pub fn cancel(&mut self) { + let Some(control) = &self.control else { return }; + if let Err(e) = control.try_signal(wire::MSG_CANCEL) { + eprintln!("compositor: the cancel was not sent: {e:?}"); + } + } + + /// Everything the session's owner said; `true` where the view changed. + pub fn said(&mut self) -> bool { + let mut changed = false; + loop { + let Some(control) = &self.control else { return changed }; + match self.rx.pump(control) { + RxStep::Idle => return changed, + RxStep::Frame { msg_type, payload_len, .. } => { + let payload = self.rx.payload(payload_len); + match (msg_type, Names::decode(payload)) { + (wire::MSG_WAITING, Some(names)) => { + println!("compositor: signing out, waiting for: {}", names.names.join(", ")); + self.waiting = Some(names); + } + (wire::MSG_CANCELLED, _) => { + println!("compositor: the sign-out is cancelled; the session goes on"); + self.waiting = None; + } + _ => eprintln!("compositor: the session's owner said {msg_type}, which is no report"), + } + changed = true; + } + RxStep::Eof | RxStep::Malformed => { + println!("compositor: the desktop session ended"); + self.control = None; + self.waiting = None; + self.reopen(); + return true; + } + } + } + } + + /// A session of this compositor's own, opened on the `login` port. + fn reopen(&mut self) { + let Some(login) = &self.login else { + return eprintln!("compositor: no `login` connector to open a desktop session with"); + }; + match wire::open(login) { + Ok((launcher, control)) => { + println!("compositor: a desktop session is open again"); + *self.launcher.lock().expect("compositor: its launcher lock is whole") = Some(launcher); + self.control = Some(control); + self.rx = ipc::FrameRx::new(); + } + Err(why) => eprintln!("compositor: no desktop session: {why}"), + } + } +} diff --git a/system/logout/Cargo.toml b/system/logout/Cargo.toml new file mode 100644 index 00000000000..cd847d0ec73 --- /dev/null +++ b/system/logout/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "logout" +description = "logout [ [--force]]: its user's login sessions, listed, or one of them signed out, asked of the supervisor." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[dependencies] +toyos = { path = "../../sdk/toyos" } +toyos-session = { path = "../../lib/session" } + +[package.metadata.toyos.host] +exempt.manages = "ToyOS's login sessions, through the supervisor's `session-admin` port" diff --git a/system/logout/src/main.rs b/system/logout/src/main.rs new file mode 100644 index 00000000000..d76fc71183b --- /dev/null +++ b/system/logout/src/main.rs @@ -0,0 +1,76 @@ +//! `/system/bin/logout`: its user's login sessions, asked of the supervisor. +//! +//! - `logout`: every session, one line each: its number, `desktop` or +//! `remote`, `active` or `ending`, and what an ending one waits for. +//! - `logout `: sign it out: ask its apps to quit, and wait for them. +//! - `logout --force`: sign it out, ending whatever still runs. +//! +//! It holds the one `session-admin` connector the build lets a program hold, +//! minted with its row and session, so it is reached with `launch logout`; +//! the supervisor answers a login session's alone, for that session's user. +//! The answer is the supervisor's text on standard output, with exit status 0 +//! where it was done and 1 where it was refused. + +use std::io::Write; + +use toyos::endow::Endowments; +use toyos::namespace::Namespace; +use toyos_session::{self as wire, SignOut, SignOutSession}; + +const USAGE: &str = "usage: logout [ [--force]]"; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let (done, text) = match request(&args) { + Ok((msg, payload)) => ask(msg, &payload), + Err(why) => (false, why.to_string()), + }; + print!("{text}"); + if !text.ends_with('\n') && !text.is_empty() { + println!(); + } + std::io::stdout().flush().expect("the answer reaches standard output"); + std::process::exit(if done { 0 } else { 1 }); +} + +fn request(args: &[String]) -> Result<(u32, Vec), &'static str> { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let sign_out = |share: &str, force| { + let share = share.parse().map_err(|_| USAGE)?; + Ok((wire::MSG_SIGN_OUT_SESSION, SignOutSession { share, sign_out: SignOut { force } }.encode().to_vec())) + }; + match args[..] { + [] => Ok((wire::MSG_LIST, Vec::new())), + [share] => sign_out(share, false), + [share, "--force"] => sign_out(share, true), + _ => Err(USAGE), + } +} + +/// The supervisor's answer: whether it was done, and its text. +fn ask(msg: u32, payload: &[u8]) -> (bool, String) { + let Some(held) = Endowments::get().take::(wire::ADMIN_PORT) else { + return (false, "logout: this process holds no `session-admin` connector; run it with `launch logout`".into()); + }; + let supervisor = match held.open(wire::ADMIN_PORT) { + Ok(conn) => conn, + Err(e) => return (false, format!("logout: the supervisor's port refused: {e:?}")), + }; + if let Err(e) = supervisor.send_bytes(msg, payload) { + return (false, format!("logout: the request was not sent: {e:?}")); + } + let answer = match supervisor.recv_header() { + Ok(answer) => answer, + Err(e) => return (false, format!("logout: the supervisor did not answer: {e:?}")), + }; + let mut text = vec![0u8; answer.len() as usize]; + let said = match supervisor.recv_bytes(&answer, &mut text) { + Ok(n) => String::from_utf8_lossy(&text[..n]).into_owned(), + Err(e) => return (false, format!("logout: the supervisor's answer did not arrive whole: {e:?}")), + }; + match answer.msg_type { + wire::MSG_SESSIONS | wire::MSG_DONE => (true, said), + wire::MSG_REFUSED => (false, said), + other => (false, format!("logout: the supervisor answered message {other}, which is no answer of its port")), + } +} diff --git a/system/session/Cargo.toml b/system/session/Cargo.toml new file mode 100644 index 00000000000..428422ba151 --- /dev/null +++ b/system/session/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "session" +description = "A login session's owner: its launcher, the parent of its detached apps, and its sign-out." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[lib] +doctest = false + +[dependencies] +toyos = { path = "../../sdk/toyos" } +toyos-abi = { path = "../../sdk/abi" } +toyos-session = { path = "../../lib/session" } + +[package.metadata.toyos.host] +exempt.owns = "a ToyOS login session: its launcher, which the supervisor serves, and the apps it parents" diff --git a/system/session/src/lib.rs b/system/session/src/lib.rs new file mode 100644 index 00000000000..0afb6404ac7 --- /dev/null +++ b/system/session/src/lib.rs @@ -0,0 +1,152 @@ +//! What a session owner does about its session's apps, pure: `main.rs` holds +//! the handles and acts on what [`Owner`] answers. +//! +//! **A graceful sign-out asks and waits; only a force kills.** It asks every +//! app to quit with a hang-up, which ends at once each one that never listens, +//! and reports the rest until the last has ended, when the owner exits and +//! the session ends. Nothing here reads a clock. A cancel stops the waiting; +//! a hang-up already delivered stays delivered. + +use std::collections::BTreeMap; + +use toyos_session::Names; + +/// What the owner does next. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Act { + /// Ask this app, and everything below it, to quit with a hang-up. + Quit(u64), + Kill(u64), + /// Tell the session's controls and the supervisor what the sign-out waits for. + Report(Names), + Cancelled, + /// Every app has ended: the session ends with the owner. + Exit, +} + +/// The session's apps, by instance, and whether it is signing out. +#[derive(Debug, Default)] +pub struct Owner { + apps: BTreeMap, + signing_out: bool, +} + +impl Owner { + pub fn signing_out(&self) -> bool { + self.signing_out + } + + /// An app the supervisor started in the session. One started while the + /// session signs out is asked to quit as the rest were. + pub fn started(&mut self, instance: u64, name: String) -> Vec { + self.apps.insert(instance, name); + match self.signing_out { + true => vec![Act::Quit(instance), self.report()], + false => Vec::new(), + } + } + + pub fn ended(&mut self, instance: u64) -> Vec { + self.apps.remove(&instance); + match self.signing_out { + true => self.waited(), + false => Vec::new(), + } + } + + pub fn sign_out(&mut self, force: bool) -> Vec { + if force { + let mut acts: Vec = self.apps.keys().map(|&i| Act::Kill(i)).collect(); + acts.push(Act::Exit); + return acts; + } + let mut acts = match self.signing_out { + true => Vec::new(), + false => self.apps.keys().map(|&i| Act::Quit(i)).collect(), + }; + self.signing_out = true; + acts.extend(self.waited()); + acts + } + + pub fn cancel(&mut self) -> Vec { + match std::mem::replace(&mut self.signing_out, false) { + true => vec![Act::Cancelled], + false => Vec::new(), + } + } + + fn waited(&self) -> Vec { + match self.apps.is_empty() { + true => vec![Act::Exit], + false => vec![self.report()], + } + } + + fn report(&self) -> Act { + Act::Report(Names::of(self.apps.values().map(String::as_str))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn waiting(names: &[&str]) -> Act { + Act::Report(Names::of(names.iter().copied())) + } + + fn owner(apps: &[(u64, &str)]) -> Owner { + let mut owner = Owner::default(); + for (instance, name) in apps { + assert_eq!(owner.started(*instance, name.to_string()), Vec::new()); + } + owner + } + + /// A graceful sign-out asks each app once, reports what stays as each + /// ends, and exits after the last; nothing on that path kills. + #[test] + fn a_graceful_sign_out_asks_waits_and_never_kills() { + let mut owner = owner(&[(1, "terminal"), (2, "holder")]); + assert_eq!(owner.sign_out(false), vec![Act::Quit(1), Act::Quit(2), waiting(&["terminal", "holder"])]); + assert_eq!(owner.sign_out(false), vec![waiting(&["terminal", "holder"])], "a second ask asks nobody again"); + assert_eq!(owner.ended(1), vec![waiting(&["holder"])]); + assert_eq!(owner.started(3, "late".into()), vec![Act::Quit(3), waiting(&["holder", "late"])]); + assert_eq!(owner.ended(3), vec![waiting(&["holder"])]); + assert_eq!(owner.ended(2), vec![Act::Exit]); + } + + /// The holder that never quits stays waited for, whatever else happens, + /// until a force kills it. + #[test] + fn only_a_force_kills() { + let mut owner = owner(&[(1, "holder"), (2, "doom")]); + owner.sign_out(false); + for _ in 0..3 { + let acts = owner.sign_out(false); + assert!(!acts.iter().any(|a| matches!(a, Act::Kill(_) | Act::Exit)), "{acts:?}"); + } + assert_eq!(owner.ended(2), vec![waiting(&["holder"])]); + assert_eq!(owner.sign_out(true), vec![Act::Kill(1), Act::Exit]); + } + + /// A cancel stops the waiting, and the session goes on: an app ending + /// then is no report, and a later sign-out asks again. + #[test] + fn a_cancel_keeps_the_session() { + let mut owner = owner(&[(1, "holder"), (2, "doom")]); + owner.sign_out(false); + assert_eq!(owner.cancel(), vec![Act::Cancelled]); + assert!(!owner.signing_out()); + assert_eq!(owner.ended(2), Vec::new()); + assert_eq!(owner.cancel(), Vec::new(), "nothing to cancel"); + assert_eq!(owner.sign_out(false), vec![Act::Quit(1), waiting(&["holder"])]); + } + + #[test] + fn a_session_with_no_app_signs_out_at_once() { + assert_eq!(Owner::default().sign_out(false), vec![Act::Exit]); + assert_eq!(Owner::default().sign_out(true), vec![Act::Exit]); + } +} diff --git a/system/session/src/main.rs b/system/session/src/main.rs new file mode 100644 index 00000000000..dcf5ec5b818 --- /dev/null +++ b/system/session/src/main.rs @@ -0,0 +1,212 @@ +//! `/system/bin/session`: one login session's owner, started by the supervisor. +//! +//! It holds the session's launcher acceptor, the acceptor its control +//! connections arrive on, and its link to the supervisor. Each connection on +//! the launcher goes to the supervisor with its badge, which names the caller's +//! row; the supervisor serves the launch, and tells this owner of each app it +//! starts and of each one's end. A sign-out is [`session::Owner`]'s to decide. + +use toyos::endow::Endowments; +use toyos::ipc::{self, Connection, RxStep}; +use toyos::poller::{Poller, READABLE}; +use toyos::port::Acceptor; +use toyos::process::Process; +use toyos::quit::Reason; +use toyos::{say, AsHandle}; +use toyos_abi::syscall::MAX_BADGE; +use toyos_session::{self as wire, Names, SignOut, Started}; + +use session::{Act, Owner}; + +const TOKEN_LINK: u64 = 0; +const TOKEN_LAUNCHER: u64 = 1; +const TOKEN_CONTROLS: u64 = 2; +const TOKEN_CONTROL_BASE: u64 = 3; + +/// The control connections one session keeps: its login authority's, and +/// one per restart of a compositor that held it. +const MAX_CONTROLS: usize = 8; + +type Rx = ipc::FrameRx<{ ipc::MAX_FRAME_LEN as usize }>; + +struct Control { + conn: Connection, + rx: Rx, +} + +fn main() { + let endowed = Endowments::get(); + let link: Connection = endowed.take(wire::LINK_LABEL).expect("session: started without its link to the supervisor"); + let launcher: Acceptor = + endowed.take(wire::LAUNCHER_ACCEPTOR_LABEL).expect("session: started without its launcher's acceptor"); + let controls_acceptor: Acceptor = + endowed.take(wire::CONTROL_ACCEPTOR_LABEL).expect("session: started without its controls' acceptor"); + + // The place the session's detached apps are spawned under, before any + // launch is handed over on the same link. + let place = toyos_abi::syscall::dup(toyos::endow::this_process().as_handle()) + .expect("session: its own handle duplicates"); + // SAFETY: a duplicate made here, which nothing else answers for. + let place = unsafe { toyos::OwnedHandle::from_raw(place) }; + link.signal_with(wire::MSG_PLACE, [place]).expect("session: its place reaches the supervisor"); + + let mut owner = Owner::default(); + let mut apps: Vec<(u64, Process)> = Vec::new(); + let mut controls: Vec = Vec::new(); + let mut link_rx = Rx::new(); + let poller = Poller::new((3 + MAX_CONTROLS) as u32); + let mut ready = Vec::new(); + loop { + poller.watch(&link, READABLE, TOKEN_LINK); + poller.watch(&launcher, READABLE, TOKEN_LAUNCHER); + poller.watch(&controls_acceptor, READABLE, TOKEN_CONTROLS); + for (i, control) in controls.iter().enumerate() { + poller.watch(&control.conn, READABLE, TOKEN_CONTROL_BASE + i as u64); + } + ready.clear(); + poller.wait(1, u64::MAX, |token| ready.push(token)); + + let mut acts = Vec::new(); + if ready.contains(&TOKEN_LAUNCHER) { + hand_over(&launcher, &link); + } + if ready.contains(&TOKEN_CONTROLS) { + let conn = controls_acceptor.accept().expect("session: its own acceptor refused"); + match controls.len() < MAX_CONTROLS { + true => controls.push(Control { conn, rx: Rx::new() }), + false => say!("session: a control connection past {MAX_CONTROLS} is refused"), + } + } + if ready.contains(&TOKEN_LINK) { + loop { + match link_rx.pump(&link) { + RxStep::Idle => break, + RxStep::Eof => { + say!("session: the supervisor's link ended"); + std::process::exit(1); + } + RxStep::Malformed => panic!("session: the supervisor sent a frame the link cannot describe"), + RxStep::Frame { msg_type, payload_len, .. } => { + let payload = link_rx.payload(payload_len).to_vec(); + let batch = link_rx.take_batch(); + acts.extend(from_supervisor(&mut owner, &mut apps, msg_type, &payload, batch)); + } + } + } + } + let mut i = 0; + while i < controls.len() { + if !ready.contains(&(TOKEN_CONTROL_BASE + i as u64)) { + i += 1; + continue; + } + let control = &mut controls[i]; + match control.rx.pump(&control.conn) { + RxStep::Idle => i += 1, + // A control that hangs up asks nothing: a compositor that ends + // leaves its session as it was. + RxStep::Eof | RxStep::Malformed => { + controls.remove(i); + } + RxStep::Frame { msg_type, payload_len, .. } => { + let payload = control.rx.payload(payload_len).to_vec(); + match (msg_type, SignOut::decode(&payload)) { + (wire::MSG_SIGN_OUT, Some(asked)) => acts.extend(owner.sign_out(asked.force)), + (wire::MSG_CANCEL, _) => acts.extend(owner.cancel()), + _ => say!("session: a control asked {msg_type}, which is no request"), + } + i += 1; + } + } + } + for act in acts { + act_on(act, &mut apps, &controls, &link); + } + } +} + +/// One connection off the launcher, to the supervisor with its badge. +fn hand_over(launcher: &Acceptor, link: &Connection) { + let conn = launcher.accept().expect("session: its own launcher acceptor refused"); + let mut badge = [0u8; MAX_BADGE]; + let badge = match launcher.badge(&conn, &mut badge) { + Ok(badge) => badge.to_vec(), + Err(e) => return say!("session: a launch through an unbadged connector is dropped ({e:?})"), + }; + // The supervisor drains its link before it sleeps, so a full link is its + // own stall: the caller is answered by its connection closing. + if let Err(e) = link.try_send_bytes_with(wire::MSG_HANDOVER, &badge, [conn.into_owned()]) { + say!("session: a launch was not handed to the supervisor: {e:?}"); + } +} + +fn from_supervisor( + owner: &mut Owner, + apps: &mut Vec<(u64, Process)>, + msg_type: u32, + payload: &[u8], + batch: ipc::Batch, +) -> Vec { + match msg_type { + wire::MSG_STARTED => { + let started = Started::decode(payload).expect("session: the supervisor's start is one it writes"); + let [handle] = batch.exact::<1>().expect("session: a start carries its app"); + // SAFETY: moved into this table with the frame, and nothing else answers for it. + let process: Process = unsafe { toyos::endow::FromHandle::from_handle(handle) }; + apps.push((started.instance, process)); + owner.started(started.instance, started.name) + } + wire::MSG_ENDED => { + let instance = wire::decode_instance(payload).expect("session: the supervisor's end is one it writes"); + apps.retain(|(i, _)| *i != instance); + owner.ended(instance) + } + wire::MSG_SIGN_OUT => { + let asked = SignOut::decode(payload).expect("session: the supervisor's sign-out is one it writes"); + owner.sign_out(asked.force) + } + other => panic!("session: the supervisor sent {other}, which the link has no word for"), + } +} + +fn act_on(act: Act, apps: &mut [(u64, Process)], controls: &[Control], link: &Connection) { + let app = |instance| apps.iter().find(|(i, _)| *i == instance).map(|(_, p)| p); + match act { + Act::Quit(instance) => { + if let Some(process) = app(instance) { + if let Err(e) = process.quit(Reason::HangUp) { + say!("session: app {instance} was not asked to quit: {e:?}"); + } + } + } + Act::Kill(instance) => { + if let Some(process) = app(instance) { + if let Err(e) = process.kill() { + say!("session: app {instance} was not killed: {e:?}"); + } + } + } + Act::Report(names) => report(&names, controls, link), + Act::Cancelled => { + say!("session: the sign-out is cancelled"); + for control in controls { + let _ = control.conn.try_signal(wire::MSG_CANCELLED); + } + } + Act::Exit => { + say!("session: every app has ended; the session ends"); + std::process::exit(0); + } + } +} + +/// What the sign-out waits for, to every control and to the supervisor. A +/// reader that does not drain loses this report and reads the next. +fn report(names: &Names, controls: &[Control], link: &Connection) { + say!("session: waiting for: {}", names.names.join(", ")); + let bytes = names.encode(); + for control in controls { + let _ = control.conn.try_send_bytes(wire::MSG_WAITING, &bytes); + } + let _ = link.try_send_bytes(wire::MSG_WAITING, &bytes); +} diff --git a/system/sshserver/Cargo.toml b/system/sshserver/Cargo.toml index 04aac1f96bc..3c637615880 100644 --- a/system/sshserver/Cargo.toml +++ b/system/sshserver/Cargo.toml @@ -18,3 +18,8 @@ toyos-tmpdir = { path = "../../lib/tmpdir" } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("toyos"))'] } + +[target.'cfg(target_os = "toyos")'.dependencies] +toyos = { path = "../../sdk/toyos" } +# Opening a login session per connection, and signing it out. +toyos-session = { path = "../../lib/session" } diff --git a/system/sshserver/src/spawn.rs b/system/sshserver/src/spawn.rs index 634b6727bb9..c6b90e664b9 100644 --- a/system/sshserver/src/spawn.rs +++ b/system/sshserver/src/spawn.rs @@ -1,19 +1,85 @@ //! How this daemon starts what a client asks for. +//! +//! On ToyOS each connection's programs run in a login session the supervisor +//! opens for it ([`Login`]): launched in their own rows, detached under that +//! session's owner, so they hold no host key and no network right of this +//! daemon's. A connection that goes signs its session out, which asks its +//! programs to quit and ends none by force. Elsewhere a program has no row +//! and runs as this daemon's child, killed when its connection goes. -use std::process::Command; +use std::process::{Child, Command}; -/// A command for `program` that runs in that program's own row, not this -/// daemon's: it holds no host key and no network right of sshserver's. +/// The login session one connection's programs run in. #[cfg(target_os = "toyos")] -pub fn own_row(program: &str) -> Command { - use std::os::toyos::process::CommandExt; - let mut command = Command::new(program); - command.independent(); - command +pub struct Login { + launcher: toyos::namespace::Namespace, + control: toyos::ipc::Connection, } -/// Elsewhere a program has no row, and runs as this daemon's child. +/// What this daemon was endowed to open sessions with, taken once. +#[cfg(target_os = "toyos")] +static LOGIN: std::sync::OnceLock> = std::sync::OnceLock::new(); + +#[cfg(target_os = "toyos")] +impl Login { + /// A session opened for one connection. + pub fn open() -> Result { + use toyos_session as wire; + let login = LOGIN + .get_or_init(|| toyos::endow::Endowments::get().take(wire::LOGIN_PORT)) + .as_ref() + .ok_or("this daemon holds no `login` connector")?; + let (launcher, control) = wire::open(login)?; + Ok(Self { launcher, control }) + } + + /// `program`, to be launched in its own row in this session, detached + /// under the session's owner. + pub fn command(&self, program: &str) -> Command { + use std::os::toyos::process::CommandExt; + let mut command = Command::new(program); + command.independent().detached().launcher(toyos::AsHandle::as_handle(&self.launcher).0); + command + } + + /// A program whose connection is gone: its session's sign-out asks it to + /// quit, so this daemon lets it go. + pub fn abandon(_child: &mut Child, name: &str, peer: &str) { + println!("sshserver: {peer}: the connection is gone; {name} is asked to quit with its session"); + } +} + +/// The connection is gone: its session signs out, asking what runs in it to +/// quit and waiting for it. +#[cfg(target_os = "toyos")] +impl Drop for Login { + fn drop(&mut self) { + let asked = toyos_session::SignOut { force: false }.encode(); + if let Err(e) = self.control.try_send_bytes(toyos_session::MSG_SIGN_OUT, &asked) { + println!("sshserver: a session was not signed out with its connection: {e:?}"); + } + } +} + +/// Elsewhere, no session: a program is this daemon's child. #[cfg(not(target_os = "toyos"))] -pub fn own_row(program: &str) -> Command { - Command::new(program) +pub struct Login; + +#[cfg(not(target_os = "toyos"))] +impl Login { + pub fn open() -> Result { + Ok(Self) + } + + pub fn command(&self, program: &str) -> Command { + Command::new(program) + } + + /// A program whose connection is gone is killed: nobody is left to read it. + pub fn abandon(child: &mut Child, name: &str, peer: &str) { + match child.kill().and_then(|()| child.wait()) { + Ok(_) => println!("sshserver: {peer}: the connection is gone; ended {name}"), + Err(e) => println!("sshserver: {peer}: the connection is gone and {name} would not end: {e}"), + } + } } diff --git a/system/supervisor/Cargo.toml b/system/supervisor/Cargo.toml index 2407093c2a7..b8ac5b7637b 100644 --- a/system/supervisor/Cargo.toml +++ b/system/supervisor/Cargo.toml @@ -16,6 +16,8 @@ toyos-osrelease = { path = "../../sdk/osrelease" } toyos-wallclock = { path = "../../lib/wallclock" } toyos-logstream = { path = "../../lib/logstream" } toyos-swap = { path = "../../lib/swap" } +# The login session protocols: opening one, its owner's link and `logout`'s. +toyos-session = { path = "../../lib/session" } # The slot table and which slot is idle, for the one row that asks for it. toyos-update = { path = "../../lib/update" } # The partition types that name the running ROOT and the slot table. diff --git a/system/supervisor/lifecycle/src/lib.rs b/system/supervisor/lifecycle/src/lib.rs index bffea24e7ca..5e7140e7f6d 100644 --- a/system/supervisor/lifecycle/src/lib.rs +++ b/system/supervisor/lifecycle/src/lib.rs @@ -3,9 +3,11 @@ //! //! [`restart`] is when a process that ended is started again, [`claims`] which //! claims a start mints, [`drives`] which PCI functions a `drives` row has -//! a process on, and [`storage`] which partitions a file server's role reaches. +//! a process on, [`storage`] which partitions a file server's role reaches, +//! and [`sessions`] when a login session may be opened and signed out. pub mod claims; pub mod drives; pub mod restart; pub mod storage; +pub mod sessions; diff --git a/system/supervisor/lifecycle/src/sessions.rs b/system/supervisor/lifecycle/src/sessions.rs new file mode 100644 index 00000000000..b885b98f306 --- /dev/null +++ b/system/supervisor/lifecycle/src/sessions.rs @@ -0,0 +1,182 @@ +//! Login sessions, as far as the supervisor decides about them: whether a +//! login authority may open one, and which of them a user's `logout` may sign +//! out. Pure. +//! +//! **Only a reopen after a failure is budgeted.** A session owner's failure is +//! an end the supervisor did not ask for and no sign-out preceded; an open by +//! the same login authority after one spends [`Reopens`]'s budget, which is +//! [`crate::restart::Budget`]. An open with no failure before it is never +//! counted. + +use std::collections::BTreeMap; +use std::time::Instant; + +use toyos_abi::end::End; +use toyos_manifest::launch::Session; + +use crate::restart::Budget; + +/// The session a launch an owner handed over runs in: its link's, never what +/// the connection's badge names, which must be the same one. +pub fn handed_over(link: Session, badge: Session) -> Result { + match badge == link { + true => Ok(link), + false => Err(badge), + } +} + +/// Where an app launched detached is spawned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Placed { + /// In the machine's session, which no owner holds. + UnderSupervisor, + /// In a login session, under its owner: the owner's end takes it down. + UnderOwner, + /// In a login session whose owner has given no place yet: refused. + Unplaced, +} + +/// Where a detached app goes, in a login session or the machine's, with its +/// owner's place given or not. **Never the supervisor in a login session**: +/// the session's apps end with its owner. +pub fn detached(in_login: bool, owner_placed: bool) -> Placed { + match (in_login, owner_placed) { + (false, _) => Placed::UnderSupervisor, + (true, true) => Placed::UnderOwner, + (true, false) => Placed::Unplaced, + } +} + +/// Whether a session whose owner ended as `end` was signed out: its owner +/// exits 0 once it has signed the session out, and any other end is a +/// failure, which ends the session all the same. +pub fn signed_out(end: End) -> bool { + end == End::Exited(0) +} + +/// The budget each login authority's opens after a failure spend. +#[derive(Default, Debug)] +pub struct Reopens { + owed: BTreeMap, +} + +/// An open refused because its authority's budget is spent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Exhausted; + +impl Reopens { + /// `opener`'s session ended: by a sign-out, or a failure. + pub fn ended(&mut self, opener: &str, signed_out: bool) { + self.owed.entry(opener.to_string()).or_default().0 = !signed_out; + } + + /// Whether `opener` may open a session at `now`, spending its budget where + /// its last session failed. + pub fn open(&mut self, opener: &str, now: Instant) -> Result<(), Exhausted> { + let Some((failed, budget)) = self.owed.get_mut(opener) else { return Ok(()) }; + if !*failed { + return Ok(()); + } + match budget.spend(now) { + true => { + *failed = false; + Ok(()) + } + false => Err(Exhausted), + } + } +} + +/// Why `logout` may not sign a session out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refused { + NoSuchSession, + NotYours, +} + +/// Whether `user` may sign out the session `share` names, among `sessions`, +/// each a share and the user it is of. A session is its own user's alone. +pub fn sign_out_target(sessions: &[(u64, &str)], user: &str, share: u64) -> Result<(), Refused> { + match sessions.iter().find(|(s, _)| *s == share) { + None => Err(Refused::NoSuchSession), + Some((_, of)) if *of != user => Err(Refused::NotYours), + Some(_) => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::restart::{RESTARTS, WINDOW}; + use std::time::Duration; + + /// Opens with no failure before them are never counted; reopens after a + /// failure spend the budget, which refuses the fourth inside its window + /// and is whole again after it, and a sign-out spends nothing. + #[test] + fn only_a_reopen_after_a_failure_spends() { + let mut reopens = Reopens::default(); + let now = Instant::now(); + for _ in 0..10 * RESTARTS { + assert_eq!(reopens.open("sshserver", now), Ok(())); + reopens.ended("sshserver", true); + } + reopens.ended("compositor", false); + for _ in 0..RESTARTS { + assert_eq!(reopens.open("compositor", now), Ok(())); + reopens.ended("compositor", false); + } + assert_eq!(reopens.open("compositor", now), Err(Exhausted)); + let later = now + WINDOW + Duration::from_millis(1); + assert_eq!(reopens.open("compositor", later), Ok(())); + } + + /// An owner hands over only its own session's launches: a badge naming + /// another session is refused, whichever it names. + #[test] + fn a_handed_over_launch_runs_in_its_links_session() { + use toyos_manifest::launch::{Screen, Sessions}; + let mut sessions = Sessions::default(); + let (mine, other, machine) = (sessions.open(Screen::Not), sessions.open(Screen::Held), sessions.machine()); + assert_eq!(handed_over(mine, mine), Ok(mine)); + assert_eq!(handed_over(mine, other), Err(other)); + assert_eq!(handed_over(mine, machine), Err(machine)); + } + + /// A login session's detached apps are its owner's, never the + /// supervisor's, so the owner's end takes them down. + #[test] + fn a_detached_app_is_placed_under_its_sessions_owner() { + assert_eq!(detached(true, true), Placed::UnderOwner); + assert_eq!(detached(true, false), Placed::Unplaced); + assert_eq!(detached(false, true), Placed::UnderSupervisor); + assert_eq!(detached(false, false), Placed::UnderSupervisor); + } + + /// An owner's end other than its sign-out's is the session's end too, + /// and a failure its authority's next open spends the budget for. + #[test] + fn an_owner_that_ends_unasked_ends_its_session_as_a_failure() { + use toyos_abi::end::Fault; + assert!(signed_out(End::Exited(0))); + let mut reopens = Reopens::default(); + let now = Instant::now(); + for end in [End::Exited(1), End::Killed, End::Fault(Fault::Memory)] { + assert!(!signed_out(end), "{end:?}"); + reopens.ended("compositor", signed_out(end)); + } + for _ in 0..RESTARTS { + assert_eq!(reopens.open("compositor", now), Ok(())); + reopens.ended("compositor", signed_out(End::Killed)); + } + assert_eq!(reopens.open("compositor", now), Err(Exhausted)); + } + + #[test] + fn a_user_signs_out_their_own_sessions_alone() { + let sessions = [(3, "toy"), (4, "guest")]; + assert_eq!(sign_out_target(&sessions, "toy", 3), Ok(())); + assert_eq!(sign_out_target(&sessions, "toy", 4), Err(Refused::NotYours)); + assert_eq!(sign_out_target(&sessions, "toy", 5), Err(Refused::NoSuchSession)); + } +} diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index 6686c4e9e89..99c5bef172f 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -87,6 +87,8 @@ //! that asks for the supervisor is the one way to outlive the caller, and one naming //! neither is refused. +mod sessions; + use std::cell::{Cell, RefCell}; use std::collections::BTreeMap; use std::os::toyos::process::{ChildExt, CommandExt}; @@ -188,6 +190,10 @@ enum Port { Launcher(Caller), /// Who asks to read or change the grants, off the connection's badge. Grants(Caller), + /// The login authority asking for a session, off its badge. + Login(&'static Program), + /// The `logout` asking about its user's sessions, off its badge. + Admin(Caller), Swap, Power, } @@ -198,6 +204,26 @@ enum Port { struct Minted { launcher: Acceptor, grants: Acceptor, + /// [`toyos_session::LOGIN_PORT`], minted with a `login` row's key. + login: Acceptor, + /// [`toyos_session::ADMIN_PORT`], minted with its holder's row and session. + admin: Acceptor, +} + +/// What one start is minted: the session its files and grants are in, and +/// the launcher port its launches are minted on with their session. +#[derive(Clone, Copy)] +struct Minting<'m> { + minted: &'m Minted, + session: Session, + launches: (&'m Acceptor, Session), +} + +impl<'m> Minting<'m> { + /// A start whose launches are in its own session, on the machine's launcher. + fn own(minted: &'m Minted, session: Session) -> Self { + Self { minted, session, launches: (&minted.launcher, session) } + } } /// The poll tokens for the `launcher`, `swap`, `power` and `grants` acceptors, for the @@ -215,12 +241,20 @@ const TOKEN_GRANTS_ACCEPTOR: u64 = 5; /// The parked launch's caller, and its question's connection ([`Parked`]). const TOKEN_PARKED_CALLER: u64 = 6; const TOKEN_CONSENT: u64 = 7; -const TOKEN_PENDING_BASE: u64 = 8; +const TOKEN_LOGIN_ACCEPTOR: u64 = 8; +const TOKEN_ADMIN_ACCEPTOR: u64 = 9; +/// Every session owner's link, and the ends of every session's apps. +const TOKEN_LINKS: u64 = 10; +const TOKEN_APPS: u64 = 11; +const TOKEN_PENDING_BASE: u64 = 12; /// The processes whose end the supervisor watches at once: its services and /// its `drives` processes, in what a poller holds beside the loop's own /// watches. A `drives` process past it is not started, and says so. -const MAX_CHILDREN: usize = Poller::MAX_HANDLES as usize - TOKEN_PENDING_BASE as usize - MAX_PENDING_LAUNCHES; +const MAX_CHILDREN: usize = Poller::MAX_HANDLES as usize + - TOKEN_PENDING_BASE as usize + - MAX_PENDING_LAUNCHES + - 2 * sessions::MAX_SESSIONS; /// How long a stop waits for `logkeeper` to say the log is whole. /// @@ -451,7 +485,9 @@ fn main() { // with that program's row. let (launcher, _) = port::create().expect("supervisor: no port for `launcher`"); let (grants_port, grants_connector) = port::create().expect("supervisor: no port for `grants`"); - let minted = Minted { launcher, grants: grants_port }; + let (login, _) = port::create().expect("supervisor: no port for `login`"); + let (admin, _) = port::create().expect("supervisor: no port for `session-admin`"); + let minted = Minted { launcher, grants: grants_port, login, admin }; let mut sessions = Sessions::default(); // SYSTEM, from the file server's boot image: what serves `/system`. No @@ -507,7 +543,7 @@ fn main() { .served_names() .into_iter() .chain(system.supervisor_serves.iter().map(String::as_str)) - .filter(|name| *name != grants::PORT); + .filter(|name| ![grants::PORT, toyos_session::ADMIN_PORT].contains(name)); for name in names { let (acceptor, connector) = port::create().unwrap_or_else(|e| panic!("supervisor: no port for `{name}`: {e:?}")); @@ -548,6 +584,8 @@ fn main() { log, worker: Worker::start(), restarting: false, + logins: sessions::Logins::default(), + watch: sessions::Watch::new(), }; supervisor.boot(&mut role_acceptors); @@ -612,7 +650,7 @@ impl Worker { } }) .expect("supervisor: its file worker could not be started"); - Self { jobs, done, owed: 0, owed_what: String::new(), poller: Poller::new(1 + MAX_CHILDREN as u32) } + Self { jobs, done, owed: 0, owed_what: String::new(), poller: Poller::new((1 + MAX_CHILDREN + sessions::MAX_SESSIONS) as u32) } } /// Count the jobs that have finished since last asked. @@ -687,6 +725,10 @@ struct Supervisor<'a> { /// Inside [`Supervisor::ended`]: a file call made there leaves an end for /// the loop, which is the one caller that acts on it. restarting: bool, + /// The login sessions open, each with its owner. + logins: sessions::Logins, + /// The ends of every app started in a login session. + watch: sessions::Watch, } /// One `drives` process, and the function it holds. @@ -725,6 +767,8 @@ struct Service<'a> { session: Session, /// The starts after an end it has spent. budget: Budget, + /// The screen's row's desktop session, which each start of it holds. + desktop: Option, } impl<'a> Service<'a> { @@ -739,6 +783,7 @@ impl<'a> Service<'a> { swapping: false, session, budget: Budget::default(), + desktop: None, } } @@ -774,6 +819,14 @@ impl<'a> Service<'a> { if is_block(self.program) { storage.args.extend(block); } + let launches = match &self.desktop { + Some(desktop) => { + let control = desktop.control().map_err(std::io::Error::other)?; + storage.endow.push((toyos_session::CONTROL_LABEL, control.into_raw())); + (&*desktop.launcher, desktop.session) + } + None => (&minted.launcher, self.session), + }; let ports = self.ports.borrow(); let served = Served::Keep { acceptors: &ports, owed }; let command = match source { @@ -794,7 +847,7 @@ impl<'a> Service<'a> { grants, &[], storage, - (minted, self.session), + Minting { minted, session: self.session, launches }, Output::Boot(log), )?; drop(ports); @@ -947,6 +1000,7 @@ impl<'a> Supervisor<'a> { homes_made = true; } self.make_state_dir(program); + self.desktop_for(at); let block = self.block_args(); let service = &mut self.services[at]; // SYSTEM was started before the manifest was read. @@ -1056,7 +1110,7 @@ impl<'a> Supervisor<'a> { extra.stop = Some(read); stop = Some(write); } - let launcher = (&self.minted, self.sessions.machine()); + let launcher = Minting::own(&self.minted, self.sessions.machine()); let served = Served::Keep { acceptors: &[], owed: &[] }; let output = Output::Boot(&mut self.log); let path = row.source.path().ok_or_else(|| format!("{} runs {}, and a `drives` row runs a file", row.name, row.source))?; @@ -1317,6 +1371,12 @@ impl<'a> Supervisor<'a> { loop { poller.watch(&self.minted.launcher, READABLE, TOKEN_ACCEPTOR); poller.watch(&self.minted.grants, READABLE, TOKEN_GRANTS_ACCEPTOR); + poller.watch(&self.minted.login, READABLE, TOKEN_LOGIN_ACCEPTOR); + poller.watch(&self.minted.admin, READABLE, TOKEN_ADMIN_ACCEPTOR); + poller.watch(&self.watch.ended, READABLE, TOKEN_APPS); + for login in &self.logins.open { + poller.watch(&login.link, READABLE, TOKEN_LINKS); + } poller.watch(swap, READABLE, TOKEN_SWAP_ACCEPTOR); poller.watch(power, READABLE, TOKEN_POWER_ACCEPTOR); self.watch_children(&poller); @@ -1360,6 +1420,8 @@ impl<'a> Supervisor<'a> { (|s, c| s.caller(&s.minted.launcher, c).map(Port::Launcher)) as fn(&Self, &Connection) -> _, ), (TOKEN_GRANTS_ACCEPTOR, &self.minted.grants, |s, c| s.caller(&s.minted.grants, c).map(Port::Grants)), + (TOKEN_LOGIN_ACCEPTOR, &self.minted.login, |s, c| s.opener(c).map(Port::Login)), + (TOKEN_ADMIN_ACCEPTOR, &self.minted.admin, |s, c| s.caller(&s.minted.admin, c).map(Port::Admin)), (TOKEN_SWAP_ACCEPTOR, swap, |_, _| Ok(Port::Swap)), (TOKEN_POWER_ACCEPTOR, power, |_, _| Ok(Port::Power)), ] { @@ -1426,6 +1488,8 @@ impl<'a> Supervisor<'a> { self.serve_launch(p.conn, &caller, msg_type, &payload, carried, &mut parked) } Port::Grants(caller) => self.serve_grants(&p.conn, &caller, msg_type, p.rx.payload(payload_len)), + Port::Login(opener) => self.serve_login(&p.conn, opener, msg_type), + Port::Admin(caller) => self.serve_admin(&p.conn, &caller, msg_type, p.rx.payload(payload_len)), Port::Power => self.stop(&p.conn, msg_type), Port::Swap => { let payload = p.rx.payload(payload_len).to_vec(); @@ -1456,12 +1520,26 @@ impl<'a> Supervisor<'a> { let (caller_ready, consent_ready) = (ready.contains(&TOKEN_PARKED_CALLER), ready.contains(&TOKEN_CONSENT)); parked = parked.and_then(|asked| self.advance_parked(asked, caller_ready, consent_ready)); + if ready.contains(&TOKEN_LINKS) { + self.pump_links(&mut pending); + } + if ready.contains(&TOKEN_APPS) { + self.apps_ended(); + } if ready.contains(&TOKEN_ENDED) { self.ended(); } } } + /// The `login` row the badge of a connection on [`Minted::login`] names. + fn opener(&self, conn: &Connection) -> Result<&'static Program, String> { + let mut badge = [0u8; MAX_BADGE]; + let badge = self.minted.login.badge(conn, &mut badge).map_err(|e| format!("its connection has no badge ({e:?})"))?; + let row = std::str::from_utf8(badge).map_err(|_| format!("its badge is not one this supervisor writes: {badge:?}"))?; + self.system.program(row).filter(|p| p.login).ok_or_else(|| format!("its badge names `{row}`, which is no `login` row")) + } + /// The row and session the badge of a connection on `acceptor`, one of /// [`Minted`]'s, names. Only this supervisor mints on them, so a refusal /// here is its own bug, said and the connection dropped. @@ -1590,6 +1668,7 @@ impl<'a> Supervisor<'a> { let owed = self.services[index].devices.clone(); let program = self.services[index].program; self.make_state_dir(program); + self.desktop_for(index); let block = self.block_args(); let service = &mut self.services[index]; swapped( @@ -1802,7 +1881,7 @@ impl<'a> Supervisor<'a> { /// end, but a swapping service's: its swap watches that. fn watch_children(&self, poller: &Poller) { let services = self.services.iter().filter(|service| !service.swapping).filter_map(|service| service.child.as_ref()); - for child in services.chain(self.instances.iter().map(|instance| &instance.child)) { + for child in services.chain(self.instances.iter().map(|instance| &instance.child)).chain(self.owners()) { poller.watch_raw(toyos::RawHandle(child.as_raw_handle()), READABLE, TOKEN_ENDED); } } @@ -1834,6 +1913,7 @@ impl<'a> Supervisor<'a> { AfterEnd::Restart => { let (source, owed, program) = (service.source.clone(), service.devices.clone(), service.program); self.make_state_dir(program); + self.desktop_for(index); let block = self.block_args(); let service = &mut self.services[index]; let (system, syscap, connectors, grants) = (self.system, self.syscap, &self.connectors, &self.grants); @@ -1861,6 +1941,7 @@ impl<'a> Supervisor<'a> { if any { self.drive(); } + self.sessions_ended(); self.restarting = false; } @@ -1869,6 +1950,7 @@ impl<'a> Supervisor<'a> { fn restore(&mut self, index: usize, previous: &Source, owed: &[String]) { let program = self.services[index].program; self.make_state_dir(program); + self.desktop_for(index); let block = self.block_args(); let service = &mut self.services[index]; let name = service.program.name.clone(); @@ -2055,7 +2137,15 @@ impl Supervisor<'_> { // detached, which would let it outlive a caller that never asked it to. let under = match request.parent() { Some(Parent::Place(())) => Some(place.0[0]), - Some(Parent::Detached) => None, + // Under the session's owner, or in the machine's session the supervisor. + Some(Parent::Detached) => match self.logins.detached_place(caller.session) { + Ok(place) => place, + Err(why) => { + say!("supervisor: launcher: refused a detached launch: {why}"); + let _ = conn.try_signal(launch::MSG_REFUSED); + return; + } + }, None => { say!("supervisor: launcher: refused a launch that names no parent"); let _ = conn.try_signal(launch::MSG_REFUSED); @@ -2103,7 +2193,7 @@ 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 starts = authority::may_start(row, session, target)?; + authority::may_start(row, session, target)?; let (Target::Row(program) | Target::Package(program)) = target; let prepared = match program.source.path() { Some(path) => command.image_from(Path::new(path)).prepare().map(drop), @@ -2115,7 +2205,7 @@ impl Supervisor<'_> { .map(|bytes| toyos_swap::hex(&toyos_swap::digest(bytes))) .ok_or_else(|| format!("{} was not read before its start, so no grant can name it", program.source)) }); - Ok((starts, binary, prepared.map(|()| command))) + Ok((binary, prepared.map(|()| command))) }) }); let resolved = match found { @@ -2143,7 +2233,7 @@ impl Supervisor<'_> { return; } }; - let (starts, binary, prepared) = match verdict { + let (binary, prepared) = match verdict { Ok(judged) => judged, Err(why) => { say!("supervisor: launcher: {} in {} may not start {}: {why}", caller.row.name, caller.session, program.name); @@ -2167,7 +2257,7 @@ impl Supervisor<'_> { return; } }; - let session = starts.session(&mut self.sessions); + let session = caller.session; let caller_fds: Option> = request .fds() .zip(fds.0.iter().copied()) @@ -2324,6 +2414,8 @@ impl Supervisor<'_> { } } let storage = self.slot_storage(program); + let in_login = self.logins.launcher_of(session); + let launches = (in_login.as_deref().unwrap_or(&self.minted.launcher), session); let started = start( command, program, @@ -2334,12 +2426,13 @@ impl Supervisor<'_> { &self.grants, &extras, storage, - (&self.minted, session), + Minting { minted: &self.minted, session, launches }, Output::Launch { log: &mut self.log, fds: &caller_fds, job: job.take()[0] }, ); drop(fds); match started { Ok((child, _)) => { + self.started_in(session, &program.name, toyos::RawHandle(child.as_raw_handle())); // SAFETY: `into_raw_handle` gave up the child's one handle in this table. let handle = unsafe { toyos::OwnedHandle::from_raw(toyos::RawHandle(child.into_raw_handle())) }; // Consumed either way, so the supervisor keeps no `Process` handle from a @@ -2672,10 +2765,10 @@ fn start<'a>( grants: &Grants<'_>, extras: &[(String, Connector)], extra: Extra, - launcher: (&Minted, Session), + minting: Minting<'_>, output: Output<'_>, ) -> Result<(Child, Vec), StartError> { - let Extra { args, claims, root, ports, stop } = extra; + let Extra { args, claims, root, ports, stop, endow } = extra; // A storage row's own arguments first: a file server's role leads its argv. command.args(&args); command.args(&program.args); @@ -2722,9 +2815,13 @@ fn start<'a>( command.endow(ROOT_IMAGE_LABEL, root.0); held.0.push(root); } - let mut view = grants.view(program, launcher.1); + for (label, handle) in endow { + command.endow(label, handle.0); + held.0.push(handle); + } + let mut view = grants.view(program, minting.session); view.extend(ports); - view.extend(grants.netstack(program, launcher.1)); + view.extend(grants.netstack(program, minting.session)); if let Some(ns) = build_namespace(program, system, connectors, view, extras)? { let raw = ns.into_raw(); command.endow(SVC_LABEL, raw.0); @@ -2735,14 +2832,24 @@ fn start<'a>( command.endow(toyos_swap::LABEL, raw.0); held.0.push(raw); } - if let Some(ns) = launcher_namespace(program, launcher) { + if let Some(ns) = launcher_namespace(program, minting.launches) { let raw = ns.into_raw(); command.endow(LAUNCHER, raw.0); held.0.push(raw); } - if let Some(ns) = grants_namespace(program, launcher) { - let raw = ns.into_raw(); - command.endow(grants::PORT, raw.0); + let minted = minting.minted; + let badged = [ + (grants::PORT, program.receives.iter().any(|name| name == grants::PORT), &minted.grants), + (toyos_session::ADMIN_PORT, program.receives.iter().any(|name| name == toyos_session::ADMIN_PORT), &minted.admin), + ]; + for (name, _, acceptor) in badged.into_iter().filter(|(_, receives, _)| *receives) { + let raw = badged_namespace(program, name, acceptor, minting.session).into_raw(); + command.endow(name, raw.0); + held.0.push(raw); + } + if program.login { + let raw = login_namespace(program, &minted.login).into_raw(); + command.endow(toyos_session::LOGIN_PORT, raw.0); held.0.push(raw); } @@ -2994,7 +3101,7 @@ fn build_namespace( extras: &[(String, Connector)], ) -> std::io::Result> { // Never the swap port nor the grants port: [`swap_namespace`] and - // [`grants_namespace`] say why. Never the block port's own connector, + // [`badged_namespace`] say why. Never the block port's own connector, // which reaches nothing: a holder of `block` holds one minted for what it // reaches, in `view`, and a disk driver one of `disk` minted for who it // is; nor netstack's, which nothing holds: a holder of `netstack` holds @@ -3003,7 +3110,8 @@ fn build_namespace( .receives .iter() .filter(|name| { - ![toyos_swap::PORT, grants::PORT, toyos_blockring::PORT, toyos_blockring::disk::PORT, NETSTACK].contains(&name.as_str()) + ![toyos_swap::PORT, grants::PORT, toyos_session::ADMIN_PORT, toyos_blockring::PORT, toyos_blockring::disk::PORT, NETSTACK] + .contains(&name.as_str()) }) .collect(); if receives.is_empty() && extras.is_empty() && view.is_empty() { @@ -3181,43 +3289,43 @@ fn swap_namespace(program: &Program, connectors: &BTreeMap<&str, Connector>) -> Some(ns) } -/// A `launcher` minted with `program`'s row and the session it runs in, in a -/// namespace of its own, for a row whose `starts` lists anything; endowed under -/// [`LAUNCHER`] and never an entry of `svc`, for [`swap_namespace`]'s reason. -fn launcher_namespace(program: &Program, (minted, session): (&Minted, Session)) -> Option { +/// A `launcher` minted with `program`'s row and the session its launches run +/// in, on that session's launcher port, in a namespace of its own, for a row +/// whose `starts` lists anything; endowed under [`LAUNCHER`] and never an +/// entry of `svc`, for [`swap_namespace`]'s reason. +fn launcher_namespace(program: &Program, (launcher, session): (&Acceptor, Session)) -> Option { if program.starts.is_empty() { return None; } + Some(badged_namespace(program, LAUNCHER, launcher, session)) +} + +/// `name`, minted on `acceptor` with `program`'s row and `session`, in a +/// namespace of its own: endowed under that name and never an entry of `svc`, +/// for [`swap_namespace`]'s reason. The badge is who asks: the supervisor +/// honours a login session's alone on [`grants::PORT`] and +/// [`toyos_session::ADMIN_PORT`]. +fn badged_namespace(program: &Program, name: &str, acceptor: &Acceptor, session: Session) -> Namespace { let badge = Authority { row: program.name.clone(), session }.encode(); - let connector = minted - .launcher + let connector = acceptor .mint(&badge) - .unwrap_or_else(|e| panic!("supervisor: no launcher for {}: {e:?}", program.name)); - let ns = namespace::build() - .add(LAUNCHER, &connector) + .unwrap_or_else(|e| panic!("supervisor: no `{name}` connector for {}: {e:?}", program.name)); + namespace::build() + .add(name, &connector) .finish() - .unwrap_or_else(|e| panic!("supervisor: no launcher namespace for {}: {e:?}", program.name)); - Some(ns) + .unwrap_or_else(|e| panic!("supervisor: no `{name}` namespace for {}: {e:?}", program.name)) } -/// [`grants::PORT`] minted with `program`'s row and the session it runs in, in -/// a namespace of its own, for the row whose `receives` names it; endowed under -/// that name and never an entry of `svc`, for [`swap_namespace`]'s reason. -/// The badge is who asks: the supervisor honours a login session's alone. -fn grants_namespace(program: &Program, (minted, session): (&Minted, Session)) -> Option { - if !program.receives.iter().any(|name| name == grants::PORT) { - return None; - } - let badge = Authority { row: program.name.clone(), session }.encode(); - let connector = minted - .grants - .mint(&badge) - .unwrap_or_else(|e| panic!("supervisor: no grants connector for {}: {e:?}", program.name)); - let ns = namespace::build() - .add(grants::PORT, &connector) +/// [`toyos_session::LOGIN_PORT`] minted with a `login` row's key, the login +/// authority whose sessions it opens. +fn login_namespace(program: &Program, login: &Acceptor) -> Namespace { + let connector = login + .mint(program.name.as_bytes()) + .unwrap_or_else(|e| panic!("supervisor: no `login` connector for {}: {e:?}", program.name)); + namespace::build() + .add(toyos_session::LOGIN_PORT, &connector) .finish() - .unwrap_or_else(|e| panic!("supervisor: no grants namespace for {}: {e:?}", program.name)); - Some(ns) + .unwrap_or_else(|e| panic!("supervisor: no `login` namespace for {}: {e:?}", program.name)) } /// One of the supervisor's words on a swap: its line in the log. @@ -3238,6 +3346,8 @@ struct Extra { ports: Vec<(String, Connector)>, /// A disk driver's end of its [`toyos_quiesce::DRIVER_STOP`] pipe. stop: Option, + /// Handles endowed by label, owned by the start until the spawn moves them. + endow: Vec<(&'static str, toyos::RawHandle)>, } /// Every record the kernel's inventory answers. diff --git a/system/supervisor/src/sessions.rs b/system/supervisor/src/sessions.rs new file mode 100644 index 00000000000..1fb3c71dd34 --- /dev/null +++ b/system/supervisor/src/sessions.rs @@ -0,0 +1,497 @@ +//! Login sessions: each one's owner, `/system/bin/session`, which the +//! supervisor starts as its child, and what the supervisor does for it. +//! +//! **The supervisor opens a session for a `login` row**: for the screen's row +//! as it starts it, endowing that session's launcher and a control connection +//! ([`Desktop`]), and for any `login` row that asks on [`wire::LOGIN_PORT`]. +//! Each session has a launcher port of its own; the supervisor mints its +//! badges and the owner accepts on it, handing each connection back over the +//! owner's link with its badge ([`wire::MSG_HANDOVER`]). **The session is the +//! link's, never the badge's or the request's.** +//! +//! **Every app started in a session is the supervisor's to watch** ([`Watch`]): +//! it tells the owner of each start, with a handle that may quit and kill it, +//! and of each end. A detached app is placed under the owner, so the owner's +//! end takes the session's apps down. +//! +//! **A session ends with its owner.** Ended by exit 0, the owner signed it out; +//! any other end is a failure, after which its login authority's next open +//! spends a budget ([`Reopens`]). + +use std::process::{Child, Command}; +use std::rc::Rc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc}; +use std::time::Instant; + +use toyos::ipc::{self, Connection, RxStep}; +use toyos::launch::LAUNCHER; +use toyos::namespace::{self, Namespace}; +use toyos::poller::{Poller, READABLE}; +use toyos::port::{self, Acceptor}; +use toyos::{say, AsHandle, OwnedHandle, Pipe, RawHandle}; +use toyos_abi::syscall; +use toyos_abi::Rights; +use toyos_manifest::launch::{Authority, Screen, Session}; +use toyos_manifest::Program; +use toyos_session::{self as wire, Listed, Names, SignOut, SignOutSession, Started, State}; +use toyos_supervisor::sessions::{self as decide, Reopens}; + +use crate::{exit_of, start, Caller, Extra, LaunchRx, Minting, Output, Pending, Port, Served, Supervisor}; + +/// The name a session's controls namespace holds its control port under. +const CONTROL: &str = "control"; + +/// The login sessions the supervisor watches the owners of at once. Each +/// owner is a process its loop watches, beside its services and `drives` +/// processes. +pub(crate) const MAX_SESSIONS: usize = 16; + +/// One open login session. +pub(crate) struct Login { + pub session: Session, + /// The login authority's row that opened it. + opener: &'static Program, + pub owner: Child, + /// The supervisor's end of the owner's link. + pub link: Connection, + rx: LaunchRx, + /// The owner's handle to itself, which places a detached app under it. + place: Option, + /// The session's launcher port, which every launcher in it is minted on. + pub launcher: Rc, + state: State, + waiting: Names, +} + +/// The desktop session, as the screen's row is started holding it. +#[derive(Clone)] +pub(crate) struct Desktop { + pub launcher: Rc, + pub session: Session, + pub controls: Rc, +} + +impl Desktop { + /// A control connection to the session, for one start of the screen's row. + pub fn control(&self) -> Result { + self.controls.open(CONTROL).map_err(|e| format!("the desktop session's control port refused ({e:?})")) + } +} + +#[derive(Default)] +pub(crate) struct Logins { + pub open: Vec, + /// The next app's instance: never reused in a boot. + next_instance: u64, + reopens: Reopens, +} + +impl Logins { + fn of(&self, session: Session) -> Option<&Login> { + self.open.iter().find(|login| login.session == session) + } + + /// Where an app detached in `session` is placed: under that session's + /// owner, `Ok(None)` in the machine's session, which is under the + /// supervisor, and `Err` in a login session whose owner has given no place. + pub fn detached_place(&self, session: Session) -> Result, &'static str> { + let place = self.of(session).and_then(|login| login.place.as_ref()); + match decide::detached(matches!(session, Session::Login(..)), place.is_some()) { + decide::Placed::UnderSupervisor => Ok(None), + decide::Placed::UnderOwner => Ok(place.map(|place| place.as_handle())), + decide::Placed::Unplaced => Err("its session's owner gave no place to detach under"), + } + } + + /// The launcher port `session`'s launches are minted on, for a login one. + pub fn launcher_of(&self, session: Session) -> Option> { + self.of(session).map(|login| Rc::clone(&login.launcher)) + } +} + +/// The ends of every app started in a login session, watched off the loop. +/// +/// **One thread per [`PER_WATCHER`] apps**, each waiting on its own poller, so +/// the set is bounded by the process table and not by one poller's width. An +/// end reaches the loop through one pipe, which it watches under +/// `TOKEN_INSTANCES`. +pub(crate) struct Watch { + /// Readable once per end. + pub ended: Pipe, + notify: Arc, + ends: mpsc::Receiver<(u64, Session)>, + sender: mpsc::Sender<(u64, Session)>, + watchers: Vec, +} + +/// What one watcher thread's poller holds beside its wake pipe. +const PER_WATCHER: usize = Poller::MAX_HANDLES as usize - 1; +const WAKE: u64 = u64::MAX; + +struct Watcher { + add: mpsc::Sender<(u64, Session, OwnedHandle)>, + wake: Pipe, + held: Arc, +} + +impl Watch { + pub fn new() -> Self { + let (ended, notify) = toyos::pipe_pair().expect("supervisor: no pipe for its apps' ends"); + let (sender, ends) = mpsc::channel(); + Self { ended, notify: Arc::new(notify), ends, sender, watchers: Vec::new() } + } + + /// Watch app `instance` of `session` until it ends. + fn add(&mut self, instance: u64, session: Session, handle: OwnedHandle) { + let at = match self.watchers.iter().position(|w| w.held.load(Ordering::Relaxed) < PER_WATCHER) { + Some(at) => at, + None => { + self.watchers.push(self.watcher()); + self.watchers.len() - 1 + } + }; + let watcher = &self.watchers[at]; + watcher.held.fetch_add(1, Ordering::Relaxed); + watcher.add.send((instance, session, handle)).expect("supervisor: a watcher of its apps has ended"); + let _ = watcher.wake.write_nonblock(&[1]); + } + + fn watcher(&self) -> Watcher { + let (add, added) = mpsc::channel::<(u64, Session, OwnedHandle)>(); + let (woken, wake) = toyos::pipe_pair().expect("supervisor: no pipe for a watcher of its apps"); + let held = Arc::new(AtomicUsize::new(0)); + let (notify, sender, count) = (Arc::clone(&self.notify), self.sender.clone(), Arc::clone(&held)); + std::thread::Builder::new() + .name("apps".into()) + .spawn(move || { + let poller = Poller::new(Poller::MAX_HANDLES); + let mut apps: Vec<(u64, Session, OwnedHandle)> = Vec::new(); + let mut ready = Vec::new(); + loop { + apps.extend(added.try_iter()); + poller.watch(&woken, READABLE, WAKE); + for (i, (_, _, handle)) in apps.iter().enumerate() { + poller.watch_raw(handle.as_handle(), READABLE, i as u64); + } + ready.clear(); + poller.wait(1, u64::MAX, |token| ready.push(token)); + if ready.contains(&WAKE) { + let mut drained = [0u8; 64]; + while woken.read_nonblock(&mut drained).is_ok_and(|n| n > 0) {} + } + let mut at = apps.len(); + while at > 0 { + at -= 1; + if syscall::process_wait_nonblock(apps[at].2.as_handle()).is_err() { + continue; + } + let (instance, session, _) = apps.swap_remove(at); + count.fetch_sub(1, Ordering::Relaxed); + sender.send((instance, session)).expect("supervisor: its loop has ended"); + let _ = notify.write_nonblock(&[1]); + } + } + }) + .expect("supervisor: a watcher of its apps could not be started"); + Watcher { add, wake, held } + } +} + +impl Supervisor<'_> { + /// Open a login session for `opener`, start its owner, and answer the + /// opener's launcher in it and the session's controls. + fn open_session(&mut self, opener: &'static Program) -> Result<(Session, Rc, Rc), String> { + self.logins.reopens.open(&opener.name, Instant::now()).map_err(|_| { + format!( + "its sessions failed {} times in {} s", + toyos_supervisor::restart::RESTARTS + 1, + toyos_supervisor::restart::WINDOW.as_secs() + ) + })?; + if self.logins.open.len() >= MAX_SESSIONS { + return Err(format!("{MAX_SESSIONS} sessions are open")); + } + let owner_row = self + .system + .program(wire::OWNER) + .ok_or_else(|| format!("this image declares no `{}` row to own a session", wire::OWNER))?; + let path = owner_row.source.path().ok_or("the session owner's row runs no file")?; + let session = self.sessions.open(Screen::of(opener)); + let (launcher, _) = port::create().map_err(|e| format!("no launcher port ({e:?})"))?; + let (controls_acceptor, controls_connector) = port::create().map_err(|e| format!("no control port ({e:?})"))?; + let (link_acceptor, link_connector) = port::create().map_err(|e| format!("no link ({e:?})"))?; + let links = namespace::build().add(CONTROL, &link_connector).finish().map_err(|e| format!("{e:?}"))?; + let link = links.open(CONTROL).map_err(|e| format!("the link would not open ({e:?})"))?; + let owner_end = link_acceptor.accept().map_err(|e| format!("the link was not accepted ({e:?})"))?; + let controls = namespace::build().add(CONTROL, &controls_connector).finish().map_err(|e| format!("{e:?}"))?; + let accepting = syscall::dup(launcher.as_handle()).map_err(|e| format!("no copy of its launcher ({e:?})"))?; + let extra = Extra { + endow: vec![ + (wire::LINK_LABEL, owner_end.into_raw()), + (wire::LAUNCHER_ACCEPTOR_LABEL, accepting), + (wire::CONTROL_ACCEPTOR_LABEL, controls_acceptor.into_raw()), + ], + ..Extra::default() + }; + self.make_state_dir(owner_row); + let minting = Minting { minted: &self.minted, session, launches: (&launcher, session) }; + let served = Served::Keep { acceptors: &[], owed: &[] }; + let (owner, _) = start( + Command::new(path), + owner_row, + self.system, + self.syscap, + served, + &self.connectors, + &self.grants, + &[], + extra, + minting, + Output::Boot(&mut self.log), + ) + .map_err(|e| format!("its owner did not start: {e}"))?; + say!("supervisor: session {} opened for {}, owned by pid {}", session.share(), opener.name, owner.id()); + let (launcher, controls) = (Rc::new(launcher), Rc::new(controls)); + self.logins.open.push(Login { + session, + opener, + owner, + link, + rx: LaunchRx::new(), + place: None, + launcher: Rc::clone(&launcher), + state: State::Active, + waiting: Names::default(), + }); + Ok((session, launcher, controls)) + } + + /// The desktop session the screen's row at `index` starts holding: the one + /// it held, while that session lives, or one opened for it now. + pub(crate) fn desktop_for(&mut self, index: usize) { + let program = self.services[index].program; + if !(program.login && Screen::of(program) == Screen::Held) { + return; + } + let program = self.system.program(&program.name).expect("a `login` row is the manifest's"); + let alive = self.services[index] + .desktop + .as_ref() + .is_some_and(|desktop| self.logins.of(desktop.session).is_some()); + if alive { + return; + } + self.services[index].desktop = match self.open_session(program) { + Ok((session, launcher, controls)) => Some(Desktop { launcher, session, controls }), + Err(why) => { + say!("supervisor: {} starts with no desktop session: {why}", program.name); + None + } + }; + } + + /// One request on [`wire::LOGIN_PORT`] from `opener`'s row. + pub(crate) fn serve_login(&mut self, conn: &Connection, opener: &'static Program, msg_type: u32) { + if msg_type != wire::MSG_OPEN { + say!("supervisor: login: {} asked {msg_type}, which is no request", opener.name); + return; + } + let answered = self.open_session(opener).and_then(|(session, launcher, controls)| { + // The screen's row opening its desktop again: a start of it after + // this one is endowed this session. + if Screen::of(opener) == Screen::Held { + for service in self.services.iter_mut().filter(|s| s.program.name == opener.name) { + service.desktop = Some(Desktop { launcher: Rc::clone(&launcher), session, controls: Rc::clone(&controls) }); + } + } + let badge = Authority { row: opener.name.clone(), session }.encode(); + let connector = launcher.mint(&badge).map_err(|e| format!("no launcher was minted ({e:?})"))?; + let ns = namespace::build().add(LAUNCHER, &connector).finish().map_err(|e| format!("{e:?}"))?; + let control = controls.open(CONTROL).map_err(|e| format!("no control connection ({e:?})"))?; + Ok((ns, control)) + }); + match answered { + Ok((ns, control)) => { + // SAFETY: the namespace's own handle, given up here and to nothing else. + let handles = [unsafe { OwnedHandle::from_raw(ns.into_raw()) }, control.into_owned()]; + if let Err(e) = conn.try_signal_with(wire::MSG_OPENED, handles) { + say!("supervisor: login: {}'s session was opened and not answered ({e:?})", opener.name); + } + } + Err(why) => { + say!("supervisor: login: {} was refused a session: {why}", opener.name); + let _ = conn.try_send_bytes(wire::MSG_REFUSED, why.as_bytes()); + } + } + } + + /// Everything each session owner said on its link: its launcher's + /// connections, answered as launches in its session, and its reports. + pub(crate) fn pump_links(&mut self, pending: &mut Vec) { + for at in 0..self.logins.open.len() { + loop { + let login = &mut self.logins.open[at]; + let (msg_type, payload, batch) = match login.rx.pump(&login.link) { + RxStep::Idle | RxStep::Eof => break, + RxStep::Malformed => { + say!("supervisor: session {}'s owner sent a frame its link cannot describe", login.session.share()); + break; + } + RxStep::Frame { msg_type, payload_len, .. } => { + (msg_type, login.rx.payload(payload_len).to_vec(), login.rx.take_batch()) + } + }; + let session = login.session; + match msg_type { + wire::MSG_HANDOVER => match self.handed_over(session, &payload, batch) { + Ok(handed) if pending.len() < crate::MAX_PENDING_LAUNCHES => pending.push(handed), + Ok(_) => say!("supervisor: launcher: refusing a handed-over launch: the table is full"), + Err(why) => say!("supervisor: launcher: a launch session {} handed over is dropped: {why}", session.share()), + }, + wire::MSG_PLACE => match batch.exact::<1>() { + Some([handle]) => { + // SAFETY: moved into this table with the frame, and nothing else answers for it. + self.logins.open[at].place = Some(unsafe { OwnedHandle::from_raw(handle) }); + } + None => say!("supervisor: session {}'s owner sent no place", session.share()), + }, + wire::MSG_WAITING => { + let login = &mut self.logins.open[at]; + login.state = State::Ending; + login.waiting = Names::decode(&payload).unwrap_or_default(); + } + wire::MSG_CANCELLED => { + let login = &mut self.logins.open[at]; + login.state = State::Active; + login.waiting = Names::default(); + } + other => say!("supervisor: session {}'s owner said {other}, which its link has no word for", session.share()), + } + } + } + } + + /// A connection `session`'s owner accepted on its launcher, as a launch + /// waiting for its frame. Its caller is the badge's row; its session is + /// `session`, which the badge must name. + fn handed_over(&self, session: Session, badge: &[u8], batch: ipc::Batch) -> Result { + let [handle] = batch.exact::<1>().ok_or("it carried no single connection")?; + let conn = Connection::received(handle); + let Some(Authority { row, session: named }) = Authority::decode(badge) else { + return Err(format!("its badge is not one this supervisor writes: {badge:?}")); + }; + let session = decide::handed_over(session, named).map_err(|named| format!("its badge names session {}", named.share()))?; + let row = self.system.program(&row).ok_or_else(|| format!("its badge names `{row}`, which is no row"))?; + Ok(Pending { conn, rx: LaunchRx::new(), since: Instant::now(), port: Port::Launcher(Caller { row, session }) }) + } + + /// An app started in a login session: watched, and its owner told. + pub(crate) fn started_in(&mut self, session: Session, name: &str, process: RawHandle) { + let Some(login) = self.logins.open.iter().find(|login| login.session == session) else { return }; + let instance = self.logins.next_instance; + self.logins.next_instance += 1; + let watched = syscall::dup_narrowed(process, Rights::WAIT); + let managed = syscall::dup_narrowed(process, Rights::MANAGE.union(Rights::TRANSFER)); + let (Ok(watched), Ok(managed)) = (watched, managed) else { + return say!("supervisor: {name} in session {} was started and is not watched", session.share()); + }; + let started = Started { instance, name: name.to_string() }; + // SAFETY: a duplicate made above, which nothing else answers for. + let managed = unsafe { OwnedHandle::from_raw(managed) }; + if let Err(e) = login.link.try_send_bytes_with(wire::MSG_STARTED, &started.encode(), [managed]) { + say!("supervisor: session {}'s owner was not told of {name} ({e:?})", session.share()); + } + // SAFETY: as above. + self.watch.add(instance, session, unsafe { OwnedHandle::from_raw(watched) }); + } + + /// Every app end the watch saw, told to its session's owner. + pub(crate) fn apps_ended(&mut self) { + let mut drained = [0u8; 64]; + while self.watch.ended.read_nonblock(&mut drained).is_ok_and(|n| n > 0) {} + while let Ok((instance, session)) = self.watch.ends.try_recv() { + if let Some(login) = self.logins.of(session) { + let _ = login.link.try_send_bytes(wire::MSG_ENDED, &instance.to_le_bytes()); + } + } + } + + /// The owners the loop watches the ends of. + pub(crate) fn owners(&self) -> impl Iterator { + self.logins.open.iter().map(|login| &login.owner) + } + + /// Every session whose owner has ended, closed: its launcher's port with + /// it, and a failure counted against its login authority. + pub(crate) fn sessions_ended(&mut self) { + let mut at = 0; + while at < self.logins.open.len() { + let Some(end) = exit_of(&self.logins.open[at].owner) else { + at += 1; + continue; + }; + let login = self.logins.open.swap_remove(at); + let signed_out = decide::signed_out(end); + self.logins.reopens.ended(&login.opener.name, signed_out); + match signed_out { + true => say!("supervisor: session {} is signed out", login.session.share()), + false => say!("supervisor: session {} ended with its owner, {end}", login.session.share()), + } + } + } + + /// One request on [`wire::ADMIN_PORT`], from `logout`. + pub(crate) fn serve_admin(&mut self, conn: &Connection, caller: &Caller, msg_type: u32, payload: &[u8]) { + let answer = match caller.session { + Session::Machine(_) => Err("only a login session's `logout` acts on sessions".to_string()), + Session::Login(..) => self.admin(msg_type, payload), + }; + let (msg, text) = match answer { + Ok(text) => (if msg_type == wire::MSG_LIST { wire::MSG_SESSIONS } else { wire::MSG_DONE }, text), + Err(why) => { + say!("supervisor: session-admin: refused {} in {}: {why}", caller.row.name, caller.session); + (wire::MSG_REFUSED, why) + } + }; + let _ = conn.try_send_bytes(msg, text.as_bytes()); + } + + fn admin(&mut self, msg_type: u32, payload: &[u8]) -> Result { + // One user signs in: every session is `toyos_manifest::USER`'s. + let user = toyos_manifest::USER; + let sessions: Vec<(u64, &str)> = self.logins.open.iter().map(|l| (l.session.share(), user)).collect(); + match msg_type { + wire::MSG_LIST => { + let lines: Vec = self + .logins + .open + .iter() + .map(|l| Listed { + share: l.session.share(), + desktop: l.session.at_screen(), + state: l.state, + waiting: l.waiting.clone(), + }) + .map(|listed| listed.line()) + .collect(); + Ok(lines.join("\n")) + } + wire::MSG_SIGN_OUT_SESSION => { + let asked = SignOutSession::decode(payload).ok_or("its request is not one `logout` writes")?; + decide::sign_out_target(&sessions, user, asked.share).map_err(|why| match why { + decide::Refused::NoSuchSession => format!("no session {} is open", asked.share), + decide::Refused::NotYours => format!("session {} is another user's", asked.share), + })?; + let login = self.logins.open.iter().find(|l| l.session.share() == asked.share).expect("found above"); + let SignOut { force } = asked.sign_out; + login + .link + .try_send_bytes(wire::MSG_SIGN_OUT, &asked.sign_out.encode()) + .map_err(|e| format!("session {}'s owner was not asked ({e:?})", asked.share))?; + Ok(format!("session {} is asked to sign out{}", asked.share, if force { ", by force" } else { "" })) + } + other => Err(format!("{other} is no request")), + } + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 6aae06f1aef..b92c3162aab 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -12,6 +12,7 @@ pub mod hostedclang; pub mod iommu; pub mod launchstdio; pub mod libcchildren; +pub mod sessionlifecycle; pub mod irqcensus; /// The `isa` claim's rows on the T14. pub mod isa; diff --git a/tests/common/sessionlifecycle.rs b/tests/common/sessionlifecycle.rs new file mode 100644 index 00000000000..4575e2f73c9 --- /dev/null +++ b/tests/common/sessionlifecycle.rs @@ -0,0 +1,112 @@ +//! Login sessions, stage 5 of +//! `issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md`, +//! in one boot of `tests/images/sessioncase.toml` on QEMU x86-64: +//! +//! - **the desktop's**, opened for the compositor as it starts: Ctrl+N's +//! terminal detaches `hangupholder`, typed at its shell, and Ctrl+Alt+Q +//! signs the session out, waiting for the holder; Escape cancels and keeps +//! it, and Enter ends it, the compositor opening another; +//! - **a job's**, opened with test-runner's `login` connector: +//! `session_lifecycle` holds the owner to the same rules from inside, with +//! `logout` listing it, and opens ten in a row unbudgeted. +//! +//! What it claims is order, completion and counts, which QEMU establishes. + +use std::time::Duration; + +use super::qemu::{self, await_marker, await_marker_new, BootOptions, QemuInstance, QmpInput}; +use super::{compile, ps2server}; + +const JOB: &str = "session_lifecycle"; + +/// A hang ceiling: its job's own waits are bounded below it. +const BOUND: Duration = Duration::from_secs(300); + +pub fn session_lifecycle() -> Result<(), String> { + let case = compile::repo_root().join("tests/images/sessioncase.toml"); + let rust_tests = compile::repo_root().join("tests/guest/rust-tests"); + let job = qemu::build_toyos_bin(qemu::SUITE_ARCH, &rust_tests, JOB); + let options = BootOptions { profile: qemu::Profile::Metal, qmp: true, ..Default::default() }; + let mut qemu = QemuInstance::boot_with_options(&case, &[], &[(JOB.to_string(), job)], options); + let mut console = format!("{}\n", qemu.boot_log()); + desktop(&mut qemu, &mut console).map_err(|e| { + let said: Vec<&str> = console.lines().filter(|l| l.contains("compositor") || l.contains("session") || l.contains("hangup")).collect(); + format!("{e}\n--- what the desktop said ---\n{}", said.join("\n")) + })?; + + let result = qemu.run_test(&format!("test_rs_{JOB}"), BOUND); + if let Some(why) = &result.error { + return Err(format!("{why}\nthe job said:\n{}", result.stdout)); + } + if result.exit_code != Some(0) { + return Err(format!("the job ended {:?}:\n{}", result.exit_code, result.stdout)); + } + for said in result.stdout.lines().filter(|l| l.contains(&format!("{JOB}: "))) { + eprintln!(" [{JOB}] {}", said.trim()); + } + match result.stdout.lines().any(|l| l.trim_end().ends_with(&format!("{JOB}: ok"))) { + true => Ok(()), + false => Err(format!("the job never said it was whole:\n{}", result.stdout)), + } +} + +/// The desktop session, driven through its keys. +fn desktop(qemu: &mut QemuInstance, console: &mut String) -> Result<(), String> { + await_marker(qemu, console, "opened for compositor", "the desktop session to open")?; + await_marker(qemu, console, "compositor: ready", "the compositor to take its panel")?; + await_marker(qemu, console, ps2server::ARMED_BOTH, "ps2server to arm the i8042")?; + let press = |qemu: &QemuInstance, chord: &[&str]| { + let mut input = QmpInput::open(qemu.qmp_socket()); + input.keys(&chord.iter().map(|k| (*k, true)).collect::>()); + input.keys(&chord.iter().rev().map(|k| (*k, false)).collect::>()); + }; + + let from = console.len(); + press(qemu, &["ctrl", "n"]); + await_marker_new(qemu, console, "spawn: /system/bin/shell", from, "Ctrl+N's terminal to start its shell")?; + await_marker_new(qemu, console, "terminal: ready", from, "the terminal to take keys")?; + let from = console.len(); + for key in qcodes("detach /system/bin/hangupholder") { + press(qemu, &[key]); + } + press(qemu, &["ret"]); + await_marker_new(qemu, console, "spawn: /system/bin/hangupholder", from, "the shell to detach the holder")?; + + let waiting = "compositor: signing out, waiting for: hangupholder"; + let from = console.len(); + press(qemu, &["ctrl", "alt", "q"]); + await_marker_new(qemu, console, waiting, from, "the sign-out to wait for the holder alone")?; + let from = console.len(); + press(qemu, &["esc"]); + await_marker_new(qemu, console, "compositor: the sign-out is cancelled", from, "Escape to cancel the sign-out")?; + let from = console.len(); + press(qemu, &["ctrl", "alt", "q"]); + await_marker_new(qemu, console, waiting, from, "a second sign-out to wait for the holder")?; + let from = console.len(); + press(qemu, &["ret"]); + await_marker_new(qemu, console, "compositor: signing out, ending what still runs", from, "Enter to force the sign-out")?; + await_marker_new(qemu, console, "is signed out", from, "the forced session to end")?; + let holder_end = console[from..].lines().find(|l| l.contains("exit: hangupholder pid=")).unwrap_or(""); + if !holder_end.contains(" killed ") { + return Err(format!("the force did not kill the holder, which ended: {holder_end:?}")); + } + await_marker_new(qemu, console, "compositor: a desktop session is open again", from, "the desktop to open again")?; + eprintln!(" [{JOB}] the desktop's sign-out waited for the holder, Escape kept it, and Enter ended it"); + Ok(()) +} + +/// QMP's key names for `text`: lowercase letters, `/` and spaces. +fn qcodes(text: &str) -> Vec<&'static str> { + const LETTERS: [&str; 26] = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", + "w", "x", "y", "z", + ]; + text.chars() + .map(|c| match c { + 'a'..='z' => LETTERS[(c as u8 - b'a') as usize], + '/' => "slash", + ' ' => "spc", + other => panic!("no key is typed for {other:?}"), + }) + .collect() +} diff --git a/tests/guest/hangupholder/Cargo.toml b/tests/guest/hangupholder/Cargo.toml new file mode 100644 index 00000000000..082c05aada6 --- /dev/null +++ b/tests/guest/hangupholder/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "hangupholder" +description = "Test-only: an app that reads every reason it is asked to quit and stays, as one a session's sign-out waits for." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[dependencies] +toyos = { path = "../../../sdk/toyos" } diff --git a/tests/guest/hangupholder/src/main.rs b/tests/guest/hangupholder/src/main.rs new file mode 100644 index 00000000000..5297b5cbcbb --- /dev/null +++ b/tests/guest/hangupholder/src/main.rs @@ -0,0 +1,16 @@ +//! An app that ignores a hang-up: it reads each reason it is asked to quit, +//! says so where it still can, and stays until it is killed. What it says +//! goes to whatever started it, which a sign-out may already have ended, so +//! a write that fails is no reason to end. + +use std::io::Write; + +fn main() { + let notice = toyos::quit::notice().expect("hangupholder: every process holds its notice"); + notice.listen().expect("hangupholder: its notice listens"); + let _ = writeln!(std::io::stdout(), "hangupholder: holding"); + loop { + let reason = notice.read().expect("hangupholder: its notice reads"); + let _ = writeln!(std::io::stdout(), "hangupholder: asked to quit ({reason:?}), and staying"); + } +} diff --git a/tests/guest/rust-tests/Cargo.lock b/tests/guest/rust-tests/Cargo.lock index 40e7b2c0903..5c09e74f4e2 100644 --- a/tests/guest/rust-tests/Cargo.lock +++ b/tests/guest/rust-tests/Cargo.lock @@ -983,11 +983,19 @@ dependencies = [ "toyos-parity", "toyos-pci", "toyos-pci-claim", + "toyos-session", "toyos-tco", "toyos-trace", "toyos-window", ] +[[package]] +name = "toyos-session" +version = "0.1.0" +dependencies = [ + "toyos", +] + [[package]] name = "toyos-tco" version = "0.1.0" diff --git a/tests/guest/rust-tests/Cargo.toml b/tests/guest/rust-tests/Cargo.toml index 1985c02d0ad..343c895021c 100644 --- a/tests/guest/rust-tests/Cargo.toml +++ b/tests/guest/rust-tests/Cargo.toml @@ -11,6 +11,8 @@ toyos = { path = "../../../sdk/toyos" } toyos-window = { path = "../../../sdk/window" } toyos-tco = { path = "../../../lib/tco" } toyos-inspect = { path = "../../../lib/inspect" } +# A login session opened by a job, for `app_view`'s grants. +toyos-session = { path = "../../../lib/session" } # The log as logkeeper serves it, and the asking for it, for `counters_metal`'s # wait on the log being quiet. toyos-logstream = { path = "../../../lib/logstream" } diff --git a/tests/guest/rust-tests/src/bin/app_view.rs b/tests/guest/rust-tests/src/bin/app_view.rs index af966a34a27..e8c2e25f10e 100644 --- a/tests/guest/rust-tests/src/bin/app_view.rs +++ b/tests/guest/rust-tests/src/bin/app_view.rs @@ -23,8 +23,8 @@ //! //! Then it grants the package a folder of the home, `/home/toy/Games` holding //! a ROM, through `/system/bin/grants`: refused in this job's own session, -//! which is the machine's, and refused `Apps/appview` by name, from the login -//! session a shell opens. Granted it there, the package run as `game` browses +//! which is the machine's, and refused `Apps/appview` by name, from a login +//! session this job opens with the `login` connector test-runner hands it. Granted it there, the package run as `game` browses //! its working directory with gbae's own `list_directory` and saves beside //! the ROM as gbae does: it starts in the folder, lists the ROM and writes //! `test.sav`. The same package with one byte more is another binary and @@ -251,10 +251,23 @@ fn app() { println!(" every arm held"); } -/// `line` run by a shell this job launches, whose `login` row opens a login -/// session for what it launches: the session `grants` is answered in. +/// `line` run by a shell this job launches in the login session it opened, +/// the session `grants` is answered in. fn in_login(line: &str) -> std::process::Output { - Command::new("/system/bin/shell").independent().args(["-c", line]).current_dir("/").output().expect("launch a shell") + static SESSION: std::sync::OnceLock<(toyos::namespace::Namespace, toyos::ipc::Connection)> = std::sync::OnceLock::new(); + let (launcher, _) = SESSION.get_or_init(|| { + let login = toyos::endow::Endowments::get() + .take::(toyos_session::LOGIN_PORT) + .expect("test-runner hands this job its `login` connector"); + toyos_session::open(&login).expect("a login session for this job") + }); + Command::new("/system/bin/shell") + .independent() + .launcher(toyos::AsHandle::as_handle(launcher).0) + .args(["-c", line]) + .current_dir("/") + .output() + .expect("launch a shell") } /// What the package saw as `game`: its working directory, the ROMs gbae's diff --git a/tests/guest/rust-tests/src/bin/launch_unsent_stdio.rs b/tests/guest/rust-tests/src/bin/launch_unsent_stdio.rs index 20b939aa41e..d45cce247e3 100644 --- a/tests/guest/rust-tests/src/bin/launch_unsent_stdio.rs +++ b/tests/guest/rust-tests/src/bin/launch_unsent_stdio.rs @@ -33,6 +33,7 @@ fn launched(program: &str, argv: &[u8], fds: &[(u32, RawHandle, Kind)]) { provided: &[], parent: Parent::Caller, independent: true, + launcher: None, }; let child = match request.start().map(|start| match start { Start::Launch(launch) => launch.launch(), diff --git a/tests/guest/rust-tests/src/bin/session_lifecycle.rs b/tests/guest/rust-tests/src/bin/session_lifecycle.rs new file mode 100644 index 00000000000..e230499298c --- /dev/null +++ b/tests/guest/rust-tests/src/bin/session_lifecycle.rs @@ -0,0 +1,189 @@ +//! A login session, end to end, from a job test-runner hands its `login` +//! connector, as a login authority: the session's owner parents what is +//! detached in it, signs it out by asking and waiting, and ends what stays +//! only when forced; `logout` lists it; and opens are never budgeted. +//! `hangupholder` is the app that ignores a hang-up. + +use std::os::toyos::process::CommandExt; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use toyos::endow::{Endowments, SYSCAP_LABEL}; +use toyos::ipc::{self, Connection, RxStep}; +use toyos::namespace::Namespace; +use toyos::poller::{Poller, READABLE}; +use toyos::syscap::SysCap; +use toyos::AsHandle; +use toyos_session::{self as wire, Names, SignOut}; + +const HOLDER: &str = "hangupholder"; +const ZOMBIE: u8 = 3; +/// Opens after one another, none of them after a failure. +const OPENS: usize = 10; +/// A hang ceiling, never a verdict on time: QEMU's clock is no measure. +const CEILING: Duration = Duration::from_secs(60); + +fn main() { + let login = Endowments::get().take::(wire::LOGIN_PORT).expect("test-runner hands this job its `login`"); + let (launcher, control) = wire::open(&login).expect("a session for this job"); + let mut control = Control { conn: control, rx: ipc::FrameRx::new() }; + // `logout` runs in a second session, which no sign-out here asks to quit. + let (admin, _admin_control) = wire::open(&login).expect("a second session, for `logout`"); + let launched = |launcher: &Namespace, program: &str, args: &[&str]| { + Command::new(program) + .independent() + .launcher(launcher.as_handle().0) + .args(args) + .current_dir("/") + .output() + .unwrap_or_else(|e| panic!("launch {program} in a session: {e}")) + }; + let logout = || String::from_utf8_lossy(&launched(&admin, "/system/bin/logout", &[]).stdout).into_owned(); + + // A detached app outlives the shell that started it. The shell's output + // is nobody's, since the holder it detaches keeps what it inherits open. + let shell = Command::new("/system/bin/shell") + .independent() + .launcher(launcher.as_handle().0) + .args(["-c", &format!("detach /system/bin/{HOLDER}")]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .current_dir("/") + .status() + .expect("launch a shell in the session"); + assert!(shell.success(), "the shell's detach ended {shell:?}"); + let holder = live(HOLDER).into_iter().next().expect("the detached holder runs once its shell has ended"); + println!("session_lifecycle: a detached app (pid {holder}) outlives its shell"); + + // Shares only grow, so the first session opened has the smaller. + let said = logout(); + let share: u64 = said + .lines() + .filter(|l| l.contains(" remote active")) + .filter_map(|l| l.split(' ').next()?.parse().ok()) + .min() + .unwrap_or_else(|| panic!("logout listed no active remote session: {said:?}")); + println!("session_lifecycle: logout lists session {share} active"); + + // A graceful sign-out asks, and the holder, which stays, is what it waits for. + control.send(wire::MSG_SIGN_OUT, &SignOut { force: false }.encode()); + let waiting = control.waiting(); + assert!(waiting.names.iter().any(|n| n == HOLDER), "the sign-out waits for {waiting:?}"); + let said = logout(); + let line = said.lines().find(|l| l.starts_with(&format!("{share} "))).unwrap_or(""); + assert!(line.contains(" remote ending ") && line.contains(HOLDER), "logout listed {said:?}"); + println!("session_lifecycle: the sign-out waits for the holder, and logout lists it ending"); + + // A cancel keeps the session, and the holder in it. + control.send(wire::MSG_CANCEL, &[]); + control.expect(wire::MSG_CANCELLED); + assert!(live(HOLDER).contains(&holder), "the holder ended with a cancelled sign-out"); + println!("session_lifecycle: a cancel keeps the session and the holder"); + + // Only a force ends what stays; the owner ends with it. + control.send(wire::MSG_SIGN_OUT, &SignOut { force: false }.encode()); + let _ = control.waiting(); + control.send(wire::MSG_SIGN_OUT, &SignOut { force: true }.encode()); + control.ended(); + // The roster has no readiness to wait on, so it is read until the + // holder is gone, under the ceiling. + wait_for(|| (!live(HOLDER).contains(&holder)).then_some(()), "the forced holder to end"); + println!("session_lifecycle: a force ends the holder, and the session ends with its owner"); + + // Opens with no failure before them are never counted. + for i in 0..OPENS { + let (_, control) = wire::open(&login).unwrap_or_else(|why| panic!("open {i} of {OPENS}: {why}")); + let mut control = Control { conn: control, rx: ipc::FrameRx::new() }; + control.send(wire::MSG_SIGN_OUT, &SignOut { force: false }.encode()); + control.ended(); + } + println!("session_lifecycle: {OPENS} sessions opened and signed out in a row"); + println!("session_lifecycle: ok"); +} + +struct Control { + conn: Connection, + rx: ipc::FrameRx<{ ipc::MAX_FRAME_LEN as usize }>, +} + +impl Control { + fn send(&self, msg: u32, payload: &[u8]) { + self.conn.send_bytes(msg, payload).expect("the session's owner takes its control's request"); + } + + /// The next frame, or `None` once the owner has hung up. + fn next(&mut self) -> Option<(u32, Vec)> { + let poller = Poller::new(1); + let deadline = Instant::now() + CEILING; + loop { + match self.rx.pump(&self.conn) { + RxStep::Frame { msg_type, payload_len, .. } => return Some((msg_type, self.rx.payload(payload_len).to_vec())), + RxStep::Eof => return None, + RxStep::Malformed => panic!("the session's owner sent a frame its control cannot describe"), + RxStep::Idle => {} + } + let left = deadline.checked_duration_since(Instant::now()).expect("the session's owner said nothing past the ceiling"); + poller.watch(&self.conn, READABLE, 0); + poller.wait(1, left.as_nanos() as u64, |_| {}); + } + } + + fn waiting(&mut self) -> Names { + match self.next() { + Some((wire::MSG_WAITING, payload)) => Names::decode(&payload).expect("a report the owner writes"), + other => panic!("the sign-out answered {other:?}, not what it waits for"), + } + } + + fn expect(&mut self, msg: u32) { + loop { + match self.next() { + Some((m, _)) if m == msg => return, + Some((wire::MSG_WAITING, _)) => continue, + other => panic!("the owner answered {other:?}, not {msg}"), + } + } + } + + /// Every report until the owner hangs up, which its end is. + fn ended(&mut self) { + while let Some((msg, _)) = self.next() { + assert_eq!(msg, wire::MSG_WAITING, "a sign-out reported {msg}"); + } + } +} + +fn cap() -> &'static SysCap { + static CAP: std::sync::OnceLock = std::sync::OnceLock::new(); + CAP.get_or_init(|| Endowments::get().take(SYSCAP_LABEL).expect("test-runner endows this job its system capability")) +} + +/// The pids of every live process named `name`. +fn live(name: &str) -> Vec { + const HEADER: usize = toyos::system::SYSINFO_HEADER_SIZE; + const ENTRY: usize = toyos::system::SYSINFO_ENTRY_SIZE; + let mut buf = vec![0u8; HEADER + ENTRY * 1024]; + let n = cap().roster(&mut buf); + assert!((HEADER..=buf.len()).contains(&n), "sysinfo answered {n}"); + (HEADER..) + .step_by(ENTRY) + .take_while(|pos| pos + ENTRY <= n) + .map(|pos| &buf[pos..pos + ENTRY]) + .filter(|entry| entry[9] == 0 && entry[8] != ZOMBIE) + .filter(|entry| String::from_utf8_lossy(&entry[32..60]).trim_end_matches('\0') == name) + .map(|entry| u32::from_le_bytes(entry[0..4].try_into().unwrap())) + .collect() +} + +/// `check` until it answers, under the hang ceiling, which names what stalled. +fn wait_for(mut check: impl FnMut() -> Option, what: &str) -> T { + let deadline = Instant::now() + CEILING; + loop { + if let Some(found) = check() { + return found; + } + assert!(Instant::now() < deadline, "session_lifecycle: stalled waiting for {what}"); + std::thread::sleep(Duration::from_millis(20)); + } +} diff --git a/tests/guest/test-runner/Cargo.toml b/tests/guest/test-runner/Cargo.toml index 63a45c66313..53bb52b4c8f 100644 --- a/tests/guest/test-runner/Cargo.toml +++ b/tests/guest/test-runner/Cargo.toml @@ -8,4 +8,6 @@ license = "MIT OR Apache-2.0" [dependencies] toyos = { path = "../../../sdk/toyos" } toyos-abi = { path = "../../../sdk/abi" } +# The `login` label a login authority row is endowed, which each job gets a copy of. +toyos-session = { path = "../../../lib/session" } toyos-tco = { path = "../../../lib/tco" } diff --git a/tests/guest/test-runner/src/main.rs b/tests/guest/test-runner/src/main.rs index 2efb1c5b9e8..378261c0b65 100644 --- a/tests/guest/test-runner/src/main.rs +++ b/tests/guest/test-runner/src/main.rs @@ -9,6 +9,7 @@ use toyos::endow::{Endowments, SYSCAP_LABEL}; use toyos::power::Stop; use toyos::process::Process; use toyos::syscap::SysCap; +use toyos_session::LOGIN_PORT; /// The job the runner is inside, and whether the list got through: written by /// the loop, read by the deadline watching it. @@ -25,6 +26,10 @@ static FINISHED: AtomicBool = AtomicBool::new(false); /// blocked inside its `wait`. static JOB: AtomicU32 = AtomicU32::new(0); +/// The `login` connector this row is endowed as a login authority, which each +/// job is handed a copy of. +static LOGIN: std::sync::OnceLock> = std::sync::OnceLock::new(); + /// Set by the deadline before it kills the job it was watching, so the loop /// that kill releases does not start another one. /// @@ -256,15 +261,18 @@ fn run_one(name: &str, args: &[&str], cap: Option<&SysCap>) -> Ran { return Ran::No; } } - // Its launcher too, where its row starts anything: a job is a direct spawn, - // and a direct spawn inherits none. - if let Some(launcher) = toyos::endow::launcher() { - match toyos_abi::syscall::dup(toyos::AsHandle::as_handle(launcher)) { + // Its launcher too, where its row starts anything, and its `login` + // connector where its row is a login authority: a job is a helper, and a + // helper inherits neither. + let login = LOGIN.get_or_init(|| toyos::endow::Endowments::get().take::(LOGIN_PORT)); + for (label, held) in [(toyos::launch::LAUNCHER, toyos::endow::launcher()), (LOGIN_PORT, login.as_ref())] { + let Some(held) = held else { continue }; + match toyos_abi::syscall::dup(toyos::AsHandle::as_handle(held)) { Ok(dup) => { - command.endow(toyos::launch::LAUNCHER, dup.0); + command.endow(label, dup.0); } Err(e) => { - println!("===TEST_END {name} error=the launcher would not duplicate: {e:?}==="); + println!("===TEST_END {name} error=its {label} would not duplicate: {e:?}==="); let _ = io::stdout().flush(); return Ran::No; } diff --git a/tests/images/proctreecase.toml b/tests/images/proctreecase.toml index 203dff36a58..f5c218dedd3 100644 --- a/tests/images/proctreecase.toml +++ b/tests/images/proctreecase.toml @@ -11,8 +11,9 @@ # package it installed under the image's `[apps]` row and the view an # installed package is minted, and is refused one named after the `shell` row. # It grants that package a folder through `grants`, which test-runner's row -# lists, so it is refused in the machine's session, and the shell's, so the -# login session the shell opens is answered. +# lists, so it is refused in the machine's session, and the shell's, so it is +# answered from the login session the job opens: test-runner is a login +# authority, and hands each job its `login` connector. [boot] start = ["logkeeper", "diskserver", "fileserver", "test-runner"] @@ -32,6 +33,7 @@ syscap = ["logread"] [programs.test-runner] syscap = ["dup", "roster"] starts = ["toybox", "shell", "grants", "/apps"] +login = true # `roster`, which a child the job spawns itself never holds: a child holding # it ran under this row, and `launch_toctou` asks which bytes that child was. @@ -40,7 +42,9 @@ syscap = ["roster"] [programs.shell] starts = ["toybox", "shell", "grants"] -login = true + +# The owner of each login session a job opens. +[programs.session] [programs.grants] receives = ["grants"] diff --git a/tests/images/sessioncase.toml b/tests/images/sessioncase.toml new file mode 100644 index 00000000000..504ae70d26f --- /dev/null +++ b/tests/images/sessioncase.toml @@ -0,0 +1,57 @@ +# The boot `session_lifecycle` runs on: login sessions, the desktop's opened +# for the compositor, the screen's row, as it starts, and others opened by +# test-runner as their login authority and handed to its job. +# +# The desktop's: the compositor's Ctrl+N starts a terminal in it, whose shell +# detaches `hangupholder`, typed through ps2server, and Ctrl+Alt+Q signs it +# out, which Escape cancels and Enter forces. +# +# test-runner's row is `login`, so its job opens sessions with its `login` +# connector, and lists what a session's launcher starts: a shell, which +# detaches `hangupholder`, the app that ignores a hang-up; and `logout`, the +# one row receiving `session-admin`. `session` owns each session opened. + +assets = ["assets"] + +[boot] +start = ["logkeeper", "compositor", "ps2server", "test-runner"] + +[programs.logkeeper] +service = true +syscap = ["logread"] + +# `roster`, which the job reads to see the holder outlive its shell and end +# when forced; `dup`, because test-runner hands each job its capability. +[programs.test-runner] +syscap = ["dup", "roster"] +starts = ["shell", "logout"] +login = true + +[programs.compositor] +service = true +serves = ["compositor", "prompt", "clipboard", "input", "display"] +devices = ["framebuffer"] +starts = ["terminal"] +syscap = ["jobs"] +login = true + +[programs.ps2server] +restart = true +receives = ["input"] +devices = ["isa:0060,0064:1,12"] + +[programs.terminal] +provides = ["surface"] +receives = ["compositor"] +starts = ["shell"] + +[programs.shell] +receives = ["surface"] +starts = ["hangupholder"] + +[programs.hangupholder] + +[programs.session] + +[programs.logout] +receives = ["session-admin"] diff --git a/tests/toyos.rs b/tests/toyos.rs index 6586797dfee..7ef670e9ff8 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -240,6 +240,9 @@ const RUST_SKIP: &[&str] = &[ // It needs a launcher: `a_launch_gives_each_unsent_stdio_slot_a_null` // runs it on tests/images/launchcase.toml. "launch_unsent_stdio", + // It needs a `login` connector and the session rows: `session_lifecycle` + // runs it on tests/images/sessioncase.toml. + "session_lifecycle", // It claims the xHCI controller no image's usbd row names, which only a // machine with a spare has: `usbd_drives_the_spare` and the // `usbd_drives_the_type_c_controller` metal row run it. @@ -591,6 +594,9 @@ const MACHINE_TESTS: &[&str] = &[ // harness is the peer, on the host's loopback that QEMU's user network // reaches, so no public network. x86-64 alone (`qemu::SUITE_ARCH`). "stream_exported_to_a_child", + // Login sessions: their owner parents, signs out and ends with each, which + // only processes in one boot show, as order, completion and counts. + "session_lifecycle", // A launch's unsent stdio slots: its own boot, because the launcher it // needs makes every spawn of a declared program a launch. "a_launch_gives_each_unsent_stdio_slot_a_null", @@ -4327,6 +4333,7 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> { "libc_children" => common::libcchildren::libc_children(), "stream_holders" => stream_holders(test_config), "stream_exported_to_a_child" => stream_exported_to_a_child(), + "session_lifecycle" => common::sessionlifecycle::session_lifecycle(), "a_launch_gives_each_unsent_stdio_slot_a_null" => common::launchstdio::a_launch_gives_each_unsent_stdio_slot_a_null(), "a_row_reaches_its_own_state_and_no_other" => common::stateview::a_row_reaches_its_own_state_and_no_other(), "usbd_drives_the_spare" => usbd_drives_the_spare(test_config), From b8ae2f206773dee2bec68576d5ea005bde44974c Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 03:59:01 +0200 Subject: [PATCH 2/5] issues: stage 5 is #1035 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q --- ...ilds-end-is-an-event-and-a-parent-takes-its-children-down.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index 3f4dbf94181..baac1e8d94c 100644 --- a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md +++ b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md @@ -177,7 +177,7 @@ an explicit force issues it, and never a timer's. control pipe, so it watches at most 254 children: the 255th is refused `EAGAIN`, a `poll` takes 255 descriptors beside its wake pipe, and `SIG_IGN` is shown over 255 children. -5. **A login is a session under its owner**. **In review** (the P1b pull +5. **A login is a session under its owner**. **In review** (#1035, the P1b pull request). Per login the supervisor starts one session owner, `/system/bin/session`, which accepts on the session's launcher and hands each connection to the supervisor with its badge; the supervisor serves the From 52713931e321ae913db92c32067096b6f1f3d036 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 05:13:31 +0200 Subject: [PATCH 3/5] session: answer review round 1 of #1035 - A `login` row holds no launcher of the machine's session (`holds_machine_launcher`): its launches are its sessions' alone. The compositor started with no desktop session opens one, and again at each launch asked for while none is open, saying why it is refused; the supervisor refuses every launch into a session that has ended. A `login` test-runner opens one session for its jobs and hands them its launcher. - A session's launcher acceptor lives in its `Login` alone, so its end closes the port and a launch connected to it reads its end. The screen's row is handed its session's launcher and control minted for each start. The compositor's launcher thread takes a copy of the reopened launcher and holds no lock across a launch (`Namespace::duplicate`). - Reopens are budgeted on failures: a login authority whose sessions failed more than three times in ten seconds is refused its next open, and no open spends anything, so one session's failure or a sibling's sign-out changes no other login. - The last session slot is the desktop's, and an owner that breaks its link is killed, its session ending as a failure. - `session-admin` has no user dimension: the machine has one user, so a login session's `logout` acts on every session. - sshserver's session half is rewritten on #1026's server: a connection opens its session with its first program and signs it out when it ends, and a channel's end asks its program to quit with a hang-up through a handle of its own (`spawn::Ender`), on a host still killing it. - `app_view` loses its machine-session grants arm, since a `login` test-runner's jobs launch in its session. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q --- Cargo.lock | 1 + ...nt-and-a-parent-takes-its-children-down.md | 22 ++- ...ot-end-a-program-that-closed-its-output.md | 11 +- lib/session/src/lib.rs | 8 +- sdk/toyos/src/namespace.rs | 6 + system/compositor/src/session.rs | 28 ++- system/compositor/src/signout.rs | 33 ++-- system/logout/Cargo.toml | 2 +- system/logout/src/main.rs | 5 +- system/session/src/main.rs | 5 +- system/sshserver/Cargo.toml | 2 + system/sshserver/src/connection.rs | 21 ++- system/sshserver/src/main.rs | 3 +- system/sshserver/src/program.rs | 22 +-- system/sshserver/src/spawn.rs | 59 ++++-- system/supervisor/lifecycle/src/sessions.rs | 168 +++++++++--------- system/supervisor/src/main.rs | 49 +++-- system/supervisor/src/sessions.rs | 128 ++++++------- tests/guest/rust-tests/src/bin/app_view.rs | 30 +--- .../guest/rust-tests/src/bin/process_tree.rs | 8 +- .../rust-tests/src/bin/session_lifecycle.rs | 18 +- tests/guest/test-runner/src/main.rs | 35 +++- tests/images/proctreecase.toml | 6 +- tests/toyos.rs | 1 - 24 files changed, 388 insertions(+), 283 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32af096d9bb..d90d9c2fc02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3136,6 +3136,7 @@ version = "0.0.0" dependencies = [ "getrandom 0.3.4", "toyos", + "toyos-abi", "toyos-crypto", "toyos-session", "toyos-ssh", diff --git a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md index baac1e8d94c..04589c6c5b8 100644 --- a/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md +++ b/issues/a-childs-end-is-an-event-and-a-parent-takes-its-children-down.md @@ -186,13 +186,18 @@ an explicit force issues it, and never a timer's. the owner gives it, and the supervisor tells it of each app's start and end. The desktop's session opens with the screen's row, endowed to it, and again when it ends; any other `login` row opens one on the supervisor's - `login` port, sshserver one per connection. `launch ` asks the shell's + `login` port, sshserver one per connection. A `login` row holds no + launcher of the machine's session: its launches are its sessions' alone, + and with none open it launches nothing and says so. `launch ` asks the shell's session for an independent app; a bare command is the shell's helper. Sign-out asks every app to quit with a hang-up, reports what stays to the session's controls, keeps the session on a cancel and ends what stays only - on a force; `logout`, the one row holding `session-admin`, lists its user's - sessions and signs one out. An owner's end other than its sign-out's is a - failure, and only the next open after one is budgeted. Its namespace is + on a force; `logout`, the one row holding `session-admin`, lists the + sessions open and signs one out. The machine has one user, so a login + session's `logout` acts on every session. An owner's end other than its + sign-out's is a failure, and a login authority whose sessions failed more + than the restart budget's three times in ten seconds is refused its next + open; no other open is counted. Its namespace is `issues/a-user-is-a-home-tree-and-a-login-row.md`'s login row; the SSH session's shell holds no host key and no SSH service authority. *Exit*: ending the desktop's session owner ends every program of that @@ -206,7 +211,8 @@ an explicit force issues it, and never a timer's. waits for an app that ignores a hang-up, Escape keeps it and Enter kills it, and the compositor opens the desktop again; a job's session, opened on `login`, signs out the same way, `logout` lists it active and ending, - and ten sessions open and sign out in a row. + and ten sessions open and sign out in a row. Jobs of a `login` + test-runner run in a session it opens for them. - The owner's unasked end ending its apps has no guest arm, since nothing may kill a process by its number: the kernel's parent rule is `process_tree`'s, and the supervisor's half, placing detached apps under @@ -219,9 +225,9 @@ an explicit force issues it, and never a timer's. image, so the controls are mutations, each red by name: a graceful sign-out that kills (`session_lifecycle` stalls at Escape's cancel, and the owner's host laws red); a handed-over launch in its badge's session; - ordinary opens spending the budget; `session-admin` ignoring whose - session it is; a detached app placed under the supervisor; any row - receiving `session-admin`. + a sibling's sign-out forgetting a failure; one failure refusing a login; + a detached app placed under the supervisor; a `login` row holding the + machine's launcher; any row receiving `session-admin`. 6. **A program is asked to quit**. - 6.1 **Done** (#995, the kernel's half): `SYS_PROCESS_QUIT` (135: the 124 this stage named went to `SYS_COUNTERS`), `(process, reason)`, needs `MANAGE` diff --git a/issues/sshserver-cannot-end-a-program-that-closed-its-output.md b/issues/sshserver-cannot-end-a-program-that-closed-its-output.md index 51d81f12534..13a30c73312 100644 --- a/issues/sshserver-cannot-end-a-program-that-closed-its-output.md +++ b/issues/sshserver-cannot-end-a-program-that-closed-its-output.md @@ -21,11 +21,12 @@ the child runs on. Before #1026 the daemon polled `try_wait` with a backoff to keep the kill, which is a fixed delay where an event exists. -A `cfg` reaching ToyOS's process handle, whose `wait` and `kill` take `&self`, -closes the hole on ToyOS. The host gate refuses that `cfg` in a program crate -that holds tests (`build/userlandhost.rs`) until #1024, after which a `cfg` -counts only in a file that holds a test or declares a module: the fix is a -ToyOS-only leaf file, as #1024's `system/sshserver/src/spawn.rs` is. +On ToyOS a channel's end no longer goes through the `Child`: it asks the +program to quit with a hang-up through a handle of its own +(`system/sshserver/src/spawn.rs`, `Ender`), and the connection's end signs the +session out, so a program that closed its output is asked all the same. One +that ignores the hang-up is the session's to end, by a force. The hole stays +on a host build, where the kill still goes through the `Child`. **Owner**: `sshserver`. diff --git a/lib/session/src/lib.rs b/lib/session/src/lib.rs index 8e899a89cbc..a0b810e755e 100644 --- a/lib/session/src/lib.rs +++ b/lib/session/src/lib.rs @@ -21,8 +21,8 @@ /// `login` row receives. pub const LOGIN_PORT: &str = "login"; -/// The supervisor's port `logout` acts on its user's sessions through, which -/// only [`ADMIN_HOLDER`] receives, minted with its user. +/// The supervisor's port `logout` acts on the sessions open through, which +/// only [`ADMIN_HOLDER`] receives, minted with its row and session. pub const ADMIN_PORT: &str = "session-admin"; /// The one row a build may let receive [`ADMIN_PORT`]: its own binary, so no @@ -77,7 +77,7 @@ pub const MSG_ENDED: u32 = 22; /// child under it, which the session's detached apps are spawned under. pub const MSG_PLACE: u32 = 23; -/// On [`ADMIN_PORT`]: the sessions of the badge's user. No payload. +/// On [`ADMIN_PORT`]: the sessions open. No payload. pub const MSG_LIST: u32 = 30; /// The answer to [`MSG_LIST`]: [`Listed`] lines. pub const MSG_SESSIONS: u32 = 31; @@ -196,7 +196,7 @@ pub fn decode_instance(bytes: &[u8]) -> Option { Some(u64::from_le_bytes(bytes.try_into().ok()?)) } -/// `logout`'s sign-out of one of its user's sessions, by its share. +/// `logout`'s sign-out of one session, by its share. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SignOutSession { pub share: u64, diff --git a/sdk/toyos/src/namespace.rs b/sdk/toyos/src/namespace.rs index 82babceea30..f902e9c8b22 100644 --- a/sdk/toyos/src/namespace.rs +++ b/sdk/toyos/src/namespace.rs @@ -30,6 +30,12 @@ impl Namespace { self.0.into_raw() } + /// A second handle to the same namespace, for a holder handing one on or + /// using one while another thread may replace its own. + pub fn duplicate(&self) -> Result { + syscall::dup(self.0.raw()).map(|h| Self(OwnedHandle(h))) + } + /// A namespace a peer sent, consumed. Its type is the sender's word: the /// kernel refuses each namespace call on a handle of another type, as it /// does a connection's ([`Connection::received`]). diff --git a/system/compositor/src/session.rs b/system/compositor/src/session.rs index 62b49149bef..d22e2b6525b 100644 --- a/system/compositor/src/session.rs +++ b/system/compositor/src/session.rs @@ -21,6 +21,7 @@ use toyos::ipc::{Connection, FrameRx, RxStep}; use toyos::poller::{Poller, READABLE}; use toyos::port::Acceptor; use toyos::shm::SharedMemory; +use toyos::namespace::Namespace; use toyos::{ipc, system, AsHandle}; use toyos_abi::input::RawKeyEvent; use toyos_abi::syscall::SYSCAP_LABEL; @@ -2102,7 +2103,18 @@ impl Session { } /// Start `program` through the launcher, off the loop. - fn launch(&self, program: &str) { + /// Start `program` through the desktop session's launcher, off the + /// loop; with no session open and none to be opened, nothing starts. + fn launch(&mut self, program: &str) { + let had = self.desktop.conn().is_some(); + if !self.desktop.ensure() { + eprintln!("compositor: {program} did not start: no desktop session is open"); + return; + } + // A session opened just now: its control is watched from here. + if let Some(control) = self.desktop.conn().filter(|_| !had) { + self.poller.watch(control, READABLE, control.as_handle().0 as u64); + } self.launcher.send(program.to_string()).expect("compositor: its launcher thread has ended"); } @@ -2205,9 +2217,17 @@ fn launcher(session: crate::signout::Launcher) -> mpsc::Sender { }; let mut command = Command::new(&program); command.independent().detached().endow(JOB_LABEL, job.into_raw().0); - // A session this compositor opened again, in place of the endowed one. - let opened = session.lock().expect("compositor: its launcher lock is whole"); - if let Some(launcher) = &*opened { + // A session this compositor opened again, in place of the + // endowed one: a copy, so no lock is held across the launch. + let opened = session.lock().expect("compositor: its launcher lock is whole").as_ref().map(Namespace::duplicate); + let opened = match opened.transpose() { + Ok(opened) => opened, + Err(e) => { + eprintln!("compositor: {program} did not start: its launcher would not duplicate ({e:?})"); + continue; + } + }; + if let Some(launcher) = &opened { command.launcher(launcher.as_handle().0); } if let Err(e) = command.spawn() { diff --git a/system/compositor/src/signout.rs b/system/compositor/src/signout.rs index 63f2673e810..33b2c681772 100644 --- a/system/compositor/src/signout.rs +++ b/system/compositor/src/signout.rs @@ -3,11 +3,12 @@ //! through. //! //! The supervisor starts this row holding the desktop session's launcher and -//! its control connection. **A session that ends is opened again** on the -//! `login` port, which the supervisor budgets only after a failure: a -//! session signed out comes back empty, there being no greeter yet, and one -//! whose owner failed comes back unless its failures are past the budget. -//! Until then a launch goes nowhere, and says so. +//! its control connection, and no other launcher: its launches are that +//! session's alone. **A session that ends is opened again** on the `login` +//! port, which the supervisor refuses only past a budget of failures: a +//! session signed out comes back empty, there being no greeter yet. While none +//! is open, each launch asked for opens one first and, refused, says why and +//! starts nothing. use std::sync::{Arc, Mutex}; @@ -32,17 +33,27 @@ pub struct Desktop { impl Desktop { pub fn take() -> Self { let endowed = Endowments::get(); - let control = endowed.take::(wire::CONTROL_LABEL); - if control.is_none() { - eprintln!("compositor: started with no desktop session; its launches go nowhere"); - } - Self { - control, + let mut desktop = Self { + control: endowed.take::(wire::CONTROL_LABEL), rx: ipc::FrameRx::new(), waiting: None, login: endowed.take(wire::LOGIN_PORT), launcher: Arc::new(Mutex::new(None)), + }; + if desktop.control.is_none() { + eprintln!("compositor: started with no desktop session"); + desktop.reopen(); + } + desktop + } + + /// Whether a session is open, opening one where none is: the event a + /// retry waits on is a launch asked for. + pub fn ensure(&mut self) -> bool { + if self.control.is_none() { + self.reopen(); } + self.control.is_some() } pub fn conn(&self) -> Option<&Connection> { diff --git a/system/logout/Cargo.toml b/system/logout/Cargo.toml index cd847d0ec73..2abac11f5b5 100644 --- a/system/logout/Cargo.toml +++ b/system/logout/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "logout" -description = "logout [ [--force]]: its user's login sessions, listed, or one of them signed out, asked of the supervisor." +description = "logout [ [--force]]: the login sessions open, listed, or one of them signed out, asked of the supervisor." version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/system/logout/src/main.rs b/system/logout/src/main.rs index d76fc71183b..b25b43bae90 100644 --- a/system/logout/src/main.rs +++ b/system/logout/src/main.rs @@ -1,4 +1,4 @@ -//! `/system/bin/logout`: its user's login sessions, asked of the supervisor. +//! `/system/bin/logout`: the login sessions open, asked of the supervisor. //! //! - `logout`: every session, one line each: its number, `desktop` or //! `remote`, `active` or `ending`, and what an ending one waits for. @@ -7,7 +7,8 @@ //! //! It holds the one `session-admin` connector the build lets a program hold, //! minted with its row and session, so it is reached with `launch logout`; -//! the supervisor answers a login session's alone, for that session's user. +//! the supervisor answers a login session's alone. The machine has one user, +//! so that is every session's. //! The answer is the supervisor's text on standard output, with exit status 0 //! where it was done and 1 where it was refused. diff --git a/system/session/src/main.rs b/system/session/src/main.rs index dcf5ec5b818..51fe3d04b1a 100644 --- a/system/session/src/main.rs +++ b/system/session/src/main.rs @@ -23,8 +23,9 @@ const TOKEN_LAUNCHER: u64 = 1; const TOKEN_CONTROLS: u64 = 2; const TOKEN_CONTROL_BASE: u64 = 3; -/// The control connections one session keeps: its login authority's, and -/// one per restart of a compositor that held it. +/// The live control connections one session keeps: its login authority's, +/// and a restarted compositor's while its last one's hang-up is unread. One +/// past it is closed unread, which its holder reads as the session's end. const MAX_CONTROLS: usize = 8; type Rx = ipc::FrameRx<{ ipc::MAX_FRAME_LEN as usize }>; diff --git a/system/sshserver/Cargo.toml b/system/sshserver/Cargo.toml index 3c637615880..2052d9cd545 100644 --- a/system/sshserver/Cargo.toml +++ b/system/sshserver/Cargo.toml @@ -21,5 +21,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("toyos") [target.'cfg(target_os = "toyos")'.dependencies] toyos = { path = "../../sdk/toyos" } +# The handle a channel's end asks its program to quit through. +toyos-abi = { path = "../../sdk/abi" } # Opening a login session per connection, and signing it out. toyos-session = { path = "../../lib/session" } diff --git a/system/sshserver/src/connection.rs b/system/sshserver/src/connection.rs index eb85605892f..30262b748a5 100644 --- a/system/sshserver/src/connection.rs +++ b/system/sshserver/src/connection.rs @@ -32,6 +32,7 @@ use std::time::{Duration, Instant}; use toyos_ssh::{Authorizer, ChannelId, Event, HostKey, Server}; use crate::program::Program; +use crate::spawn::Login; use crate::{sftp, OsRandom}; /// This daemon would not run what was asked: the shell's own convention for @@ -128,6 +129,7 @@ pub fn serve(stream: TcpStream, host: Arc, authorizer: A tx, channels: BTreeMap::new(), peer, + login: None, }; connection.run(&rx, unauthenticated, deadline); connection.end(); @@ -155,6 +157,9 @@ struct Connection { tx: Sender, channels: BTreeMap, peer: String, + /// The login session this connection's programs run in, opened with the + /// first of them and signed out when the connection ends. + login: Option, } impl Connection { @@ -307,7 +312,12 @@ impl Connection { } fn start(&mut self, channel: ChannelId, argv: Vec, crlf: bool) { - let spawned = crate::spawn::own_row(&argv[0]) + let login = match self.login.take().map_or_else(Login::open, Ok) { + Ok(login) => self.login.insert(login), + Err(why) => return self.refuse(channel, &format!("no session to run {} in: {why}", argv[0])), + }; + let spawned = login + .command(&argv[0]) .args(&argv[1..]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -320,7 +330,14 @@ impl Connection { let stdin = child.stdin.take().expect("stdin was piped"); let stdout = child.stdout.take().expect("stdout was piped"); let stderr = child.stderr.take().expect("stderr was piped"); - let program = Arc::new(Program::new(child)); + let program = match Program::new(child) { + Ok(program) => Arc::new(program), + Err((mut child, why)) => { + let _ = child.kill(); + let _ = child.wait(); + return self.refuse(channel, &format!("{} started and {why}", argv[0])); + } + }; let (input, queue) = mpsc::channel(); let tx = self.tx.clone(); std::thread::spawn(move || write_stdin(stdin, queue, channel, tx)); diff --git a/system/sshserver/src/main.rs b/system/sshserver/src/main.rs index 25d163b07a0..926657ca567 100644 --- a/system/sshserver/src/main.rs +++ b/system/sshserver/src/main.rs @@ -1,6 +1,7 @@ //! The machine's SSH server: a shell, a command, and files both ways. A //! command run over `exec` is a line for `shell -c`, which the shell, launched -//! in its own manifest row and not this daemon's, runs. +//! in its own manifest row and not this daemon's, runs. On ToyOS each +//! connection's programs run in a login session of its own ([`spawn::Login`]). //! //! The protocol is `toyos-ssh`'s. This daemon drives it: a thread for each //! connection ([`connection`]), the host key, who may log in, and the bounds diff --git a/system/sshserver/src/program.rs b/system/sshserver/src/program.rs index f97707b2028..893027fe239 100644 --- a/system/sshserver/src/program.rs +++ b/system/sshserver/src/program.rs @@ -11,13 +11,18 @@ use std::process::Child; use std::sync::Mutex; use crate::connection::EXIT_LOST; +use crate::spawn::Ender; -pub struct Program(Mutex); +pub struct Program(Mutex, Ender); impl Program { - /// `child`, whose pipes are taken. - pub fn new(child: Child) -> Self { - Self(Mutex::new(child)) + /// `child`, whose pipes are taken; or the child back, with why it could + /// not be ended. + pub fn new(child: Child) -> Result { + match Ender::of(&child) { + Ok(ender) => Ok(Self(Mutex::new(child), ender)), + Err(why) => Err((child, why)), + } } /// Its exit status once it ends, or [`EXIT_LOST`] for an end the protocol's @@ -37,12 +42,9 @@ impl Program { } } - /// Kill it and reap it, unless a thread is already waiting for its end. + /// Let it go as its channel or connection ends ([`Ender`]). pub fn end(&self) { - if let Ok(mut child) = self.0.try_lock() { - let _ = child.kill(); - let _ = child.wait(); - } + self.1.end(&self.0); } } @@ -55,7 +57,7 @@ mod tests { #[test] fn a_program_ended_is_reaped() { let child = Command::new("sleep").arg("1000").stdin(Stdio::null()).spawn().expect("spawn sleep"); - let program = Program::new(child); + let program = Program::new(child).map_err(|(_, why)| why).expect("a host's child is ended through itself"); program.end(); let status = program.0.lock().unwrap().try_wait().expect("try_wait"); assert!(status.is_some_and(|s| !s.success()), "the killed program was reaped: {status:?}"); diff --git a/system/sshserver/src/spawn.rs b/system/sshserver/src/spawn.rs index c6b90e664b9..42358e31631 100644 --- a/system/sshserver/src/spawn.rs +++ b/system/sshserver/src/spawn.rs @@ -1,13 +1,15 @@ -//! How this daemon starts what a client asks for. +//! How this daemon starts what a client asks for, and lets it go. //! //! On ToyOS each connection's programs run in a login session the supervisor //! opens for it ([`Login`]): launched in their own rows, detached under that //! session's owner, so they hold no host key and no network right of this -//! daemon's. A connection that goes signs its session out, which asks its -//! programs to quit and ends none by force. Elsewhere a program has no row -//! and runs as this daemon's child, killed when its connection goes. +//! daemon's. A channel's end asks its program to quit with a hang-up +//! ([`Ender`]), and the connection's end signs its session out, which asks +//! every program in it and ends none by force. Elsewhere a program has no row +//! and runs as this daemon's child, killed when its channel or connection goes. use std::process::{Child, Command}; +use std::sync::Mutex; /// The login session one connection's programs run in. #[cfg(target_os = "toyos")] @@ -41,12 +43,6 @@ impl Login { command.independent().detached().launcher(toyos::AsHandle::as_handle(&self.launcher).0); command } - - /// A program whose connection is gone: its session's sign-out asks it to - /// quit, so this daemon lets it go. - pub fn abandon(_child: &mut Child, name: &str, peer: &str) { - println!("sshserver: {peer}: the connection is gone; {name} is asked to quit with its session"); - } } /// The connection is gone: its session signs out, asking what runs in it to @@ -61,6 +57,28 @@ impl Drop for Login { } } +/// How a channel's end lets its program go: a hang-up, asked through a handle +/// that may quit it, which a thread waiting for the program does not hold. +#[cfg(target_os = "toyos")] +pub struct Ender(toyos::process::Process); + +#[cfg(target_os = "toyos")] +impl Ender { + pub fn of(child: &Child) -> Result { + use std::os::toyos::process::ChildExt; + let handle = toyos::RawHandle(child.as_raw_handle()); + let quit = toyos_abi::syscall::dup_narrowed(handle, toyos_abi::Rights::MANAGE) + .map_err(|e| format!("no handle to end it was had ({e:?})"))?; + // SAFETY: a duplicate made here, which nothing else answers for. + Ok(Self(unsafe { toyos::process::Process::from_raw(quit) })) + } + + /// Ask it to quit with a hang-up, whether or not a thread waits for it. + pub fn end(&self, _child: &Mutex) { + let _ = self.0.quit(toyos::quit::Reason::HangUp); + } +} + /// Elsewhere, no session: a program is this daemon's child. #[cfg(not(target_os = "toyos"))] pub struct Login; @@ -74,12 +92,23 @@ impl Login { pub fn command(&self, program: &str) -> Command { Command::new(program) } +} + +/// Elsewhere a channel's end kills its program, nobody being left to read it. +#[cfg(not(target_os = "toyos"))] +pub struct Ender; + +#[cfg(not(target_os = "toyos"))] +impl Ender { + pub fn of(_child: &Child) -> Result { + Ok(Self) + } - /// A program whose connection is gone is killed: nobody is left to read it. - pub fn abandon(child: &mut Child, name: &str, peer: &str) { - match child.kill().and_then(|()| child.wait()) { - Ok(_) => println!("sshserver: {peer}: the connection is gone; ended {name}"), - Err(e) => println!("sshserver: {peer}: the connection is gone and {name} would not end: {e}"), + /// Kill it and reap it, unless a thread is already waiting for its end. + pub fn end(&self, child: &Mutex) { + if let Ok(mut child) = child.try_lock() { + let _ = child.kill(); + let _ = child.wait(); } } } diff --git a/system/supervisor/lifecycle/src/sessions.rs b/system/supervisor/lifecycle/src/sessions.rs index b885b98f306..eea322529fd 100644 --- a/system/supervisor/lifecycle/src/sessions.rs +++ b/system/supervisor/lifecycle/src/sessions.rs @@ -1,20 +1,61 @@ //! Login sessions, as far as the supervisor decides about them: whether a -//! login authority may open one, and which of them a user's `logout` may sign -//! out. Pure. +//! login authority may open one, where a detached app in one goes, and what +//! an owner's end means. Pure. //! -//! **Only a reopen after a failure is budgeted.** A session owner's failure is -//! an end the supervisor did not ask for and no sign-out preceded; an open by -//! the same login authority after one spends [`Reopens`]'s budget, which is -//! [`crate::restart::Budget`]. An open with no failure before it is never -//! counted. - -use std::collections::BTreeMap; +//! **Only failures are budgeted.** A session owner's failure is an end the +//! supervisor did not ask for and no sign-out preceded. A login authority +//! whose sessions failed more than [`RESTARTS`] times inside [`WINDOW`] is +//! refused its next open until the oldest failure leaves the window; every +//! other open, any number of logins and `ssh host cmd` sessions included, +//! spends nothing, and an open refused for another reason spends nothing +//! either. + +use std::collections::{BTreeMap, VecDeque}; use std::time::Instant; use toyos_abi::end::End; use toyos_manifest::launch::Session; +use toyos_manifest::Program; + +use crate::restart::{RESTARTS, WINDOW}; + +/// The failures of each login authority's sessions, inside the window. +#[derive(Default, Debug)] +pub struct Reopens { + failures: BTreeMap>, +} + +/// An open refused because its authority's sessions failed past the budget. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Exhausted; + +impl Reopens { + /// One of `opener`'s sessions ended at `now`: by a sign-out, or a failure. + pub fn ended(&mut self, opener: &str, signed_out: bool, now: Instant) { + if !signed_out { + self.failures.entry(opener.to_string()).or_default().push_back(now); + } + } -use crate::restart::Budget; + /// Whether `opener` may open a session at `now`. + pub fn may_open(&mut self, opener: &str, now: Instant) -> Result<(), Exhausted> { + let Some(failures) = self.failures.get_mut(opener) else { return Ok(()) }; + while failures.front().is_some_and(|at| now.duration_since(*at) > WINDOW) { + failures.pop_front(); + } + match failures.len() > RESTARTS { + true => Err(Exhausted), + false => Ok(()), + } + } +} + +/// Whether a start of `program` in the machine's session holds the machine's +/// launcher. **Never a `login` row's**: its launches are a session's alone, so +/// one that opened none launches nothing, rather than in the machine's. +pub fn holds_machine_launcher(program: &Program) -> bool { + !program.login +} /// The session a launch an owner handed over runs in: its link's, never what /// the connection's badge names, which must be the same one. @@ -54,81 +95,45 @@ pub fn signed_out(end: End) -> bool { end == End::Exited(0) } -/// The budget each login authority's opens after a failure spend. -#[derive(Default, Debug)] -pub struct Reopens { - owed: BTreeMap, -} - -/// An open refused because its authority's budget is spent. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Exhausted; - -impl Reopens { - /// `opener`'s session ended: by a sign-out, or a failure. - pub fn ended(&mut self, opener: &str, signed_out: bool) { - self.owed.entry(opener.to_string()).or_default().0 = !signed_out; - } - - /// Whether `opener` may open a session at `now`, spending its budget where - /// its last session failed. - pub fn open(&mut self, opener: &str, now: Instant) -> Result<(), Exhausted> { - let Some((failed, budget)) = self.owed.get_mut(opener) else { return Ok(()) }; - if !*failed { - return Ok(()); - } - match budget.spend(now) { - true => { - *failed = false; - Ok(()) - } - false => Err(Exhausted), - } - } -} - -/// Why `logout` may not sign a session out. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Refused { - NoSuchSession, - NotYours, -} - -/// Whether `user` may sign out the session `share` names, among `sessions`, -/// each a share and the user it is of. A session is its own user's alone. -pub fn sign_out_target(sessions: &[(u64, &str)], user: &str, share: u64) -> Result<(), Refused> { - match sessions.iter().find(|(s, _)| *s == share) { - None => Err(Refused::NoSuchSession), - Some((_, of)) if *of != user => Err(Refused::NotYours), - Some(_) => Ok(()), - } -} - #[cfg(test)] mod tests { use super::*; use crate::restart::{RESTARTS, WINDOW}; use std::time::Duration; - /// Opens with no failure before them are never counted; reopens after a - /// failure spend the budget, which refuses the fourth inside its window - /// and is whole again after it, and a sign-out spends nothing. + /// Opens with no failure before them are never counted, however many. #[test] - fn only_a_reopen_after_a_failure_spends() { - let mut reopens = Reopens::default(); - let now = Instant::now(); + fn ordinary_opens_spend_nothing() { + let (mut reopens, now) = (Reopens::default(), Instant::now()); for _ in 0..10 * RESTARTS { - assert_eq!(reopens.open("sshserver", now), Ok(())); - reopens.ended("sshserver", true); + assert_eq!(reopens.may_open("sshserver", now), Ok(())); + reopens.ended("sshserver", true, now); } - reopens.ended("compositor", false); + } + + /// Several sessions of one authority at once: one failing spends no other + /// login's open, a sibling's sign-out forgets no failure, and only more + /// than the budget's failures inside the window refuse, until the oldest + /// leaves it. + #[test] + fn failures_are_budgeted_across_an_authoritys_sessions() { + let (mut reopens, now) = (Reopens::default(), Instant::now()); + reopens.ended("sshserver", false, now); + assert_eq!(reopens.may_open("sshserver", now), Ok(()), "one failure refused an ordinary login"); for _ in 0..RESTARTS { - assert_eq!(reopens.open("compositor", now), Ok(())); - reopens.ended("compositor", false); + reopens.ended("sshserver", true, now); + reopens.ended("sshserver", false, now); } - assert_eq!(reopens.open("compositor", now), Err(Exhausted)); + assert_eq!(reopens.may_open("sshserver", now), Err(Exhausted), "a sibling's sign-out forgot a failure"); + assert_eq!(reopens.may_open("compositor", now), Ok(())); let later = now + WINDOW + Duration::from_millis(1); - assert_eq!(reopens.open("compositor", later), Ok(())); + assert_eq!(reopens.may_open("sshserver", later), Ok(())); + } + + #[test] + fn a_login_row_holds_no_machine_launcher() { + assert!(!holds_machine_launcher(&Program { login: true, starts: vec!["shell".into()], ..Program::default() })); + assert!(holds_machine_launcher(&Program { starts: vec!["toybox".into()], ..Program::default() })); } /// An owner hands over only its own session's launches: a badge naming @@ -161,22 +166,11 @@ mod tests { assert!(signed_out(End::Exited(0))); let mut reopens = Reopens::default(); let now = Instant::now(); - for end in [End::Exited(1), End::Killed, End::Fault(Fault::Memory)] { + for end in [End::Exited(1), End::Killed, End::Fault(Fault::Memory), End::Killed] { assert!(!signed_out(end), "{end:?}"); - reopens.ended("compositor", signed_out(end)); - } - for _ in 0..RESTARTS { - assert_eq!(reopens.open("compositor", now), Ok(())); - reopens.ended("compositor", signed_out(End::Killed)); + reopens.ended("compositor", signed_out(end), now); } - assert_eq!(reopens.open("compositor", now), Err(Exhausted)); + assert_eq!(reopens.may_open("compositor", now), Err(Exhausted)); } - #[test] - fn a_user_signs_out_their_own_sessions_alone() { - let sessions = [(3, "toy"), (4, "guest")]; - assert_eq!(sign_out_target(&sessions, "toy", 3), Ok(())); - assert_eq!(sign_out_target(&sessions, "toy", 4), Err(Refused::NotYours)); - assert_eq!(sign_out_target(&sessions, "toy", 5), Err(Refused::NoSuchSession)); - } } diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index 99c5bef172f..2bbbcf08f6c 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -192,7 +192,7 @@ enum Port { Grants(Caller), /// The login authority asking for a session, off its badge. Login(&'static Program), - /// The `logout` asking about its user's sessions, off its badge. + /// The `logout` asking about the sessions open, off its badge. Admin(Caller), Swap, Power, @@ -216,13 +216,17 @@ struct Minted { struct Minting<'m> { minted: &'m Minted, session: Session, - launches: (&'m Acceptor, Session), + /// `None` for a start that holds no launcher minted here: a `login` row + /// outside a login session, whose launches are a session's alone. + launches: Option<(&'m Acceptor, Session)>, } impl<'m> Minting<'m> { - /// A start whose launches are in its own session, on the machine's launcher. - fn own(minted: &'m Minted, session: Session) -> Self { - Self { minted, session, launches: (&minted.launcher, session) } + /// A start in the machine's session, its launches on the machine's + /// launcher, but for a `login` row's, which hold none of it. + fn machine(minted: &'m Minted, program: &Program, session: Session) -> Self { + let launches = toyos_supervisor::sessions::holds_machine_launcher(program).then_some((&minted.launcher, session)); + Self { minted, session, launches } } } @@ -767,7 +771,8 @@ struct Service<'a> { session: Session, /// The starts after an end it has spent. budget: Budget, - /// The screen's row's desktop session, which each start of it holds. + /// The screen's row's desktop session's launcher and control, minted for + /// its next start, which takes them. desktop: Option, } @@ -819,14 +824,11 @@ impl<'a> Service<'a> { if is_block(self.program) { storage.args.extend(block); } - let launches = match &self.desktop { - Some(desktop) => { - let control = desktop.control().map_err(std::io::Error::other)?; - storage.endow.push((toyos_session::CONTROL_LABEL, control.into_raw())); - (&*desktop.launcher, desktop.session) - } - None => (&minted.launcher, self.session), - }; + // The screen's row's desktop session, minted for this start alone. + if let Some(desktop) = self.desktop.take() { + storage.endow.push((LAUNCHER, desktop.launcher.into_raw())); + storage.endow.push((toyos_session::CONTROL_LABEL, desktop.control.into_raw())); + } let ports = self.ports.borrow(); let served = Served::Keep { acceptors: &ports, owed }; let command = match source { @@ -847,7 +849,7 @@ impl<'a> Service<'a> { grants, &[], storage, - Minting { minted, session: self.session, launches }, + Minting::machine(minted, self.program, self.session), Output::Boot(log), )?; drop(ports); @@ -1110,7 +1112,7 @@ impl<'a> Supervisor<'a> { extra.stop = Some(read); stop = Some(write); } - let launcher = Minting::own(&self.minted, self.sessions.machine()); + let launcher = Minting::machine(&self.minted, row, self.sessions.machine()); let served = Served::Keep { acceptors: &[], owed: &[] }; let output = Output::Boot(&mut self.log); let path = row.source.path().ok_or_else(|| format!("{} runs {}, and a `drives` row runs a file", row.name, row.source))?; @@ -2414,8 +2416,17 @@ impl Supervisor<'_> { } } let storage = self.slot_storage(program); - let in_login = self.logins.launcher_of(session); - let launches = (in_login.as_deref().unwrap_or(&self.minted.launcher), session); + let launches = match session { + Session::Login(..) => match self.logins.launcher_of(session) { + Some(launcher) => Some((launcher, session)), + None => { + say!("supervisor: launcher: {} was not started: its session has ended", program.name); + let _ = conn.try_signal(launch::MSG_REFUSED); + return; + } + }, + Session::Machine(_) => Minting::machine(&self.minted, program, session).launches, + }; let started = start( command, program, @@ -2832,7 +2843,7 @@ fn start<'a>( command.endow(toyos_swap::LABEL, raw.0); held.0.push(raw); } - if let Some(ns) = launcher_namespace(program, minting.launches) { + if let Some(ns) = minting.launches.and_then(|launches| launcher_namespace(program, launches)) { let raw = ns.into_raw(); command.endow(LAUNCHER, raw.0); held.0.push(raw); diff --git a/system/supervisor/src/sessions.rs b/system/supervisor/src/sessions.rs index 1fb3c71dd34..4ce8f1f59a6 100644 --- a/system/supervisor/src/sessions.rs +++ b/system/supervisor/src/sessions.rs @@ -19,7 +19,6 @@ //! spends a budget ([`Reopens`]). use std::process::{Child, Command}; -use std::rc::Rc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{mpsc, Arc}; use std::time::Instant; @@ -44,7 +43,8 @@ const CONTROL: &str = "control"; /// The login sessions the supervisor watches the owners of at once. Each /// owner is a process its loop watches, beside its services and `drives` -/// processes. +/// processes. The last is the desktop's alone, so sessions left ending by an +/// app that ignores its hang-up never keep the desktop from opening again. pub(crate) const MAX_SESSIONS: usize = 16; /// One open login session. @@ -59,23 +59,33 @@ pub(crate) struct Login { /// The owner's handle to itself, which places a detached app under it. place: Option, /// The session's launcher port, which every launcher in it is minted on. - pub launcher: Rc, + /// Held here alone, so the session's end closes it and a launch connected + /// to it reads its end. + pub launcher: Acceptor, + /// A connector to the session's control port, for each control connection + /// a login authority is handed. + controls: Namespace, + /// Whether the screen's row opened it: the desktop's. + desktop: bool, state: State, waiting: Names, } -/// The desktop session, as the screen's row is started holding it. -#[derive(Clone)] +/// The desktop session's launcher and a control connection to it, minted for +/// one start of the screen's row. pub(crate) struct Desktop { - pub launcher: Rc, - pub session: Session, - pub controls: Rc, + pub launcher: Namespace, + pub control: Connection, } -impl Desktop { - /// A control connection to the session, for one start of the screen's row. - pub fn control(&self) -> Result { - self.controls.open(CONTROL).map_err(|e| format!("the desktop session's control port refused ({e:?})")) +impl Login { + /// `opener`'s launcher in this session, and a control connection to it. + fn handed_to(&self, opener: &Program) -> Result<(Namespace, Connection), String> { + let badge = Authority { row: opener.name.clone(), session: self.session }.encode(); + let connector = self.launcher.mint(&badge).map_err(|e| format!("no launcher was minted ({e:?})"))?; + let launcher = namespace::build().add(LAUNCHER, &connector).finish().map_err(|e| format!("{e:?}"))?; + let control = self.controls.open(CONTROL).map_err(|e| format!("no control connection ({e:?})"))?; + Ok((launcher, control)) } } @@ -104,9 +114,9 @@ impl Logins { } } - /// The launcher port `session`'s launches are minted on, for a login one. - pub fn launcher_of(&self, session: Session) -> Option> { - self.of(session).map(|login| Rc::clone(&login.launcher)) + /// The launcher port `session`'s launches are minted on, while it is open. + pub fn launcher_of(&self, session: Session) -> Option<&Acceptor> { + self.of(session).map(|login| &login.launcher) } } @@ -199,18 +209,21 @@ impl Watch { } impl Supervisor<'_> { - /// Open a login session for `opener`, start its owner, and answer the - /// opener's launcher in it and the session's controls. - fn open_session(&mut self, opener: &'static Program) -> Result<(Session, Rc, Rc), String> { - self.logins.reopens.open(&opener.name, Instant::now()).map_err(|_| { + /// Open a login session for `opener` and start its owner: the index of + /// the session, or why none was opened. + fn open_session(&mut self, opener: &'static Program) -> Result { + let now = Instant::now(); + self.logins.reopens.may_open(&opener.name, now).map_err(|_| { format!( - "its sessions failed {} times in {} s", - toyos_supervisor::restart::RESTARTS + 1, + "more than {} of its sessions failed in {} s", + toyos_supervisor::restart::RESTARTS, toyos_supervisor::restart::WINDOW.as_secs() ) })?; - if self.logins.open.len() >= MAX_SESSIONS { - return Err(format!("{MAX_SESSIONS} sessions are open")); + let desktop = Screen::of(opener) == Screen::Held; + let room = if desktop { MAX_SESSIONS } else { MAX_SESSIONS - 1 }; + if self.logins.open.len() >= room { + return Err(format!("{} sessions are open", self.logins.open.len())); } let owner_row = self .system @@ -235,7 +248,7 @@ impl Supervisor<'_> { ..Extra::default() }; self.make_state_dir(owner_row); - let minting = Minting { minted: &self.minted, session, launches: (&launcher, session) }; + let minting = Minting { minted: &self.minted, session, launches: None }; let served = Served::Keep { acceptors: &[], owed: &[] }; let (owner, _) = start( Command::new(path), @@ -252,7 +265,6 @@ impl Supervisor<'_> { ) .map_err(|e| format!("its owner did not start: {e}"))?; say!("supervisor: session {} opened for {}, owned by pid {}", session.share(), opener.name, owner.id()); - let (launcher, controls) = (Rc::new(launcher), Rc::new(controls)); self.logins.open.push(Login { session, opener, @@ -260,30 +272,32 @@ impl Supervisor<'_> { link, rx: LaunchRx::new(), place: None, - launcher: Rc::clone(&launcher), + launcher, + controls, + desktop, state: State::Active, waiting: Names::default(), }); - Ok((session, launcher, controls)) + Ok(self.logins.open.len() - 1) } - /// The desktop session the screen's row at `index` starts holding: the one - /// it held, while that session lives, or one opened for it now. + /// The desktop session the screen's row at `index` starts holding, + /// minted for that start: the screen row's open session, or one opened + /// for it now. A start with none holds no launcher, and the compositor + /// says so at each launch it is asked for. pub(crate) fn desktop_for(&mut self, index: usize) { let program = self.services[index].program; if !(program.login && Screen::of(program) == Screen::Held) { return; } let program = self.system.program(&program.name).expect("a `login` row is the manifest's"); - let alive = self.services[index] - .desktop - .as_ref() - .is_some_and(|desktop| self.logins.of(desktop.session).is_some()); - if alive { - return; - } - self.services[index].desktop = match self.open_session(program) { - Ok((session, launcher, controls)) => Some(Desktop { launcher, session, controls }), + let open = self.logins.open.iter().position(|l| l.desktop && l.opener.name == program.name); + let at = match open.map_or_else(|| self.open_session(program), Ok) { + Ok(at) => at, + Err(why) => return say!("supervisor: {} starts with no desktop session: {why}", program.name), + }; + self.services[index].desktop = match self.logins.open[at].handed_to(program) { + Ok((launcher, control)) => Some(Desktop { launcher, control }), Err(why) => { say!("supervisor: {} starts with no desktop session: {why}", program.name); None @@ -297,20 +311,7 @@ impl Supervisor<'_> { say!("supervisor: login: {} asked {msg_type}, which is no request", opener.name); return; } - let answered = self.open_session(opener).and_then(|(session, launcher, controls)| { - // The screen's row opening its desktop again: a start of it after - // this one is endowed this session. - if Screen::of(opener) == Screen::Held { - for service in self.services.iter_mut().filter(|s| s.program.name == opener.name) { - service.desktop = Some(Desktop { launcher: Rc::clone(&launcher), session, controls: Rc::clone(&controls) }); - } - } - let badge = Authority { row: opener.name.clone(), session }.encode(); - let connector = launcher.mint(&badge).map_err(|e| format!("no launcher was minted ({e:?})"))?; - let ns = namespace::build().add(LAUNCHER, &connector).finish().map_err(|e| format!("{e:?}"))?; - let control = controls.open(CONTROL).map_err(|e| format!("no control connection ({e:?})"))?; - Ok((ns, control)) - }); + let answered = self.open_session(opener).and_then(|at| self.logins.open[at].handed_to(opener)); match answered { Ok((ns, control)) => { // SAFETY: the namespace's own handle, given up here and to nothing else. @@ -334,8 +335,11 @@ impl Supervisor<'_> { let login = &mut self.logins.open[at]; let (msg_type, payload, batch) = match login.rx.pump(&login.link) { RxStep::Idle | RxStep::Eof => break, + // The supervisor's own child broke its protocol: the session + // ends with it, as a failure. RxStep::Malformed => { - say!("supervisor: session {}'s owner sent a frame its link cannot describe", login.session.share()); + say!("supervisor: session {}'s owner sent a frame its link cannot describe; it is ended", login.session.share()); + let _ = login.owner.kill(); break; } RxStep::Frame { msg_type, payload_len, .. } => { @@ -433,7 +437,7 @@ impl Supervisor<'_> { }; let login = self.logins.open.swap_remove(at); let signed_out = decide::signed_out(end); - self.logins.reopens.ended(&login.opener.name, signed_out); + self.logins.reopens.ended(&login.opener.name, signed_out, Instant::now()); match signed_out { true => say!("supervisor: session {} is signed out", login.session.share()), false => say!("supervisor: session {} ended with its owner, {end}", login.session.share()), @@ -457,10 +461,9 @@ impl Supervisor<'_> { let _ = conn.try_send_bytes(msg, text.as_bytes()); } + /// **The machine has one user**, so a login session's `logout` acts on + /// every session open, its own and any other. fn admin(&mut self, msg_type: u32, payload: &[u8]) -> Result { - // One user signs in: every session is `toyos_manifest::USER`'s. - let user = toyos_manifest::USER; - let sessions: Vec<(u64, &str)> = self.logins.open.iter().map(|l| (l.session.share(), user)).collect(); match msg_type { wire::MSG_LIST => { let lines: Vec = self @@ -479,11 +482,12 @@ impl Supervisor<'_> { } wire::MSG_SIGN_OUT_SESSION => { let asked = SignOutSession::decode(payload).ok_or("its request is not one `logout` writes")?; - decide::sign_out_target(&sessions, user, asked.share).map_err(|why| match why { - decide::Refused::NoSuchSession => format!("no session {} is open", asked.share), - decide::Refused::NotYours => format!("session {} is another user's", asked.share), - })?; - let login = self.logins.open.iter().find(|l| l.session.share() == asked.share).expect("found above"); + let login = self + .logins + .open + .iter() + .find(|l| l.session.share() == asked.share) + .ok_or_else(|| format!("no session {} is open", asked.share))?; let SignOut { force } = asked.sign_out; login .link diff --git a/tests/guest/rust-tests/src/bin/app_view.rs b/tests/guest/rust-tests/src/bin/app_view.rs index e8c2e25f10e..73fdea3542f 100644 --- a/tests/guest/rust-tests/src/bin/app_view.rs +++ b/tests/guest/rust-tests/src/bin/app_view.rs @@ -22,9 +22,8 @@ //! the package to what it installed. //! //! Then it grants the package a folder of the home, `/home/toy/Games` holding -//! a ROM, through `/system/bin/grants`: refused in this job's own session, -//! which is the machine's, and refused `Apps/appview` by name, from a login -//! session this job opens with the `login` connector test-runner hands it. Granted it there, the package run as `game` browses +//! a ROM, through `/system/bin/grants`: refused `Apps/appview` by name, from +//! the login session test-runner, its login authority, opened for its jobs. Granted it there, the package run as `game` browses //! its working directory with gbae's own `list_directory` and saves beside //! the ROM as gbae does: it starts in the folder, lists the ROM and writes //! `test.sav`. The same package with one byte more is another binary and @@ -251,23 +250,10 @@ fn app() { println!(" every arm held"); } -/// `line` run by a shell this job launches in the login session it opened, +/// `line` run by a shell this job launches in test-runner's login session, /// the session `grants` is answered in. fn in_login(line: &str) -> std::process::Output { - static SESSION: std::sync::OnceLock<(toyos::namespace::Namespace, toyos::ipc::Connection)> = std::sync::OnceLock::new(); - let (launcher, _) = SESSION.get_or_init(|| { - let login = toyos::endow::Endowments::get() - .take::(toyos_session::LOGIN_PORT) - .expect("test-runner hands this job its `login` connector"); - toyos_session::open(&login).expect("a login session for this job") - }); - Command::new("/system/bin/shell") - .independent() - .launcher(toyos::AsHandle::as_handle(launcher).0) - .args(["-c", line]) - .current_dir("/") - .output() - .expect("launch a shell") + Command::new("/system/bin/shell").independent().args(["-c", line]).current_dir("/").output().expect("launch a shell") } /// What the package saw as `game`: its working directory, the ROMs gbae's @@ -291,14 +277,6 @@ fn granted(red: &mut Vec) { fs::create_dir_all(GAMES).expect("make the folder the package is granted"); fs::write(ROM, b"a ROM's bytes").expect("put a ROM in it"); - match Command::new("/system/bin/grants").independent().args(["add", "appview", GAMES]).output() { - Ok(out) if out.status.code() == Some(1) - && String::from_utf8_lossy(&out.stdout).contains("only a login session may ask for grants") => - { - println!(" grants in the machine's session: refused") - } - other => red.push(format!("grants in the machine's session was answered {other:?}")), - } let apps = in_login(&format!("launch /system/bin/grants add appview {HOME}")); match String::from_utf8_lossy(&apps.stdout) { said if said.contains("where every app keeps its own folder") => println!(" {HOME} as a grant: refused"), diff --git a/tests/guest/rust-tests/src/bin/process_tree.rs b/tests/guest/rust-tests/src/bin/process_tree.rs index 340097db406..ef1e4295106 100644 --- a/tests/guest/rust-tests/src/bin/process_tree.rs +++ b/tests/guest/rust-tests/src/bin/process_tree.rs @@ -6,7 +6,7 @@ //! arm — and the arm asserts, once A's wait on B answers: //! //! - C and D have ended, as killed; -//! - E, which the supervisor started, runs on: the supervisor is the one way to outlive a starter; +//! - E, which the supervisor started under its session's owner, runs on: detached is the one way to outlive a starter; //! - after the kill, a spawn and a launch under B's `self` answer `Gone`. //! //! B's first act is a spawn the loader refuses once it is admitted under B: its @@ -178,12 +178,12 @@ fn an_end_takes_its_subtree(end: Ending) { assert_eq!(End::from_word(status.into_raw()), Some(b_code), "B ended {end:?} and read {status}"); assert_eq!(grown.c.try_wait(), Ok(KILLED), "C was not ended, as killed, once B's end was published ({end:?})"); assert_eq!(grown.d.try_wait(), Ok(KILLED), "D, which the supervisor launched under B, was not ended with B ({end:?})"); - assert_eq!(grown.e.try_wait(), Err(SyscallError::WouldBlock), "E, started under the supervisor, ended with B ({end:?})"); + assert_eq!(grown.e.try_wait(), Err(SyscallError::WouldBlock), "E, started under its session's owner, ended with B ({end:?})"); grown.e.kill().expect("kill E"); assert_eq!(grown.e.wait(), Ok(KILLED)); syscall::close(grown.b_self); - println!(" B {end:?}: C and D ended, as killed; E, under the supervisor, ran on"); + println!(" B {end:?}: C and D ended, as killed; E, under its session's owner, ran on"); } /// A spawn and a launch placed under B's `self` both answer `Gone`. @@ -348,7 +348,7 @@ fn link(k: u32) -> ! { park(); } -/// A `cat` a shell `detach`es is the supervisor's child, so it runs on once the shell's +/// A `cat` a shell `detach`es is its session owner's child, so it runs on once the shell's /// end is published: it reads the input this process holds open. fn a_detached_program_outlives_its_shell() { let mut shell = Command::new(SHELL) diff --git a/tests/guest/rust-tests/src/bin/session_lifecycle.rs b/tests/guest/rust-tests/src/bin/session_lifecycle.rs index e230499298c..241e377b2cc 100644 --- a/tests/guest/rust-tests/src/bin/session_lifecycle.rs +++ b/tests/guest/rust-tests/src/bin/session_lifecycle.rs @@ -56,24 +56,24 @@ fn main() { let holder = live(HOLDER).into_iter().next().expect("the detached holder runs once its shell has ended"); println!("session_lifecycle: a detached app (pid {holder}) outlives its shell"); - // Shares only grow, so the first session opened has the smaller. - let said = logout(); - let share: u64 = said + // What `logout` lists active now, among which this job's session is. + let active: Vec = logout() .lines() .filter(|l| l.contains(" remote active")) .filter_map(|l| l.split(' ').next()?.parse().ok()) - .min() - .unwrap_or_else(|| panic!("logout listed no active remote session: {said:?}")); - println!("session_lifecycle: logout lists session {share} active"); + .collect(); // A graceful sign-out asks, and the holder, which stays, is what it waits for. control.send(wire::MSG_SIGN_OUT, &SignOut { force: false }.encode()); let waiting = control.waiting(); assert!(waiting.names.iter().any(|n| n == HOLDER), "the sign-out waits for {waiting:?}"); let said = logout(); - let line = said.lines().find(|l| l.starts_with(&format!("{share} "))).unwrap_or(""); - assert!(line.contains(" remote ending ") && line.contains(HOLDER), "logout listed {said:?}"); - println!("session_lifecycle: the sign-out waits for the holder, and logout lists it ending"); + let ending = said.lines().find(|l| l.contains(" remote ending ") && l.contains(HOLDER)); + let share: Option = ending.and_then(|l| l.split(' ').next()?.parse().ok()); + let share = share.unwrap_or_else(|| panic!("logout listed no session ending for the holder: {said:?}")); + assert!(active.contains(&share), "session {share}, ending, was not listed active before: {active:?}"); + println!("session_lifecycle: logout lists session {share} active, then ending"); + println!("session_lifecycle: the sign-out waits for the holder"); // A cancel keeps the session, and the holder in it. control.send(wire::MSG_CANCEL, &[]); diff --git a/tests/guest/test-runner/src/main.rs b/tests/guest/test-runner/src/main.rs index 378261c0b65..9d8438bb23c 100644 --- a/tests/guest/test-runner/src/main.rs +++ b/tests/guest/test-runner/src/main.rs @@ -26,9 +26,28 @@ static FINISHED: AtomicBool = AtomicBool::new(false); /// blocked inside its `wait`. static JOB: AtomicU32 = AtomicU32::new(0); -/// The `login` connector this row is endowed as a login authority, which each -/// job is handed a copy of. -static LOGIN: std::sync::OnceLock> = std::sync::OnceLock::new(); +/// What this row is endowed as a login authority, where it is one: its `login` +/// connector, which each job is handed a copy of, and the launcher of the +/// session it opens for its jobs, which a login authority's launches are in. +struct Login { + connector: toyos::namespace::Namespace, + session: toyos::namespace::Namespace, + _control: toyos::ipc::Connection, +} + +static LOGIN: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn login() -> Option<&'static Login> { + LOGIN + .get_or_init(|| { + let connector = Endowments::get().take::(LOGIN_PORT)?; + match toyos_session::open(&connector) { + Ok((session, control)) => Some(Login { connector, session, _control: control }), + Err(why) => panic!("test-runner: a login authority with no session for its jobs: {why}"), + } + }) + .as_ref() +} /// Set by the deadline before it kills the job it was watching, so the loop /// that kill releases does not start another one. @@ -262,10 +281,12 @@ fn run_one(name: &str, args: &[&str], cap: Option<&SysCap>) -> Ran { } } // Its launcher too, where its row starts anything, and its `login` - // connector where its row is a login authority: a job is a helper, and a - // helper inherits neither. - let login = LOGIN.get_or_init(|| toyos::endow::Endowments::get().take::(LOGIN_PORT)); - for (label, held) in [(toyos::launch::LAUNCHER, toyos::endow::launcher()), (LOGIN_PORT, login.as_ref())] { + // connector where its row is a login authority, whose launcher is the + // session it opened for its jobs: a job is a helper, and a helper + // inherits neither. + let login = login(); + let launcher = login.map(|l| &l.session).or(toyos::endow::launcher()); + for (label, held) in [(toyos::launch::LAUNCHER, launcher), (LOGIN_PORT, login.map(|l| &l.connector))] { let Some(held) = held else { continue }; match toyos_abi::syscall::dup(toyos::AsHandle::as_handle(held)) { Ok(dup) => { diff --git a/tests/images/proctreecase.toml b/tests/images/proctreecase.toml index f5c218dedd3..aa8ce52c9b7 100644 --- a/tests/images/proctreecase.toml +++ b/tests/images/proctreecase.toml @@ -11,9 +11,9 @@ # package it installed under the image's `[apps]` row and the view an # installed package is minted, and is refused one named after the `shell` row. # It grants that package a folder through `grants`, which test-runner's row -# lists, so it is refused in the machine's session, and the shell's, so it is -# answered from the login session the job opens: test-runner is a login -# authority, and hands each job its `login` connector. +# and the shell's list, in the login session test-runner opens for its jobs: +# test-runner is a login authority, so its jobs launch in that session and +# hold its `login` connector. [boot] start = ["logkeeper", "diskserver", "fileserver", "test-runner"] diff --git a/tests/toyos.rs b/tests/toyos.rs index 7ef670e9ff8..64e29ea04c7 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -5523,7 +5523,6 @@ fn app_view(back: &metal::Readback) -> Result<(), String> { &named, "supervisor: launcher: appview was not started: /home/toy/Apps/appview could not be made: \ /home/toy/Apps/appview is no directory", - "supervisor: grants: refused grants in the machine's session: only a login session may ask for grants", "supervisor: grants: appview is granted /home/toy/Games read-write", " granted: cwd=/home/toy/Games roms=[\"test.gba\"] saved=Ok", "supervisor: grants: appview's grant is revoked; its next launch holds no folder", From b3b2ad35517b885d29ff89d6c35c3ac8737b2972 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 06:03:34 +0200 Subject: [PATCH 4/5] session: answer review round 2 of #1035 - The compositor's opens of a session are its launcher thread's: the loop asks for one and takes the control connection it makes off a pipe it watches, so no open is a round trip on the loop. - The screen's row is answered its open desktop session on `login`, as `desktop_for` does, so there is one desktop session at a time. - sshserver's `Program::end` and a channel's end say what they do: ask a hang-up on ToyOS, kill on a host. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q --- system/compositor/src/main.rs | 6 +- system/compositor/src/session.rs | 88 +++------------- system/compositor/src/signout.rs | 160 ++++++++++++++++++++++------- system/sshserver/src/connection.rs | 3 +- system/sshserver/src/program.rs | 10 +- system/supervisor/src/sessions.rs | 9 +- 6 files changed, 158 insertions(+), 118 deletions(-) diff --git a/system/compositor/src/main.rs b/system/compositor/src/main.rs index c67b669f5d9..ac1dbf4ffe3 100644 --- a/system/compositor/src/main.rs +++ b/system/compositor/src/main.rs @@ -41,9 +41,9 @@ pub const FRAME_INTERVAL: Duration = Duration::from_nanos(16_666_667); // ~60fps pub const DRAIN_BUDGET: Duration = FRAME_INTERVAL; /// Handles the compositor watches that are not windows: keyboard, mouse, the -/// two listeners, `compositor` and `prompt`, the display's claim, and the -/// desktop session's control connection. -pub const FIXED_POLL_HANDLES: u32 = 6; +/// two listeners, `compositor` and `prompt`, the display's claim, the desktop +/// session's control connection, and the pipe its opens come back on. +pub const FIXED_POLL_HANDLES: u32 = 7; /// The `input` port's listener and each of its sources. pub const INPUT_POLL_HANDLES: u32 = 1 + toyos_input::wire::CONNECTIONS as u32; diff --git a/system/compositor/src/session.rs b/system/compositor/src/session.rs index d22e2b6525b..c85b53b712f 100644 --- a/system/compositor/src/session.rs +++ b/system/compositor/src/session.rs @@ -9,22 +9,15 @@ //! //! **No clipboard byte is ever logged**: a refusal names a length and a client. -use std::os::toyos::process::CommandExt; -use std::process::Command; -use std::sync::mpsc; use std::time::{Duration, Instant}; use toyos::endow; -use toyos::job::JOB_LABEL; -use toyos::syscap::SysCap; use toyos::ipc::{Connection, FrameRx, RxStep}; use toyos::poller::{Poller, READABLE}; use toyos::port::Acceptor; use toyos::shm::SharedMemory; -use toyos::namespace::Namespace; use toyos::{ipc, system, AsHandle}; use toyos_abi::input::RawKeyEvent; -use toyos_abi::syscall::SYSCAP_LABEL; use toyos_abi::RawHandle; use toyos_input::wire::{self, Record, CONNECTIONS, MAX_PAYLOAD}; use toyos_input::wire::DEVICES; @@ -247,10 +240,6 @@ pub struct Session { /// What the watcher has asked and is sent, for the compositor's whole life: /// a copy made while no watcher is there is the next one's. watch: Watch, - /// Where a launch is made: off the loop, since the supervisor answers one - /// whose package asks a question only once the question is answered, - /// through this loop. - launcher: mpsc::Sender, /// The `input` port, and the sources on it, by their slot. inputs: Acceptor, sources: [Option; CONNECTIONS], @@ -332,7 +321,7 @@ impl Session { let inputs = endow::acceptor(wire::PORT) .expect("the manifest declares this program serves `input`"); let (mut display, panel) = Display::claim(); - let desktop = crate::signout::Desktop::take(); + let desktop = crate::signout::Desktop::start(); let mode = display.mode(); let (image, kept) = (image_scale(), kept_scale()); let scale = scale_for(kept, image.unwrap_or(panel), mode); @@ -395,6 +384,7 @@ impl Session { if let Some(control) = desktop.conn() { poller.watch(control, READABLE, control.as_handle().0 as u64); } + poller.watch(desktop.wake(), READABLE, desktop.wake().as_handle().0 as u64); let cursor = Point { x: desk.screen.w() / 2, y: desk.screen.h() / 2 }; if display.hw_cursor() && cursor::in_hardware(scale) { @@ -405,7 +395,6 @@ impl Session { let mut damage = Damage::default(); damage.add(desk.screen); - let launcher = launcher(std::sync::Arc::clone(&desktop.launcher)); println!("compositor: ready"); let now = Instant::now(); @@ -418,7 +407,6 @@ impl Session { settings, watcher: None, watch: Watch::default(), - launcher, inputs, sources: Default::default(), merge: Merge::default(), @@ -513,6 +501,7 @@ impl Session { let volume_ready = self.volume.as_ref().is_some_and(|(conn, _)| self.is_ready(conn.as_handle())); let display_ready = self.display.handle().is_some_and(|handle| self.is_ready(handle)); let session_ready = self.desktop.conn().is_some_and(|c| self.is_ready(c.as_handle())); + let opened_ready = self.is_ready(self.desktop.wake().as_handle()); let client_ready = self.stack.iter().any(|w| self.is_ready(w.client.conn.as_handle())) || self.pending.iter().any(|p| self.is_ready(p.conn.as_handle())) || self.watcher.as_ref().is_some_and(|w| self.is_ready(w.conn.as_handle())); @@ -541,6 +530,7 @@ impl Session { && !volume_ready && !display_ready && !session_ready + && !opened_ready { return false; } @@ -573,6 +563,14 @@ impl Session { if session_ready { self.session_said(); } + if opened_ready { + if self.desktop.opened() { + if let Some(control) = self.desktop.conn() { + self.poller.watch(control, READABLE, control.as_handle().0 as u64); + } + } + self.poller.watch(self.desktop.wake(), READABLE, self.desktop.wake().as_handle().0 as u64); + } let frames = self.take_frames(); self.dispatch(frames); self.watched(); @@ -2103,19 +2101,9 @@ impl Session { } /// Start `program` through the launcher, off the loop. - /// Start `program` through the desktop session's launcher, off the - /// loop; with no session open and none to be opened, nothing starts. + /// Start `program` through the desktop session's launcher, off the loop. fn launch(&mut self, program: &str) { - let had = self.desktop.conn().is_some(); - if !self.desktop.ensure() { - eprintln!("compositor: {program} did not start: no desktop session is open"); - return; - } - // A session opened just now: its control is watched from here. - if let Some(control) = self.desktop.conn().filter(|_| !had) { - self.poller.watch(control, READABLE, control.as_handle().0 as u64); - } - self.launcher.send(program.to_string()).expect("compositor: its launcher thread has ended"); + self.desktop.launch(program); } fn retarget(&mut self, idx: usize, mode: WindowMode) { @@ -2191,54 +2179,6 @@ fn text(from: RawHandle, bytes: Vec) -> Option { .ok() } -/// The thread every launch is made on, and the channel to it: one at a time, -/// in order, since a launch waits as long as its question does. -/// -/// **Everything the desktop starts is a job of its own**, made with the -/// `jobs` right this row's system capability carries: a program started from -/// the taskbar is no helper of the compositor's, and must not share its CPU. -fn launcher(session: crate::signout::Launcher) -> mpsc::Sender { - let (launches, queue) = mpsc::channel::(); - std::thread::Builder::new() - .name("launcher".into()) - .spawn(move || { - let jobs = endow::Endowments::get().take::(SYSCAP_LABEL); - for program in queue { - let Some(jobs) = &jobs else { - eprintln!("compositor: {program} did not start: this row holds no `jobs` right to start it as its own job"); - continue; - }; - let job = match jobs.new_job() { - Ok(job) => job, - Err(e) => { - eprintln!("compositor: {program} did not start: no job for it: {e:?}"); - continue; - } - }; - let mut command = Command::new(&program); - command.independent().detached().endow(JOB_LABEL, job.into_raw().0); - // A session this compositor opened again, in place of the - // endowed one: a copy, so no lock is held across the launch. - let opened = session.lock().expect("compositor: its launcher lock is whole").as_ref().map(Namespace::duplicate); - let opened = match opened.transpose() { - Ok(opened) => opened, - Err(e) => { - eprintln!("compositor: {program} did not start: its launcher would not duplicate ({e:?})"); - continue; - } - }; - if let Some(launcher) = &opened { - command.launcher(launcher.as_handle().0); - } - if let Err(e) = command.spawn() { - eprintln!("compositor: {program} did not start: {e}"); - } - } - }) - .expect("compositor: no thread to launch on"); - launches -} - fn mode_rect(mode: Mode) -> Rect { Rect::new(0, 0, mode.width as i32, mode.height as i32) } diff --git a/system/compositor/src/signout.rs b/system/compositor/src/signout.rs index 33b2c681772..57de7aa5ef1 100644 --- a/system/compositor/src/signout.rs +++ b/system/compositor/src/signout.rs @@ -1,63 +1,116 @@ //! The desktop session this compositor is the login authority of: its control -//! connection, which signs the session out, and the launcher its launches go -//! through. +//! connection, which signs the session out, and the thread its launches and +//! its opens of a session are made on. //! //! The supervisor starts this row holding the desktop session's launcher and //! its control connection, and no other launcher: its launches are that //! session's alone. **A session that ends is opened again** on the `login` //! port, which the supervisor refuses only past a budget of failures: a //! session signed out comes back empty, there being no greeter yet. While none -//! is open, each launch asked for opens one first and, refused, says why and -//! starts nothing. +//! is open, each launch asked for asks for one first, and a refused open says +//! why and its launch starts nothing. **Every open is the launcher thread's**, +//! a supervisor round trip the loop never waits on: the control connection +//! it opens comes back to the loop through [`Desktop::opened`]. -use std::sync::{Arc, Mutex}; +use std::os::toyos::process::CommandExt; +use std::process::Command; +use std::sync::mpsc; -use toyos::endow::Endowments; +use toyos::endow::{self, Endowments}; use toyos::ipc::{self, Connection, RxStep}; +use toyos::job::JOB_LABEL; use toyos::namespace::Namespace; +use toyos::syscap::SysCap; +use toyos::{AsHandle, Pipe}; +use toyos_abi::syscall::SYSCAP_LABEL; use toyos_session::{self as wire, Names, SignOut}; -/// The launcher a launch goes through: the endowed one while `None`, and -/// one this compositor opened a session for once that one ended. -pub type Launcher = Arc>>; +/// What the launcher thread is asked. +enum Ask { + /// Open a desktop session on the `login` port. + Open, + Launch(String), +} pub struct Desktop { control: Option, rx: ipc::FrameRx<{ ipc::MAX_FRAME_LEN as usize }>, /// What a sign-out waits for, while one does. pub waiting: Option, - login: Option, - pub launcher: Launcher, + asks: mpsc::Sender, + /// The control connections the thread's opens made. + opened: mpsc::Receiver, + /// Readable once per control connection in `opened`. + wake: Pipe, + /// An open is asked and not yet answered: a launch meanwhile asks none. + opening: bool, } impl Desktop { - pub fn take() -> Self { + /// The endowed session, and the thread launches and opens are made on. + pub fn start() -> Self { let endowed = Endowments::get(); + let (asks, queue) = mpsc::channel(); + let (sent, opened) = mpsc::channel(); + let (wake, woken) = toyos::pipe_pair().expect("compositor: no pipe for its session's opens"); + let login = endowed.take::(wire::LOGIN_PORT); + std::thread::Builder::new() + .name("launcher".into()) + .spawn(move || launcher(queue, login, sent, woken)) + .expect("compositor: no thread to launch on"); let mut desktop = Self { control: endowed.take::(wire::CONTROL_LABEL), rx: ipc::FrameRx::new(), waiting: None, - login: endowed.take(wire::LOGIN_PORT), - launcher: Arc::new(Mutex::new(None)), + asks, + opened, + wake, + opening: false, }; if desktop.control.is_none() { eprintln!("compositor: started with no desktop session"); - desktop.reopen(); + desktop.open(); } desktop } - /// Whether a session is open, opening one where none is: the event a - /// retry waits on is a launch asked for. - pub fn ensure(&mut self) -> bool { - if self.control.is_none() { - self.reopen(); + pub fn conn(&self) -> Option<&Connection> { + self.control.as_ref() + } + + /// What the loop watches for an open made. + pub fn wake(&self) -> &Pipe { + &self.wake + } + + /// Start `program` in the desktop session, off the loop, asking for a + /// session first where none is open. + pub fn launch(&mut self, program: &str) { + if self.control.is_none() && !self.opening { + self.open(); } - self.control.is_some() + self.asks.send(Ask::Launch(program.to_string())).expect("compositor: its launcher thread has ended"); } - pub fn conn(&self) -> Option<&Connection> { - self.control.as_ref() + fn open(&mut self) { + self.opening = true; + self.asks.send(Ask::Open).expect("compositor: its launcher thread has ended"); + } + + /// A session the thread opened, now the one this compositor signs out: + /// `true` where there is one. + pub fn opened(&mut self) -> bool { + let mut drained = [0u8; 16]; + while self.wake.read_nonblock(&mut drained).is_ok_and(|n| n > 0) {} + let mut took = false; + while let Ok(control) = self.opened.try_recv() { + println!("compositor: a desktop session is open again"); + self.control = Some(control); + self.rx = ipc::FrameRx::new(); + self.opening = false; + took = true; + } + took } /// Sign the session out: ask what runs in it to quit, or end it by `force`. @@ -103,26 +156,63 @@ impl Desktop { println!("compositor: the desktop session ended"); self.control = None; self.waiting = None; - self.reopen(); + self.open(); return true; } } } } +} - /// A session of this compositor's own, opened on the `login` port. - fn reopen(&mut self) { - let Some(login) = &self.login else { - return eprintln!("compositor: no `login` connector to open a desktop session with"); +/// The thread every launch and every open of a session is made on, in the +/// order asked: a launch waits as long as its question does, and an open is a +/// round trip to the supervisor. +/// +/// **Everything the desktop starts is a job of its own**, made with the +/// `jobs` right this row's system capability carries: a program started from +/// the taskbar is no helper of the compositor's, and must not share its CPU. +fn launcher(queue: mpsc::Receiver, login: Option, opened: mpsc::Sender, wake: Pipe) { + let jobs = endow::Endowments::get().take::(SYSCAP_LABEL); + // A session this thread opened, whose launcher replaces the endowed one. + let mut session: Option = None; + for ask in queue { + let program = match ask { + Ask::Open => { + let Some(login) = &login else { + eprintln!("compositor: no `login` connector to open a desktop session with"); + continue; + }; + match wire::open(login) { + Ok((launcher, control)) => { + session = Some(launcher); + if opened.send(control).is_ok() { + let _ = wake.write_nonblock(&[1]); + } + } + Err(why) => eprintln!("compositor: no desktop session: {why}"), + } + continue; + } + Ask::Launch(program) => program, + }; + let Some(jobs) = &jobs else { + eprintln!("compositor: {program} did not start: this row holds no `jobs` right to start it as its own job"); + continue; }; - match wire::open(login) { - Ok((launcher, control)) => { - println!("compositor: a desktop session is open again"); - *self.launcher.lock().expect("compositor: its launcher lock is whole") = Some(launcher); - self.control = Some(control); - self.rx = ipc::FrameRx::new(); + let job = match jobs.new_job() { + Ok(job) => job, + Err(e) => { + eprintln!("compositor: {program} did not start: no job for it: {e:?}"); + continue; } - Err(why) => eprintln!("compositor: no desktop session: {why}"), + }; + let mut command = Command::new(&program); + command.independent().detached().endow(JOB_LABEL, job.into_raw().0); + if let Some(launcher) = &session { + command.launcher(launcher.as_handle().0); + } + if let Err(e) = command.spawn() { + eprintln!("compositor: {program} did not start: {e}"); } } } diff --git a/system/sshserver/src/connection.rs b/system/sshserver/src/connection.rs index 30262b748a5..a8bdd22592d 100644 --- a/system/sshserver/src/connection.rs +++ b/system/sshserver/src/connection.rs @@ -437,7 +437,8 @@ fn write_by(sink: &mut W, set_timeout: impl Fn(&W, Option) - true } -/// End what runs on a channel: its program, whose pipes then end its readers. +/// Let go of what runs on a channel: its program is asked to quit +/// ([`Program::end`]), and its pipes then end its readers once it does. fn end(running: Running) { if let Some((program, _)) = running.program { program.end(); diff --git a/system/sshserver/src/program.rs b/system/sshserver/src/program.rs index 893027fe239..8b6760734c0 100644 --- a/system/sshserver/src/program.rs +++ b/system/sshserver/src/program.rs @@ -1,10 +1,12 @@ //! A program this daemon started, which one thread waits for while another //! may end it. //! -//! std's `Child` is waited on and ended through one `&mut`. The connection -//! starts the wait once both output streams reach end of file, and until then -//! `end` kills the program. A program that closed both and runs on is waited -//! for and cannot be ended: +//! std's `Child` is waited on through one `&mut`. The connection starts the +//! wait once both output streams reach end of file. `end` lets the program go +//! through its [`Ender`]: on ToyOS it asks it to quit with a hang-up through +//! a handle of its own, whether or not a thread waits; on a host it kills it +//! unless a thread already waits, so there a program that closed both outputs +//! and runs on cannot be ended: //! `issues/sshserver-cannot-end-a-program-that-closed-its-output.md`. use std::process::Child; diff --git a/system/supervisor/src/sessions.rs b/system/supervisor/src/sessions.rs index 4ce8f1f59a6..5e5afaf2540 100644 --- a/system/supervisor/src/sessions.rs +++ b/system/supervisor/src/sessions.rs @@ -311,7 +311,14 @@ impl Supervisor<'_> { say!("supervisor: login: {} asked {msg_type}, which is no request", opener.name); return; } - let answered = self.open_session(opener).and_then(|at| self.logins.open[at].handed_to(opener)); + // The screen's row is answered its open desktop session, as + // `desktop_for` is: there is one desktop session at a time. + let open = (Screen::of(opener) == Screen::Held) + .then(|| self.logins.open.iter().position(|l| l.desktop && l.opener.name == opener.name)) + .flatten(); + let answered = open + .map_or_else(|| self.open_session(opener), Ok) + .and_then(|at| self.logins.open[at].handed_to(opener)); match answered { Ok((ns, control)) => { // SAFETY: the namespace's own handle, given up here and to nothing else. From f3f45a81414165db463c86500c51a0f9c6951dc5 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 23 Oct 2026 06:37:57 +0200 Subject: [PATCH 5/5] session: answer review round 3 of #1035 - The compositor's launcher thread answers every open it is asked, a refusal included, over the channel and pipe a session comes back on, so the loop stops waiting on a refused open and the next launch asks again (`toyos_desktop::Opening`). - The supervisor hands a desktop session again only while its owner runs: one whose end waits to be read is no longer reused, by `desktop_for` or `serve_login` (`toyos_supervisor::sessions::desktop_session`). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q --- system/compositor/desktop/src/lib.rs | 2 +- system/compositor/desktop/src/signout.rs | 38 ++++++++++++ system/compositor/src/signout.rs | 67 +++++++++++---------- system/supervisor/lifecycle/src/sessions.rs | 29 +++++++++ system/supervisor/src/sessions.rs | 15 ++++- 5 files changed, 116 insertions(+), 35 deletions(-) diff --git a/system/compositor/desktop/src/lib.rs b/system/compositor/desktop/src/lib.rs index 57a22f527c8..6b5dcd49ecb 100644 --- a/system/compositor/desktop/src/lib.rs +++ b/system/compositor/desktop/src/lib.rs @@ -52,7 +52,7 @@ pub use mode::Mode; pub use plan::{compose, content_blit, Blit, Layer}; pub use rect::{Point, Rect}; pub use scale::Scale; -pub use signout::{sign_out_rect, sign_out_text, SIGN_OUT_CHARS}; +pub use signout::{sign_out_rect, sign_out_text, Opening, SIGN_OUT_CHARS}; pub use stack::Stack; pub use taskbar::{Taskbar, MAX_STATUS_CHARS}; pub use window::{Buffers, Level, Presented, Window, WindowId, WindowMode}; diff --git a/system/compositor/desktop/src/signout.rs b/system/compositor/desktop/src/signout.rs index cfdc36592c8..ca2406f307a 100644 --- a/system/compositor/desktop/src/signout.rs +++ b/system/compositor/desktop/src/signout.rs @@ -44,11 +44,49 @@ pub fn sign_out_rect(desk: &Desk) -> Rect { Rect::corners(x0, y1 - h, x0 + w, y1) } +/// Whether the compositor has asked for a desktop session and had no answer +/// yet: while it has, a launch asks for no second one. **Every answer ends +/// the wait**, a refusal as much as a session, so a later launch asks again. +#[derive(Debug, Default)] +pub struct Opening { + asked: bool, +} + +impl Opening { + /// Whether to ask for a session now, `open` saying whether one is: asked + /// once, until it is answered. + pub fn ask(&mut self, open: bool) -> bool { + if open || self.asked { + return false; + } + self.asked = true; + true + } + + /// The open was answered, with a session or a refusal. + pub fn answered(&mut self) { + self.asked = false; + } +} + #[cfg(test)] mod tests { use super::*; use alloc::vec::Vec; + /// One open is asked at a time, and a refused one is asked again by the + /// next launch, as a granted one ends the asking. + #[test] + fn a_refused_open_is_asked_again() { + let mut opening = Opening::default(); + assert!(opening.ask(false)); + assert!(!opening.ask(false), "a second open was asked before the first was answered"); + opening.answered(); + assert!(opening.ask(false), "a refused open was never asked again"); + opening.answered(); + assert!(!opening.ask(true), "an open was asked with a session open"); + } + #[test] fn the_view_names_what_waits_and_the_keys() { assert_eq!(sign_out_text([], 0), "Signing out"); diff --git a/system/compositor/src/signout.rs b/system/compositor/src/signout.rs index 57de7aa5ef1..24b069cc7ef 100644 --- a/system/compositor/src/signout.rs +++ b/system/compositor/src/signout.rs @@ -23,6 +23,7 @@ use toyos::namespace::Namespace; use toyos::syscap::SysCap; use toyos::{AsHandle, Pipe}; use toyos_abi::syscall::SYSCAP_LABEL; +use toyos_desktop::Opening; use toyos_session::{self as wire, Names, SignOut}; /// What the launcher thread is asked. @@ -38,12 +39,11 @@ pub struct Desktop { /// What a sign-out waits for, while one does. pub waiting: Option, asks: mpsc::Sender, - /// The control connections the thread's opens made. - opened: mpsc::Receiver, - /// Readable once per control connection in `opened`. + /// The answers to the thread's opens: a control connection, or why none. + opened: mpsc::Receiver>, + /// Readable once per answer in `opened`. wake: Pipe, - /// An open is asked and not yet answered: a launch meanwhile asks none. - opening: bool, + opening: Opening, } impl Desktop { @@ -65,11 +65,11 @@ impl Desktop { asks, opened, wake, - opening: false, + opening: Opening::default(), }; if desktop.control.is_none() { eprintln!("compositor: started with no desktop session"); - desktop.open(); + desktop.ask(); } desktop } @@ -86,15 +86,15 @@ impl Desktop { /// Start `program` in the desktop session, off the loop, asking for a /// session first where none is open. pub fn launch(&mut self, program: &str) { - if self.control.is_none() && !self.opening { - self.open(); - } + self.ask(); self.asks.send(Ask::Launch(program.to_string())).expect("compositor: its launcher thread has ended"); } - fn open(&mut self) { - self.opening = true; - self.asks.send(Ask::Open).expect("compositor: its launcher thread has ended"); + /// Ask the thread for a session, where none is open and none is asked. + fn ask(&mut self) { + if self.opening.ask(self.control.is_some()) { + self.asks.send(Ask::Open).expect("compositor: its launcher thread has ended"); + } } /// A session the thread opened, now the one this compositor signs out: @@ -103,12 +103,17 @@ impl Desktop { let mut drained = [0u8; 16]; while self.wake.read_nonblock(&mut drained).is_ok_and(|n| n > 0) {} let mut took = false; - while let Ok(control) = self.opened.try_recv() { - println!("compositor: a desktop session is open again"); - self.control = Some(control); - self.rx = ipc::FrameRx::new(); - self.opening = false; - took = true; + while let Ok(answer) = self.opened.try_recv() { + self.opening.answered(); + match answer { + Ok(control) => { + println!("compositor: a desktop session is open again"); + self.control = Some(control); + self.rx = ipc::FrameRx::new(); + took = true; + } + Err(why) => eprintln!("compositor: no desktop session: {why}; the next launch asks again"), + } } took } @@ -156,7 +161,7 @@ impl Desktop { println!("compositor: the desktop session ended"); self.control = None; self.waiting = None; - self.open(); + self.ask(); return true; } } @@ -171,25 +176,23 @@ impl Desktop { /// **Everything the desktop starts is a job of its own**, made with the /// `jobs` right this row's system capability carries: a program started from /// the taskbar is no helper of the compositor's, and must not share its CPU. -fn launcher(queue: mpsc::Receiver, login: Option, opened: mpsc::Sender, wake: Pipe) { +fn launcher(queue: mpsc::Receiver, login: Option, opened: mpsc::Sender>, wake: Pipe) { let jobs = endow::Endowments::get().take::(SYSCAP_LABEL); // A session this thread opened, whose launcher replaces the endowed one. let mut session: Option = None; for ask in queue { let program = match ask { + // Answered either way, so the loop asks again after a refusal. Ask::Open => { - let Some(login) = &login else { - eprintln!("compositor: no `login` connector to open a desktop session with"); - continue; - }; - match wire::open(login) { - Ok((launcher, control)) => { + let answer = match &login { + Some(login) => wire::open(login).map(|(launcher, control)| { session = Some(launcher); - if opened.send(control).is_ok() { - let _ = wake.write_nonblock(&[1]); - } - } - Err(why) => eprintln!("compositor: no desktop session: {why}"), + control + }), + None => Err("this row holds no `login` connector".to_string()), + }; + if opened.send(answer).is_ok() { + let _ = wake.write_nonblock(&[1]); } continue; } diff --git a/system/supervisor/lifecycle/src/sessions.rs b/system/supervisor/lifecycle/src/sessions.rs index eea322529fd..180e591c4d3 100644 --- a/system/supervisor/lifecycle/src/sessions.rs +++ b/system/supervisor/lifecycle/src/sessions.rs @@ -57,6 +57,23 @@ pub fn holds_machine_launcher(program: &Program) -> bool { !program.login } +/// One open session, as [`desktop_session`] judges it: whether it is a +/// desktop, the login authority that opened it, and whether its owner still +/// runs, which it may not while its end waits to be read. +#[derive(Clone, Copy, Debug)] +pub struct Open<'a> { + pub desktop: bool, + pub opener: &'a str, + pub running: bool, +} + +/// The desktop session `opener` may be handed again, by its index: one it +/// opened whose owner still runs. **Never one whose owner has ended** though +/// its end is not read yet: its launcher's port is about to close. +pub fn desktop_session(open: &[Open<'_>], opener: &str) -> Option { + open.iter().position(|s| s.desktop && s.opener == opener && s.running) +} + /// The session a launch an owner handed over runs in: its link's, never what /// the connection's badge names, which must be the same one. pub fn handed_over(link: Session, badge: Session) -> Result { @@ -136,6 +153,18 @@ mod tests { assert!(holds_machine_launcher(&Program { starts: vec!["toybox".into()], ..Program::default() })); } + /// A desktop whose owner ended, its end not read yet, is not handed + /// again: the next running one is, or none and one is opened. + #[test] + fn a_desktop_whose_owner_ended_is_not_handed_again() { + let ended = Open { desktop: true, opener: "compositor", running: false }; + let running = Open { desktop: true, opener: "compositor", running: true }; + let remote = Open { desktop: false, opener: "sshserver", running: true }; + assert_eq!(desktop_session(&[ended], "compositor"), None); + assert_eq!(desktop_session(&[remote, ended, running], "compositor"), Some(2)); + assert_eq!(desktop_session(&[running], "sshserver"), None); + } + /// An owner hands over only its own session's launches: a badge naming /// another session is refused, whichever it names. #[test] diff --git a/system/supervisor/src/sessions.rs b/system/supervisor/src/sessions.rs index 5e5afaf2540..e993d30cbc0 100644 --- a/system/supervisor/src/sessions.rs +++ b/system/supervisor/src/sessions.rs @@ -281,6 +281,17 @@ impl Supervisor<'_> { Ok(self.logins.open.len() - 1) } + /// `opener`'s desktop session whose owner still runs, by its index. + fn desktop_session(&self, opener: &Program) -> Option { + let open: Vec> = self + .logins + .open + .iter() + .map(|l| decide::Open { desktop: l.desktop, opener: &l.opener.name, running: exit_of(&l.owner).is_none() }) + .collect(); + decide::desktop_session(&open, &opener.name) + } + /// The desktop session the screen's row at `index` starts holding, /// minted for that start: the screen row's open session, or one opened /// for it now. A start with none holds no launcher, and the compositor @@ -291,7 +302,7 @@ impl Supervisor<'_> { return; } let program = self.system.program(&program.name).expect("a `login` row is the manifest's"); - let open = self.logins.open.iter().position(|l| l.desktop && l.opener.name == program.name); + let open = self.desktop_session(program); let at = match open.map_or_else(|| self.open_session(program), Ok) { Ok(at) => at, Err(why) => return say!("supervisor: {} starts with no desktop session: {why}", program.name), @@ -314,7 +325,7 @@ impl Supervisor<'_> { // The screen's row is answered its open desktop session, as // `desktop_for` is: there is one desktop session at a time. let open = (Screen::of(opener) == Screen::Held) - .then(|| self.logins.open.iter().position(|l| l.desktop && l.opener.name == opener.name)) + .then(|| self.desktop_session(opener)) .flatten(); let answered = open .map_or_else(|| self.open_session(opener), Ok)