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..5708adb3aa8 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -42,14 +42,21 @@ 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. 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 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/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/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..a842e0799e4 --- /dev/null +++ b/issues/build/apps-that-build-or-run-on-toyos-alone.md @@ -0,0 +1,31 @@ +--- +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: +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` 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`, `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. 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 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/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 465db5cb30b..cf6beba9c0a 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, Program}; 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 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 @@ -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::programs(root) { + Ok(programs) => { + for os in Os::ALL { + steps.push(step(&format!("the apps for {}", os.name()), || { + apps_for(root, &programs, os, &host_triple) + })); + } + } + 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. steps.push(step("the toyos SDK", || { @@ -537,6 +548,75 @@ fn host(root: &Path) -> Vec { steps } +/// 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, + host_triple: &str, +) -> 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 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![verb, "--manifest-path", manifest.as_str(), "--target", triple]; + args.extend(program.features.args()); + 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!("{} app(s) pass `cargo {verb} --target {triple}`", attempted.len()); + if declared.is_empty() { + Ok(said) + } else { + Ok(format!("{said}; {} not attempted, as their manifests declare", declared.join(", "))) + } +} + +/// `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. /// @@ -852,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 ca9b855797d..fe5fed7e72a 100644 --- a/src/userlandhost.rs +++ b/src/userlandhost.rs @@ -1,5 +1,34 @@ -//! 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 what every program the images ship declares about the +//! hosts. +//! +//! [`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.owns = "the NVMe controller ToyOS claims for it" +//! ``` +//! +//! 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 attempt it there: +//! +//! ```toml +//! [package.metadata.toyos.host] +//! fails = ["windows"] +//! issue = "issues/build/.md" +//! ``` +//! +//! 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). //! //! `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 @@ -25,6 +54,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 { @@ -109,6 +140,157 @@ pub fn survey(userland: &Path) -> Result { Ok(Survey { gated: gated.into_iter().collect(), escapes: escapes.into_iter().collect() }) } +/// A host an app builds for, as a manifest spells 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 the gate judges this host's apps for. + 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) + } +} + +/// What a program's manifest declares about the hosts. +#[derive(Debug, PartialEq, Eq)] +pub enum Host { + /// An app: it builds for every host but these, which an open issue records. + App(Vec), + /// Its job exists only on ToyOS. + Exempt, +} + +/// 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 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 (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) +} + +/// `[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) -> 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")?; + 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 (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() { + 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()); + } + let issue = issue.as_str().ok_or("`issue` is not a path")?; + let app = package.get("name").and_then(toml::Value::as_str).ok_or("no [package] name")?; + owed(root, issue, app)?; + Ok(Host::App(on)) + } + _ => Err("[package.metadata.toyos.host] declares `exempt` alone, or `fails` with its \ + `issue`" + .into()), + } +} + +/// `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. fn rel(base: &Path, path: &Path) -> String { path.strip_prefix(base).unwrap_or(path).to_string_lossy().replace('\\', "/") @@ -341,6 +523,82 @@ mod tests { ); } + /// **Every program the images ship is an app or says why it is not**, the + /// ones outside the userland workspace too. + #[test] + 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"); + 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) + }; + let table = "[package.metadata.toyos.host]\n"; + let issue = "issue = \"issues/build/x.md\"\n"; + 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}fails = [\"windows\", \"linux\"]\n{issue}")), + Ok(Host::App(vec![Os::Windows, Os::Linux])) + ); + assert_eq!( + read(&format!("{table}fails = [\"linux\"]\nissue = \"issues/build/held.md\"\n")), + Ok(Host::App(vec![Os::Linux])) + ); + for refused in [ + 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}"), + format!("{table}fails = [\"freebsd\"]\n{issue}"), + 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.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).unwrap_err(); + assert!(refusal.contains("does not name `snake`"), "{refusal}"); + } + #[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..019d6d86070 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 (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. ## Caveats that bite every agent diff --git a/userland/Cargo.lock b/userland/Cargo.lock index b2cf8e7820d..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#2b67e7bd68e0688de1586ba3eb90cfc5d485c6b1" +source = "git+https://github.com/ToyOSOrg/socket2?branch=toyos#0b238fb870b01ec3a89916eeb96f8a4dc930d571" dependencies = [ "libc", "toyos", 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..e0ffa6a2d6a 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.owns = "the NVMe controller ToyOS claims for it" diff --git a/userland/compositor/Cargo.toml b/userland/compositor/Cargo.toml index 10bbaee7ff6..f8ff69cf360 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.owns = "ToyOS's framebuffer, keyboard and mouse, claimed from the kernel" diff --git a/userland/console/Cargo.toml b/userland/console/Cargo.toml index 04acb0730b1..4e35113d4fc 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.owns = "ToyOS's framebuffer, keyboard and mouse, claimed from the kernel, on which it runs the shell" 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/fsd/Cargo.toml b/userland/fsd/Cargo.toml index 8c743a4d975..815db281a66 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.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 89b5d470a2e..aaf6d4ac0b8 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.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 83de9efbdcf..55a04b24f39 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.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 3b7aae17e86..967754f7c0f 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.owns = "the kernel's record ring, read under the `logread` SysCap" diff --git a/userland/netd/Cargo.toml b/userland/netd/Cargo.toml index b7471d2cb9b..83fc07eba2b 100644 --- a/userland/netd/Cargo.toml +++ b/userland/netd/Cargo.toml @@ -26,3 +26,6 @@ features = [ "multicast", "alloc", ] + +[package.metadata.toyos.host] +exempt.owns = "the NIC ToyOS claims for it" 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/soundd/Cargo.toml b/userland/soundd/Cargo.toml index ba9f4366470..e401ded3351 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.owns = "ToyOS's audio devices, claimed from the kernel" diff --git a/userland/swap/Cargo.toml b/userland/swap/Cargo.toml index 4fd877da928..c691d6f26dc 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.manages = "ToyOS's running services: it replaces one's binary through init's `swap` port" 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" diff --git a/userland/update/Cargo.toml b/userland/update/Cargo.toml index 0a179bec84e..769af88885e 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.manages = "ToyOS's boot slots: it writes the idle one, onto the partitions init claims for it"