From 08ac265de3746e00b9a559ee2b34bf29f8b26b5c Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 14:09:16 +0200 Subject: [PATCH 1/8] Every userland package is an app that builds on every host, or says why it cannot The owner's rule: an app builds and runs on Linux under Wayland, macOS and Windows from the same source as on ToyOS, and a host build that fails is fixed in the app or its dependencies, never by making the app ToyOS-only. The one exemption is a program that by its nature cannot run elsewhere: a system server that owns ToyOS devices or kernel objects, like the compositor. calc broke the rule unseen, because CI built apps on macOS alone. The gate. `src/userlandhost.rs`'s `packages` reads every member of the userland workspace and its own manifest's `[package.metadata.toyos.host]`: nothing, for an app; `exempt = ""`; or `fails = []` with the `issue` that records them, which must name the app. Any other key or shape is refused by name. `cargo run -- --ci host` gains a step per host OS. Each builds every app for this host, and `cargo check`s it against the other two hosts' triples. `judge` reds where a build and its declaration disagree, both ways, so a declared failure goes when the app builds. It runs one cargo per app, because features unify across the packages of one invocation. A Windows runner cannot run this build system (issues/build/the-build-system-does-not-compile-on-windows.md), so Windows is checked from the others. Fourteen members are exempt, each with its reason in its manifest: blockd, compositor, console, filepicker, fsd, init, inspect, logd, metalprobe, netd, soundd, swap, test-runner and update. Each claims a device, holds a SysCap, a slot or init's swap port, serves a machine-wide port, or is init. An exemption takes a package out of the build gate only: the host tests of blockd, fsd, logd, netd and soundd still run. libc is no member of the userland workspace. It is the sysroot's C library, and its manifest is part of the sysroot's and the toolchain release's key. The rule text: - userland/CLAUDE.md states it. - reviewer.md gains it as a BLOCKER. Its "No new dependency or fetch" becomes the dependency rule the owner approved, the same as implementer.md's, because a reviewer still reading the old line would block what the implementer is now told to do. - implementer.md's "No new dependency." becomes that rule. - orchestrator.md: a "blocked" report is checked against the code and the product's principles before any ruling, and no product is changed to make a check green. userland/README.md goes. Its one sentence was this rule without the exemption, and nothing loaded it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- .claude/agents/implementer.md | 3 +- .claude/agents/orchestrator.md | 3 +- .claude/agents/reviewer.md | 9 +- src/ci.rs | 63 ++++++++- src/userlandhost.rs | 242 +++++++++++++++++++++++++++++++- userland/CLAUDE.md | 2 + userland/README.md | 3 - userland/blockd/Cargo.toml | 3 + userland/compositor/Cargo.toml | 3 + userland/console/Cargo.toml | 3 + userland/filepicker/Cargo.toml | 3 + userland/fsd/Cargo.toml | 3 + userland/init/Cargo.toml | 3 + userland/inspect/Cargo.toml | 3 + userland/logd/Cargo.toml | 3 + userland/metalprobe/Cargo.toml | 3 + userland/netd/Cargo.toml | 3 + userland/soundd/Cargo.toml | 3 + userland/swap/Cargo.toml | 3 + userland/test-runner/Cargo.toml | 3 + userland/update/Cargo.toml | 3 + 21 files changed, 357 insertions(+), 10 deletions(-) delete mode 100644 userland/README.md diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index a4d1bb91c6a..32e4e7d8592 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -57,7 +57,8 @@ Fork sources live outside this repository: a search for callers must also cover `git commit -F `, never `-m`. No `--amend`, no rebase, no force: merge `origin/main`, never rebase onto it. Never run `git submodule` in a linked worktree: it writes `core.worktree` into the fork's shared config and breaks git in the primary checkout's `rust/`. Never touch `toyos-abi/src`, `toyos/src` or `userland/libc/src` unless the brief is -an ABI brief. No new dependency. +an ABI brief. A new dependency is taken where it is the cleanest path: a general, widely used +crate (root `CLAUDE.md`, "Dependencies"), and the pull request says why. Push from your branch, never `main`, with `git status --porcelain` empty: `git push -u origin `, and `gh pr create --draft` at the first push. The pull request body is the handoff the reviewer reads, diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md index 6eb84aae517..aeead45b886 100644 --- a/.claude/agents/orchestrator.md +++ b/.claude/agents/orchestrator.md @@ -47,7 +47,8 @@ a poll loop. Every agent's transcript records its usage: a claim about cost is r The reviewer reports, you judge, and a judge who upholds everything is not judging. Only a BLOCKER sends a branch back. When a reviewer and an implementer disagree, ask for the measurement that -settles it and decide. +settles it and decide. Before ruling on a "blocked" report, read the code and check it against the +product's principles; never change what a product is to make a check green. ## Land diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index df44470c7fe..1b010a29e15 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -42,8 +42,13 @@ above; otherwise it is a NOTE. - **Fit.** Does the tree already do this? Is each new thing where it belongs: a pure decision in a pure crate, the user/kernel boundary in `toyos-userbound`, a device claim in a userland server? One declaration read by every reader, refusal by name, authority moved in by the parent. Zero - legacy: no shim, no workaround, no silent default. No new - dependency or fetch. Nothing outside the brief's fence. + legacy: no shim, no workaround, no silent default. A new dependency only where it is the + cleanest path, a general and widely used crate the pull request says why it takes; no new + fetch. Nothing outside the brief's fence. +- **Hosts.** A BLOCKER: a change that makes an app build on fewer of Linux, macOS and Windows, + or answers a host build failure by making the app ToyOS-only, by a split, a `cfg` that compiles + what it does out of a host or an `exempt` in its manifest, instead of fixing it in the app or + its dependencies. `exempt` is for a system server that owns ToyOS devices or kernel objects. Assembly, a naked function and a `core::arch` or `std::arch` path live only in an architecture's own module; `target_arch` only there, in its selector, in `src/arch.rs` and in `src/licence.rs`, which evaluates a dependency's `cfg` as data; `arch::x86_64` and diff --git a/src/ci.rs b/src/ci.rs index 465db5cb30b..1b6f8494c0f 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -28,6 +28,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::arch::{Accel, Arch}; +use crate::userlandhost::{Host, Os}; use crate::{flags, release, sdkversion, sync}; const USAGE: &str = "cargo run -- --ci , where is one of: @@ -452,7 +453,7 @@ fn run_control(root: &Path, control: &Control) -> Result { /// member of the host workspace, clippy with warnings denied, the concurrency /// models' negative controls, every userland crate with a host test /// ([`crate::userlandhost`], which also reds on a userland test none of them -/// runs), and the SDK. +/// runs), every userland app on each host ([`apps_on`]), and the SDK. /// /// **Every step runs against a `$TMPDIR` of this job's own, and the last step /// reds on anything left in it** but the lock `toyos_tmpdir` keeps there: a test @@ -528,6 +529,16 @@ fn host(root: &Path) -> Vec { } Err(why) => steps.push(Step { label: "the userland host crates".into(), verdict: Err(why) }), } + match crate::userlandhost::packages(&root.join("userland")) { + Ok(packages) => { + for os in Os::ALL { + steps.push(step(&format!("the userland apps on {}", os.name()), || { + apps_on(root, &packages, os, &host_triple) + })); + } + } + Err(why) => steps.push(Step { label: "the userland apps".into(), verdict: Err(why) }), + } // The SDK compiles against the ToyOS sysroot everywhere but here, and this // build links no syscall. steps.push(step("the toyos SDK", || { @@ -537,6 +548,56 @@ fn host(root: &Path) -> Vec { steps } +/// Every userland app built for `os` where this host is `os`, and checked +/// against `os`'s triple where it is not, each judged against what its manifest +/// declares. A Windows runner cannot run this build system +/// (`issues/build/the-build-system-does-not-compile-on-windows.md`), so Windows +/// is checked from the others. +/// +/// One cargo per app: features unify across the packages of one invocation, and +/// an app that builds only beside another's features is what this gate is for. +fn apps_on( + root: &Path, + packages: &[(String, Host)], + os: Os, + host_triple: &str, +) -> Result { + let native = Os::this()? == os; + let (verb, triple) = if native { ("build", host_triple) } else { ("check", os.triple()) }; + if !native { + let status = Command::new("rustup") + .args(["target", "add", triple]) + .status() + .map_err(|e| format!("rustup: {e}"))?; + if !status.success() { + return Err(format!("rustup target add {triple} exited {status}")); + } + } + let (mut built, mut failing, mut wrong) = (0, Vec::new(), Vec::new()); + for (dir, host) in packages { + let Host::App(fails) = host else { continue }; + let manifest = format!("userland/{dir}/Cargo.toml"); + let outcome = cargo(root, &[verb, "--manifest-path", &manifest, "--target", triple]); + match crate::userlandhost::judge(dir, fails.as_ref(), os, outcome.is_ok()) { + Ok(true) => built += 1, + Ok(false) => failing.push(dir.as_str()), + Err(why) => wrong.push(match outcome { + Err(exit) => format!("{why}: {exit}"), + Ok(_) => why, + }), + } + } + if !wrong.is_empty() { + return Err(wrong.join("; ")); + } + let said = format!("{built} app(s) pass `cargo {verb} --target {triple}`"); + if failing.is_empty() { + Ok(said) + } else { + Ok(format!("{said}; {} fail, as their manifests declare", failing.join(", "))) + } +} + /// What `tmp` holds but the lock `toyos_tmpdir` keeps in it, and every root /// under `short` whose process is gone that `before` does not name. /// diff --git a/src/userlandhost.rs b/src/userlandhost.rs index ca9b855797d..91bb7557302 100644 --- a/src/userlandhost.rs +++ b/src/userlandhost.rs @@ -1,5 +1,29 @@ -//! Which userland crates `cargo run -- --ci host` tests, and every userland test -//! it would run nowhere. +//! Which userland crates `cargo run -- --ci host` tests, every userland test it +//! would run nowhere, and which userland packages it builds on every host. +//! +//! **An app builds on Linux, macOS and Windows from the source it builds on +//! ToyOS from.** [`packages`] reads every member of the userland workspace and +//! what its own manifest declares; a member that declares nothing is an app. +//! One that by its nature cannot run anywhere but ToyOS, because it owns ToyOS +//! devices or kernel objects, says why, and only its tests run on a host: +//! +//! ```toml +//! [package.metadata.toyos.host] +//! exempt = "it drives the NVMe controller ToyOS claims for it" +//! ``` +//! +//! An app that does not build on a host yet names that host and the issue that +//! records it, which names the app: +//! +//! ```toml +//! [package.metadata.toyos.host] +//! fails = ["windows"] +//! issue = "issues/build/.md" +//! ``` +//! +//! `host` builds every app for its own host and checks it against the other two +//! hosts' triples ([`Os::triple`]), and [`judge`] reds where a build and its +//! manifest disagree, both ways: so a declared failure goes when the app builds. //! //! `userland/` is a workspace of its own that cross-compiles by default //! (`userland/.cargo/config.toml`), so none of its crates can be a member of the @@ -109,6 +133,163 @@ pub fn survey(userland: &Path) -> Result { Ok(Survey { gated: gated.into_iter().collect(), escapes: escapes.into_iter().collect() }) } +/// A host the userland apps build on, as `std::env::consts::OS` and a manifest +/// spell it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Os { + Linux, + Macos, + Windows, +} + +impl Os { + pub const ALL: [Os; 3] = [Os::Linux, Os::Macos, Os::Windows]; + + pub fn name(self) -> &'static str { + match self { + Os::Linux => "linux", + Os::Macos => "macos", + Os::Windows => "windows", + } + } + + /// The triple another host checks this one's build against. + pub fn triple(self) -> &'static str { + match self { + Os::Linux => "x86_64-unknown-linux-gnu", + Os::Macos => "aarch64-apple-darwin", + Os::Windows => "x86_64-pc-windows-msvc", + } + } + + fn named(name: &str) -> Option { + Os::ALL.into_iter().find(|os| os.name() == name) + } + + /// The host this build system runs on. + pub fn this() -> Result { + Os::named(std::env::consts::OS) + .ok_or_else(|| format!("{} is none of the hosts an app builds on", std::env::consts::OS)) + } +} + +/// What a userland package's manifest declares about the hosts. +#[derive(Debug, PartialEq, Eq)] +pub enum Host { + /// Builds on every host, or on every host but the ones it fails on. + App(Option), + /// Runs on ToyOS alone, and why. + Exempt(String), +} + +/// The hosts an app does not build on yet, and the issue that records them. +#[derive(Debug, PartialEq, Eq)] +pub struct Fails { + pub on: Vec, + pub issue: String, +} + +/// Every member of the userland workspace by its path under `userland`, and +/// what its manifest declares about the hosts. +pub fn packages(userland: &Path) -> Result, String> { + let read = |path: PathBuf| -> Result { + let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + text.parse().map_err(|e| format!("{}: not TOML: {e}", path.display())) + }; + let root = userland.parent().ok_or("userland has no parent")?; + let workspace = read(userland.join("Cargo.toml"))?; + let members = workspace + .get("workspace") + .and_then(|w| w.get("members")) + .and_then(toml::Value::as_array) + .ok_or("userland/Cargo.toml lists no [workspace] members")?; + let mut found = Vec::new(); + for member in members { + let dir = member.as_str().ok_or_else(|| format!("a member that is not a path: {member}"))?; + let manifest = read(userland.join(dir).join("Cargo.toml"))?; + let host = declared(&manifest, root, dir) + .map_err(|why| format!("userland/{dir}/Cargo.toml: {why}"))?; + found.push((dir.to_string(), host)); + } + Ok(found) +} + +/// `[package.metadata.toyos.host]`, read whole: anything it does not know is +/// refused, because a misspelt key would make an exempt program an app or a +/// failing one quiet. +fn declared(manifest: &toml::Value, root: &Path, dir: &str) -> Result { + let Some(host) = manifest + .get("package") + .and_then(|p| p.get("metadata")) + .and_then(|m| m.get("toyos")) + .and_then(|t| t.get("host")) + else { + return Ok(Host::App(None)); + }; + let host = host.as_table().ok_or("[package.metadata.toyos.host] is not a table")?; + let string = |key: &str| match host.get(key) { + Some(v) => v + .as_str() + .filter(|s| !s.trim().is_empty()) + .map(|s| Some(s.to_string())) + .ok_or_else(|| format!("`{key}` is not a sentence")), + None => Ok(None), + }; + if let Some(key) = host.keys().find(|k| !["exempt", "fails", "issue"].contains(&k.as_str())) { + return Err(format!("[package.metadata.toyos.host] declares `{key}`, which nothing reads")); + } + match (string("exempt")?, host.get("fails"), string("issue")?) { + (Some(why), None, None) => Ok(Host::Exempt(why)), + (None, Some(fails), Some(issue)) => { + let mut on = Vec::new(); + for name in fails.as_array().into_iter().flatten() { + let os = name.as_str().and_then(Os::named).ok_or_else(|| { + format!("`fails` names {name}, which is none of linux, macos and windows") + })?; + if on.contains(&os) { + return Err(format!("`fails` names {} twice", os.name())); + } + on.push(os); + } + if on.is_empty() { + return Err("`fails` names no host".into()); + } + if !issue.starts_with("issues/") { + return Err(format!("`issue` is {issue:?}, which is no path under issues/")); + } + let text = std::fs::read_to_string(root.join(&issue)) + .map_err(|e| format!("`issue` is {issue}, which does not open: {e}"))?; + if !text.contains(&format!("`{dir}`")) { + return Err(format!("{issue} does not name `{dir}`, so the set it records is short")); + } + Ok(Host::App(Some(Fails { on, issue }))) + } + _ => Err("[package.metadata.toyos.host] declares `exempt` alone, or `fails` with its \ + `issue`" + .into()), + } +} + +/// One app's build for `os` against what it declares: `Ok(true)` it built, +/// `Ok(false)` it failed where its manifest says it fails, and `Err` when the +/// two disagree. +pub fn judge(dir: &str, fails: Option<&Fails>, os: Os, built: bool) -> Result { + let declared = fails.filter(|f| f.on.contains(&os)).map(|f| &f.issue); + match (built, declared) { + (true, None) | (false, Some(_)) => Ok(built), + (false, None) => Err(format!( + "userland/{dir} does not build for {}, and its manifest says neither that it fails \ + there nor that it is exempt", + os.name() + )), + (true, Some(issue)) => Err(format!( + "userland/{dir} builds for {}, which its `fails` still names: the name goes, and its \ + row in {issue}", + os.name() + )), + } +} + /// `path` under `base`, with forward slashes. fn rel(base: &Path, path: &Path) -> String { path.strip_prefix(base).unwrap_or(path).to_string_lossy().replace('\\', "/") @@ -341,6 +522,63 @@ mod tests { ); } + /// **Every userland package is an app or says why it is not.** + #[test] + fn every_userland_package_declares_what_it_is_to_a_host() { + let packages = packages(&repo_root().join("userland")).expect("every declaration reads"); + assert!(packages.iter().any(|(_, h)| matches!(h, Host::App(_))), "no app: {packages:?}"); + assert!(packages.iter().any(|(_, h)| matches!(h, Host::Exempt(_))), "no exemption"); + } + + #[test] + fn a_host_declaration_is_read_whole_and_refused_by_name() { + let dir = toyos_tmpdir::TempDir::new("userlandhost-declared"); + fs::create_dir_all(dir.join("issues/build")).expect("make the fixture tree"); + fs::write(dir.join("issues/build/x.md"), "| `calc` | 101 |\n").expect("write the issue"); + let read = |host: &str| { + let manifest = format!("[package]\nname = \"calc\"\n{host}"); + declared(&manifest.parse().expect("TOML"), &dir, "calc") + }; + let table = "[package.metadata.toyos.host]\n"; + let issue = "issue = \"issues/build/x.md\"\n"; + assert_eq!(read(""), Ok(Host::App(None))); + assert_eq!(read("[package.metadata.toyos]\nother = 1\n"), Ok(Host::App(None))); + assert_eq!(read(&format!("{table}exempt = \"it owns a panel\"\n")), Ok(Host::Exempt("it owns a panel".into()))); + assert_eq!( + read(&format!("{table}fails = [\"windows\", \"linux\"]\n{issue}")), + Ok(Host::App(Some(Fails { on: vec![Os::Windows, Os::Linux], issue: "issues/build/x.md".into() }))) + ); + for refused in [ + format!("{table}exempt = \" \"\n"), + format!("{table}exempt = \"why\"\nfails = [\"linux\"]\n{issue}"), + format!("{table}fails = [\"linux\"]\n"), + format!("{table}{issue}"), + format!("{table}fails = []\n{issue}"), + format!("{table}fails = [\"freebsd\"]\n{issue}"), + format!("{table}fails = [\"linux\", \"linux\"]\n{issue}"), + format!("{table}fails = \"linux\"\n{issue}"), + format!("{table}fails = [\"linux\"]\nissue = \"README.md\"\n"), + format!("{table}fails = [\"linux\"]\nissue = \"issues/build/gone.md\"\n"), + format!("{table}exmept = \"why\"\n"), + "[package.metadata.toyos]\nhost = \"exempt\"\n".to_string(), + ] { + assert!(read(&refused).is_err(), "read {refused:?}"); + } + let unnamed = format!("[package]\nname = \"snake\"\n{table}fails = [\"linux\"]\n{issue}"); + let refusal = declared(&unnamed.parse().expect("TOML"), &dir, "snake").unwrap_err(); + assert!(refusal.contains("does not name `snake`"), "{refusal}"); + } + + #[test] + fn a_build_and_its_declaration_disagreeing_is_red_both_ways() { + let fails = Fails { on: vec![Os::Windows], issue: "issues/build/x.md".into() }; + assert_eq!(judge("calc", None, Os::Linux, true), Ok(true)); + assert!(judge("calc", None, Os::Linux, false).unwrap_err().contains("neither")); + assert_eq!(judge("calc", Some(&fails), Os::Windows, false), Ok(false)); + assert!(judge("calc", Some(&fails), Os::Windows, true).unwrap_err().contains("goes")); + assert!(judge("calc", Some(&fails), Os::Linux, false).is_err(), "it fails on Windows alone"); + } + #[test] fn a_test_attribute_is_a_word_and_not_a_substring() { let test = |text| scan(text).expect("closes").test; diff --git a/userland/CLAUDE.md b/userland/CLAUDE.md index 9589142df4b..775230a521f 100644 --- a/userland/CLAUDE.md +++ b/userland/CLAUDE.md @@ -2,6 +2,8 @@ The module header at the site owns its subject — surfaces, translators and the channel in `toyos/`'s surface modules, soundd whole under `userland/soundd/`, what a process holds and how it got it at `kernel/src/object/` and `/system/bin/init`. The compositor's decisions are `toyos-desktop/`, pure and host-tested; `userland/compositor/` is devices, handles, shared memory and the panel. POSIX lives in `userland/libc` — ours, not a fork; that layer may be ugly, the kernel may not. +**An app builds and runs on Linux under Wayland, macOS and Windows from the same source as on ToyOS**: a host build that fails is fixed in the app or its dependencies, never by making the app ToyOS-only. Only a program that cannot by its nature, a system server that owns ToyOS devices or kernel objects like the compositor, is exempt, with its reason in its own manifest (`src/userlandhost.rs`). + **A server never blocks on a client** — the doctrine no single site owns. Accept and the first frame are two events; a frame is buffered until whole before anything acts on it; a write is one `try_send` whose refusal drops the peer by name; a blocking read or write of a pipe the client owns is the same bug. init, the compositor, netd, soundd and every surface host use `ipc::FrameRx`. filepicker violates it today. ## Caveats that bite every agent diff --git a/userland/README.md b/userland/README.md deleted file mode 100644 index 6473450414a..00000000000 --- a/userland/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Userland - -Everything under `userland/` must build and run on both the host and ToyOS (`x86_64-unknown-toyos`). diff --git a/userland/blockd/Cargo.toml b/userland/blockd/Cargo.toml index 92576bcc00d..cac73a0847a 100644 --- a/userland/blockd/Cargo.toml +++ b/userland/blockd/Cargo.toml @@ -20,3 +20,6 @@ toyos = { path = "../../toyos" } toyos-blockring = { path = "../../toyos-blockring" } toyos-blockhold = { path = "../../toyos-blockhold" } toyos-gpt = { path = "../../toyos-gpt" } + +[package.metadata.toyos.host] +exempt = "it drives the NVMe controller ToyOS claims for it, and serves its partitions on the `block` port" diff --git a/userland/compositor/Cargo.toml b/userland/compositor/Cargo.toml index 10bbaee7ff6..f799ce5b06d 100644 --- a/userland/compositor/Cargo.toml +++ b/userland/compositor/Cargo.toml @@ -13,3 +13,6 @@ toyos-inspect = { path = "../../toyos-inspect" } toyos-font = { path = "../toyos-font" } toyos-manifest = { path = "../../toyos-manifest" } toyos-window = { path = "../toyos-window" } + +[package.metadata.toyos.host] +exempt = "it claims ToyOS's framebuffer, keyboard and mouse, and serves the `compositor` port" diff --git a/userland/console/Cargo.toml b/userland/console/Cargo.toml index 04acb0730b1..a2bbef194b8 100644 --- a/userland/console/Cargo.toml +++ b/userland/console/Cargo.toml @@ -11,3 +11,6 @@ toyos-abi = { path = "../../toyos-abi" } toyos-font = { path = "../toyos-font" } toyos-window = { path = "../toyos-window" } toyos-logstream = { path = "../../toyos-logstream" } + +[package.metadata.toyos.host] +exempt = "it claims ToyOS's framebuffer, and draws the log ToyOS's logd keeps" diff --git a/userland/filepicker/Cargo.toml b/userland/filepicker/Cargo.toml index e340b33eb01..30148359a19 100644 --- a/userland/filepicker/Cargo.toml +++ b/userland/filepicker/Cargo.toml @@ -9,3 +9,6 @@ filepicker-api = { path = "../filepicker-api" } toyos = { path = "../../toyos" } toyos-font = { path = "../toyos-font" } toyos-window = { path = "../toyos-window" } + +[package.metadata.toyos.host] +exempt = "init starts it at boot to serve ToyOS's machine-wide `filepicker` port" diff --git a/userland/fsd/Cargo.toml b/userland/fsd/Cargo.toml index 8c743a4d975..409f130bec6 100644 --- a/userland/fsd/Cargo.toml +++ b/userland/fsd/Cargo.toml @@ -22,3 +22,6 @@ toyos-gpt = { path = "../../toyos-gpt" } toyos-fat32 = { path = "../../toyos-fat32" } bcachefs = { path = "../../bcachefs" } blockd = { path = "../blockd" } + +[package.metadata.toyos.host] +exempt = "it serves ToyOS's storage roles from the partitions ToyOS claims for it" diff --git a/userland/init/Cargo.toml b/userland/init/Cargo.toml index 89b5d470a2e..bc6c0e0adb5 100644 --- a/userland/init/Cargo.toml +++ b/userland/init/Cargo.toml @@ -18,3 +18,6 @@ toyos-gpt = { path = "../../toyos-gpt" } toyos-blockring = { path = "../../toyos-blockring" } # How long a stop is prepared before the kernel is asked for it. toyos-quiesce = { path = "../../toyos-quiesce" } + +[package.metadata.toyos.host] +exempt = "it is the program ToyOS's kernel starts, and holds the machine's system capability" diff --git a/userland/inspect/Cargo.toml b/userland/inspect/Cargo.toml index 83de9efbdcf..78ab61df2cc 100644 --- a/userland/inspect/Cargo.toml +++ b/userland/inspect/Cargo.toml @@ -8,3 +8,6 @@ license = "MIT OR Apache-2.0" toyos = { path = "../../toyos" } toyos-abi = { path = "../../toyos-abi" } toyos-inspect = { path = "../../toyos-inspect" } + +[package.metadata.toyos.host] +exempt = "it reads what ToyOS's servers and kernel say of themselves, through their ports and the `inventory` SysCap" diff --git a/userland/logd/Cargo.toml b/userland/logd/Cargo.toml index 3b7aae17e86..e007f1400a6 100644 --- a/userland/logd/Cargo.toml +++ b/userland/logd/Cargo.toml @@ -12,3 +12,6 @@ toyos-logstream = { path = "../../toyos-logstream" } toyos-elide = { path = "../../toyos-elide" } toyos-inspect = { path = "../../toyos-inspect" } toyos-wallclock = { path = "../../toyos-wallclock" } + +[package.metadata.toyos.host] +exempt = "it reads the ToyOS kernel's record ring under the `logread` SysCap, and serves the `log` port" diff --git a/userland/metalprobe/Cargo.toml b/userland/metalprobe/Cargo.toml index c35e0ca43ac..95a9bda5cd5 100644 --- a/userland/metalprobe/Cargo.toml +++ b/userland/metalprobe/Cargo.toml @@ -8,3 +8,6 @@ license = "MIT OR Apache-2.0" toyos = { path = "../../toyos" } toyos-abi = { path = "../../toyos-abi" } toyos-tco = { path = "../../toyos-tco" } + +[package.metadata.toyos.host] +exempt = "it measures the devices of a ToyOS metal boot, each claimed from ToyOS's kernel" diff --git a/userland/netd/Cargo.toml b/userland/netd/Cargo.toml index b7471d2cb9b..b02818e1e0c 100644 --- a/userland/netd/Cargo.toml +++ b/userland/netd/Cargo.toml @@ -26,3 +26,6 @@ features = [ "multicast", "alloc", ] + +[package.metadata.toyos.host] +exempt = "it drives the NIC ToyOS claims for it, and serves the `netd` port" diff --git a/userland/soundd/Cargo.toml b/userland/soundd/Cargo.toml index ba9f4366470..065f87e9715 100644 --- a/userland/soundd/Cargo.toml +++ b/userland/soundd/Cargo.toml @@ -10,3 +10,6 @@ toyos-hda = { path = "../../toyos-hda" } toyos-mixer = { path = "../../toyos-mixer" } toyos-inspect = { path = "../../toyos-inspect" } rubato = "0.16" + +[package.metadata.toyos.host] +exempt = "it claims ToyOS's audio devices, and serves the `soundd` port" diff --git a/userland/swap/Cargo.toml b/userland/swap/Cargo.toml index 4fd877da928..8c6204ba780 100644 --- a/userland/swap/Cargo.toml +++ b/userland/swap/Cargo.toml @@ -8,3 +8,6 @@ license = "MIT OR Apache-2.0" toyos = { path = "../../toyos" } # The words init and the host say about a swap, and the digest it is held to. toyos-swap = { path = "../../toyos-swap" } + +[package.metadata.toyos.host] +exempt = "it replaces a running ToyOS service's binary through init's `swap` port, which only it holds" diff --git a/userland/test-runner/Cargo.toml b/userland/test-runner/Cargo.toml index 638278ace1d..100fa9117cc 100644 --- a/userland/test-runner/Cargo.toml +++ b/userland/test-runner/Cargo.toml @@ -8,3 +8,6 @@ license = "MIT OR Apache-2.0" toyos = { path = "../../toyos" } toyos-abi = { path = "../../toyos-abi" } toyos-tco = { path = "../../toyos-tco" } + +[package.metadata.toyos.host] +exempt = "it runs a ToyOS guest's job list, and stops the machine under its SysCap" diff --git a/userland/update/Cargo.toml b/userland/update/Cargo.toml index 0a179bec84e..6818a5e5be9 100644 --- a/userland/update/Cargo.toml +++ b/userland/update/Cargo.toml @@ -13,3 +13,6 @@ toyos-update = { path = "../../toyos-update" } toyos-fat32 = { path = "../../toyos-fat32" } # ROOT is hashed as it streams onto its partition, never held whole. sha2 = { version = "0.10", default-features = false } + +[package.metadata.toyos.host] +exempt = "it writes ToyOS's idle boot slot, onto the partitions init claims for it" From bd35a4bc57bc159d6ea06ebd0d8c3a79475cb5dc Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 14:09:24 +0200 Subject: [PATCH 2/8] sshd builds on Windows: socket2's fork keeps recvmsg off it `cargo check --manifest-path userland/sshd/Cargo.toml --target x86_64-pc-windows-msvc` stopped in socket2: "cannot find function `recvmsg` in module `sys`". The ToyOS port of socket2 (a35837c) had widened `Socket::recvmsg` from upstream's `all(unix, not(target_os = "redox"))` to every target but Redox and WASI, which takes in Windows. The fork's `toyos` branch gains 48d15f5, which names ToyOS beside upstream's condition, as its other arms do. This moves userland/Cargo.lock's one socket2 entry onto it. No other lockfile or gitlink names that branch. Measured on the macOS dev host: - socket2 alone, `cargo check --all-features`: x86_64-pc-windows-msvc exits 101 at 2b67e7b, and 0 at 48d15f5. x86_64-unknown-linux-gnu and aarch64-apple-darwin exit 0 at 48d15f5. - sshd for x86_64-pc-windows-msvc against this lockfile exits 0. - `cargo run -- --build-only`, with userland's socket2 taken from 48d15f5 through a file:// git source and the tree restored after, exits 0. The log compiles socket2 at 48d15f59 and then sshd for ToyOS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- userland/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 5489d455e17..6315939209a 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -3715,7 +3715,7 @@ dependencies = [ [[package]] name = "socket2" version = "0.6.3" -source = "git+https://github.com/ToyOSOrg/socket2?branch=toyos#2b67e7bd68e0688de1586ba3eb90cfc5d485c6b1" +source = "git+https://github.com/ToyOSOrg/socket2?branch=toyos#48d15f5965852c07b639f72e6261d5810ae70c76" dependencies = [ "libc", "toyos", From 0948dc5058a0c3d5d56fef31fee202c9e492bfeb Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 14:17:12 +0200 Subject: [PATCH 3/8] userlandhost: each refusal's own case, and terser verdicts Two cases in the fixture test reached the catch-all arm before the check they were meant for: a misspelt key alone, and an `issue` outside issues/ that did not exist. Now an unknown key sits beside a valid `exempt`, and the outside path exists and names the app. Each check, mutated away as a checked patch, turns `a_host_declaration_is_read_whole_and_refused_by_name` red with exit 101: the unknown-key filter made to match nothing, and the issues/ prefix test made to refuse only an empty path. The tree was restored after both. `Os::this` is `Os::current`, and the judge's two refusals say what is missing in fewer words. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- src/ci.rs | 2 +- src/userlandhost.rs | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/ci.rs b/src/ci.rs index 1b6f8494c0f..bdb400a755c 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -562,7 +562,7 @@ fn apps_on( os: Os, host_triple: &str, ) -> Result { - let native = Os::this()? == os; + let native = Os::current()? == os; let (verb, triple) = if native { ("build", host_triple) } else { ("check", os.triple()) }; if !native { let status = Command::new("rustup") diff --git a/src/userlandhost.rs b/src/userlandhost.rs index 91bb7557302..d3e34856f93 100644 --- a/src/userlandhost.rs +++ b/src/userlandhost.rs @@ -1,9 +1,9 @@ //! Which userland crates `cargo run -- --ci host` tests, every userland test it //! would run nowhere, and which userland packages it builds on every host. //! -//! **An app builds on Linux, macOS and Windows from the source it builds on -//! ToyOS from.** [`packages`] reads every member of the userland workspace and -//! what its own manifest declares; a member that declares nothing is an app. +//! **An app builds on Linux, macOS and Windows from the same source as on +//! ToyOS.** [`packages`] reads every member of the userland workspace and what +//! its own manifest declares; a member that declares nothing is an app. //! One that by its nature cannot run anywhere but ToyOS, because it owns ToyOS //! devices or kernel objects, says why, and only its tests run on a host: //! @@ -167,7 +167,7 @@ impl Os { } /// The host this build system runs on. - pub fn this() -> Result { + pub fn current() -> Result { Os::named(std::env::consts::OS) .ok_or_else(|| format!("{} is none of the hosts an app builds on", std::env::consts::OS)) } @@ -278,13 +278,12 @@ pub fn judge(dir: &str, fails: Option<&Fails>, os: Os, built: bool) -> Result Ok(built), (false, None) => Err(format!( - "userland/{dir} does not build for {}, and its manifest says neither that it fails \ - there nor that it is exempt", + "userland/{dir} does not build for {} and declares neither `fails` there nor `exempt`", os.name() )), (true, Some(issue)) => Err(format!( - "userland/{dir} builds for {}, which its `fails` still names: the name goes, and its \ - row in {issue}", + "userland/{dir} builds for {}, which its `fails` names: the name goes, and its row in \ + {issue}", os.name() )), } @@ -535,6 +534,7 @@ mod tests { let dir = toyos_tmpdir::TempDir::new("userlandhost-declared"); fs::create_dir_all(dir.join("issues/build")).expect("make the fixture tree"); fs::write(dir.join("issues/build/x.md"), "| `calc` | 101 |\n").expect("write the issue"); + fs::write(dir.join("notes.md"), "| `calc` | 101 |\n").expect("write a note"); let read = |host: &str| { let manifest = format!("[package]\nname = \"calc\"\n{host}"); declared(&manifest.parse().expect("TOML"), &dir, "calc") @@ -543,10 +543,14 @@ mod tests { let issue = "issue = \"issues/build/x.md\"\n"; assert_eq!(read(""), Ok(Host::App(None))); assert_eq!(read("[package.metadata.toyos]\nother = 1\n"), Ok(Host::App(None))); - assert_eq!(read(&format!("{table}exempt = \"it owns a panel\"\n")), Ok(Host::Exempt("it owns a panel".into()))); + assert_eq!( + read(&format!("{table}exempt = \"it owns a panel\"\n")), + Ok(Host::Exempt("it owns a panel".into())) + ); + let on = vec![Os::Windows, Os::Linux]; assert_eq!( read(&format!("{table}fails = [\"windows\", \"linux\"]\n{issue}")), - Ok(Host::App(Some(Fails { on: vec![Os::Windows, Os::Linux], issue: "issues/build/x.md".into() }))) + Ok(Host::App(Some(Fails { on, issue: "issues/build/x.md".into() }))) ); for refused in [ format!("{table}exempt = \" \"\n"), @@ -557,9 +561,10 @@ mod tests { format!("{table}fails = [\"freebsd\"]\n{issue}"), format!("{table}fails = [\"linux\", \"linux\"]\n{issue}"), format!("{table}fails = \"linux\"\n{issue}"), - format!("{table}fails = [\"linux\"]\nissue = \"README.md\"\n"), + format!("{table}fails = [\"linux\"]\nissue = \"notes.md\"\n"), format!("{table}fails = [\"linux\"]\nissue = \"issues/build/gone.md\"\n"), format!("{table}exmept = \"why\"\n"), + format!("{table}exempt = \"why\"\nnote = \"it may grow\"\n"), "[package.metadata.toyos]\nhost = \"exempt\"\n".to_string(), ] { assert!(read(&refused).is_err(), "read {refused:?}"); @@ -573,7 +578,7 @@ mod tests { fn a_build_and_its_declaration_disagreeing_is_red_both_ways() { let fails = Fails { on: vec![Os::Windows], issue: "issues/build/x.md".into() }; assert_eq!(judge("calc", None, Os::Linux, true), Ok(true)); - assert!(judge("calc", None, Os::Linux, false).unwrap_err().contains("neither")); + assert!(judge("calc", None, Os::Linux, false).unwrap_err().contains("declares neither")); assert_eq!(judge("calc", Some(&fails), Os::Windows, false), Ok(false)); assert!(judge("calc", Some(&fails), Os::Windows, true).unwrap_err().contains("goes")); assert!(judge("calc", Some(&fails), Os::Linux, false).is_err(), "it fails on Windows alone"); From 1ee249f6e6043b62474b797e090e66d116649e88 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 14:18:06 +0200 Subject: [PATCH 4/8] reviewer.md: Hosts is a bullet of its own; Linux means Wayland The Hosts bullet had landed inside Fit, ahead of Fit's architecture lines, which it would have taken as its own. It now follows Fit. The owner ruled that on Linux, ToyOS apps support Wayland alone: X11 is legacy, and no X11 backend feature is added or kept. Where the rule text names Linux it now says Wayland, userland/CLAUDE.md says never X11, and an X11 backend is a reviewer's BLOCKER. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- .claude/agents/reviewer.md | 9 +++++---- src/userlandhost.rs | 7 ++++--- userland/CLAUDE.md | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index 1b010a29e15..513afd88ca9 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -45,16 +45,17 @@ above; otherwise it is a NOTE. legacy: no shim, no workaround, no silent default. A new dependency only where it is the cleanest path, a general and widely used crate the pull request says why it takes; no new fetch. Nothing outside the brief's fence. -- **Hosts.** A BLOCKER: a change that makes an app build on fewer of Linux, macOS and Windows, - or answers a host build failure by making the app ToyOS-only, by a split, a `cfg` that compiles - what it does out of a host or an `exempt` in its manifest, instead of fixing it in the app or - its dependencies. `exempt` is for a system server that owns ToyOS devices or kernel objects. Assembly, a naked function and a `core::arch` or `std::arch` path live only in an architecture's own module; `target_arch` only there, in its selector, in `src/arch.rs` and in `src/licence.rs`, which evaluates a dependency's `cfg` as data; `arch::x86_64` and `arch::aarch64` in no generic kernel code; and none of them in a crate whose manifest `description` says pure. A `4096` in the kernel that means a page is a private copy of `mm::PAGE_SIZE`. +- **Hosts.** A BLOCKER: a change that makes an app build on fewer of Linux under Wayland, macOS + and Windows, or answers a host build failure by making the app ToyOS-only, by a split, a `cfg` + that compiles what it does out of a host or an `exempt` in its manifest, instead of fixing it in + the app or its dependencies. `exempt` is for a system server that owns ToyOS devices or kernel + objects. An X11 backend is legacy, and a BLOCKER too. - **Instructions.** A change that removes or renames a command, flag or step an agent runs updates every prompt that names it — each `CLAUDE.md` and `.claude/agents/*.md` — in the same diff, saying what to do instead. Such an instruction is not the prose "Prose is removed, never diff --git a/src/userlandhost.rs b/src/userlandhost.rs index d3e34856f93..561bc48e12a 100644 --- a/src/userlandhost.rs +++ b/src/userlandhost.rs @@ -1,9 +1,10 @@ //! Which userland crates `cargo run -- --ci host` tests, every userland test it //! would run nowhere, and which userland packages it builds on every host. //! -//! **An app builds on Linux, macOS and Windows from the same source as on -//! ToyOS.** [`packages`] reads every member of the userland workspace and what -//! its own manifest declares; a member that declares nothing is an app. +//! **An app builds on Linux under Wayland, macOS and Windows from the same +//! source as on ToyOS.** [`packages`] reads every member of the userland +//! workspace and what its own manifest declares; a member that declares nothing +//! is an app. //! One that by its nature cannot run anywhere but ToyOS, because it owns ToyOS //! devices or kernel objects, says why, and only its tests run on a host: //! diff --git a/userland/CLAUDE.md b/userland/CLAUDE.md index 775230a521f..77d21f4326d 100644 --- a/userland/CLAUDE.md +++ b/userland/CLAUDE.md @@ -2,7 +2,7 @@ The module header at the site owns its subject — surfaces, translators and the channel in `toyos/`'s surface modules, soundd whole under `userland/soundd/`, what a process holds and how it got it at `kernel/src/object/` and `/system/bin/init`. The compositor's decisions are `toyos-desktop/`, pure and host-tested; `userland/compositor/` is devices, handles, shared memory and the panel. POSIX lives in `userland/libc` — ours, not a fork; that layer may be ugly, the kernel may not. -**An app builds and runs on Linux under Wayland, macOS and Windows from the same source as on ToyOS**: a host build that fails is fixed in the app or its dependencies, never by making the app ToyOS-only. Only a program that cannot by its nature, a system server that owns ToyOS devices or kernel objects like the compositor, is exempt, with its reason in its own manifest (`src/userlandhost.rs`). +**An app builds and runs on Linux under Wayland (never X11, which is legacy), macOS and Windows from the same source as on ToyOS**: a host build that fails is fixed in the app or its dependencies, never by making the app ToyOS-only. Only a program that cannot by its nature, a system server that owns ToyOS devices or kernel objects like the compositor, is exempt, with its reason in its own manifest (`src/userlandhost.rs`). **A server never blocks on a client** — the doctrine no single site owns. Accept and the first frame are two events; a frame is buffered until whole before anything acts on it; a write is one `try_send` whose refusal drops the peer by name; a blocking read or write of a pipe the client owns is the same bug. init, the compositor, netd, soundd and every surface host use `ipc::FrameRx`. filepicker violates it today. From 16ba29562f5bcef359814c79014fb86d7180baed Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 14:51:07 +0200 Subject: [PATCH 5/8] The apps that build on no host declare it, in the one issue #667 opened #667 filed what still built or ran on ToyOS alone. This takes that issue over, renamed, because the tree refutes its slug's claim: editor, files, paint and filepicker build on all three hosts. `issues/build/apps-on-toyos-window-terminal-toybox-and-doom-build-for-toyos-alone.md` is now `issues/build/apps-that-build-or-run-on-toyos-alone.md`. Nothing cited the old name. The set is now the one the gate measures. doom, toybox, terminal, shell and proctest build on none of Linux, macOS and Windows. Each declares `fails = ["linux", "macos", "windows"]` against the issue, which names each. Measured on the macOS dev host, natively and by `cargo check --target` for the other two: - shell and proctest reach `std::os::toyos`, as terminal and toybox do. #667's Linux-only table could not show it for those two. - filepicker leaves the issue. It is exempt: init starts it at boot to serve the machine-wide `filepicker` port. - editor, files and paint keep their row. They build, and toyos-window reaches no display but ToyOS's compositor. The exit now says what the gate can fail: no manifest names the file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- ...l-toybox-and-doom-build-for-toyos-alone.md | 25 ------------------ .../apps-that-build-or-run-on-toyos-alone.md | 26 +++++++++++++++++++ userland/doom/Cargo.toml | 4 +++ userland/proctest/Cargo.toml | 4 +++ userland/shell/Cargo.toml | 4 +++ userland/terminal/Cargo.toml | 4 +++ userland/toybox/Cargo.toml | 4 +++ 7 files changed, 46 insertions(+), 25 deletions(-) delete mode 100644 issues/build/apps-on-toyos-window-terminal-toybox-and-doom-build-for-toyos-alone.md create mode 100644 issues/build/apps-that-build-or-run-on-toyos-alone.md diff --git a/issues/build/apps-on-toyos-window-terminal-toybox-and-doom-build-for-toyos-alone.md b/issues/build/apps-on-toyos-window-terminal-toybox-and-doom-build-for-toyos-alone.md deleted file mode 100644 index dcbdf16d871..00000000000 --- a/issues/build/apps-on-toyos-window-terminal-toybox-and-doom-build-for-toyos-alone.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-10-01 ---- - -# Apps on toyos-window, terminal, toybox and doom build or run on ToyOS alone - -A userland app builds and runs on macOS, Linux, Windows and ToyOS from the same -source (owner's ruling). These do not. Each row below is -`cargo check --manifest-path userland//Cargo.toml --target x86_64-unknown-linux-gnu`, -run on the macOS dev host: - -| app | exit | what stops it | -|---|---|---| -| editor, files, paint, filepicker | 0 | they compile, but `toyos-abi/src/syscall.rs` issues ToyOS's `syscall` on every OS, so `toyos-window` reaches no display off ToyOS | -| terminal | 101 | "unresolved import `std::os::toyos`" | -| toybox, doom | 101 | `alsa-sys`, which cpal's Linux backend links: its `build.rs` finds ALSA only through `pkg-config`, so a Linux build needs `libasound2-dev` installed | - -doom's `build.rs` also panics on any target the build system does not name a C -compiler for. With `--target x86_64-pc-windows-msvc` it exits 101 with -"CC_x86_64_pc_windows_msvc is unset". - -**Exit:** each builds with a plain `cargo build` for Linux, macOS and Windows, -and opens its window there. diff --git a/issues/build/apps-that-build-or-run-on-toyos-alone.md b/issues/build/apps-that-build-or-run-on-toyos-alone.md new file mode 100644 index 00000000000..68fa71024a0 --- /dev/null +++ b/issues/build/apps-that-build-or-run-on-toyos-alone.md @@ -0,0 +1,26 @@ +--- +status: open +kind: defect +opened: 2026-10-01 +--- + +# Apps that build or run on ToyOS alone + +An app builds and runs on Linux under Wayland, macOS and Windows from the same +source as on ToyOS (`userland/CLAUDE.md`). These build on none of the +three, and each manifest's `[package.metadata.toyos.host] fails` names all +three, which `cargo run -- --ci host` holds to the build: + +| app | what stops it | +|---|---| +| `doom` | its `build.rs` panics unless the build system names a C compiler in `CC_`; on Linux, cpal's `alsa-sys` finds ALSA only through `pkg-config`, so a build needs `libasound2-dev` | +| `toybox` | `std::os::toyos` in its `stats` and `locale` applets; on Linux, cpal's `alsa-sys`, as doom | +| `terminal` | `std::os::toyos`: it starts its shell with `CommandExt::provide` | +| `shell` | `std::os::toyos`: raw stdin, and `CommandExt::provide` to hand each program the connectors the shell was given | +| `proctest` | `std::os::toyos::io::set_stdin_raw`, and it spawns `/system/bin/echo` and itself by their ToyOS paths | + +`editor`, `files` and `paint` build on all three and run on none: +`toyos-window` reaches no display but ToyOS's compositor, because +`toyos-abi/src/syscall.rs` issues ToyOS's `syscall` on every OS. + +**Exit:** no manifest names this file, and each app above runs on every host. diff --git a/userland/doom/Cargo.toml b/userland/doom/Cargo.toml index feca4d69569..1699e175f30 100644 --- a/userland/doom/Cargo.toml +++ b/userland/doom/Cargo.toml @@ -22,3 +22,7 @@ rustls-rustcrypto = "0.0.2-alpha" webpki-roots = "1" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } tar = "0.4" + +[package.metadata.toyos.host] +fails = ["linux", "macos", "windows"] +issue = "issues/build/apps-that-build-or-run-on-toyos-alone.md" diff --git a/userland/proctest/Cargo.toml b/userland/proctest/Cargo.toml index dee67de1fe1..5cfdb8cce46 100644 --- a/userland/proctest/Cargo.toml +++ b/userland/proctest/Cargo.toml @@ -3,3 +3,7 @@ name = "proctest" version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" + +[package.metadata.toyos.host] +fails = ["linux", "macos", "windows"] +issue = "issues/build/apps-that-build-or-run-on-toyos-alone.md" diff --git a/userland/shell/Cargo.toml b/userland/shell/Cargo.toml index ab97397f224..5c3f55116c5 100644 --- a/userland/shell/Cargo.toml +++ b/userland/shell/Cargo.toml @@ -8,3 +8,7 @@ license = "MIT OR Apache-2.0" toyos = { path = "../../toyos" } # `launch_path`, which is the other half of the gate init refuses a launch on. toyos-manifest = { path = "../../toyos-manifest" } + +[package.metadata.toyos.host] +fails = ["linux", "macos", "windows"] +issue = "issues/build/apps-that-build-or-run-on-toyos-alone.md" diff --git a/userland/terminal/Cargo.toml b/userland/terminal/Cargo.toml index 13c03bd5461..01225368bce 100644 --- a/userland/terminal/Cargo.toml +++ b/userland/terminal/Cargo.toml @@ -11,3 +11,7 @@ toyos-window = { path = "../toyos-window" } [lib] doctest = false + +[package.metadata.toyos.host] +fails = ["linux", "macos", "windows"] +issue = "issues/build/apps-that-build-or-run-on-toyos-alone.md" diff --git a/userland/toybox/Cargo.toml b/userland/toybox/Cargo.toml index 5a7feaf1614..f0f51c154fc 100644 --- a/userland/toybox/Cargo.toml +++ b/userland/toybox/Cargo.toml @@ -10,3 +10,7 @@ toyos-abi = { path = "../../toyos-abi" } toyos-keymap = { path = "../../toyos-keymap" } toyos = { path = "../../toyos" } toyos-window = { path = "../toyos-window" } + +[package.metadata.toyos.host] +fails = ["linux", "macos", "windows"] +issue = "issues/build/apps-that-build-or-run-on-toyos-alone.md" From 6a4e021bb91e5df6f114906d2398db08539432d6 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 15:53:12 +0200 Subject: [PATCH 6/8] socket2's MsgHdrMut import follows recvmsg off Windows ToyOSOrg/socket2 `toyos` 48d15f5 narrowed `Socket::recvmsg` to upstream's condition with ToyOS beside it, and left the `use crate::MsgHdrMut` only `recvmsg` reads under the port's wider one, so on Windows the import is unused and warns. 0b238fb gives the import `recvmsg`'s condition. In the fork, `RUSTFLAGS="-D warnings -A unexpected_cfgs" cargo check --all-features --target x86_64-pc-windows-msvc` exits 101 at 48d15f5 on that warning alone, and 0 at 0b238fb, as it does for x86_64-unknown-linux-gnu and aarch64-apple-darwin at both. `unexpected_cfgs` is allowed because stable rustc knows no `toyos` target and warns on every arm naming it. `userland/Cargo.lock` is the one lockfile or gitlink naming the branch, and its socket2 entry moves to 0b238fb. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- userland/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 76e679fe81c..b21a2ec8efa 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -3801,7 +3801,7 @@ dependencies = [ [[package]] name = "socket2" version = "0.6.3" -source = "git+https://github.com/ToyOSOrg/socket2?branch=toyos#48d15f5965852c07b639f72e6261d5810ae70c76" +source = "git+https://github.com/ToyOSOrg/socket2?branch=toyos#0b238fb870b01ec3a89916eeb96f8a4dc930d571" dependencies = [ "libc", "toyos", From 33f357f271e12d7d9a5dcc89fc91d7d749e9577c Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 16:00:36 +0200 Subject: [PATCH 7/8] The images' programs are the gate's apps, each host is checked one way everywhere, and an exemption names its case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review of 16ba29562 sent the gate back on its universe, its judges and two host needs it did not declare. Each is answered at its root. The universe is `build::shipped`. `Shipped` gains `programs`, its `crates` but the kernel and the loader, from the same `config_crates` walk the build and the licence gate read; `userlandhost::programs` reads each one's manifest. A program outside the userland workspace is judged now: toyos-ld, which checks for all three triples. test-runner and metalprobe ship in no mode's image, so nothing reads a declaration of theirs and both go. One judgement per host. Every host's verdict is `cargo check --target` its triple, on whichever host runs `--ci host`. Before, the PR runner built Linux natively and the nightly's macOS runner checked it, and macOS the reverse, so one `fails` entry was right on one runner and wrong on the other. `Os::current` goes with the native arm. Linux is no longer linked on the PR runner: linking there would make Linux's verdict differ again from the macOS runner's check of it. `issues/build/no-app-is-built-for-a-host-each-is-only-checked.md` records what a check cannot see, on every host, with its exit. A host an app's `fails` names is not attempted. The gate compiled doom and toybox only to see them fail: on Linux cpal's `alsa-sys` ran `pkg-config`, and doom's build script fetched doomgeneric before it read `CC_`. Neither runs now. The ratchet that made a declared failure that builds red goes with it, so a stale `fails` stays until the one who fixes the app removes it. An exemption names its case, the owner's two: `exempt.owns`, the ToyOS devices or kernel objects a program owns (blockd, compositor, console, fsd, init, logd, netd, soundd), or `exempt.manages`, the part of ToyOS it manages (inspect, swap, update), each with what and why. Any other shape is refused by name. filepicker is no longer exempt: it is a file dialog over `std::fs`, and its port is only how it is reached. It checks for all three triples, and the issue records that it runs on none, with editor, files and paint. A `fails` entry's `issue` must be `issues//.md` whose front matter says `open` or `assigned`: `issues/README.md` and `issues/../…` are refused. `userland/CLAUDE.md` and the reviewer's Hosts bullet state the two cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- .claude/agents/reviewer.md | 5 +- .../apps-that-build-or-run-on-toyos-alone.md | 19 +- ...s-built-for-a-host-each-is-only-checked.md | 28 ++ src/build.rs | 15 +- src/ci.rs | 86 +++--- src/userlandhost.rs | 265 +++++++++--------- userland/CLAUDE.md | 2 +- userland/blockd/Cargo.toml | 2 +- userland/compositor/Cargo.toml | 2 +- userland/console/Cargo.toml | 2 +- userland/filepicker/Cargo.toml | 3 - userland/fsd/Cargo.toml | 2 +- userland/init/Cargo.toml | 2 +- userland/inspect/Cargo.toml | 2 +- userland/logd/Cargo.toml | 2 +- userland/metalprobe/Cargo.toml | 3 - userland/netd/Cargo.toml | 2 +- userland/soundd/Cargo.toml | 2 +- userland/swap/Cargo.toml | 2 +- userland/test-runner/Cargo.toml | 3 - userland/update/Cargo.toml | 2 +- 21 files changed, 248 insertions(+), 203 deletions(-) create mode 100644 issues/build/no-app-is-built-for-a-host-each-is-only-checked.md diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index 513afd88ca9..5708adb3aa8 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -54,8 +54,9 @@ above; otherwise it is a NOTE. - **Hosts.** A BLOCKER: a change that makes an app build on fewer of Linux under Wayland, macOS and Windows, or answers a host build failure by making the app ToyOS-only, by a split, a `cfg` that compiles what it does out of a host or an `exempt` in its manifest, instead of fixing it in - the app or its dependencies. `exempt` is for a system server that owns ToyOS devices or kernel - objects. An X11 backend is legacy, and a BLOCKER too. + the app or its dependencies. `exempt` is for a program whose job exists only on ToyOS: it owns + ToyOS devices or kernel objects, or it manages ToyOS itself. An X11 backend is legacy, and a + BLOCKER too. - **Instructions.** A change that removes or renames a command, flag or step an agent runs updates every prompt that names it — each `CLAUDE.md` and `.claude/agents/*.md` — in the same diff, saying what to do instead. Such an instruction is not the prose "Prose is removed, never diff --git a/issues/build/apps-that-build-or-run-on-toyos-alone.md b/issues/build/apps-that-build-or-run-on-toyos-alone.md index 68fa71024a0..a842e0799e4 100644 --- a/issues/build/apps-that-build-or-run-on-toyos-alone.md +++ b/issues/build/apps-that-build-or-run-on-toyos-alone.md @@ -7,20 +7,25 @@ opened: 2026-10-01 # Apps that build or run on ToyOS alone An app builds and runs on Linux under Wayland, macOS and Windows from the same -source as on ToyOS (`userland/CLAUDE.md`). These build on none of the -three, and each manifest's `[package.metadata.toyos.host] fails` names all -three, which `cargo run -- --ci host` holds to the build: +source as on ToyOS (`userland/CLAUDE.md`). These build on none of the three: +each manifest's `[package.metadata.toyos.host] fails` names all three, so +`cargo run -- --ci host` checks none of them there. | app | what stops it | |---|---| -| `doom` | its `build.rs` panics unless the build system names a C compiler in `CC_`; on Linux, cpal's `alsa-sys` finds ALSA only through `pkg-config`, so a build needs `libasound2-dev` | +| `doom` | its `build.rs` compiles doomgeneric's C with the compiler the build system names in `CC_`, and panics without one; a check of another host's triple has no C library for it. On Linux, cpal's `alsa-sys` links ALSA's C library, found through `pkg-config`, and neither is a declared host tool (`issues/build/the-build-runs-host-tools-outside-rust-and-qemu.md`) | | `toybox` | `std::os::toyos` in its `stats` and `locale` applets; on Linux, cpal's `alsa-sys`, as doom | | `terminal` | `std::os::toyos`: it starts its shell with `CommandExt::provide` | | `shell` | `std::os::toyos`: raw stdin, and `CommandExt::provide` to hand each program the connectors the shell was given | | `proctest` | `std::os::toyos::io::set_stdin_raw`, and it spawns `/system/bin/echo` and itself by their ToyOS paths | -`editor`, `files` and `paint` build on all three and run on none: +`editor`, `files`, `paint` and `filepicker` build on all three and run on none: `toyos-window` reaches no display but ToyOS's compositor, because -`toyos-abi/src/syscall.rs` issues ToyOS's `syscall` on every OS. +`toyos-abi/src/syscall.rs` issues ToyOS's `syscall` on every OS. A program +reaches `filepicker` only through the `filepicker` port, which +`filepicker-api` opens with `toyos::endow`, so off ToyOS a pick needs a +transport the same source carries. -**Exit:** no manifest names this file, and each app above runs on every host. +**Exit:** no manifest names this file, and each app above opens its window and +works on each host. The gate reads the first; nobody but a person on each host +judges the second. diff --git a/issues/build/no-app-is-built-for-a-host-each-is-only-checked.md b/issues/build/no-app-is-built-for-a-host-each-is-only-checked.md new file mode 100644 index 00000000000..5d80f41feff --- /dev/null +++ b/issues/build/no-app-is-built-for-a-host-each-is-only-checked.md @@ -0,0 +1,28 @@ +--- +status: open +kind: tooling +opened: 2026-10-01 +--- + +# No app is built for a host: each is only checked + +`cargo run -- --ci host` judges every app the images ship for Linux, macOS and +Windows by `cargo check --target `, the same on whichever host runs it +(`src/userlandhost.rs`). A check links nothing and runs build scripts on the +host that checks, so on every host these go unseen: + +- a link failure, and an error only code generation raises; +- a build script that answers differently on the host it is judged for: one + that runs `cc` or `pkg-config` probes the host that checks, so an app that + compiles C or links a system library cannot pass a check of another host's + triple. + +A `cargo build` where the host is the one judged would give that host a second +verdict beside the check another host makes of it. + +Owner: the host gate, `src/userlandhost.rs`. + +**Exit:** each host's apps are built, `cargo build`, on a runner of that host on +each pull request, and no host checks another's. Windows waits on +`issues/build/the-build-system-does-not-compile-on-windows.md`, macOS on a +macOS runner in `ci.yml`. diff --git a/src/build.rs b/src/build.rs index 81493de21c5..f37b0342292 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1087,6 +1087,8 @@ impl Boot { /// is a test image and is not here. pub struct Shipped { pub crates: BTreeSet<(PathBuf, Features)>, + /// `crates` but the kernel and the loader: every program an image runs. + pub programs: BTreeSet<(PathBuf, Features)>, pub assets: BTreeSet, } @@ -1096,6 +1098,7 @@ pub struct Shipped { /// the rust fork's `compiler/` workspace, which no reader of this answer walks. pub fn shipped(root: &Path) -> Result { let mut crates = BTreeSet::new(); + let mut programs = BTreeSet::new(); let mut assets = BTreeSet::new(); for boot in [Boot::shipped(root), Boot::diag(root), Boot::console(root)] { let config = parse_config(&boot.config); @@ -1105,10 +1108,15 @@ pub fn shipped(root: &Path) -> Result { boot.config.display() )); } - crates.extend(config_crates(root, &config).into_iter().map(|c| (c.dir, c.features))); + for c in config_crates(root, &config) { + if matches!(c.built, Built::Member | Built::Standalone) { + programs.insert((c.dir.clone(), c.features)); + } + crates.insert((c.dir, c.features)); + } assets.extend(config.assets.iter().map(|dir| root.join(dir))); } - Ok(Shipped { crates, assets }) + Ok(Shipped { crates, programs, assets }) } /// The parameters an image built for flashing may carry: the kernel's own boot @@ -2251,6 +2259,9 @@ mod tests { shipped.crates ); } + let (kernel, loader) = (root.join("kernel"), root.join("bootloader")); + let programs = shipped.crates.iter().filter(|(dir, _)| *dir != kernel && *dir != loader); + assert_eq!(shipped.programs, programs.cloned().collect()); } /// **A standalone crate's clean takes all its guest build wrote — the diff --git a/src/ci.rs b/src/ci.rs index bdb400a755c..eb5d4b943a8 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::arch::{Accel, Arch}; -use crate::userlandhost::{Host, Os}; +use crate::userlandhost::{Host, Os, Program}; use crate::{flags, release, sdkversion, sync}; const USAGE: &str = "cargo run -- --ci , where is one of: @@ -453,7 +453,7 @@ fn run_control(root: &Path, control: &Control) -> Result { /// member of the host workspace, clippy with warnings denied, the concurrency /// models' negative controls, every userland crate with a host test /// ([`crate::userlandhost`], which also reds on a userland test none of them -/// runs), every userland app on each host ([`apps_on`]), and the SDK. +/// runs), every app the images ship for each host ([`apps_for`]), and the SDK. /// /// **Every step runs against a `$TMPDIR` of this job's own, and the last step /// reds on anything left in it** but the lock `toyos_tmpdir` keeps there: a test @@ -529,15 +529,15 @@ fn host(root: &Path) -> Vec { } Err(why) => steps.push(Step { label: "the userland host crates".into(), verdict: Err(why) }), } - match crate::userlandhost::packages(&root.join("userland")) { - Ok(packages) => { + match crate::userlandhost::programs(root) { + Ok(programs) => { for os in Os::ALL { - steps.push(step(&format!("the userland apps on {}", os.name()), || { - apps_on(root, &packages, os, &host_triple) + steps.push(step(&format!("the apps for {}", os.name()), || { + apps_for(root, &programs, os) })); } } - Err(why) => steps.push(Step { label: "the userland apps".into(), verdict: Err(why) }), + Err(why) => steps.push(Step { label: "the apps".into(), verdict: Err(why) }), } // The SDK compiles against the ToyOS sysroot everywhere but here, and this // build links no syscall. @@ -548,53 +548,49 @@ fn host(root: &Path) -> Vec { steps } -/// Every userland app built for `os` where this host is `os`, and checked -/// against `os`'s triple where it is not, each judged against what its manifest -/// declares. A Windows runner cannot run this build system -/// (`issues/build/the-build-system-does-not-compile-on-windows.md`), so Windows -/// is checked from the others. +/// Every app the images ship, checked against `os`'s triple with the features +/// its image builds it with, on whichever host this is, but where its manifest +/// declares it fails ([`crate::userlandhost`]). /// /// One cargo per app: features unify across the packages of one invocation, and /// an app that builds only beside another's features is what this gate is for. -fn apps_on( - root: &Path, - packages: &[(String, Host)], - os: Os, - host_triple: &str, -) -> Result { - let native = Os::current()? == os; - let (verb, triple) = if native { ("build", host_triple) } else { ("check", os.triple()) }; - if !native { - let status = Command::new("rustup") - .args(["target", "add", triple]) - .status() - .map_err(|e| format!("rustup: {e}"))?; - if !status.success() { - return Err(format!("rustup target add {triple} exited {status}")); - } +fn apps_for(root: &Path, programs: &[Program], os: Os) -> Result { + let triple = os.triple(); + let status = Command::new("rustup") + .args(["target", "add", triple]) + .status() + .map_err(|e| format!("rustup: {e}"))?; + if !status.success() { + return Err(format!("rustup target add {triple} exited {status}")); } - let (mut built, mut failing, mut wrong) = (0, Vec::new(), Vec::new()); - for (dir, host) in packages { - let Host::App(fails) = host else { continue }; - let manifest = format!("userland/{dir}/Cargo.toml"); - let outcome = cargo(root, &[verb, "--manifest-path", &manifest, "--target", triple]); - match crate::userlandhost::judge(dir, fails.as_ref(), os, outcome.is_ok()) { - Ok(true) => built += 1, - Ok(false) => failing.push(dir.as_str()), - Err(why) => wrong.push(match outcome { - Err(exit) => format!("{why}: {exit}"), - Ok(_) => why, - }), + let (mut checked, mut declared, mut red) = (0, Vec::new(), Vec::new()); + for program in programs { + let Host::App(fails) = &program.host else { continue }; + if fails.contains(&os) { + declared.push(program.dir.as_str()); + continue; + } + let manifest = format!("{}/Cargo.toml", program.dir); + let mut args = vec!["check", "--manifest-path", manifest.as_str(), "--target", triple]; + args.extend(program.features.args()); + match cargo(root, &args) { + Ok(_) => checked += 1, + Err(exit) => red.push(format!( + "{} does not compile for {} and declares neither `fails` there nor `exempt`: \ + {exit}", + program.dir, + os.name() + )), } } - if !wrong.is_empty() { - return Err(wrong.join("; ")); + if !red.is_empty() { + return Err(red.join("; ")); } - let said = format!("{built} app(s) pass `cargo {verb} --target {triple}`"); - if failing.is_empty() { + let said = format!("{checked} app(s) pass `cargo check --target {triple}`"); + if declared.is_empty() { Ok(said) } else { - Ok(format!("{said}; {} fail, as their manifests declare", failing.join(", "))) + Ok(format!("{said}; {} not attempted, as their manifests declare", declared.join(", "))) } } diff --git a/src/userlandhost.rs b/src/userlandhost.rs index 561bc48e12a..7cb912ba84d 100644 --- a/src/userlandhost.rs +++ b/src/userlandhost.rs @@ -1,20 +1,20 @@ //! Which userland crates `cargo run -- --ci host` tests, every userland test it -//! would run nowhere, and which userland packages it builds on every host. +//! would run nowhere, and what every program the images ship declares about the +//! hosts. //! -//! **An app builds on Linux under Wayland, macOS and Windows from the same -//! source as on ToyOS.** [`packages`] reads every member of the userland -//! workspace and what its own manifest declares; a member that declares nothing -//! is an app. -//! One that by its nature cannot run anywhere but ToyOS, because it owns ToyOS -//! devices or kernel objects, says why, and only its tests run on a host: +//! [`programs`] reads each program [`crate::build::shipped`] names, and its own +//! manifest. One that declares nothing is an app, and builds for Linux, macOS +//! and Windows. One whose job exists only on ToyOS names which of the two cases +//! it is, and why: `exempt.owns` the ToyOS devices or kernel objects it owns, +//! or `exempt.manages` the part of ToyOS it manages. //! //! ```toml //! [package.metadata.toyos.host] -//! exempt = "it drives the NVMe controller ToyOS claims for it" +//! exempt.owns = "the NVMe controller ToyOS claims for it" //! ``` //! -//! An app that does not build on a host yet names that host and the issue that -//! records it, which names the app: +//! An app that does not build for a host yet names the host, and the open issue +//! that records it and names the app. The gate does not check it there: //! //! ```toml //! [package.metadata.toyos.host] @@ -22,9 +22,12 @@ //! issue = "issues/build/.md" //! ``` //! -//! `host` builds every app for its own host and checks it against the other two -//! hosts' triples ([`Os::triple`]), and [`judge`] reds where a build and its -//! manifest disagree, both ways: so a declared failure goes when the app builds. +//! A host's verdict is `cargo check --target` its triple ([`Os::triple`]), the +//! same on whichever host runs it. A check links nothing, and runs the build +//! scripts on the host that checks +//! (`issues/build/no-app-is-built-for-a-host-each-is-only-checked.md`). An app +//! whose work a `cfg` compiles out of a host checks green there: only review +//! holds that (`.claude/agents/reviewer.md`, Hosts). //! //! `userland/` is a workspace of its own that cross-compiles by default //! (`userland/.cargo/config.toml`), so none of its crates can be a member of the @@ -50,6 +53,8 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; +use crate::build::Features; + /// What [`survey`] found under one `userland/` directory. #[derive(Debug, PartialEq, Eq)] pub struct Survey { @@ -134,8 +139,7 @@ pub fn survey(userland: &Path) -> Result { Ok(Survey { gated: gated.into_iter().collect(), escapes: escapes.into_iter().collect() }) } -/// A host the userland apps build on, as `std::env::consts::OS` and a manifest -/// spell it. +/// A host an app builds for, as a manifest spells it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Os { Linux, @@ -154,7 +158,7 @@ impl Os { } } - /// The triple another host checks this one's build against. + /// The triple every host checks this one's apps against. pub fn triple(self) -> &'static str { match self { Os::Linux => "x86_64-unknown-linux-gnu", @@ -166,51 +170,39 @@ impl Os { fn named(name: &str) -> Option { Os::ALL.into_iter().find(|os| os.name() == name) } - - /// The host this build system runs on. - pub fn current() -> Result { - Os::named(std::env::consts::OS) - .ok_or_else(|| format!("{} is none of the hosts an app builds on", std::env::consts::OS)) - } } -/// What a userland package's manifest declares about the hosts. +/// What a program's manifest declares about the hosts. #[derive(Debug, PartialEq, Eq)] pub enum Host { - /// Builds on every host, or on every host but the ones it fails on. - App(Option), - /// Runs on ToyOS alone, and why. - Exempt(String), + /// An app: it builds for every host but these, which an open issue records. + App(Vec), + /// Its job exists only on ToyOS. + Exempt, } -/// The hosts an app does not build on yet, and the issue that records them. -#[derive(Debug, PartialEq, Eq)] -pub struct Fails { - pub on: Vec, - pub issue: String, +/// One program the images ship. +#[derive(Debug)] +pub struct Program { + /// Its directory under the repository. + pub dir: String, + /// What the image builds it with. + pub features: Features, + pub host: Host, } -/// Every member of the userland workspace by its path under `userland`, and -/// what its manifest declares about the hosts. -pub fn packages(userland: &Path) -> Result, String> { - let read = |path: PathBuf| -> Result { - let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; - text.parse().map_err(|e| format!("{}: not TOML: {e}", path.display())) - }; - let root = userland.parent().ok_or("userland has no parent")?; - let workspace = read(userland.join("Cargo.toml"))?; - let members = workspace - .get("workspace") - .and_then(|w| w.get("members")) - .and_then(toml::Value::as_array) - .ok_or("userland/Cargo.toml lists no [workspace] members")?; +/// Every program [`crate::build::shipped`] names, and what its manifest +/// declares about the hosts. +pub fn programs(root: &Path) -> Result, String> { let mut found = Vec::new(); - for member in members { - let dir = member.as_str().ok_or_else(|| format!("a member that is not a path: {member}"))?; - let manifest = read(userland.join(dir).join("Cargo.toml"))?; - let host = declared(&manifest, root, dir) - .map_err(|why| format!("userland/{dir}/Cargo.toml: {why}"))?; - found.push((dir.to_string(), host)); + for (dir, features) in crate::build::shipped(root)?.programs { + let at = rel(root, &dir); + let text = std::fs::read_to_string(dir.join("Cargo.toml")) + .map_err(|e| format!("{at}/Cargo.toml: {e}"))?; + let manifest: toml::Value = + text.parse().map_err(|e| format!("{at}/Cargo.toml: not TOML: {e}"))?; + let host = declared(&manifest, root).map_err(|why| format!("{at}/Cargo.toml: {why}"))?; + found.push(Program { dir: at, features, host }); } Ok(found) } @@ -218,29 +210,31 @@ pub fn packages(userland: &Path) -> Result, String> { /// `[package.metadata.toyos.host]`, read whole: anything it does not know is /// refused, because a misspelt key would make an exempt program an app or a /// failing one quiet. -fn declared(manifest: &toml::Value, root: &Path, dir: &str) -> Result { - let Some(host) = manifest - .get("package") - .and_then(|p| p.get("metadata")) - .and_then(|m| m.get("toyos")) - .and_then(|t| t.get("host")) - else { - return Ok(Host::App(None)); +fn declared(manifest: &toml::Value, root: &Path) -> Result { + let package = manifest.get("package").ok_or("no [package]")?; + let toyos = package.get("metadata").and_then(|m| m.get("toyos")); + let Some(host) = toyos.and_then(|t| t.get("host")) else { + return Ok(Host::App(Vec::new())); }; let host = host.as_table().ok_or("[package.metadata.toyos.host] is not a table")?; - let string = |key: &str| match host.get(key) { - Some(v) => v - .as_str() - .filter(|s| !s.trim().is_empty()) - .map(|s| Some(s.to_string())) - .ok_or_else(|| format!("`{key}` is not a sentence")), - None => Ok(None), - }; if let Some(key) = host.keys().find(|k| !["exempt", "fails", "issue"].contains(&k.as_str())) { return Err(format!("[package.metadata.toyos.host] declares `{key}`, which nothing reads")); } - match (string("exempt")?, host.get("fails"), string("issue")?) { - (Some(why), None, None) => Ok(Host::Exempt(why)), + match (host.get("exempt"), host.get("fails"), host.get("issue")) { + (Some(exempt), None, None) => { + let case = exempt.as_table().filter(|t| t.len() == 1).and_then(|t| t.iter().next()); + match case { + Some((case, why)) + if ["owns", "manages"].contains(&case.as_str()) + && why.as_str().is_some_and(|why| !why.trim().is_empty()) => + { + Ok(Host::Exempt) + } + _ => Err("`exempt` is `exempt.owns`, the ToyOS devices or kernel objects it owns, \ + or `exempt.manages`, the part of ToyOS it manages" + .into()), + } + } (None, Some(fails), Some(issue)) => { let mut on = Vec::new(); for name in fails.as_array().into_iter().flatten() { @@ -255,15 +249,10 @@ fn declared(manifest: &toml::Value, root: &Path, dir: &str) -> Result Err("[package.metadata.toyos.host] declares `exempt` alone, or `fails` with its \ `issue`" @@ -271,23 +260,34 @@ fn declared(manifest: &toml::Value, root: &Path, dir: &str) -> Result, os: Os, built: bool) -> Result { - let declared = fails.filter(|f| f.on.contains(&os)).map(|f| &f.issue); - match (built, declared) { - (true, None) | (false, Some(_)) => Ok(built), - (false, None) => Err(format!( - "userland/{dir} does not build for {} and declares neither `fails` there nor `exempt`", - os.name() - )), - (true, Some(issue)) => Err(format!( - "userland/{dir} builds for {}, which its `fails` names: the name goes, and its row in \ - {issue}", - os.name() - )), +/// `issue` is `issues//.md`, work still owed, and names `app` in +/// backticks. +fn owed(root: &Path, issue: &str, app: &str) -> Result<(), String> { + let word = |s: &str| { + !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + }; + let shaped = match issue.split('/').collect::>()[..] { + ["issues", area, slug] => word(area) && slug.strip_suffix(".md").is_some_and(word), + _ => false, + }; + if !shaped { + return Err(format!("`issue` is {issue:?}, which is no issues//.md")); } + let text = std::fs::read_to_string(root.join(issue)) + .map_err(|e| format!("`issue` is {issue}, which does not open: {e}"))?; + let status = text + .strip_prefix("---\n") + .and_then(|rest| rest.split("\n---\n").next()) + .into_iter() + .flat_map(str::lines) + .find_map(|line| line.strip_prefix("status: ")); + if !matches!(status, Some("open" | "assigned")) { + return Err(format!("{issue} is no work still owed: its status is {status:?}")); + } + if !text.contains(&format!("`{app}`")) { + return Err(format!("{issue} does not name `{app}`, so the set it records is short")); + } + Ok(()) } /// `path` under `base`, with forward slashes. @@ -522,40 +522,60 @@ mod tests { ); } - /// **Every userland package is an app or says why it is not.** + /// **Every program the images ship is an app or says why it is not**, the + /// ones outside the userland workspace too. #[test] - fn every_userland_package_declares_what_it_is_to_a_host() { - let packages = packages(&repo_root().join("userland")).expect("every declaration reads"); - assert!(packages.iter().any(|(_, h)| matches!(h, Host::App(_))), "no app: {packages:?}"); - assert!(packages.iter().any(|(_, h)| matches!(h, Host::Exempt(_))), "no exemption"); + fn every_program_the_images_ship_declares_what_it_is_to_a_host() { + let root = repo_root(); + let programs = programs(&root).expect("every declaration reads"); + let shipped = crate::build::shipped(&root).expect("the modes' configs").programs; + let dirs: BTreeSet = programs.iter().map(|p| root.join(&p.dir)).collect(); + assert_eq!(dirs, shipped.into_iter().map(|(dir, _)| dir).collect()); + assert!(programs.iter().any(|p| p.host == Host::App(Vec::new())), "no app: {programs:?}"); + assert!(programs.iter().any(|p| p.host == Host::Exempt), "no exemption: {programs:?}"); } #[test] fn a_host_declaration_is_read_whole_and_refused_by_name() { let dir = toyos_tmpdir::TempDir::new("userlandhost-declared"); - fs::create_dir_all(dir.join("issues/build")).expect("make the fixture tree"); - fs::write(dir.join("issues/build/x.md"), "| `calc` | 101 |\n").expect("write the issue"); - fs::write(dir.join("notes.md"), "| `calc` | 101 |\n").expect("write a note"); + let put = |path: &str, status: &str| { + let path = dir.join(path); + fs::create_dir_all(path.parent().expect("a parent")).expect("make the fixture tree"); + let text = format!("---\nstatus: {status}\nkind: defect\n---\n\n| `calc` | 101 |\n"); + fs::write(path, text).expect("write a fixture issue"); + }; + put("issues/build/x.md", "open"); + put("issues/build/held.md", "assigned"); + put("issues/build/asked.md", "owner"); + put("issues/README.md", "open"); + put("notes.md", "open"); let read = |host: &str| { let manifest = format!("[package]\nname = \"calc\"\n{host}"); - declared(&manifest.parse().expect("TOML"), &dir, "calc") + declared(&manifest.parse().expect("TOML"), &dir) }; let table = "[package.metadata.toyos.host]\n"; let issue = "issue = \"issues/build/x.md\"\n"; - assert_eq!(read(""), Ok(Host::App(None))); - assert_eq!(read("[package.metadata.toyos]\nother = 1\n"), Ok(Host::App(None))); + assert_eq!(read(""), Ok(Host::App(Vec::new()))); + assert_eq!(read("[package.metadata.toyos]\nother = 1\n"), Ok(Host::App(Vec::new()))); + for case in ["owns", "manages"] { + assert_eq!(read(&format!("{table}exempt.{case} = \"a panel\"\n")), Ok(Host::Exempt)); + } assert_eq!( - read(&format!("{table}exempt = \"it owns a panel\"\n")), - Ok(Host::Exempt("it owns a panel".into())) + read(&format!("{table}fails = [\"windows\", \"linux\"]\n{issue}")), + Ok(Host::App(vec![Os::Windows, Os::Linux])) ); - let on = vec![Os::Windows, Os::Linux]; assert_eq!( - read(&format!("{table}fails = [\"windows\", \"linux\"]\n{issue}")), - Ok(Host::App(Some(Fails { on, issue: "issues/build/x.md".into() }))) + read(&format!("{table}fails = [\"linux\"]\nissue = \"issues/build/held.md\"\n")), + Ok(Host::App(vec![Os::Linux])) ); for refused in [ - format!("{table}exempt = \" \"\n"), - format!("{table}exempt = \"why\"\nfails = [\"linux\"]\n{issue}"), + format!("{table}exempt = \"a panel\"\n"), + format!("{table}exempt.serves = \"a port\"\n"), + format!("{table}exempt.owns = \" \"\n"), + format!("{table}exempt.owns = 1\n"), + format!("{table}exempt = {{}}\n"), + format!("{table}exempt = {{ owns = \"a panel\", manages = \"a slot\" }}\n"), + format!("{table}exempt.owns = \"a panel\"\nfails = [\"linux\"]\n{issue}"), format!("{table}fails = [\"linux\"]\n"), format!("{table}{issue}"), format!("{table}fails = []\n{issue}"), @@ -563,28 +583,21 @@ mod tests { format!("{table}fails = [\"linux\", \"linux\"]\n{issue}"), format!("{table}fails = \"linux\"\n{issue}"), format!("{table}fails = [\"linux\"]\nissue = \"notes.md\"\n"), + format!("{table}fails = [\"linux\"]\nissue = \"issues/README.md\"\n"), + format!("{table}fails = [\"linux\"]\nissue = \"issues/../notes.md\"\n"), + format!("{table}fails = [\"linux\"]\nissue = \"issues/build/asked.md\"\n"), format!("{table}fails = [\"linux\"]\nissue = \"issues/build/gone.md\"\n"), - format!("{table}exmept = \"why\"\n"), - format!("{table}exempt = \"why\"\nnote = \"it may grow\"\n"), + format!("{table}exmept.owns = \"a panel\"\n"), + format!("{table}exempt.owns = \"a panel\"\nnote = \"it may grow\"\n"), "[package.metadata.toyos]\nhost = \"exempt\"\n".to_string(), ] { assert!(read(&refused).is_err(), "read {refused:?}"); } let unnamed = format!("[package]\nname = \"snake\"\n{table}fails = [\"linux\"]\n{issue}"); - let refusal = declared(&unnamed.parse().expect("TOML"), &dir, "snake").unwrap_err(); + let refusal = declared(&unnamed.parse().expect("TOML"), &dir).unwrap_err(); assert!(refusal.contains("does not name `snake`"), "{refusal}"); } - #[test] - fn a_build_and_its_declaration_disagreeing_is_red_both_ways() { - let fails = Fails { on: vec![Os::Windows], issue: "issues/build/x.md".into() }; - assert_eq!(judge("calc", None, Os::Linux, true), Ok(true)); - assert!(judge("calc", None, Os::Linux, false).unwrap_err().contains("declares neither")); - assert_eq!(judge("calc", Some(&fails), Os::Windows, false), Ok(false)); - assert!(judge("calc", Some(&fails), Os::Windows, true).unwrap_err().contains("goes")); - assert!(judge("calc", Some(&fails), Os::Linux, false).is_err(), "it fails on Windows alone"); - } - #[test] fn a_test_attribute_is_a_word_and_not_a_substring() { let test = |text| scan(text).expect("closes").test; diff --git a/userland/CLAUDE.md b/userland/CLAUDE.md index 77d21f4326d..696caf47178 100644 --- a/userland/CLAUDE.md +++ b/userland/CLAUDE.md @@ -2,7 +2,7 @@ The module header at the site owns its subject — surfaces, translators and the channel in `toyos/`'s surface modules, soundd whole under `userland/soundd/`, what a process holds and how it got it at `kernel/src/object/` and `/system/bin/init`. The compositor's decisions are `toyos-desktop/`, pure and host-tested; `userland/compositor/` is devices, handles, shared memory and the panel. POSIX lives in `userland/libc` — ours, not a fork; that layer may be ugly, the kernel may not. -**An app builds and runs on Linux under Wayland (never X11, which is legacy), macOS and Windows from the same source as on ToyOS**: a host build that fails is fixed in the app or its dependencies, never by making the app ToyOS-only. Only a program that cannot by its nature, a system server that owns ToyOS devices or kernel objects like the compositor, is exempt, with its reason in its own manifest (`src/userlandhost.rs`). +**An app builds and runs on Linux under Wayland (never X11, which is legacy), macOS and Windows from the same source as on ToyOS**: a host build that fails is fixed in the app or its dependencies, never by making the app ToyOS-only. A program is ToyOS-only by nature when its job exists only on ToyOS: it owns ToyOS devices or kernel objects, as the system servers do, or it manages ToyOS itself, as `update` does. Its manifest names which, and why (`src/userlandhost.rs`). **A server never blocks on a client** — the doctrine no single site owns. Accept and the first frame are two events; a frame is buffered until whole before anything acts on it; a write is one `try_send` whose refusal drops the peer by name; a blocking read or write of a pipe the client owns is the same bug. init, the compositor, netd, soundd and every surface host use `ipc::FrameRx`. filepicker violates it today. diff --git a/userland/blockd/Cargo.toml b/userland/blockd/Cargo.toml index cac73a0847a..b7fd9d62088 100644 --- a/userland/blockd/Cargo.toml +++ b/userland/blockd/Cargo.toml @@ -22,4 +22,4 @@ toyos-blockhold = { path = "../../toyos-blockhold" } toyos-gpt = { path = "../../toyos-gpt" } [package.metadata.toyos.host] -exempt = "it drives the NVMe controller ToyOS claims for it, and serves its partitions on the `block` port" +exempt.owns = "the NVMe controller ToyOS claims for it, whose partitions it serves on the `block` port" diff --git a/userland/compositor/Cargo.toml b/userland/compositor/Cargo.toml index f799ce5b06d..4c16bdcd305 100644 --- a/userland/compositor/Cargo.toml +++ b/userland/compositor/Cargo.toml @@ -15,4 +15,4 @@ toyos-manifest = { path = "../../toyos-manifest" } toyos-window = { path = "../toyos-window" } [package.metadata.toyos.host] -exempt = "it claims ToyOS's framebuffer, keyboard and mouse, and serves the `compositor` port" +exempt.owns = "ToyOS's framebuffer, keyboard and mouse, claimed from the kernel, and the `compositor` port it serves" diff --git a/userland/console/Cargo.toml b/userland/console/Cargo.toml index a2bbef194b8..4e35113d4fc 100644 --- a/userland/console/Cargo.toml +++ b/userland/console/Cargo.toml @@ -13,4 +13,4 @@ toyos-window = { path = "../toyos-window" } toyos-logstream = { path = "../../toyos-logstream" } [package.metadata.toyos.host] -exempt = "it claims ToyOS's framebuffer, and draws the log ToyOS's logd keeps" +exempt.owns = "ToyOS's framebuffer, keyboard and mouse, claimed from the kernel, on which it runs the shell" diff --git a/userland/filepicker/Cargo.toml b/userland/filepicker/Cargo.toml index 30148359a19..e340b33eb01 100644 --- a/userland/filepicker/Cargo.toml +++ b/userland/filepicker/Cargo.toml @@ -9,6 +9,3 @@ filepicker-api = { path = "../filepicker-api" } toyos = { path = "../../toyos" } toyos-font = { path = "../toyos-font" } toyos-window = { path = "../toyos-window" } - -[package.metadata.toyos.host] -exempt = "init starts it at boot to serve ToyOS's machine-wide `filepicker` port" diff --git a/userland/fsd/Cargo.toml b/userland/fsd/Cargo.toml index 409f130bec6..815db281a66 100644 --- a/userland/fsd/Cargo.toml +++ b/userland/fsd/Cargo.toml @@ -24,4 +24,4 @@ bcachefs = { path = "../../bcachefs" } blockd = { path = "../blockd" } [package.metadata.toyos.host] -exempt = "it serves ToyOS's storage roles from the partitions ToyOS claims for it" +exempt.owns = "ToyOS's storage roles, served from the partitions ToyOS claims for it" diff --git a/userland/init/Cargo.toml b/userland/init/Cargo.toml index bc6c0e0adb5..aaf6d4ac0b8 100644 --- a/userland/init/Cargo.toml +++ b/userland/init/Cargo.toml @@ -20,4 +20,4 @@ toyos-blockring = { path = "../../toyos-blockring" } toyos-quiesce = { path = "../../toyos-quiesce" } [package.metadata.toyos.host] -exempt = "it is the program ToyOS's kernel starts, and holds the machine's system capability" +exempt.owns = "the machine's system capability: the kernel starts it, and it builds every program's namespace" diff --git a/userland/inspect/Cargo.toml b/userland/inspect/Cargo.toml index 78ab61df2cc..55a04b24f39 100644 --- a/userland/inspect/Cargo.toml +++ b/userland/inspect/Cargo.toml @@ -10,4 +10,4 @@ toyos-abi = { path = "../../toyos-abi" } toyos-inspect = { path = "../../toyos-inspect" } [package.metadata.toyos.host] -exempt = "it reads what ToyOS's servers and kernel say of themselves, through their ports and the `inventory` SysCap" +exempt.manages = "ToyOS's machine inventory: what every owner's port and the kernel's `inventory` SysCap report" diff --git a/userland/logd/Cargo.toml b/userland/logd/Cargo.toml index e007f1400a6..a42f2b6a164 100644 --- a/userland/logd/Cargo.toml +++ b/userland/logd/Cargo.toml @@ -14,4 +14,4 @@ toyos-inspect = { path = "../../toyos-inspect" } toyos-wallclock = { path = "../../toyos-wallclock" } [package.metadata.toyos.host] -exempt = "it reads the ToyOS kernel's record ring under the `logread` SysCap, and serves the `log` port" +exempt.owns = "the kernel's record ring, read under the `logread` SysCap, and the `log` port it serves" diff --git a/userland/metalprobe/Cargo.toml b/userland/metalprobe/Cargo.toml index 95a9bda5cd5..c35e0ca43ac 100644 --- a/userland/metalprobe/Cargo.toml +++ b/userland/metalprobe/Cargo.toml @@ -8,6 +8,3 @@ license = "MIT OR Apache-2.0" toyos = { path = "../../toyos" } toyos-abi = { path = "../../toyos-abi" } toyos-tco = { path = "../../toyos-tco" } - -[package.metadata.toyos.host] -exempt = "it measures the devices of a ToyOS metal boot, each claimed from ToyOS's kernel" diff --git a/userland/netd/Cargo.toml b/userland/netd/Cargo.toml index b02818e1e0c..488cff91e40 100644 --- a/userland/netd/Cargo.toml +++ b/userland/netd/Cargo.toml @@ -28,4 +28,4 @@ features = [ ] [package.metadata.toyos.host] -exempt = "it drives the NIC ToyOS claims for it, and serves the `netd` port" +exempt.owns = "the NIC ToyOS claims for it, and the `netd` port it serves" diff --git a/userland/soundd/Cargo.toml b/userland/soundd/Cargo.toml index 065f87e9715..6f9cf8c5cb4 100644 --- a/userland/soundd/Cargo.toml +++ b/userland/soundd/Cargo.toml @@ -12,4 +12,4 @@ toyos-inspect = { path = "../../toyos-inspect" } rubato = "0.16" [package.metadata.toyos.host] -exempt = "it claims ToyOS's audio devices, and serves the `soundd` port" +exempt.owns = "ToyOS's audio devices, claimed from the kernel, and the `soundd` port it serves" diff --git a/userland/swap/Cargo.toml b/userland/swap/Cargo.toml index 8c6204ba780..c691d6f26dc 100644 --- a/userland/swap/Cargo.toml +++ b/userland/swap/Cargo.toml @@ -10,4 +10,4 @@ toyos = { path = "../../toyos" } toyos-swap = { path = "../../toyos-swap" } [package.metadata.toyos.host] -exempt = "it replaces a running ToyOS service's binary through init's `swap` port, which only it holds" +exempt.manages = "ToyOS's running services: it replaces one's binary through init's `swap` port" diff --git a/userland/test-runner/Cargo.toml b/userland/test-runner/Cargo.toml index 100fa9117cc..638278ace1d 100644 --- a/userland/test-runner/Cargo.toml +++ b/userland/test-runner/Cargo.toml @@ -8,6 +8,3 @@ license = "MIT OR Apache-2.0" toyos = { path = "../../toyos" } toyos-abi = { path = "../../toyos-abi" } toyos-tco = { path = "../../toyos-tco" } - -[package.metadata.toyos.host] -exempt = "it runs a ToyOS guest's job list, and stops the machine under its SysCap" diff --git a/userland/update/Cargo.toml b/userland/update/Cargo.toml index 6818a5e5be9..769af88885e 100644 --- a/userland/update/Cargo.toml +++ b/userland/update/Cargo.toml @@ -15,4 +15,4 @@ toyos-fat32 = { path = "../../toyos-fat32" } sha2 = { version = "0.10", default-features = false } [package.metadata.toyos.host] -exempt = "it writes ToyOS's idle boot slot, onto the partitions init claims for it" +exempt.manages = "ToyOS's boot slots: it writes the idle one, onto the partitions init claims for it" From 207bdde973c69cb8721efe8eb663f995a097fd81 Mon Sep 17 00:00:00 2001 From: japabu Date: Thu, 1 Oct 2026 16:54:10 +0200 Subject: [PATCH 8/8] Review round 3: the per-host skip is tested, a host's apps are built where the gate runs on it, and a stale `fails` is recorded `apps_for`'s two per-host decisions are pure functions now, `attempted` and `verb`, and `an_app_is_judged_for_every_host_its_fails_does_not_name` holds both with no cargo run: an app whose `fails` is `[windows]` is judged for Linux and macOS and skipped for Windows, an exempt program is judged for none, and on a Linux host Linux's apps are built and the other two hosts' checked. Nothing tested the skip before: every `fails` in the tree names all three hosts, so `if !fails.is_empty()` in place of `if fails.contains(&os)` stayed green everywhere. Where the gate runs on the host it judges, it builds: `cargo build --target ` when the triple is the host's own, and `cargo check --target ` elsewhere. On ubuntu-24.04 that links Linux's apps on every pull request, and on the nightly's macOS runner, macOS's. Round 2 dropped the link so that a host would have one verdict on every runner, which matters only beside a ratchet. A declared host is attempted on no runner, so a failure only one runner sees is answered by `fails` and deadlocks nothing. On the macOS dev host, from an empty target directory, checking the 11 apps for aarch64-apple-darwin took 20 s and 443 MB, and building them 26 s and 1.2 GB, exit 0 each. A `fails` entry that outlives its fix keeps that host unjudged while it claims the app fails there. Attempting declared hosts, red when one builds, is not sound while two runners judge one host. For example, once doom's build.rs compiles with the host's C compiler, doom builds for macOS on the macOS runner and fails the Linux runner's check of macOS. Its `fails` entry would then be red whether it stays or goes. The gap is recorded where the other gaps of judging a host from another host's runner are. That issue was `no-app-is-built-for-a-host-each-is-only-checked`, and its title stopped being true when Linux's apps began to be built. It is now `a-hosts-apps-are-judged-on-other-hosts-runners`, and its exit attempts each declared host on that host's own runner. Deleted as the review asked: - "the same on whichever host runs it", which a build script that probes the host that checks makes false; - the served port in the exemptions of blockd, compositor, logd, netd and soundd. filepicker serves a port and is an app, so the device or SysCap claim alone carries each exemption; - "as the system servers do," in `userland/CLAUDE.md`, since filepicker is a system server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L --- ...-apps-are-judged-on-other-hosts-runners.md | 33 +++++++ ...s-built-for-a-host-each-is-only-checked.md | 28 ------ src/ci.rs | 88 ++++++++++++++----- src/userlandhost.rs | 13 +-- userland/CLAUDE.md | 2 +- userland/blockd/Cargo.toml | 2 +- userland/compositor/Cargo.toml | 2 +- userland/logd/Cargo.toml | 2 +- userland/netd/Cargo.toml | 2 +- userland/soundd/Cargo.toml | 2 +- 10 files changed, 114 insertions(+), 60 deletions(-) create mode 100644 issues/build/a-hosts-apps-are-judged-on-other-hosts-runners.md delete mode 100644 issues/build/no-app-is-built-for-a-host-each-is-only-checked.md diff --git a/issues/build/a-hosts-apps-are-judged-on-other-hosts-runners.md b/issues/build/a-hosts-apps-are-judged-on-other-hosts-runners.md new file mode 100644 index 00000000000..b0a0b4a42ba --- /dev/null +++ b/issues/build/a-hosts-apps-are-judged-on-other-hosts-runners.md @@ -0,0 +1,33 @@ +--- +status: open +kind: tooling +opened: 2026-10-01 +--- + +# A host's apps are judged on other hosts' runners + +`cargo run -- --ci host` builds each host's apps where it runs on that host's +triple, and checks them against it elsewhere (`src/userlandhost.rs`). `ci.yml` +runs it on Linux alone and the nightly on Linux and macOS, so on a pull +request macOS's and Windows's apps are only checked, and Windows's are built +nowhere. These go unseen: + +- on a host whose apps are only checked, a link failure, and an error only code + generation raises; +- a build script that answers differently on the host it is judged for: one + that runs `cc` or `pkg-config` probes the host that checks, so an app that + compiles C or links a system library cannot pass a check of another host's + triple; +- a declared failure that outlives its fix. A host an app's `fails` names is + attempted on no runner, so the declaration keeps that host unjudged while it + claims the app fails there. Attempting it, red when the app builds, is sound + only where one runner judges the host: while two do, a fix that passes on one + and fails on the other is red whether its `fails` entry stays or goes. + +Owner: the host gate, `src/userlandhost.rs` and `src/ci.rs`'s `apps_for`. + +**Exit:** on each pull request, a runner of each host builds that host's apps, +judges no other host's, and attempts each app whose `fails` names its host, red +when one builds. Windows waits on +`issues/build/the-build-system-does-not-compile-on-windows.md`, macOS on a +macOS runner in `ci.yml`. diff --git a/issues/build/no-app-is-built-for-a-host-each-is-only-checked.md b/issues/build/no-app-is-built-for-a-host-each-is-only-checked.md deleted file mode 100644 index 5d80f41feff..00000000000 --- a/issues/build/no-app-is-built-for-a-host-each-is-only-checked.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -status: open -kind: tooling -opened: 2026-10-01 ---- - -# No app is built for a host: each is only checked - -`cargo run -- --ci host` judges every app the images ship for Linux, macOS and -Windows by `cargo check --target `, the same on whichever host runs it -(`src/userlandhost.rs`). A check links nothing and runs build scripts on the -host that checks, so on every host these go unseen: - -- a link failure, and an error only code generation raises; -- a build script that answers differently on the host it is judged for: one - that runs `cc` or `pkg-config` probes the host that checks, so an app that - compiles C or links a system library cannot pass a check of another host's - triple. - -A `cargo build` where the host is the one judged would give that host a second -verdict beside the check another host makes of it. - -Owner: the host gate, `src/userlandhost.rs`. - -**Exit:** each host's apps are built, `cargo build`, on a runner of that host on -each pull request, and no host checks another's. Windows waits on -`issues/build/the-build-system-does-not-compile-on-windows.md`, macOS on a -macOS runner in `ci.yml`. diff --git a/src/ci.rs b/src/ci.rs index eb5d4b943a8..cf6beba9c0a 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -533,7 +533,7 @@ fn host(root: &Path) -> Vec { Ok(programs) => { for os in Os::ALL { steps.push(step(&format!("the apps for {}", os.name()), || { - apps_for(root, &programs, os) + apps_for(root, &programs, os, &host_triple) })); } } @@ -548,13 +548,17 @@ fn host(root: &Path) -> Vec { steps } -/// Every app the images ship, checked against `os`'s triple with the features -/// its image builds it with, on whichever host this is, but where its manifest -/// declares it fails ([`crate::userlandhost`]). +/// Every app the images ship, judged for `os` with the features its image +/// builds it with ([`crate::userlandhost`]). /// /// One cargo per app: features unify across the packages of one invocation, and /// an app that builds only beside another's features is what this gate is for. -fn apps_for(root: &Path, programs: &[Program], os: Os) -> Result { +fn apps_for( + root: &Path, + programs: &[Program], + os: Os, + host_triple: &str, +) -> Result { let triple = os.triple(); let status = Command::new("rustup") .args(["target", "add", triple]) @@ -563,30 +567,25 @@ fn apps_for(root: &Path, programs: &[Program], os: Os) -> Result if !status.success() { return Err(format!("rustup target add {triple} exited {status}")); } - let (mut checked, mut declared, mut red) = (0, Vec::new(), Vec::new()); - for program in programs { - let Host::App(fails) = &program.host else { continue }; - if fails.contains(&os) { - declared.push(program.dir.as_str()); - continue; - } + let verb = verb(os, host_triple); + let (attempted, declared) = attempted(programs, os); + let mut red = Vec::new(); + for program in &attempted { let manifest = format!("{}/Cargo.toml", program.dir); - let mut args = vec!["check", "--manifest-path", manifest.as_str(), "--target", triple]; + let mut args = vec![verb, "--manifest-path", manifest.as_str(), "--target", triple]; args.extend(program.features.args()); - match cargo(root, &args) { - Ok(_) => checked += 1, - Err(exit) => red.push(format!( - "{} does not compile for {} and declares neither `fails` there nor `exempt`: \ - {exit}", + if let Err(exit) = cargo(root, &args) { + red.push(format!( + "{} fails for {} and declares neither `fails` there nor `exempt`: {exit}", program.dir, os.name() - )), + )); } } if !red.is_empty() { return Err(red.join("; ")); } - let said = format!("{checked} app(s) pass `cargo check --target {triple}`"); + let said = format!("{} app(s) pass `cargo {verb} --target {triple}`", attempted.len()); if declared.is_empty() { Ok(said) } else { @@ -594,6 +593,30 @@ fn apps_for(root: &Path, programs: &[Program], os: Os) -> Result } } +/// `build` where the gate runs on `os`'s own triple, and `check` elsewhere. +fn verb(os: Os, host_triple: &str) -> &'static str { + if os.triple() == host_triple { + "build" + } else { + "check" + } +} + +/// The apps judged for `os`, and those whose manifests declare they fail there; +/// an exempt program is in neither. +fn attempted(programs: &[Program], os: Os) -> (Vec<&Program>, Vec<&str>) { + let (mut attempted, mut declared) = (Vec::new(), Vec::new()); + for program in programs { + let Host::App(fails) = &program.host else { continue }; + if fails.contains(&os) { + declared.push(program.dir.as_str()); + } else { + attempted.push(program); + } + } + (attempted, declared) +} + /// What `tmp` holds but the lock `toyos_tmpdir` keeps in it, and every root /// under `short` whose process is gone that `before` does not name. /// @@ -909,6 +932,31 @@ mod tests { line.split_whitespace().map(String::from).collect() } + /// **An app is judged for every host its `fails` does not name**, built + /// where the gate runs on that host and checked elsewhere; an exempt + /// program is judged for none. + #[test] + fn an_app_is_judged_for_every_host_its_fails_does_not_name() { + let program = |dir: &str, host| Program { + dir: dir.into(), + features: crate::build::Features::Default, + host, + }; + let programs = [ + program("calc", Host::App(Vec::new())), + program("doom", Host::App(vec![Os::Windows])), + program("init", Host::Exempt), + ]; + let judged = |os| { + let (attempted, declared) = attempted(&programs, os); + (attempted.iter().map(|p| p.dir.as_str()).collect::>(), declared) + }; + assert_eq!(judged(Os::Linux), (vec!["calc", "doom"], vec![])); + assert_eq!(judged(Os::Macos), (vec!["calc", "doom"], vec![])); + assert_eq!(judged(Os::Windows), (vec!["calc"], vec!["doom"])); + assert_eq!(Os::ALL.map(|os| verb(os, Os::Linux.triple())), ["build", "check", "check"]); + } + #[test] fn a_job_is_named_and_takes_nothing_after_it() { assert_eq!(parse(&words("host")), Ok(Job::Host)); diff --git a/src/userlandhost.rs b/src/userlandhost.rs index 7cb912ba84d..fe5fed7e72a 100644 --- a/src/userlandhost.rs +++ b/src/userlandhost.rs @@ -14,7 +14,7 @@ //! ``` //! //! An app that does not build for a host yet names the host, and the open issue -//! that records it and names the app. The gate does not check it there: +//! that records it and names the app. The gate does not attempt it there: //! //! ```toml //! [package.metadata.toyos.host] @@ -22,10 +22,11 @@ //! issue = "issues/build/.md" //! ``` //! -//! A host's verdict is `cargo check --target` its triple ([`Os::triple`]), the -//! same on whichever host runs it. A check links nothing, and runs the build -//! scripts on the host that checks -//! (`issues/build/no-app-is-built-for-a-host-each-is-only-checked.md`). An app +//! A host's apps are built where the gate runs on its triple ([`Os::triple`]), +//! and checked against that triple elsewhere. A check links nothing and runs +//! build scripts on the host that checks, and a declared failure is attempted on +//! no host, so one that outlives its fix goes unseen +//! (`issues/build/a-hosts-apps-are-judged-on-other-hosts-runners.md`). An app //! whose work a `cfg` compiles out of a host checks green there: only review //! holds that (`.claude/agents/reviewer.md`, Hosts). //! @@ -158,7 +159,7 @@ impl Os { } } - /// The triple every host checks this one's apps against. + /// The triple the gate judges this host's apps for. pub fn triple(self) -> &'static str { match self { Os::Linux => "x86_64-unknown-linux-gnu", diff --git a/userland/CLAUDE.md b/userland/CLAUDE.md index 696caf47178..019d6d86070 100644 --- a/userland/CLAUDE.md +++ b/userland/CLAUDE.md @@ -2,7 +2,7 @@ The module header at the site owns its subject — surfaces, translators and the channel in `toyos/`'s surface modules, soundd whole under `userland/soundd/`, what a process holds and how it got it at `kernel/src/object/` and `/system/bin/init`. The compositor's decisions are `toyos-desktop/`, pure and host-tested; `userland/compositor/` is devices, handles, shared memory and the panel. POSIX lives in `userland/libc` — ours, not a fork; that layer may be ugly, the kernel may not. -**An app builds and runs on Linux under Wayland (never X11, which is legacy), macOS and Windows from the same source as on ToyOS**: a host build that fails is fixed in the app or its dependencies, never by making the app ToyOS-only. A program is ToyOS-only by nature when its job exists only on ToyOS: it owns ToyOS devices or kernel objects, as the system servers do, or it manages ToyOS itself, as `update` does. Its manifest names which, and why (`src/userlandhost.rs`). +**An app builds and runs on Linux under Wayland (never X11, which is legacy), macOS and Windows from the same source as on ToyOS**: a host build that fails is fixed in the app or its dependencies, never by making the app ToyOS-only. A program is ToyOS-only by nature when its job exists only on ToyOS: it owns ToyOS devices or kernel objects, or it manages ToyOS itself, as `update` does. Its manifest names which, and why (`src/userlandhost.rs`). **A server never blocks on a client** — the doctrine no single site owns. Accept and the first frame are two events; a frame is buffered until whole before anything acts on it; a write is one `try_send` whose refusal drops the peer by name; a blocking read or write of a pipe the client owns is the same bug. init, the compositor, netd, soundd and every surface host use `ipc::FrameRx`. filepicker violates it today. diff --git a/userland/blockd/Cargo.toml b/userland/blockd/Cargo.toml index b7fd9d62088..e0ffa6a2d6a 100644 --- a/userland/blockd/Cargo.toml +++ b/userland/blockd/Cargo.toml @@ -22,4 +22,4 @@ toyos-blockhold = { path = "../../toyos-blockhold" } toyos-gpt = { path = "../../toyos-gpt" } [package.metadata.toyos.host] -exempt.owns = "the NVMe controller ToyOS claims for it, whose partitions it serves on the `block` port" +exempt.owns = "the NVMe controller ToyOS claims for it" diff --git a/userland/compositor/Cargo.toml b/userland/compositor/Cargo.toml index 4c16bdcd305..f8ff69cf360 100644 --- a/userland/compositor/Cargo.toml +++ b/userland/compositor/Cargo.toml @@ -15,4 +15,4 @@ toyos-manifest = { path = "../../toyos-manifest" } toyos-window = { path = "../toyos-window" } [package.metadata.toyos.host] -exempt.owns = "ToyOS's framebuffer, keyboard and mouse, claimed from the kernel, and the `compositor` port it serves" +exempt.owns = "ToyOS's framebuffer, keyboard and mouse, claimed from the kernel" diff --git a/userland/logd/Cargo.toml b/userland/logd/Cargo.toml index a42f2b6a164..967754f7c0f 100644 --- a/userland/logd/Cargo.toml +++ b/userland/logd/Cargo.toml @@ -14,4 +14,4 @@ toyos-inspect = { path = "../../toyos-inspect" } toyos-wallclock = { path = "../../toyos-wallclock" } [package.metadata.toyos.host] -exempt.owns = "the kernel's record ring, read under the `logread` SysCap, and the `log` port it serves" +exempt.owns = "the kernel's record ring, read under the `logread` SysCap" diff --git a/userland/netd/Cargo.toml b/userland/netd/Cargo.toml index 488cff91e40..83fc07eba2b 100644 --- a/userland/netd/Cargo.toml +++ b/userland/netd/Cargo.toml @@ -28,4 +28,4 @@ features = [ ] [package.metadata.toyos.host] -exempt.owns = "the NIC ToyOS claims for it, and the `netd` port it serves" +exempt.owns = "the NIC ToyOS claims for it" diff --git a/userland/soundd/Cargo.toml b/userland/soundd/Cargo.toml index 6f9cf8c5cb4..e401ded3351 100644 --- a/userland/soundd/Cargo.toml +++ b/userland/soundd/Cargo.toml @@ -12,4 +12,4 @@ toyos-inspect = { path = "../../toyos-inspect" } rubato = "0.16" [package.metadata.toyos.host] -exempt.owns = "ToyOS's audio devices, claimed from the kernel, and the `soundd` port it serves" +exempt.owns = "ToyOS's audio devices, claimed from the kernel"