From e1ba1c0c1bc9ae4b3be443940fcf279b28722282 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 15:07:00 +0200 Subject: [PATCH 01/23] netd takes this machine's address from the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface carried `10.0.2.15/24`, a default route to `10.0.2.2` and a resolver at `10.0.2.3`, written into `main` as literals. That configuration was one network's — QEMU's user-mode backend — and it was right about a machine nobody had asked. The bench's router leases something else, so the T14 could never have had an address at all. netd now runs smoltcp's DHCPv4 client and applies what it is leased: the address and its prefix, the default route, and the resolvers the DNS socket queries, all three replaced together on a lease and dropped together when one is lost. A route left standing over an address that is gone sends frames out with a source nothing will answer, which is why they move as one. Every DISCOVER and REQUEST carries the host-name option (RFC 2132 §3.14) with `toyos-t14` in it: the bench's router is the only DHCP server in reach that records a client's name, and what it records this machine under is what that name then resolves to. `netd: ready, at most N piped connections` moves from before the loop to the first pass on which the address question is settled — a lease landed, or twenty seconds passed with none. Sixteen arms in this suite wait for that line and then connect; a netd that announced itself before it had an address would hand each of them a stack with none. A machine that gets no lease says so in one line and goes on serving, and its clients get their connects refused one at a time, which is what they are already written to survive. Measured against QEMU's user-mode DHCP server, which is an implementation of RFC 2131 this repository did not write and whose lease is known field by field: `lan_dhcp_lease` reads `10.0.2.15/24` from `10.0.2.2`, gateway `10.0.2.2`, resolver `10.0.2.3`, eleven milliseconds after netd came up, and holds the readiness line to arriving after it. The other fifteen network arms — `netd_*`, `https_tls13`, `https_tls13_e1000e`, the five `log_stream` arms and the four `sshd` ones — are green on the same tree. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- ...nswers-an-address-nobody-asked-netd-for.md | 26 +++ userland/netd/Cargo.toml | 1 + userland/netd/src/dhcp.rs | 203 ++++++++++++++++++ userland/netd/src/main.rs | 55 +++-- 4 files changed, 267 insertions(+), 18 deletions(-) create mode 100644 issues/design-debt/getsockname-answers-an-address-nobody-asked-netd-for.md create mode 100644 userland/netd/src/dhcp.rs diff --git a/issues/design-debt/getsockname-answers-an-address-nobody-asked-netd-for.md b/issues/design-debt/getsockname-answers-an-address-nobody-asked-netd-for.md new file mode 100644 index 0000000000..e06d0c01af --- /dev/null +++ b/issues/design-debt/getsockname-answers-an-address-nobody-asked-netd-for.md @@ -0,0 +1,26 @@ +--- +status: open +kind: finding +opened: 2026-09-08 +--- + +# `getsockname` answers an address nobody asked netd for + +`userland/libc/src/socket.rs`'s `getsockname()` fills the caller's `sockaddr` +with `[10, 0, 2, 15]` and the socket's local port. The address is a literal in +that function; netd is not asked, and the SDK has nothing to ask it with — +`toyos::net` carries `tcp_connect`, `tcp_bind`, `tcp_accept`, the UDP calls and +`dns_lookup`, and no call that answers "what is this machine's address". + +It was true by coincidence until now: netd carried the same literal, so the +shim and the stack agreed. netd takes its address from DHCP as of the change +that filed this, so the two agree only on a machine whose server happens to +lease `10.0.2.15` — QEMU's user-mode backend does, and the bench's router does +not. Every C program that asks what address it is bound to is told the wrong +one there. + +What it costs to fix is a message type on netd's protocol and one plumbed +answer; what it costs to leave is that the one caller of `getsockname` in a +POSIX program is the one that then advertises an address nothing can reach. +Nothing in the tree reads it today, which is why this is a finding and not a +defect. diff --git a/userland/netd/Cargo.toml b/userland/netd/Cargo.toml index b2004833e7..96e1482fc3 100644 --- a/userland/netd/Cargo.toml +++ b/userland/netd/Cargo.toml @@ -17,5 +17,6 @@ features = [ "socket-tcp", "socket-udp", "socket-dns", + "socket-dhcpv4", "alloc", ] diff --git a/userland/netd/src/dhcp.rs b/userland/netd/src/dhcp.rs new file mode 100644 index 0000000000..2c3fc8ee67 --- /dev/null +++ b/userland/netd/src/dhcp.rs @@ -0,0 +1,203 @@ +//! This machine's address, taken from the network rather than written down. +//! +//! **There is no static configuration to fall back to.** A machine's address +//! belongs to the network it is plugged into, and both networks this program +//! has ever run on — QEMU's user-mode backend and the bench's router — serve +//! DHCP. What a hard-coded `10.0.2.15/24` bought was one of them, and it bought +//! it by being right about a machine nobody had asked. +//! +//! What the lease decides is the whole of the interface: the address and its +//! prefix, the default route, and the resolvers the DNS socket queries. All +//! three are replaced together on every lease and dropped together when one is +//! lost, because a route left standing over an address that is gone sends +//! frames out with a source nothing will answer. +//! +//! **A machine that gets no lease says so and goes on serving.** Its clients +//! then get their connects refused, one refusal at a time, which is what they +//! are already written to survive; a daemon that waited here instead would put +//! a whole userland behind a router that did not answer. + +use std::time::{Duration, Instant}; + +use smoltcp::iface::Interface; +use smoltcp::socket::{dhcpv4, dns}; +use smoltcp::wire::{DhcpOption, IpAddress, IpCidr, Ipv4Address, Ipv4Cidr}; + +/// RFC 2132 §3.14. +const OPT_HOST_NAME: u8 = 12; + +/// The name this machine asks its network to record for it. +/// +/// **One name, because there is one machine.** The bench's router is the only +/// DHCP server in reach that records a client's name at all, and what it +/// records this one under is what `toyos-t14` then resolves to — so the name is +/// the bench's, and a second machine running this program would need a second +/// answer before it needed anything else here. +const HOSTNAME: &[u8] = b"toyos-t14"; + +/// The options every DISCOVER and REQUEST carries. +static OUTGOING: [DhcpOption<'static>; 1] = + [DhcpOption { kind: OPT_HOST_NAME, data: HOSTNAME }]; + +/// How long this machine waits for its first lease before saying it has none. +/// +/// It bounds the *report*, never the client: the socket goes on retrying for +/// the life of the boot, and a lease that lands after this is applied like any +/// other. What the bound buys is a line in the log on a machine whose network +/// never answers, instead of a boot that is silent about the one thing wrong +/// with it. Wide enough for a gigabit link to finish negotiating first, which +/// on the bench's I219 is seconds. +const LEASE_BOUND: Duration = Duration::from_secs(20); + +/// The DHCP client socket this machine runs, asking for a lease under +/// [`HOSTNAME`]. +pub fn socket() -> dhcpv4::Socket<'static> { + let mut socket = dhcpv4::Socket::new(); + socket.set_outgoing_options(&OUTGOING); + socket +} + +/// What the client decided, owned. +/// +/// **Taken out of the socket before anything is applied**, because the +/// interface and the DNS resolver are the other two things a lease changes and +/// all three live in one `SocketSet`: an event still borrowing the client is an +/// event nothing can be done about. +pub enum Change { + Leased { address: Ipv4Cidr, router: Option, server: Ipv4Address, dns: Vec }, + Lost, +} + +impl Change { + /// Whatever the client has to say this pass. + pub fn of(client: &mut dhcpv4::Socket) -> Option { + match client.poll()? { + dhcpv4::Event::Configured(config) => Some(Self::Leased { + address: config.address, + router: config.router, + server: config.server.address, + dns: config.dns_servers.to_vec(), + }), + dhcpv4::Event::Deconfigured => Some(Self::Lost), + } + } +} + +/// The lease's own state, and what the boot's log still owes about it. +pub struct Dhcp { + began: Instant, + /// Whether the interface currently holds a lease. + leased: bool, + /// Whether this boot has settled the question once — a lease landed, or the + /// bound passed with none. netd announces itself on the edge of this. + settled: bool, +} + +impl Dhcp { + pub fn new() -> Self { + Self { began: Instant::now(), leased: false, settled: false } + } + + /// How long netd may sleep before this owes the log a line. + /// + /// **A bound nothing else would wake for.** A machine whose network never + /// answers produces no frame and no timer, so the loop's own delay is + /// unbounded and the report at [`LEASE_BOUND`] would never be written. + pub fn report_within(&self) -> Option { + (!self.settled).then(|| LEASE_BOUND.saturating_sub(self.began.elapsed())) + } + + /// Apply what the client decided, and answer whether this machine's address + /// question has just been settled — which is the moment netd has something + /// to serve with. + pub fn pass( + &mut self, + change: Option, + iface: &mut Interface, + resolver: &mut dns::Socket, + ) -> bool { + match change { + Some(Change::Leased { address, router, server, dns }) => { + self.apply(address, router, server, &dns, iface, resolver); + self.leased = true; + } + Some(Change::Lost) => { + // Only worth a line where there was something to lose: the + // client reports this on its way to a first lease too. + if self.leased { + crate::say!("netd: DHCP: the lease is gone; this machine has no address"); + } + self.clear(iface, resolver); + self.leased = false; + } + None => {} + } + if self.settled { + return false; + } + if self.leased { + self.settled = true; + return true; + } + if self.began.elapsed() >= LEASE_BOUND { + crate::say!( + "netd: DHCP: no lease as {} in {} s; this machine has no address and every \ + connect through it is refused", + String::from_utf8_lossy(HOSTNAME), + LEASE_BOUND.as_secs(), + ); + self.settled = true; + return true; + } + false + } + + /// The lease, written into the interface and said out loud. + /// + /// **One record carrying every field the lease decided.** A boot read off a + /// stick or a stream has this line and nothing else to say what this + /// machine's network was, and a judge that had to assemble it from three + /// lines would be guessing which boot each of them came from. + fn apply( + &self, + address: Ipv4Cidr, + router: Option, + server: Ipv4Address, + dns: &[Ipv4Address], + iface: &mut Interface, + resolver: &mut dns::Socket, + ) { + iface.update_ip_addrs(|addrs| { + // Cleared before the push, so a list already holding an address + // cannot leave the old one standing beside the new. + addrs.clear(); + addrs.push(IpCidr::Ipv4(address)).expect("an emptied address list takes one"); + }); + iface.routes_mut().remove_default_ipv4_route(); + if let Some(router) = router { + iface + .routes_mut() + .add_default_ipv4_route(router) + .expect("an emptied route table takes one default route"); + } + let servers: Vec = dns.iter().map(|s| IpAddress::Ipv4(*s)).collect(); + resolver.update_servers(&servers); + crate::say!( + "netd: DHCP: lease {}/{} from {server}, gateway {}, dns [{}], {} ms after netd came up", + address.address(), + address.prefix_len(), + match router { + Some(router) => router.to_string(), + None => "none".to_string(), + }, + dns.iter().map(ToString::to_string).collect::>().join(" "), + self.began.elapsed().as_millis(), + ); + } + + fn clear(&self, iface: &mut Interface, resolver: &mut dns::Socket) { + iface.update_ip_addrs(|addrs| addrs.clear()); + iface.routes_mut().remove_default_ipv4_route(); + resolver.update_servers(&[]); + } +} diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index d7115e6af8..c289723a95 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -30,6 +30,7 @@ macro_rules! say { } mod device; +mod dhcp; mod i219; mod virtio_net; @@ -59,9 +60,9 @@ use toyos::net::*; use smoltcp::iface::{Config, Interface, PollResult, SocketHandle, SocketSet}; use smoltcp::phy::{self, Device, DeviceCapabilities, Medium}; -use smoltcp::socket::{dns, tcp, udp}; +use smoltcp::socket::{dhcpv4, dns, tcp, udp}; use smoltcp::time::Instant as SmoltcpInstant; -use smoltcp::wire::{DnsQueryType, EthernetAddress, HardwareAddress, IpAddress, IpCidr, IpEndpoint}; +use smoltcp::wire::{DnsQueryType, EthernetAddress, HardwareAddress, IpAddress, IpEndpoint}; use std::net::Ipv4Addr; @@ -1313,30 +1314,22 @@ fn main() { let now = SmoltcpInstant::from_millis(0); let mut iface = Interface::new(config, &mut device, now); - iface.update_ip_addrs(|addrs| { - addrs.push(IpCidr::new(IpAddress::v4(10, 0, 2, 15), 24)).ok(); - }); - iface.routes_mut() - .add_default_ipv4_route(Ipv4Addr::new(10, 0, 2, 2)) - .ok(); - + // **The interface starts with no address at all.** What it gets is a lease, + // and `dhcp::Dhcp` is what writes one into the address list, the route table + // and the resolvers together. let mut socket_set = SocketSet::new(vec![]); - let dns_servers = &[IpAddress::v4(10, 0, 2, 3)]; - let dns_socket = dns::Socket::new(dns_servers, vec![]); + // Empty, because the lease names the resolvers and nothing else may: a + // server written down here would answer for one network on every other. + let dns_socket = dns::Socket::new(&[], vec![]); let dns_handle = socket_set.add(dns_socket); + let dhcp_handle = socket_set.add(dhcp::socket()); + let mut dhcp = dhcp::Dhcp::new(); let total_mem = total_memory(); let max_piped = max_piped_connections(total_mem); let mut daemon = NetDaemon::new(dns_handle, max_piped); - say!( - "netd: ready, at most {max_piped} piped connections \ - ({} MiB each of {} MiB total)", - PIPED_CONNECTION_BYTES / (1024 * 1024), - total_mem / (1024 * 1024), - ); - // Sized for the slot ceiling rather than for `max_piped`: the batch // between two `wait` calls is the two fixed registrations, one per live piped // connection and one per pending connection, and the ceiling is what that @@ -1359,6 +1352,25 @@ fn main() { let now = SmoltcpInstant::from_millis(epoch.elapsed().as_millis() as i64); while iface.poll(now, &mut device, &mut socket_set) != PollResult::None {} + // **After the poll and before anything is served.** The lease is what + // gives this machine an address, a route and its resolvers, so a client + // answered before it was applied would be answered on a machine that is + // on no network. + let change = dhcp::Change::of(socket_set.get_mut::(dhcp_handle)); + if dhcp.pass(change, &mut iface, socket_set.get_mut::(dns_handle)) { + // **The line says netd is serving, and it is said once this machine + // has an address to serve on** — or once it has been told it will + // not get one. Every arm that waits for netd waits for this, so + // moving it earlier would put those arms in front of a stack with no + // address. + say!( + "netd: ready, at most {max_piped} piped connections \ + ({} MiB each of {} MiB total)", + PIPED_CONNECTION_BYTES / (1024 * 1024), + total_mem / (1024 * 1024), + ); + } + daemon.bridge_piped(&mut socket_set); daemon.check_piped_listeners(&mut socket_set); @@ -1415,6 +1427,13 @@ fn main() { } else { timeout.min(HANDSHAKE_TIMEOUT.as_nanos() as u64) }; + // The same argument for the lease: a machine whose network answers + // nothing produces neither a frame nor a socket timer, so the report + // that says so has to be a wake of its own. + let timeout = match dhcp.report_within() { + Some(left) => timeout.min(left.as_nanos() as u64), + None => timeout, + }; let mut ready: Vec = Vec::new(); poller.wait(1, timeout, |token| ready.push(token)); From 220305b4ea2e758f2860169f1561f873a4126184 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 15:07:22 +0200 Subject: [PATCH 02/23] The cable: the T14's own NIC, and a boot that answers a ping on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/lancase` is the boot that runs netd in front of the ThinkPad's onboard I219 at `00:1f.6`, `8086:15fc`. It is a directory of its own for the reason `tests/e1000case` is: a program that names a card a machine does not have costs an `init:` refusal line on every boot of the config that does, and no machine has both. Nothing on it reaches the internal NVMe — there is no `[disks]` row, and the PCI function netd claims is the only device any program on it names. `lan_hold` is the one job on that boot and it asserts nothing: a metal boot is about a second long, and nothing on a network can be asked of a machine that is up for that long — the I219's link takes seconds to negotiate before a DHCP discover can go out. It holds the machine up for twenty seconds and exits, and its exit record is what says the machine stayed up for the whole window. It is on `RUST_SKIP` because on any other boot it is twenty seconds of nothing. The metal loop now pings the machine's own address across the window between its two operating systems, and writes what it saw into the readback beside `back_secs` and `stick_secs`. Three things make that a fact about the boot: - The window opens when `ssh` stops answering and closes when it answers again, and inside it the only thing that can be running is the image this loop wrote. - A reply counts only after the address has been silent for five seconds. `reboot` takes `sshd` down before the interface, so the machine that is already "down" by this loop's reckoning still answers ICMP for a moment, and a reply counted there is the operating system that is leaving. - The address is resolved before the flash, through the same router's DNS that issues the lease, and the boot's own lease record is held to it afterwards: a run where the leased address and the pinged one differ is a ping something else answered. `ping` is the host's own ICMP client, declared in `src/sourcegate.rs` beside `ssh` and used nowhere else. It is an implementation of ICMP nobody here wrote, which is what makes it an oracle for the stack under test rather than a second opinion from it — and it is the only question this repository can ask a metal boot while that boot is still running, everything else being on a stick read minutes later. The number is taken on every metal boot and claimed by one. Every other boot in the suite runs no netd on that card, so its reading is Ubuntu answering on the way back up — which is the separation `boot.lancase.ping_secs`'s ceiling will be derived from. Until the machine has answered once, that row bounds the loop's own wait and says so; the run that takes the first reading is the run that tightens it, and every other row in the lancase block says the same. `lan_dhcp_lease` is one name with two judges, as `metal_device_probe` is: under QEMU it certifies netd's DHCP client against the user-mode backend's server, and on the T14 it certifies the whole path — the kernel handing netd the I219's function, the link, the lease from the bench's router under this machine's name, and the host's ping answered at the leased address. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- src/build.rs | 1 + src/metal.rs | 216 ++++++++++++++++- src/sourcegate.rs | 12 + tests/common/lan.rs | 264 +++++++++++++++++++++ tests/common/metal.rs | 44 +++- tests/common/mod.rs | 3 + tests/lancase/system.toml | 32 +++ tests/metal-profile.toml | 48 ++++ tests/test-durations | 1 + tests/toyos-rust-tests/src/bin/lan_hold.rs | 31 +++ tests/toyos.rs | 30 ++- 11 files changed, 672 insertions(+), 10 deletions(-) create mode 100644 tests/common/lan.rs create mode 100644 tests/lancase/system.toml create mode 100644 tests/toyos-rust-tests/src/bin/lan_hold.rs diff --git a/src/build.rs b/src/build.rs index 56497284a9..b344adec72 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2607,6 +2607,7 @@ mod tests { "tests/e1000case/system.toml", "tests/jobcase/system.toml", "tests/jobdeadlinecase/system.toml", + "tests/lancase/system.toml", "tests/latencycase/system.toml", "tests/logrotatecase/system.toml", "tests/metalcase/system.toml", diff --git a/src/metal.rs b/src/metal.rs index 432d886087..62721b4e1a 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -58,6 +58,25 @@ pub fn return_secs() -> u64 { const POLL_SECS: u64 = 5; +/// How often the machine's own address is pinged while it is not answering +/// `ssh`. +const PING_EVERY_SECS: u64 = 1; + +/// How long one probe waits for its reply. +const PING_WAIT_MS: u64 = 1_000; + +/// How long the address has to answer *nothing* before a reply counts as this +/// boot's. +/// +/// **`ssh` stops answering before the network does.** `reboot` takes `sshd` +/// down first and the interface some seconds later, so the machine that is +/// already "down" by this loop's reckoning still answers ICMP for a moment — +/// and a reply counted there would be the operating system that is leaving, +/// never the one being flashed. What is looked for is a reply *after* the +/// address went quiet, which is the window in which the only thing that can be +/// running is the image this loop wrote. +const PING_SILENCE_SECS: u64 = 5; + /// How long the boot stick gets to be there again once Ubuntu is up. /// /// **The bench's own device is the one judge there is of whether a reset left a @@ -129,6 +148,9 @@ pub enum Refusal { /// A lid key that no longer reads `ignore`, which is what keeps the machine up. Lid { key: &'static str, got: String }, Remote { what: String, status: String, stderr: String }, + /// The machine's name answers no IPv4 address on this host, so the boot + /// this loop is about to make could not be reached over the cable at all. + Unresolved { host: String, why: String }, /// The machine did not go down, or did not come back. Silent { what: &'static str, secs: u64 }, /// The machine came back and the boot stick did not: the boot before this @@ -254,6 +276,11 @@ impl fmt::Display for Refusal { Self::Remote { what, status, stderr } => { write!(f, "{what} on the machine {status}: {stderr}") } + Self::Unresolved { host, why } => write!( + f, + "{host:?} answers no IPv4 address on this host ({why}), so nothing on the cable \ + could be asked whether this boot came up" + ), Self::Silent { what, secs } => write!( f, "the machine did not {what} within {secs} s, which is longer than every watchdog \ @@ -596,6 +623,28 @@ impl Target { Ok(format!("sudo -n {}", words.join(" "))) } + /// The machine's own IPv4 address on this LAN. + /// + /// **Resolved before the boot, because the boot cannot be asked.** The + /// machine's MAC is the same under either operating system, so the lease + /// the router hands the flashed image is the one its name already resolves + /// to — and the name is resolved through the same router's DNS that issued + /// it. The boot's own record of the lease says which address it took, and + /// the judge holds the two together; what this gives is an address to ping + /// while the boot is up, which is the only window there is. + fn address(&self) -> Result { + use std::net::ToSocketAddrs; + let unresolved = |why: String| Refusal::Unresolved { host: self.host.clone(), why }; + (self.host.as_str(), 22u16) + .to_socket_addrs() + .map_err(|e| unresolved(e.to_string()))? + .find_map(|at| match at { + std::net::SocketAddr::V4(v4) => Some(*v4.ip()), + std::net::SocketAddr::V6(_) => None, + }) + .ok_or_else(|| unresolved("it resolves to IPv6 alone".to_string())) + } + /// The one read that decides whether anything is written. fn identity(&self) -> String { let at = self.node.sysfs(); @@ -936,6 +985,86 @@ fn lid_policy(text: &str) -> Result<(), Refusal> { Ok(()) } +/// Whether anything answers at the machine's own address while the machine is +/// between two operating systems, and how far into that window it first did. +/// +/// **The one thing this loop can ask a boot that is still running.** Everything +/// else it reads is on the stick, and the stick is read minutes later, from +/// Ubuntu; a boot's network exists only while the boot does. The probe is the +/// host's own `ping`, which is an implementation of ICMP this repository did +/// not write — so what it establishes about the stack under test is +/// independent of that stack. +/// +/// It runs on a thread because the loop is inside `ssh` for whole seconds at a +/// time waiting for the machine to answer again, and a boot that is up for +/// twenty of them cannot be sampled between those. +struct Ping { + first: std::sync::Arc>>, + stop: std::sync::Arc, + thread: std::thread::JoinHandle<()>, +} + +impl Ping { + /// Begin, now: the caller has just watched the machine stop answering + /// `ssh`, and the window this measures starts there. + fn start(addr: std::net::Ipv4Addr) -> Self { + let first = std::sync::Arc::new(std::sync::Mutex::new(None)); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (mine, theirs) = (std::sync::Arc::clone(&first), std::sync::Arc::clone(&stop)); + let thread = std::thread::Builder::new() + .name("metal-ping".into()) + .spawn(move || { + let began = std::time::Instant::now(); + let mut quiet_since: Option = None; + let silence = std::time::Duration::from_secs(PING_SILENCE_SECS); + while !theirs.load(std::sync::atomic::Ordering::SeqCst) { + if ping_once(addr) { + // A reply before the address has been quiet is the + // operating system that is going down, whose `sshd` + // stops before its interface does. + if quiet_since.is_some_and(|at| at.elapsed() >= silence) { + *mine.lock().expect("the ping's answer") = + Some(began.elapsed().as_secs()); + return; + } + quiet_since = None; + } else if quiet_since.is_none() { + quiet_since = Some(std::time::Instant::now()); + } + std::thread::sleep(std::time::Duration::from_secs(PING_EVERY_SECS)); + } + }) + .expect("the metal loop's ping probe could not be started"); + Self { first, stop, thread } + } + + /// Stop probing, and answer when the first reply after the silence came. + fn end(self) -> Option { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = self.thread.join(); + let answer = *self.first.lock().expect("the ping's answer"); + answer + } +} + +/// One probe, whose whole answer is whether the address replied. +/// +/// **`-W` is milliseconds on this host and seconds on Linux**, and the two +/// spellings are three orders of magnitude apart: a bound written for one is a +/// probe that hangs for a quarter of an hour on the other. +fn ping_once(addr: std::net::Ipv4Addr) -> bool { + let wait = if cfg!(target_os = "macos") { + PING_WAIT_MS.to_string() + } else { + PING_WAIT_MS.div_ceil(1_000).to_string() + }; + Command::new("ping") + .args(["-n", "-c", "1", "-W", &wait, &addr.to_string()]) + .stdin(Stdio::null()) + .output() + .is_ok_and(|out| out.status.success()) +} + /// The loop, over one target. struct Driver { target: Target, @@ -1083,9 +1212,20 @@ impl Driver { /// **`reboot` is `systemctl` and returns before the machine goes down**, so /// the machine is watched down before it is watched back up: a probe that /// caught dying Ubuntu would read a stick ToyOS had never booted. - fn ride_the_reboot(&self, secs: u64) -> Result { + /// + /// The window between the two is the only span in which the machine is + /// running the image this loop wrote, and [`Ping`] is what asks the cable + /// about it while it lasts. + fn ride_the_reboot( + &self, + secs: u64, + addr: std::net::Ipv4Addr, + ) -> Result<(u64, Option), Refusal> { self.wait(GOING_DOWN_SECS, "go down", false)?; - self.wait(secs, "come back", true) + let ping = Ping::start(addr); + let back = self.wait(secs, "come back", true); + let answered = ping.end(); + Ok((back?, answered)) } /// Wait for the log partition's device node, and say how long it took. @@ -1431,6 +1571,10 @@ pub fn run(args: &Args) -> Result, Refusal> { ))); }; let image = admit(asked, &args.target)?; + // Before the flash, because a machine whose name answers no address is one + // no boot of it could be asked anything over the cable, and that is a + // finding about this host rather than about the boot. + let address = args.target.address()?; // **Before the flash, and before anything can refuse.** Every refusal below // returns without reaching `write_readback`, so a directory left holding the // last run's files is one a judge reads as this run's — which is how a boot @@ -1480,8 +1624,15 @@ pub fn run(args: &Args) -> Result, Refusal> { return Ok(None); } - let back = driver.ride_the_reboot(args.wait_secs)?; + let (back, pinged) = driver.ride_the_reboot(args.wait_secs, address)?; println!("the machine answered ssh again after {back} s"); + match pinged { + Some(secs) => println!( + "{address} answered a ping {secs} s into the window, after {PING_SILENCE_SECS} s of \ + silence — so something on this cable was up while Ubuntu was not" + ), + None => println!("nothing answered a ping at {address} while the machine was down"), + } // Before the mount, so the stick's own answer is a number rather than // the reason a mount failed. let stick = driver.wait_for_the_stick()?; @@ -1508,7 +1659,7 @@ pub fn run(args: &Args) -> Result, Refusal> { println!("toyos-fat32-check: the log partition's {} bytes check out", bytes.len()); } if let Some(dir) = &args.readback { - write_readback(dir, &loader, &log, back, stick)?; + write_readback(dir, &loader, &log, back, stick, address, pinged)?; println!("readback written to {}", dir.display()); } // **Named by evidence, before the boot record is missed.** A boot that @@ -1626,10 +1777,20 @@ pub const READBACK_BOOT: &str = "boot.txt"; /// the outside judge read, so a complaint can be looked at rather than retold. pub const READBACK_VOLUME: &str = "log-partition.img"; -/// The two keys [`READBACK_BOOT`] carries, one ` ` per line. +/// The keys [`READBACK_BOOT`] carries, one ` ` per line. pub const BACK_SECS: &str = "back_secs"; pub const STICK_SECS_KEY: &str = "stick_secs"; +/// The address this loop pinged while the machine was down, and how far into +/// that window the first reply came. +/// +/// **The address is written whether or not anything answered, and the seconds +/// only if something did.** Which address was asked is a fact about the run; +/// whether it replied is the boot's answer, and an absent key is `no` said +/// where a zero would be a reply in the first second. +pub const PING_ADDR_KEY: &str = "ping_addr"; +pub const PING_SECS_KEY: &str = "ping_secs"; + /// Every file a readback directory carries, so a run that writes none of them /// leaves none of the last run's behind. pub const READBACK_FILES: &[&str] = @@ -1666,6 +1827,8 @@ fn write_readback( log: &str, back: u64, stick: u64, + address: std::net::Ipv4Addr, + pinged: Option, ) -> Result<(), Refusal> { let wrote = |path: &Path, text: &str| -> Result<(), Refusal> { std::fs::write(path, text) @@ -1678,7 +1841,13 @@ fn write_readback( // The boot's own millisecond count is in the kernel log and read from // there; this file carries only what the *host* clock measured, which no // log can. - wrote(&dir.join(READBACK_BOOT), &format!("{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n")) + let mut boot = format!( + "{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n{PING_ADDR_KEY} {address}\n" + ); + if let Some(secs) = pinged { + boot.push_str(&format!("{PING_SECS_KEY} {secs}\n")); + } + wrote(&dir.join(READBACK_BOOT), &boot) } /// Whether this boot was a loader pass that reported a record and booted no @@ -1725,6 +1894,21 @@ pub fn stick_secs(text: &str) -> Option { key(text, STICK_SECS_KEY) } +/// How far into the window between the two operating systems the machine's own +/// address first answered a ping, or `None` where nothing did. +pub fn ping_secs(text: &str) -> Option { + key(text, PING_SECS_KEY) +} + +/// The address that was pinged. Absent only from a readback written before this +/// loop asked. +pub fn ping_addr(text: &str) -> Option { + text.lines() + .find_map(|line| line.strip_prefix(PING_ADDR_KEY)) + .map(|rest| rest.trim().to_string()) + .filter(|got| !got.is_empty()) +} + fn key(text: &str, name: &str) -> Option { text.lines() .find_map(|line| line.strip_prefix(name)) @@ -1933,6 +2117,26 @@ mod tests { assert_eq!(back_secs("back_secs later\n"), None); } + /// **A boot the cable did not answer is not a boot that answered in the + /// first second.** The address is written whichever way it went, so a + /// readback carrying one and no seconds says the window passed in silence, + /// and one carrying neither is a run from before this loop asked at all. + #[test] + fn a_ping_nothing_answered_is_written_as_no_answer() { + let answered = "back_secs 61\nstick_secs 2\nping_addr 192.168.1.42\nping_secs 17\n"; + let silent = "back_secs 46\nstick_secs 2\nping_addr 192.168.1.42\n"; + assert_eq!(ping_addr(answered).as_deref(), Some("192.168.1.42")); + assert_eq!(ping_secs(answered), Some(17)); + assert_eq!(ping_addr(silent).as_deref(), Some("192.168.1.42")); + assert_eq!(ping_secs(silent), None); + assert_eq!(ping_addr("back_secs 46\n"), None); + // The two keys share a prefix, and neither may be read off the other's + // line. + assert_eq!(ping_secs("ping_addr 192.168.1.42\n"), None); + assert_eq!(back_secs(answered), Some(61)); + assert_eq!(stick_secs(answered), Some(2)); + } + #[test] fn an_nvme_node_cannot_be_written_down() { for name in ["/dev/nvme0n1", "/dev/nvme0n1p3", "/dev/sda1", "/dev/sdaa", "/dev/SDA", "sda"] diff --git a/src/sourcegate.rs b/src/sourcegate.rs index c1393ca450..285660803e 100644 --- a/src/sourcegate.rs +++ b/src/sourcegate.rs @@ -559,6 +559,18 @@ const HOST_SPAWNS: &[Spawn] = &[ nothing else — no build, boot or gate reaches it, and the metal loop runs only \ when it is asked for", }, + Spawn { + arg: "\"ping\"", + sites: &[], + why: "the host's own ICMP client, in `src/metal.rs` alone and only across the window in \ + which the T14 is running neither of its operating systems. It is the one question \ + this repository can ask a metal boot while that boot is still up — everything \ + else it reads is on a stick, read minutes later from Ubuntu — and it is an \ + implementation of ICMP nobody here wrote, which is what makes it an oracle for the \ + stack under test rather than a second opinion from it. Outside the bar and \ + declared by nothing else: no build, boot or gate reaches it, and the metal loop \ + runs only when it is asked for", + }, Spawn { arg: "\"/sbin/newfs_msdos\"", sites: &[], diff --git a/tests/common/lan.rs b/tests/common/lan.rs new file mode 100644 index 0000000000..7b78ca36ce --- /dev/null +++ b/tests/common/lan.rs @@ -0,0 +1,264 @@ +//! The cable: netd taking this machine's address from the network, and the T14 +//! answering the development host on it. +//! +//! **The two arms answer different questions.** Under QEMU the DHCP server is +//! the user-mode backend's own, an implementation of RFC 2131 this repository +//! did not write, and what it certifies is the client: the lease it hands out +//! is known — `10.0.2.15/24`, gateway and server `10.0.2.2`, resolver +//! `10.0.2.3` — so a client that mis-parses any field is caught by name. On the +//! T14 the server is the bench's router, the lease is whatever it has for this +//! MAC, and what is certified is the whole path: the kernel handing netd the +//! I219's function, the driver bringing its link up, the lease, and the +//! development host's own `ping` being answered at the leased address while the +//! machine is running nothing else. +//! +//! Every line read here is a record. On the T14 a userland `println!` reaches +//! `Backend::None`, so what crosses to the stick is the kernel's log — into +//! which netd's `say!` writes, being a `write` to a console object. + +use std::path::Path; + +use toyos_build::metalprofile::Profile; + +use super::metal; +use super::qemu::{self, BootOptions, QemuInstance}; +use super::serial; + +/// The boot config the T14 arm flashes, and the name every profile row for that +/// boot is under. +pub const CONFIG: &str = "tests/lancase"; +pub const BOOT: &str = "lancase"; + +/// The one job on that boot: it holds the machine up while the host pings it. +pub const JOBS: &[&str] = &["test_rs_lan_hold"]; + +/// The config the QEMU arm boots — the Intel driver in front of the user-mode +/// backend, which is the same driver the T14 arm runs and the only DHCP server +/// this host can put in front of it. +const QEMU_CONFIG: &str = "tests/e1000case"; + +/// What QEMU's user-mode backend leases, and what it says about the network it +/// leases on. Its own defaults, not this repository's: they are the oracle. +const SLIRP_ADDRESS: &str = "10.0.2.15"; +const SLIRP_PREFIX: u8 = 24; +const SLIRP_ROUTER: &str = "10.0.2.2"; +const SLIRP_DNS: &str = "10.0.2.3"; + +/// The card the T14 arm claims, as the kernel and the manifest spell it. +const ID: &str = "8086:15fc"; + +/// The records this pair of arms is written against, spelled once. +/// +/// They are netd's own `say!` lines, and netd is another crate: what holds the +/// two spellings together is that a boot missing any of these fails here by +/// name rather than passing quietly. +const MAC: &str = "netd: MAC "; +const LEASE: &str = "netd: DHCP: lease "; +const LINK_UP: &str = "netd: I219: link up at "; +const READY: &str = "netd: ready, at most "; + +/// One lease, as the record carries it. +#[derive(Debug, PartialEq, Eq)] +pub struct Lease { + pub address: String, + pub prefix: u8, + pub server: String, + pub gateway: String, + pub dns: Vec, + /// Milliseconds between netd starting and the lease landing. + pub ms: u64, +} + +/// The lease record, read out of a boot's log. +/// +/// Anchored on the record's own words rather than on positions, so a line that +/// grows a field still reads and one that loses a field is refused by name. +pub fn lease_in(text: &str) -> Result { + let line = text + .lines() + .find(|l| l.contains(LEASE)) + .ok_or_else(|| format!("no {LEASE:?} record: this boot took no address from its network"))?; + let unreadable = |what: &str| format!("{line:?} carries no {what}"); + let after = |head: &str, tail: &str| -> Result { + let (_, rest) = line.split_once(head).ok_or_else(|| unreadable(head))?; + let (got, _) = rest.split_once(tail).ok_or_else(|| unreadable(tail))?; + Ok(got.to_string()) + }; + let cidr = after(LEASE, " from ")?; + let (address, prefix) = cidr.split_once('/').ok_or_else(|| unreadable("an address/prefix"))?; + let dns = after(", dns [", "]")?; + Ok(Lease { + address: address.to_string(), + prefix: prefix.parse().map_err(|_| unreadable("a prefix length"))?, + server: after(" from ", ",")?, + gateway: after(", gateway ", ",")?, + dns: dns.split_whitespace().map(str::to_string).collect(), + ms: after("], ", " ms after netd came up")? + .parse() + .map_err(|_| unreadable("a millisecond count"))?, + }) +} + +/// How long after the driver came up the link did, out of the driver's own +/// record. +pub fn link_up_ms(text: &str) -> Result { + let line = text.lines().find(|l| l.contains(LINK_UP)).ok_or_else(|| { + format!("no {LINK_UP:?} record: this boot's card never reported a link") + })?; + let (_, rest) = line.split_once(", ").ok_or_else(|| { + format!("{line:?} says nothing about when the link came up, so the card was already up") + })?; + rest.split_once(" ms after the driver came up") + .ok_or_else(|| format!("{line:?} carries no link-up time"))? + .0 + .parse() + .map_err(|_| format!("{line:?} carries no readable link-up time")) +} + +/// The T14's judge: the claim, the card, the lease, and the host's own ping. +/// +/// **The ping and the lease are held to each other.** The address the host +/// pinged is the one this machine's name resolved to before the boot; the +/// address the boot leased is in its own record; a run where those differ is a +/// ping answered by something that is not this boot. +pub fn on_metal(back: &metal::Readback) -> Result<(), String> { + let profile = Profile::load(&super::compile::repo_root()).map_err(|why| why.to_string())?; + let kernel = back.kernel(); + let text = kernel.text(); + let mut bad: Vec = Vec::new(); + + // The kernel's own account of the hand-over, which is where an interrupt + // mechanism the substrate cannot arm is refused by name. A boot with no + // hand-over line carries the refusal instead, and quoting it is the whole + // diagnosis. + let handed = format!("[{}] handed over on slot", ID); + match text.lines().find(|l| l.contains(&handed)) { + Some(line) => eprintln!(" [lan] {}", line.trim()), + None => bad.push(match text.lines().find(|l| l.contains("NOT HANDED OVER")) { + Some(line) => format!("the kernel refused this function: {}", line.trim()), + None => format!( + "no `{handed}` record and no refusal either: nothing on this machine claimed \ + {ID}, so `tests/lancase` was flashed onto a machine that has no such card" + ), + }), + } + + for owed in [MAC, LINK_UP, READY] { + if !text.contains(owed) { + bad.push(format!("no {owed:?} record")); + } + } + + match link_up_ms(text) { + Ok(ms) => { + eprintln!(" [lan] the link came up {ms} ms after the driver did"); + if let Err(why) = profile.judge(&format!("lan.{}.link_up_ms", back.label), ms) { + bad.push(why.to_string()); + } + } + Err(why) => bad.push(why), + } + + match lease_in(text) { + Ok(lease) => { + eprintln!( + " [lan] leased {}/{} from {} in {} ms, gateway {}, dns {:?}", + lease.address, lease.prefix, lease.server, lease.ms, lease.gateway, lease.dns + ); + if let Err(why) = profile.judge(&format!("lan.{}.lease_ms", back.label), lease.ms) { + bad.push(why.to_string()); + } + if lease.address != back.ping_addr { + bad.push(format!( + "this boot leased {} and the host pinged {}, so whatever answered was not \ + this boot", + lease.address, back.ping_addr + )); + } + } + Err(why) => bad.push(why), + } + + match back.ping_secs { + Some(secs) => { + eprintln!( + " [lan] {} answered the host's ping {secs} s into the window", + back.ping_addr + ); + // Judged here as well as by the boot loop, because this is the arm + // that *claims* the number: a ceiling nobody wrote is what tells + // Ubuntu's reply on its way back up from this boot's, and the + // profile refuses an unpriced name rather than passing it. + if let Err(why) = profile.judge(&format!("boot.{}.ping_secs", back.label), secs) { + bad.push(why.to_string()); + } + } + None => bad.push(format!( + "nothing answered a ping at {} while this machine was between its two operating \ + systems", + back.ping_addr + )), + } + + if let Err(why) = back.job_passed(JOBS[0]) { + bad.push(why); + } + + if bad.is_empty() { + return Ok(()); + } + Err(format!("{} finding(s):\n {}", bad.len(), bad.join("\n "))) +} + +/// The QEMU arm: the client, against a DHCP server this repository did not +/// write. +/// +/// Every field of the lease is checked against what the user-mode backend +/// serves, because a client that dropped the router option or read the mask off +/// the wrong option would otherwise pass on a machine where the answers happen +/// to agree. And the readiness line is checked to come *after* the lease: every +/// other arm in this suite waits for that line and then connects, so a netd that +/// announced itself before it had an address would hand those arms a stack with +/// none. +pub fn lan_dhcp_lease( + _test_config: &Path, + _c_bins: &[(String, Vec)], + _rust_bins: &[(String, Vec)], +) -> Result<(), String> { + let case = super::compile::repo_root().join(QEMU_CONFIG); + let options = BootOptions { profile: qemu::Profile::E1000e, ..Default::default() }; + if !qemu::profile_argv(&options).iter().any(|a| a.contains("e1000e")) { + return Err("this test needs an Intel NIC and the profile has none".to_string()); + } + let mut guest = QemuInstance::boot_with_options(&case, &[], &[], options); + let mut console = guest.boot_log().to_string(); + qemu::await_marker(&mut guest, &mut console, READY, "netd to take an address")?; + console.push_str(&guest.drain_serial(std::time::Duration::from_millis(500))); + let log = serial::Serial::named("the lan boot", console.as_str()); + + let lease = lease_in(log.text())?; + let want = Lease { + address: SLIRP_ADDRESS.to_string(), + prefix: SLIRP_PREFIX, + server: SLIRP_ROUTER.to_string(), + gateway: SLIRP_ROUTER.to_string(), + dns: vec![SLIRP_DNS.to_string()], + ms: lease.ms, + }; + if lease != want { + return Err(format!( + "the client read this lease as {lease:?} and the backend serves {want:?}" + )); + } + // The order, and not merely the presence of both. + log.must_say_after(LEASE, READY)?; + log.must_say(LINK_UP)?; + let ms = link_up_ms(log.text())?; + eprintln!( + " [lan] the emulated link came up in {ms} ms and the lease landed {} ms after netd \ + started", + lease.ms + ); + log.must_be_clean()?; + Ok(()) +} diff --git a/tests/common/metal.rs b/tests/common/metal.rs index 43b310ccd1..8ed4079eba 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -204,6 +204,20 @@ pub struct Readback { /// this machine holds, and it is a row rather than the reason a mount /// happened to work. pub stick_secs: u64, + /// The address the loop pinged while the machine was between its two + /// operating systems. + pub ping_addr: String, + /// How far into that window the address first answered, and `None` where + /// nothing did. + /// + /// **A fact about the cable, measured on every boot.** Ubuntu answers at + /// this address too, on its way back up, so the number alone says only that + /// *something* did. What makes it a verdict is the ceiling + /// `tests/metal-profile.toml` prices for the boot that claims it, which is + /// far under what a boot with no network of its own measures — every other + /// boot in this suite is that boot, so the separation is read rather than + /// assumed. + pub ping_secs: Option, } impl Readback { @@ -695,6 +709,10 @@ fn read_readback(dir: &Path, label: &str) -> Result { .ok_or_else(|| format!("{label}'s boot file names no `back_secs`: {boot:?}"))?; let stick_secs = toyos_build::metal::stick_secs(&boot) .ok_or_else(|| format!("{label}'s boot file names no `stick_secs`: {boot:?}"))?; + // Required, and the seconds beside it are not: the address says the loop + // asked, and its absence is a readback from a run that could not. + let ping_addr = toyos_build::metal::ping_addr(&boot) + .ok_or_else(|| format!("{label}'s boot file names no `ping_addr`: {boot:?}"))?; Ok(Readback { label: label.to_string(), boot_ms: bootlog::boot_millis(&kernel), @@ -702,6 +720,8 @@ fn read_readback(dir: &Path, label: &str) -> Result { kernel, back_secs, stick_secs, + ping_addr, + ping_secs: toyos_build::metal::ping_secs(&boot), }) } @@ -898,17 +918,37 @@ pub fn run( ("stick_secs", Some(back.stick_secs)), ("deadline_lateness_ms", back.deadline_lateness_ms()), ("lockup_lateness_ms", back.lockup_lateness_ms()), + ("ping_secs", back.ping_secs), ] { let name = format!("boot.{label}.{field}"); let priced = profile.row(&name).is_some(); + // **The ping is taken on every boot and claimed by one.** + // Ubuntu answers this address on its way back up, so every + // boot with no network of its own produces a reading — and + // those readings are what the priced boot's ceiling is + // derived from, not numbers each of those boots owes a row + // for. A boot that *is* priced still owes its reading, and + // the arm below is where a silent one reds. + if !priced && field == "ping_secs" { + continue; + } if value.is_none() && !priced && field.ends_with("_lateness_ms") { continue; } let Some(value) = value else { + let why = if field == "ping_secs" { + format!( + "nothing answered a ping at {} in the window between the two \ + operating systems, so this boot's own network never came up", + back.ping_addr + ) + } else { + "the bound this boot was armed for is not the one that ended it" + .to_string() + }; eprintln!( " FAIL {name}: this boot recorded none, and the profile prices \ - it — so the bound this boot was armed for is not the one that \ - ended it" + it — {why}" ); red = true; continue; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index a832cc19e5..e9a3cb85e6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -25,6 +25,9 @@ pub mod https; pub mod iommu; #[allow(dead_code)] pub mod irqcensus; +/// The cable: netd's address, and the T14 answering the host on it. +#[allow(dead_code)] +pub mod lan; #[allow(dead_code)] pub mod logread; #[allow(dead_code)] diff --git a/tests/lancase/system.toml b/tests/lancase/system.toml new file mode 100644 index 0000000000..e3bf43699c --- /dev/null +++ b/tests/lancase/system.toml @@ -0,0 +1,32 @@ +# The one boot that runs netd in front of the ThinkPad T14's own NIC. +# +# The card is the onboard I219 at `00:1f.6`, `8086:15fc`, which is the same +# register file `tests/e1000case`'s 82574L has — so what this config changes +# against that one is the pair of identifiers and nothing else. It is a +# directory of its own for the reason e1000case is: a program that names a card +# this machine does not have costs an `init:` refusal line on every boot of the +# config that does, and no machine has both. +# +# Nothing here reaches the internal NVMe. There is no `[disks]` row, and the +# only device any program on this boot claims is the PCI function named below. + +[boot] +start = ["logd", "netd", "test-runner"] + +[programs.logd] +syscap = ["logread"] +# The record stream's authority: the address on the boot parameter line is +# information, and this row is the whole of what can act on it. +receives = ["netd"] + +# netd holds the NIC's PCI function and drives it: the descriptor rings, the +# register window and the interrupt are its own, and the kernel keeps only the +# claim. Named by vendor and device rather than by slot, so one row finds the +# card wherever firmware put it. +[programs.netd] +serves = ["netd"] +devices = ["pci:8086:15fc"] + +[programs.test-runner] +receives = ["netd"] +syscap = ["logread"] diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index e99f0df0f1..2f0cfb4566 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -609,3 +609,51 @@ name = "boot.ccorpus-2.stick_secs" unit = "s" ceiling = 30 ceiling_from = "as boot.testcases.stick_secs" + +# --- the cable: the boot that runs netd in front of the T14's own I219 --- +# Every number here is unread: the ceilings below are the widest true bounds +# this repository can derive before the machine has answered once, which is what +# `ceiling_from` says of each. The run that takes the first readings is the run +# that tightens them. + +[[number]] +name = "boot.lancase.complete_ms" +unit = "ms" +ceiling = 60000 +ceiling_from = "toyos_tco::JOB_BOUND_MS — as boot.testcases.complete_ms" + +[[number]] +name = "boot.lancase.back_secs" +unit = "s" +ceiling = 420 +ceiling_from = "toyos_build::metal::return_secs" + +[[number]] +name = "boot.lancase.stick_secs" +unit = "s" +ceiling = 30 +ceiling_from = "as boot.testcases.stick_secs" + +[[number]] +name = "boot.lancase.ping_secs" +unit = "s" +ceiling = 420 +ceiling_from = "toyos_build::metal::return_secs — the window this is measured in is the loop's own wait for the machine to answer ssh again, and nothing narrower has been read yet. What it becomes is a ceiling under the reading every boot with no network of its own takes at the same address, which is Ubuntu answering on its way back up: those readings are the separation this number rests on, and until one exists this row bounds the loop rather than the boot" + +[[number]] +name = "list.lancase.job_ms" +unit = "ms" +ceiling = 22000 +ceiling_from = "lan_hold's own twenty-second window plus two seconds for the spawn and the exit record around it; the one member of this list is that window and nothing else" + +[[number]] +name = "lan.lancase.link_up_ms" +unit = "ms" +ceiling = 20000 +ceiling_from = "lan_hold's twenty-second window: a link that comes up later than that is a link this boot never had, because the machine has already handed itself back" + +[[number]] +name = "lan.lancase.lease_ms" +unit = "ms" +ceiling = 20000 +ceiling_from = "as lan.lancase.link_up_ms, and netd's own dhcp::LEASE_BOUND is the same twenty seconds: a boot with no lease by then has already said so in its log" diff --git a/tests/test-durations b/tests/test-durations index 5dd3b24c61..4ff464f93f 100644 --- a/tests/test-durations +++ b/tests/test-durations @@ -254,6 +254,7 @@ keyboard_claim_close_spares_stdin 4580 shards=12 kill_while_blocked 44 shards=12 klogd_hosted 5674 shards=12 klogd_panic_halts 16658 shards=12 +lan_dhcp_lease 18446744073709551615 shards=none lapic_spurious_vector 6829 shards=12 late_storage_connect 6455 shards=12 latency_wake 8790 shards=12 diff --git a/tests/toyos-rust-tests/src/bin/lan_hold.rs b/tests/toyos-rust-tests/src/bin/lan_hold.rs new file mode 100644 index 0000000000..72c72c3ee5 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/lan_hold.rs @@ -0,0 +1,31 @@ +//! Hold the boot open for as long as the host needs to reach this machine over +//! the cable, and exit. +//! +//! **A metal boot ends itself**, and the whole of a `tests/lancase` boot is +//! about a second: the job list runs and the last job hands the machine back to +//! firmware. Nothing on the network could be asked of a machine that is up for +//! that long — the I219's link takes seconds to negotiate before a DHCP +//! discover can even go out — so this job is the window, and its exit record is +//! what says the machine stayed up for the whole of it. +//! +//! It asserts nothing. What it is evidence *for* is judged on the host, out of +//! the records netd wrote inside this window and out of whether the host's own +//! `ping` was answered while it was open; a bare `sleep` cannot be wrong about +//! either. It is on `RUST_SKIP` for the same reason: on any boot but that one +//! it is twenty seconds of nothing. + +use std::thread::sleep; +use std::time::Duration; + +/// How long this machine stays up for the host. +/// +/// The host polls once a second and the link and the lease come first, so what +/// this has to cover is a gigabit auto-negotiation, a DHCP exchange with the +/// router and several polls after both. `tests/metal-profile.toml`'s +/// `list.lancase.job_ms` is this number plus what a spawn costs, and moving one +/// without the other is a job list the runner's own deadline cuts short. +const HOLD: Duration = Duration::from_secs(20); + +fn main() { + sleep(HOLD); +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 8a1a880e73..1d12807712 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -12,8 +12,8 @@ use common::qemu::{ STALLED, }; use common::{ - audio, compile, devices, faults, hostload, metal, pkg, power, screen, serial, stats, storage, - usb, + audio, compile, devices, faults, hostload, lan, metal, pkg, power, screen, serial, stats, + storage, usb, }; use toyos_build::day::Day; use toyos_build::bootlog::{self, boot_millis}; @@ -231,6 +231,11 @@ const RUST_SKIP: &[&str] = &[ // Needs a NIC in front of netd; only `tests/netcase` has one. // `netd_listener_forgery` runs it there. "netd_listener_forgery", + // It asserts nothing at all: it holds a `tests/lancase` boot open for + // twenty seconds so the host can reach this machine over the cable, and + // `lan_dhcp_lease`'s metal arm is the only job list that names it. On a + // shared boot it would be twenty seconds of nothing. + "lan_hold", // Needs SYS_DEBUG, which the shipping kernel has no arm of at all. // `heap_ceiling_recovery` boots the `test-actuators` kernel on one CPU, // which is also what makes its claim about *the recovered CPU* precise. @@ -643,6 +648,12 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ // is the buffers' business and no arm's to demand; what it may never cost // is half a line, and that is what this one judges. ("log_stream_stalled_peer_delivers_whole_records", Sched::Parallel, Tier::Nightly), + // netd taking this machine's address from the network instead of carrying + // one written down. The DHCP server it is judged against is QEMU's own, an + // implementation of RFC 2131 this repository did not write, and its lease + // is known field by field. The verdicts are records and a lease's fields; + // no clock in it. Fast with the UNMEASURED bootstrap marker until priced. + ("lan_dhcp_lease", Sched::Parallel, Tier::Fast), ("netd_connection_caps", Sched::Parallel, Tier::Fast), // The netcase boot again: netd must not abort a listener on a ring flag its // own client forged. Its verdict is a kernel-reported EOF or its absence; @@ -1295,6 +1306,16 @@ const METAL: &[(&str, metal::Metal)] = &[ "metal_device_probe", metal::Metal::Runs { arms: METALDEVICECASE, judge: |b| devices::on_metal(b[0]) }, ), + ( + // The cable. Under QEMU this name judges netd's DHCP client against the + // user-mode backend's server; here it judges the whole path — the + // kernel handing netd the T14's own I219, the link, a lease from the + // bench's router under this machine's name, and the development host's + // `ping` answered at the leased address in the window where the machine + // is running nothing but this image. + "lan_dhcp_lease", + metal::Metal::Runs { arms: LANCASE, judge: |b| lan::on_metal(b[0]) }, + ), // ---- one image: tests/testcases, no parameters, one job list ---- ( "blackbox_unclaimed_page", @@ -1665,6 +1686,10 @@ const USB_RESET_BOOTS: &[metal::Arm] = &[ const METALCASE: &[metal::Arm] = &[metal::once("metalcase", "tests/metalcase", &[], &[])]; +/// The cable's own boot: netd in front of the T14's I219, and one job that +/// holds the machine up long enough for the host to reach it. +const LANCASE: &[metal::Arm] = &[metal::once(lan::BOOT, lan::CONFIG, &[], lan::JOBS)]; + /// One boot for every in-kernel self-test that logs its verdict at init and /// does nothing else. /// @@ -13604,6 +13629,7 @@ fn run_machine_test( ); Ok(()) } + "lan_dhcp_lease" => lan::lan_dhcp_lease(test_config, c_bins, rust_bins), "https_tls13" => common::https::tls13_judge(rust_bins, common::https::VIRTIO), "https_tls13_e1000e" => common::https::tls13_judge(rust_bins, common::https::E1000E), "log_stream" => common::logstream::stream(common::logstream::VIRTIO, c_bins, rust_bins), From 8dec3ec85d90fc792d991ead8240de49bb0c209b Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 17:31:35 +0200 Subject: [PATCH 03/23] A claimed function is armed on MSI where it has no MSI-X MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pcidev::bring_up` armed exactly one mechanism — `enable_msix(...).ok_or(Refusal::NoMsix)?` — so a function that publishes no MSI-X capability was refused by name and its holder never ran. Every function this project had handed to a process so far was a virtio one and every one of those has MSI-X, so the refusal had only ever been reached by `virtio_net_no_msix`'s deliberate `vectors=0`. The ThinkPad T14's onboard NIC is not one of those. Measured on the machine, not assumed: `/proc/interrupts` names its interrupt `IR-PCI-MSI-0000:00:1f.6 ... enp0s31f6` and `/sys/bus/pci/devices/0000:00:1f.6/msi_irqs/162` reads `mode=msi`, so Linux drives that function on MSI. Flashed and booted (run 28), this kernel wrote `pcidev: PCI 00:1f.6 NOT HANDED OVER — its MSI-X could not be armed`, netd found no endowment and exited, and the bench's one cable stayed dark. `bring_up` now arms MSI-X and falls back to MSI, and `Bound` holds whichever it got. **The driver above the boundary cannot tell which one it is and does not have to**: both deliver the same vector into the same `Interrupt`, and the claim answers the same handle either way. What differs is where the message lives, and therefore what a hand-over back has to write to silence it — `Armed::silence` masks an MSI-X table entry or clears MSI Enable, and `Armed::undo` puts the capability itself back off for a hand-over that armed a vector and was then refused. **MSI is not the weaker mechanism here, and the security argument is the same one.** MSI-X's table is kept out of what the holder maps because a holder that could rewrite it could point the device's message at any address the LAPIC decodes. An MSI function's message is in its own config space, which `pcidev` keeps: `config_read` is read-only and there is no writing counterpart. So MSI needs no BAR withheld — the same rule reaching a different register file. `Refusal::NoMsix` becomes `NoInterrupt` and says both mechanisms, because that is now what it means. `PciDevice::disable_msi` is new and is `disable_msix`'s counterpart: it sets the per-vector mask where the capability implements one and clears MSI Enable, which every function has. `toyos_pci::msi` grows `disabled` and the `MASKED`/`UNMASKED` mask values, host-tested — `disabled` deliberately does not restore the Multiple Message Enable field an arming zeroed, or a function would come back armed for as many vectors as it can raise. The hand-over record now names the mechanism (`vector 0x28 on MSI-X`): it is the first thing a machine that never heard from its device is asked, and it is not something the driver above the boundary can see. The two checks. - **Negative control.** Run 28 on the T14 is this change reverted whole, on the base the granted claim will be measured against: the same `tests/lancase` image on this machine's own I219 with `bring_up` arming MSI-X alone. It refused the claim by name at 1.349 s, netd exited `code=0` at 2.268 s, and nothing answered on the cable for the twenty seconds the boot stayed up. - **Independent oracles, two.** Linux's own reading of the same function, above — a driver nobody here wrote, saying that function is an MSI part. And the capability's register layout, which is the PCI spec's and which `toyos-pci/src/msi.rs`'s tests encode: the offsets of the address, data and mask registers all move with the 64-bit address bit, and writing a vector at the wrong one of them lands in whatever capability comes next in the list. Beside them, this kernel already produces the shape MSI must reproduce — `PCI 00:1f.3: msi address=0xfee00098 data=0x00000000` for the T14's HDA. Green: `cargo test -p toyos-pci` (41), `cargo test --lib` (296), `cargo run -- --clippy` (all five invocations), and the four guest arms that read this module — `virtio_net_no_msix`, `iommu_virtio_platform`, `pci_function_is_exclusive` and `userdev_dma_fault`. The MSI arm itself is exercised on the T14 alone: no device QEMU models that a process may claim publishes MSI without MSI-X, so there is no guest that can take that branch. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- kernel/src/drivers/pci.rs | 18 ++++++- kernel/src/pcidev/mod.rs | 103 +++++++++++++++++++++++++++++++------- tests/common/faults.rs | 7 ++- tests/common/iommu.rs | 14 ++++-- toyos-pci/src/msi.rs | 43 ++++++++++++++++ 5 files changed, 161 insertions(+), 24 deletions(-) diff --git a/kernel/src/drivers/pci.rs b/kernel/src/drivers/pci.rs index 407c5ab1d8..0030246e4c 100644 --- a/kernel/src/drivers/pci.rs +++ b/kernel/src/drivers/pci.rs @@ -326,7 +326,7 @@ impl PciDevice { } cap.write_u16(msi.data(), data as u16); if let Some(mask) = msi.mask() { - cap.write_u32(mask, 0); + cap.write_u32(mask, msi::UNMASKED); } cap.write_u16(msi::MESSAGE_CONTROL, msi::Msi::enabled(control)); self.report_message( @@ -337,6 +337,22 @@ impl PciDevice { true } + /// Put MSI back off: the counterpart of [`Self::disable_msix`], and what a + /// claimed MSI function has in place of masking a table entry. + /// + /// The mask first where the function implements one, because that is the + /// per-vector lever and the one MSI-X's own hand-back uses; the enable bit + /// after, because every function has that one. A function still delivering + /// once its holder is gone writes its message into a slot with no reader. + pub fn disable_msi(&self) { + let Some(cap) = self.capabilities().find(|c| c.id() == msi::CAP_ID) else { return }; + let control = cap.read_u16(msi::MESSAGE_CONTROL); + if let Some(mask) = msi::Msi::decode(control).mask() { + cap.write_u32(mask, msi::MASKED); + } + cap.write_u16(msi::MESSAGE_CONTROL, msi::Msi::disabled(control)); + } + pub fn capabilities(&self) -> CapabilityIter<'_> { let first = self.mmio.read_u8(CAPABILITIES_PTR); CapabilityIter { device: self, walk: caps::CapWalk::new(), next: first } diff --git a/kernel/src/pcidev/mod.rs b/kernel/src/pcidev/mod.rs index ea8d9f5813..429126ae62 100644 --- a/kernel/src/pcidev/mod.rs +++ b/kernel/src/pcidev/mod.rs @@ -3,8 +3,9 @@ //! The line through the device is **who can name an address**. This module //! keeps config space — there is no write path to it from userland — puts the //! function in an address space of its own at the unit *before* it enables bus -//! mastering, programs the interrupt vector into its MSI-X table, and hands out -//! every device address a descriptor may carry. Nothing the holder writes into +//! mastering, programs the interrupt vector into whichever of the function's +//! two message mechanisms it has, and hands out every device address a +//! descriptor may carry. Nothing the holder writes into //! a descriptor can make the device touch memory the kernel did not grant it: //! the domain maps the grants and nothing else, and an address outside them is //! refused at the unit and recorded against that claim. @@ -16,7 +17,9 @@ //! //! **The BAR holding the MSI-X table or PBA is never mapped**: a holder that //! could rewrite the table could point the device's message at any address the -//! LAPIC decodes. +//! LAPIC decodes. An MSI function's message is in config space instead, which +//! is read-only from userland, so it needs no BAR withheld — the same rule +//! reaching a different register file, not a weaker one. //! //! **A function with no address space of its own is not handed over**, because //! every grant would answer with a physical address and a descriptor holding @@ -32,7 +35,7 @@ //! above is the mechanism and the reset is the belt. //! //! **What is read back, and what is not.** `Owned` is -//! `pci_function_is_exclusive`, `NoMsix` is `virtio_net_no_msix`, +//! `pci_function_is_exclusive`, `NoInterrupt` is `virtio_net_no_msix`, //! `Untranslated` is `iommu_virtio_platform`'s no-unit arm, the domain is //! `userdev_dma_fault`, and `SYS_DEVICE_REG_READ`'s bound is netd's own //! `config_space_is_bounded`. `Ambiguous`, `KernelDriven`, `Exhausted`, every @@ -121,12 +124,69 @@ struct Grant { bytes: u64, } +/// How a claimed function was made to speak, and what it takes to silence it. +/// +/// **The driver above the boundary cannot tell which one it got, and does not +/// care**: both deliver [`VECTORS`]`[slot]` into the same [`Interrupt`], and the +/// claim answers the same handle either way. What differs is where the message +/// lives — an MSI-X table entry in a mapping this kernel keeps for itself, or a +/// word of config space, which has no write path from userland at all — and so +/// what a hand-over back has to write to stop it. +enum Armed { + /// This function's one MSI-X table entry, mapped for the kernel alone. + Msix(Mmio), + /// MSI: the message is in this function's own config space and nothing was + /// mapped for it. + Msi, +} + +impl Armed { + fn name(&self) -> &'static str { + match self { + Self::Msix(_) => "MSI-X", + Self::Msi => "MSI", + } + } + + /// Stop this function delivering, for a holder that is gone. + fn silence(&self, pci: &PciDevice) { + match self { + Self::Msix(entry) => entry.write_u32(msix::ENTRY_VECTOR_CONTROL, msix::ENTRY_MASKED), + Self::Msi => pci.disable_msi(), + } + } + + /// Put the capability itself back off, for a hand-over that armed a vector + /// and was then refused: a function left enabled at a vector nobody holds + /// delivers into a slot with no reader. + fn undo(&self, pci: &PciDevice) { + match self { + Self::Msix(_) => pci.disable_msix(), + Self::Msi => pci.disable_msi(), + } + } +} + +/// MSI-X first, then MSI, and neither is somewhere a holder can write. +/// +/// **MSI-X first because it is the one this kernel can mask per vector**, and +/// because a function that publishes it is one whose table BAR is then kept out +/// of what the holder maps. MSI is not a lesser mechanism — the device performs +/// the same write to the same address — and the parts that have only it are not +/// rare: the ThinkPad's onboard I219 is one, which is where this arm came from. +fn arm(pci: &PciDevice, vector: u8) -> Option { + if let Some(entry) = pci.enable_msix(vector) { + return Some(Armed::Msix(entry)); + } + pci.enable_msi(vector).then_some(Armed::Msi) +} + /// What a live slot drives. The ISR never reads this. struct Bound { pci: PciDevice, space: DeviceSpace, - /// This function's one MSI-X table entry, mapped for the kernel alone. - entry: Mmio, + /// How this function was made to speak, and what silences it. + armed: Armed, id: PciId, /// Where each mappable BAR was put, and how much of it the function /// advertises; 0 bytes is a slot with no BAR this claim may map. @@ -329,7 +389,7 @@ fn window(assigned: u64, ceiling: u64) -> (u64, u64) { /// place. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Refusal { - NoMsix, + NoInterrupt, Untranslated(IommuError), NoWindow, BarUnsizable(u8), @@ -341,10 +401,10 @@ enum Refusal { impl core::fmt::Display for Refusal { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { - Self::NoMsix => write!( + Self::NoInterrupt => write!( f, - "its MSI-X could not be armed, and a claim with no interrupt is a driver \ - that would never be told anything" + "neither its MSI-X nor its MSI could be armed, and a claim with no interrupt \ + is a driver that would never be told anything" ), Self::Untranslated(why) => write!( f, @@ -422,12 +482,17 @@ pub fn claim(id: PciId) -> Result<(PciFunctionInfo, u8, Claim), ClaimError> { func: pci.func, _pad: [0; 5], }; + // Read off the hand-over rather than restated: which mechanism a + // function was armed on is the first thing a machine that never + // heard from its device is asked, and it is not a thing the driver + // above the boundary can see. + let armed = bound.armed.name(); *BOUND[slot].lock() = Some(bound); IRQ[slot].clear(); crate::iommu::note_user_owned(pci.bus, pci.dev, pci.func, Some(slot)); log!( "pcidev: PCI {:02x}:{:02x}.{} [{:04x}:{:04x}] handed over on slot {slot}, \ - vector {:#x}", + vector {:#x} on {armed}", pci.bus, pci.dev, pci.func, @@ -475,11 +540,11 @@ fn bring_up(pci: PciDevice, id: PciId, slot: usize) -> Result { // Decode must be on for a BAR to answer, and off across each move. pci.enable_memory_space(); - // Then the interrupt, still before a window is cut: a function whose MSI-X - // cannot be armed is one no holder could ever be told anything about, and - // `virtio_net_no_msix` reads that refusal off the console *and* the absence - // of any BAR line after it. - let entry = pci.enable_msix(VECTORS[slot]).ok_or(Refusal::NoMsix)?; + // Then the interrupt, still before a window is cut: a function neither + // mechanism can be armed on is one no holder could ever be told anything + // about, and `virtio_net_no_msix` reads that refusal off the console *and* + // the absence of any BAR line after it. + let armed = arm(&pci, VECTORS[slot]).ok_or(Refusal::NoInterrupt)?; // From here a refusal has to undo: a vector is armed, and the arms below // move the function's BARs. @@ -489,7 +554,7 @@ fn bring_up(pci: PciDevice, id: PciId, slot: usize) -> Result { Ok(Bound { pci, space, - entry, + armed, id, bar_at, bar_bytes, @@ -499,7 +564,7 @@ fn bring_up(pci: PciDevice, id: PciId, slot: usize) -> Result { }) } Err(why) => { - pci.disable_msix(); + armed.undo(&pci); Err(why) } } @@ -732,7 +797,7 @@ pub fn release(slot: usize) { fn tear_down(slot: usize, bound: Bound) { bound.pci.disable_bus_master(); - bound.entry.write_u32(msix::ENTRY_VECTOR_CONTROL, msix::ENTRY_MASKED); + bound.armed.silence(&bound.pci); crate::iommu::note_user_owned(bound.pci.bus, bound.pci.dev, bound.pci.func, None); for grant in bound.grants.iter() { if let Err(why) = bound.space.unmap(grant.at, grant.bytes) { diff --git a/tests/common/faults.rs b/tests/common/faults.rs index c1338bad02..1189e99aeb 100644 --- a/tests/common/faults.rs +++ b/tests/common/faults.rs @@ -285,7 +285,12 @@ pub fn virtio_net_no_msix() -> Result<(), String> { // whose holder would never be told anything, and handing it over anyway // would be handing out a device that looks alive and never speaks. log.must_say("pcidev: PCI 00:03.0 NOT HANDED OVER")?; - log.must_say("its MSI-X could not be armed")?; + // **Both mechanisms, named.** `pcidev` arms MSI-X and falls back to MSI, so + // this refusal is owed only by a function that has neither — and a virtio + // function stripped of its MSI-X table publishes no MSI capability to fall + // back to. A predicate naming one of them alone would be satisfied on a + // machine that armed the other and handed the function over. + log.must_say("neither its MSI-X nor its MSI could be armed")?; log.must_not_say("[1af4:1041] handed over")?; // And the refusal is the *whole* of it: no BAR moved for a function nobody // can be given one. diff --git a/tests/common/iommu.rs b/tests/common/iommu.rs index c6768648b9..66df45e8d8 100644 --- a/tests/common/iommu.rs +++ b/tests/common/iommu.rs @@ -794,11 +794,12 @@ fn no_unit_is_no_claim(log: &Serial) -> Result<(), String> { log.must_not_say("handed over on slot")?; // **And the refusal spent nothing on the way out.** No BAR was moved, so // this function's BARs are still where firmware put them, and no vector was - // programmed into its MSI-X table — which is what says `bring_up` asks for - // the address space *before* it touches the function. `slot_space` put back - // below `place_bars` reds here. + // programmed into *either* of the two mechanisms `bring_up` may arm — which + // is what says it asks for the address space before it touches the function. + // `slot_space` put back below `place_bars` reds here. log.must_not_say(BAR_MOVED)?; log.must_not_say(MSIX_ARMED)?; + log.must_not_say(MSI_ARMED)?; log.must_say("init: netd: pci:1af4:1041 is on this machine and could not be handed over")?; // netd's own exit is not read here: it speaks after the ready marker this // capture ends at. It is the same endowment-is-empty path @@ -815,6 +816,13 @@ fn no_unit_is_no_claim(log: &Serial) -> Result<(), String> { const BAR_MOVED: &str = "pcidev: PCI 00:03.0 BAR"; const MSIX_ARMED: &str = "PCI 00:03.0: msix address="; +/// The other mechanism a hand-over may arm, whose absence the no-unit arm owes +/// as well. `bring_up` falls back to MSI, so "no MSI-X message was written" +/// stopped being the whole of "no vector was programmed" the moment it did — +/// and the two lines differ by one character, which is why each is spelled once +/// here rather than at its use. +const MSI_ARMED: &str = "PCI 00:03.0: msi address="; + /// The control that makes the two arms above mean something: a guest that /// declines the feature its host offered gets no device, not a bypassing one. /// `virtio_validate_features` returns `-EFAULT` and `virtio_set_status` returns diff --git a/toyos-pci/src/msi.rs b/toyos-pci/src/msi.rs index db05f0ea90..23a2c3dde8 100644 --- a/toyos-pci/src/msi.rs +++ b/toyos-pci/src/msi.rs @@ -79,8 +79,28 @@ impl Msi { pub fn enabled(message_control: u16) -> u16 { (message_control & !MULTI_MESSAGE_ENABLE) | ENABLE } + + /// Message Control with the function delivering nothing, and everything it + /// said about itself left alone. + /// + /// **The counterpart of a hand-over.** A function whose holder is gone has + /// to stop writing its message somewhere, and MSI-X's per-entry mask lives + /// in a table that does not exist here: this bit is what stands in for it, + /// because a capability's per-vector mask is optional and [`Msi::mask`] is + /// `None` on every function that does not implement one. + pub fn disabled(message_control: u16) -> u16 { + message_control & !ENABLE + } } +/// The Mask Bits value that masks the one vector [`Msi::enabled`] leaves a +/// function armed for, and the one that unmasks it. +/// +/// Bit *n* is vector *n* (PCIe §7.7.1.7), and Multiple Message Enable is zero +/// after `enabled`, so the armed vector is always index 0. +pub const MASKED: u32 = 1 << 0; +pub const UNMASKED: u32 = 0; + #[cfg(test)] mod tests { use super::*; @@ -135,4 +155,27 @@ mod tests { let ctrl = ADDRESS_64 | PER_VECTOR_MASK | (5 << 1); assert_eq!(Msi::enabled(ctrl), ctrl | ENABLE); } + + /// **Disabling is not the inverse of enabling, and must not be.** What a + /// hand-over back owes is that the function stops delivering; what it does + /// not owe is the Multiple Message Enable field an arming zeroed, and a + /// `disabled` that restored it would leave a function armed for as many + /// vectors as it can raise the moment anything set the enable bit again. + #[test] + fn disabling_clears_the_enable_bit_and_nothing_else() { + let ctrl = ADDRESS_64 | PER_VECTOR_MASK | (5 << 1); + assert_eq!(Msi::disabled(ctrl | ENABLE), ctrl); + assert_eq!(Msi::disabled(ctrl), ctrl); + // Round trip, on the field that moves: arming zeroes Multiple Message + // Enable and disarming leaves it zeroed. + assert_eq!(Msi::disabled(Msi::enabled(ctrl | MULTI_MESSAGE_ENABLE)), ctrl); + } + + /// The armed vector is index 0, so the mask that silences it is bit 0. + #[test] + fn the_mask_names_the_one_vector_that_was_armed() { + assert_eq!(MASKED, 1); + assert_eq!(UNMASKED, 0); + assert_eq!(Msi::enabled(MULTI_MESSAGE_ENABLE) & MULTI_MESSAGE_ENABLE, 0); + } } From 938957ae1bb5fc8d2f5528ac657b870f6fee88b1 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 17:34:10 +0200 Subject: [PATCH 04/23] The address the loop pings is the claimed function's, not the machine's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 28 on the T14 pinged `100.92.92.12` and got an answer 64 seconds into the window, one second before `ssh` came back. That is the machine's Tailscale address: `t14` resolves to it on this host, `Target::address` resolved the name, and only the operating system that is leaving and the one coming back ever hold it. The reply was Ubuntu's, and the five seconds of silence the loop waits for did not catch it because Tailscale comes up late in Ubuntu's boot — a boot whose netd never ran at all would have been reported the same way. The name was the wrong question. What a boot of a metal image can answer on is the address held on the PCI function that image *claims*, so that is what is read: `Target::nic` names `0000:00:1f.6`, and `Driver::wire` asks the machine, before the flash, which interface that function is, what MAC it has, and what address `ip -4 -brief addr show` reports for it. Three reads, none of them a root command and none of them a write. Measured just now over ssh: `enp0s31f6`, `192.168.1.46/24`, gateway `192.168.1.1`, with the Mac on `192.168.1.47` — the same LAN, which the Tailscale address is not. The MAC is carried out beside the address and written into the readback, and `lan::on_metal` holds the boot's own `netd: MAC` record to it. That is what turns "something answered" into "this boot answered": a MAC does not change with the operating system, so a driver reporting this one is the driver holding that address, and a reply from any other interface at it is somebody else's. An interface with no address at all is refused by name rather than read as the next line's — `an_interface_with_no_address_is_refused_by_name` stages the T14's own four-interface listing, Tailscale address included. `Refusal::Unresolved` becomes `Refusal::Wire`, because the thing that can now fail is the machine's account of one function rather than a name lookup. Run 28's other numbers are recorded against their rows: `complete_ms` 1223, `back_secs` 65, `stick_secs` 0. They are facts about a boot whose claim the kernel refused, which makes them facts about the boot and not about the claim. `ping_secs` gets none — no run has pinged the wire's address yet — and the comment above the block says so rather than leaving a reader to assume the row was simply not reached. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- src/metal.rs | 221 +++++++++++++++++++++++++++++---------- tests/common/lan.rs | 14 +++ tests/common/metal.rs | 12 ++- tests/metal-profile.toml | 17 ++- 4 files changed, 205 insertions(+), 59 deletions(-) diff --git a/src/metal.rs b/src/metal.rs index 62721b4e1a..dde182b9ea 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -148,9 +148,9 @@ pub enum Refusal { /// A lid key that no longer reads `ignore`, which is what keeps the machine up. Lid { key: &'static str, got: String }, Remote { what: String, status: String, stderr: String }, - /// The machine's name answers no IPv4 address on this host, so the boot - /// this loop is about to make could not be reached over the cable at all. - Unresolved { host: String, why: String }, + /// The machine could not say what address it holds on the function the + /// flashed image claims, so the boot could not be reached over the cable. + Wire { nic: String, why: String }, /// The machine did not go down, or did not come back. Silent { what: &'static str, secs: u64 }, /// The machine came back and the boot stick did not: the boot before this @@ -276,10 +276,11 @@ impl fmt::Display for Refusal { Self::Remote { what, status, stderr } => { write!(f, "{what} on the machine {status}: {stderr}") } - Self::Unresolved { host, why } => write!( + Self::Wire { nic, why } => write!( f, - "{host:?} answers no IPv4 address on this host ({why}), so nothing on the cable \ - could be asked whether this boot came up" + "the machine says nothing usable about PCI function {nic}: {why}. That is the \ + function the flashed image claims, and its address is the only one a boot of \ + that image could answer on" ), Self::Silent { what, secs } => write!( f, @@ -509,6 +510,15 @@ struct Target { mount: String, /// The boot entry's label in the firmware's list. label: String, + /// The PCI function whose cable this loop reaches the boot over, in the + /// spelling `/sys/bus/pci/devices` uses. + /// + /// **The function and not an interface name.** What the flashed image + /// claims is a PCI function, and what answers a ping is whatever address + /// the operating system before it held on that same function — so the two + /// are tied to one identifier here rather than to a name Ubuntu happens to + /// give it. + nic: String, } impl Target { @@ -524,6 +534,7 @@ impl Target { log_part: 3, mount: "/home/t14/toyos-log".to_string(), label: "ToyOS".to_string(), + nic: "0000:00:1f.6".to_string(), }) } @@ -623,28 +634,6 @@ impl Target { Ok(format!("sudo -n {}", words.join(" "))) } - /// The machine's own IPv4 address on this LAN. - /// - /// **Resolved before the boot, because the boot cannot be asked.** The - /// machine's MAC is the same under either operating system, so the lease - /// the router hands the flashed image is the one its name already resolves - /// to — and the name is resolved through the same router's DNS that issued - /// it. The boot's own record of the lease says which address it took, and - /// the judge holds the two together; what this gives is an address to ping - /// while the boot is up, which is the only window there is. - fn address(&self) -> Result { - use std::net::ToSocketAddrs; - let unresolved = |why: String| Refusal::Unresolved { host: self.host.clone(), why }; - (self.host.as_str(), 22u16) - .to_socket_addrs() - .map_err(|e| unresolved(e.to_string()))? - .find_map(|at| match at { - std::net::SocketAddr::V4(v4) => Some(*v4.ip()), - std::net::SocketAddr::V6(_) => None, - }) - .ok_or_else(|| unresolved("it resolves to IPv6 alone".to_string())) - } - /// The one read that decides whether anything is written. fn identity(&self) -> String { let at = self.node.sysfs(); @@ -985,6 +974,44 @@ fn lid_policy(text: &str) -> Result<(), Refusal> { Ok(()) } +/// What the machine holds on the PCI function the flashed image claims. +/// +/// **Read off that function and not off a name.** The address the loop pings +/// has to be the one a boot of this image could answer on, and the two +/// operating systems agree about it for exactly one reason: the function's MAC +/// is the same under both, so a DHCP server ordinarily hands both the same +/// lease. So the MAC is carried out beside the address and the boot's own +/// `netd: MAC` record is held to it — a ping answered at an address some other +/// interface holds is a ping this loop must not report as the boot's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Wire { + pub iface: String, + pub addr: std::net::Ipv4Addr, + /// Lower case, colon separated, as `/sys/class/net//address` writes it. + pub mac: String, +} + +/// `ip -4 -brief addr show `'s one line, as `Wire` needs it. +/// +/// The brief form is ` ...`, and an interface with no +/// address has no third field at all — which is the machine saying the cable is +/// out, and it is refused by name rather than read as some other interface's. +fn brief_address(iface: &str, text: &str) -> Result { + let line = text + .lines() + .find(|l| l.split_whitespace().next() == Some(iface)) + .ok_or_else(|| format!("`ip -4 -brief addr show {iface}` said {text:?}"))?; + let cidr = line + .split_whitespace() + .nth(2) + .ok_or_else(|| format!("{iface} holds no IPv4 address: {line:?}"))?; + cidr.split('/') + .next() + .unwrap_or(cidr) + .parse() + .map_err(|_| format!("{iface}'s address reads {cidr:?}")) +} + /// Whether anything answers at the machine's own address while the machine is /// between two operating systems, and how far into that window it first did. /// @@ -1115,6 +1142,45 @@ impl Driver { answer(what, out).map(Some) } + /// What this machine holds on the function the flashed image claims: the + /// interface Ubuntu gave it, its address, and its MAC. + /// + /// Three reads and not one, so a machine that answers oddly is refused with + /// the read that was odd. None of them is a root command and none of them + /// writes. + fn wire(&self) -> Result { + let nic = &self.target.nic; + let bad = |why: String| Refusal::Wire { nic: nic.clone(), why }; + let at = shell_word(&format!("/sys/bus/pci/devices/{nic}/net")); + let listing = self + .ssh("listing the claimed function's interfaces", &format!("ls {at}")) + .map_err(|e| bad(e.to_string()))?; + let names: Vec<&str> = listing.split_whitespace().collect(); + // Exactly one, refused rather than resolved to the first: a function + // this loop cannot name one interface for is one whose address it would + // be guessing at. + let [iface] = names[..] else { + return Err(bad(format!("it answers {names:?} interface(s), and one is needed"))); + }; + let mac = self + .ssh( + "reading the claimed function's MAC", + &format!("cat {}", shell_word(&format!("/sys/class/net/{iface}/address"))), + ) + .map_err(|e| bad(e.to_string()))?; + let brief = self + .ssh( + "reading the claimed function's address", + &format!("ip -4 -brief addr show {}", shell_word(iface)), + ) + .map_err(|e| bad(e.to_string()))?; + Ok(Wire { + iface: iface.to_string(), + addr: brief_address(iface, &brief).map_err(bad)?, + mac: mac.trim().to_ascii_lowercase(), + }) + } + /// The loop refuses to run at all until the rule is on the machine. fn require_sudo(&self) -> Result<(), Refusal> { let probe = self.target.remote(Job::Probe, None)?; @@ -1571,10 +1637,6 @@ pub fn run(args: &Args) -> Result, Refusal> { ))); }; let image = admit(asked, &args.target)?; - // Before the flash, because a machine whose name answers no address is one - // no boot of it could be asked anything over the cable, and that is a - // finding about this host rather than about the boot. - let address = args.target.address()?; // **Before the flash, and before anything can refuse.** Every refusal below // returns without reaching `write_readback`, so a directory left holding the // last run's files is one a judge reads as this run's — which is how a boot @@ -1611,6 +1673,15 @@ pub fn run(args: &Args) -> Result, Refusal> { identity.vendor, identity.model ); + // Before the flash, because the address a boot of this image could answer + // on is one only the operating system that is still up can be asked for — + // and a machine that cannot say it is a finding about this host rather than + // about the boot. + let wire = driver.wire()?; + println!( + "the claimed function {} is {} at {}, MAC {}", + args.target.nic, wire.iface, wire.addr, wire.mac + ); driver.flash(&image)?; let entry = driver.boot_entry(&image.esp)?; @@ -1624,14 +1695,15 @@ pub fn run(args: &Args) -> Result, Refusal> { return Ok(None); } - let (back, pinged) = driver.ride_the_reboot(args.wait_secs, address)?; + let (back, pinged) = driver.ride_the_reboot(args.wait_secs, wire.addr)?; println!("the machine answered ssh again after {back} s"); match pinged { Some(secs) => println!( - "{address} answered a ping {secs} s into the window, after {PING_SILENCE_SECS} s of \ - silence — so something on this cable was up while Ubuntu was not" + "{} answered a ping {secs} s into the window, after {PING_SILENCE_SECS} s of \ + silence — so something on this cable was up while Ubuntu was not", + wire.addr ), - None => println!("nothing answered a ping at {address} while the machine was down"), + None => println!("nothing answered a ping at {} while the machine was down", wire.addr), } // Before the mount, so the stick's own answer is a number rather than // the reason a mount failed. @@ -1659,7 +1731,7 @@ pub fn run(args: &Args) -> Result, Refusal> { println!("toyos-fat32-check: the log partition's {} bytes check out", bytes.len()); } if let Some(dir) = &args.readback { - write_readback(dir, &loader, &log, back, stick, address, pinged)?; + write_readback(dir, &loader, &log, back, stick, &wire, pinged)?; println!("readback written to {}", dir.display()); } // **Named by evidence, before the boot record is missed.** A boot that @@ -1781,15 +1853,19 @@ pub const READBACK_VOLUME: &str = "log-partition.img"; pub const BACK_SECS: &str = "back_secs"; pub const STICK_SECS_KEY: &str = "stick_secs"; -/// The address this loop pinged while the machine was down, and how far into -/// that window the first reply came. +/// The address this loop pinged while the machine was down, the MAC of the +/// function holding it, and how far into that window the first reply came. /// -/// **The address is written whether or not anything answered, and the seconds -/// only if something did.** Which address was asked is a fact about the run; -/// whether it replied is the boot's answer, and an absent key is `no` said -/// where a zero would be a reply in the first second. +/// **The address and the MAC are written whether or not anything answered, and +/// the seconds only if something did.** Which address was asked, and on which +/// function, are facts about the run; whether it replied is the boot's answer, +/// and an absent key is `no` said where a zero would be a reply in the first +/// second. The MAC is what a judge holds this boot's own driver record to, so +/// an answer from a different interface at that address cannot be read as the +/// boot's. pub const PING_ADDR_KEY: &str = "ping_addr"; pub const PING_SECS_KEY: &str = "ping_secs"; +pub const WIRE_MAC_KEY: &str = "wire_mac"; /// Every file a readback directory carries, so a run that writes none of them /// leaves none of the last run's behind. @@ -1827,7 +1903,7 @@ fn write_readback( log: &str, back: u64, stick: u64, - address: std::net::Ipv4Addr, + wire: &Wire, pinged: Option, ) -> Result<(), Refusal> { let wrote = |path: &Path, text: &str| -> Result<(), Refusal> { @@ -1842,7 +1918,8 @@ fn write_readback( // there; this file carries only what the *host* clock measured, which no // log can. let mut boot = format!( - "{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n{PING_ADDR_KEY} {address}\n" + "{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n{PING_ADDR_KEY} {}\n{WIRE_MAC_KEY} {}\n", + wire.addr, wire.mac ); if let Some(secs) = pinged { boot.push_str(&format!("{PING_SECS_KEY} {secs}\n")); @@ -1903,8 +1980,18 @@ pub fn ping_secs(text: &str) -> Option { /// The address that was pinged. Absent only from a readback written before this /// loop asked. pub fn ping_addr(text: &str) -> Option { + word(text, PING_ADDR_KEY) +} + +/// The MAC of the function that held the pinged address, as the operating +/// system before this boot reported it. +pub fn wire_mac(text: &str) -> Option { + word(text, WIRE_MAC_KEY) +} + +fn word(text: &str, name: &str) -> Option { text.lines() - .find_map(|line| line.strip_prefix(PING_ADDR_KEY)) + .find_map(|line| line.strip_prefix(name)) .map(|rest| rest.trim().to_string()) .filter(|got| !got.is_empty()) } @@ -2123,20 +2210,48 @@ mod tests { /// and one carrying neither is a run from before this loop asked at all. #[test] fn a_ping_nothing_answered_is_written_as_no_answer() { - let answered = "back_secs 61\nstick_secs 2\nping_addr 192.168.1.42\nping_secs 17\n"; - let silent = "back_secs 46\nstick_secs 2\nping_addr 192.168.1.42\n"; - assert_eq!(ping_addr(answered).as_deref(), Some("192.168.1.42")); + let answered = "back_secs 61\nstick_secs 2\nping_addr 192.168.1.46\n\ + wire_mac 8c:8c:aa:bb:cc:dd\nping_secs 17\n"; + let silent = "back_secs 46\nstick_secs 2\nping_addr 192.168.1.46\n\ + wire_mac 8c:8c:aa:bb:cc:dd\n"; + assert_eq!(ping_addr(answered).as_deref(), Some("192.168.1.46")); + assert_eq!(wire_mac(answered).as_deref(), Some("8c:8c:aa:bb:cc:dd")); assert_eq!(ping_secs(answered), Some(17)); - assert_eq!(ping_addr(silent).as_deref(), Some("192.168.1.42")); + assert_eq!(ping_addr(silent).as_deref(), Some("192.168.1.46")); assert_eq!(ping_secs(silent), None); assert_eq!(ping_addr("back_secs 46\n"), None); - // The two keys share a prefix, and neither may be read off the other's - // line. - assert_eq!(ping_secs("ping_addr 192.168.1.42\n"), None); + // The two ping keys share a prefix, and neither may be read off the + // other's line. + assert_eq!(ping_secs("ping_addr 192.168.1.46\n"), None); assert_eq!(back_secs(answered), Some(61)); assert_eq!(stick_secs(answered), Some(2)); } + /// **The address is the one on the function the image claims, and an + /// interface with none is refused rather than read as the next one's.** + /// `ip -4 -brief` prints the name, the state and then the addresses, and an + /// interface whose cable is out prints the first two and stops — which is + /// exactly the machine this loop must not go on to flash and then ping. + #[test] + fn an_interface_with_no_address_is_refused_by_name() { + let up = "enp0s31f6 UP 192.168.1.46/24 \n"; + assert_eq!(brief_address("enp0s31f6", up), Ok("192.168.1.46".parse().unwrap())); + + let down = "enp0s31f6 DOWN \n"; + assert!(brief_address("enp0s31f6", down).unwrap_err().contains("no IPv4 address")); + + // Another interface's line is not this one's answer, however many are + // printed: the T14 holds a Wi-Fi address and a Tailscale one, and a + // ping aimed at either is a ping only Ubuntu ever answers. + let many = "lo UNKNOWN 127.0.0.1/8\n\ + enp0s31f6 UP 192.168.1.46/24\n\ + wlp9s0 UP 192.168.1.244/24\n\ + tailscale0 UNKNOWN 100.92.92.12/32\n"; + assert_eq!(brief_address("enp0s31f6", many), Ok("192.168.1.46".parse().unwrap())); + assert_eq!(brief_address("wlp9s0", many), Ok("192.168.1.244".parse().unwrap())); + assert!(brief_address("enp0s31f7", many).unwrap_err().contains("ip -4 -brief")); + } + #[test] fn an_nvme_node_cannot_be_written_down() { for name in ["/dev/nvme0n1", "/dev/nvme0n1p3", "/dev/sda1", "/dev/sdaa", "/dev/SDA", "sda"] diff --git a/tests/common/lan.rs b/tests/common/lan.rs index 7b78ca36ce..3d885fb2a6 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -149,6 +149,20 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { } } + // **The MAC is what makes an answered ping this boot's.** The address was + // read off the same PCI function under the operating system before this + // one, and a MAC does not change with the operating system — so a driver + // reporting this MAC is the driver holding that address, and a reply from + // anything else at it is some other interface's. + let mac = format!("{MAC}{}", back.wire_mac); + if !text.contains(&mac) { + bad.push(format!( + "no {mac:?} record: the card this boot brought up is not the one that held {} \ + before it", + back.ping_addr + )); + } + match link_up_ms(text) { Ok(ms) => { eprintln!(" [lan] the link came up {ms} ms after the driver did"); diff --git a/tests/common/metal.rs b/tests/common/metal.rs index 8ed4079eba..d3968c54fe 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -205,8 +205,15 @@ pub struct Readback { /// happened to work. pub stick_secs: u64, /// The address the loop pinged while the machine was between its two - /// operating systems. + /// operating systems, read off the PCI function the flashed image claims. pub ping_addr: String, + /// The MAC that function held under the operating system before this boot. + /// + /// **What ties an answered ping to this boot and not to the machine.** A + /// MAC does not change with the operating system, so a boot whose own + /// driver reports this one is the boot that holds that address; an answer + /// from any other interface at it is somebody else's. + pub wire_mac: String, /// How far into that window the address first answered, and `None` where /// nothing did. /// @@ -713,6 +720,8 @@ fn read_readback(dir: &Path, label: &str) -> Result { // asked, and its absence is a readback from a run that could not. let ping_addr = toyos_build::metal::ping_addr(&boot) .ok_or_else(|| format!("{label}'s boot file names no `ping_addr`: {boot:?}"))?; + let wire_mac = toyos_build::metal::wire_mac(&boot) + .ok_or_else(|| format!("{label}'s boot file names no `wire_mac`: {boot:?}"))?; Ok(Readback { label: label.to_string(), boot_ms: bootlog::boot_millis(&kernel), @@ -721,6 +730,7 @@ fn read_readback(dir: &Path, label: &str) -> Result { back_secs, stick_secs, ping_addr, + wire_mac, ping_secs: toyos_build::metal::ping_secs(&boot), }) } diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index 2f0cfb4566..95ab8a610c 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -611,34 +611,41 @@ ceiling = 30 ceiling_from = "as boot.testcases.stick_secs" # --- the cable: the boot that runs netd in front of the T14's own I219 --- -# Every number here is unread: the ceilings below are the widest true bounds -# this repository can derive before the machine has answered once, which is what -# `ceiling_from` says of each. The run that takes the first readings is the run -# that tightens them. +# The three boot facts below are the machine's own, off a boot whose claim the +# kernel refused for want of MSI: they are facts about the boot rather than +# about the claim, so the readings stand. The two `lan.` rows have none, because +# that boot's netd never ran. `ping_secs` has none either, and for a different +# reason: the run that took one pinged the address this machine's *name* +# resolves to, which here is a Tailscale address only the operating system +# before the boot holds — the loop reads the claimed function's own address now, +# and no run has pinged that yet. [[number]] name = "boot.lancase.complete_ms" unit = "ms" ceiling = 60000 ceiling_from = "toyos_tco::JOB_BOUND_MS — as boot.testcases.complete_ms" +measured = 1223 [[number]] name = "boot.lancase.back_secs" unit = "s" ceiling = 420 ceiling_from = "toyos_build::metal::return_secs" +measured = 65 [[number]] name = "boot.lancase.stick_secs" unit = "s" ceiling = 30 ceiling_from = "as boot.testcases.stick_secs" +measured = 0 [[number]] name = "boot.lancase.ping_secs" unit = "s" ceiling = 420 -ceiling_from = "toyos_build::metal::return_secs — the window this is measured in is the loop's own wait for the machine to answer ssh again, and nothing narrower has been read yet. What it becomes is a ceiling under the reading every boot with no network of its own takes at the same address, which is Ubuntu answering on its way back up: those readings are the separation this number rests on, and until one exists this row bounds the loop rather than the boot" +ceiling_from = "toyos_build::metal::return_secs — the window this is measured in is the loop's own wait for the machine to answer ssh again, and nothing narrower has been read yet. What it becomes is a ceiling under the reading a boot with no network of its own takes at the same address, which is the operating system before it answering on its way back up. Until one of those exists this row bounds the loop rather than the boot" [[number]] name = "list.lancase.job_ms" From a85520af76c82af9da05bdace2f5822d9c444ee8 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 18:01:39 +0200 Subject: [PATCH 05/23] The 32-bit window is a free run, and this kernel cannot yet prove one is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 29 on the T14 armed the I219's MSI and then refused the hand-over: pcidev: 24 functions; a 32-bit window comes from 0x0..0x0, a 64-bit one from 0x603dc00000..0x6040c00000 PCI 00:1f.6: msi address=0xfee000b8 data=0x00000000 pcidev: PCI 00:1f.6 NOT HANDED OVER — this machine has no 2 MiB-aligned address space above what firmware assigned to put a BAR in The refusal is correct and its wording was not. `window` places a BAR above everything firmware described; in 64 bits that always exists, and below 4 GiB it never does, because the platform's fixed MMIO is at `0xFEC00000` and the UEFI map reaches it. So the message read as "this machine is full" where the truth is "this module only ever looks above everything, and there is no above down there". A 32-bit window is a free run *between* things. Leaving the BAR where firmware put it is not the alternative: on this machine the I219's `0xbcf00000` and the internal NVMe's `0xbce00000` are in one 2 MiB page, and 2 MiB is the only page this kernel maps, so handing it over unmoved would put a disk controller's registers inside netd's mapping. What this change does is make the machine say what it has left, and name what is missing. `toyos_pci::bridge` decodes a Type 1 header's forwarded memory windows — bits 31:20 in the top twelve of each half, a limit that names the last megabyte, and the base-above-limit encoding for a bridge that forwards nothing, which read literally is a range that wraps. `PciDevice::forwarded_below_4g` reads them, and the prefetchable one is dropped where its upper base puts it above 4 GiB. `survey_low_space` runs on the machine where the low window comes out empty, and only there: a boot with room says nothing, and thirty lines on every boot is a log that has one channel off this bench. It merges the firmware map, this bus's assigned BARs and every forwarded bridge window, prints the free runs of 2 MiB or more, and ends with the sentence that matters — pcidev: a run above is a candidate and not a claim — what says whether an address below 4 GiB reaches this bus at all is the host bridge's own aperture, which is ACPI's `_CRS`, and this kernel runs no AML **Nothing here hands a run out**, and that is deliberate. An address outside the host bridge's aperture is not free space, it is unrouted, and a read of it answers ones — which `Refusal::Dead` cannot tell from a device that is not there. Linux reads `_CRS` for exactly this and keeps its chipset registers as quirks for firmware that gets `_CRS` wrong, not as an alternative to it. Guessing a range here would be guessing whether a process's mapping reaches the bus or the void. `issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md` carries the three ways out and their prices. `Refusal::NoWindow` was three facts under one name and is now three: `NoWindow { wide }` for a machine that published no window of that width — with the 32-bit arm naming the aperture — `WindowFull { wide }` for one this module used up, and `NoMappableBar` for a function with nothing to map, which is not about address space at all and sent a reader to the allocator. The two checks. - **Negative control.** Run 29 is this change reverted whole, on this image and this machine: the same boot, the same claim, and a refusal that named no free run and no missing input. The next run on the T14 is the arm — the survey either prints runs or prints none, and either is a reading this repository does not have today. - **Independent oracle.** The bridge window layout is the PCI-to-PCI Bridge Architecture Specification's, encoded in `toyos-pci/src/bridge.rs`'s tests: the address is bits 31:20 of each half and not the field's own offset, the limit names the last megabyte, and base-above-limit is "forwards nothing" rather than a wrapped range. Beside it, the T14's own enumeration is the check on the survey's arithmetic: `04:00.0` at `0xbce00000` and `00:1f.6` at `0xbcf00000` are behind bridges, so a survey that reported the page holding them free would be contradicted by the enumeration in the same log. Green: `cargo test -p toyos-pci` 44, `cargo test --lib` 297, `cargo run -- --clippy` all five invocations, and the four guest arms that read `pcidev` — `virtio_net_no_msix`, `iommu_virtio_platform`, `pci_function_is_exclusive`, `userdev_dma_fault`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- ...bit-bar-needs-the-host-bridges-aperture.md | 64 ++++++++ kernel/src/drivers/pci.rs | 21 ++- kernel/src/pcidev/mod.rs | 142 ++++++++++++++-- toyos-pci/src/bridge.rs | 152 ++++++++++++++++++ toyos-pci/src/lib.rs | 1 + 5 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md create mode 100644 toyos-pci/src/bridge.rs diff --git a/issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md b/issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md new file mode 100644 index 0000000000..7ed5b17a4b --- /dev/null +++ b/issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md @@ -0,0 +1,64 @@ +--- +status: open +kind: defect +opened: 2026-09-08 +--- + +# A 32-bit BAR cannot be handed to a process, because nothing here reads the host bridge's aperture + +`kernel/src/pcidev`'s window allocator places a claimed function's BARs on +2 MiB pages **above everything firmware described**. In 64 bits that is always +possible. Below 4 GiB it never is: the platform's fixed MMIO sits at +`0xFEC00000` and the UEFI map reaches it, so `window(narrow_end, PLATFORM_MMIO)` +answers `0x0..0x0` on every machine. Read off the ThinkPad T14, run 29: + +``` +pcidev: 24 functions; a 32-bit window comes from 0x0..0x0, a 64-bit one from 0x603dc00000..0x6040c00000 +pcidev: PCI 00:1f.6 NOT HANDED OVER — this machine has no 2 MiB-aligned address space above what firmware assigned to put a BAR in +``` + +That function is the bench's own NIC, and its BAR is 32-bit +(`bar0=0xbcf00000`). So the cable this project's test bench answers on cannot be +driven by a process at all until this is built. + +**Leaving the BAR where firmware put it is not the fix.** The kernel maps 2 MiB +pages, and on this machine the I219's BAR shares its page with the internal +NVMe's (`0xbcf00000` and `0xbce00000` are both inside `0xbce00000..0xbd000000`): +handing it over unmoved would put a disk controller's registers inside a +network daemon's mapping. `alone_in_its_page` is the assertion that says so, and +the refusal is right. + +**What is missing is a free run, and what is missing to find one is ACPI.** A +32-bit window is a run *between* things rather than a span above them, and three +of the four things it has to miss are readable already — the firmware map, the +BARs this bus assigned, and every range a PCI-to-PCI bridge forwards to a +secondary bus (`toyos_pci::bridge`, and `survey_low_space` prints all three on +the machine where the window comes out empty). The fourth is the host bridge's +own aperture: which addresses below 4 GiB the root complex decodes and forwards +to PCI at all. That is the `_CRS` of the `PNP0A08` device, an AML method, and +this kernel runs no AML. An address outside the aperture is not free space — it +is unrouted, and a read of it answers ones, which `Refusal::Dead` cannot tell +from a device that is simply not there. + +Linux's own answer is the same one: `acpi_pci_probe_root_resources` reads +`_CRS`, and the per-chipset fallbacks it keeps are quirks for firmware that gets +`_CRS` wrong, not an alternative to it. Reading a host bridge register such as +Intel's `TOLUD` instead would be chipset-specific and is not a road this project +takes. + +So the work is one of: + +- an AML interpreter far enough to evaluate `_CRS` on the host bridge, which is + a large thing to want for one method; or +- a 4 KiB mapping for a claimed BAR, which removes the need to move a BAR whose + page is shared and is a change in `mm` rather than here; or +- the owner ruling that some other source of the aperture is admissible. + +The survey is committed and prints the candidates; nothing hands one out. The +line it ends with says why: + +``` +pcidev: a run above is a candidate and not a claim — what says whether an address below 4 GiB +reaches this bus at all is the host bridge's own aperture, which is ACPI's `_CRS`, and this +kernel runs no AML +``` diff --git a/kernel/src/drivers/pci.rs b/kernel/src/drivers/pci.rs index 0030246e4c..8141855934 100644 --- a/kernel/src/drivers/pci.rs +++ b/kernel/src/drivers/pci.rs @@ -1,6 +1,6 @@ use alloc::vec::Vec; -use toyos_pci::{bar, caps, msi, msix}; +use toyos_pci::{bar, bridge, caps, msi, msix}; use crate::mm::Mmio; use crate::mm::paging::MmioPolicy; @@ -353,6 +353,25 @@ impl PciDevice { cap.write_u16(msi::MESSAGE_CONTROL, msi::Msi::disabled(control)); } + /// Every memory range this function forwards to its secondary bus, below + /// 4 GiB. Empty on a function that is not a bridge. + /// + /// **Read, never probed**: these are the ranges nothing above this bridge + /// may hand out, and reading them costs the machine nothing — unlike + /// `bar_size`, which takes memory decode off for the length of its probe. + pub fn forwarded_below_4g(&self) -> Vec { + if self.read_config_u8(HEADER_TYPE) & !MULTI_FUNCTION != bridge::HEADER_TYPE_BRIDGE { + return Vec::new(); + } + let mut out = Vec::new(); + out.extend(bridge::window(self.read_config_u32(bridge::MEMORY_BASE))); + out.extend(bridge::prefetch_below_4g( + self.read_config_u32(bridge::PREFETCH_BASE), + self.read_config_u32(bridge::PREFETCH_BASE_UPPER), + )); + out + } + pub fn capabilities(&self) -> CapabilityIter<'_> { let first = self.mmio.read_u8(CAPABILITIES_PTR); CapabilityIter { device: self, walk: caps::CapWalk::new(), next: first } diff --git a/kernel/src/pcidev/mod.rs b/kernel/src/pcidev/mod.rs index 429126ae62..ecb3602c64 100644 --- a/kernel/src/pcidev/mod.rs +++ b/kernel/src/pcidev/mod.rs @@ -368,6 +368,91 @@ pub fn publish(devices: &[PciDevice], maps: &[MemoryMapEntry]) { machine.wide.0, machine.wide.1, ); + let empty = machine.narrow.0 == 0; + let taken = machine.decoded.clone(); + drop(machine); + // Only on the machine where it is owed. A boot whose low space has room + // says nothing about it, and a survey printed every time would be thirty + // lines of a log that has one channel off this bench. + if empty { + survey_low_space(devices, maps, &taken); + } +} + +/// What is left below 4 GiB, said out loud on the machine where nothing is. +/// +/// **A refusal that says "no room" where the truth is "this module only ever +/// looks above everything" sends a reader to the wrong place**, and it sent one +/// there: the ThinkPad's I219 has a 32-bit BAR, [`window`] answered `0x0..0x0` +/// for the low space, and the refusal read as a full machine. Below 4 GiB there +/// is nothing above everything — the platform's fixed MMIO is at +/// [`PLATFORM_MMIO`] — so a 32-bit window is a free run *between* things rather +/// than a span above them. +/// +/// This prints the runs, and it accounts for exactly three things and names +/// them, because what it does not account for is the point: the firmware map, +/// the BARs this bus has assigned, and every range a bridge forwards to a +/// secondary bus. **It does not account for the host bridge's own aperture**, +/// which is what says whether an address below 4 GiB reaches this bus at all, +/// and which is ACPI's `_CRS` — an AML method this kernel does not run. So a +/// run below is a candidate for whoever reads the log, never a claim by this +/// module, and nothing here hands one out. +fn survey_low_space(devices: &[PciDevice], maps: &[MemoryMapEntry], decoded: &[(u16, u64, u64)]) { + let mut taken: Vec<(u64, u64)> = Vec::new(); + let mut note = |start: u64, end: u64| { + let (start, end) = (start.min(PLATFORM_MMIO), end.min(PLATFORM_MMIO)); + if start < end { + taken.push((start, end)); + } + }; + for entry in maps { + note(entry.start, entry.end); + } + for (_, start, end) in decoded { + note(*start, *end); + } + let mut bridges = 0usize; + for device in devices { + for forwarded in device.forwarded_below_4g() { + bridges += 1; + log!( + "pcidev: PCI {:02x}:{:02x}.{} forwards {:#x}..{:#x} to its secondary bus", + device.bus, + device.dev, + device.func, + forwarded.start, + forwarded.end, + ); + note(forwarded.start, forwarded.end); + } + } + taken.sort_unstable(); + let mut free: Vec<(u64, u64)> = Vec::new(); + let mut at = 0u64; + for (start, end) in taken { + if start > at { + free.push((at, start)); + } + at = at.max(end); + } + if at < PLATFORM_MMIO { + free.push((at, PLATFORM_MMIO)); + } + free.retain(|(start, end)| end - start >= PAGE_2M); + log!( + "pcidev: no 32-bit window. Below {PLATFORM_MMIO:#x} the firmware map, this bus's \ + assigned BARs and {bridges} forwarded bridge window(s) leave {} run(s) of 2 MiB or \ + more:", + free.len(), + ); + for (start, end) in free.iter() { + log!("pcidev: {start:#x}..{end:#x} ({} MiB)", (end - start) / (1024 * 1024)); + } + log!( + "pcidev: a run above is a candidate and not a claim — what says whether an address \ + below 4 GiB reaches this bus at all is the host bridge's own aperture, which is \ + ACPI's `_CRS`, and this kernel runs no AML" + ); } /// The span above `assigned` this module may hand out, or an empty one where @@ -391,7 +476,16 @@ fn window(assigned: u64, ceiling: u64) -> (u64, u64) { enum Refusal { NoInterrupt, Untranslated(IommuError), - NoWindow, + /// This machine published no window of that width at all. **Not the same + /// fact as a window that filled up**, and on the 32-bit side not the same + /// fact as a full machine either: `survey_low_space` is what says what is + /// actually left below 4 GiB. + NoWindow { wide: bool }, + /// The window exists and every page of it is already cut. + WindowFull { wide: bool }, + /// The function publishes nothing this claim may map — no memory BAR, or + /// only the one holding its own MSI-X table. + NoMappableBar, BarUnsizable(u8), BarUnplaceable(u8), BarResized(u8), @@ -411,11 +505,35 @@ impl core::fmt::Display for Refusal { "it would have no address space of its own — {why} — and a process driving \ it would be given physical addresses to put in descriptors" ), - Self::NoWindow => write!( + // **Two sentences, because a 32-bit window is a different problem + // from a 64-bit one.** Above the highest address firmware described + // there is always room in 64 bits and never any in 32: the + // platform's fixed MMIO is up there. So the low answer names what + // would actually settle it, and the survey beside it in this log + // says what the machine has left. + Self::NoWindow { wide: true } => write!( f, - "this machine has no 2 MiB-aligned address space above what firmware \ + "this machine has no 2 MiB-aligned 64-bit address space above what firmware \ assigned to put a BAR in" ), + Self::NoWindow { wide: false } => write!( + f, + "its BAR is 32-bit and this module has no window below 4 GiB to put one in: \ + there is nothing above everything firmware described down there, so a window \ + has to be a free run between things — and what says a run is reachable is the \ + host bridge's aperture, which is ACPI's `_CRS` and which this kernel does not \ + read" + ), + Self::WindowFull { wide } => write!( + f, + "the {}-bit window this module cut is full", + if *wide { 64 } else { 32 } + ), + Self::NoMappableBar => write!( + f, + "it publishes no memory BAR this claim may map, so its holder would have no \ + registers to drive it through" + ), Self::BarUnsizable(i) => write!(f, "BAR {i} answers no size to bound a window by"), Self::BarUnplaceable(i) => write!(f, "BAR {i} did not take the address it was given"), Self::BarResized(i) => write!( @@ -594,8 +712,11 @@ fn place_bars(pci: &PciDevice, table_bar: Option) -> Result<([u64; BARS], [u bar_bytes[index as usize] = size; index += step; } + // Its own refusal and not a window one: nothing about this machine's + // address space is wrong, and a reader sent to the window allocator would + // find it healthy. if bar_bytes.iter().all(|bytes| *bytes == 0) { - return Err(Refusal::NoWindow); + return Err(Refusal::NoMappableBar); } Ok((bar_at, bar_bytes)) } @@ -714,19 +835,22 @@ fn take_window(pci: &PciDevice, index: u8, wide: bool, span: u64) -> Result *top { - return Err(Refusal::NoWindow); + return Err(Refusal::WindowFull { wide }); } *next = end; at diff --git a/toyos-pci/src/bridge.rs b/toyos-pci/src/bridge.rs new file mode 100644 index 0000000000..15c21841c2 --- /dev/null +++ b/toyos-pci/src/bridge.rs @@ -0,0 +1,152 @@ +//! A PCI-to-PCI bridge's forwarded memory windows (PCI-to-PCI Bridge +//! Architecture Specification §3.2.5.6-3.2.5.8). +//! +//! **What a bridge forwards, nothing above it may hand out.** An address inside +//! a bridge's window is routed to that bridge's secondary bus and answered by +//! whatever is on it — or by nothing, which reads as ones and is not +//! distinguishable from unrouted space. So a module placing a window below +//! 4 GiB has to know these ranges before it can call any address free, and they +//! are readable from config space alone: no interpreter, no table. +//! +//! The registers hold address bits 31:20 in their top twelve bits and hardwire +//! the rest, so every window is a whole number of megabytes and a *limit* names +//! the last byte rather than the first free one. A bridge forwarding nothing +//! writes a base above its limit, which is the encoding for "disabled" and the +//! one this decode has to get right: read literally it is a range that wraps. + +/// Byte offsets in a Type 1 header. +pub const MEMORY_BASE: u64 = 0x20; +pub const PREFETCH_BASE: u64 = 0x24; + +/// The Type 1 header, as `HEADER_TYPE` reports it with the multi-function bit +/// removed. +pub const HEADER_TYPE_BRIDGE: u8 = 1; + +/// The granularity both windows are expressed in: bits 31:20. +const GRANULE: u64 = 1 << 20; + +/// One forwarded range, `start..end`, `end` exclusive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Window { + pub start: u64, + pub end: u64, +} + +/// The window a Memory Base/Limit pair describes, or `None` where the bridge +/// forwards nothing. +/// +/// `pair` is the dword at [`MEMORY_BASE`] or [`PREFETCH_BASE`]: base in the low +/// half, limit in the high half. Only the top twelve bits of each half are the +/// address; the low four are the type field for a prefetchable window and are +/// reserved for a non-prefetchable one, and neither is part of the range. +pub fn window(pair: u32) -> Option { + // The twelve bits at 15:4 of each half *are* address bits 31:20, so each + // half moves left by sixteen and not by the four its own field is offset by. + let base = u64::from(pair & 0xFFF0) << 16; + let limit = u64::from((pair >> 16) & 0xFFF0) << 16; + // A base above its limit is the encoding for a bridge that forwards + // nothing, and it is what firmware writes into a window it did not need — + // read as a range it would be `0x00100000..0x0`, which wraps. + if base > limit { + return None; + } + // The limit names the last megabyte, not the first free one. + Some(Window { start: base, end: limit + GRANULE }) +} + +/// Whether a prefetchable window's registers name a 64-bit range, in which case +/// the upper dwords at 0x28 and 0x2C carry the rest of it. +/// +/// Answered rather than decoded, because a module that hands out only 32-bit +/// space needs to know that a window it read the low half of may reach far +/// above what it can see — and treating that as a 32-bit range would call +/// addresses free that the bridge forwards. +pub fn prefetch_is_64_bit(pair: u32) -> bool { + pair & 0xF == 1 +} + +/// Byte offsets of the two dwords that carry the rest of a 64-bit prefetchable +/// window. +pub const PREFETCH_BASE_UPPER: u64 = 0x28; +pub const PREFETCH_LIMIT_UPPER: u64 = 0x2C; + +/// The part of a prefetchable window that lies below 4 GiB, or `None` where +/// none of it does. +/// +/// **A survey of the low space may not count a window that is not in it.** A +/// 64-bit prefetchable window whose upper base is set begins above 4 GiB +/// entirely, and reading its low half as a range would call a megabyte of the +/// low space forwarded that no bridge forwards. Where the upper base is zero +/// the low half *is* the low part of the range, whatever the upper limit adds +/// above it. +pub fn prefetch_below_4g(pair: u32, base_upper: u32) -> Option { + if prefetch_is_64_bit(pair) && base_upper != 0 { + return None; + } + window(pair) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The registers hold bits 31:20 and hardwire the rest, so a window is + /// whole megabytes and its limit names the last one. + #[test] + fn the_limit_names_the_last_megabyte_and_not_the_first_free_one() { + // Limit in the high half, base in the low one. Base 0xbc200000 and + // limit 0xbc2fffff: one megabyte forwarded. + assert_eq!(window(0xbc20_bc20), Some(Window { start: 0xbc20_0000, end: 0xbc30_0000 })); + // Base 0xbc200000, limit 0xbc3fffff: two. + assert_eq!(window(0xbc30_bc20), Some(Window { start: 0xbc20_0000, end: 0xbc40_0000 })); + // And the address really is bits 31:20 — a half read as if its field + // offset were the shift would land a thousandth of the way up. + assert_eq!(window(0x0000_0000), Some(Window { start: 0, end: 0x0010_0000 })); + } + + /// **A bridge that forwards nothing must not read as a range.** Firmware + /// writes a base above the limit for a window it did not need, and taken + /// literally that is a range which wraps — and a free-space search over a + /// wrapped range calls the whole of memory forwarded, or none of it. + #[test] + fn a_disabled_window_is_no_window() { + // The canonical disabled encoding: base 0x00100000, limit 0x00000000. + assert_eq!(window(0x0000_0010), None); + // And the widest form of the same thing. + assert_eq!(window(0x0000_fff0), None); + // A bridge forwarding *everything* is the opposite and not the same + // reading: base 0, limit 0xfff00000. + assert_eq!(window(0xfff0_0000), Some(Window { start: 0, end: 0xfff0_0000 + GRANULE })); + // Equal halves are one megabyte and not nothing. + assert_eq!(window(0x0010_0010), Some(Window { start: 0x0010_0000, end: 0x0020_0000 })); + } + + /// The low four bits of each half are the type field, never the address. + /// Reading them as address bits moves a window by up to a megabyte. + #[test] + fn the_type_field_is_not_part_of_the_address() { + let plain = window(0xbc30_bc20).expect("a forwarded window"); + let typed = window(0xbc3f_bc21).expect("the same window, prefetchable and 64-bit"); + assert_eq!(plain, typed); + assert!(prefetch_is_64_bit(0xbc3f_bc21)); + assert!(!prefetch_is_64_bit(0xbc30_bc20)); + } + + /// A 64-bit prefetchable window that starts above 4 GiB is not a low range, + /// and its low half is not one either. + #[test] + fn a_prefetchable_window_above_four_gigabytes_is_not_low_space() { + assert_eq!(prefetch_below_4g(0xbc31_bc21, 0x60), None); + assert_eq!( + prefetch_below_4g(0xbc31_bc21, 0), + Some(Window { start: 0xbc20_0000, end: 0xbc40_0000 }) + ); + // A 32-bit window's upper dwords are hardwired to zero and say nothing; + // a machine that answers otherwise must not lose the window over it. + assert_eq!( + prefetch_below_4g(0xbc30_bc20, 0x60), + Some(Window { start: 0xbc20_0000, end: 0xbc40_0000 }) + ); + assert_eq!(prefetch_below_4g(0x0000_0010, 0), None); + } +} diff --git a/toyos-pci/src/lib.rs b/toyos-pci/src/lib.rs index c8527e3b1e..bef6d41810 100644 --- a/toyos-pci/src/lib.rs +++ b/toyos-pci/src/lib.rs @@ -23,6 +23,7 @@ #![forbid(unsafe_code)] pub mod bar; +pub mod bridge; pub mod caps; pub mod express; pub mod msi; From 0a5717f53385e93b71f15e343dcc49aa6158ca06 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 18:04:07 +0200 Subject: [PATCH 06/23] Runs 29 and 30 recorded, and the track file says where the laptop stands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 29 armed the I219's MSI and stopped at the 32-bit BAR; run 30, the negative control with the claim pointed at a card this machine does not have, did the same boot with no netd at all. Both are recorded here. `boot.lancase.complete_ms` 1257 and `back_secs` 61 are the machine's own numbers off boots whose claim was refused — facts about the boot rather than about the claim, so the readings stand. The two `lan.` rows still have none, because netd never came up on either. **Run 30 changed what `ping_secs` is for.** The control pinged 192.168.1.46 once a second for its whole 61-second window and got nothing: this machine's wire answers no earlier than its ssh, and the loop stops pinging when ssh answers. So the number that row will hold is not "earlier than Ubuntu" — it is "at all", because nothing answers at that address unless the boot brings it up. That is a stronger control than the one the row was written for, and the `ceiling_from` says so. It still waits on a green reading before it can be tightened to the span a boot is actually up for. The track file is brought up to what these three runs established: sshd's half landed as #440, the I219 driver and DHCP are built and green under QEMU, and what is left is the laptop — the claim, and the two things that wait on it. Its constraints list carries three measurements instead of two guesses: the I219 is an MSI part (`/proc/interrupts`, `msi_irqs/162`), `toyos-t14` resolves to nothing on this LAN and `t14` resolves to a Tailscale address only Ubuntu holds, and the 32-bit BAR shares its 2 MiB page with the internal NVMe — which is why leaving it where firmware put it is not the way out either. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- ...he-t14-answers-only-through-a-usb-stick.md | 55 ++++++++++++++----- tests/metal-profile.toml | 26 +++++---- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/issues/hardware/the-t14-answers-only-through-a-usb-stick.md b/issues/hardware/the-t14-answers-only-through-a-usb-stick.md index 467be96774..d2f60d7b0e 100644 --- a/issues/hardware/the-t14-answers-only-through-a-usb-stick.md +++ b/issues/hardware/the-t14-answers-only-through-a-usb-stick.md @@ -12,13 +12,29 @@ Intel I219 at `00:1f.6`, `8086:15fc`, which the kernel enumerates and nothing claims. The track is to make that cable the answer path. The substrate a process needs to drive a PCI function itself is built -(`kernel/src/pcidev/mod.rs`, `userland/netd/src/virtio_net.rs`). What is left is -the I219 driver in netd, with DHCP under the hostname `toyos-t14` and a first -ping and ssh from the Mac; a record stream from logd to a listener in the -harness, so a boot's log arrives while it is booting; command execution, file -transfer both ways and key auth in sshd, with the harness running userland tests -over ssh through a russh client; and a netboot spike in which the firmware -fetches the loader over HTTP so the stick leaves the boot path. +(`kernel/src/pcidev/mod.rs`, `userland/netd/src/virtio_net.rs`); the I219 driver +is built (`toyos-i219/`, `userland/netd/src/i219.rs`) and moves frames under +QEMU's `e1000e`; netd takes its address from DHCP rather than carrying one +written down (`userland/netd/src/dhcp.rs`); and sshd grew command execution, +file transfer both ways and key auth. What is left is the laptop: + +- **The claim on the T14's own card.** `tests/lancase` is the boot that puts + netd in front of it, and the metal loop pings the address that card holds + across the window between the machine's two operating systems. Three runs on + the bench: the claim was refused for want of MSI-X, `pcidev` grew MSI, and it + now stops at the 32-bit BAR. What is owed for that is + `issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md`, and until it + is paid no process on this machine can drive that cable. +- **The record stream from the laptop**, so a boot's log arrives while it is + booting. The guest half is built and green under QEMU on both drivers + (`toyos-logstream/`, `userland/logd/src/stream.rs`); the metal half — arming + the flashed image with the Mac's address and listening while the T14 boots — + waits on the claim above. +- **The first ssh from the Mac into ToyOS on the T14**, through the harness's + russh client (`tests/ssh-client-host`), running a test binary over the cable + and judging its exit status. The same dependency. +- and a **netboot spike** in which the firmware fetches the loader over HTTP so + the stick leaves the boot path. Constraints a reader would otherwise pay to re-derive: @@ -34,12 +50,25 @@ Constraints a reader would otherwise pay to re-derive: before suspecting the driver. - **ssh is the bench's transport and a real feature**: sshd is built on russh and the harness's client is russh too. No host ssh binary, no fork. -- **Addressing is DHCP with a hostname**, resolved through the router's DNS. The - T14's MAC is the same under ToyOS and Ubuntu, so the lease is the one `t14` - already resolves to. Wi-Fi is out — the AX210 needs a firmware image. -- The I219 has **32-bit BARs**, and `pcidev`'s window allocator has only ever - placed a 64-bit one: `Refusal::NoWindow` on that machine means nothing was - found above everything firmware described and below the platform's fixed MMIO. +- **Addressing is DHCP with a hostname**, and netd sends `toyos-t14` as the + host-name option. **The name resolves to nothing on this LAN** — measured: the + T14's DHCP-served resolvers are the ISP's, and on the development Mac `t14` + resolves to the *Tailscale* address `100.92.92.12`, which only Ubuntu ever + holds. So the address is read off the claimed PCI function instead + (`Driver::wire`), the wire is `enp0s31f6` at `192.168.1.46/24` with the Mac on + `192.168.1.47`, and the boot's own MAC record is what ties a reply to the + boot. Wi-Fi is out — the AX210 needs a firmware image. +- **The I219 is an MSI part**, measured: `/proc/interrupts` names its interrupt + `IR-PCI-MSI-0000:00:1f.6` and `msi_irqs/162` reads `mode=msi`. `pcidev` armed + MSI-X alone and refused it; it arms either now. +- The I219 has a **32-bit BAR** (`bar0=0xbcf00000`), and that is where the claim + stops today: `pcidev`'s window allocator places a BAR above everything + firmware described, and below 4 GiB there is no above — the platform's fixed + MMIO is at `0xFEC00000`. Leaving the BAR where it sits is not the way out + either: the internal NVMe's `0xbce00000` is in the same 2 MiB page, which is + the only page size this kernel maps. `survey_low_space` prints what the + machine has left and what it cannot answer for; the owed work and its three + prices are `issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md`. - **QEMU's `virtio-net-pci-non-transitional` on `q35` advertises no PCIe function-level reset** — measured, not assumed: `pcidev`'s refusal on that ground reddened every netd registration at once. So a re-claim is made safe by diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index 95ab8a610c..d086b47161 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -611,28 +611,32 @@ ceiling = 30 ceiling_from = "as boot.testcases.stick_secs" # --- the cable: the boot that runs netd in front of the T14's own I219 --- -# The three boot facts below are the machine's own, off a boot whose claim the -# kernel refused for want of MSI: they are facts about the boot rather than -# about the claim, so the readings stand. The two `lan.` rows have none, because -# that boot's netd never ran. `ping_secs` has none either, and for a different -# reason: the run that took one pinged the address this machine's *name* -# resolves to, which here is a Tailscale address only the operating system -# before the boot holds — the loop reads the claimed function's own address now, -# and no run has pinged that yet. +# The three boot facts below are the machine's own, off boots whose claim the +# kernel refused — first for want of MSI, then at the 32-bit BAR. They are facts +# about the boot rather than about the claim, so the readings stand; the two +# `lan.` rows have none, because netd never came up on either. +# +# `ping_secs` has none, and its ceiling is what run 30 changed. The control boot +# pinged 192.168.1.46 once a second for its whole 61-second window and got +# nothing: the machine's wire answers no earlier than its ssh, and the loop +# stops pinging when ssh answers. So the number this row will hold is not +# "earlier than Ubuntu" — it is "at all", because on this machine nothing +# answers at that address unless the boot brings it up. The ceiling still waits +# on a green reading to be tightened to. [[number]] name = "boot.lancase.complete_ms" unit = "ms" ceiling = 60000 ceiling_from = "toyos_tco::JOB_BOUND_MS — as boot.testcases.complete_ms" -measured = 1223 +measured = 1257 [[number]] name = "boot.lancase.back_secs" unit = "s" ceiling = 420 ceiling_from = "toyos_build::metal::return_secs" -measured = 65 +measured = 61 [[number]] name = "boot.lancase.stick_secs" @@ -645,7 +649,7 @@ measured = 0 name = "boot.lancase.ping_secs" unit = "s" ceiling = 420 -ceiling_from = "toyos_build::metal::return_secs — the window this is measured in is the loop's own wait for the machine to answer ssh again, and nothing narrower has been read yet. What it becomes is a ceiling under the reading a boot with no network of its own takes at the same address, which is the operating system before it answering on its way back up. Until one of those exists this row bounds the loop rather than the boot" +ceiling_from = "toyos_build::metal::return_secs — the window this is measured in is the loop's own wait for the machine to answer ssh again, and nothing narrower has been read yet. Run 30 is the control it rests on: a boot with no network of its own, pinged once a second at 192.168.1.46 for the whole 61-second window, answered nothing at all — so a reply anywhere in the window is the boot's and not the machine's. A green reading is what tightens this to the span a boot is actually up for" [[number]] name = "list.lancase.job_ms" From ccda08b7ae4308a2e47f41c331a0ca9ceb4ea6e1 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 18:17:03 +0200 Subject: [PATCH 07/23] A reply is the boot's only if the boot's own records bracket it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 30's window was dark and I wrote a judge on that one sample: a reply anywhere in the window is the boot's. Run 31 falsified it. That boot's claim was refused at the 32-bit BAR, netd never held the card, and the loop still reported `192.168.1.46 answered a ping 57 s into the window` — two seconds before the machine's own `sshd` came back. The wire comes up before ssh does, and the loop stops probing when ssh answers, so the reply was inside the window, inside the ceiling, and belonged to the operating system after the boot. A ceiling on `ping_secs` could never have told the two apart, whatever number it held. What tells them apart is time against the boot's own timeline. `logd` writes a wall clock on every record and the loop now writes one beside the reply; both are UTC, the T14's clock being Ubuntu's from the network and this host's NTP's. `bootlog::record_unix_secs` reads the one field in a log a host clock can be held against, and `lan::the_boot_answered` is the judge: - the reply is inside the span this boot's records bracket, ending at `Rebooting.` — anything after that is the next operating system, whatever its timing; and - it is at or after the `netd: DHCP: lease` record, because a machine with no address answers nothing at that address. Both halves are needed. The first alone would accept a reply from before netd had an address; the second alone would accept run 31's, which came thirty-four seconds after the boot had handed the machine back. The bracket is about twenty-three seconds wide on this boot and the two clocks agree to within a second, which is what makes comparing them admissible; a machine whose clocks drifted further would show it as a reply just outside the bracket rather than as a mystery, and the refusal prints both numbers. `ping_secs` stays, priced, as the cost of a reply — it is a number worth watching move and it is not a verdict, and its `ceiling_from` says so now rather than claiming a separation it cannot make. `boot.txt` gains `ping_at`, written only where something answered: a reply has a time or it did not happen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- src/bootlog.rs | 67 ++++++++++++++++++++++++++++++ src/metal.rs | 87 ++++++++++++++++++++++++++++++++------- tests/common/lan.rs | 89 ++++++++++++++++++++++++++++++++++++---- tests/common/metal.rs | 10 +++++ tests/metal-profile.toml | 17 ++++---- 5 files changed, 241 insertions(+), 29 deletions(-) diff --git a/src/bootlog.rs b/src/bootlog.rs index 4354bd85ec..5cd9e10d6e 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -226,6 +226,42 @@ pub fn record_millis(line: &str) -> Option { secs.checked_mul(1_000)?.checked_add(millis) } +/// The UTC second one record line carries, as seconds since the epoch. +/// +/// **The only field in a log a host clock can be held against.** Everything +/// else a record says is measured from this boot's own start, and a host that +/// wants to know whether something it saw happened *while this boot was up* has +/// nothing to compare that with. `logd` writes the wall clock; the panel writes +/// none, and this answers `None` for those lines rather than reading the +/// milliseconds field as a date. +pub fn record_unix_secs(line: &str) -> Option { + const EPOCH: &str = "1970-01-01"; + let mut fields = line.strip_prefix('[')?.split_whitespace(); + let day = crate::day::Day::parse(fields.next()?)?; + let days = crate::day::Day::parse(EPOCH).expect("the epoch is a date").until(day); + let (hours, rest) = fields.next()?.split_once(':')?; + let (minutes, seconds) = rest.split_once(':')?; + let (hours, minutes, seconds): (i64, i64, i64) = + (hours.parse().ok()?, minutes.parse().ok()?, seconds.parse().ok()?); + // A leap second is the one value past the ordinary range that is a time. + if !(0..24).contains(&hours) || !(0..60).contains(&minutes) || !(0..=60).contains(&seconds) { + return None; + } + u64::try_from(days * 86_400 + hours * 3_600 + minutes * 60 + seconds).ok() +} + +/// The first and last wall clocks in `log`: the span in which this boot was the +/// machine. +/// +/// **A boot's own clock cannot say this and a host's cannot either.** The +/// records are the only place the two meet, which is what makes them the +/// bracket a host-side observation is judged against. +pub fn record_unix_span(log: &str) -> Option<(u64, u64)> { + let first = log.lines().find_map(record_unix_secs)?; + let last = log.lines().rev().find_map(record_unix_secs)?; + Some((first, last)) +} + /// When the last record in `log` was written, in milliseconds since boot. pub fn last_record_millis(log: &str) -> Option { log.lines().rev().find_map(record_millis) @@ -432,4 +468,35 @@ mod record_time_tests { assert_eq!(last_record_millis(log), Some(2_500)); assert_eq!(last_record_millis("nothing\n"), None); } + + /// **The wall clock, which is the only field a host clock can be held + /// against.** The lines are `logd`'s own, off the T14's run 31: the boot + /// started at 16:08:21 UTC and handed the machine back twenty-three seconds + /// later, and a ping the host saw at 16:09:18 was therefore the operating + /// system after it, however early in the loop's own window it fell. + #[test] + fn a_records_wall_clock_brackets_the_boot() { + let log = concat!( + "[2026-09-08 16:08:21 0.000 cpu0 boot] boot: memory map\n", + "[2026-09-08 16:08:22 1.257 cpu0] Boot: complete (1257ms)\n", + "[2026-09-08 16:08:44 22.990 cpu1] Rebooting.\n", + ); + let (first, last) = record_unix_span(log).expect("a span"); + assert_eq!(last - first, 23); + assert_eq!(record_unix_secs("[2026-09-08 16:08:21 0.000 cpu0 boot] x"), Some(first)); + // 16:09:18, which is 57 s into a window that opened before the boot did. + assert!(first + 57 > last, "the reply this judge has to reject is outside the bracket"); + } + + /// The panel writes no wall clock, and its milliseconds field must not be + /// read as one: `[1.000 cpu0]` would otherwise parse `1.000` as a date and + /// answer some second in 1970. + #[test] + fn a_line_with_no_wall_clock_answers_none() { + assert_eq!(record_unix_secs("[1.000 cpu0] first"), None); + assert_eq!(record_unix_secs("not a record"), None); + assert_eq!(record_unix_secs("[2026-09-08 25:00:00 0.000 cpu0] x"), None); + assert_eq!(record_unix_secs("[2026-02-31 10:00:00 0.000 cpu0] x"), None); + assert_eq!(record_unix_span("[1.000 cpu0] first\n"), None); + } } diff --git a/src/metal.rs b/src/metal.rs index dde182b9ea..ccb9eddc27 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -1025,8 +1025,27 @@ fn brief_address(iface: &str, text: &str) -> Result /// It runs on a thread because the loop is inside `ssh` for whole seconds at a /// time waiting for the machine to answer again, and a boot that is up for /// twenty of them cannot be sampled between those. +/// The first reply after the silence: how far into the window it came, and +/// when it came on this host's clock. +/// +/// **The wall clock is the half that identifies it.** How far into the window a +/// reply came says nothing about which operating system sent it — measured on +/// the T14, a reply 57 s in was the machine's wire returning two seconds ahead +/// of its own `sshd`, on a boot whose claim had been refused and whose netd +/// never held the card. What settles it is whether the reply falls inside the +/// span the boot's own records bracket, and only a wall clock can be held +/// against those. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reply { + pub secs: u64, + /// Seconds since the epoch, UTC, taken when the probe answered. The probe + /// waits up to a second for its reply, so this is late by at most that — + /// against a bracket tens of seconds wide. + pub at: u64, +} + struct Ping { - first: std::sync::Arc>>, + first: std::sync::Arc>>, stop: std::sync::Arc, thread: std::thread::JoinHandle<()>, } @@ -1050,8 +1069,10 @@ impl Ping { // operating system that is going down, whose `sshd` // stops before its interface does. if quiet_since.is_some_and(|at| at.elapsed() >= silence) { - *mine.lock().expect("the ping's answer") = - Some(began.elapsed().as_secs()); + *mine.lock().expect("the ping's answer") = Some(Reply { + secs: began.elapsed().as_secs(), + at: unix_now(), + }); return; } quiet_since = None; @@ -1065,8 +1086,8 @@ impl Ping { Self { first, stop, thread } } - /// Stop probing, and answer when the first reply after the silence came. - fn end(self) -> Option { + /// Stop probing, and answer what the first reply after the silence was. + fn end(self) -> Option { self.stop.store(true, std::sync::atomic::Ordering::SeqCst); let _ = self.thread.join(); let answer = *self.first.lock().expect("the ping's answer"); @@ -1074,6 +1095,20 @@ impl Ping { } } +/// This host's clock, as seconds since the epoch in UTC. +/// +/// The T14's own clock is Ubuntu's, set from the network; this host's is NTP's. +/// The bracket a reply is judged against is tens of seconds wide, which is what +/// makes holding the one clock against the other admissible at all — and a +/// machine whose two disagreed by more than that would show it as a reply just +/// outside the bracket rather than as a mystery. +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("a host clock before 1970 is a host to fix") + .as_secs() +} + /// One probe, whose whole answer is whether the address replied. /// /// **`-W` is milliseconds on this host and seconds on Linux**, and the two @@ -1286,7 +1321,7 @@ impl Driver { &self, secs: u64, addr: std::net::Ipv4Addr, - ) -> Result<(u64, Option), Refusal> { + ) -> Result<(u64, Option), Refusal> { self.wait(GOING_DOWN_SECS, "go down", false)?; let ping = Ping::start(addr); let back = self.wait(secs, "come back", true); @@ -1698,10 +1733,14 @@ pub fn run(args: &Args) -> Result, Refusal> { let (back, pinged) = driver.ride_the_reboot(args.wait_secs, wire.addr)?; println!("the machine answered ssh again after {back} s"); match pinged { - Some(secs) => println!( - "{} answered a ping {secs} s into the window, after {PING_SILENCE_SECS} s of \ - silence — so something on this cable was up while Ubuntu was not", - wire.addr + // **Something, and which something is not this loop's to say.** The + // machine's own wire comes back before its `sshd` does, so a reply in + // this window may be either operating system; the wall clock beside it + // is what a judge holds against the boot's own records. + Some(reply) => println!( + "{} answered a ping {} s into the window, after {PING_SILENCE_SECS} s of \ + silence, at {} UTC seconds", + wire.addr, reply.secs, reply.at ), None => println!("nothing answered a ping at {} while the machine was down", wire.addr), } @@ -1867,6 +1906,15 @@ pub const PING_ADDR_KEY: &str = "ping_addr"; pub const PING_SECS_KEY: &str = "ping_secs"; pub const WIRE_MAC_KEY: &str = "wire_mac"; +/// When the reply came, in seconds since the epoch on this host's clock. +/// +/// **The key that says which operating system answered.** Every other number +/// here is measured from the window's own start, and the window holds both of +/// the machine's operating systems — the T14 answered 57 s in on a boot whose +/// claim had been refused, two seconds before its own `sshd` came back. A judge +/// holds this against the wall clocks the boot's own records carry. +pub const PING_AT_KEY: &str = "ping_at"; + /// Every file a readback directory carries, so a run that writes none of them /// leaves none of the last run's behind. pub const READBACK_FILES: &[&str] = @@ -1904,7 +1952,7 @@ fn write_readback( back: u64, stick: u64, wire: &Wire, - pinged: Option, + pinged: Option, ) -> Result<(), Refusal> { let wrote = |path: &Path, text: &str| -> Result<(), Refusal> { std::fs::write(path, text) @@ -1921,8 +1969,9 @@ fn write_readback( "{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n{PING_ADDR_KEY} {}\n{WIRE_MAC_KEY} {}\n", wire.addr, wire.mac ); - if let Some(secs) = pinged { - boot.push_str(&format!("{PING_SECS_KEY} {secs}\n")); + if let Some(reply) = pinged { + boot.push_str(&format!("{PING_SECS_KEY} {}\n", reply.secs)); + boot.push_str(&format!("{PING_AT_KEY} {}\n", reply.at)); } wrote(&dir.join(READBACK_BOOT), &boot) } @@ -1977,6 +2026,11 @@ pub fn ping_secs(text: &str) -> Option { key(text, PING_SECS_KEY) } +/// When that reply came, on this host's clock. `None` where nothing answered. +pub fn ping_at(text: &str) -> Option { + key(text, PING_AT_KEY) +} + /// The address that was pinged. Absent only from a readback written before this /// loop asked. pub fn ping_addr(text: &str) -> Option { @@ -2211,18 +2265,23 @@ mod tests { #[test] fn a_ping_nothing_answered_is_written_as_no_answer() { let answered = "back_secs 61\nstick_secs 2\nping_addr 192.168.1.46\n\ - wire_mac 8c:8c:aa:bb:cc:dd\nping_secs 17\n"; + wire_mac 8c:8c:aa:bb:cc:dd\nping_secs 17\nping_at 1757347715\n"; let silent = "back_secs 46\nstick_secs 2\nping_addr 192.168.1.46\n\ wire_mac 8c:8c:aa:bb:cc:dd\n"; assert_eq!(ping_addr(answered).as_deref(), Some("192.168.1.46")); assert_eq!(wire_mac(answered).as_deref(), Some("8c:8c:aa:bb:cc:dd")); assert_eq!(ping_secs(answered), Some(17)); + assert_eq!(ping_at(answered), Some(1_757_347_715)); + // A window nothing answered carries neither number: a reply has a time + // or it did not happen. + assert_eq!(ping_at(silent), None); assert_eq!(ping_addr(silent).as_deref(), Some("192.168.1.46")); assert_eq!(ping_secs(silent), None); assert_eq!(ping_addr("back_secs 46\n"), None); // The two ping keys share a prefix, and neither may be read off the // other's line. assert_eq!(ping_secs("ping_addr 192.168.1.46\n"), None); + assert_eq!(ping_at("ping_addr 192.168.1.46\n"), None); assert_eq!(back_secs(answered), Some(61)); assert_eq!(stick_secs(answered), Some(2)); } diff --git a/tests/common/lan.rs b/tests/common/lan.rs index 3d885fb2a6..b26857651e 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -18,6 +18,7 @@ use std::path::Path; +use toyos_build::bootlog; use toyos_build::metalprofile::Profile; use super::metal; @@ -115,6 +116,78 @@ pub fn link_up_ms(text: &str) -> Result { .map_err(|_| format!("{line:?} carries no readable link-up time")) } +/// Whether the reply the host saw came from *this boot*, held against the wall +/// clocks the boot's own records carry. +/// +/// **How far into the window a reply came is not a judge, and one run proved +/// it.** Run 30's window was dark and the row's ceiling was written on that one +/// sample — "a reply anywhere in the window is the boot's". Run 31 answered at +/// 57 s on a boot whose claim had been refused and whose netd never held the +/// card: the machine's own wire came back two seconds ahead of its `sshd`, and +/// the loop stops probing when `sshd` answers, so that reply was inside the +/// window and inside the ceiling and belonged to the operating system after the +/// boot. +/// +/// What separates them is time against the boot's own timeline. `logd` writes a +/// wall clock on every record, the loop writes one beside the reply, and both +/// are UTC — the T14's clock is Ubuntu's, set from the network, and the host's +/// is NTP's. So: +/// +/// - the reply is inside the span this boot's first and last records bracket, +/// whose end is the `Rebooting.` record: anything after that is the next +/// operating system, whatever its timing; and +/// - it is at or after the lease record, because a machine with no address +/// answers nothing at that address. +/// +/// The bracket is tens of seconds wide and the two clocks are within a second +/// of each other, which is what makes comparing them admissible at all; a +/// machine whose clocks drifted further would show it here as a near miss. +fn the_boot_answered(back: &metal::Readback, at: u64) -> Result<(), String> { + let kernel = back.kernel(); + let text = kernel.text(); + let (first, last) = bootlog::record_unix_span(text).ok_or_else(|| { + format!( + "{}'s log carries no record with a wall clock on it, so there is nothing to hold \ + the host's own clock against", + back.label + ) + })?; + let rebooting = text + .lines() + .rfind(|l| l.contains(bootlog::REBOOTING)) + .and_then(bootlog::record_unix_secs) + .unwrap_or(last); + if at < first || at > rebooting { + return Err(format!( + "the reply at {at} is outside the span this boot's own records bracket \ + ({first}..{rebooting}, {} s wide): it came {} s {} the boot, so it is the \ + operating system on the other side of it and not this one", + rebooting.saturating_sub(first), + if at < first { first - at } else { at - rebooting }, + if at < first { "before" } else { "after" }, + )); + } + let leased = text + .lines() + .find(|l| l.contains(LEASE)) + .and_then(bootlog::record_unix_secs) + .ok_or_else(|| format!("{}'s lease record carries no wall clock", back.label))?; + if at < leased { + return Err(format!( + "the reply at {at} came {} s before this boot's lease at {leased}, and a machine \ + with no address answers nothing at that address", + leased - at + )); + } + eprintln!( + " [lan] the reply landed {} s after the lease and {} s before this boot handed the \ + machine back", + at - leased, + rebooting.saturating_sub(at), + ); + Ok(()) +} + /// The T14's judge: the claim, the card, the lease, and the host's own ping. /// /// **The ping and the lease are held to each other.** The address the host @@ -193,21 +266,23 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { Err(why) => bad.push(why), } - match back.ping_secs { - Some(secs) => { + match (back.ping_secs, back.ping_at) { + (Some(secs), Some(at)) => { eprintln!( " [lan] {} answered the host's ping {secs} s into the window", back.ping_addr ); - // Judged here as well as by the boot loop, because this is the arm - // that *claims* the number: a ceiling nobody wrote is what tells - // Ubuntu's reply on its way back up from this boot's, and the - // profile refuses an unpriced name rather than passing it. + // The cost of the reply, priced. It is not what says the reply was + // this boot's — `the_boot_answered` is — but a boot that answers + // far later than the last one is a boot something changed under. if let Err(why) = profile.judge(&format!("boot.{}.ping_secs", back.label), secs) { bad.push(why.to_string()); } + if let Err(why) = the_boot_answered(back, at) { + bad.push(why); + } } - None => bad.push(format!( + _ => bad.push(format!( "nothing answered a ping at {} while this machine was between its two operating \ systems", back.ping_addr diff --git a/tests/common/metal.rs b/tests/common/metal.rs index d3968c54fe..80743c94dd 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -225,6 +225,15 @@ pub struct Readback { /// boot in this suite is that boot, so the separation is read rather than /// assumed. pub ping_secs: Option, + /// When that reply came, on the host's clock, in seconds since the epoch. + /// + /// **What says which operating system answered.** Run 31 measured a reply + /// 57 s into the window on a boot whose claim had been refused and whose + /// netd never held the card: the machine's own wire came back two seconds + /// ahead of its `sshd`. So the window holds both operating systems, and + /// the only thing that separates them is this against the wall clocks the + /// boot's own records carry. + pub ping_at: Option, } impl Readback { @@ -732,6 +741,7 @@ fn read_readback(dir: &Path, label: &str) -> Result { ping_addr, wire_mac, ping_secs: toyos_build::metal::ping_secs(&boot), + ping_at: toyos_build::metal::ping_at(&boot), }) } diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index d086b47161..5495afd6a5 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -616,13 +616,14 @@ ceiling_from = "as boot.testcases.stick_secs" # about the boot rather than about the claim, so the readings stand; the two # `lan.` rows have none, because netd never came up on either. # -# `ping_secs` has none, and its ceiling is what run 30 changed. The control boot -# pinged 192.168.1.46 once a second for its whole 61-second window and got -# nothing: the machine's wire answers no earlier than its ssh, and the loop -# stops pinging when ssh answers. So the number this row will hold is not -# "earlier than Ubuntu" — it is "at all", because on this machine nothing -# answers at that address unless the boot brings it up. The ceiling still waits -# on a green reading to be tightened to. +# `ping_secs` has none, and it is no longer the judge. Run 30's window was dark +# and this file said so: "a reply anywhere in the window is the boot's". Run 31 +# answered at 57 s on a boot whose claim was refused and whose netd never held +# the card — the machine's own wire came back two seconds ahead of its sshd, and +# the loop stops probing when sshd answers. One sample was not a judge. What +# identifies the boot's reply is its wall clock against the boot's own records +# (`lan::the_boot_answered`); this row prices the cost of a reply and nothing +# else. [[number]] name = "boot.lancase.complete_ms" @@ -649,7 +650,7 @@ measured = 0 name = "boot.lancase.ping_secs" unit = "s" ceiling = 420 -ceiling_from = "toyos_build::metal::return_secs — the window this is measured in is the loop's own wait for the machine to answer ssh again, and nothing narrower has been read yet. Run 30 is the control it rests on: a boot with no network of its own, pinged once a second at 192.168.1.46 for the whole 61-second window, answered nothing at all — so a reply anywhere in the window is the boot's and not the machine's. A green reading is what tightens this to the span a boot is actually up for" +ceiling_from = "toyos_build::metal::return_secs — the loop's own wait for the machine to answer ssh again, and nothing narrower has been read. It is a cost and not a verdict: run 31 answered at 57 s from the operating system *after* the boot, so no ceiling on this number could have told the two apart, and what does is the wall-clock bracket in `lan::the_boot_answered`. A green reading tightens this to what a boot that answers actually costs" [[number]] name = "list.lancase.job_ms" From f817503587ebc772faa0c7f317335e3eaa7f7b2a Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 19:11:01 +0200 Subject: [PATCH 08/23] The judge moves to bootlog, and a half-written readback is refused by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The judge that says a host-side reply belongs to this boot lived in the integration-test tree, where nothing could host-test it, beside a module that declares itself the home for exactly its shape. It is now `bootlog::host_second_inside_this_boot`: a log, the two ends of the host's own window, the record the observation may not precede, and one second in; a verdict out. Four tests cover both arms, and the fixture is three lines copied out of the stick the T14 wrote rather than three runs' numbers blended together. The one assertion that could not fail — `x + 57 > x + 23` over string constants — is gone with it. The clock skew the bracket rested on is measured instead of asserted. The loop records the host's clock at both ends of the window in which the machine is running neither of its operating systems, and the judge refuses a boot whose own records do not lie inside it before it places anything against them. Nothing claims the two clocks agree to within a second any more; the run's own data bounds their disagreement. The four cable fields in a readback are one `Cable`, and reading one refuses every partial set by name. A file naming seconds and no wall clock used to fall to a `_` arm and be reported as "nothing answered a ping" — the opposite of what the run recorded, and the shape of every readback taken so far. The reading and the probe belong to the boot that asks for them. `Arm::nic` carries the PCI function into the driver's invocation as `--nic`, and only the lancase arm sets it: `metal_device_probe`, `blackbox_unclaimed_page`, `ccorpus` and every other registration no longer take three `ssh` reads and a ping thread for a fact none of their judges reads. A host with no `ping` is now `Refusal::Probe` rather than a dark cable, and `ping_secs` is judged once, by `lan::on_metal`, instead of twice through a string compare that holed `Unfit::Unpriced` for one field name. `measured` on the lancase rows was run 29's boot time and the negative control's `back_secs`. It is run 31's, the last boot of that config: 1258 ms and 59 s. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- src/bootlog.rs | 142 ++++++++++++--- src/metal.rs | 373 +++++++++++++++++++++++---------------- tests/common/lan.rs | 208 +++++++++++----------- tests/common/metal.rs | 110 ++++-------- tests/metal-profile.toml | 23 +-- tests/toyos.rs | 13 +- 6 files changed, 499 insertions(+), 370 deletions(-) diff --git a/src/bootlog.rs b/src/bootlog.rs index 5cd9e10d6e..cf03b40403 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -250,16 +250,65 @@ pub fn record_unix_secs(line: &str) -> Option { u64::try_from(days * 86_400 + hours * 3_600 + minutes * 60 + seconds).ok() } -/// The first and last wall clocks in `log`: the span in which this boot was the -/// machine. -/// -/// **A boot's own clock cannot say this and a host's cannot either.** The -/// records are the only place the two meet, which is what makes them the -/// bracket a host-side observation is judged against. -pub fn record_unix_span(log: &str) -> Option<(u64, u64)> { +/// The span this boot's own records bracket: its first wall clock, and the one +/// on [`REBOOTING`] where the boot got that far. +fn record_unix_span(log: &str) -> Option<(u64, u64)> { let first = log.lines().find_map(record_unix_secs)?; let last = log.lines().rev().find_map(record_unix_secs)?; - Some((first, last)) + let ended = log.lines().rfind(|l| l.contains(REBOOTING)).and_then(record_unix_secs); + Some((first, ended.unwrap_or(last))) +} + +/// Whether a second on the *host's* clock fell inside the boot this log is of, +/// at or after the record `after` names. +/// +/// **The records are the one place a host clock and a boot's clock meet.** +/// `window` is the host's own clock at the two ends of the span in which the +/// machine was running neither of its operating systems; this boot's records +/// have to fall inside it, which bounds the two clocks' disagreement against +/// the run's own data instead of assuming a bound. How far into that window an +/// observation came separates nothing: the window holds the operating system +/// that left and the one that came back as well as this boot. +pub fn host_second_inside_this_boot( + log: &str, + window: (u64, u64), + after: &str, + at: u64, +) -> Result<(), String> { + let (first, ended) = record_unix_span(log).ok_or_else(|| { + "this log carries no record with a wall clock on it, so there is nothing to hold the \ + host's own clock against" + .to_string() + })?; + let (from, to) = window; + if first < from || ended > to { + return Err(format!( + "this boot's own records run {first}..{ended} and the host watched the machine over \ + {from}..{to}: the two clocks disagree by more than the window is wide, so nothing \ + the host saw can be placed inside this boot" + )); + } + if at < first || at > ended { + return Err(format!( + "the host saw it at {at}, outside the {first}..{ended} this boot's own records \ + bracket: it came {} s {} the boot, so it belongs to the operating system on the \ + other side of it", + if at < first { first - at } else { at - ended }, + if at < first { "before" } else { "after" }, + )); + } + let after_at = log + .lines() + .find(|l| l.contains(after)) + .and_then(record_unix_secs) + .ok_or_else(|| format!("this boot has no {after:?} record carrying a wall clock"))?; + if at < after_at { + return Err(format!( + "the host saw it at {at}, {} s before this boot's {after:?} record at {after_at}", + after_at - at + )); + } + Ok(()) } /// When the last record in `log` was written, in milliseconds since boot. @@ -469,23 +518,67 @@ mod record_time_tests { assert_eq!(last_record_millis("nothing\n"), None); } - /// **The wall clock, which is the only field a host clock can be held - /// against.** The lines are `logd`'s own, off the T14's run 31: the boot - /// started at 16:08:21 UTC and handed the machine back twenty-three seconds - /// later, and a ping the host saw at 16:09:18 was therefore the operating - /// system after it, however early in the loop's own window it fell. + /// One boot's records, verbatim from a stick the T14 wrote + /// (`lancase-run31/kernel.log` lines 1, 279 and 379). + const BOOT: &str = concat!( + "[2026-09-08 16:08:21 0.000 cpu0 boot] panic console: armed 1920x1080 stride=1920 \ + format=1 at 0x4000000000\n", + "[2026-09-08 16:08:22 1.258 cpu0] Boot: complete (1258ms)\n", + "[2026-09-08 16:08:44 23.340 cpu1] Rebooting.\n", + ); + + /// The whole window a host watches the machine over, wider than the boot at + /// both ends because firmware runs inside it. + fn window() -> (u64, u64) { + let (first, ended) = record_unix_span(BOOT).expect("a span"); + (first - 4, ended + 36) + } + #[test] - fn a_records_wall_clock_brackets_the_boot() { - let log = concat!( - "[2026-09-08 16:08:21 0.000 cpu0 boot] boot: memory map\n", - "[2026-09-08 16:08:22 1.257 cpu0] Boot: complete (1257ms)\n", - "[2026-09-08 16:08:44 22.990 cpu1] Rebooting.\n", + fn a_second_inside_the_boot_and_after_the_named_record_is_this_boots() { + let (first, ended) = record_unix_span(BOOT).expect("a span"); + assert_eq!(ended - first, 23); + assert_eq!( + host_second_inside_this_boot(BOOT, window(), "Boot: complete", first + 2), + Ok(()) ); - let (first, last) = record_unix_span(log).expect("a span"); - assert_eq!(last - first, 23); - assert_eq!(record_unix_secs("[2026-09-08 16:08:21 0.000 cpu0 boot] x"), Some(first)); - // 16:09:18, which is 57 s into a window that opened before the boot did. - assert!(first + 57 > last, "the reply this judge has to reject is outside the bracket"); + } + + /// **A reply after the boot handed the machine back is the next operating + /// system's, however early in the host's window it fell.** The window opens + /// no later than the boot's first record, so a reply 57 s into it came at + /// least 34 s after this boot's `Rebooting.` + #[test] + fn a_second_past_the_reboot_record_is_the_next_operating_systems() { + let (first, ended) = record_unix_span(BOOT).expect("a span"); + let why = host_second_inside_this_boot(BOOT, window(), "Boot: complete", first + 57) + .expect_err("57 s past the window's opening is past this boot"); + assert!(why.contains(&format!("{first}..{ended}")), "{why}"); + assert!(why.contains("34 s after the boot"), "{why}"); + } + + #[test] + fn a_second_before_the_named_record_is_refused_by_that_record() { + let (first, _) = record_unix_span(BOOT).expect("a span"); + let why = host_second_inside_this_boot(BOOT, window(), "Boot: complete", first) + .expect_err("the boot had not completed yet"); + assert!(why.contains("1 s before"), "{why}"); + let why = host_second_inside_this_boot(BOOT, window(), "netd: DHCP: lease ", first + 2) + .expect_err("this boot took no lease"); + assert!(why.contains("no \"netd: DHCP: lease \" record"), "{why}"); + } + + /// **The window is what bounds the two clocks' disagreement.** A boot whose + /// records fall outside the span the host watched it over is a boot whose + /// clock cannot be held against the host's at all, and the numbers are + /// printed rather than the conclusion. + #[test] + fn records_outside_the_hosts_own_window_place_nothing() { + let (first, ended) = record_unix_span(BOOT).expect("a span"); + let why = host_second_inside_this_boot(BOOT, (first + 5, ended + 36), "Rebooting.", ended) + .expect_err("the boot began before the host started watching"); + assert!(why.contains(&format!("{first}..{ended}")), "{why}"); + assert!(why.contains("disagree by more than the window"), "{why}"); } /// The panel writes no wall clock, and its milliseconds field must not be @@ -498,5 +591,8 @@ mod record_time_tests { assert_eq!(record_unix_secs("[2026-09-08 25:00:00 0.000 cpu0] x"), None); assert_eq!(record_unix_secs("[2026-02-31 10:00:00 0.000 cpu0] x"), None); assert_eq!(record_unix_span("[1.000 cpu0] first\n"), None); + let why = host_second_inside_this_boot("[1.000 cpu0] first\n", (0, 1), "x", 0) + .expect_err("a panel log carries no wall clock"); + assert!(why.contains("no record with a wall clock"), "{why}"); } } diff --git a/src/metal.rs b/src/metal.rs index ccb9eddc27..0da8492baf 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -151,6 +151,8 @@ pub enum Refusal { /// The machine could not say what address it holds on the function the /// flashed image claims, so the boot could not be reached over the cable. Wire { nic: String, why: String }, + /// This host could not run the probe, which is a fact about the host. + Probe { why: String }, /// The machine did not go down, or did not come back. Silent { what: &'static str, secs: u64 }, /// The machine came back and the boot stick did not: the boot before this @@ -282,6 +284,12 @@ impl fmt::Display for Refusal { function the flashed image claims, and its address is the only one a boot of \ that image could answer on" ), + Self::Probe { why } => write!( + f, + "this host could not run `ping`: {why}. It is the only question this loop can \ + ask a boot while that boot is still up, so a run that cannot ask it \ + establishes nothing about the cable" + ), Self::Silent { what, secs } => write!( f, "the machine did not {what} within {secs} s, which is longer than every watchdog \ @@ -510,15 +518,6 @@ struct Target { mount: String, /// The boot entry's label in the firmware's list. label: String, - /// The PCI function whose cable this loop reaches the boot over, in the - /// spelling `/sys/bus/pci/devices` uses. - /// - /// **The function and not an interface name.** What the flashed image - /// claims is a PCI function, and what answers a ping is whatever address - /// the operating system before it held on that same function — so the two - /// are tied to one identifier here rather than to a name Ubuntu happens to - /// give it. - nic: String, } impl Target { @@ -534,7 +533,6 @@ impl Target { log_part: 3, mount: "/home/t14/toyos-log".to_string(), label: "ToyOS".to_string(), - nic: "0000:00:1f.6".to_string(), }) } @@ -977,12 +975,11 @@ fn lid_policy(text: &str) -> Result<(), Refusal> { /// What the machine holds on the PCI function the flashed image claims. /// /// **Read off that function and not off a name.** The address the loop pings -/// has to be the one a boot of this image could answer on, and the two -/// operating systems agree about it for exactly one reason: the function's MAC -/// is the same under both, so a DHCP server ordinarily hands both the same -/// lease. So the MAC is carried out beside the address and the boot's own -/// `netd: MAC` record is held to it — a ping answered at an address some other -/// interface holds is a ping this loop must not report as the boot's. +/// has to be one a boot of this image could answer on, and the two operating +/// systems agree about it for exactly one reason: the function's MAC is the +/// same under both, so a DHCP server ordinarily hands both the same lease. The +/// MAC is carried out beside the address so the boot's own driver record can be +/// held to it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Wire { pub iface: String, @@ -1012,40 +1009,38 @@ fn brief_address(iface: &str, text: &str) -> Result .map_err(|_| format!("{iface}'s address reads {cidr:?}")) } -/// Whether anything answers at the machine's own address while the machine is -/// between two operating systems, and how far into that window it first did. -/// -/// **The one thing this loop can ask a boot that is still running.** Everything -/// else it reads is on the stick, and the stick is read minutes later, from -/// Ubuntu; a boot's network exists only while the boot does. The probe is the -/// host's own `ping`, which is an implementation of ICMP this repository did -/// not write — so what it establishes about the stack under test is -/// independent of that stack. -/// -/// It runs on a thread because the loop is inside `ssh` for whole seconds at a -/// time waiting for the machine to answer again, and a boot that is up for -/// twenty of them cannot be sampled between those. -/// The first reply after the silence: how far into the window it came, and -/// when it came on this host's clock. -/// -/// **The wall clock is the half that identifies it.** How far into the window a -/// reply came says nothing about which operating system sent it — measured on -/// the T14, a reply 57 s in was the machine's wire returning two seconds ahead -/// of its own `sshd`, on a boot whose claim had been refused and whose netd -/// never held the card. What settles it is whether the reply falls inside the -/// span the boot's own records bracket, and only a wall clock can be held -/// against those. +/// The first reply after the silence: how far into the window it came, and when +/// it came on this host's clock. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Reply { pub secs: u64, /// Seconds since the epoch, UTC, taken when the probe answered. The probe - /// waits up to a second for its reply, so this is late by at most that — - /// against a bracket tens of seconds wide. + /// waits up to a second for its reply, so this is late by at most that. pub at: u64, } +/// What this host saw across the window in which the machine was running +/// neither of its operating systems. +/// +/// **The two ends of the window are the host's own clock, and they are what a +/// judge holds the boot's records against.** Nothing else this loop reads can +/// place a host-side observation inside a boot: how far into the window a reply +/// came says only that it was in the window, which both operating systems are. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Watch { + pub from: u64, + pub to: u64, + pub reply: Option, +} + +/// The probe, running while the loop is inside `ssh` waiting for the machine. +/// +/// **The one thing this loop can ask a boot that is still running.** Everything +/// else it reads is on the stick, and the stick is read minutes later, from +/// Ubuntu. The probe is the host's own `ping`, an implementation of ICMP this +/// repository did not write. struct Ping { - first: std::sync::Arc>>, + first: std::sync::Arc, String>>>, stop: std::sync::Arc, thread: std::thread::JoinHandle<()>, } @@ -1054,7 +1049,7 @@ impl Ping { /// Begin, now: the caller has just watched the machine stop answering /// `ssh`, and the window this measures starts there. fn start(addr: std::net::Ipv4Addr) -> Self { - let first = std::sync::Arc::new(std::sync::Mutex::new(None)); + let first = std::sync::Arc::new(std::sync::Mutex::new(Ok(None))); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let (mine, theirs) = (std::sync::Arc::clone(&first), std::sync::Arc::clone(&stop)); let thread = std::thread::Builder::new() @@ -1064,15 +1059,20 @@ impl Ping { let mut quiet_since: Option = None; let silence = std::time::Duration::from_secs(PING_SILENCE_SECS); while !theirs.load(std::sync::atomic::Ordering::SeqCst) { - if ping_once(addr) { + let answered = match ping_once(addr) { + Ok(answered) => answered, + Err(why) => { + *mine.lock().expect("the ping's answer") = Err(why); + return; + } + }; + if answered { // A reply before the address has been quiet is the // operating system that is going down, whose `sshd` // stops before its interface does. if quiet_since.is_some_and(|at| at.elapsed() >= silence) { - *mine.lock().expect("the ping's answer") = Some(Reply { - secs: began.elapsed().as_secs(), - at: unix_now(), - }); + *mine.lock().expect("the ping's answer") = + Ok(Some(Reply { secs: began.elapsed().as_secs(), at: unix_now() })); return; } quiet_since = None; @@ -1087,21 +1087,15 @@ impl Ping { } /// Stop probing, and answer what the first reply after the silence was. - fn end(self) -> Option { + fn end(self) -> Result, Refusal> { self.stop.store(true, std::sync::atomic::Ordering::SeqCst); let _ = self.thread.join(); - let answer = *self.first.lock().expect("the ping's answer"); - answer + let answer = self.first.lock().expect("the ping's answer").clone(); + answer.map_err(|why| Refusal::Probe { why }) } } /// This host's clock, as seconds since the epoch in UTC. -/// -/// The T14's own clock is Ubuntu's, set from the network; this host's is NTP's. -/// The bracket a reply is judged against is tens of seconds wide, which is what -/// makes holding the one clock against the other admissible at all — and a -/// machine whose two disagreed by more than that would show it as a reply just -/// outside the bracket rather than as a mystery. fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1111,10 +1105,14 @@ fn unix_now() -> u64 { /// One probe, whose whole answer is whether the address replied. /// +/// **A host with no `ping` and a cable with nothing on it are separate +/// answers.** A spawn that fails is this host's failing, and reporting it as +/// silence would red the boot for a binary the host does not have. +/// /// **`-W` is milliseconds on this host and seconds on Linux**, and the two /// spellings are three orders of magnitude apart: a bound written for one is a /// probe that hangs for a quarter of an hour on the other. -fn ping_once(addr: std::net::Ipv4Addr) -> bool { +fn ping_once(addr: std::net::Ipv4Addr) -> Result { let wait = if cfg!(target_os = "macos") { PING_WAIT_MS.to_string() } else { @@ -1124,7 +1122,8 @@ fn ping_once(addr: std::net::Ipv4Addr) -> bool { .args(["-n", "-c", "1", "-W", &wait, &addr.to_string()]) .stdin(Stdio::null()) .output() - .is_ok_and(|out| out.status.success()) + .map(|out| out.status.success()) + .map_err(|e| e.to_string()) } /// The loop, over one target. @@ -1183,9 +1182,8 @@ impl Driver { /// Three reads and not one, so a machine that answers oddly is refused with /// the read that was odd. None of them is a root command and none of them /// writes. - fn wire(&self) -> Result { - let nic = &self.target.nic; - let bad = |why: String| Refusal::Wire { nic: nic.clone(), why }; + fn wire(&self, nic: &str) -> Result { + let bad = |why: String| Refusal::Wire { nic: nic.to_string(), why }; let at = shell_word(&format!("/sys/bus/pci/devices/{nic}/net")); let listing = self .ssh("listing the claimed function's interfaces", &format!("ls {at}")) @@ -1316,17 +1314,22 @@ impl Driver { /// /// The window between the two is the only span in which the machine is /// running the image this loop wrote, and [`Ping`] is what asks the cable - /// about it while it lasts. + /// about it while it lasts — on the boots that name a function to ask it + /// over, and on no other. fn ride_the_reboot( &self, secs: u64, - addr: std::net::Ipv4Addr, - ) -> Result<(u64, Option), Refusal> { + addr: Option, + ) -> Result<(u64, Option), Refusal> { self.wait(GOING_DOWN_SECS, "go down", false)?; + let Some(addr) = addr else { + return Ok((self.wait(secs, "come back", true)?, None)); + }; + let from = unix_now(); let ping = Ping::start(addr); let back = self.wait(secs, "come back", true); - let answered = ping.end(); - Ok((back?, answered)) + let reply = ping.end()?; + Ok((back?, Some(Watch { from, to: unix_now(), reply }))) } /// Wait for the log partition's device node, and say how long it took. @@ -1490,6 +1493,13 @@ pub struct Args { /// `toyos-fat32-check`. The outside judge, and the only reader of that /// volume in this tree that is not the family of code that wrote it. fat32_check: bool, + /// The PCI function this boot's image claims, in `/sys/bus/pci/devices`'s + /// spelling, for the boots this loop reaches over the cable. + /// + /// **A boot names it or the cable is not asked at all.** The reads are + /// three `ssh` round trips and the probe is a host binary, and a boot that + /// claims no NIC would be refused for a fact none of its judges reads. + nic: Option, } impl Args { @@ -1503,6 +1513,7 @@ impl Args { wait_secs: return_secs(), readback: None, fat32_check: false, + nic: None, }; let mut at = 0; while at < args.len() { @@ -1555,6 +1566,11 @@ impl Args { out.fat32_check = true; 1 } + "--nic" => { + out.about_a_boot.push("--nic"); + out.nic = Some(value()?); + 2 + } "--wait-secs" => { out.about_a_boot.push("--wait-secs"); let secs = value()?; @@ -1709,14 +1725,18 @@ pub fn run(args: &Args) -> Result, Refusal> { identity.model ); // Before the flash, because the address a boot of this image could answer - // on is one only the operating system that is still up can be asked for — - // and a machine that cannot say it is a finding about this host rather than - // about the boot. - let wire = driver.wire()?; - println!( - "the claimed function {} is {} at {}, MAC {}", - args.target.nic, wire.iface, wire.addr, wire.mac - ); + // on is one only the operating system that is still up can be asked for. + let wire = match &args.nic { + Some(nic) => { + let wire = driver.wire(nic)?; + println!( + "the claimed function {nic} is {} at {}, MAC {}", + wire.iface, wire.addr, wire.mac + ); + Some(wire) + } + None => None, + }; driver.flash(&image)?; let entry = driver.boot_entry(&image.esp)?; @@ -1730,19 +1750,21 @@ pub fn run(args: &Args) -> Result, Refusal> { return Ok(None); } - let (back, pinged) = driver.ride_the_reboot(args.wait_secs, wire.addr)?; + let (back, watched) = driver.ride_the_reboot(args.wait_secs, wire.as_ref().map(|w| w.addr))?; println!("the machine answered ssh again after {back} s"); - match pinged { + if let (Some(wire), Some(watch)) = (&wire, &watched) { // **Something, and which something is not this loop's to say.** The // machine's own wire comes back before its `sshd` does, so a reply in // this window may be either operating system; the wall clock beside it // is what a judge holds against the boot's own records. - Some(reply) => println!( - "{} answered a ping {} s into the window, after {PING_SILENCE_SECS} s of \ - silence, at {} UTC seconds", - wire.addr, reply.secs, reply.at - ), - None => println!("nothing answered a ping at {} while the machine was down", wire.addr), + match watch.reply { + Some(reply) => println!( + "{} answered a ping {} s into the window, after {PING_SILENCE_SECS} s of \ + silence, at {} UTC seconds", + wire.addr, reply.secs, reply.at + ), + None => println!("nothing answered a ping at {} while the machine was down", wire.addr), + } } // Before the mount, so the stick's own answer is a number rather than // the reason a mount failed. @@ -1770,7 +1792,7 @@ pub fn run(args: &Args) -> Result, Refusal> { println!("toyos-fat32-check: the log partition's {} bytes check out", bytes.len()); } if let Some(dir) = &args.readback { - write_readback(dir, &loader, &log, back, stick, &wire, pinged)?; + write_readback(dir, &loader, &log, back, stick, wire.as_ref(), watched)?; println!("readback written to {}", dir.display()); } // **Named by evidence, before the boot record is missed.** A boot that @@ -1892,27 +1914,21 @@ pub const READBACK_VOLUME: &str = "log-partition.img"; pub const BACK_SECS: &str = "back_secs"; pub const STICK_SECS_KEY: &str = "stick_secs"; -/// The address this loop pinged while the machine was down, the MAC of the -/// function holding it, and how far into that window the first reply came. +/// The cable: the address this loop pinged, the MAC of the function holding +/// it, and the host's own clock at the two ends of the window it asked across. /// -/// **The address and the MAC are written whether or not anything answered, and -/// the seconds only if something did.** Which address was asked, and on which -/// function, are facts about the run; whether it replied is the boot's answer, -/// and an absent key is `no` said where a zero would be a reply in the first -/// second. The MAC is what a judge holds this boot's own driver record to, so -/// an answer from a different interface at that address cannot be read as the -/// boot's. +/// **All four together or none of them.** They are written only by a boot that +/// named a function to ask over, and a judge that read three of them would be +/// placing an observation against a window it could not see. pub const PING_ADDR_KEY: &str = "ping_addr"; -pub const PING_SECS_KEY: &str = "ping_secs"; pub const WIRE_MAC_KEY: &str = "wire_mac"; +pub const WINDOW_FROM_KEY: &str = "window_from"; +pub const WINDOW_TO_KEY: &str = "window_to"; -/// When the reply came, in seconds since the epoch on this host's clock. -/// -/// **The key that says which operating system answered.** Every other number -/// here is measured from the window's own start, and the window holds both of -/// the machine's operating systems — the T14 answered 57 s in on a boot whose -/// claim had been refused, two seconds before its own `sshd` came back. A judge -/// holds this against the wall clocks the boot's own records carry. +/// How far into that window the first reply came, and when it came on this +/// host's clock. **Both or neither**: an absent pair is `no` said where a zero +/// would be a reply in the first second, and the seconds alone place nothing. +pub const PING_SECS_KEY: &str = "ping_secs"; pub const PING_AT_KEY: &str = "ping_at"; /// Every file a readback directory carries, so a run that writes none of them @@ -1951,8 +1967,8 @@ fn write_readback( log: &str, back: u64, stick: u64, - wire: &Wire, - pinged: Option, + wire: Option<&Wire>, + watched: Option, ) -> Result<(), Refusal> { let wrote = |path: &Path, text: &str| -> Result<(), Refusal> { std::fs::write(path, text) @@ -1965,13 +1981,15 @@ fn write_readback( // The boot's own millisecond count is in the kernel log and read from // there; this file carries only what the *host* clock measured, which no // log can. - let mut boot = format!( - "{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n{PING_ADDR_KEY} {}\n{WIRE_MAC_KEY} {}\n", - wire.addr, wire.mac - ); - if let Some(reply) = pinged { - boot.push_str(&format!("{PING_SECS_KEY} {}\n", reply.secs)); - boot.push_str(&format!("{PING_AT_KEY} {}\n", reply.at)); + let mut boot = format!("{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n"); + if let (Some(wire), Some(watch)) = (wire, watched) { + boot.push_str(&format!( + "{PING_ADDR_KEY} {}\n{WIRE_MAC_KEY} {}\n{WINDOW_FROM_KEY} {}\n{WINDOW_TO_KEY} {}\n", + wire.addr, wire.mac, watch.from, watch.to + )); + if let Some(reply) = watch.reply { + boot.push_str(&format!("{PING_SECS_KEY} {}\n{PING_AT_KEY} {}\n", reply.secs, reply.at)); + } } wrote(&dir.join(READBACK_BOOT), &boot) } @@ -2020,27 +2038,61 @@ pub fn stick_secs(text: &str) -> Option { key(text, STICK_SECS_KEY) } -/// How far into the window between the two operating systems the machine's own -/// address first answered a ping, or `None` where nothing did. -pub fn ping_secs(text: &str) -> Option { - key(text, PING_SECS_KEY) -} - -/// When that reply came, on this host's clock. `None` where nothing answered. -pub fn ping_at(text: &str) -> Option { - key(text, PING_AT_KEY) -} - -/// The address that was pinged. Absent only from a readback written before this -/// loop asked. -pub fn ping_addr(text: &str) -> Option { - word(text, PING_ADDR_KEY) +/// What one boot's readback says about the cable, or `None` where the loop was +/// not asked to reach one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cable { + pub addr: String, + /// The MAC of the function that held that address, as the operating system + /// before this boot reported it. + pub mac: String, + /// The host's own clock at the two ends of the window it asked across. + pub window: (u64, u64), + pub reply: Option, } -/// The MAC of the function that held the pinged address, as the operating -/// system before this boot reported it. -pub fn wire_mac(text: &str) -> Option { - word(text, WIRE_MAC_KEY) +/// The cable a readback carries, **refusing every partial set by name**. +/// +/// A readback naming a reply and no window, or a window and no address, is one +/// this loop wrote in a shape no judge can read; answering `None` for it would +/// report a boot that answered as a boot nothing answered, which is the +/// opposite of what the run recorded. +pub fn cable(text: &str) -> Result, String> { + let addr = word(text, PING_ADDR_KEY); + let mac = word(text, WIRE_MAC_KEY); + let from = key(text, WINDOW_FROM_KEY); + let to = key(text, WINDOW_TO_KEY); + let secs = key(text, PING_SECS_KEY); + let at = key(text, PING_AT_KEY); + let named: Vec<&str> = [ + (addr.is_some(), PING_ADDR_KEY), + (mac.is_some(), WIRE_MAC_KEY), + (from.is_some(), WINDOW_FROM_KEY), + (to.is_some(), WINDOW_TO_KEY), + (secs.is_some(), PING_SECS_KEY), + (at.is_some(), PING_AT_KEY), + ] + .iter() + .filter_map(|(has, name)| has.then_some(*name)) + .collect(); + if named.is_empty() { + return Ok(None); + } + let (Some(addr), Some(mac), Some(from), Some(to)) = (addr, mac, from, to) else { + return Err(format!( + "this readback names {named:?} and a cable is {PING_ADDR_KEY}, {WIRE_MAC_KEY}, {WINDOW_FROM_KEY} and {WINDOW_TO_KEY} together" + )); + }; + let reply = match (secs, at) { + (Some(secs), Some(at)) => Some(Reply { secs, at }), + (None, None) => None, + _ => { + return Err(format!( + "this readback names {named:?}: a reply is {PING_SECS_KEY} and {PING_AT_KEY} together, and the seconds alone place it in neither operating system" + )); + } + }; + Ok(Some(Cable { addr, mac, window: (from, to), reply })) } fn word(text: &str, name: &str) -> Option { @@ -2259,31 +2311,48 @@ mod tests { } /// **A boot the cable did not answer is not a boot that answered in the - /// first second.** The address is written whichever way it went, so a - /// readback carrying one and no seconds says the window passed in silence, - /// and one carrying neither is a run from before this loop asked at all. + /// first second, and neither is a boot that was never asked.** #[test] fn a_ping_nothing_answered_is_written_as_no_answer() { - let answered = "back_secs 61\nstick_secs 2\nping_addr 192.168.1.46\n\ - wire_mac 8c:8c:aa:bb:cc:dd\nping_secs 17\nping_at 1757347715\n"; - let silent = "back_secs 46\nstick_secs 2\nping_addr 192.168.1.46\n\ - wire_mac 8c:8c:aa:bb:cc:dd\n"; - assert_eq!(ping_addr(answered).as_deref(), Some("192.168.1.46")); - assert_eq!(wire_mac(answered).as_deref(), Some("8c:8c:aa:bb:cc:dd")); - assert_eq!(ping_secs(answered), Some(17)); - assert_eq!(ping_at(answered), Some(1_757_347_715)); - // A window nothing answered carries neither number: a reply has a time - // or it did not happen. - assert_eq!(ping_at(silent), None); - assert_eq!(ping_addr(silent).as_deref(), Some("192.168.1.46")); - assert_eq!(ping_secs(silent), None); - assert_eq!(ping_addr("back_secs 46\n"), None); - // The two ping keys share a prefix, and neither may be read off the - // other's line. - assert_eq!(ping_secs("ping_addr 192.168.1.46\n"), None); - assert_eq!(ping_at("ping_addr 192.168.1.46\n"), None); - assert_eq!(back_secs(answered), Some(61)); - assert_eq!(stick_secs(answered), Some(2)); + let asked = "back_secs 61\nstick_secs 2\nping_addr 192.168.1.46\n\ + wire_mac 8c:8c:aa:bb:cc:dd\nwindow_from 1757347650\nwindow_to 1757347711\n"; + let answered = format!("{asked}ping_secs 17\nping_at 1757347715\n"); + let answered_cable = cable(&answered).expect("a whole cable").expect("a cable"); + assert_eq!(answered_cable.addr, "192.168.1.46"); + assert_eq!(answered_cable.mac, "8c:8c:aa:bb:cc:dd"); + assert_eq!(answered_cable.window, (1_757_347_650, 1_757_347_711)); + assert_eq!(answered_cable.reply, Some(Reply { secs: 17, at: 1_757_347_715 })); + // A window nothing answered carries neither number. + let silent = cable(asked).expect("a whole cable").expect("a cable"); + assert_eq!(silent.reply, None); + // A boot that named no function to ask over carries none of it. + assert_eq!(cable("back_secs 46\nstick_secs 2\n"), Ok(None)); + assert_eq!(back_secs(&answered), Some(61)); + assert_eq!(stick_secs(&answered), Some(2)); + } + + /// **Every partial set is refused by name**, and the seconds without their + /// wall clock are the one that would otherwise read as no answer at all. + #[test] + fn half_a_cable_is_refused_rather_than_read_as_none() { + let whole = "ping_addr 192.168.1.46\nwire_mac 8c:8c:aa:bb:cc:dd\n\ + window_from 1757347650\nwindow_to 1757347711\n"; + // Half a reply. The seconds without their wall clock are the one that + // would otherwise read as no answer at all. + for text in [format!("{whole}ping_secs 57\n"), format!("{whole}ping_at 1757347715\n")] { + let why = cable(&text).expect_err("half a reply is not a reply"); + assert!(why.contains("place it in neither operating system"), "{why}"); + } + // Half a cable. + for text in [ + "ping_addr 1.2.3.4\nwire_mac aa:bb\nwindow_from 1\n", + "ping_addr 1.2.3.4\nwindow_from 1\nwindow_to 2\n", + "ping_secs 57\nping_at 1757347715\n", + "ping_addr 192.168.1.46\n", + ] { + let why = cable(text).expect_err("half a cable is not a cable"); + assert!(why.contains("together"), "{why}"); + } } /// **The address is the one on the function the image claims, and an diff --git a/tests/common/lan.rs b/tests/common/lan.rs index b26857651e..402480105a 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -1,17 +1,6 @@ //! The cable: netd taking this machine's address from the network, and the T14 //! answering the development host on it. //! -//! **The two arms answer different questions.** Under QEMU the DHCP server is -//! the user-mode backend's own, an implementation of RFC 2131 this repository -//! did not write, and what it certifies is the client: the lease it hands out -//! is known — `10.0.2.15/24`, gateway and server `10.0.2.2`, resolver -//! `10.0.2.3` — so a client that mis-parses any field is caught by name. On the -//! T14 the server is the bench's router, the lease is whatever it has for this -//! MAC, and what is certified is the whole path: the kernel handing netd the -//! I219's function, the driver bringing its link up, the lease, and the -//! development host's own `ping` being answered at the leased address while the -//! machine is running nothing else. -//! //! Every line read here is a record. On the T14 a userland `println!` reaches //! `Backend::None`, so what crosses to the stick is the kernel's log — into //! which netd's `say!` writes, being a `write` to a console object. @@ -48,6 +37,10 @@ const SLIRP_DNS: &str = "10.0.2.3"; /// The card the T14 arm claims, as the kernel and the manifest spell it. const ID: &str = "8086:15fc"; +/// The PCI function that card is, as `/sys/bus/pci/devices` spells it: the +/// cable the metal loop reaches this boot over while it runs. +pub const NIC: &str = "0000:00:1f.6"; + /// The records this pair of arms is written against, spelled once. /// /// They are netd's own `say!` lines, and netd is another crate: what holds the @@ -57,6 +50,15 @@ const MAC: &str = "netd: MAC "; const LEASE: &str = "netd: DHCP: lease "; const LINK_UP: &str = "netd: I219: link up at "; const READY: &str = "netd: ready, at most "; +const NO_LEASE: &str = "netd: DHCP: no lease as toyos-t14 in "; + +/// netd's own `dhcp::LEASE_BOUND`: how long it waits before saying it has no +/// address. +const LEASE_BOUND_SECS: u64 = 20; + +/// The host-name option (RFC 2132 §3.14) as it goes out on the wire: the kind, +/// the length, and the name netd asks its network to record it under. +const HOST_NAME_OPTION: &[u8] = b"\x0c\x09toyos-t14"; /// One lease, as the record carries it. #[derive(Debug, PartialEq, Eq)] @@ -116,89 +118,19 @@ pub fn link_up_ms(text: &str) -> Result { .map_err(|_| format!("{line:?} carries no readable link-up time")) } -/// Whether the reply the host saw came from *this boot*, held against the wall -/// clocks the boot's own records carry. -/// -/// **How far into the window a reply came is not a judge, and one run proved -/// it.** Run 30's window was dark and the row's ceiling was written on that one -/// sample — "a reply anywhere in the window is the boot's". Run 31 answered at -/// 57 s on a boot whose claim had been refused and whose netd never held the -/// card: the machine's own wire came back two seconds ahead of its `sshd`, and -/// the loop stops probing when `sshd` answers, so that reply was inside the -/// window and inside the ceiling and belonged to the operating system after the -/// boot. -/// -/// What separates them is time against the boot's own timeline. `logd` writes a -/// wall clock on every record, the loop writes one beside the reply, and both -/// are UTC — the T14's clock is Ubuntu's, set from the network, and the host's -/// is NTP's. So: -/// -/// - the reply is inside the span this boot's first and last records bracket, -/// whose end is the `Rebooting.` record: anything after that is the next -/// operating system, whatever its timing; and -/// - it is at or after the lease record, because a machine with no address -/// answers nothing at that address. -/// -/// The bracket is tens of seconds wide and the two clocks are within a second -/// of each other, which is what makes comparing them admissible at all; a -/// machine whose clocks drifted further would show it here as a near miss. -fn the_boot_answered(back: &metal::Readback, at: u64) -> Result<(), String> { - let kernel = back.kernel(); - let text = kernel.text(); - let (first, last) = bootlog::record_unix_span(text).ok_or_else(|| { - format!( - "{}'s log carries no record with a wall clock on it, so there is nothing to hold \ - the host's own clock against", - back.label - ) - })?; - let rebooting = text - .lines() - .rfind(|l| l.contains(bootlog::REBOOTING)) - .and_then(bootlog::record_unix_secs) - .unwrap_or(last); - if at < first || at > rebooting { - return Err(format!( - "the reply at {at} is outside the span this boot's own records bracket \ - ({first}..{rebooting}, {} s wide): it came {} s {} the boot, so it is the \ - operating system on the other side of it and not this one", - rebooting.saturating_sub(first), - if at < first { first - at } else { at - rebooting }, - if at < first { "before" } else { "after" }, - )); - } - let leased = text - .lines() - .find(|l| l.contains(LEASE)) - .and_then(bootlog::record_unix_secs) - .ok_or_else(|| format!("{}'s lease record carries no wall clock", back.label))?; - if at < leased { - return Err(format!( - "the reply at {at} came {} s before this boot's lease at {leased}, and a machine \ - with no address answers nothing at that address", - leased - at - )); - } - eprintln!( - " [lan] the reply landed {} s after the lease and {} s before this boot handed the \ - machine back", - at - leased, - rebooting.saturating_sub(at), - ); - Ok(()) -} - /// The T14's judge: the claim, the card, the lease, and the host's own ping. -/// -/// **The ping and the lease are held to each other.** The address the host -/// pinged is the one this machine's name resolved to before the boot; the -/// address the boot leased is in its own record; a run where those differ is a -/// ping answered by something that is not this boot. pub fn on_metal(back: &metal::Readback) -> Result<(), String> { let profile = Profile::load(&super::compile::repo_root()).map_err(|why| why.to_string())?; let kernel = back.kernel(); let text = kernel.text(); let mut bad: Vec = Vec::new(); + let cable = back.cable.as_ref().ok_or_else(|| { + format!( + "{}'s readback carries no cable: this boot was driven by a loop that was not asked \ + to reach it over one, so nothing here is about the network", + back.label + ) + })?; // The kernel's own account of the hand-over, which is where an interrupt // mechanism the substrate cannot arm is refused by name. A boot with no @@ -227,12 +159,12 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { // one, and a MAC does not change with the operating system — so a driver // reporting this MAC is the driver holding that address, and a reply from // anything else at it is some other interface's. - let mac = format!("{MAC}{}", back.wire_mac); + let mac = format!("{MAC}{}", cable.mac); if !text.contains(&mac) { bad.push(format!( "no {mac:?} record: the card this boot brought up is not the one that held {} \ before it", - back.ping_addr + cable.addr )); } @@ -255,37 +187,41 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { if let Err(why) = profile.judge(&format!("lan.{}.lease_ms", back.label), lease.ms) { bad.push(why.to_string()); } - if lease.address != back.ping_addr { + if lease.address != cable.addr { bad.push(format!( - "this boot leased {} and the host pinged {}, so whatever answered was not \ - this boot", - lease.address, back.ping_addr + "this boot leased {} and the host pinged {}, which the router hands this \ + MAC under the operating system before it — so either something else \ + answered or that server does not repeat a lease across the two", + lease.address, cable.addr )); } } Err(why) => bad.push(why), } - match (back.ping_secs, back.ping_at) { - (Some(secs), Some(at)) => { + match cable.reply { + Some(reply) => { eprintln!( - " [lan] {} answered the host's ping {secs} s into the window", - back.ping_addr + " [lan] {} answered the host's ping {} s into the window", + cable.addr, reply.secs ); - // The cost of the reply, priced. It is not what says the reply was - // this boot's — `the_boot_answered` is — but a boot that answers - // far later than the last one is a boot something changed under. - if let Err(why) = profile.judge(&format!("boot.{}.ping_secs", back.label), secs) { + // The cost of the reply, priced. What says the reply was this + // boot's is the wall clock beside it, never how far into the window + // it came: the window holds both of this machine's operating + // systems. + if let Err(why) = profile.judge(&format!("boot.{}.ping_secs", back.label), reply.secs) { bad.push(why.to_string()); } - if let Err(why) = the_boot_answered(back, at) { + if let Err(why) = + bootlog::host_second_inside_this_boot(text, cable.window, LEASE, reply.at) + { bad.push(why); } } - _ => bad.push(format!( + None => bad.push(format!( "nothing answered a ping at {} while this machine was between its two operating \ systems", - back.ping_addr + cable.addr )), } @@ -315,7 +251,12 @@ pub fn lan_dhcp_lease( _rust_bins: &[(String, Vec)], ) -> Result<(), String> { let case = super::compile::repo_root().join(QEMU_CONFIG); - let options = BootOptions { profile: qemu::Profile::E1000e, ..Default::default() }; + let dump = wire_dump("lease"); + let options = BootOptions { + profile: qemu::Profile::E1000e, + wire_dump: Some(dump.clone()), + ..Default::default() + }; if !qemu::profile_argv(&options).iter().any(|a| a.contains("e1000e")) { return Err("this test needs an Intel NIC and the profile has none".to_string()); } @@ -349,5 +290,60 @@ pub fn lan_dhcp_lease( lease.ms ); log.must_be_clean()?; + asked_under_its_own_name(&dump) +} + +/// The client on a wire with nothing at the other end. +/// +/// **The refusal the lease boot cannot reach.** A machine whose network never +/// answers still has to announce itself, because every other arm in this suite +/// waits for that line and connects after it — a netd that stayed silent would +/// hang each of them instead of refusing their connects one at a time. +pub fn lan_no_lease( + _test_config: &Path, + _c_bins: &[(String, Vec)], + _rust_bins: &[(String, Vec)], +) -> Result<(), String> { + let case = super::compile::repo_root().join(QEMU_CONFIG); + let options = + BootOptions { profile: qemu::Profile::E1000eNoServer, ..Default::default() }; + let mut guest = QemuInstance::boot_with_options(&case, &[], &[], options); + let mut console = guest.boot_log().to_string(); + // Drained rather than waited on: netd owes its line inside its own bound + // and the guest says nothing at all until then, which every wait in this + // harness reads as a machine that stopped. + console.push_str(&guest.drain_serial(std::time::Duration::from_secs(LEASE_BOUND_SECS + 10))); + let log = serial::Serial::named("the lan boot with no server", console.as_str()); + if let Ok(lease) = lease_in(log.text()) { + return Err(format!("a wire with no server leased {lease:?}")); + } + log.must_say_after(NO_LEASE, READY)?; + eprintln!(" [lan] no server answered and netd said so, then served anyway"); + Ok(()) +} + +/// Where this process writes the frames one boot put on its wire. +fn wire_dump(which: &str) -> std::path::PathBuf { + let at = std::env::temp_dir() + .join(format!("toyos-lan-{which}-{}.pcap", std::process::id())); + let _ = std::fs::remove_file(&at); + at +} + +/// **The one place the host-name option can be read.** A server that ignores it +/// writes nothing about it and answers the same lease either way, so the frames +/// the client sent are the only evidence that it asked at all. +fn asked_under_its_own_name(dump: &Path) -> Result<(), String> { + let frames = std::fs::read(dump).map_err(|e| format!("{}: {e}", dump.display()))?; + let asked = frames.windows(HOST_NAME_OPTION.len()).any(|w| w == HOST_NAME_OPTION); + let _ = std::fs::remove_file(dump); + if !asked { + return Err(format!( + "none of the {} bytes this client put on the wire carries the host-name option \ + {HOST_NAME_OPTION:?}", + frames.len() + )); + } + eprintln!(" [lan] the client asked under its own name on the wire"); Ok(()) } diff --git a/tests/common/metal.rs b/tests/common/metal.rs index 80743c94dd..b2a8f92e60 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -62,6 +62,14 @@ pub struct Arm { /// kernel, and that is what most of the suite wants: it is the artifact the /// owner flashes. pub features: &'static [&'static str], + /// The PCI function this boot's image claims, where the loop is to reach + /// the boot over its cable while it runs. + /// + /// **`None` on every boot that does not ask.** Reading it costs three `ssh` + /// round trips before the flash and the probe costs a host binary, and a + /// boot whose judges read no cable would be refused for a fact none of them + /// looks at. + pub nic: Option<&'static str>, } /// The ordinary arm: one boot, and the fields a caller must still say. @@ -75,7 +83,7 @@ pub const fn once( params: &'static [&'static str], jobs: &'static [&'static str], ) -> Arm { - Arm { boot, config, params, jobs, features: &[] } + Arm { boot, config, params, jobs, features: &[], nic: None } } /// One boot carrying members that are **discovered rather than registered**. @@ -204,36 +212,10 @@ pub struct Readback { /// this machine holds, and it is a row rather than the reason a mount /// happened to work. pub stick_secs: u64, - /// The address the loop pinged while the machine was between its two - /// operating systems, read off the PCI function the flashed image claims. - pub ping_addr: String, - /// The MAC that function held under the operating system before this boot. - /// - /// **What ties an answered ping to this boot and not to the machine.** A - /// MAC does not change with the operating system, so a boot whose own - /// driver reports this one is the boot that holds that address; an answer - /// from any other interface at it is somebody else's. - pub wire_mac: String, - /// How far into that window the address first answered, and `None` where - /// nothing did. - /// - /// **A fact about the cable, measured on every boot.** Ubuntu answers at - /// this address too, on its way back up, so the number alone says only that - /// *something* did. What makes it a verdict is the ceiling - /// `tests/metal-profile.toml` prices for the boot that claims it, which is - /// far under what a boot with no network of its own measures — every other - /// boot in this suite is that boot, so the separation is read rather than - /// assumed. - pub ping_secs: Option, - /// When that reply came, on the host's clock, in seconds since the epoch. - /// - /// **What says which operating system answered.** Run 31 measured a reply - /// 57 s into the window on a boot whose claim had been refused and whose - /// netd never held the card: the machine's own wire came back two seconds - /// ahead of its `sshd`. So the window holds both operating systems, and - /// the only thing that separates them is this against the wall clocks the - /// boot's own records carry. - pub ping_at: Option, + /// What the host asked the cable while the machine was between its two + /// operating systems, and `None` on every boot that named no function to + /// ask over. + pub cable: Option, } impl Readback { @@ -454,6 +436,8 @@ struct Batch { jobs: Vec, files: Vec<(String, Vec)>, links: Vec<(String, String)>, + /// [`Arm::nic`], carried to the invocation that drives this boot. + nic: Option<&'static str>, } impl Batch { @@ -498,6 +482,7 @@ fn batches( jobs: boot.jobs.clone(), files: boot.files.clone(), links: boot.links.clone(), + nic: None, }, ); if was.is_some() { @@ -514,21 +499,25 @@ fn batches( jobs: Vec::new(), files: Vec::new(), links: Vec::new(), + nic: arm.nic, }); if batch.config != arm.config || batch.params != arm.params || batch.features != arm.features + || batch.nic != arm.nic { return Err(format!( - "{name} rides the boot {:?} as ({}, {:?}, {:?}) and another row rides it \ - as ({}, {:?}, {:?}); one boot is one image", + "{name} rides the boot {:?} as ({}, {:?}, {:?}, {:?}) and another row rides \ + it as ({}, {:?}, {:?}, {:?}); one boot is one image", arm.boot, arm.config, arm.params, arm.features, + arm.nic, batch.config, batch.params, - batch.features + batch.features, + batch.nic )); } batch.add(arm.jobs.iter().map(|j| (*j).to_string())); @@ -692,8 +681,8 @@ fn fingerprint(text: &str) -> u64 { /// The invocation that turns one image into one readback. Written down in the /// staged request and run by [`Mode::Drive`], so the two cannot differ. -fn invocation(image: &Path, home: &Path) -> Vec { - vec![ +fn invocation(image: &Path, home: &Path, nic: Option<&str>) -> Vec { + let mut words = vec![ "run".to_string(), "--bin".to_string(), "toyos-metal".to_string(), @@ -707,7 +696,14 @@ fn invocation(image: &Path, home: &Path) -> Vec { // `/log` has no reader of those bytes that is not the family of code // that wrote them. "--fat32-check".to_string(), - ] + ]; + // Only where the boot's own judges read a cable: the reads are three `ssh` + // round trips before the flash and the probe is a host binary. + if let Some(nic) = nic { + words.push("--nic".to_string()); + words.push(nic.to_string()); + } + words } fn read_readback(dir: &Path, label: &str) -> Result { @@ -725,12 +721,7 @@ fn read_readback(dir: &Path, label: &str) -> Result { .ok_or_else(|| format!("{label}'s boot file names no `back_secs`: {boot:?}"))?; let stick_secs = toyos_build::metal::stick_secs(&boot) .ok_or_else(|| format!("{label}'s boot file names no `stick_secs`: {boot:?}"))?; - // Required, and the seconds beside it are not: the address says the loop - // asked, and its absence is a readback from a run that could not. - let ping_addr = toyos_build::metal::ping_addr(&boot) - .ok_or_else(|| format!("{label}'s boot file names no `ping_addr`: {boot:?}"))?; - let wire_mac = toyos_build::metal::wire_mac(&boot) - .ok_or_else(|| format!("{label}'s boot file names no `wire_mac`: {boot:?}"))?; + let cable = toyos_build::metal::cable(&boot).map_err(|why| format!("{label}: {why}"))?; Ok(Readback { label: label.to_string(), boot_ms: bootlog::boot_millis(&kernel), @@ -738,10 +729,7 @@ fn read_readback(dir: &Path, label: &str) -> Result { kernel, back_secs, stick_secs, - ping_addr, - wire_mac, - ping_secs: toyos_build::metal::ping_secs(&boot), - ping_at: toyos_build::metal::ping_at(&boot), + cable, }) } @@ -858,7 +846,7 @@ pub fn run( request.push_str(&format!( "\n{label}\n image: {}\n cargo {}\n", image.display(), - invocation(image, &at(dir, label)).join(" ") + invocation(image, &at(dir, label), batches[*label].nic).join(" ") )); } let path = dir.join("request.txt"); @@ -885,7 +873,7 @@ pub fn run( let mut refused: BTreeMap<&str, String> = BTreeMap::new(); if mode == Mode::Drive { for (label, image) in &images { - let words = invocation(image, &at(dir, label)); + let words = invocation(image, &at(dir, label), batches[*label].nic); eprintln!("[metal] {label}: cargo {}", words.join(" ")); match Command::new("cargo").args(&words).current_dir(&root).status() { Ok(status) if status.success() => {} @@ -938,37 +926,17 @@ pub fn run( ("stick_secs", Some(back.stick_secs)), ("deadline_lateness_ms", back.deadline_lateness_ms()), ("lockup_lateness_ms", back.lockup_lateness_ms()), - ("ping_secs", back.ping_secs), ] { let name = format!("boot.{label}.{field}"); let priced = profile.row(&name).is_some(); - // **The ping is taken on every boot and claimed by one.** - // Ubuntu answers this address on its way back up, so every - // boot with no network of its own produces a reading — and - // those readings are what the priced boot's ceiling is - // derived from, not numbers each of those boots owes a row - // for. A boot that *is* priced still owes its reading, and - // the arm below is where a silent one reds. - if !priced && field == "ping_secs" { - continue; - } if value.is_none() && !priced && field.ends_with("_lateness_ms") { continue; } let Some(value) = value else { - let why = if field == "ping_secs" { - format!( - "nothing answered a ping at {} in the window between the two \ - operating systems, so this boot's own network never came up", - back.ping_addr - ) - } else { - "the bound this boot was armed for is not the one that ended it" - .to_string() - }; eprintln!( " FAIL {name}: this boot recorded none, and the profile prices \ - it — {why}" + it — so the bound this boot was armed for is not the one that \ + ended it" ); red = true; continue; diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index 5495afd6a5..901a30e8c5 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -611,33 +611,24 @@ ceiling = 30 ceiling_from = "as boot.testcases.stick_secs" # --- the cable: the boot that runs netd in front of the T14's own I219 --- -# The three boot facts below are the machine's own, off boots whose claim the -# kernel refused — first for want of MSI, then at the 32-bit BAR. They are facts -# about the boot rather than about the claim, so the readings stand; the two -# `lan.` rows have none, because netd never came up on either. -# -# `ping_secs` has none, and it is no longer the judge. Run 30's window was dark -# and this file said so: "a reply anywhere in the window is the boot's". Run 31 -# answered at 57 s on a boot whose claim was refused and whose netd never held -# the card — the machine's own wire came back two seconds ahead of its sshd, and -# the loop stops probing when sshd answers. One sample was not a judge. What -# identifies the boot's reply is its wall clock against the boot's own records -# (`lan::the_boot_answered`); this row prices the cost of a reply and nothing -# else. +# The three boot facts below are the machine's own, off the last boot of this +# config; its claim was refused at the 32-bit BAR, which makes them facts about +# the boot rather than about the claim. The two `lan.` rows have none: netd +# never came up on any boot of it. [[number]] name = "boot.lancase.complete_ms" unit = "ms" ceiling = 60000 ceiling_from = "toyos_tco::JOB_BOUND_MS — as boot.testcases.complete_ms" -measured = 1257 +measured = 1258 [[number]] name = "boot.lancase.back_secs" unit = "s" ceiling = 420 ceiling_from = "toyos_build::metal::return_secs" -measured = 61 +measured = 59 [[number]] name = "boot.lancase.stick_secs" @@ -650,7 +641,7 @@ measured = 0 name = "boot.lancase.ping_secs" unit = "s" ceiling = 420 -ceiling_from = "toyos_build::metal::return_secs — the loop's own wait for the machine to answer ssh again, and nothing narrower has been read. It is a cost and not a verdict: run 31 answered at 57 s from the operating system *after* the boot, so no ceiling on this number could have told the two apart, and what does is the wall-clock bracket in `lan::the_boot_answered`. A green reading tightens this to what a boot that answers actually costs" +ceiling_from = "toyos_build::metal::return_secs — the loop's own wait for the machine to answer ssh again, which bounds the window a reply can fall in and nothing narrower has been read. It is a cost and not a verdict: the window holds both of this machine's operating systems, so no ceiling on this number separates them, and what does is the wall-clock bracket in bootlog::host_second_inside_this_boot. A green reading tightens this to what a boot that answers actually costs" [[number]] name = "list.lancase.job_ms" diff --git a/tests/toyos.rs b/tests/toyos.rs index 1d12807712..4ac5602f90 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -654,6 +654,12 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ // is known field by field. The verdicts are records and a lease's fields; // no clock in it. Fast with the UNMEASURED bootstrap marker until priced. ("lan_dhcp_lease", Sched::Parallel, Tier::Fast), + // The same client on a wire with no server: it says it has no address and + // announces itself anyway. Its cost is netd's own twenty-second lease bound + // waited out in real time, so it is `Why::TimerAnchored` and belongs + // Nightly; a new name is bootstrapped Fast with the UNMEASURED marker + // because only the fast tier can replace one. + ("lan_no_lease", Sched::Parallel, Tier::Fast), ("netd_connection_caps", Sched::Parallel, Tier::Fast), // The netcase boot again: netd must not abort a listener on a ring flag its // own client forged. Its verdict is a kernel-reported EOF or its absence; @@ -1687,8 +1693,10 @@ const USB_RESET_BOOTS: &[metal::Arm] = &[ const METALCASE: &[metal::Arm] = &[metal::once("metalcase", "tests/metalcase", &[], &[])]; /// The cable's own boot: netd in front of the T14's I219, and one job that -/// holds the machine up long enough for the host to reach it. -const LANCASE: &[metal::Arm] = &[metal::once(lan::BOOT, lan::CONFIG, &[], lan::JOBS)]; +/// holds the machine up long enough for the host to reach it. The one arm in +/// this suite that names a PCI function for the loop to reach the boot over. +const LANCASE: &[metal::Arm] = + &[metal::Arm { nic: Some(lan::NIC), ..metal::once(lan::BOOT, lan::CONFIG, &[], lan::JOBS) }]; /// One boot for every in-kernel self-test that logs its verdict at init and /// does nothing else. @@ -13630,6 +13638,7 @@ fn run_machine_test( Ok(()) } "lan_dhcp_lease" => lan::lan_dhcp_lease(test_config, c_bins, rust_bins), + "lan_no_lease" => lan::lan_no_lease(test_config, c_bins, rust_bins), "https_tls13" => common::https::tls13_judge(rust_bins, common::https::VIRTIO), "https_tls13_e1000e" => common::https::tls13_judge(rust_bins, common::https::E1000E), "log_stream" => common::logstream::stream(common::logstream::VIRTIO, c_bins, rust_bins), From fe442b12b427b05e426750f487ba8d296454b55f Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 19:11:17 +0200 Subject: [PATCH 09/23] netd holds every resolver a lease can carry, and asks under its own name on a wire that is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dns::Socket::update_servers` truncates to `DNS_MAX_SERVER_COUNT` and says nothing, and smoltcp's default for it is one — so a lease offering three left netd's own record naming two resolvers the stack did not have. The count is raised in `userland/.cargo/config.toml` to the three a lease can carry (`smoltcp::wire::DHCP_MAX_DNS_SERVER_COUNT`), and `dhcp.rs` holds the two together with a `const` assertion: a build that lowers it again does not compile. Nothing is dropped and nothing has to be reported as dropped. `report_within` is deleted, and so is the `timeout.min` it fed. Its premise was that a machine whose network never answers produces no timer, so the loop's own delay is unbounded. `dhcpv4::Socket::poll_at` returns `PollAt::Time(retry_at)` in every state, and netd's delay comes from `iface.poll_delay`, so a discovering client already wakes the loop every ten seconds. The report now prints the seconds it actually fired at instead of the constant it was written for. The lease path and the lease-lost path are one writer. `Dhcp::write` takes the lease or its absence and replaces the address, the default route and the resolvers together, so the code that drops a lease is the code every boot already runs. Two things the lease boot cannot ask are now asked. `lan_no_lease` boots the same config on an `e1000e` plugged into a hub with nothing else on it — the only machine in this suite where a DHCP client gets no answer — and holds netd to saying it has no address and then announcing itself anyway, which is what keeps every arm waiting on that line from hanging. And `filter-dump` writes the frames the client actually sent, so the host-name option is read as bytes on the wire: a server that ignores it writes nothing about it, and there was no other place the owner's hostname decision could be checked at all. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- tests/common/qemu.rs | 29 ++++++++++ tests/test-durations | 1 + userland/.cargo/config.toml | 10 +++- userland/netd/src/dhcp.rs | 110 +++++++++++++++++------------------- userland/netd/src/main.rs | 18 +----- 5 files changed, 93 insertions(+), 75 deletions(-) diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 136a3dc5cd..e82bf8c78d 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -1093,6 +1093,13 @@ pub enum Profile { /// NIC, and everything else — console, sound, disks — unchanged. The only /// machine in reach on which netd's Intel driver runs at all. E1000e, + /// [`Profile::E1000e`] with its cable plugged into nothing. + /// + /// The one machine in this suite on which a DHCP client gets no answer: + /// the user-mode backend serves a lease whatever else it is told to + /// restrict, so no profile that has one can ask what a boot does on a + /// network that never replies. + E1000eNoServer, Gop, /// A virtio-gpu function and no VGA: the owner's own desktop, and the one /// machine where a mode change can succeed rather than answering @@ -1480,6 +1487,10 @@ enum Nic { /// QEMU's `e1000e`, which is the 82574L at `8086:10d3`: the same register /// file the ThinkPad T14's onboard I219 has. E1000e, + /// The same card on a hub nothing else is plugged into: a link the guest + /// brings up and puts frames onto, with no host, router or server at the + /// other end. + E1000eNoServer, } /// Everything a profile decides about the machine, in one table. A new @@ -1663,6 +1674,7 @@ impl Profile { }, Self::HeadlessNoIommu => Shape { iommu: None, ..Self::Headless.shape() }, Self::E1000e => Shape { nic: Nic::E1000e, ..Self::Headless.shape() }, + Self::E1000eNoServer => Shape { nic: Nic::E1000eNoServer, ..Self::Headless.shape() }, Self::VirtioNetNoMsix => Shape { vga: "none", panel: None, @@ -2305,6 +2317,10 @@ pub struct BootOptions { /// carries no `-netdev` for it to reach, which [`ssh_forward_argv`] is /// what a test refuses before it boots. pub ssh_port: Option, + /// Write every frame this machine's NIC sends or receives to this file, in + /// pcap. **The only way to read what the guest asked for**: a request the + /// server ignores reaches no log on either side. + pub wire_dump: Option, } /// Where the guest sees the host under QEMU's user-mode networking, and where @@ -2387,6 +2403,7 @@ impl Default for BootOptions { extra_root_files: Vec::new(), log_stream: None, ssh_port: None, + wire_dump: None, } } } @@ -4205,6 +4222,18 @@ fn qemu_command( .arg("-device") .arg("e1000e,netdev=net0"); } + Nic::E1000eNoServer => { + qemu.arg("-netdev") + .arg("hubport,id=net0,hubid=0") + .arg("-device") + .arg("e1000e,netdev=net0"); + } + } + // The frames the guest put on that wire, so a test can read what its client + // asked for and not only what a server chose to answer. + if let Some(at) = &options.wire_dump { + qemu.arg("-object") + .arg(format!("filter-dump,id=wire,netdev=net0,file={}", at.display())); } if shape.virtio.present() { diff --git a/tests/test-durations b/tests/test-durations index 4ff464f93f..a339ae1d83 100644 --- a/tests/test-durations +++ b/tests/test-durations @@ -255,6 +255,7 @@ kill_while_blocked 44 shards=12 klogd_hosted 5674 shards=12 klogd_panic_halts 16658 shards=12 lan_dhcp_lease 18446744073709551615 shards=none +lan_no_lease 18446744073709551615 shards=none lapic_spurious_vector 6829 shards=12 late_storage_connect 6455 shards=12 latency_wake 8790 shards=12 diff --git a/userland/.cargo/config.toml b/userland/.cargo/config.toml index 2ed8cca7f4..459d67cdc8 100644 --- a/userland/.cargo/config.toml +++ b/userland/.cargo/config.toml @@ -3,4 +3,12 @@ channel = "toyos" [build] target = "x86_64-unknown-toyos" -rustflags = ["-Dwarnings"] \ No newline at end of file +rustflags = ["-Dwarnings"] +# smoltcp's build script reads these; its defaults are one-device numbers. +# `DNS_MAX_SERVER_COUNT` is what the resolver holds, and its default of 1 makes +# `dns::Socket::update_servers` drop the rest of a lease silently — so netd's +# own lease record would name resolvers the stack does not have. Three is what +# `smoltcp::wire::DHCP_MAX_DNS_SERVER_COUNT` lets a lease carry, and +# `userland/netd/src/dhcp.rs` asserts the two at compile time. +[env] +SMOLTCP_DNS_MAX_SERVER_COUNT = "3" diff --git a/userland/netd/src/dhcp.rs b/userland/netd/src/dhcp.rs index 2c3fc8ee67..be0012e9b9 100644 --- a/userland/netd/src/dhcp.rs +++ b/userland/netd/src/dhcp.rs @@ -3,36 +3,40 @@ //! **There is no static configuration to fall back to.** A machine's address //! belongs to the network it is plugged into, and both networks this program //! has ever run on — QEMU's user-mode backend and the bench's router — serve -//! DHCP. What a hard-coded `10.0.2.15/24` bought was one of them, and it bought -//! it by being right about a machine nobody had asked. +//! DHCP. //! //! What the lease decides is the whole of the interface: the address and its //! prefix, the default route, and the resolvers the DNS socket queries. All -//! three are replaced together on every lease and dropped together when one is +//! three are written together on every lease and cleared together when one is //! lost, because a route left standing over an address that is gone sends //! frames out with a source nothing will answer. //! //! **A machine that gets no lease says so and goes on serving.** Its clients //! then get their connects refused, one refusal at a time, which is what they -//! are already written to survive; a daemon that waited here instead would put -//! a whole userland behind a router that did not answer. +//! are already written to survive. use std::time::{Duration, Instant}; +use smoltcp::config::DNS_MAX_SERVER_COUNT; use smoltcp::iface::Interface; use smoltcp::socket::{dhcpv4, dns}; -use smoltcp::wire::{DhcpOption, IpAddress, IpCidr, Ipv4Address, Ipv4Cidr}; +use smoltcp::wire::{ + DhcpOption, IpAddress, IpCidr, Ipv4Address, Ipv4Cidr, DHCP_MAX_DNS_SERVER_COUNT, +}; + +/// **The resolver holds every server a lease can carry.** +/// `dns::Socket::update_servers` truncates to `DNS_MAX_SERVER_COUNT` without +/// saying so, and smoltcp's default for it is one — so a lease offering three +/// would leave this machine's own record naming two resolvers it does not have. +/// The count is raised in `userland/.cargo/config.toml`, and this is what makes +/// a build that lowers it again fail to compile. +const _: () = assert!(DNS_MAX_SERVER_COUNT >= DHCP_MAX_DNS_SERVER_COUNT); /// RFC 2132 §3.14. const OPT_HOST_NAME: u8 = 12; -/// The name this machine asks its network to record for it. -/// -/// **One name, because there is one machine.** The bench's router is the only -/// DHCP server in reach that records a client's name at all, and what it -/// records this one under is what `toyos-t14` then resolves to — so the name is -/// the bench's, and a second machine running this program would need a second -/// answer before it needed anything else here. +/// The name this machine asks its network to record for it. One name, because +/// there is one machine. const HOSTNAME: &[u8] = b"toyos-t14"; /// The options every DISCOVER and REQUEST carries. @@ -45,8 +49,7 @@ static OUTGOING: [DhcpOption<'static>; 1] = /// the life of the boot, and a lease that lands after this is applied like any /// other. What the bound buys is a line in the log on a machine whose network /// never answers, instead of a boot that is silent about the one thing wrong -/// with it. Wide enough for a gigabit link to finish negotiating first, which -/// on the bench's I219 is seconds. +/// with it. const LEASE_BOUND: Duration = Duration::from_secs(20); /// The DHCP client socket this machine runs, asking for a lease under @@ -59,10 +62,9 @@ pub fn socket() -> dhcpv4::Socket<'static> { /// What the client decided, owned. /// -/// **Taken out of the socket before anything is applied**, because the -/// interface and the DNS resolver are the other two things a lease changes and -/// all three live in one `SocketSet`: an event still borrowing the client is an -/// event nothing can be done about. +/// **Taken out of the socket before anything is applied**, because the resolver +/// this lease writes lives in the same `SocketSet` as the client: an event +/// still borrowing the client is an event nothing can be done about. pub enum Change { Leased { address: Ipv4Cidr, router: Option, server: Ipv4Address, dns: Vec }, Lost, @@ -98,15 +100,6 @@ impl Dhcp { Self { began: Instant::now(), leased: false, settled: false } } - /// How long netd may sleep before this owes the log a line. - /// - /// **A bound nothing else would wake for.** A machine whose network never - /// answers produces no frame and no timer, so the loop's own delay is - /// unbounded and the report at [`LEASE_BOUND`] would never be written. - pub fn report_within(&self) -> Option { - (!self.settled).then(|| LEASE_BOUND.saturating_sub(self.began.elapsed())) - } - /// Apply what the client decided, and answer whether this machine's address /// question has just been settled — which is the moment netd has something /// to serve with. @@ -118,7 +111,22 @@ impl Dhcp { ) -> bool { match change { Some(Change::Leased { address, router, server, dns }) => { - self.apply(address, router, server, &dns, iface, resolver); + self.write(Some((address, router)), &dns, iface, resolver); + // **One record carrying every field the lease decided.** A boot + // read off a stick or a stream has this line and nothing else + // to say what this machine's network was. + crate::say!( + "netd: DHCP: lease {}/{} from {server}, gateway {}, dns [{}], {} ms after \ + netd came up", + address.address(), + address.prefix_len(), + match router { + Some(router) => router.to_string(), + None => "none".to_string(), + }, + dns.iter().map(ToString::to_string).collect::>().join(" "), + self.began.elapsed().as_millis(), + ); self.leased = true; } Some(Change::Lost) => { @@ -127,7 +135,7 @@ impl Dhcp { if self.leased { crate::say!("netd: DHCP: the lease is gone; this machine has no address"); } - self.clear(iface, resolver); + self.write(None, &[], iface, resolver); self.leased = false; } None => {} @@ -144,7 +152,7 @@ impl Dhcp { "netd: DHCP: no lease as {} in {} s; this machine has no address and every \ connect through it is refused", String::from_utf8_lossy(HOSTNAME), - LEASE_BOUND.as_secs(), + self.began.elapsed().as_secs(), ); self.settled = true; return true; @@ -152,17 +160,16 @@ impl Dhcp { false } - /// The lease, written into the interface and said out loud. + /// The address, the default route and the resolvers, written together; + /// `None` writes the absence of all three. /// - /// **One record carrying every field the lease decided.** A boot read off a - /// stick or a stream has this line and nothing else to say what this - /// machine's network was, and a judge that had to assemble it from three - /// lines would be guessing which boot each of them came from. - fn apply( + /// **One writer for both**, so the path that drops a lease is the path that + /// takes one: a clearing function of its own would be reached only by a + /// network that took an address away, which nothing in this tree can + /// arrange. + fn write( &self, - address: Ipv4Cidr, - router: Option, - server: Ipv4Address, + lease: Option<(Ipv4Cidr, Option)>, dns: &[Ipv4Address], iface: &mut Interface, resolver: &mut dns::Socket, @@ -171,10 +178,12 @@ impl Dhcp { // Cleared before the push, so a list already holding an address // cannot leave the old one standing beside the new. addrs.clear(); - addrs.push(IpCidr::Ipv4(address)).expect("an emptied address list takes one"); + if let Some((address, _)) = lease { + addrs.push(IpCidr::Ipv4(address)).expect("an emptied address list takes one"); + } }); iface.routes_mut().remove_default_ipv4_route(); - if let Some(router) = router { + if let Some(router) = lease.and_then(|(_, router)| router) { iface .routes_mut() .add_default_ipv4_route(router) @@ -182,22 +191,5 @@ impl Dhcp { } let servers: Vec = dns.iter().map(|s| IpAddress::Ipv4(*s)).collect(); resolver.update_servers(&servers); - crate::say!( - "netd: DHCP: lease {}/{} from {server}, gateway {}, dns [{}], {} ms after netd came up", - address.address(), - address.prefix_len(), - match router { - Some(router) => router.to_string(), - None => "none".to_string(), - }, - dns.iter().map(ToString::to_string).collect::>().join(" "), - self.began.elapsed().as_millis(), - ); - } - - fn clear(&self, iface: &mut Interface, resolver: &mut dns::Socket) { - iface.update_ip_addrs(|addrs| addrs.clear()); - iface.routes_mut().remove_default_ipv4_route(); - resolver.update_servers(&[]); } } diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index c289723a95..20c286fafc 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -1314,9 +1314,6 @@ fn main() { let now = SmoltcpInstant::from_millis(0); let mut iface = Interface::new(config, &mut device, now); - // **The interface starts with no address at all.** What it gets is a lease, - // and `dhcp::Dhcp` is what writes one into the address list, the route table - // and the resolvers together. let mut socket_set = SocketSet::new(vec![]); // Empty, because the lease names the resolvers and nothing else may: a @@ -1358,11 +1355,9 @@ fn main() { // on no network. let change = dhcp::Change::of(socket_set.get_mut::(dhcp_handle)); if dhcp.pass(change, &mut iface, socket_set.get_mut::(dns_handle)) { - // **The line says netd is serving, and it is said once this machine - // has an address to serve on** — or once it has been told it will - // not get one. Every arm that waits for netd waits for this, so - // moving it earlier would put those arms in front of a stack with no - // address. + // Every arm that waits for netd waits for this line, so it is said + // once this machine has an address to serve on — or has been told + // it will not get one. say!( "netd: ready, at most {max_piped} piped connections \ ({} MiB each of {} MiB total)", @@ -1427,13 +1422,6 @@ fn main() { } else { timeout.min(HANDSHAKE_TIMEOUT.as_nanos() as u64) }; - // The same argument for the lease: a machine whose network answers - // nothing produces neither a frame nor a socket timer, so the report - // that says so has to be a wake of its own. - let timeout = match dhcp.report_within() { - Some(left) => timeout.min(left.as_nanos() as u64), - None => timeout, - }; let mut ready: Vec = Vec::new(); poller.wait(1, timeout, |token| ready.push(token)); From 0ca8d425be13ef03ccc2af808b98c5bb089dc2c6 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 19:11:29 +0200 Subject: [PATCH 10/23] The prose the review refused, deleted Every prose finding in the review is answered by deletion rather than by a rewrite. What went: what an earlier implementation bought, the alternatives that were rejected, the borrow checker's opinion, a measurement with no command behind it, the run numbers and the story of which run falsified which judge, the comments that restate the line beneath them, and the fourteen-line header over one `sleep`. `tests/lancase/system.toml` claimed it changes "the pair of identifiers" against `tests/e1000case`; it changes one. It also carried a boot parameter line no config on this branch sets, copied in from its neighbour. The track file had grown a stage table with a rationale per bullet, which is a plan again. It is a paragraph, and what remains of the growth is two constraints the bench measured: the I219 publishes MSI and no MSI-X, and its 32-bit BAR shares a 2 MiB page with the internal NVMe. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- ...he-t14-answers-only-through-a-usb-stick.md | 66 +++++++------------ tests/lancase/system.toml | 11 +--- tests/toyos-rust-tests/src/bin/lan_hold.rs | 24 ++----- 3 files changed, 31 insertions(+), 70 deletions(-) diff --git a/issues/hardware/the-t14-answers-only-through-a-usb-stick.md b/issues/hardware/the-t14-answers-only-through-a-usb-stick.md index d2f60d7b0e..38430a6fb3 100644 --- a/issues/hardware/the-t14-answers-only-through-a-usb-stick.md +++ b/issues/hardware/the-t14-answers-only-through-a-usb-stick.md @@ -11,30 +11,14 @@ is on a cable on the same LAN as the development Mac and its NIC is the onboard Intel I219 at `00:1f.6`, `8086:15fc`, which the kernel enumerates and nothing claims. The track is to make that cable the answer path. -The substrate a process needs to drive a PCI function itself is built -(`kernel/src/pcidev/mod.rs`, `userland/netd/src/virtio_net.rs`); the I219 driver -is built (`toyos-i219/`, `userland/netd/src/i219.rs`) and moves frames under -QEMU's `e1000e`; netd takes its address from DHCP rather than carrying one -written down (`userland/netd/src/dhcp.rs`); and sshd grew command execution, -file transfer both ways and key auth. What is left is the laptop: - -- **The claim on the T14's own card.** `tests/lancase` is the boot that puts - netd in front of it, and the metal loop pings the address that card holds - across the window between the machine's two operating systems. Three runs on - the bench: the claim was refused for want of MSI-X, `pcidev` grew MSI, and it - now stops at the 32-bit BAR. What is owed for that is - `issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md`, and until it - is paid no process on this machine can drive that cable. -- **The record stream from the laptop**, so a boot's log arrives while it is - booting. The guest half is built and green under QEMU on both drivers - (`toyos-logstream/`, `userland/logd/src/stream.rs`); the metal half — arming - the flashed image with the Mac's address and listening while the T14 boots — - waits on the claim above. -- **The first ssh from the Mac into ToyOS on the T14**, through the harness's - russh client (`tests/ssh-client-host`), running a test binary over the cable - and judging its exit status. The same dependency. -- and a **netboot spike** in which the firmware fetches the loader over HTTP so - the stick leaves the boot path. +Built and green under QEMU: the substrate (`kernel/src/pcidev/mod.rs`), the +I219 driver (`toyos-i219/`, `userland/netd/src/i219.rs`), netd's address from +DHCP (`userland/netd/src/dhcp.rs`), the record stream (`toyos-logstream/`, +`userland/logd/src/stream.rs`) and sshd's exec, transfer and key auth. What is +left is the laptop — the claim on its own card (`tests/lancase`), the stream and +the ssh from the Mac over the cable (`tests/ssh-client-host`), and a netboot +spike that takes the stick out of the boot path — and all of it waits on +`issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md`. Constraints a reader would otherwise pay to re-derive: @@ -51,24 +35,19 @@ Constraints a reader would otherwise pay to re-derive: - **ssh is the bench's transport and a real feature**: sshd is built on russh and the harness's client is russh too. No host ssh binary, no fork. - **Addressing is DHCP with a hostname**, and netd sends `toyos-t14` as the - host-name option. **The name resolves to nothing on this LAN** — measured: the - T14's DHCP-served resolvers are the ISP's, and on the development Mac `t14` - resolves to the *Tailscale* address `100.92.92.12`, which only Ubuntu ever - holds. So the address is read off the claimed PCI function instead - (`Driver::wire`), the wire is `enp0s31f6` at `192.168.1.46/24` with the Mac on - `192.168.1.47`, and the boot's own MAC record is what ties a reply to the - boot. Wi-Fi is out — the AX210 needs a firmware image. + host-name option — but **the name resolves to nothing on this LAN**, measured: + the T14's DHCP-served resolvers are the ISP's, and on the development Mac + `t14` resolves to the Tailscale address `100.92.92.12`, which only Ubuntu ever + holds. The address is read off the claimed PCI function instead + (`Driver::wire`): `enp0s31f6` at `192.168.1.46/24`, the Mac on `192.168.1.47`. + Wi-Fi is out — the AX210 needs a firmware image. - **The I219 is an MSI part**, measured: `/proc/interrupts` names its interrupt - `IR-PCI-MSI-0000:00:1f.6` and `msi_irqs/162` reads `mode=msi`. `pcidev` armed - MSI-X alone and refused it; it arms either now. -- The I219 has a **32-bit BAR** (`bar0=0xbcf00000`), and that is where the claim - stops today: `pcidev`'s window allocator places a BAR above everything - firmware described, and below 4 GiB there is no above — the platform's fixed - MMIO is at `0xFEC00000`. Leaving the BAR where it sits is not the way out - either: the internal NVMe's `0xbce00000` is in the same 2 MiB page, which is - the only page size this kernel maps. `survey_low_space` prints what the - machine has left and what it cannot answer for; the owed work and its three - prices are `issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md`. + `IR-PCI-MSI-0000:00:1f.6` and `msi_irqs/162` reads `mode=msi`. +- The I219 has a **32-bit BAR** (`bar0=0xbcf00000`): `pcidev`'s window allocator + places a BAR above everything firmware described, and below 4 GiB there is no + above — the platform's fixed MMIO is at `0xFEC00000`. Leaving the BAR where it + sits is not a way out either: the internal NVMe's `0xbce00000` is in the same + 2 MiB page, which is the only page size this kernel maps. - **QEMU's `virtio-net-pci-non-transitional` on `q35` advertises no PCIe function-level reset** — measured, not assumed: `pcidev`'s refusal on that ground reddened every netd registration at once. So a re-claim is made safe by @@ -78,9 +57,8 @@ Constraints a reader would otherwise pay to re-derive: one; the I219 does, so on the T14 both hold. - **The record stream is `logstream=:` on the parameter line**, copied by the kernel into `/system/bin/init`'s environment and read from there - by `logd` (`toyos-logstream`'s `PARAM` and `ENV`). What is left to build is the - metal half: arming the flashed image with the Mac's address and listening while - the T14 boots. A boot that dies before `logd` runs still needs the stick. + by `logd` (`toyos-logstream`'s `PARAM` and `ENV`). A boot that dies before + `logd` runs still needs the stick. - **A stalled peer's backpressure reaches `logd`'s queue only after megabytes.** Between them stand a 2 MiB kernel pipe (`kernel/src/pipe.rs`'s `PIPE_SIZE`) and netd's 64 KiB send buffer, and a `log-storm` at `--smp 8` produces 4,213 lines diff --git a/tests/lancase/system.toml b/tests/lancase/system.toml index e3bf43699c..170c2ce8c9 100644 --- a/tests/lancase/system.toml +++ b/tests/lancase/system.toml @@ -1,11 +1,8 @@ # The one boot that runs netd in front of the ThinkPad T14's own NIC. # -# The card is the onboard I219 at `00:1f.6`, `8086:15fc`, which is the same -# register file `tests/e1000case`'s 82574L has — so what this config changes -# against that one is the pair of identifiers and nothing else. It is a -# directory of its own for the reason e1000case is: a program that names a card -# this machine does not have costs an `init:` refusal line on every boot of the -# config that does, and no machine has both. +# A directory of its own for the reason `tests/e1000case` is one: a program that +# names a card the machine does not have costs an `init:` refusal line on every +# boot of the config that does, and no machine has both. # # Nothing here reaches the internal NVMe. There is no `[disks]` row, and the # only device any program on this boot claims is the PCI function named below. @@ -15,8 +12,6 @@ start = ["logd", "netd", "test-runner"] [programs.logd] syscap = ["logread"] -# The record stream's authority: the address on the boot parameter line is -# information, and this row is the whole of what can act on it. receives = ["netd"] # netd holds the NIC's PCI function and drives it: the descriptor rings, the diff --git a/tests/toyos-rust-tests/src/bin/lan_hold.rs b/tests/toyos-rust-tests/src/bin/lan_hold.rs index 72c72c3ee5..6f783a37f6 100644 --- a/tests/toyos-rust-tests/src/bin/lan_hold.rs +++ b/tests/toyos-rust-tests/src/bin/lan_hold.rs @@ -1,29 +1,17 @@ //! Hold the boot open for as long as the host needs to reach this machine over //! the cable, and exit. //! -//! **A metal boot ends itself**, and the whole of a `tests/lancase` boot is -//! about a second: the job list runs and the last job hands the machine back to -//! firmware. Nothing on the network could be asked of a machine that is up for -//! that long — the I219's link takes seconds to negotiate before a DHCP -//! discover can even go out — so this job is the window, and its exit record is -//! what says the machine stayed up for the whole of it. -//! -//! It asserts nothing. What it is evidence *for* is judged on the host, out of +//! It asserts nothing: what it is evidence *for* is judged on the host, out of //! the records netd wrote inside this window and out of whether the host's own -//! `ping` was answered while it was open; a bare `sleep` cannot be wrong about -//! either. It is on `RUST_SKIP` for the same reason: on any boot but that one -//! it is twenty seconds of nothing. +//! `ping` was answered while it was open. use std::thread::sleep; use std::time::Duration; -/// How long this machine stays up for the host. -/// -/// The host polls once a second and the link and the lease come first, so what -/// this has to cover is a gigabit auto-negotiation, a DHCP exchange with the -/// router and several polls after both. `tests/metal-profile.toml`'s -/// `list.lancase.job_ms` is this number plus what a spawn costs, and moving one -/// without the other is a job list the runner's own deadline cuts short. +/// How long this machine stays up for the host. `list.lancase.job_ms` in +/// `tests/metal-profile.toml` is this number plus what a spawn costs, and +/// moving one without the other is a job list the runner's own deadline cuts +/// short. const HOLD: Duration = Duration::from_secs(20); fn main() { From 751e40eb8c593f0515ab49512b41202a98b74d70 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 19:13:48 +0200 Subject: [PATCH 11/23] `--nic` is a flag that describes a boot, and the test that says so names it `installing_the_rule_is_not_also_a_boot` enumerates every boot-describing flag and asserts each one is refused beside `--install-sudoers`. `--nic` pushes onto `about_a_boot` like the rest and was missing from that list. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- src/metal.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/metal.rs b/src/metal.rs index 0da8492baf..db5b7f847a 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -2199,6 +2199,7 @@ mod tests { vec!["--fat32-check"], vec!["--dry-run"], vec!["--wait-secs", "60"], + vec!["--nic", "0000:00:1f.6"], ] { let mut words = vec!["--install-sudoers".to_string(), "/tmp/pw".to_string()]; words.extend(flag.iter().map(|w| (*w).to_string())); From 36ef73b2d3420bde892b1fa6cfd87792e12a048c Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 19:15:23 +0200 Subject: [PATCH 12/23] Three comments that restate the declaration above them `Arm::nic`'s doc says why only the boot that names a function asks the cable, and `invocation` said it again a screen later; `BootOptions::wire_dump`'s doc says what the dump is for, and the argv said it again; and the partial-cable test's own doc says which half would otherwise read as no answer, twice. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- src/metal.rs | 3 --- tests/common/metal.rs | 2 -- tests/common/qemu.rs | 2 -- 3 files changed, 7 deletions(-) diff --git a/src/metal.rs b/src/metal.rs index db5b7f847a..ae0bc6c184 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -2338,13 +2338,10 @@ mod tests { fn half_a_cable_is_refused_rather_than_read_as_none() { let whole = "ping_addr 192.168.1.46\nwire_mac 8c:8c:aa:bb:cc:dd\n\ window_from 1757347650\nwindow_to 1757347711\n"; - // Half a reply. The seconds without their wall clock are the one that - // would otherwise read as no answer at all. for text in [format!("{whole}ping_secs 57\n"), format!("{whole}ping_at 1757347715\n")] { let why = cable(&text).expect_err("half a reply is not a reply"); assert!(why.contains("place it in neither operating system"), "{why}"); } - // Half a cable. for text in [ "ping_addr 1.2.3.4\nwire_mac aa:bb\nwindow_from 1\n", "ping_addr 1.2.3.4\nwindow_from 1\nwindow_to 2\n", diff --git a/tests/common/metal.rs b/tests/common/metal.rs index b2a8f92e60..453b47d0be 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -697,8 +697,6 @@ fn invocation(image: &Path, home: &Path, nic: Option<&str>) -> Vec { // that wrote them. "--fat32-check".to_string(), ]; - // Only where the boot's own judges read a cable: the reads are three `ssh` - // round trips before the flash and the probe is a host binary. if let Some(nic) = nic { words.push("--nic".to_string()); words.push(nic.to_string()); diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index e82bf8c78d..509405f0eb 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -4229,8 +4229,6 @@ fn qemu_command( .arg("e1000e,netdev=net0"); } } - // The frames the guest put on that wire, so a test can read what its client - // asked for and not only what a server chose to answer. if let Some(at) = &options.wire_dump { qemu.arg("-object") .arg(format!("filter-dump,id=wire,netdev=net0,file={}", at.display())); From e6efb81776a52b3514e183b120cd8b5b1cb1b93c Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 8 Sep 2026 19:16:40 +0200 Subject: [PATCH 13/23] The lancase registration no longer says the router records this machine's name `toyos-t14` resolves to nothing on the bench LAN. netd asks under the name and the wire is read for the option; whether any server records it is not a claim this repository can make, and the metal registration made it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz --- tests/toyos.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/toyos.rs b/tests/toyos.rs index 4ac5602f90..e1b35f0491 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -1316,9 +1316,9 @@ const METAL: &[(&str, metal::Metal)] = &[ // The cable. Under QEMU this name judges netd's DHCP client against the // user-mode backend's server; here it judges the whole path — the // kernel handing netd the T14's own I219, the link, a lease from the - // bench's router under this machine's name, and the development host's - // `ping` answered at the leased address in the window where the machine - // is running nothing but this image. + // bench's router, and the development host's `ping` answered at the + // leased address in the window where the machine is running nothing but + // this image. "lan_dhcp_lease", metal::Metal::Runs { arms: LANCASE, judge: |b| lan::on_metal(b[0]) }, ), From f65140096176e565a478a9aa893ead8e2b9da52d Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 14:11:41 +0200 Subject: [PATCH 14/23] netd asks for its own resolver count, and one writer both takes and drops the lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SMOLTCP_DNS_MAX_SERVER_COUNT` was a workspace-wide `[env]` entry in `userland/.cargo/config.toml`: without `force` it is silently ignored where the ambient environment already names the variable, and it applied to every userland crate rather than to netd. smoltcp declares the count as a crate feature, so netd asks for `dns-max-server-count-3` on its own dependency line beside `socket-dhcpv4`. `cargo check -p netd` reads `DNS_MAX_SERVER_COUNT = 3` out of the build script's `out/config.rs`, and the `const` assertion still holds. `Dhcp::pass` had a `Change::Lost` arm whose effect nothing could see: replacing its body with `self.leased = false` left every arm on this branch green. There is one call to `write` now, reached by both changes, with the arm deciding only what is written — so the path that drops a lease cannot be removed without removing the path every boot takes to get one. `dhcp::LEASE_BOUND` is `toyos_tco::LEASE_BOUND_MS`, one declaration the harness reads too, and `tests/lancase/system.toml` no longer grants `receives = ["netd"]` to logd and test-runner: this boot sets no `logstream=` parameter and neither program consumes any of that authority. Prose deleted: the chronology in the module header, the rejected clearing function at `write`, the restatement under `Dhcp::pass`, and the six lines in `userland/.cargo/config.toml` that duplicated the doc at the assertion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- tests/lancase/system.toml | 2 - toyos-tco/src/lib.rs | 5 ++ userland/.cargo/config.toml | 10 +--- userland/Cargo.lock | 1 + userland/netd/Cargo.toml | 4 ++ userland/netd/src/dhcp.rs | 93 ++++++++++++++++--------------------- userland/netd/src/main.rs | 3 -- 7 files changed, 51 insertions(+), 67 deletions(-) diff --git a/tests/lancase/system.toml b/tests/lancase/system.toml index 170c2ce8c9..671423a004 100644 --- a/tests/lancase/system.toml +++ b/tests/lancase/system.toml @@ -12,7 +12,6 @@ start = ["logd", "netd", "test-runner"] [programs.logd] syscap = ["logread"] -receives = ["netd"] # netd holds the NIC's PCI function and drives it: the descriptor rings, the # register window and the interrupt are its own, and the kernel keeps only the @@ -23,5 +22,4 @@ serves = ["netd"] devices = ["pci:8086:15fc"] [programs.test-runner] -receives = ["netd"] syscap = ["logread"] diff --git a/toyos-tco/src/lib.rs b/toyos-tco/src/lib.rs index a4cbf0b93c..cc805064d0 100644 --- a/toyos-tco/src/lib.rs +++ b/toyos-tco/src/lib.rs @@ -114,6 +114,11 @@ pub const FIRMWARE_BOUND_MS: u64 = 60_000; /// while a job never finishes is no wedge to it and nothing else ends the boot. pub const JOB_BOUND_MS: u64 = 60_000; +/// The bound netd gives this machine's first DHCP lease before it says it has +/// none and serves anyway, in milliseconds. The harness waits it out on a wire +/// with no server, so the two read one declaration. +pub const LEASE_BOUND_MS: u64 = 20_000; + /// The bound a panicked kernel holds its panel for before it returns the /// machine to firmware itself, in milliseconds. Nothing feeds this one: a key /// press retires it, because a key means somebody is reading the panel, and diff --git a/userland/.cargo/config.toml b/userland/.cargo/config.toml index 459d67cdc8..2ed8cca7f4 100644 --- a/userland/.cargo/config.toml +++ b/userland/.cargo/config.toml @@ -3,12 +3,4 @@ channel = "toyos" [build] target = "x86_64-unknown-toyos" -rustflags = ["-Dwarnings"] -# smoltcp's build script reads these; its defaults are one-device numbers. -# `DNS_MAX_SERVER_COUNT` is what the resolver holds, and its default of 1 makes -# `dns::Socket::update_servers` drop the rest of a lease silently — so netd's -# own lease record would name resolvers the stack does not have. Three is what -# `smoltcp::wire::DHCP_MAX_DNS_SERVER_COUNT` lets a lease carry, and -# `userland/netd/src/dhcp.rs` asserts the two at compile time. -[env] -SMOLTCP_DNS_MAX_SERVER_COUNT = "3" +rustflags = ["-Dwarnings"] \ No newline at end of file diff --git a/userland/Cargo.lock b/userland/Cargo.lock index cbfd8caa64..193c50303d 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -1922,6 +1922,7 @@ dependencies = [ "toyos 0.6.0", "toyos-abi 0.5.0", "toyos-i219", + "toyos-tco", ] [[package]] diff --git a/userland/netd/Cargo.toml b/userland/netd/Cargo.toml index 96e1482fc3..8a29c74cdd 100644 --- a/userland/netd/Cargo.toml +++ b/userland/netd/Cargo.toml @@ -7,6 +7,7 @@ license = "MIT OR Apache-2.0" toyos-abi = { path = "../../toyos-abi" } toyos = { path = "../../toyos" } toyos-i219 = { path = "../../toyos-i219" } +toyos-tco = { path = "../../toyos-tco" } [dependencies.smoltcp] version = "0.12" @@ -18,5 +19,8 @@ features = [ "socket-udp", "socket-dns", "socket-dhcpv4", + # A lease carries up to `wire::DHCP_MAX_DNS_SERVER_COUNT` resolvers and the + # resolver socket's default holds one; `netd/src/dhcp.rs` asserts the two. + "dns-max-server-count-3", "alloc", ] diff --git a/userland/netd/src/dhcp.rs b/userland/netd/src/dhcp.rs index be0012e9b9..22805efd43 100644 --- a/userland/netd/src/dhcp.rs +++ b/userland/netd/src/dhcp.rs @@ -1,10 +1,5 @@ //! This machine's address, taken from the network rather than written down. //! -//! **There is no static configuration to fall back to.** A machine's address -//! belongs to the network it is plugged into, and both networks this program -//! has ever run on — QEMU's user-mode backend and the bench's router — serve -//! DHCP. -//! //! What the lease decides is the whole of the interface: the address and its //! prefix, the default route, and the resolvers the DNS socket queries. All //! three are written together on every lease and cleared together when one is @@ -26,10 +21,8 @@ use smoltcp::wire::{ /// **The resolver holds every server a lease can carry.** /// `dns::Socket::update_servers` truncates to `DNS_MAX_SERVER_COUNT` without -/// saying so, and smoltcp's default for it is one — so a lease offering three -/// would leave this machine's own record naming two resolvers it does not have. -/// The count is raised in `userland/.cargo/config.toml`, and this is what makes -/// a build that lowers it again fail to compile. +/// saying so and smoltcp's default is one, so this crate asks for +/// `dns-max-server-count-3`; a build that drops it does not compile. const _: () = assert!(DNS_MAX_SERVER_COUNT >= DHCP_MAX_DNS_SERVER_COUNT); /// RFC 2132 §3.14. @@ -45,12 +38,10 @@ static OUTGOING: [DhcpOption<'static>; 1] = /// How long this machine waits for its first lease before saying it has none. /// -/// It bounds the *report*, never the client: the socket goes on retrying for -/// the life of the boot, and a lease that lands after this is applied like any -/// other. What the bound buys is a line in the log on a machine whose network -/// never answers, instead of a boot that is silent about the one thing wrong -/// with it. -const LEASE_BOUND: Duration = Duration::from_secs(20); +/// It bounds the *report*, never the client: the socket retries for the life of +/// the boot and a lease that lands later is applied like any other. What it buys +/// is a line in the log on a machine whose network never answers. +const LEASE_BOUND: Duration = Duration::from_millis(toyos_tco::LEASE_BOUND_MS); /// The DHCP client socket this machine runs, asking for a lease under /// [`HOSTNAME`]. @@ -60,11 +51,9 @@ pub fn socket() -> dhcpv4::Socket<'static> { socket } -/// What the client decided, owned. -/// -/// **Taken out of the socket before anything is applied**, because the resolver -/// this lease writes lives in the same `SocketSet` as the client: an event -/// still borrowing the client is an event nothing can be done about. +/// What the client decided, owned: the resolver this lease writes lives in the +/// same `SocketSet` as the client, so an event still borrowing the client is an +/// event nothing can be done about. pub enum Change { Leased { address: Ipv4Cidr, router: Option, server: Ipv4Address, dns: Vec }, Lost, @@ -109,36 +98,37 @@ impl Dhcp { iface: &mut Interface, resolver: &mut dns::Socket, ) -> bool { - match change { - Some(Change::Leased { address, router, server, dns }) => { - self.write(Some((address, router)), &dns, iface, resolver); - // **One record carrying every field the lease decided.** A boot - // read off a stick or a stream has this line and nothing else - // to say what this machine's network was. - crate::say!( - "netd: DHCP: lease {}/{} from {server}, gateway {}, dns [{}], {} ms after \ - netd came up", - address.address(), - address.prefix_len(), - match router { - Some(router) => router.to_string(), - None => "none".to_string(), - }, - dns.iter().map(ToString::to_string).collect::>().join(" "), - self.began.elapsed().as_millis(), - ); - self.leased = true; - } - Some(Change::Lost) => { + if let Some(change) = change { + let (lease, dns) = match change { + Change::Leased { address, router, server, dns } => { + // **One record carrying every field the lease decided.** A + // boot read off a stick or a stream has this line and + // nothing else to say what this machine's network was. + crate::say!( + "netd: DHCP: lease {}/{} from {server}, gateway {}, dns [{}], {} ms after \ + netd came up", + address.address(), + address.prefix_len(), + match router { + Some(router) => router.to_string(), + None => "none".to_string(), + }, + dns.iter().map(ToString::to_string).collect::>().join(" "), + self.began.elapsed().as_millis(), + ); + (Some((address, router)), dns) + } // Only worth a line where there was something to lose: the // client reports this on its way to a first lease too. - if self.leased { - crate::say!("netd: DHCP: the lease is gone; this machine has no address"); + Change::Lost => { + if self.leased { + crate::say!("netd: DHCP: the lease is gone; this machine has no address"); + } + (None, Vec::new()) } - self.write(None, &[], iface, resolver); - self.leased = false; - } - None => {} + }; + self.leased = lease.is_some(); + self.write(lease, &dns, iface, resolver); } if self.settled { return false; @@ -161,12 +151,9 @@ impl Dhcp { } /// The address, the default route and the resolvers, written together; - /// `None` writes the absence of all three. - /// - /// **One writer for both**, so the path that drops a lease is the path that - /// takes one: a clearing function of its own would be reached only by a - /// network that took an address away, which nothing in this tree can - /// arrange. + /// `None` writes the absence of all three. **One writer, reached by every + /// change**, so a route left standing over an address that is gone cannot + /// be arranged without breaking the path every boot takes to its lease. fn write( &self, lease: Option<(Ipv4Cidr, Option)>, diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index 20c286fafc..2ed6db5d15 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -1355,9 +1355,6 @@ fn main() { // on no network. let change = dhcp::Change::of(socket_set.get_mut::(dhcp_handle)); if dhcp.pass(change, &mut iface, socket_set.get_mut::(dns_handle)) { - // Every arm that waits for netd waits for this line, so it is said - // once this machine has an address to serve on — or has been told - // it will not get one. say!( "netd: ready, at most {max_piped} piped connections \ ({} MiB each of {} MiB total)", From cd755d51f83ad54a1ba6020b3b0050ba30054400 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 14:11:59 +0200 Subject: [PATCH 15/23] A reply is placed by a measured clock offset, not by a window wider than the error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The judge bounded the host↔T14 skew by the down-window's width less the boot's own span. On run 31's geometry that admits tens of seconds, which is enough to place the very reply the judge exists to reject back inside `first..ended`. The loop reads the machine's own clock as a fourth `ssh` read before the flash, with this host's clock taken at both ends of that read and a read straddling more than one second refused by name, and `host_second_inside_this_boot` takes that offset in place of a window. `Watch`, `window_from` and `window_to` are gone with it, and the readback carries `clock_skew` instead. The negative control is run 31's own reply, refused on run 31's own numbers. That run's transcript opens 33 s before the boot's first record, so a reply 57 s into a window that opens no earlier than the run itself came no earlier than one second past this boot's `Rebooting.` The "at least 34 s" the old test and the pull request body both claimed inverted the arithmetic: a window opening earlier than the first record makes that gap smaller, never larger. `Cable::addr` is an `Ipv4Addr`, so a readback whose `ping_addr` is not an address is refused rather than read and judged. The two `cable()` refusal strings that carried fourteen and eighteen literal spaces mid-sentence are line continuations again. `ride_the_reboot` resolves the boot's own silence before this host's missing `ping`, so a machine that never came back is `Silent` and not `Probe`. `Refusal::Wire` and `Refusal::Probe` are pinned as the loop's own failures, exit 2, by the test that pins that classification. `asked_under_its_own_name` scanned the whole pcap, and `filter-dump` records both directions: a server echoing the option back would have read as the question. It walks the records now and counts only IPv4-over-UDP frames leaving the client's own port. The dump is read once the guest is gone and removed there, so no refusal below it leaves a pcap behind, and its one parameter with one value is gone. `toyos-t14` was spelled three times; there is one spelling in the harness now, held to netd's own declaration by reading that source, with the record and the option both built from it. `ping_once` drops the arm for a host this loop does not run on, and with it the `div_ceil` that was the constant 1. Prose deleted: the fixture's citation of a path no `git ls-files` finds, the invariant restated at five sites, the docs restating the constants beneath them, the router-repeats-the-lease premise stated as fact, the review finding's own provenance inside `cable()`, and the Wi-Fi and Tailscale measurements inside a test doc. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- src/bootlog.rs | 90 +++++++--------- src/metal.rs | 233 ++++++++++++++++++------------------------ tests/common/lan.rs | 138 ++++++++++++++----------- tests/common/metal.rs | 11 +- 4 files changed, 222 insertions(+), 250 deletions(-) diff --git a/src/bootlog.rs b/src/bootlog.rs index cf03b40403..0763248da9 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -263,15 +263,13 @@ fn record_unix_span(log: &str) -> Option<(u64, u64)> { /// at or after the record `after` names. /// /// **The records are the one place a host clock and a boot's clock meet.** -/// `window` is the host's own clock at the two ends of the span in which the -/// machine was running neither of its operating systems; this boot's records -/// have to fall inside it, which bounds the two clocks' disagreement against -/// the run's own data instead of assuming a bound. How far into that window an -/// observation came separates nothing: the window holds the operating system -/// that left and the one that came back as well as this boot. +/// `skew` is this machine's clock minus the host's as the caller measured the +/// two against each other; how far into a host-side window an observation came +/// separates nothing, because such a window holds the operating system that +/// left and the one that came back as well as this boot. pub fn host_second_inside_this_boot( log: &str, - window: (u64, u64), + skew: i64, after: &str, at: u64, ) -> Result<(), String> { @@ -280,19 +278,14 @@ pub fn host_second_inside_this_boot( host's own clock against" .to_string() })?; - let (from, to) = window; - if first < from || ended > to { - return Err(format!( - "this boot's own records run {first}..{ended} and the host watched the machine over \ - {from}..{to}: the two clocks disagree by more than the window is wide, so nothing \ - the host saw can be placed inside this boot" - )); - } + let at = at + .checked_add_signed(skew) + .ok_or_else(|| format!("a host second of {at} and a skew of {skew} is no second at all"))?; if at < first || at > ended { return Err(format!( - "the host saw it at {at}, outside the {first}..{ended} this boot's own records \ - bracket: it came {} s {} the boot, so it belongs to the operating system on the \ - other side of it", + "the host saw it at {at} on this machine's clock, outside the {first}..{ended} this \ + boot's own records bracket: it came {} s {} the boot, so it belongs to the \ + operating system on the other side of it", if at < first { first - at } else { at - ended }, if at < first { "before" } else { "after" }, )); @@ -304,7 +297,8 @@ pub fn host_second_inside_this_boot( .ok_or_else(|| format!("this boot has no {after:?} record carrying a wall clock"))?; if at < after_at { return Err(format!( - "the host saw it at {at}, {} s before this boot's {after:?} record at {after_at}", + "the host saw it at {at} on this machine's clock, {} s before this boot's {after:?} \ + record at {after_at}", after_at - at )); } @@ -518,8 +512,7 @@ mod record_time_tests { assert_eq!(last_record_millis("nothing\n"), None); } - /// One boot's records, verbatim from a stick the T14 wrote - /// (`lancase-run31/kernel.log` lines 1, 279 and 379). + /// One boot's records, verbatim from a stick the T14 wrote. const BOOT: &str = concat!( "[2026-09-08 16:08:21 0.000 cpu0 boot] panic console: armed 1920x1080 stride=1920 \ format=1 at 0x4000000000\n", @@ -527,58 +520,49 @@ mod record_time_tests { "[2026-09-08 16:08:44 23.340 cpu1] Rebooting.\n", ); - /// The whole window a host watches the machine over, wider than the boot at - /// both ends because firmware runs inside it. - fn window() -> (u64, u64) { - let (first, ended) = record_unix_span(BOOT).expect("a span"); - (first - 4, ended + 36) - } - #[test] fn a_second_inside_the_boot_and_after_the_named_record_is_this_boots() { let (first, ended) = record_unix_span(BOOT).expect("a span"); assert_eq!(ended - first, 23); - assert_eq!( - host_second_inside_this_boot(BOOT, window(), "Boot: complete", first + 2), - Ok(()) - ); + assert_eq!(host_second_inside_this_boot(BOOT, 0, "Boot: complete", first + 2), Ok(())); } - /// **A reply after the boot handed the machine back is the next operating - /// system's, however early in the host's window it fell.** The window opens - /// no later than the boot's first record, so a reply 57 s into it came at - /// least 34 s after this boot's `Rebooting.` + /// **The reply the boot above was refused on.** The loop writes the second + /// its own clock read when the probe answered, 57 s into a window that + /// opens no earlier than the run itself; that run's first line is 33 s + /// before this boot's first record, so the earliest that reply can have + /// been is one second past `Rebooting.` #[test] - fn a_second_past_the_reboot_record_is_the_next_operating_systems() { + fn the_reply_57_s_into_that_window_is_the_next_operating_systems() { let (first, ended) = record_unix_span(BOOT).expect("a span"); - let why = host_second_inside_this_boot(BOOT, window(), "Boot: complete", first + 57) - .expect_err("57 s past the window's opening is past this boot"); + let window_from = first - 33; + let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", window_from + 57) + .expect_err("57 s into that window is past this boot's reset"); assert!(why.contains(&format!("{first}..{ended}")), "{why}"); - assert!(why.contains("34 s after the boot"), "{why}"); + assert!(why.contains("1 s after the boot"), "{why}"); } #[test] fn a_second_before_the_named_record_is_refused_by_that_record() { let (first, _) = record_unix_span(BOOT).expect("a span"); - let why = host_second_inside_this_boot(BOOT, window(), "Boot: complete", first) + let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first) .expect_err("the boot had not completed yet"); assert!(why.contains("1 s before"), "{why}"); - let why = host_second_inside_this_boot(BOOT, window(), "netd: DHCP: lease ", first + 2) + let why = host_second_inside_this_boot(BOOT, 0, "netd: DHCP: lease ", first + 2) .expect_err("this boot took no lease"); assert!(why.contains("no \"netd: DHCP: lease \" record"), "{why}"); } - /// **The window is what bounds the two clocks' disagreement.** A boot whose - /// records fall outside the span the host watched it over is a boot whose - /// clock cannot be held against the host's at all, and the numbers are - /// printed rather than the conclusion. + /// **The measured skew is the whole of what places a host second.** The + /// same reading is inside the boot on one clock and the next operating + /// system's on another, and nothing but the measurement separates them. #[test] - fn records_outside_the_hosts_own_window_place_nothing() { - let (first, ended) = record_unix_span(BOOT).expect("a span"); - let why = host_second_inside_this_boot(BOOT, (first + 5, ended + 36), "Rebooting.", ended) - .expect_err("the boot began before the host started watching"); - assert!(why.contains(&format!("{first}..{ended}")), "{why}"); - assert!(why.contains("disagree by more than the window"), "{why}"); + fn the_measured_skew_is_what_the_host_second_is_read_through() { + let (first, _) = record_unix_span(BOOT).expect("a span"); + assert_eq!(host_second_inside_this_boot(BOOT, 30, "Boot: complete", first - 28), Ok(())); + let why = host_second_inside_this_boot(BOOT, -30, "Boot: complete", first + 2) + .expect_err("thirty seconds the other way is before this boot began"); + assert!(why.contains("28 s before the boot"), "{why}"); } /// The panel writes no wall clock, and its milliseconds field must not be @@ -591,7 +575,7 @@ mod record_time_tests { assert_eq!(record_unix_secs("[2026-09-08 25:00:00 0.000 cpu0] x"), None); assert_eq!(record_unix_secs("[2026-02-31 10:00:00 0.000 cpu0] x"), None); assert_eq!(record_unix_span("[1.000 cpu0] first\n"), None); - let why = host_second_inside_this_boot("[1.000 cpu0] first\n", (0, 1), "x", 0) + let why = host_second_inside_this_boot("[1.000 cpu0] first\n", 0, "x", 0) .expect_err("a panel log carries no wall clock"); assert!(why.contains("no record with a wall clock"), "{why}"); } diff --git a/src/metal.rs b/src/metal.rs index ae0bc6c184..0de4bd7f55 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -58,23 +58,17 @@ pub fn return_secs() -> u64 { const POLL_SECS: u64 = 5; -/// How often the machine's own address is pinged while it is not answering -/// `ssh`. const PING_EVERY_SECS: u64 = 1; -/// How long one probe waits for its reply. +/// Milliseconds, which is what `-W` means to the macOS `ping` this loop runs. const PING_WAIT_MS: u64 = 1_000; /// How long the address has to answer *nothing* before a reply counts as this /// boot's. /// -/// **`ssh` stops answering before the network does.** `reboot` takes `sshd` -/// down first and the interface some seconds later, so the machine that is -/// already "down" by this loop's reckoning still answers ICMP for a moment — -/// and a reply counted there would be the operating system that is leaving, -/// never the one being flashed. What is looked for is a reply *after* the -/// address went quiet, which is the window in which the only thing that can be -/// running is the image this loop wrote. +/// **`ssh` stops answering before the network does**: `reboot` takes `sshd` down +/// first and the interface seconds later, so a reply before the silence is the +/// operating system that is leaving rather than the image this loop wrote. const PING_SILENCE_SECS: u64 = 5; /// How long the boot stick gets to be there again once Ubuntu is up. @@ -280,15 +274,13 @@ impl fmt::Display for Refusal { } Self::Wire { nic, why } => write!( f, - "the machine says nothing usable about PCI function {nic}: {why}. That is the \ - function the flashed image claims, and its address is the only one a boot of \ - that image could answer on" + "the machine says nothing usable about PCI function {nic}, which the flashed \ + image claims and a boot of it could answer on: {why}" ), Self::Probe { why } => write!( f, - "this host could not run `ping`: {why}. It is the only question this loop can \ - ask a boot while that boot is still up, so a run that cannot ask it \ - establishes nothing about the cable" + "this host could not run `ping`, the one question this loop can ask a boot that \ + is still up: {why}" ), Self::Silent { what, secs } => write!( f, @@ -972,27 +964,21 @@ fn lid_policy(text: &str) -> Result<(), Refusal> { Ok(()) } -/// What the machine holds on the PCI function the flashed image claims. -/// -/// **Read off that function and not off a name.** The address the loop pings -/// has to be one a boot of this image could answer on, and the two operating -/// systems agree about it for exactly one reason: the function's MAC is the -/// same under both, so a DHCP server ordinarily hands both the same lease. The -/// MAC is carried out beside the address so the boot's own driver record can be -/// held to it. +/// What the machine holds on the PCI function the flashed image claims, read +/// off that function and not off a name. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Wire { pub iface: String, pub addr: std::net::Ipv4Addr, /// Lower case, colon separated, as `/sys/class/net//address` writes it. pub mac: String, + /// This machine's clock minus this host's, in seconds. + pub skew: i64, } -/// `ip -4 -brief addr show `'s one line, as `Wire` needs it. -/// -/// The brief form is ` ...`, and an interface with no -/// address has no third field at all — which is the machine saying the cable is -/// out, and it is refused by name rather than read as some other interface's. +/// `ip -4 -brief addr show `'s one line, as `Wire` needs it: the brief +/// form is ` ...`, and an interface with no address has no +/// third field at all, which is the machine saying the cable is out. fn brief_address(iface: &str, text: &str) -> Result { let line = text .lines() @@ -1019,26 +1005,6 @@ pub struct Reply { pub at: u64, } -/// What this host saw across the window in which the machine was running -/// neither of its operating systems. -/// -/// **The two ends of the window are the host's own clock, and they are what a -/// judge holds the boot's records against.** Nothing else this loop reads can -/// place a host-side observation inside a boot: how far into the window a reply -/// came says only that it was in the window, which both operating systems are. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Watch { - pub from: u64, - pub to: u64, - pub reply: Option, -} - -/// The probe, running while the loop is inside `ssh` waiting for the machine. -/// -/// **The one thing this loop can ask a boot that is still running.** Everything -/// else it reads is on the stick, and the stick is read minutes later, from -/// Ubuntu. The probe is the host's own `ping`, an implementation of ICMP this -/// repository did not write. struct Ping { first: std::sync::Arc, String>>>, stop: std::sync::Arc, @@ -1046,8 +1012,7 @@ struct Ping { } impl Ping { - /// Begin, now: the caller has just watched the machine stop answering - /// `ssh`, and the window this measures starts there. + /// Begin, now: the caller has just watched the machine stop answering `ssh`. fn start(addr: std::net::Ipv4Addr) -> Self { let first = std::sync::Arc::new(std::sync::Mutex::new(Ok(None))); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -1067,9 +1032,6 @@ impl Ping { } }; if answered { - // A reply before the address has been quiet is the - // operating system that is going down, whose `sshd` - // stops before its interface does. if quiet_since.is_some_and(|at| at.elapsed() >= silence) { *mine.lock().expect("the ping's answer") = Ok(Some(Reply { secs: began.elapsed().as_secs(), at: unix_now() })); @@ -1095,7 +1057,6 @@ impl Ping { } } -/// This host's clock, as seconds since the epoch in UTC. fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1108,18 +1069,9 @@ fn unix_now() -> u64 { /// **A host with no `ping` and a cable with nothing on it are separate /// answers.** A spawn that fails is this host's failing, and reporting it as /// silence would red the boot for a binary the host does not have. -/// -/// **`-W` is milliseconds on this host and seconds on Linux**, and the two -/// spellings are three orders of magnitude apart: a bound written for one is a -/// probe that hangs for a quarter of an hour on the other. fn ping_once(addr: std::net::Ipv4Addr) -> Result { - let wait = if cfg!(target_os = "macos") { - PING_WAIT_MS.to_string() - } else { - PING_WAIT_MS.div_ceil(1_000).to_string() - }; Command::new("ping") - .args(["-n", "-c", "1", "-W", &wait, &addr.to_string()]) + .args(["-n", "-c", "1", "-W", &PING_WAIT_MS.to_string(), &addr.to_string()]) .stdin(Stdio::null()) .output() .map(|out| out.status.success()) @@ -1177,11 +1129,9 @@ impl Driver { } /// What this machine holds on the function the flashed image claims: the - /// interface Ubuntu gave it, its address, and its MAC. - /// - /// Three reads and not one, so a machine that answers oddly is refused with - /// the read that was odd. None of them is a root command and none of them - /// writes. + /// interface Ubuntu gave it, its address, its MAC, and how far its own clock + /// stands from this host's. Four reads and not one, so a machine that + /// answers oddly is refused with the read that was odd; none writes. fn wire(&self, nic: &str) -> Result { let bad = |why: String| Refusal::Wire { nic: nic.to_string(), why }; let at = shell_word(&format!("/sys/bus/pci/devices/{nic}/net")); @@ -1207,10 +1157,28 @@ impl Driver { &format!("ip -4 -brief addr show {}", shell_word(iface)), ) .map_err(|e| bad(e.to_string()))?; + // **The host's own clock at both ends of the read**, so what bounds the + // two clocks' disagreement is this round trip and not the width of some + // window. Read under Ubuntu and spent on ToyOS's records because both + // operating systems keep this machine's one RTC. + let before = unix_now(); + let said = self + .ssh("reading the machine's own clock", "date -u +%s") + .map_err(|e| bad(e.to_string()))?; + let after = unix_now(); + if after - before > 1 { + return Err(bad(format!( + "this host's clock read {before} before that and {after} after it, so the two \ + clocks cannot be held to a second" + ))); + } + let machine: i64 = + said.trim().parse().map_err(|_| bad(format!("`date -u +%s` said {said:?}")))?; Ok(Wire { iface: iface.to_string(), addr: brief_address(iface, &brief).map_err(bad)?, mac: mac.trim().to_ascii_lowercase(), + skew: machine - before as i64, }) } @@ -1312,24 +1280,23 @@ impl Driver { /// the machine is watched down before it is watched back up: a probe that /// caught dying Ubuntu would read a stick ToyOS had never booted. /// - /// The window between the two is the only span in which the machine is - /// running the image this loop wrote, and [`Ping`] is what asks the cable - /// about it while it lasts — on the boots that name a function to ask it - /// over, and on no other. + /// [`Ping`] asks the cable across the span between the two, on the boots + /// that name a function to ask it over and on no other. fn ride_the_reboot( &self, secs: u64, addr: Option, - ) -> Result<(u64, Option), Refusal> { + ) -> Result<(u64, Option), Refusal> { self.wait(GOING_DOWN_SECS, "go down", false)?; let Some(addr) = addr else { return Ok((self.wait(secs, "come back", true)?, None)); }; - let from = unix_now(); let ping = Ping::start(addr); let back = self.wait(secs, "come back", true); - let reply = ping.end()?; - Ok((back?, Some(Watch { from, to: unix_now(), reply }))) + let reply = ping.end(); + // The boot's own failure before this loop's: a machine that never came + // back is that, whatever this host's `ping` could or could not do. + Ok((back?, reply?)) } /// Wait for the log partition's device node, and say how long it took. @@ -1494,11 +1461,9 @@ pub struct Args { /// volume in this tree that is not the family of code that wrote it. fat32_check: bool, /// The PCI function this boot's image claims, in `/sys/bus/pci/devices`'s - /// spelling, for the boots this loop reaches over the cable. - /// - /// **A boot names it or the cable is not asked at all.** The reads are - /// three `ssh` round trips and the probe is a host binary, and a boot that - /// claims no NIC would be refused for a fact none of its judges reads. + /// spelling. **A boot names it or the cable is not asked at all**: the reads + /// cost four `ssh` round trips and a boot whose judges read no cable would + /// be refused for a fact none of them looks at. nic: Option, } @@ -1750,14 +1715,10 @@ pub fn run(args: &Args) -> Result, Refusal> { return Ok(None); } - let (back, watched) = driver.ride_the_reboot(args.wait_secs, wire.as_ref().map(|w| w.addr))?; + let (back, replied) = driver.ride_the_reboot(args.wait_secs, wire.as_ref().map(|w| w.addr))?; println!("the machine answered ssh again after {back} s"); - if let (Some(wire), Some(watch)) = (&wire, &watched) { - // **Something, and which something is not this loop's to say.** The - // machine's own wire comes back before its `sshd` does, so a reply in - // this window may be either operating system; the wall clock beside it - // is what a judge holds against the boot's own records. - match watch.reply { + if let Some(wire) = &wire { + match replied { Some(reply) => println!( "{} answered a ping {} s into the window, after {PING_SILENCE_SECS} s of \ silence, at {} UTC seconds", @@ -1792,7 +1753,7 @@ pub fn run(args: &Args) -> Result, Refusal> { println!("toyos-fat32-check: the log partition's {} bytes check out", bytes.len()); } if let Some(dir) = &args.readback { - write_readback(dir, &loader, &log, back, stick, wire.as_ref(), watched)?; + write_readback(dir, &loader, &log, back, stick, wire.as_ref(), replied)?; println!("readback written to {}", dir.display()); } // **Named by evidence, before the boot record is missed.** A boot that @@ -1914,16 +1875,13 @@ pub const READBACK_VOLUME: &str = "log-partition.img"; pub const BACK_SECS: &str = "back_secs"; pub const STICK_SECS_KEY: &str = "stick_secs"; -/// The cable: the address this loop pinged, the MAC of the function holding -/// it, and the host's own clock at the two ends of the window it asked across. -/// -/// **All four together or none of them.** They are written only by a boot that -/// named a function to ask over, and a judge that read three of them would be -/// placing an observation against a window it could not see. +/// The cable: the address this loop pinged, the MAC of the function holding it, +/// and how far the machine's own clock stood from this host's. **All three +/// together or none**: a judge reading two of them places an observation +/// against a clock it cannot see. pub const PING_ADDR_KEY: &str = "ping_addr"; pub const WIRE_MAC_KEY: &str = "wire_mac"; -pub const WINDOW_FROM_KEY: &str = "window_from"; -pub const WINDOW_TO_KEY: &str = "window_to"; +pub const CLOCK_SKEW_KEY: &str = "clock_skew"; /// How far into that window the first reply came, and when it came on this /// host's clock. **Both or neither**: an absent pair is `no` said where a zero @@ -1968,7 +1926,7 @@ fn write_readback( back: u64, stick: u64, wire: Option<&Wire>, - watched: Option, + replied: Option, ) -> Result<(), Refusal> { let wrote = |path: &Path, text: &str| -> Result<(), Refusal> { std::fs::write(path, text) @@ -1982,12 +1940,12 @@ fn write_readback( // there; this file carries only what the *host* clock measured, which no // log can. let mut boot = format!("{BACK_SECS} {back}\n{STICK_SECS_KEY} {stick}\n"); - if let (Some(wire), Some(watch)) = (wire, watched) { + if let Some(wire) = wire { boot.push_str(&format!( - "{PING_ADDR_KEY} {}\n{WIRE_MAC_KEY} {}\n{WINDOW_FROM_KEY} {}\n{WINDOW_TO_KEY} {}\n", - wire.addr, wire.mac, watch.from, watch.to + "{PING_ADDR_KEY} {}\n{WIRE_MAC_KEY} {}\n{CLOCK_SKEW_KEY} {}\n", + wire.addr, wire.mac, wire.skew )); - if let Some(reply) = watch.reply { + if let Some(reply) = replied { boot.push_str(&format!("{PING_SECS_KEY} {}\n{PING_AT_KEY} {}\n", reply.secs, reply.at)); } } @@ -2042,33 +2000,28 @@ pub fn stick_secs(text: &str) -> Option { /// not asked to reach one. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cable { - pub addr: String, + pub addr: std::net::Ipv4Addr, /// The MAC of the function that held that address, as the operating system /// before this boot reported it. pub mac: String, - /// The host's own clock at the two ends of the window it asked across. - pub window: (u64, u64), + /// That machine's clock minus this host's, in seconds, before the flash. + pub skew: i64, pub reply: Option, } -/// The cable a readback carries, **refusing every partial set by name**. -/// -/// A readback naming a reply and no window, or a window and no address, is one -/// this loop wrote in a shape no judge can read; answering `None` for it would -/// report a boot that answered as a boot nothing answered, which is the -/// opposite of what the run recorded. +/// The cable a readback carries, **refusing every partial set by name**: a +/// readback naming a reply and no address is one no judge can read, and +/// answering `None` for it would report a boot that answered as one nothing did. pub fn cable(text: &str) -> Result, String> { let addr = word(text, PING_ADDR_KEY); let mac = word(text, WIRE_MAC_KEY); - let from = key(text, WINDOW_FROM_KEY); - let to = key(text, WINDOW_TO_KEY); - let secs = key(text, PING_SECS_KEY); - let at = key(text, PING_AT_KEY); + let skew: Option = key(text, CLOCK_SKEW_KEY); + let secs: Option = key(text, PING_SECS_KEY); + let at: Option = key(text, PING_AT_KEY); let named: Vec<&str> = [ (addr.is_some(), PING_ADDR_KEY), (mac.is_some(), WIRE_MAC_KEY), - (from.is_some(), WINDOW_FROM_KEY), - (to.is_some(), WINDOW_TO_KEY), + (skew.is_some(), CLOCK_SKEW_KEY), (secs.is_some(), PING_SECS_KEY), (at.is_some(), PING_AT_KEY), ] @@ -2078,21 +2031,26 @@ pub fn cable(text: &str) -> Result, String> { if named.is_empty() { return Ok(None); } - let (Some(addr), Some(mac), Some(from), Some(to)) = (addr, mac, from, to) else { + let (Some(addr), Some(mac), Some(skew)) = (addr, mac, skew) else { return Err(format!( - "this readback names {named:?} and a cable is {PING_ADDR_KEY}, {WIRE_MAC_KEY}, {WINDOW_FROM_KEY} and {WINDOW_TO_KEY} together" + "this readback names {named:?} and a cable is {PING_ADDR_KEY}, {WIRE_MAC_KEY} and \ + {CLOCK_SKEW_KEY} together" )); }; + let addr = addr + .parse() + .map_err(|_| format!("this readback's {PING_ADDR_KEY} reads {addr:?}, which is no address"))?; let reply = match (secs, at) { (Some(secs), Some(at)) => Some(Reply { secs, at }), (None, None) => None, _ => { return Err(format!( - "this readback names {named:?}: a reply is {PING_SECS_KEY} and {PING_AT_KEY} together, and the seconds alone place it in neither operating system" + "this readback names {named:?}: a reply is {PING_SECS_KEY} and {PING_AT_KEY} \ + together, and the seconds alone place it in neither operating system" )); } }; - Ok(Some(Cable { addr, mac, window: (from, to), reply })) + Ok(Some(Cable { addr, mac, skew, reply })) } fn word(text: &str, name: &str) -> Option { @@ -2102,7 +2060,7 @@ fn word(text: &str, name: &str) -> Option { .filter(|got| !got.is_empty()) } -fn key(text: &str, name: &str) -> Option { +fn key(text: &str, name: &str) -> Option { text.lines() .find_map(|line| line.strip_prefix(name)) .and_then(|rest| rest.trim().parse().ok()) @@ -2316,12 +2274,12 @@ mod tests { #[test] fn a_ping_nothing_answered_is_written_as_no_answer() { let asked = "back_secs 61\nstick_secs 2\nping_addr 192.168.1.46\n\ - wire_mac 8c:8c:aa:bb:cc:dd\nwindow_from 1757347650\nwindow_to 1757347711\n"; + wire_mac 8c:8c:aa:bb:cc:dd\nclock_skew -3\n"; let answered = format!("{asked}ping_secs 17\nping_at 1757347715\n"); let answered_cable = cable(&answered).expect("a whole cable").expect("a cable"); - assert_eq!(answered_cable.addr, "192.168.1.46"); + assert_eq!(answered_cable.addr, std::net::Ipv4Addr::new(192, 168, 1, 46)); assert_eq!(answered_cable.mac, "8c:8c:aa:bb:cc:dd"); - assert_eq!(answered_cable.window, (1_757_347_650, 1_757_347_711)); + assert_eq!(answered_cable.skew, -3); assert_eq!(answered_cable.reply, Some(Reply { secs: 17, at: 1_757_347_715 })); // A window nothing answered carries neither number. let silent = cable(asked).expect("a whole cable").expect("a cable"); @@ -2336,21 +2294,24 @@ mod tests { /// wall clock are the one that would otherwise read as no answer at all. #[test] fn half_a_cable_is_refused_rather_than_read_as_none() { - let whole = "ping_addr 192.168.1.46\nwire_mac 8c:8c:aa:bb:cc:dd\n\ - window_from 1757347650\nwindow_to 1757347711\n"; + let whole = "ping_addr 192.168.1.46\nwire_mac 8c:8c:aa:bb:cc:dd\nclock_skew 0\n"; for text in [format!("{whole}ping_secs 57\n"), format!("{whole}ping_at 1757347715\n")] { let why = cable(&text).expect_err("half a reply is not a reply"); assert!(why.contains("place it in neither operating system"), "{why}"); } for text in [ - "ping_addr 1.2.3.4\nwire_mac aa:bb\nwindow_from 1\n", - "ping_addr 1.2.3.4\nwindow_from 1\nwindow_to 2\n", + "ping_addr 1.2.3.4\nwire_mac aa:bb\n", + "ping_addr 1.2.3.4\nclock_skew 1\n", "ping_secs 57\nping_at 1757347715\n", "ping_addr 192.168.1.46\n", ] { let why = cable(text).expect_err("half a cable is not a cable"); assert!(why.contains("together"), "{why}"); } + // A whole set whose address is not one is refused rather than judged. + let why = cable("ping_addr enp0s31f6\nwire_mac aa:bb\nclock_skew 0\n") + .expect_err("an interface name is not an address"); + assert!(why.contains("which is no address"), "{why}"); } /// **The address is the one on the function the image claims, and an @@ -2367,8 +2328,7 @@ mod tests { assert!(brief_address("enp0s31f6", down).unwrap_err().contains("no IPv4 address")); // Another interface's line is not this one's answer, however many are - // printed: the T14 holds a Wi-Fi address and a Tailscale one, and a - // ping aimed at either is a ping only Ubuntu ever answers. + // printed. let many = "lo UNKNOWN 127.0.0.1/8\n\ enp0s31f6 UP 192.168.1.46/24\n\ wlp9s0 UP 192.168.1.244/24\n\ @@ -2672,6 +2632,13 @@ mod tests { assert!(!Refusal::Sudo("a password is required".to_string()).about_the_boot()); assert!(!Refusal::Landed { what: "dd".to_string(), want: 1, got: 2 }.about_the_boot()); assert!(!Refusal::NoHome.about_the_boot()); + // The cable's two, which are the machine under the operating system + // before the boot and this host's own binary: neither is the boot. + assert!( + !Refusal::Wire { nic: "0000:00:1f.6".to_string(), why: "x".to_string() } + .about_the_boot() + ); + assert!(!Refusal::Probe { why: "no ping".to_string() }.about_the_boot()); } #[test] diff --git a/tests/common/lan.rs b/tests/common/lan.rs index 402480105a..43e6e4f591 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -29,7 +29,7 @@ const QEMU_CONFIG: &str = "tests/e1000case"; /// What QEMU's user-mode backend leases, and what it says about the network it /// leases on. Its own defaults, not this repository's: they are the oracle. -const SLIRP_ADDRESS: &str = "10.0.2.15"; +const SLIRP_ADDRESS: std::net::Ipv4Addr = std::net::Ipv4Addr::new(10, 0, 2, 15); const SLIRP_PREFIX: u8 = 24; const SLIRP_ROUTER: &str = "10.0.2.2"; const SLIRP_DNS: &str = "10.0.2.3"; @@ -42,28 +42,38 @@ const ID: &str = "8086:15fc"; pub const NIC: &str = "0000:00:1f.6"; /// The records this pair of arms is written against, spelled once. -/// -/// They are netd's own `say!` lines, and netd is another crate: what holds the -/// two spellings together is that a boot missing any of these fails here by -/// name rather than passing quietly. const MAC: &str = "netd: MAC "; const LEASE: &str = "netd: DHCP: lease "; const LINK_UP: &str = "netd: I219: link up at "; const READY: &str = "netd: ready, at most "; -const NO_LEASE: &str = "netd: DHCP: no lease as toyos-t14 in "; +const NO_LEASE: &str = "netd: DHCP: no lease as "; + +/// The name this machine asks its network to record for it — netd's own +/// `dhcp::HOSTNAME`, which [`netd_spells_this_name`] holds this to, and which +/// the record above and the option below are both built from. +const HOSTNAME: &str = "toyos-t14"; -/// netd's own `dhcp::LEASE_BOUND`: how long it waits before saying it has no -/// address. -const LEASE_BOUND_SECS: u64 = 20; +/// RFC 2132 §3.14: the kind, the length, and the name. +fn host_name_option() -> Vec { + let mut option = vec![12, HOSTNAME.len() as u8]; + option.extend_from_slice(HOSTNAME.as_bytes()); + option +} -/// The host-name option (RFC 2132 §3.14) as it goes out on the wire: the kind, -/// the length, and the name netd asks its network to record it under. -const HOST_NAME_OPTION: &[u8] = b"\x0c\x09toyos-t14"; +fn netd_spells_this_name() -> Result<(), String> { + let at = super::compile::repo_root().join("userland/netd/src/dhcp.rs"); + let source = std::fs::read_to_string(&at).map_err(|e| format!("{}: {e}", at.display()))?; + let declared = format!("const HOSTNAME: &[u8] = b\"{HOSTNAME}\";"); + if source.contains(&declared) { + return Ok(()); + } + Err(format!("{} declares no `{declared}`", at.display())) +} /// One lease, as the record carries it. #[derive(Debug, PartialEq, Eq)] pub struct Lease { - pub address: String, + pub address: std::net::Ipv4Addr, pub prefix: u8, pub server: String, pub gateway: String, @@ -91,7 +101,7 @@ pub fn lease_in(text: &str) -> Result { let (address, prefix) = cidr.split_once('/').ok_or_else(|| unreadable("an address/prefix"))?; let dns = after(", dns [", "]")?; Ok(Lease { - address: address.to_string(), + address: address.parse().map_err(|_| unreadable("an IPv4 address"))?, prefix: prefix.parse().map_err(|_| unreadable("a prefix length"))?, server: after(" from ", ",")?, gateway: after(", gateway ", ",")?, @@ -132,10 +142,8 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { ) })?; - // The kernel's own account of the hand-over, which is where an interrupt - // mechanism the substrate cannot arm is refused by name. A boot with no - // hand-over line carries the refusal instead, and quoting it is the whole - // diagnosis. + // A boot with no hand-over line carries the kernel's refusal instead, and + // quoting that is the whole diagnosis. let handed = format!("[{}] handed over on slot", ID); match text.lines().find(|l| l.contains(&handed)) { Some(line) => eprintln!(" [lan] {}", line.trim()), @@ -154,11 +162,6 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { } } - // **The MAC is what makes an answered ping this boot's.** The address was - // read off the same PCI function under the operating system before this - // one, and a MAC does not change with the operating system — so a driver - // reporting this MAC is the driver holding that address, and a reply from - // anything else at it is some other interface's. let mac = format!("{MAC}{}", cable.mac); if !text.contains(&mac) { bad.push(format!( @@ -205,15 +208,11 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { " [lan] {} answered the host's ping {} s into the window", cable.addr, reply.secs ); - // The cost of the reply, priced. What says the reply was this - // boot's is the wall clock beside it, never how far into the window - // it came: the window holds both of this machine's operating - // systems. if let Err(why) = profile.judge(&format!("boot.{}.ping_secs", back.label), reply.secs) { bad.push(why.to_string()); } if let Err(why) = - bootlog::host_second_inside_this_boot(text, cable.window, LEASE, reply.at) + bootlog::host_second_inside_this_boot(text, cable.skew, LEASE, reply.at) { bad.push(why); } @@ -238,20 +237,18 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { /// The QEMU arm: the client, against a DHCP server this repository did not /// write. /// -/// Every field of the lease is checked against what the user-mode backend -/// serves, because a client that dropped the router option or read the mask off -/// the wrong option would otherwise pass on a machine where the answers happen -/// to agree. And the readiness line is checked to come *after* the lease: every -/// other arm in this suite waits for that line and then connects, so a netd that -/// announced itself before it had an address would hand those arms a stack with -/// none. +/// Every field of the lease is checked, because a client that dropped the router +/// option or read the mask off the wrong one would otherwise pass where the +/// answers happen to agree; and the readiness line is checked to come *after* +/// the lease, because every other arm waits for it and then connects. pub fn lan_dhcp_lease( _test_config: &Path, _c_bins: &[(String, Vec)], _rust_bins: &[(String, Vec)], ) -> Result<(), String> { + netd_spells_this_name()?; let case = super::compile::repo_root().join(QEMU_CONFIG); - let dump = wire_dump("lease"); + let dump = wire_dump(); let options = BootOptions { profile: qemu::Profile::E1000e, wire_dump: Some(dump.clone()), @@ -264,11 +261,16 @@ pub fn lan_dhcp_lease( let mut console = guest.boot_log().to_string(); qemu::await_marker(&mut guest, &mut console, READY, "netd to take an address")?; console.push_str(&guest.drain_serial(std::time::Duration::from_millis(500))); + // QEMU owns the pcap while it runs, and every refusal below is a return: + // the frames are taken once the machine is gone and the file removed here. + drop(guest); + let frames = std::fs::read(&dump).map_err(|e| format!("{}: {e}", dump.display()))?; + let _ = std::fs::remove_file(&dump); let log = serial::Serial::named("the lan boot", console.as_str()); let lease = lease_in(log.text())?; let want = Lease { - address: SLIRP_ADDRESS.to_string(), + address: SLIRP_ADDRESS, prefix: SLIRP_PREFIX, server: SLIRP_ROUTER.to_string(), gateway: SLIRP_ROUTER.to_string(), @@ -290,58 +292,80 @@ pub fn lan_dhcp_lease( lease.ms ); log.must_be_clean()?; - asked_under_its_own_name(&dump) + asked_under_its_own_name(&frames) } /// The client on a wire with nothing at the other end. /// /// **The refusal the lease boot cannot reach.** A machine whose network never -/// answers still has to announce itself, because every other arm in this suite -/// waits for that line and connects after it — a netd that stayed silent would -/// hang each of them instead of refusing their connects one at a time. +/// answers still has to announce itself, or every arm that waits for that line +/// hangs instead of having its connects refused one at a time. pub fn lan_no_lease( _test_config: &Path, _c_bins: &[(String, Vec)], _rust_bins: &[(String, Vec)], ) -> Result<(), String> { + netd_spells_this_name()?; let case = super::compile::repo_root().join(QEMU_CONFIG); - let options = - BootOptions { profile: qemu::Profile::E1000eNoServer, ..Default::default() }; + let options = BootOptions { profile: qemu::Profile::E1000eNoServer, ..Default::default() }; let mut guest = QemuInstance::boot_with_options(&case, &[], &[], options); let mut console = guest.boot_log().to_string(); // Drained rather than waited on: netd owes its line inside its own bound // and the guest says nothing at all until then, which every wait in this // harness reads as a machine that stopped. - console.push_str(&guest.drain_serial(std::time::Duration::from_secs(LEASE_BOUND_SECS + 10))); + console.push_str( + &guest.drain_serial(std::time::Duration::from_millis(toyos_tco::LEASE_BOUND_MS + 10_000)), + ); let log = serial::Serial::named("the lan boot with no server", console.as_str()); if let Ok(lease) = lease_in(log.text()) { return Err(format!("a wire with no server leased {lease:?}")); } - log.must_say_after(NO_LEASE, READY)?; + log.must_say_after(&format!("{NO_LEASE}{HOSTNAME} in "), READY)?; eprintln!(" [lan] no server answered and netd said so, then served anyway"); Ok(()) } /// Where this process writes the frames one boot put on its wire. -fn wire_dump(which: &str) -> std::path::PathBuf { - let at = std::env::temp_dir() - .join(format!("toyos-lan-{which}-{}.pcap", std::process::id())); +fn wire_dump() -> std::path::PathBuf { + let at = std::env::temp_dir().join(format!("toyos-lan-{}.pcap", std::process::id())); let _ = std::fs::remove_file(&at); at } /// **The one place the host-name option can be read.** A server that ignores it -/// writes nothing about it and answers the same lease either way, so the frames -/// the client sent are the only evidence that it asked at all. -fn asked_under_its_own_name(dump: &Path) -> Result<(), String> { - let frames = std::fs::read(dump).map_err(|e| format!("{}: {e}", dump.display()))?; - let asked = frames.windows(HOST_NAME_OPTION.len()).any(|w| w == HOST_NAME_OPTION); - let _ = std::fs::remove_file(dump); +/// answers the same lease either way, so the frames the client sent are the only +/// evidence that it asked at all — and `filter-dump` records both directions, so +/// a frame counts only where it is IPv4 over UDP leaving the client's own port. +fn asked_under_its_own_name(pcap: &[u8]) -> Result<(), String> { + const LITTLE_ENDIAN_PCAP: [u8; 4] = [0xd4, 0xc3, 0xb2, 0xa1]; + const GLOBAL_HEADER: usize = 24; + const RECORD_HEADER: usize = 16; + /// Ethernet, an IPv4 header carrying no options, and UDP. + const HEADERS: usize = 14 + 20 + 8; + if pcap.get(..LITTLE_ENDIAN_PCAP.len()) != Some(&LITTLE_ENDIAN_PCAP[..]) { + return Err("this file does not open with a little-endian pcap header".to_string()); + } + let option = host_name_option(); + let (mut at, mut sent, mut asked) = (GLOBAL_HEADER, 0usize, false); + while let Some(header) = pcap.get(at..at + RECORD_HEADER) { + let len = u32::from_le_bytes(header[8..12].try_into().expect("four bytes")) as usize; + let frame = pcap.get(at + RECORD_HEADER..at + RECORD_HEADER + len).ok_or_else(|| { + format!("this pcap's record at byte {at} names {len} bytes the file has not") + })?; + at += RECORD_HEADER + len; + if frame.len() > HEADERS + && frame[12..14] == [0x08, 0x00] + && frame[23] == 17 + && frame[34..36] == [0, 68] + { + sent += 1; + asked |= frame.windows(option.len()).any(|w| w == option); + } + } if !asked { return Err(format!( - "none of the {} bytes this client put on the wire carries the host-name option \ - {HOST_NAME_OPTION:?}", - frames.len() + "none of the {sent} frame(s) this client sent a DHCP server carries the host-name \ + option {option:?}" )); } eprintln!(" [lan] the client asked under its own name on the wire"); diff --git a/tests/common/metal.rs b/tests/common/metal.rs index 453b47d0be..a5f608ac79 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -62,13 +62,10 @@ pub struct Arm { /// kernel, and that is what most of the suite wants: it is the artifact the /// owner flashes. pub features: &'static [&'static str], - /// The PCI function this boot's image claims, where the loop is to reach - /// the boot over its cable while it runs. - /// - /// **`None` on every boot that does not ask.** Reading it costs three `ssh` - /// round trips before the flash and the probe costs a host binary, and a - /// boot whose judges read no cable would be refused for a fact none of them - /// looks at. + /// The PCI function this boot's image claims, where the loop reaches the + /// boot over its cable while it runs. **`None` on every boot that does not + /// ask**: a boot whose judges read no cable would be refused for a fact + /// none of them looks at. pub nic: Option<&'static str>, } From f8d21d9b8d674ed691a669c2f6f8c978cadca1ac Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 14:12:14 +0200 Subject: [PATCH 16/23] The harness refuses the boots it cannot run, and lan_hold sleeps a bound this tree prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Nic::E1000eNoServer` was the one NIC arm that dropped `forward`, so a boot asking for `ssh_port` on that profile got no `hostfwd` rather than a refusal; and `wire_dump` was attached outside the `shape.nic` match, so a profile with no NIC handed QEMU a `filter-dump` naming a `netdev` that does not exist. Both combinations are refused by name before the boot. `lan_hold` sleeps `toyos_tco::LEASE_BOUND_MS` rather than a number of its own, and `metalprofile`'s own test holds `tests/metal-profile.toml`'s three `lancase` rows to that constant — the coupling that lived in a comment saying the two had to move together. `boot.lancase.ping_secs` carries run 31's reading, `measured = 57`. The relegation `lan_no_lease` owes is in `issues/build/a-timer-anchored-names-tier-is-decided-by-its-price.md`, beside the two names already waiting on the same unresolved rule, rather than in a source comment with no owner and no exit condition. Prose deleted: the block of run history in `tests/metal-profile.toml`, the plan in `ceiling_from`, the narration of `lan::on_metal` at its registration, the `ping` row's second telling of what makes it an oracle, and eleven comment lines over six of code in `lan_hold.rs`. The track file is back to main's 61 lines and under its word count. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- ...ored-names-tier-is-decided-by-its-price.md | 4 +++ ...he-t14-answers-only-through-a-usb-stick.md | 31 +++++++------------ src/metalprofile.rs | 16 ++++++++++ src/sourcegate.rs | 13 +++----- tests/common/qemu.rs | 10 ++++++ tests/metal-profile.toml | 13 +++----- tests/toyos-rust-tests/Cargo.lock | 5 +++ tests/toyos-rust-tests/Cargo.toml | 1 + tests/toyos-rust-tests/src/bin/lan_hold.rs | 16 ++++------ tests/toyos.rs | 14 +++------ 10 files changed, 68 insertions(+), 55 deletions(-) diff --git a/issues/build/a-timer-anchored-names-tier-is-decided-by-its-price.md b/issues/build/a-timer-anchored-names-tier-is-decided-by-its-price.md index f9dc1ba9ab..56a2cae556 100644 --- a/issues/build/a-timer-anchored-names-tier-is-decided-by-its-price.md +++ b/issues/build/a-timer-anchored-names-tier-is-decided-by-its-price.md @@ -27,6 +27,10 @@ it". Two of the nine that stayed Fast are anchored by exactly that test: what decides them. - **`job_deadline_reboots`** (4,806 ms) — its verdict waits out a staged window, the runner's job list running past `toyos_tco::JOB_BOUND_MS`. +- **`lan_no_lease`** (unpriced) — it waits out `toyos_tco::LEASE_BOUND_MS` in + real time for netd to say it has no address. Registered Fast with the + `UNMEASURED` marker, which only the fast tier carries, so its first CI price + decides it under the same unresolved rule. So the tier of a timer-anchored name is currently decided by its price, and the classification the boundary states is not what placed it. Either the two above diff --git a/issues/hardware/the-t14-answers-only-through-a-usb-stick.md b/issues/hardware/the-t14-answers-only-through-a-usb-stick.md index 38430a6fb3..bb294923c8 100644 --- a/issues/hardware/the-t14-answers-only-through-a-usb-stick.md +++ b/issues/hardware/the-t14-answers-only-through-a-usb-stick.md @@ -11,14 +11,11 @@ is on a cable on the same LAN as the development Mac and its NIC is the onboard Intel I219 at `00:1f.6`, `8086:15fc`, which the kernel enumerates and nothing claims. The track is to make that cable the answer path. -Built and green under QEMU: the substrate (`kernel/src/pcidev/mod.rs`), the -I219 driver (`toyos-i219/`, `userland/netd/src/i219.rs`), netd's address from -DHCP (`userland/netd/src/dhcp.rs`), the record stream (`toyos-logstream/`, -`userland/logd/src/stream.rs`) and sshd's exec, transfer and key auth. What is -left is the laptop — the claim on its own card (`tests/lancase`), the stream and -the ssh from the Mac over the cable (`tests/ssh-client-host`), and a netboot -spike that takes the stick out of the boot path — and all of it waits on -`issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md`. +Built and green under QEMU: the substrate (`kernel/src/pcidev/mod.rs`), the I219 +driver (`toyos-i219/`), netd's address from DHCP, the record stream +(`toyos-logstream/`) and sshd's exec, transfer and key auth. What is left is the +laptop — its own card (`tests/lancase`), the stream and the ssh from the Mac, and +a netboot spike — on `issues/kernel/a-32-bit-bar-needs-the-host-bridges-aperture.md`. Constraints a reader would otherwise pay to re-derive: @@ -35,19 +32,15 @@ Constraints a reader would otherwise pay to re-derive: - **ssh is the bench's transport and a real feature**: sshd is built on russh and the harness's client is russh too. No host ssh binary, no fork. - **Addressing is DHCP with a hostname**, and netd sends `toyos-t14` as the - host-name option — but **the name resolves to nothing on this LAN**, measured: - the T14's DHCP-served resolvers are the ISP's, and on the development Mac - `t14` resolves to the Tailscale address `100.92.92.12`, which only Ubuntu ever - holds. The address is read off the claimed PCI function instead - (`Driver::wire`): `enp0s31f6` at `192.168.1.46/24`, the Mac on `192.168.1.47`. - Wi-Fi is out — the AX210 needs a firmware image. + host-name option — but **the name resolves to nothing on this LAN**, measured, + so the metal loop reads the address off the claimed function (`Driver::wire`) + instead. Wi-Fi is out — the AX210 needs a firmware image. - **The I219 is an MSI part**, measured: `/proc/interrupts` names its interrupt `IR-PCI-MSI-0000:00:1f.6` and `msi_irqs/162` reads `mode=msi`. -- The I219 has a **32-bit BAR** (`bar0=0xbcf00000`): `pcidev`'s window allocator - places a BAR above everything firmware described, and below 4 GiB there is no - above — the platform's fixed MMIO is at `0xFEC00000`. Leaving the BAR where it - sits is not a way out either: the internal NVMe's `0xbce00000` is in the same - 2 MiB page, which is the only page size this kernel maps. +- The I219 has a **32-bit BAR** (`bar0=0xbcf00000`) and `pcidev` places a BAR + above everything firmware described, of which below 4 GiB there is none; + leaving it where it sits shares a 2 MiB page with the internal NVMe's + `0xbce00000`, which is the only page size this kernel maps. - **QEMU's `virtio-net-pci-non-transitional` on `q35` advertises no PCIe function-level reset** — measured, not assumed: `pcidev`'s refusal on that ground reddened every netd registration at once. So a re-claim is made safe by diff --git a/src/metalprofile.rs b/src/metalprofile.rs index 6cb324c159..971af2158c 100644 --- a/src/metalprofile.rs +++ b/src/metalprofile.rs @@ -334,6 +334,22 @@ mod sizing_tests { assert_eq!(members_per_boot(spendable / 2), 2); } + /// **`lan_hold` sleeps a bound this file prices.** The guest binary holds + /// the machine up for `toyos_tco::LEASE_BOUND_MS`, and the boot's own job + /// allowance has to outlast it or the runner's deadline cuts the window the + /// host reaches that machine across. + #[test] + fn the_window_lan_hold_sleeps_is_the_window_this_file_prices() { + let profile = Profile::load(root()).expect(PATH); + let hold = toyos_tco::LEASE_BOUND_MS; + let job = profile.row(&job_ms_row("lancase")).expect("lancase's own allowance"); + assert!(job.ceiling > hold, "{} against a {hold} ms hold", job.ceiling); + for name in ["lan.lancase.link_up_ms", "lan.lancase.lease_ms"] { + let row = profile.row(name).unwrap_or_else(|| panic!("{name} is priced")); + assert_eq!(row.ceiling, hold, "{name}"); + } + } + /// A boot whose allowance nobody wrote down is refused, not given the /// bound: the whole point of the row is that a list is cut to a number /// somebody committed. diff --git a/src/sourcegate.rs b/src/sourcegate.rs index 285660803e..ba0b934995 100644 --- a/src/sourcegate.rs +++ b/src/sourcegate.rs @@ -562,14 +562,11 @@ const HOST_SPAWNS: &[Spawn] = &[ Spawn { arg: "\"ping\"", sites: &[], - why: "the host's own ICMP client, in `src/metal.rs` alone and only across the window in \ - which the T14 is running neither of its operating systems. It is the one question \ - this repository can ask a metal boot while that boot is still up — everything \ - else it reads is on a stick, read minutes later from Ubuntu — and it is an \ - implementation of ICMP nobody here wrote, which is what makes it an oracle for the \ - stack under test rather than a second opinion from it. Outside the bar and \ - declared by nothing else: no build, boot or gate reaches it, and the metal loop \ - runs only when it is asked for", + why: "the host's own ICMP client, in `src/metal.rs` alone: the one question this \ + repository can ask a metal boot while that boot is still up, and an \ + implementation of ICMP nobody here wrote, which is what makes it an oracle for \ + the stack under test. Outside the bar and declared by nothing else: no build, \ + boot or gate reaches it, and the metal loop runs only when it is asked for", }, Spawn { arg: "\"/sbin/newfs_msdos\"", diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 509405f0eb..1c9512f05d 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -4223,6 +4223,12 @@ fn qemu_command( .arg("e1000e,netdev=net0"); } Nic::E1000eNoServer => { + // The hub is not slirp and takes no `hostfwd`, so a boot asking for + // one here is refused rather than booted without a forward. + assert!( + options.ssh_port.is_none(), + "this profile's cable is plugged into nothing, so no host port reaches the guest" + ); qemu.arg("-netdev") .arg("hubport,id=net0,hubid=0") .arg("-device") @@ -4230,6 +4236,10 @@ fn qemu_command( } } if let Some(at) = &options.wire_dump { + assert!( + !matches!(shape.nic, Nic::Absent), + "this profile carries no NIC, so there is no `net0` to dump frames off" + ); qemu.arg("-object") .arg(format!("filter-dump,id=wire,netdev=net0,file={}", at.display())); } diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index 901a30e8c5..5677d1cd53 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -611,10 +611,6 @@ ceiling = 30 ceiling_from = "as boot.testcases.stick_secs" # --- the cable: the boot that runs netd in front of the T14's own I219 --- -# The three boot facts below are the machine's own, off the last boot of this -# config; its claim was refused at the 32-bit BAR, which makes them facts about -# the boot rather than about the claim. The two `lan.` rows have none: netd -# never came up on any boot of it. [[number]] name = "boot.lancase.complete_ms" @@ -641,22 +637,23 @@ measured = 0 name = "boot.lancase.ping_secs" unit = "s" ceiling = 420 -ceiling_from = "toyos_build::metal::return_secs — the loop's own wait for the machine to answer ssh again, which bounds the window a reply can fall in and nothing narrower has been read. It is a cost and not a verdict: the window holds both of this machine's operating systems, so no ceiling on this number separates them, and what does is the wall-clock bracket in bootlog::host_second_inside_this_boot. A green reading tightens this to what a boot that answers actually costs" +ceiling_from = "toyos_build::metal::return_secs — the loop's own wait for the machine to answer ssh again, which bounds the window a reply can fall in" +measured = 57 [[number]] name = "list.lancase.job_ms" unit = "ms" ceiling = 22000 -ceiling_from = "lan_hold's own twenty-second window plus two seconds for the spawn and the exit record around it; the one member of this list is that window and nothing else" +ceiling_from = "toyos_tco::LEASE_BOUND_MS, which is lan_hold's whole sleep, plus two seconds for the spawn and the exit record around it" [[number]] name = "lan.lancase.link_up_ms" unit = "ms" ceiling = 20000 -ceiling_from = "lan_hold's twenty-second window: a link that comes up later than that is a link this boot never had, because the machine has already handed itself back" +ceiling_from = "toyos_tco::LEASE_BOUND_MS: a link that comes up later than lan_hold sleeps is a link this boot never had, because the machine has already handed itself back" [[number]] name = "lan.lancase.lease_ms" unit = "ms" ceiling = 20000 -ceiling_from = "as lan.lancase.link_up_ms, and netd's own dhcp::LEASE_BOUND is the same twenty seconds: a boot with no lease by then has already said so in its log" +ceiling_from = "as lan.lancase.link_up_ms, which is netd's own dhcp::LEASE_BOUND: a boot with no lease by then has already said so in its log" diff --git a/tests/toyos-rust-tests/Cargo.lock b/tests/toyos-rust-tests/Cargo.lock index 14285ec724..1b51427e87 100644 --- a/tests/toyos-rust-tests/Cargo.lock +++ b/tests/toyos-rust-tests/Cargo.lock @@ -1483,11 +1483,16 @@ dependencies = [ "sha2", "toyos 0.6.0", "toyos-abi 0.5.0", + "toyos-tco", "toyos-window", "ureq", "webpki-roots", ] +[[package]] +name = "toyos-tco" +version = "0.1.0" + [[package]] name = "toyos-window" version = "0.6.0" diff --git a/tests/toyos-rust-tests/Cargo.toml b/tests/toyos-rust-tests/Cargo.toml index d68b44d3b2..05cb4b2bd4 100644 --- a/tests/toyos-rust-tests/Cargo.toml +++ b/tests/toyos-rust-tests/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT OR Apache-2.0" toyos-abi = { path = "../../toyos-abi" } toyos = { path = "../../toyos" } toyos-window = { path = "../../userland/toyos-window" } +toyos-tco = { path = "../../toyos-tco" } libloading = { git = "https://github.com/ToyOSOrg/rust_libloading", branch = "toyos" } cpal = { git = "https://github.com/ToyOSOrg/cpal", branch = "toyos-0.18.0-sdk-0.2" } ureq = { version = "3", default-features = false, features = ["rustls-no-provider", "rustls-webpki-roots"] } diff --git a/tests/toyos-rust-tests/src/bin/lan_hold.rs b/tests/toyos-rust-tests/src/bin/lan_hold.rs index 6f783a37f6..56374b307c 100644 --- a/tests/toyos-rust-tests/src/bin/lan_hold.rs +++ b/tests/toyos-rust-tests/src/bin/lan_hold.rs @@ -1,18 +1,14 @@ //! Hold the boot open for as long as the host needs to reach this machine over -//! the cable, and exit. -//! -//! It asserts nothing: what it is evidence *for* is judged on the host, out of -//! the records netd wrote inside this window and out of whether the host's own -//! `ping` was answered while it was open. +//! the cable, and exit. It asserts nothing; the host judges the records netd +//! wrote inside this window. use std::thread::sleep; use std::time::Duration; -/// How long this machine stays up for the host. `list.lancase.job_ms` in -/// `tests/metal-profile.toml` is this number plus what a spawn costs, and -/// moving one without the other is a job list the runner's own deadline cuts -/// short. -const HOLD: Duration = Duration::from_secs(20); +/// **netd's own lease bound**, so this boot cannot hand the machine back before +/// netd has settled whether it has an address. `tests/metal-profile.toml`'s +/// `lancase` rows are priced against the same constant. +const HOLD: Duration = Duration::from_millis(toyos_tco::LEASE_BOUND_MS); fn main() { sleep(HOLD); diff --git a/tests/toyos.rs b/tests/toyos.rs index e1b35f0491..22f3ee6185 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -655,10 +655,10 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ // no clock in it. Fast with the UNMEASURED bootstrap marker until priced. ("lan_dhcp_lease", Sched::Parallel, Tier::Fast), // The same client on a wire with no server: it says it has no address and - // announces itself anyway. Its cost is netd's own twenty-second lease bound - // waited out in real time, so it is `Why::TimerAnchored` and belongs - // Nightly; a new name is bootstrapped Fast with the UNMEASURED marker - // because only the fast tier can replace one. + // announces itself anyway. Fast with the UNMEASURED marker, which only the + // fast tier carries; its verdict is timer-anchored, and + // `issues/build/a-timer-anchored-names-tier-is-decided-by-its-price.md` + // holds the relegation it owes. ("lan_no_lease", Sched::Parallel, Tier::Fast), ("netd_connection_caps", Sched::Parallel, Tier::Fast), // The netcase boot again: netd must not abort a listener on a ring flag its @@ -1313,12 +1313,6 @@ const METAL: &[(&str, metal::Metal)] = &[ metal::Metal::Runs { arms: METALDEVICECASE, judge: |b| devices::on_metal(b[0]) }, ), ( - // The cable. Under QEMU this name judges netd's DHCP client against the - // user-mode backend's server; here it judges the whole path — the - // kernel handing netd the T14's own I219, the link, a lease from the - // bench's router, and the development host's `ping` answered at the - // leased address in the window where the machine is running nothing but - // this image. "lan_dhcp_lease", metal::Metal::Runs { arms: LANCASE, judge: |b| lan::on_metal(b[0]) }, ), From 07eb6ec0fa16b6076aceece97cbc38609c3cd5d6 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 14:52:35 +0200 Subject: [PATCH 17/23] A reply is this boot's by a distance the clocks support, not by one second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skew the judge reads a host second through is a difference of two floored whole-second clocks taken at either end of an `ssh` round trip, so it carries error the bracket it fed decided inside. Run 31's reply — the one recorded false positive — sat exactly one second past `Rebooting.`, and one count of that error greened it. `bootlog::MARGIN` is the daylight a reply now needs on both sides: three seconds for the skew (a floored second either side of a round trip held to one), one for the floored readings the comparison is made between, and one so a refusal is a distance rather than a coin. Run 31's reply is refused at every skew from -3 to +3, by at least three seconds at the worst of them, and that sweep is a test. The bracket's lower edge is the record the caller anchors on and its upper edge is `Rebooting.`; a log carrying no dated `Rebooting.` record is refused by name instead of being widened to its last line, which deleted `record_unix_span` and the boot's first record with it. `record_unix_secs` is private again. The skew derivation leaves `Driver::wire` for `clock_skew`, pure and held by a test that feeds it a host clock stepping backwards across the round trip — the condition the guard exists for, which was a `u64` subtraction under `overflow-checks` and panicked the loop instead of refusing. `src/lan.rs` is the cable's "text and frames in, verdicts out", beside `src/metaldevices.rs`: the lease record's grammar, the driver's link-up time, the pcap walk and the name netd asks under, none of which could be unit-tested where they sat. The walk's direction is now held by a test that feeds it the server's own echo of the option and expects a refusal, so reading the destination port instead is seen. The name is held to netd's declaration through `bootlog::declares`, which sees a rustfmt-wrapped declaration the exact-substring scan it replaces could not. `ping_once` refuses a host whose `ping` reads `-W` as seconds, which is every host but the bench Mac. `Dhcp::write` takes no `self` it never read. `boot.lancase.ping_secs` carries no `measured`: that reading is the reply this branch declares belongs to the next operating system. The two premises the judge spends and nothing has measured — the T14's RTC unchanged across the reset, and the router repeating the lease across the two operating systems — are `issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md` with what would close each. Prose the review refused, deleted: the fixture's provenance, the negative control's derivation off an out-of-tree transcript, the RTC premise stated as fact, the claim that a round trip holds two clocks to a second, the `-W` platform contract a refusal now carries, `tests/lancase`'s copy of `tests/e1000case`'s netd comment, `lan_hold`'s third telling of a priced row, `record_unix_secs`'s second paragraph, and two the branch merely passed by — `BootOptions::ssh_port`'s unfinished sentence and `tests/metalcase`'s citation to a deleted issue file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- ...pends-two-premises-nothing-has-measured.md | 31 ++ src/bootlog.rs | 199 ++++++------ src/lan.rs | 287 ++++++++++++++++++ src/lib.rs | 1 + src/metal.rs | 72 ++++- tests/common/lan.rs | 150 +-------- tests/common/qemu.rs | 3 +- tests/lancase/system.toml | 4 - tests/metal-profile.toml | 1 - tests/metalcase/system.toml | 5 +- tests/toyos-rust-tests/src/bin/lan_hold.rs | 3 +- userland/netd/src/dhcp.rs | 3 +- 12 files changed, 500 insertions(+), 259 deletions(-) create mode 100644 issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md create mode 100644 src/lan.rs diff --git a/issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md b/issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md new file mode 100644 index 0000000000..3e71cfc8ca --- /dev/null +++ b/issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md @@ -0,0 +1,31 @@ +--- +status: open +kind: tooling +opened: 2026-09-13 +--- + +# The cable judge spends two premises nothing has measured + +`tests/common/lan.rs`'s `on_metal` decides whether a ping the metal loop saw was +this boot's, and two of its steps rest on facts no run has taken a reading of. + +**The T14's RTC is unchanged across the reset.** `src/metal.rs`'s `Driver::wire` +reads `date -u +%s` under Ubuntu before the flash and +`bootlog::host_second_inside_this_boot` spends that offset on records ToyOS wrote +after it. Nothing has measured that the two operating systems read the same +counter to the second, and a boot whose firmware or whose kernel moved it would +be judged against a clock that no longer exists. It would take one boot to +measure: a ToyOS record's wall clock read back against `date -u +%s` on the +machine after it, with the loop's own skew applied. Closed by that reading, or +by the judge ceasing to compare the two machines' clocks at all. + +**The router repeats the lease across the two operating systems.** The same +`on_metal` refuses a boot whose leased address is not the one the loop pinged, +and the address the loop pinged is the one Ubuntu held on the same MAC. On a +server that hands the MAC a different address under ToyOS the arm reds for a +fact about the router rather than about the boot — which is a red that names the +wrong thing, not a false green. Closed by a lancase run whose lease record and +whose `ping_addr` are compared, which is the first thing that run will print. + +Neither premise has an owner today: both were recorded in a pull request body, +which nothing reads back. diff --git a/src/bootlog.rs b/src/bootlog.rs index 0763248da9..716cc097c1 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -228,13 +228,9 @@ pub fn record_millis(line: &str) -> Option { /// The UTC second one record line carries, as seconds since the epoch. /// -/// **The only field in a log a host clock can be held against.** Everything -/// else a record says is measured from this boot's own start, and a host that -/// wants to know whether something it saw happened *while this boot was up* has -/// nothing to compare that with. `logd` writes the wall clock; the panel writes -/// none, and this answers `None` for those lines rather than reading the -/// milliseconds field as a date. -pub fn record_unix_secs(line: &str) -> Option { +/// `logd` writes the wall clock and the panel writes none, so a line without one +/// answers `None` rather than reading the milliseconds field as a date. +fn record_unix_secs(line: &str) -> Option { const EPOCH: &str = "1970-01-01"; let mut fields = line.strip_prefix('[')?.split_whitespace(); let day = crate::day::Day::parse(fields.next()?)?; @@ -250,17 +246,39 @@ pub fn record_unix_secs(line: &str) -> Option { u64::try_from(days * 86_400 + hours * 3_600 + minutes * 60 + seconds).ok() } -/// The span this boot's own records bracket: its first wall clock, and the one -/// on [`REBOOTING`] where the boot got that far. -fn record_unix_span(log: &str) -> Option<(u64, u64)> { - let first = log.lines().find_map(record_unix_secs)?; - let last = log.lines().rev().find_map(record_unix_secs)?; - let ended = log.lines().rfind(|l| l.contains(REBOOTING)).and_then(record_unix_secs); - Some((first, ended.unwrap_or(last))) +/// Whether `source` declares a constant whose value is exactly `rhs`, wrapped +/// or not. +/// +/// **The only way two crates nothing links are held to one spelling.** The line +/// must end `= ;`, so a name in a message or inside a longer literal is not +/// a declaration, and rustfmt's wrap of a value too wide for its line still is. +pub fn declares(source: &str, rhs: &str) -> bool { + let tail = format!("= {rhs};"); + let mut joined = String::new(); + for line in source.lines() { + let line = line.trim_end(); + if joined.ends_with('=') { + joined.push(' '); + joined.push_str(line.trim_start()); + continue; + } + joined.push('\n'); + joined.push_str(line); + } + joined.lines().any(|line| line.trim_end().ends_with(&tail)) } +/// The daylight a host second needs on either side before it is this boot's. +/// +/// **A judge reading whole seconds does not get to decide at one.** `skew` is a +/// difference of two floored clocks across a round trip its reader holds to a +/// second, which is three of these; the second the host read and the second the +/// record carries are floored too, which is the fourth; and the fifth is what +/// makes a refusal a distance rather than a coin. +pub const MARGIN: u64 = 5; + /// Whether a second on the *host's* clock fell inside the boot this log is of, -/// at or after the record `after` names. +/// clear of [`MARGIN`] on both the record `after` names and the reset. /// /// **The records are the one place a host clock and a boot's clock meet.** /// `skew` is this machine's clock minus the host's as the caller measured the @@ -273,33 +291,33 @@ pub fn host_second_inside_this_boot( after: &str, at: u64, ) -> Result<(), String> { - let (first, ended) = record_unix_span(log).ok_or_else(|| { - "this log carries no record with a wall clock on it, so there is nothing to hold the \ - host's own clock against" - .to_string() + let dated = |line: Option<&str>| line.and_then(record_unix_secs).map(i128::from); + let began = dated(log.lines().find(|l| l.contains(after))) + .ok_or_else(|| format!("this log carries no dated {after:?} record"))?; + let ended = dated(log.lines().rfind(|l| l.contains(REBOOTING))).ok_or_else(|| { + format!( + "this log carries no dated {REBOOTING:?} record, so nothing in it says when this boot \ + handed the machine back" + ) })?; - let at = at - .checked_add_signed(skew) - .ok_or_else(|| format!("a host second of {at} and a skew of {skew} is no second at all"))?; - if at < first || at > ended { + let at = i128::from( + at.checked_add_signed(skew) + .ok_or_else(|| format!("a host second of {at} and a skew of {skew} is no second"))?, + ); + if at - began < i128::from(MARGIN) { return Err(format!( - "the host saw it at {at} on this machine's clock, outside the {first}..{ended} this \ - boot's own records bracket: it came {} s {} the boot, so it belongs to the \ - operating system on the other side of it", - if at < first { first - at } else { at - ended }, - if at < first { "before" } else { "after" }, + "the host saw it at {at} on this machine's clock and this boot's {after:?} record is \ + at {began}, {} s apart: nothing closer than {MARGIN} s past that record is this \ + boot's, because these clocks are whole seconds", + at - began )); } - let after_at = log - .lines() - .find(|l| l.contains(after)) - .and_then(record_unix_secs) - .ok_or_else(|| format!("this boot has no {after:?} record carrying a wall clock"))?; - if at < after_at { + if ended - at < i128::from(MARGIN) { return Err(format!( - "the host saw it at {at} on this machine's clock, {} s before this boot's {after:?} \ - record at {after_at}", - after_at - at + "the host saw it at {at} on this machine's clock and this boot's {REBOOTING:?} record \ + is at {ended}, {} s apart: nothing closer than {MARGIN} s before that record is this \ + boot's, so it belongs to the operating system on the other side of the reset", + ended - at )); } Ok(()) @@ -353,29 +371,6 @@ mod tests { assert_eq!(verdict(""), Err(Unfit::NoBootRecord)); } - /// Whether `source` declares a constant whose value is exactly `rhs`. - /// - /// Anchored to the declaration, so a name that appears in a message or in - /// a longer literal is not one: the line must end `= ;`. - /// A declaration whose value is `rhs`, wrapped or not: rustfmt puts a value - /// too wide for the line under the `=`, and a scan that could not see one - /// would pass by finding nothing to hold. - fn declares(source: &str, rhs: &str) -> bool { - let tail = format!("= {rhs};"); - let mut joined = String::new(); - for line in source.lines() { - let line = line.trim_end(); - if joined.ends_with('=') { - joined.push(' '); - joined.push_str(line.trim_start()); - continue; - } - joined.push('\n'); - joined.push_str(line); - } - joined.lines().any(|line| line.trim_end().ends_with(&tail)) - } - #[test] fn only_a_declaration_of_the_whole_value_counts() { assert!(declares("const A: &str = \"x\";", "\"x\"")); @@ -512,7 +507,6 @@ mod record_time_tests { assert_eq!(last_record_millis("nothing\n"), None); } - /// One boot's records, verbatim from a stick the T14 wrote. const BOOT: &str = concat!( "[2026-09-08 16:08:21 0.000 cpu0 boot] panic console: armed 1920x1080 stride=1920 \ format=1 at 0x4000000000\n", @@ -520,49 +514,70 @@ mod record_time_tests { "[2026-09-08 16:08:44 23.340 cpu1] Rebooting.\n", ); + /// That boot's first record, which every second below is placed against. + fn first() -> u64 { + record_unix_secs(BOOT.lines().next().expect("a record")).expect("a wall clock") + } + + /// **[`MARGIN`] decides both edges**, and one second short of either is + /// refused rather than read as inside. #[test] - fn a_second_inside_the_boot_and_after_the_named_record_is_this_boots() { - let (first, ended) = record_unix_span(BOOT).expect("a span"); - assert_eq!(ended - first, 23); - assert_eq!(host_second_inside_this_boot(BOOT, 0, "Boot: complete", first + 2), Ok(())); + fn a_second_clear_of_this_boots_records_by_the_margin_is_this_boots() { + let first = first(); + for at in [first + MARGIN + 1, first + 23 - MARGIN] { + assert_eq!(host_second_inside_this_boot(BOOT, 0, "Boot: complete", at), Ok(()), "{at}"); + } + let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first + MARGIN) + .expect_err("a second short of the margin past the record it is anchored on"); + assert!(why.contains(&format!("closer than {MARGIN} s past")), "{why}"); + let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first + 24 - MARGIN) + .expect_err("a second short of the margin before the reset"); + assert!(why.contains(&format!("closer than {MARGIN} s before")), "{why}"); } - /// **The reply the boot above was refused on.** The loop writes the second - /// its own clock read when the probe answered, 57 s into a window that - /// opens no earlier than the run itself; that run's first line is 33 s - /// before this boot's first record, so the earliest that reply can have - /// been is one second past `Rebooting.` + /// **The one reply this judge exists to refuse.** The loop wrote it 57 s + /// into a window opening no earlier than its own run, whose first line is + /// 33 s before this boot's first record; it is anchored here on the earliest + /// record the boot carries, which is the most favourable anchor there is, + /// and no skew the measurement can be wrong by brings it inside. #[test] - fn the_reply_57_s_into_that_window_is_the_next_operating_systems() { - let (first, ended) = record_unix_span(BOOT).expect("a span"); - let window_from = first - 33; - let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", window_from + 57) - .expect_err("57 s into that window is past this boot's reset"); - assert!(why.contains(&format!("{first}..{ended}")), "{why}"); - assert!(why.contains("1 s after the boot"), "{why}"); + fn that_reply_is_refused_at_every_skew_the_measurement_can_be_wrong_by() { + let earliest_window = first() - 33; + for skew in -3..=3 { + let why = + host_second_inside_this_boot(BOOT, skew, "Boot: complete", earliest_window + 57) + .expect_err("a skew of this size does not place that reply inside the boot"); + assert!(why.contains(&format!("closer than {MARGIN} s before")), "{skew}: {why}"); + } } + /// **A boot that never reached its reset brackets nothing**, and neither + /// does one that never wrote the record the caller anchors on. #[test] - fn a_second_before_the_named_record_is_refused_by_that_record() { - let (first, _) = record_unix_span(BOOT).expect("a span"); - let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first) - .expect_err("the boot had not completed yet"); - assert!(why.contains("1 s before"), "{why}"); - let why = host_second_inside_this_boot(BOOT, 0, "netd: DHCP: lease ", first + 2) + fn a_log_missing_either_record_is_refused_rather_than_widened() { + let first = first(); + let unfinished: String = BOOT.lines().take(2).map(|l| format!("{l}\n")).collect(); + let why = host_second_inside_this_boot(&unfinished, 0, "Boot: complete", first + 10) + .expect_err("a log with no reset says nothing about when this boot ended"); + assert!(why.contains(&format!("no dated {REBOOTING:?} record")), "{why}"); + let why = host_second_inside_this_boot(BOOT, 0, "netd: DHCP: lease ", first + 10) .expect_err("this boot took no lease"); - assert!(why.contains("no \"netd: DHCP: lease \" record"), "{why}"); + assert!(why.contains("no dated \"netd: DHCP: lease \" record"), "{why}"); + let why = host_second_inside_this_boot("[1.000 cpu0] first\n", 0, "first", 0) + .expect_err("a panel log carries no wall clock"); + assert!(why.contains("no dated \"first\" record"), "{why}"); } /// **The measured skew is the whole of what places a host second.** The - /// same reading is inside the boot on one clock and the next operating - /// system's on another, and nothing but the measurement separates them. + /// same reading is this boot's on one clock and the next operating system's + /// on another. #[test] fn the_measured_skew_is_what_the_host_second_is_read_through() { - let (first, _) = record_unix_span(BOOT).expect("a span"); - assert_eq!(host_second_inside_this_boot(BOOT, 30, "Boot: complete", first - 28), Ok(())); - let why = host_second_inside_this_boot(BOOT, -30, "Boot: complete", first + 2) + let first = first(); + assert_eq!(host_second_inside_this_boot(BOOT, 30, "Boot: complete", first - 20), Ok(())); + let why = host_second_inside_this_boot(BOOT, -30, "Boot: complete", first + 10) .expect_err("thirty seconds the other way is before this boot began"); - assert!(why.contains("28 s before the boot"), "{why}"); + assert!(why.contains(&format!("closer than {MARGIN} s past")), "{why}"); } /// The panel writes no wall clock, and its milliseconds field must not be @@ -574,9 +589,5 @@ mod record_time_tests { assert_eq!(record_unix_secs("not a record"), None); assert_eq!(record_unix_secs("[2026-09-08 25:00:00 0.000 cpu0] x"), None); assert_eq!(record_unix_secs("[2026-02-31 10:00:00 0.000 cpu0] x"), None); - assert_eq!(record_unix_span("[1.000 cpu0] first\n"), None); - let why = host_second_inside_this_boot("[1.000 cpu0] first\n", 0, "x", 0) - .expect_err("a panel log carries no wall clock"); - assert!(why.contains("no record with a wall clock"), "{why}"); } } diff --git a/src/lan.rs b/src/lan.rs new file mode 100644 index 0000000000..9929e01709 --- /dev/null +++ b/src/lan.rs @@ -0,0 +1,287 @@ +//! What a boot's log and its wire say about the address this machine took from +//! its network. +//! +//! **Text and frames in, verdicts out.** Nothing here touches a machine: the +//! QEMU arm and the T14 arm in `tests/common/lan.rs` read their answers through +//! this, so a guest and a laptop cannot be judged by different grammars. + +#![forbid(unsafe_code)] + +use std::net::Ipv4Addr; + +/// The records both arms are written against, spelled once. +pub const MAC: &str = "netd: MAC "; +pub const LEASE: &str = "netd: DHCP: lease "; +pub const LINK_UP: &str = "netd: I219: link up at "; +pub const READY: &str = "netd: ready, at most "; +pub const NO_LEASE: &str = "netd: DHCP: no lease as "; + +/// The name this machine asks its network to record for it, held to netd's own +/// `dhcp::HOSTNAME` by [`tests::netd_declares_the_name_this_module_spells`]. +pub const HOSTNAME: &str = "toyos-t14"; + +/// RFC 2132 §3.14: the kind, the length, and the name. +fn host_name_option() -> Vec { + let mut option = vec![12, HOSTNAME.len() as u8]; + option.extend_from_slice(HOSTNAME.as_bytes()); + option +} + +/// One lease, as the record carries it. +#[derive(Debug, PartialEq, Eq)] +pub struct Lease { + pub address: Ipv4Addr, + pub prefix: u8, + pub server: Ipv4Addr, + pub gateway: Ipv4Addr, + pub dns: Vec, + /// Milliseconds between netd starting and the lease landing. + pub ms: u64, +} + +/// The lease record, read out of a boot's log. +/// +/// Anchored on the record's own words rather than on positions, so a line that +/// grows a field still reads and one that loses a field is refused by name. +pub fn lease_in(text: &str) -> Result { + let line = text + .lines() + .find(|l| l.contains(LEASE)) + .ok_or_else(|| format!("no {LEASE:?} record: this boot took no address from its network"))?; + let unreadable = |what: &str| format!("{line:?} carries no {what}"); + let after = |head: &str, tail: &str| -> Result { + let (_, rest) = line.split_once(head).ok_or_else(|| unreadable(head))?; + let (got, _) = rest.split_once(tail).ok_or_else(|| unreadable(tail))?; + Ok(got.to_string()) + }; + let address = |what: &'static str, got: String| -> Result { + got.parse().map_err(|_| format!("{line:?} reads {got:?} where {what} belongs")) + }; + let cidr = after(LEASE, " from ")?; + let (host, prefix) = cidr.split_once('/').ok_or_else(|| unreadable("an address/prefix"))?; + let mut dns = Vec::new(); + for server in after(", dns [", "]")?.split_whitespace() { + dns.push(address("a resolver", server.to_string())?); + } + Ok(Lease { + address: address("this machine's address", host.to_string())?, + prefix: prefix.parse().map_err(|_| unreadable("a prefix length"))?, + server: address("the server's address", after(" from ", ",")?)?, + gateway: address("the gateway's address", after(", gateway ", ",")?)?, + dns, + ms: after("], ", " ms after netd came up")? + .parse() + .map_err(|_| unreadable("a millisecond count"))?, + }) +} + +/// How long after the driver came up the link did, out of the driver's own +/// record. +pub fn link_up_ms(text: &str) -> Result { + let line = text.lines().find(|l| l.contains(LINK_UP)).ok_or_else(|| { + format!("no {LINK_UP:?} record: this boot's card never reported a link") + })?; + let (_, rest) = line.split_once(", ").ok_or_else(|| { + format!("{line:?} says nothing about when the link came up, so the card was already up") + })?; + rest.split_once(" ms after the driver came up") + .ok_or_else(|| format!("{line:?} carries no link-up time"))? + .0 + .parse() + .map_err(|_| format!("{line:?} carries no readable link-up time")) +} + +/// **The one place the host-name option can be read.** A server that ignores it +/// answers the same lease either way, so the frames the client sent are the only +/// evidence that it asked at all — and `filter-dump` records both directions, so +/// a frame counts only where it is IPv4 over UDP *leaving* the client's own port. +pub fn asked_under_its_own_name(pcap: &[u8]) -> Result<(), String> { + const LITTLE_ENDIAN_PCAP: [u8; 4] = [0xd4, 0xc3, 0xb2, 0xa1]; + const GLOBAL_HEADER: usize = 24; + const RECORD_HEADER: usize = 16; + /// Ethernet, an IPv4 header carrying no options, and UDP. + const HEADERS: usize = 14 + 20 + 8; + if pcap.get(..LITTLE_ENDIAN_PCAP.len()) != Some(&LITTLE_ENDIAN_PCAP[..]) { + return Err("this file does not open with a little-endian pcap header".to_string()); + } + let option = host_name_option(); + let (mut at, mut sent, mut asked) = (GLOBAL_HEADER, 0usize, false); + while let Some(header) = pcap.get(at..at + RECORD_HEADER) { + let len = u32::from_le_bytes(header[8..12].try_into().expect("four bytes")) as usize; + let frame = pcap.get(at + RECORD_HEADER..at + RECORD_HEADER + len).ok_or_else(|| { + format!("this pcap's record at byte {at} names {len} bytes the file has not") + })?; + at += RECORD_HEADER + len; + if frame.len() > HEADERS + && frame[12..14] == [0x08, 0x00] + && frame[23] == 17 + && frame[34..36] == [0, 68] + { + sent += 1; + asked |= frame.windows(option.len()).any(|w| w == option); + } + } + if !asked { + return Err(format!( + "none of the {sent} frame(s) this client sent a DHCP server carries the host-name \ + option {option:?}" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LEASED: &str = "[2026-09-08 16:08:23 2.100 cpu0] netd: DHCP: lease 10.0.2.15/24 from \ + 10.0.2.2, gateway 10.0.2.2, dns [10.0.2.3 10.0.2.4], 412 ms after netd \ + came up"; + + /// Nothing links the two crates: netd is a `no_std`-shaped userland binary + /// and this is the build system, so the name both ends spell is held to + /// netd's own declaration by reading its source. + #[test] + fn netd_declares_the_name_this_module_spells() { + let at = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("userland/netd/src/dhcp.rs"); + let source = std::fs::read_to_string(&at).expect("netd's dhcp module"); + assert!( + crate::bootlog::declares(&source, &format!("b\"{HOSTNAME}\"")), + "{} declares no constant equal to b\"{HOSTNAME}\"", + at.display() + ); + } + + /// **Every field the lease decided, typed**, so a record that grew a field + /// still reads and one that lost a field is refused by the field's name. + #[test] + fn a_lease_record_is_read_field_by_field() { + assert_eq!( + lease_in(LEASED), + Ok(Lease { + address: Ipv4Addr::new(10, 0, 2, 15), + prefix: 24, + server: Ipv4Addr::new(10, 0, 2, 2), + gateway: Ipv4Addr::new(10, 0, 2, 2), + dns: vec![Ipv4Addr::new(10, 0, 2, 3), Ipv4Addr::new(10, 0, 2, 4)], + ms: 412, + }) + ); + // A lease with no resolvers at all is a lease, and an empty list is not + // a missing field. + let none = LEASED.replace("10.0.2.3 10.0.2.4", ""); + assert!(lease_in(&none).expect("a lease").dns.is_empty()); + } + + #[test] + fn a_record_missing_a_field_is_refused_by_that_fields_name() { + assert!(lease_in("nothing here\n").unwrap_err().contains("took no address")); + for (cut, says) in [ + (", gateway 10.0.2.2", "gateway"), + (", dns [10.0.2.3 10.0.2.4]", "dns ["), + ("/24", "an address/prefix"), + ] { + let why = lease_in(&LEASED.replace(cut, "")).expect_err(cut); + assert!(why.contains(says), "{cut}: {why}"); + } + // A field that is there and is not what it claims to be. + let why = lease_in(&LEASED.replace("gateway 10.0.2.2", "gateway enp0s31f6")) + .expect_err("an interface name is not a gateway"); + assert!(why.contains("the gateway's address"), "{why}"); + let why = lease_in(&LEASED.replace("dns [10.0.2.3", "dns [fe80::1")) + .expect_err("an IPv6 resolver is not one this record can carry"); + assert!(why.contains("a resolver"), "{why}"); + let why = lease_in(&LEASED.replace("412 ms", "later ms")).expect_err("no milliseconds"); + assert!(why.contains("a millisecond count"), "{why}"); + } + + #[test] + fn a_link_that_was_already_up_is_told_from_one_that_came_up() { + let came_up = format!("[x] {LINK_UP}1000 Mb/s, 2400 ms after the driver came up"); + assert_eq!(link_up_ms(&came_up), Ok(2_400)); + assert!(link_up_ms("nothing\n").unwrap_err().contains("never reported a link")); + let why = link_up_ms(&format!("[x] {LINK_UP}1000 Mb/s")).expect_err("no comma"); + assert!(why.contains("already up"), "{why}"); + } + + /// One pcap record per frame, with the timestamps a reader here never looks + /// at left zero. + fn pcap(frames: &[Vec]) -> Vec { + let mut out = vec![0xd4, 0xc3, 0xb2, 0xa1]; + out.extend_from_slice(&[0u8; 20]); + for frame in frames { + out.extend_from_slice(&[0u8; 8]); + out.extend_from_slice(&(frame.len() as u32).to_le_bytes()); + out.extend_from_slice(&(frame.len() as u32).to_le_bytes()); + out.extend_from_slice(frame); + } + out + } + + /// One frame: an ethertype, an IPv4 protocol, the two UDP ports, and a + /// payload. + fn frame(ethertype: [u8; 2], protocol: u8, src: u16, dst: u16, payload: &[u8]) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 8]; + frame[12..14].copy_from_slice(ðertype); + frame[23] = protocol; + frame[34..36].copy_from_slice(&src.to_be_bytes()); + frame[36..38].copy_from_slice(&dst.to_be_bytes()); + frame.extend_from_slice(payload); + frame + } + + fn from_client(payload: &[u8]) -> Vec { + frame([0x08, 0x00], 17, 68, 67, payload) + } + + #[test] + fn a_frame_carrying_the_option_out_of_the_clients_own_port_is_the_evidence() { + let asked = pcap(&[from_client(&host_name_option())]); + assert_eq!(asked_under_its_own_name(&asked), Ok(())); + } + + /// **The server's own echo of the option is not the client asking.** A walk + /// keyed on the destination port would count the frame below and report the + /// question as asked when nothing asked it. + #[test] + fn only_the_direction_leaving_the_client_counts() { + let option = host_name_option(); + let echoed = frame([0x08, 0x00], 17, 67, 68, &option); + let why = asked_under_its_own_name(&pcap(std::slice::from_ref(&echoed))) + .expect_err("a server's reply is not this client asking"); + assert!(why.contains("none of the 0 frame(s)"), "{why}"); + // The same echo beside a client frame that asked nothing. + let why = asked_under_its_own_name(&pcap(&[echoed, from_client(&[53, 1, 1])])) + .expect_err("the one frame the client sent carried no name"); + assert!(why.contains("none of the 1 frame(s)"), "{why}"); + } + + #[test] + fn a_frame_that_is_not_ipv4_over_udp_carries_no_option_here() { + let option = host_name_option(); + // ARP, and IPv4 carrying TCP: both hold the bytes and neither is a + // DHCP request. + for stray in [ + frame([0x08, 0x06], 17, 68, 67, &option), + frame([0x08, 0x00], 6, 68, 67, &option), + ] { + let why = asked_under_its_own_name(&pcap(&[stray])).expect_err("not a DHCP frame"); + assert!(why.contains("none of the 0 frame(s)"), "{why}"); + } + // A frame with the headers and no payload at all. + let bare = from_client(&[]); + assert!(asked_under_its_own_name(&pcap(&[bare])).is_err()); + } + + #[test] + fn a_file_that_is_not_a_pcap_and_a_record_past_its_end_are_refused_by_name() { + assert!(asked_under_its_own_name(b"").unwrap_err().contains("little-endian pcap")); + assert!( + asked_under_its_own_name(b"\xa1\xb2\xc3\xd4rest").unwrap_err().contains("pcap header") + ); + let mut truncated = pcap(&[from_client(&host_name_option())]); + truncated.truncate(truncated.len() - 4); + let why = asked_under_its_own_name(&truncated).expect_err("the last record is cut short"); + assert!(why.contains("bytes the file has not"), "{why}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 727d003f5c..e370d731f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod image; /// but its own tests. #[cfg(test)] pub mod kernelkeys; +pub mod lan; pub mod libc; pub mod mergehealth; pub mod metal; diff --git a/src/metal.rs b/src/metal.rs index 0de4bd7f55..0bfbbf00bb 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -60,7 +60,6 @@ const POLL_SECS: u64 = 5; const PING_EVERY_SECS: u64 = 1; -/// Milliseconds, which is what `-W` means to the macOS `ping` this loop runs. const PING_WAIT_MS: u64 = 1_000; /// How long the address has to answer *nothing* before a reply counts as this @@ -995,6 +994,30 @@ fn brief_address(iface: &str, text: &str) -> Result .map_err(|_| format!("{iface}'s address reads {cidr:?}")) } +/// This machine's clock minus this host's, from `date -u +%s` and the host's own +/// reading at each end of that round trip. +/// +/// **A whole second either side and a round trip in between**, which is where +/// [`crate::bootlog::MARGIN`]'s first three seconds come from; a round trip +/// longer than that, or a host clock that stepped backwards inside it, is +/// refused rather than spent. +fn clock_skew(before: u64, said: &str, after: u64) -> Result { + let took = after.checked_sub(before).ok_or_else(|| { + format!("this host's clock read {before} before the machine's and {after} after it") + })?; + if took > 1 { + return Err(format!( + "this host's clock read {before} before the machine's and {after} after it, and a \ + skew read across {took} s is worth less than the judge it feeds" + )); + } + let machine: i64 = said.trim().parse().map_err(|_| format!("`date -u +%s` said {said:?}"))?; + i64::try_from(before) + .ok() + .and_then(|before| machine.checked_sub(before)) + .ok_or_else(|| format!("`date -u +%s` said {said:?} against a host second of {before}")) +} + /// The first reply after the silence: how far into the window it came, and when /// it came on this host's clock. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1070,6 +1093,15 @@ fn unix_now() -> u64 { /// answers.** A spawn that fails is this host's failing, and reporting it as /// silence would red the boot for a binary the host does not have. fn ping_once(addr: std::net::Ipv4Addr) -> Result { + // `-W` is milliseconds to macOS's `ping` and seconds to every other's, so a + // host this argument has not been read against is refused by name rather + // than waiting a thousand seconds a probe. + if !cfg!(target_os = "macos") { + return Err(format!( + "`ping -W {PING_WAIT_MS}` means milliseconds on macOS and seconds on {}", + std::env::consts::OS + )); + } Command::new("ping") .args(["-n", "-c", "1", "-W", &PING_WAIT_MS.to_string(), &addr.to_string()]) .stdin(Stdio::null()) @@ -1157,28 +1189,17 @@ impl Driver { &format!("ip -4 -brief addr show {}", shell_word(iface)), ) .map_err(|e| bad(e.to_string()))?; - // **The host's own clock at both ends of the read**, so what bounds the - // two clocks' disagreement is this round trip and not the width of some - // window. Read under Ubuntu and spent on ToyOS's records because both - // operating systems keep this machine's one RTC. + // The host's own clock at both ends of the read, so what the answer is + // worth is this round trip and not the width of some window. let before = unix_now(); let said = self .ssh("reading the machine's own clock", "date -u +%s") .map_err(|e| bad(e.to_string()))?; - let after = unix_now(); - if after - before > 1 { - return Err(bad(format!( - "this host's clock read {before} before that and {after} after it, so the two \ - clocks cannot be held to a second" - ))); - } - let machine: i64 = - said.trim().parse().map_err(|_| bad(format!("`date -u +%s` said {said:?}")))?; Ok(Wire { iface: iface.to_string(), addr: brief_address(iface, &brief).map_err(bad)?, mac: mac.trim().to_ascii_lowercase(), - skew: machine - before as i64, + skew: clock_skew(before, &said, unix_now()).map_err(bad)?, }) } @@ -2338,6 +2359,27 @@ mod tests { assert!(brief_address("enp0s31f7", many).unwrap_err().contains("ip -4 -brief")); } + /// **A host clock that stepped backwards across the round trip is the + /// condition this guard exists for**, and it is a refusal by name and not + /// the subtraction overflow it would otherwise be. + #[test] + fn a_skew_read_across_a_clock_this_host_moved_is_refused() { + assert_eq!(clock_skew(1_757_347_700, "1757347703\n", 1_757_347_700), Ok(3)); + assert_eq!(clock_skew(1_757_347_700, "1757347698", 1_757_347_701), Ok(-2)); + + let why = clock_skew(1_757_347_701, "1757347700", 1_757_347_700) + .expect_err("the second read is before the first"); + assert!(why.contains("1757347701 before"), "{why}"); + let why = clock_skew(1_757_347_700, "1757347700", 1_757_347_705) + .expect_err("five seconds is no round trip to spend a judge on"); + assert!(why.contains("across 5 s"), "{why}"); + let why = clock_skew(1_757_347_700, "Tue Sep 9 10:00:00 UTC 2026", 1_757_347_700) + .expect_err("a date is not a count of seconds"); + assert!(why.contains("`date -u +%s` said"), "{why}"); + // A machine answering a second no host second can be subtracted from. + assert!(clock_skew(1_757_347_700, &i64::MIN.to_string(), 1_757_347_700).is_err()); + } + #[test] fn an_nvme_node_cannot_be_written_down() { for name in ["/dev/nvme0n1", "/dev/nvme0n1p3", "/dev/sda1", "/dev/sdaa", "/dev/SDA", "sda"] diff --git a/tests/common/lan.rs b/tests/common/lan.rs index 43e6e4f591..d156d518ac 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -5,9 +5,14 @@ //! `Backend::None`, so what crosses to the stick is the kernel's log — into //! which netd's `say!` writes, being a `write` to a console object. +use std::net::Ipv4Addr; use std::path::Path; use toyos_build::bootlog; +use toyos_build::lan::{ + asked_under_its_own_name, lease_in, link_up_ms, Lease, HOSTNAME, LEASE, LINK_UP, MAC, NO_LEASE, + READY, +}; use toyos_build::metalprofile::Profile; use super::metal; @@ -29,10 +34,10 @@ const QEMU_CONFIG: &str = "tests/e1000case"; /// What QEMU's user-mode backend leases, and what it says about the network it /// leases on. Its own defaults, not this repository's: they are the oracle. -const SLIRP_ADDRESS: std::net::Ipv4Addr = std::net::Ipv4Addr::new(10, 0, 2, 15); +const SLIRP_ADDRESS: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 15); const SLIRP_PREFIX: u8 = 24; -const SLIRP_ROUTER: &str = "10.0.2.2"; -const SLIRP_DNS: &str = "10.0.2.3"; +const SLIRP_ROUTER: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 2); +const SLIRP_DNS: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 3); /// The card the T14 arm claims, as the kernel and the manifest spell it. const ID: &str = "8086:15fc"; @@ -41,93 +46,6 @@ const ID: &str = "8086:15fc"; /// cable the metal loop reaches this boot over while it runs. pub const NIC: &str = "0000:00:1f.6"; -/// The records this pair of arms is written against, spelled once. -const MAC: &str = "netd: MAC "; -const LEASE: &str = "netd: DHCP: lease "; -const LINK_UP: &str = "netd: I219: link up at "; -const READY: &str = "netd: ready, at most "; -const NO_LEASE: &str = "netd: DHCP: no lease as "; - -/// The name this machine asks its network to record for it — netd's own -/// `dhcp::HOSTNAME`, which [`netd_spells_this_name`] holds this to, and which -/// the record above and the option below are both built from. -const HOSTNAME: &str = "toyos-t14"; - -/// RFC 2132 §3.14: the kind, the length, and the name. -fn host_name_option() -> Vec { - let mut option = vec![12, HOSTNAME.len() as u8]; - option.extend_from_slice(HOSTNAME.as_bytes()); - option -} - -fn netd_spells_this_name() -> Result<(), String> { - let at = super::compile::repo_root().join("userland/netd/src/dhcp.rs"); - let source = std::fs::read_to_string(&at).map_err(|e| format!("{}: {e}", at.display()))?; - let declared = format!("const HOSTNAME: &[u8] = b\"{HOSTNAME}\";"); - if source.contains(&declared) { - return Ok(()); - } - Err(format!("{} declares no `{declared}`", at.display())) -} - -/// One lease, as the record carries it. -#[derive(Debug, PartialEq, Eq)] -pub struct Lease { - pub address: std::net::Ipv4Addr, - pub prefix: u8, - pub server: String, - pub gateway: String, - pub dns: Vec, - /// Milliseconds between netd starting and the lease landing. - pub ms: u64, -} - -/// The lease record, read out of a boot's log. -/// -/// Anchored on the record's own words rather than on positions, so a line that -/// grows a field still reads and one that loses a field is refused by name. -pub fn lease_in(text: &str) -> Result { - let line = text - .lines() - .find(|l| l.contains(LEASE)) - .ok_or_else(|| format!("no {LEASE:?} record: this boot took no address from its network"))?; - let unreadable = |what: &str| format!("{line:?} carries no {what}"); - let after = |head: &str, tail: &str| -> Result { - let (_, rest) = line.split_once(head).ok_or_else(|| unreadable(head))?; - let (got, _) = rest.split_once(tail).ok_or_else(|| unreadable(tail))?; - Ok(got.to_string()) - }; - let cidr = after(LEASE, " from ")?; - let (address, prefix) = cidr.split_once('/').ok_or_else(|| unreadable("an address/prefix"))?; - let dns = after(", dns [", "]")?; - Ok(Lease { - address: address.parse().map_err(|_| unreadable("an IPv4 address"))?, - prefix: prefix.parse().map_err(|_| unreadable("a prefix length"))?, - server: after(" from ", ",")?, - gateway: after(", gateway ", ",")?, - dns: dns.split_whitespace().map(str::to_string).collect(), - ms: after("], ", " ms after netd came up")? - .parse() - .map_err(|_| unreadable("a millisecond count"))?, - }) -} - -/// How long after the driver came up the link did, out of the driver's own -/// record. -pub fn link_up_ms(text: &str) -> Result { - let line = text.lines().find(|l| l.contains(LINK_UP)).ok_or_else(|| { - format!("no {LINK_UP:?} record: this boot's card never reported a link") - })?; - let (_, rest) = line.split_once(", ").ok_or_else(|| { - format!("{line:?} says nothing about when the link came up, so the card was already up") - })?; - rest.split_once(" ms after the driver came up") - .ok_or_else(|| format!("{line:?} carries no link-up time"))? - .0 - .parse() - .map_err(|_| format!("{line:?} carries no readable link-up time")) -} - /// The T14's judge: the claim, the card, the lease, and the host's own ping. pub fn on_metal(back: &metal::Readback) -> Result<(), String> { let profile = Profile::load(&super::compile::repo_root()).map_err(|why| why.to_string())?; @@ -246,7 +164,6 @@ pub fn lan_dhcp_lease( _c_bins: &[(String, Vec)], _rust_bins: &[(String, Vec)], ) -> Result<(), String> { - netd_spells_this_name()?; let case = super::compile::repo_root().join(QEMU_CONFIG); let dump = wire_dump(); let options = BootOptions { @@ -272,9 +189,9 @@ pub fn lan_dhcp_lease( let want = Lease { address: SLIRP_ADDRESS, prefix: SLIRP_PREFIX, - server: SLIRP_ROUTER.to_string(), - gateway: SLIRP_ROUTER.to_string(), - dns: vec![SLIRP_DNS.to_string()], + server: SLIRP_ROUTER, + gateway: SLIRP_ROUTER, + dns: vec![SLIRP_DNS], ms: lease.ms, }; if lease != want { @@ -292,7 +209,9 @@ pub fn lan_dhcp_lease( lease.ms ); log.must_be_clean()?; - asked_under_its_own_name(&frames) + asked_under_its_own_name(&frames)?; + eprintln!(" [lan] the client asked under its own name on the wire"); + Ok(()) } /// The client on a wire with nothing at the other end. @@ -305,7 +224,6 @@ pub fn lan_no_lease( _c_bins: &[(String, Vec)], _rust_bins: &[(String, Vec)], ) -> Result<(), String> { - netd_spells_this_name()?; let case = super::compile::repo_root().join(QEMU_CONFIG); let options = BootOptions { profile: qemu::Profile::E1000eNoServer, ..Default::default() }; let mut guest = QemuInstance::boot_with_options(&case, &[], &[], options); @@ -331,43 +249,3 @@ fn wire_dump() -> std::path::PathBuf { let _ = std::fs::remove_file(&at); at } - -/// **The one place the host-name option can be read.** A server that ignores it -/// answers the same lease either way, so the frames the client sent are the only -/// evidence that it asked at all — and `filter-dump` records both directions, so -/// a frame counts only where it is IPv4 over UDP leaving the client's own port. -fn asked_under_its_own_name(pcap: &[u8]) -> Result<(), String> { - const LITTLE_ENDIAN_PCAP: [u8; 4] = [0xd4, 0xc3, 0xb2, 0xa1]; - const GLOBAL_HEADER: usize = 24; - const RECORD_HEADER: usize = 16; - /// Ethernet, an IPv4 header carrying no options, and UDP. - const HEADERS: usize = 14 + 20 + 8; - if pcap.get(..LITTLE_ENDIAN_PCAP.len()) != Some(&LITTLE_ENDIAN_PCAP[..]) { - return Err("this file does not open with a little-endian pcap header".to_string()); - } - let option = host_name_option(); - let (mut at, mut sent, mut asked) = (GLOBAL_HEADER, 0usize, false); - while let Some(header) = pcap.get(at..at + RECORD_HEADER) { - let len = u32::from_le_bytes(header[8..12].try_into().expect("four bytes")) as usize; - let frame = pcap.get(at + RECORD_HEADER..at + RECORD_HEADER + len).ok_or_else(|| { - format!("this pcap's record at byte {at} names {len} bytes the file has not") - })?; - at += RECORD_HEADER + len; - if frame.len() > HEADERS - && frame[12..14] == [0x08, 0x00] - && frame[23] == 17 - && frame[34..36] == [0, 68] - { - sent += 1; - asked |= frame.windows(option.len()).any(|w| w == option); - } - } - if !asked { - return Err(format!( - "none of the {sent} frame(s) this client sent a DHCP server carries the host-name \ - option {option:?}" - )); - } - eprintln!(" [lan] the client asked under its own name on the wire"); - Ok(()) -} diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 1c9512f05d..67244f1e8b 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -2314,8 +2314,7 @@ pub struct BootOptions { /// Forward this host port to the guest's TCP 22. **slirp is one-way /// without it**: nothing on the host can open a connection into the guest /// unless QEMU is told which port to translate. A profile with no NIC - /// carries no `-netdev` for it to reach, which [`ssh_forward_argv`] is - /// what a test refuses before it boots. + /// carries no `-netdev` for it to reach. pub ssh_port: Option, /// Write every frame this machine's NIC sends or receives to this file, in /// pcap. **The only way to read what the guest asked for**: a request the diff --git a/tests/lancase/system.toml b/tests/lancase/system.toml index 671423a004..5eaf3999ff 100644 --- a/tests/lancase/system.toml +++ b/tests/lancase/system.toml @@ -13,10 +13,6 @@ start = ["logd", "netd", "test-runner"] [programs.logd] syscap = ["logread"] -# netd holds the NIC's PCI function and drives it: the descriptor rings, the -# register window and the interrupt are its own, and the kernel keeps only the -# claim. Named by vendor and device rather than by slot, so one row finds the -# card wherever firmware put it. [programs.netd] serves = ["netd"] devices = ["pci:8086:15fc"] diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index 5677d1cd53..1e042b850a 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -638,7 +638,6 @@ name = "boot.lancase.ping_secs" unit = "s" ceiling = 420 ceiling_from = "toyos_build::metal::return_secs — the loop's own wait for the machine to answer ssh again, which bounds the window a reply can fall in" -measured = 57 [[number]] name = "list.lancase.job_ms" diff --git a/tests/metalcase/system.toml b/tests/metalcase/system.toml index 58e5830555..486aba3fd7 100644 --- a/tests/metalcase/system.toml +++ b/tests/metalcase/system.toml @@ -7,9 +7,8 @@ # its own and reaches the machine's shape only through netd — and it was the # one this config left out, so nothing anywhere ran the branch where it finds # no network. It costs the boot the second `NetdConn::connect_blocking` spends -# retrying a netd that will never come -# (the closed hardware entry on clients paying a boot retry), which is the price of -# certifying the line at all. +# retrying a netd that will never come, which is the price of certifying the +# line at all. # test-runner makes the boot announce itself, so the harness waits for a # marker instead of guessing from pixels — and it is what lets an in-guest # binary run on the machine shape that gets flashed. Under `mute` it simply diff --git a/tests/toyos-rust-tests/src/bin/lan_hold.rs b/tests/toyos-rust-tests/src/bin/lan_hold.rs index 56374b307c..40514f0184 100644 --- a/tests/toyos-rust-tests/src/bin/lan_hold.rs +++ b/tests/toyos-rust-tests/src/bin/lan_hold.rs @@ -6,8 +6,7 @@ use std::thread::sleep; use std::time::Duration; /// **netd's own lease bound**, so this boot cannot hand the machine back before -/// netd has settled whether it has an address. `tests/metal-profile.toml`'s -/// `lancase` rows are priced against the same constant. +/// netd has settled whether it has an address. const HOLD: Duration = Duration::from_millis(toyos_tco::LEASE_BOUND_MS); fn main() { diff --git a/userland/netd/src/dhcp.rs b/userland/netd/src/dhcp.rs index 22805efd43..cd23951b83 100644 --- a/userland/netd/src/dhcp.rs +++ b/userland/netd/src/dhcp.rs @@ -128,7 +128,7 @@ impl Dhcp { } }; self.leased = lease.is_some(); - self.write(lease, &dns, iface, resolver); + Self::write(lease, &dns, iface, resolver); } if self.settled { return false; @@ -155,7 +155,6 @@ impl Dhcp { /// change**, so a route left standing over an address that is gone cannot /// be arranged without breaking the path every boot takes to its lease. fn write( - &self, lease: Option<(Ipv4Cidr, Option)>, dns: &[Ipv4Address], iface: &mut Interface, From ebf53230705f8c556de1e4aed04c9ae41f370c48 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 15:00:48 +0200 Subject: [PATCH 18/23] The pcap walk's two happy arms are one, and the issue keeps its readings `only_the_direction_leaving_the_client_counts` asserts the client's own frame first, so the walk's one green arm stands beside the echo it must refuse rather than in a test of its own; and the cable judge's two premises are recorded in what closes each and nothing else. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- ...pends-two-premises-nothing-has-measured.md | 32 ++++++++----------- src/lan.rs | 9 ++---- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md b/issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md index 3e71cfc8ca..c21b82730f 100644 --- a/issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md +++ b/issues/hardware/the-cable-judge-spends-two-premises-nothing-has-measured.md @@ -7,25 +7,21 @@ opened: 2026-09-13 # The cable judge spends two premises nothing has measured `tests/common/lan.rs`'s `on_metal` decides whether a ping the metal loop saw was -this boot's, and two of its steps rest on facts no run has taken a reading of. +this boot's, and two of its steps rest on readings nobody has taken. -**The T14's RTC is unchanged across the reset.** `src/metal.rs`'s `Driver::wire` -reads `date -u +%s` under Ubuntu before the flash and +**The T14's RTC is unchanged across the reset.** `Driver::wire` reads +`date -u +%s` under Ubuntu before the flash and `bootlog::host_second_inside_this_boot` spends that offset on records ToyOS wrote -after it. Nothing has measured that the two operating systems read the same -counter to the second, and a boot whose firmware or whose kernel moved it would -be judged against a clock that no longer exists. It would take one boot to -measure: a ToyOS record's wall clock read back against `date -u +%s` on the -machine after it, with the loop's own skew applied. Closed by that reading, or -by the judge ceasing to compare the two machines' clocks at all. +after it; a machine whose firmware or whose kernel moved the counter would be +judged against a clock that no longer exists. Closed by one boot: a ToyOS +record's wall clock read back against `date -u +%s` on the machine after it, with +the loop's own skew applied — or by the judge ceasing to compare the two +operating systems' clocks at all. **The router repeats the lease across the two operating systems.** The same -`on_metal` refuses a boot whose leased address is not the one the loop pinged, -and the address the loop pinged is the one Ubuntu held on the same MAC. On a -server that hands the MAC a different address under ToyOS the arm reds for a -fact about the router rather than about the boot — which is a red that names the -wrong thing, not a false green. Closed by a lancase run whose lease record and -whose `ping_addr` are compared, which is the first thing that run will print. - -Neither premise has an owner today: both were recorded in a pull request body, -which nothing reads back. +judge refuses a boot whose leased address is not the one the loop pinged, and +the one the loop pinged is what Ubuntu held on that MAC. A server that hands the +MAC a different address under ToyOS reds the arm for a fact about the router +rather than about the boot — a red naming the wrong thing, not a false green. +Closed by a lancase run whose lease record and whose `ping_addr` are compared, +which is the first thing that run prints. diff --git a/src/lan.rs b/src/lan.rs index 9929e01709..7ee916e454 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -234,18 +234,13 @@ mod tests { frame([0x08, 0x00], 17, 68, 67, payload) } - #[test] - fn a_frame_carrying_the_option_out_of_the_clients_own_port_is_the_evidence() { - let asked = pcap(&[from_client(&host_name_option())]); - assert_eq!(asked_under_its_own_name(&asked), Ok(())); - } - /// **The server's own echo of the option is not the client asking.** A walk - /// keyed on the destination port would count the frame below and report the + /// keyed on the destination port would count the echo below and report the /// question as asked when nothing asked it. #[test] fn only_the_direction_leaving_the_client_counts() { let option = host_name_option(); + assert_eq!(asked_under_its_own_name(&pcap(&[from_client(&option)])), Ok(())); let echoed = frame([0x08, 0x00], 17, 67, 68, &option); let why = asked_under_its_own_name(&pcap(std::slice::from_ref(&echoed))) .expect_err("a server's reply is not this client asking"); From 5aa0499157188a8bd5b46131f068c875ed34f665 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 15:08:54 +0200 Subject: [PATCH 19/23] Each edge of the bracket is the distance to the wrong answer on its own side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The margin was spent inwards at both edges, and at the lease that refuses the answer it exists to accept: this machine's address exists only from the lease record onward and the probe asks every second, so a true reply lands a second or two past it and inside any margin worth having. The two sides are not alike. Before the reset the wrong answer is the operating system coming back, one second away on run 31, which is inside this instrument's error — so the margin is spent inwards and a reply must clear `Rebooting.` by it. At the lease the only wrong answer is the operating system that left, a whole POST away: run 31 measures at least 33 s between the reboot command and this boot's first record. So the margin is spent outwards there, a reply up to it before the lease is still this boot's, and one 28 s before it is still refused by a distance. `a_reply_a_second_after_the_lease_record_is_this_boots` is the arm that was missing: the lease record at F+1, the reply at F+1, F+2 and F+3, all this boot's. `each_edge_is_the_margin_from_the_record_that_sets_it` holds both edges and the second outside each. The run-31 sweep is unchanged and still refuses that reply at every skew from -3 to +3: the edge that decides it is the reset, and the lease edge on that run would sit 28 s below it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- src/bootlog.rs | 59 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/src/bootlog.rs b/src/bootlog.rs index 716cc097c1..c11fcb5b83 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -268,7 +268,7 @@ pub fn declares(source: &str, rhs: &str) -> bool { joined.lines().any(|line| line.trim_end().ends_with(&tail)) } -/// The daylight a host second needs on either side before it is this boot's. +/// This instrument's own error, which is what each edge below is set by. /// /// **A judge reading whole seconds does not get to decide at one.** `skew` is a /// difference of two floored clocks across a round trip its reader holds to a @@ -277,8 +277,13 @@ pub fn declares(source: &str, rhs: &str) -> bool { /// makes a refusal a distance rather than a coin. pub const MARGIN: u64 = 5; -/// Whether a second on the *host's* clock fell inside the boot this log is of, -/// clear of [`MARGIN`] on both the record `after` names and the reset. +/// Whether a second on the *host's* clock fell inside the boot this log is of: +/// no more than [`MARGIN`] before the record `after` names, and no less than +/// [`MARGIN`] before the reset. +/// +/// **Each edge is set by the distance to the nearest wrong answer on that +/// side**, which is why the margin is spent outwards at one and inwards at the +/// other. /// /// **The records are the one place a host clock and a boot's clock meet.** /// `skew` is this machine's clock minus the host's as the caller measured the @@ -304,14 +309,18 @@ pub fn host_second_inside_this_boot( at.checked_add_signed(skew) .ok_or_else(|| format!("a host second of {at} and a skew of {skew} is no second"))?, ); - if at - began < i128::from(MARGIN) { + // Spent outwards here, because the only wrong answer on this side is the + // operating system that left, a whole POST away. + if began - at > i128::from(MARGIN) { return Err(format!( - "the host saw it at {at} on this machine's clock and this boot's {after:?} record is \ - at {began}, {} s apart: nothing closer than {MARGIN} s past that record is this \ - boot's, because these clocks are whole seconds", - at - began + "the host saw it at {at} on this machine's clock, {} s before this boot's {after:?} \ + record at {began}: further back than this instrument's {MARGIN} s of error, so it \ + belongs to the operating system that left", + began - at )); } + // Spent inwards here, because the wrong answer on this side is the operating + // system that came back, one second away. if ended - at < i128::from(MARGIN) { return Err(format!( "the host saw it at {at} on this machine's clock and this boot's {REBOOTING:?} record \ @@ -519,22 +528,40 @@ mod record_time_tests { record_unix_secs(BOOT.lines().next().expect("a record")).expect("a wall clock") } - /// **[`MARGIN`] decides both edges**, and one second short of either is - /// refused rather than read as inside. + /// **Both edges, and the second outside each.** The anchor record is at + /// `first + 1` and the reset at `first + 23`, so the span this boot owns + /// runs from [`MARGIN`] before the one to `MARGIN` before the other. #[test] - fn a_second_clear_of_this_boots_records_by_the_margin_is_this_boots() { + fn each_edge_is_the_margin_from_the_record_that_sets_it() { let first = first(); - for at in [first + MARGIN + 1, first + 23 - MARGIN] { + for at in [first + 1 - MARGIN, first + 23 - MARGIN] { assert_eq!(host_second_inside_this_boot(BOOT, 0, "Boot: complete", at), Ok(()), "{at}"); } - let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first + MARGIN) - .expect_err("a second short of the margin past the record it is anchored on"); - assert!(why.contains(&format!("closer than {MARGIN} s past")), "{why}"); + let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first - MARGIN) + .expect_err("a second further back than the error the margin is"); + assert!(why.contains("belongs to the operating system that left"), "{why}"); let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first + 24 - MARGIN) .expect_err("a second short of the margin before the reset"); assert!(why.contains(&format!("closer than {MARGIN} s before")), "{why}"); } + /// **A true reply lands a second or two past the lease and is never + /// refused**: this machine's address exists from that record onward and the + /// probe asks every second, so the lower edge may never be spent inwards. + #[test] + fn a_reply_a_second_after_the_lease_record_is_this_boots() { + let leased = BOOT.replace( + "Boot: complete (1258ms)", + "netd: DHCP: lease 192.168.1.46/24 from 192.168.1.1, gateway 192.168.1.1, dns \ + [192.168.1.1], 412 ms after netd came up", + ); + let lease_at = first() + 1; + for at in [lease_at, lease_at + 1, lease_at + 2] { + let verdict = host_second_inside_this_boot(&leased, 0, crate::lan::LEASE, at); + assert_eq!(verdict, Ok(()), "{} s after the lease", at - lease_at); + } + } + /// **The one reply this judge exists to refuse.** The loop wrote it 57 s /// into a window opening no earlier than its own run, whose first line is /// 33 s before this boot's first record; it is anchored here on the earliest @@ -577,7 +604,7 @@ mod record_time_tests { assert_eq!(host_second_inside_this_boot(BOOT, 30, "Boot: complete", first - 20), Ok(())); let why = host_second_inside_this_boot(BOOT, -30, "Boot: complete", first + 10) .expect_err("thirty seconds the other way is before this boot began"); - assert!(why.contains(&format!("closer than {MARGIN} s past")), "{why}"); + assert!(why.contains("belongs to the operating system that left"), "{why}"); } /// The panel writes no wall clock, and its milliseconds field must not be From a3512dc29bc5bc78edc3deb6ab7e8a2caa8876d0 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 15:47:54 +0200 Subject: [PATCH 20/23] The margin is one number the guard and the judge both read, and the probe's exit is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bootlog::MARGIN` was a free 5 that no arm could fail on: every test spent it symbolically, so it held for any value in 3..=20, and the round trip it was derived from was a separate literal in `clock_skew`. There is now one constant, `bootlog::SKEW_ROUND_TRIP_SECS`, which the guard refuses past and which `MARGIN` is `+ 4` from, and `each_edge_is_five_seconds_from_the_record_that_sets_it` spends the seconds in full rather than the name. Moving either number reds it: `SKEW_ROUND_TRIP_SECS = 4` and `MARGIN = SKEW_ROUND_TRIP_SECS + 3` were both tried and both fail that test. The five record heads `src/lan.rs` and `on_metal` rest on are held to netd's own source the way `metaldevices` holds the probe's refusals, reading across the continuations rustfmt leaves in a wrapped literal; rewording `netd: I219: link up at ` in `userland/netd/src/i219.rs` was tried and reds it by that name. The link record is no longer owed twice, so one absent record is one finding. A lease whose server sent no router option is a lease: netd writes `gateway none` and `Lease::gateway` is now `Option`, which is what the one writer can write and the one reader could not read. `ping_said` splits the probe's exit three ways on what `ping(8)` documents — 0 a response, 2 a transmission that got none, anything else this host refusing, with its stderr — so a `ping` that refuses its arguments no longer reads as a dark window. Deleted with the findings: the margin's derivation, which did not follow from the code it cited; the sweep's derivation off an uncommitted transcript; the fixture narrated three lines below itself; the edge rule told twice; two comments restating the code beneath them. And, taken while there: four single-use SLIRP constants inlined at the literal they fill, `wire_dump` at its one caller, `word` built on `key` instead of repeating it, and `LINK_UP` made private now that nothing outside the module reads it. Gates: `cargo test --lib` 0 (314 passed, 0 failed, 1 ignored), `cargo test --workspace --exclude toyos-build` 0 (138 ok, 0 FAILED), `cargo run -- --clippy` 0 (5 invocations clean), `cargo test --test toyos-build --no-run` 0, `cargo check -p netd` 0. The guest arms are unrun: the shared sysroot is claimed elsewhere and `--claim-sysroot` was not passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- src/bootlog.rs | 42 ++++++++++++---------------- src/lan.rs | 51 ++++++++++++++++++++++------------ src/metal.rs | 67 +++++++++++++++++++++++++++------------------ tests/common/lan.rs | 38 +++++++++---------------- 4 files changed, 106 insertions(+), 92 deletions(-) diff --git a/src/bootlog.rs b/src/bootlog.rs index c11fcb5b83..f4ed5ee550 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -268,23 +268,20 @@ pub fn declares(source: &str, rhs: &str) -> bool { joined.lines().any(|line| line.trim_end().ends_with(&tail)) } -/// This instrument's own error, which is what each edge below is set by. -/// -/// **A judge reading whole seconds does not get to decide at one.** `skew` is a -/// difference of two floored clocks across a round trip its reader holds to a -/// second, which is three of these; the second the host read and the second the -/// record carries are floored too, which is the fourth; and the fifth is what -/// makes a refusal a distance rather than a coin. -pub const MARGIN: u64 = 5; +/// The longest round trip a skew may be read across — `src/metal.rs`'s +/// `clock_skew` refuses past it — so a floored difference taken across one +/// estimates the true offset to within `SKEW_ROUND_TRIP_SECS + 1`. +pub const SKEW_ROUND_TRIP_SECS: u64 = 1; + +/// What a host second placed against these records can be wrong by: that +/// estimate's `+ 1`, one for a probe that waits a second for its reply, one for +/// the two floored seconds compared, and one so a refusal is a distance. +pub const MARGIN: u64 = SKEW_ROUND_TRIP_SECS + 4; /// Whether a second on the *host's* clock fell inside the boot this log is of: /// no more than [`MARGIN`] before the record `after` names, and no less than /// [`MARGIN`] before the reset. /// -/// **Each edge is set by the distance to the nearest wrong answer on that -/// side**, which is why the margin is spent outwards at one and inwards at the -/// other. -/// /// **The records are the one place a host clock and a boot's clock meet.** /// `skew` is this machine's clock minus the host's as the caller measured the /// two against each other; how far into a host-side window an observation came @@ -528,19 +525,19 @@ mod record_time_tests { record_unix_secs(BOOT.lines().next().expect("a record")).expect("a wall clock") } - /// **Both edges, and the second outside each.** The anchor record is at - /// `first + 1` and the reset at `first + 23`, so the span this boot owns - /// runs from [`MARGIN`] before the one to `MARGIN` before the other. + /// **Both edges, at the seconds they fall on.** Spending [`MARGIN`] + /// symbolically on both sides of both comparisons would hold for every + /// value of it, so the seconds below are written out. #[test] - fn each_edge_is_the_margin_from_the_record_that_sets_it() { + fn each_edge_is_five_seconds_from_the_record_that_sets_it() { let first = first(); - for at in [first + 1 - MARGIN, first + 23 - MARGIN] { + for at in [first - 4, first + 18] { assert_eq!(host_second_inside_this_boot(BOOT, 0, "Boot: complete", at), Ok(()), "{at}"); } - let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first - MARGIN) + let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first - 5) .expect_err("a second further back than the error the margin is"); assert!(why.contains("belongs to the operating system that left"), "{why}"); - let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first + 24 - MARGIN) + let why = host_second_inside_this_boot(BOOT, 0, "Boot: complete", first + 19) .expect_err("a second short of the margin before the reset"); assert!(why.contains(&format!("closer than {MARGIN} s before")), "{why}"); } @@ -562,11 +559,8 @@ mod record_time_tests { } } - /// **The one reply this judge exists to refuse.** The loop wrote it 57 s - /// into a window opening no earlier than its own run, whose first line is - /// 33 s before this boot's first record; it is anchored here on the earliest - /// record the boot carries, which is the most favourable anchor there is, - /// and no skew the measurement can be wrong by brings it inside. + /// **The one reply this judge exists to refuse**, anchored on the earliest + /// record this boot carries, which is the most favourable anchor there is. #[test] fn that_reply_is_refused_at_every_skew_the_measurement_can_be_wrong_by() { let earliest_window = first() - 33; diff --git a/src/lan.rs b/src/lan.rs index 7ee916e454..0fa266baa4 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -12,12 +12,12 @@ use std::net::Ipv4Addr; /// The records both arms are written against, spelled once. pub const MAC: &str = "netd: MAC "; pub const LEASE: &str = "netd: DHCP: lease "; -pub const LINK_UP: &str = "netd: I219: link up at "; +const LINK_UP: &str = "netd: I219: link up at "; pub const READY: &str = "netd: ready, at most "; pub const NO_LEASE: &str = "netd: DHCP: no lease as "; /// The name this machine asks its network to record for it, held to netd's own -/// `dhcp::HOSTNAME` by [`tests::netd_declares_the_name_this_module_spells`]. +/// `dhcp::HOSTNAME` by [`tests::netd_writes_the_records_this_module_reads`]. pub const HOSTNAME: &str = "toyos-t14"; /// RFC 2132 §3.14: the kind, the length, and the name. @@ -33,7 +33,8 @@ pub struct Lease { pub address: Ipv4Addr, pub prefix: u8, pub server: Ipv4Addr, - pub gateway: Ipv4Addr, + /// `None` where the server sent no router option: netd writes `gateway none`. + pub gateway: Option, pub dns: Vec, /// Milliseconds between netd starting and the lease landing. pub ms: u64, @@ -59,6 +60,10 @@ pub fn lease_in(text: &str) -> Result { }; let cidr = after(LEASE, " from ")?; let (host, prefix) = cidr.split_once('/').ok_or_else(|| unreadable("an address/prefix"))?; + let gateway = match after(", gateway ", ",")?.as_str() { + "none" => None, + got => Some(address("the gateway's address", got.to_string())?), + }; let mut dns = Vec::new(); for server in after(", dns [", "]")?.split_whitespace() { dns.push(address("a resolver", server.to_string())?); @@ -67,7 +72,7 @@ pub fn lease_in(text: &str) -> Result { address: address("this machine's address", host.to_string())?, prefix: prefix.parse().map_err(|_| unreadable("a prefix length"))?, server: address("the server's address", after(" from ", ",")?)?, - gateway: address("the gateway's address", after(", gateway ", ",")?)?, + gateway, dns, ms: after("], ", " ms after netd came up")? .parse() @@ -138,22 +143,33 @@ mod tests { 10.0.2.2, gateway 10.0.2.2, dns [10.0.2.3 10.0.2.4], 412 ms after netd \ came up"; - /// Nothing links the two crates: netd is a `no_std`-shaped userland binary - /// and this is the build system, so the name both ends spell is held to - /// netd's own declaration by reading its source. + /// netd's own source, with rustfmt's continuations inside a wrapped literal + /// closed up, so a head reads across one. + fn netd_source() -> String { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("userland/netd/src"); + let read = |name: &str| { + let at = root.join(name); + std::fs::read_to_string(&at).unwrap_or_else(|e| panic!("{}: {e}", at.display())) + }; + let whole = ["main.rs", "i219.rs", "dhcp.rs"].map(read).join("\n"); + whole.split("\\\n").map(str::trim_start).collect() + } + + /// Nothing links the two crates, so every record this module and `on_metal` + /// rest on is held to netd's own source: a reworded one reds by its name + /// rather than as an absence. #[test] - fn netd_declares_the_name_this_module_spells() { - let at = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("userland/netd/src/dhcp.rs"); - let source = std::fs::read_to_string(&at).expect("netd's dhcp module"); + fn netd_writes_the_records_this_module_reads() { + let source = netd_source(); + for head in [MAC, LEASE, LINK_UP, READY, NO_LEASE] { + assert!(source.contains(&format!("\"{head}")), "netd opens no record with {head:?}"); + } assert!( crate::bootlog::declares(&source, &format!("b\"{HOSTNAME}\"")), - "{} declares no constant equal to b\"{HOSTNAME}\"", - at.display() + "netd declares no constant equal to b\"{HOSTNAME}\"" ); } - /// **Every field the lease decided, typed**, so a record that grew a field - /// still reads and one that lost a field is refused by the field's name. #[test] fn a_lease_record_is_read_field_by_field() { assert_eq!( @@ -162,7 +178,7 @@ mod tests { address: Ipv4Addr::new(10, 0, 2, 15), prefix: 24, server: Ipv4Addr::new(10, 0, 2, 2), - gateway: Ipv4Addr::new(10, 0, 2, 2), + gateway: Some(Ipv4Addr::new(10, 0, 2, 2)), dns: vec![Ipv4Addr::new(10, 0, 2, 3), Ipv4Addr::new(10, 0, 2, 4)], ms: 412, }) @@ -171,6 +187,9 @@ mod tests { // a missing field. let none = LEASED.replace("10.0.2.3 10.0.2.4", ""); assert!(lease_in(&none).expect("a lease").dns.is_empty()); + // A server that sent no router option leases too, and netd says so. + let routerless = LEASED.replace("gateway 10.0.2.2", "gateway none"); + assert_eq!(lease_in(&routerless).expect("a lease").gateway, None); } #[test] @@ -218,8 +237,6 @@ mod tests { out } - /// One frame: an ethertype, an IPv4 protocol, the two UDP ports, and a - /// payload. fn frame(ethertype: [u8; 2], protocol: u8, src: u16, dst: u16, payload: &[u8]) -> Vec { let mut frame = vec![0u8; 14 + 20 + 8]; frame[12..14].copy_from_slice(ðertype); diff --git a/src/metal.rs b/src/metal.rs index 0bfbbf00bb..6d4b141f6e 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -997,15 +997,14 @@ fn brief_address(iface: &str, text: &str) -> Result /// This machine's clock minus this host's, from `date -u +%s` and the host's own /// reading at each end of that round trip. /// -/// **A whole second either side and a round trip in between**, which is where -/// [`crate::bootlog::MARGIN`]'s first three seconds come from; a round trip -/// longer than that, or a host clock that stepped backwards inside it, is -/// refused rather than spent. +/// A round trip longer than [`bootlog::SKEW_ROUND_TRIP_SECS`] — the bound +/// [`bootlog::MARGIN`] is built on — or a host clock that stepped backwards +/// inside one, is refused rather than spent. fn clock_skew(before: u64, said: &str, after: u64) -> Result { let took = after.checked_sub(before).ok_or_else(|| { format!("this host's clock read {before} before the machine's and {after} after it") })?; - if took > 1 { + if took > bootlog::SKEW_ROUND_TRIP_SECS { return Err(format!( "this host's clock read {before} before the machine's and {after} after it, and a \ skew read across {took} s is worth less than the judge it feeds" @@ -1087,11 +1086,22 @@ fn unix_now() -> u64 { .as_secs() } -/// One probe, whose whole answer is whether the address replied. +/// What one `ping` exit means. /// -/// **A host with no `ping` and a cable with nothing on it are separate -/// answers.** A spawn that fails is this host's failing, and reporting it as -/// silence would red the boot for a binary the host does not have. +/// **A probe this host refused and a cable with nothing on it are separate +/// answers.** `ping(8)` exits 0 on a response and 2 on a transmission that got +/// none; reading any other status as silence would red the boot for a probe +/// that never ran. +fn ping_said(code: Option, stderr: &str) -> Result { + match code { + Some(0) => Ok(true), + Some(2) => Ok(false), + Some(code) => Err(format!("`ping` exited {code} and said {:?}", stderr.trim())), + None => Err(format!("`ping` was killed and said {:?}", stderr.trim())), + } +} + +/// One probe, whose whole answer is whether the address replied. fn ping_once(addr: std::net::Ipv4Addr) -> Result { // `-W` is milliseconds to macOS's `ping` and seconds to every other's, so a // host this argument has not been read against is refused by name rather @@ -1102,12 +1112,12 @@ fn ping_once(addr: std::net::Ipv4Addr) -> Result { std::env::consts::OS )); } - Command::new("ping") + let out = Command::new("ping") .args(["-n", "-c", "1", "-W", &PING_WAIT_MS.to_string(), &addr.to_string()]) .stdin(Stdio::null()) .output() - .map(|out| out.status.success()) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + ping_said(out.status.code(), &String::from_utf8_lossy(&out.stderr)) } /// The loop, over one target. @@ -2075,10 +2085,7 @@ pub fn cable(text: &str) -> Result, String> { } fn word(text: &str, name: &str) -> Option { - text.lines() - .find_map(|line| line.strip_prefix(name)) - .map(|rest| rest.trim().to_string()) - .filter(|got| !got.is_empty()) + key::(text, name).filter(|got| !got.is_empty()) } fn key(text: &str, name: &str) -> Option { @@ -2337,9 +2344,6 @@ mod tests { /// **The address is the one on the function the image claims, and an /// interface with none is refused rather than read as the next one's.** - /// `ip -4 -brief` prints the name, the state and then the addresses, and an - /// interface whose cable is out prints the first two and stops — which is - /// exactly the machine this loop must not go on to flash and then ping. #[test] fn an_interface_with_no_address_is_refused_by_name() { let up = "enp0s31f6 UP 192.168.1.46/24 \n"; @@ -2359,20 +2363,20 @@ mod tests { assert!(brief_address("enp0s31f7", many).unwrap_err().contains("ip -4 -brief")); } - /// **A host clock that stepped backwards across the round trip is the - /// condition this guard exists for**, and it is a refusal by name and not - /// the subtraction overflow it would otherwise be. + /// **A host clock that stepped backwards across the round trip** is a + /// refusal by name and not the subtraction overflow it would otherwise be. #[test] fn a_skew_read_across_a_clock_this_host_moved_is_refused() { + const TRIP: u64 = bootlog::SKEW_ROUND_TRIP_SECS; assert_eq!(clock_skew(1_757_347_700, "1757347703\n", 1_757_347_700), Ok(3)); - assert_eq!(clock_skew(1_757_347_700, "1757347698", 1_757_347_701), Ok(-2)); + assert_eq!(clock_skew(1_757_347_700, "1757347698", 1_757_347_700 + TRIP), Ok(-2)); let why = clock_skew(1_757_347_701, "1757347700", 1_757_347_700) .expect_err("the second read is before the first"); assert!(why.contains("1757347701 before"), "{why}"); - let why = clock_skew(1_757_347_700, "1757347700", 1_757_347_705) - .expect_err("five seconds is no round trip to spend a judge on"); - assert!(why.contains("across 5 s"), "{why}"); + let why = clock_skew(1_757_347_700, "1757347700", 1_757_347_701 + TRIP) + .expect_err("a trip one second longer than the bound the margin is built on"); + assert!(why.contains(&format!("across {} s", TRIP + 1)), "{why}"); let why = clock_skew(1_757_347_700, "Tue Sep 9 10:00:00 UTC 2026", 1_757_347_700) .expect_err("a date is not a count of seconds"); assert!(why.contains("`date -u +%s` said"), "{why}"); @@ -2380,6 +2384,17 @@ mod tests { assert!(clock_skew(1_757_347_700, &i64::MIN.to_string(), 1_757_347_700).is_err()); } + /// **A `ping` that refused its arguments is not a dark window**, and + /// reading one as the other makes a broken probe red the boot. + #[test] + fn a_ping_this_host_refused_is_told_from_one_nothing_answered() { + assert_eq!(ping_said(Some(0), ""), Ok(true)); + assert_eq!(ping_said(Some(2), ""), Ok(false)); + let why = ping_said(Some(64), "ping: invalid option -- Z\n").expect_err("a usage error"); + assert!(why.contains("exited 64") && why.contains("invalid option"), "{why}"); + assert!(ping_said(None, "").unwrap_err().contains("killed")); + } + #[test] fn an_nvme_node_cannot_be_written_down() { for name in ["/dev/nvme0n1", "/dev/nvme0n1p3", "/dev/sda1", "/dev/sdaa", "/dev/SDA", "sda"] diff --git a/tests/common/lan.rs b/tests/common/lan.rs index d156d518ac..0a0d6ec0ba 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -10,8 +10,7 @@ use std::path::Path; use toyos_build::bootlog; use toyos_build::lan::{ - asked_under_its_own_name, lease_in, link_up_ms, Lease, HOSTNAME, LEASE, LINK_UP, MAC, NO_LEASE, - READY, + asked_under_its_own_name, lease_in, link_up_ms, Lease, HOSTNAME, LEASE, MAC, NO_LEASE, READY, }; use toyos_build::metalprofile::Profile; @@ -32,13 +31,6 @@ pub const JOBS: &[&str] = &["test_rs_lan_hold"]; /// this host can put in front of it. const QEMU_CONFIG: &str = "tests/e1000case"; -/// What QEMU's user-mode backend leases, and what it says about the network it -/// leases on. Its own defaults, not this repository's: they are the oracle. -const SLIRP_ADDRESS: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 15); -const SLIRP_PREFIX: u8 = 24; -const SLIRP_ROUTER: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 2); -const SLIRP_DNS: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 3); - /// The card the T14 arm claims, as the kernel and the manifest spell it. const ID: &str = "8086:15fc"; @@ -74,7 +66,8 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { }), } - for owed in [MAC, LINK_UP, READY] { + // Not the link record: `link_up_ms` below already refuses its absence. + for owed in [MAC, READY] { if !text.contains(owed) { bad.push(format!("no {owed:?} record")); } @@ -102,7 +95,7 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { match lease_in(text) { Ok(lease) => { eprintln!( - " [lan] leased {}/{} from {} in {} ms, gateway {}, dns {:?}", + " [lan] leased {}/{} from {} in {} ms, gateway {:?}, dns {:?}", lease.address, lease.prefix, lease.server, lease.ms, lease.gateway, lease.dns ); if let Err(why) = profile.judge(&format!("lan.{}.lease_ms", back.label), lease.ms) { @@ -165,7 +158,9 @@ pub fn lan_dhcp_lease( _rust_bins: &[(String, Vec)], ) -> Result<(), String> { let case = super::compile::repo_root().join(QEMU_CONFIG); - let dump = wire_dump(); + // Where this process writes the frames the boot puts on its wire. + let dump = std::env::temp_dir().join(format!("toyos-lan-{}.pcap", std::process::id())); + let _ = std::fs::remove_file(&dump); let options = BootOptions { profile: qemu::Profile::E1000e, wire_dump: Some(dump.clone()), @@ -186,12 +181,13 @@ pub fn lan_dhcp_lease( let log = serial::Serial::named("the lan boot", console.as_str()); let lease = lease_in(log.text())?; + // QEMU's user-mode backend's own defaults, not this repository's: the oracle. let want = Lease { - address: SLIRP_ADDRESS, - prefix: SLIRP_PREFIX, - server: SLIRP_ROUTER, - gateway: SLIRP_ROUTER, - dns: vec![SLIRP_DNS], + address: Ipv4Addr::new(10, 0, 2, 15), + prefix: 24, + server: Ipv4Addr::new(10, 0, 2, 2), + gateway: Some(Ipv4Addr::new(10, 0, 2, 2)), + dns: vec![Ipv4Addr::new(10, 0, 2, 3)], ms: lease.ms, }; if lease != want { @@ -201,7 +197,6 @@ pub fn lan_dhcp_lease( } // The order, and not merely the presence of both. log.must_say_after(LEASE, READY)?; - log.must_say(LINK_UP)?; let ms = link_up_ms(log.text())?; eprintln!( " [lan] the emulated link came up in {ms} ms and the lease landed {} ms after netd \ @@ -242,10 +237,3 @@ pub fn lan_no_lease( eprintln!(" [lan] no server answered and netd said so, then served anyway"); Ok(()) } - -/// Where this process writes the frames one boot put on its wire. -fn wire_dump() -> std::path::PathBuf { - let at = std::env::temp_dir().join(format!("toyos-lan-{}.pcap", std::process::id())); - let _ = std::fs::remove_file(&at); - at -} From b657923850be70880f14164ba263fea23ab41ac4 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 16:13:29 +0200 Subject: [PATCH 21/23] Hold the de-wrap and the `none` spelling, and owe the MAC once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `netd_source`'s de-wrap was held by nothing: replacing it with the identity left every `cargo test --lib` test green, because no netd literal wraps at a record head today. The de-wrap is now `dewrapped`, a pure function over a fixture whose wrap falls inside a head, and the file reading moved to its one caller. `"none"` was spelled on both sides of the crate boundary and closed by no scan: a netd that wrote `gateway -` would have reads on the T14 and nowhere else. It is `NO_GATEWAY` in `src/lan.rs`, read by `lease_in`'s match and by the same test that holds the five record heads to netd's own source. `on_metal` owed the MAC twice — an absent record pushed both `no "netd: MAC " record` and a mismatch finding that diagnosed a card swap that had not happened. One check now, with the two arms the `handed` check above it uses: the line itself where a different card named itself, the absence otherwise. Negative controls, both run and reverted: - `dewrapped` returning `source.to_string()`: `cargo test --lib lan::` exit 101, `a_head_rustfmt_split_across_two_lines_reads_as_one` panicked at `src/lan.rs:165`, `the wrap still swallows "\"netd: I219: link up at "`. - netd writing `None => "-".to_string()` (`userland/netd/src/dhcp.rs:114`): `cargo test --lib lan::tests::netd_writes` exit 101, `netd_writes_the_records_this_module_reads` panicked at `src/lan.rs:182`, `netd writes no "none" where a server sent no router option`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- src/lan.rs | 39 +++++++++++++++++++++++++++------------ tests/common/lan.rs | 22 ++++++++++++---------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/src/lan.rs b/src/lan.rs index 0fa266baa4..5f92414187 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -16,6 +16,10 @@ const LINK_UP: &str = "netd: I219: link up at "; pub const READY: &str = "netd: ready, at most "; pub const NO_LEASE: &str = "netd: DHCP: no lease as "; +/// What netd writes where a server sent no router option, held to netd's own +/// source by [`tests::netd_writes_the_records_this_module_reads`]. +const NO_GATEWAY: &str = "none"; + /// The name this machine asks its network to record for it, held to netd's own /// `dhcp::HOSTNAME` by [`tests::netd_writes_the_records_this_module_reads`]. pub const HOSTNAME: &str = "toyos-t14"; @@ -61,7 +65,7 @@ pub fn lease_in(text: &str) -> Result { let cidr = after(LEASE, " from ")?; let (host, prefix) = cidr.split_once('/').ok_or_else(|| unreadable("an address/prefix"))?; let gateway = match after(", gateway ", ",")?.as_str() { - "none" => None, + NO_GATEWAY => None, got => Some(address("the gateway's address", got.to_string())?), }; let mut dns = Vec::new(); @@ -143,16 +147,18 @@ mod tests { 10.0.2.2, gateway 10.0.2.2, dns [10.0.2.3 10.0.2.4], 412 ms after netd \ came up"; - /// netd's own source, with rustfmt's continuations inside a wrapped literal - /// closed up, so a head reads across one. - fn netd_source() -> String { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("userland/netd/src"); - let read = |name: &str| { - let at = root.join(name); - std::fs::read_to_string(&at).unwrap_or_else(|e| panic!("{}: {e}", at.display())) - }; - let whole = ["main.rs", "i219.rs", "dhcp.rs"].map(read).join("\n"); - whole.split("\\\n").map(str::trim_start).collect() + fn dewrapped(source: &str) -> String { + source.split("\\\n").map(str::trim_start).collect() + } + + /// **Held here and not by netd's source**: no netd literal wraps at a head + /// today, so a scan over the file as written is green either way. + #[test] + fn a_head_rustfmt_split_across_two_lines_reads_as_one() { + let wrapped = " crate::say!(\"netd: I219: link up \\\n at {} Mb/s\");"; + let head = format!("\"{LINK_UP}"); + assert!(!wrapped.contains(&head), "this fixture carries no wrap to close up"); + assert!(dewrapped(wrapped).contains(&head), "the wrap still swallows {head:?}"); } /// Nothing links the two crates, so every record this module and `on_metal` @@ -160,10 +166,19 @@ mod tests { /// rather than as an absence. #[test] fn netd_writes_the_records_this_module_reads() { - let source = netd_source(); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("userland/netd/src"); + let read = |name: &str| { + let at = root.join(name); + std::fs::read_to_string(&at).unwrap_or_else(|e| panic!("{}: {e}", at.display())) + }; + let source = dewrapped(&["main.rs", "i219.rs", "dhcp.rs"].map(read).join("\n")); for head in [MAC, LEASE, LINK_UP, READY, NO_LEASE] { assert!(source.contains(&format!("\"{head}")), "netd opens no record with {head:?}"); } + assert!( + source.contains(&format!("\"{NO_GATEWAY}\"")), + "netd writes no {NO_GATEWAY:?} where a server sent no router option" + ); assert!( crate::bootlog::declares(&source, &format!("b\"{HOSTNAME}\"")), "netd declares no constant equal to b\"{HOSTNAME}\"" diff --git a/tests/common/lan.rs b/tests/common/lan.rs index 0a0d6ec0ba..2c598ab980 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -67,19 +67,22 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { } // Not the link record: `link_up_ms` below already refuses its absence. - for owed in [MAC, READY] { - if !text.contains(owed) { - bad.push(format!("no {owed:?} record")); - } + if !text.contains(READY) { + bad.push(format!("no {READY:?} record")); } + // One fact, one finding: a boot that named no card at all is not a boot + // that named a different one. let mac = format!("{MAC}{}", cable.mac); if !text.contains(&mac) { - bad.push(format!( - "no {mac:?} record: the card this boot brought up is not the one that held {} \ - before it", - cable.addr - )); + bad.push(match text.lines().find(|l| l.contains(MAC)) { + Some(line) => format!( + "{}: the card this boot brought up is not the one that held {} before it", + line.trim(), + cable.addr + ), + None => format!("no {MAC:?} record"), + }); } match link_up_ms(text) { @@ -158,7 +161,6 @@ pub fn lan_dhcp_lease( _rust_bins: &[(String, Vec)], ) -> Result<(), String> { let case = super::compile::repo_root().join(QEMU_CONFIG); - // Where this process writes the frames the boot puts on its wire. let dump = std::env::temp_dir().join(format!("toyos-lan-{}.pcap", std::process::id())); let _ = std::fs::remove_file(&dump); let options = BootOptions { From a7f4836de416eb30fa058a0d0ba5e97810fbb024 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 17:13:15 +0200 Subject: [PATCH 22/23] The T14 arm reads what crosses: the hand-over, an exit code and the census MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 36 is the first boot that handed the T14's I219 to netd, and it showed this branch's metal judge rests on evidence that cannot reach the stick. `grep -l 'netd:'` over every readback the bench has taken is empty, run 36 included, where netd held the card for twenty seconds: a `netd:` line is a userland write to a console whose backend on that machine is `None`, and the QEMU arm reads those lines only because a guest's console is a serial port the harness holds the other end of. `on_metal` read the lease record, the MAC record, the readiness line and the link record out of `kernel.log`, so it would have red a boot whose card answered on the cable. What crosses is the kernel's records and the loop's own readings. netd now answers one word of its own with the MAC of the card it drives and the address its lease gave the machine; `test_rs_lan_state` asks over the port netd already serves and exits with the answer, and the kernel's `exit:` record carries it. `toyos-lanstate` is the grammar all three read: a code at or above 2^30 is the FNV-1a fold of the pair, a negative code is a refusal by its own name as metalprobe's are, and every other code is foreign — what a job that was killed or panicked leaves, which is a different finding from a fold that disagreed. The fold is forced rather than chosen: an address and a MAC are eighty bits and the record carries thirty-two, so what the judge can ask of a machine with no console is agreement with the pair it already holds. `ASK` is declared in that crate rather than in `toyos::net::MsgType` because nothing that uses the network sends it — and because the SDK is the shared sysroot, which this branch may not touch. Two declaration sites for one connection's words is a collision hazard, so it is held closed: `the_state_word_is_no_message_the_sdk_sends` scans `toyos/src/net.rs` for the discriminants of its two `#[repr(u32)]` enums, asserts the scan found the ones it names, and asserts `ASK` is not among the fourteen. `on_metal` now reads: the hand-over record, whose needle is built from the same `NIC` constant the loop reads the wire off, so the card the host measured and the card the kernel gave away are one record; the job's exit code against the cable; the `irq:` census's `userdev` column, where a lease with no interrupt is a contradiction refused by name; the ping inside the bracket; and that `lan_hold` exited clean. The bracket's lower edge moves from the lease record to the hand-over, which is a record the T14 writes — on run 36 it is at 1.450 s and netd spawns 0.2 s later, so the window is the same one — and a boot with no hand-over record is not bracketed at all, so that absence is one finding. `lan.lancase.link_up_ms` and `lan.lancase.lease_ms` are deleted: both were read out of `netd:` records, and a row for a number this bench cannot measure is a ceiling that can only ever pass. `lan_dhcp_lease` runs the whole grammar end to end under QEMU: the same job, the same netd, and the exit code checked against the `netd: MAC` and lease records netd wrote out of the driver where the job's answer came out of the interface. Negative controls, run then reverted. The fold over the address alone — `cargo test -p toyos-lanstate --lib` EXIT=101, `a_fingerprint_is_of_the_whole_pair_and_stays_in_its_band` FAILED. `ASK = 22`, which is `MsgType::TcpAcceptPiped` — `cargo test --lib lan::tests::the_state_word` EXIT=101, "netd's state word 22 is also one of the SDK's: [4, 7, 8, 9, 10, 11, 12, 13, 14, 20, 21, 22, 128, 129]". Gates: `cargo test --lib` 0, 318 passed. `cargo test --workspace --exclude toyos-build` 0, 140 ok. `cargo run -- --clippy` 0. `cargo test --test toyos-build --no-run` 0. `cargo check -p netd` 0. The guest arms are unrun: the shared sysroot is held by another worktree for an ABI change that is not on main, and `--build-only` refuses by name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- Cargo.lock | 5 + Cargo.toml | 5 + src/bootlog.rs | 22 +- src/lan.rs | 132 +++++++++++ src/metalprofile.rs | 4 - tests/common/lan.rs | 191 ++++++++++------ tests/lancase/system.toml | 3 + tests/metal-profile.toml | 17 +- tests/toyos-rust-tests/Cargo.lock | 5 + tests/toyos-rust-tests/Cargo.toml | 1 + tests/toyos-rust-tests/src/bin/lan_state.rs | 31 +++ tests/toyos.rs | 17 +- toyos-lanstate/Cargo.toml | 10 + toyos-lanstate/src/lib.rs | 237 ++++++++++++++++++++ userland/Cargo.lock | 5 + userland/netd/Cargo.toml | 1 + userland/netd/src/main.rs | 18 +- 17 files changed, 605 insertions(+), 99 deletions(-) create mode 100644 tests/toyos-rust-tests/src/bin/lan_state.rs create mode 100644 toyos-lanstate/Cargo.toml create mode 100644 toyos-lanstate/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 699863fa02..b373de2df4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1130,6 +1130,7 @@ dependencies = [ "toyos-fat32-check", "toyos-gpt", "toyos-keymap", + "toyos-lanstate", "toyos-ld", "toyos-logstream", "toyos-manifest", @@ -1199,6 +1200,10 @@ version = "0.1.0" name = "toyos-keymap" version = "0.1.0" +[[package]] +name = "toyos-lanstate" +version = "0.1.0" + [[package]] name = "toyos-ld" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b3ed8e92d5..2766ff420c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "toyos-hda", "toyos-i219", "toyos-keymap", + "toyos-lanstate", "toyos-ld", "toyos-logstream", "toyos-manifest", @@ -111,6 +112,10 @@ toyos-fat32 = { path = "toyos-fat32" } toyos-fat32-check = { path = "toyos-fat32-check" } toyos-gpt = { path = "toyos-gpt" } toyos-keymap = { path = "toyos-keymap" } +# The word netd answers about the network it is on, and the grammar of the exit +# code a boot with no console reports it through: `src/lan.rs` judges that code +# and the guest job that asks netd writes it. +toyos-lanstate = { path = "toyos-lanstate" } # The record stream's boot parameter, so the gate that clears a valued # parameter by name reads the same constant the kernel and `logd` do. toyos-logstream = { path = "toyos-logstream" } diff --git a/src/bootlog.rs b/src/bootlog.rs index f4ed5ee550..146dc4b1d7 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -542,20 +542,20 @@ mod record_time_tests { assert!(why.contains(&format!("closer than {MARGIN} s before")), "{why}"); } - /// **A true reply lands a second or two past the lease and is never - /// refused**: this machine's address exists from that record onward and the - /// probe asks every second, so the lower edge may never be spent inwards. + /// **A true reply lands a second or two past the hand-over and is never + /// refused**: the driver this machine answers through is spawned after that + /// record, and the probe asks every second, so the lower edge may never be + /// spent inwards. #[test] - fn a_reply_a_second_after_the_lease_record_is_this_boots() { - let leased = BOOT.replace( + fn a_reply_a_second_after_the_hand_over_record_is_this_boots() { + let handed = BOOT.replace( "Boot: complete (1258ms)", - "netd: DHCP: lease 192.168.1.46/24 from 192.168.1.1, gateway 192.168.1.1, dns \ - [192.168.1.1], 412 ms after netd came up", + "pcidev: PCI 00:1f.6 [8086:15fc] handed over on slot 0, vector 0x28", ); - let lease_at = first() + 1; - for at in [lease_at, lease_at + 1, lease_at + 2] { - let verdict = host_second_inside_this_boot(&leased, 0, crate::lan::LEASE, at); - assert_eq!(verdict, Ok(()), "{} s after the lease", at - lease_at); + let handed_at = first() + 1; + for at in [handed_at, handed_at + 1, handed_at + 2] { + let verdict = host_second_inside_this_boot(&handed, 0, "handed over on slot", at); + assert_eq!(verdict, Ok(()), "{} s after the hand-over", at - handed_at); } } diff --git a/src/lan.rs b/src/lan.rs index 5f92414187..4c26e3a42e 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -4,11 +4,28 @@ //! **Text and frames in, verdicts out.** Nothing here touches a machine: the //! QEMU arm and the T14 arm in `tests/common/lan.rs` read their answers through //! this, so a guest and a laptop cannot be judged by different grammars. +//! +//! **The two arms do not read the same channel.** A `netd:` record exists on a +//! guest, whose console is a serial port the harness holds the other end of; +//! on the T14 there is no serial port, a userland write ends at +//! `Backend::None`, and no `netd:` line has ever reached the stick. So the +//! records below are the QEMU arm's, and the T14 arm's answers are the +//! kernel's own records and `toyos_lanstate`'s exit code. #![forbid(unsafe_code)] use std::net::Ipv4Addr; +/// The PCI function the T14's card is, as `/sys/bus/pci/devices` spells it: +/// the cable the metal loop reaches a boot over while it runs. +pub const NIC: &str = "0000:00:1f.6"; + +/// The same function as the kernel's records spell it: `/sys` names the PCI +/// segment first and the kernel's records name the bus. +pub fn kernel_function(sysfs: &str) -> &str { + sysfs.split_once(':').map_or(sysfs, |(_, function)| function) +} + /// The records both arms are written against, spelled once. pub const MAC: &str = "netd: MAC "; pub const LEASE: &str = "netd: DHCP: lease "; @@ -100,6 +117,49 @@ pub fn link_up_ms(text: &str) -> Result { .map_err(|_| format!("{line:?} carries no readable link-up time")) } +/// What the job that asked netd left in its `exit:` record, judged against the +/// cable the metal loop reached this boot over. +/// +/// `Ok(())` is netd holding the address that answered the host's ping, on the +/// card whose MAC that host read off the wire — the two halves of the cable, +/// agreed to from inside the machine. Everything else is one finding, and the +/// three kinds are separate on purpose: a machine that took no lease, a machine +/// that took another network's, and a job that never got to ask are different +/// defects and a T14 boot says nothing else about which. +pub fn job_said(code: i32, addr: Ipv4Addr, mac: &str) -> Result<(), String> { + let bytes = mac_bytes(mac) + .ok_or_else(|| format!("this readback's wire MAC reads {mac:?}, which is no MAC"))?; + match toyos_lanstate::said(code) { + toyos_lanstate::Said::Fingerprint(got) => { + let want = toyos_lanstate::fingerprint(bytes, addr); + if got == want { + return Ok(()); + } + Err(format!( + "the job that asked netd exited {got} and {addr} on {mac} folds to {want}: the \ + address netd held and the card it drove are not the pair this cable carried" + )) + } + toyos_lanstate::Said::Refused(refusal) => { + Err(format!("the job that asked netd exited {code}: {}", refusal.why())) + } + toyos_lanstate::Said::Foreign(code) => Err(format!( + "the job that asked netd exited {code}, which is no word of its grammar: it died \ + before it could ask" + )), + } +} + +/// `/sys/class/net//address`'s six bytes, as the metal loop passes them on. +fn mac_bytes(text: &str) -> Option<[u8; 6]> { + let mut bytes = [0u8; 6]; + let mut fields = text.split(':'); + for byte in bytes.iter_mut() { + *byte = u8::from_str_radix(fields.next()?, 16).ok()?; + } + fields.next().is_none().then_some(bytes) +} + /// **The one place the host-name option can be read.** A server that ignores it /// answers the same lease either way, so the frames the client sent are the only /// evidence that it asked at all — and `filter-dump` records both directions, so @@ -185,6 +245,78 @@ mod tests { ); } + /// **A word two crates send down one connection may be one word only.** + /// `toyos_lanstate::ASK` is netd's and is declared outside the SDK that + /// owns every other, so nothing but this holds the two apart: a collision + /// would make netd answer a `MsgType` with its own state and a client read + /// that state as the answer it asked for. + #[test] + fn the_state_word_is_no_message_the_sdk_sends() { + let at = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("toyos/src/net.rs"); + let source = std::fs::read_to_string(&at) + .unwrap_or_else(|e| panic!("{}: {e}", at.display())); + let words = sdk_words(&source); + // The scan before the claim: a parser that found nothing would pass + // this test on any word at all. + for (name, word) in [("TcpClose", 4), ("TcpAcceptPiped", 22), ("Error", 129)] { + assert!(words.contains(&word), "the scan missed `{name} = {word}`: {words:?}"); + } + assert!( + !words.contains(&toyos_lanstate::ASK), + "netd's state word {} is also one of the SDK's: {words:?}", + toyos_lanstate::ASK + ); + } + + /// Every number `toyos::net` puts in an IPC header, out of the SDK's own + /// source: the discriminants of its two `#[repr(u32)]` enums. + fn sdk_words(source: &str) -> Vec { + source + .lines() + .filter_map(|line| line.trim_end().strip_suffix(',')?.split_once(" = ")) + .filter_map(|(_, value)| value.trim().parse().ok()) + .collect() + } + + /// The whole grammar as `on_metal` reads it, against the pair a readback + /// carries: the fold agreeing, the fold not agreeing, every refusal, and a + /// code the job never wrote. + #[test] + fn one_exit_code_is_read_against_the_cable_the_loop_reached() { + let addr = Ipv4Addr::new(192, 168, 1, 42); + let mac = "54:bf:64:2f:0a:1c"; + let bytes = mac_bytes(mac).expect("a MAC"); + assert_eq!(job_said(toyos_lanstate::fingerprint(bytes, addr), addr, mac), Ok(())); + // The same boot, the card that held the address before it swapped. + let other = mac_bytes("54:bf:64:2f:0a:1d").expect("a MAC"); + let why = job_said(toyos_lanstate::fingerprint(other, addr), addr, mac) + .expect_err("another card's fold is not this one's"); + assert!(why.contains("not the pair this cable carried"), "{why}"); + let why = job_said(toyos_lanstate::Refusal::NoLease.code(), addr, mac) + .expect_err("a machine with no address"); + assert!(why.contains("no address"), "{why}"); + let why = job_said(0, addr, mac).expect_err("a job that exited before it asked"); + assert!(why.contains("no word of its grammar"), "{why}"); + // The MAC the loop read, refused where it is not one rather than folded + // into a mismatch that names the card. + let why = job_said(0, addr, "enp0s31f6").expect_err("an interface name is not a MAC"); + assert!(why.contains("which is no MAC"), "{why}"); + for not_a_mac in ["54:bf:64:2f:0a", "54:bf:64:2f:0a:1c:ff", "54:bf:64:2f:0a:zz", ""] { + assert_eq!(mac_bytes(not_a_mac), None, "{not_a_mac:?}"); + } + } + + /// The judge's hand-over needle is built out of [`NIC`], so the function the + /// loop reached the boot over and the function the kernel handed to netd are + /// one fact rather than two spellings that could drift apart. + #[test] + fn the_function_the_loop_reaches_is_the_one_the_kernel_records() { + assert_eq!(kernel_function(NIC), "00:1f.6"); + // The segment and nothing else: a needle short of the bus would find + // the hand-over record of whatever function shared its device number. + assert_eq!(format!("0000:{}", kernel_function(NIC)), NIC); + } + #[test] fn a_lease_record_is_read_field_by_field() { assert_eq!( diff --git a/src/metalprofile.rs b/src/metalprofile.rs index 971af2158c..ce64c1b5e8 100644 --- a/src/metalprofile.rs +++ b/src/metalprofile.rs @@ -344,10 +344,6 @@ mod sizing_tests { let hold = toyos_tco::LEASE_BOUND_MS; let job = profile.row(&job_ms_row("lancase")).expect("lancase's own allowance"); assert!(job.ceiling > hold, "{} against a {hold} ms hold", job.ceiling); - for name in ["lan.lancase.link_up_ms", "lan.lancase.lease_ms"] { - let row = profile.row(name).unwrap_or_else(|| panic!("{name} is priced")); - assert_eq!(row.ceiling, hold, "{name}"); - } } /// A boot whose allowance nobody wrote down is refused, not given the diff --git a/tests/common/lan.rs b/tests/common/lan.rs index 2c598ab980..dfabf4c519 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -1,19 +1,25 @@ //! The cable: netd taking this machine's address from the network, and the T14 //! answering the development host on it. //! -//! Every line read here is a record. On the T14 a userland `println!` reaches -//! `Backend::None`, so what crosses to the stick is the kernel's log — into -//! which netd's `say!` writes, being a `write` to a console object. +//! **The two arms read different channels.** A guest's console is a serial port +//! this process holds the other end of, so the QEMU arm reads netd's own +//! records. The T14 has no serial port: a userland write ends at +//! `Backend::None`, no `netd:` line has ever reached the stick, and the T14 arm +//! reads what the kernel records — the hand-over, the interrupt census and the +//! `exit:` code the job that asked netd left — beside what the loop measured +//! off the cable itself. use std::net::Ipv4Addr; use std::path::Path; use toyos_build::bootlog; use toyos_build::lan::{ - asked_under_its_own_name, lease_in, link_up_ms, Lease, HOSTNAME, LEASE, MAC, NO_LEASE, READY, + asked_under_its_own_name, job_said, kernel_function, lease_in, link_up_ms, Lease, HOSTNAME, + LEASE, MAC, NIC, NO_LEASE, READY, }; use toyos_build::metalprofile::Profile; +use super::irqcensus::Census; use super::metal; use super::qemu::{self, BootOptions, QemuInstance}; use super::serial; @@ -23,8 +29,10 @@ use super::serial; pub const CONFIG: &str = "tests/lancase"; pub const BOOT: &str = "lancase"; -/// The one job on that boot: it holds the machine up while the host pings it. -pub const JOBS: &[&str] = &["test_rs_lan_hold"]; +/// That boot's job list, in order: one holds the machine up while the host +/// pings it, and the second asks netd what network it is on — after the ping +/// window, so a machine that answers is a machine still holding the cable. +pub const JOBS: &[&str] = &["test_rs_lan_hold", "test_rs_lan_state"]; /// The config the QEMU arm boots — the Intel driver in front of the user-mode /// backend, which is the same driver the T14 arm runs and the only DHCP server @@ -34,11 +42,15 @@ const QEMU_CONFIG: &str = "tests/e1000case"; /// The card the T14 arm claims, as the kernel and the manifest spell it. const ID: &str = "8086:15fc"; -/// The PCI function that card is, as `/sys/bus/pci/devices` spells it: the -/// cable the metal loop reaches this boot over while it runs. -pub const NIC: &str = "0000:00:1f.6"; +/// The census source a NIC a *process* drives raises its interrupts under. +const USERDEV: &str = "userdev"; -/// The T14's judge: the claim, the card, the lease, and the host's own ping. +/// The binary behind [`JOBS`]`[1]`, as the build stages it: the runner spawns +/// it under the `test_rs_` prefix and `rust_bins` carries it under its own. +const ASKER: &str = "lan_state"; + +/// The T14's judge: the hand-over, what netd answered the job that asked it, +/// the interrupts that answer cost, and the host's own ping. pub fn on_metal(back: &metal::Readback) -> Result<(), String> { let profile = Profile::load(&super::compile::repo_root()).map_err(|why| why.to_string())?; let kernel = back.kernel(); @@ -52,10 +64,13 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { ) })?; - // A boot with no hand-over line carries the kernel's refusal instead, and - // quoting that is the whole diagnosis. - let handed = format!("[{}] handed over on slot", ID); - match text.lines().find(|l| l.contains(&handed)) { + // The function the loop reached this boot over is named in the needle, so + // the card the host read its MAC off and the card the kernel gave netd are + // one record rather than two checks. A boot with no hand-over line carries + // the kernel's refusal instead, and quoting that is the whole diagnosis. + let handed = format!("PCI {} [{ID}] handed over on slot", kernel_function(NIC)); + let handover = text.lines().find(|l| l.contains(&handed)); + match handover { Some(line) => eprintln!(" [lan] {}", line.trim()), None => bad.push(match text.lines().find(|l| l.contains("NOT HANDED OVER")) { Some(line) => format!("the kernel refused this function: {}", line.trim()), @@ -66,54 +81,37 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { }), } - // Not the link record: `link_up_ms` below already refuses its absence. - if !text.contains(READY) { - bad.push(format!("no {READY:?} record")); - } - - // One fact, one finding: a boot that named no card at all is not a boot - // that named a different one. - let mac = format!("{MAC}{}", cable.mac); - if !text.contains(&mac) { - bad.push(match text.lines().find(|l| l.contains(MAC)) { - Some(line) => format!( - "{}: the card this boot brought up is not the one that held {} before it", - line.trim(), - cable.addr - ), - None => format!("no {MAC:?} record"), - }); - } - - match link_up_ms(text) { - Ok(ms) => { - eprintln!(" [lan] the link came up {ms} ms after the driver did"); - if let Err(why) = profile.judge(&format!("lan.{}.link_up_ms", back.label), ms) { - bad.push(why.to_string()); - } + // What netd held, folded through the one word a machine with no console + // has. `Ok` is the address that answered the ping on the card the host read + // off the wire, so the address check the log used to carry is inside it. + let leased = match back.exit_code(JOBS[1]).and_then(|code| { + job_said(code, cable.addr, &cable.mac).map(|()| code) + }) { + Ok(code) => { + eprintln!(" [lan] netd held {} on {} ({code})", cable.addr, cable.mac); + true } - Err(why) => bad.push(why), - } + Err(why) => { + bad.push(why); + false + } + }; - match lease_in(text) { - Ok(lease) => { - eprintln!( - " [lan] leased {}/{} from {} in {} ms, gateway {:?}, dns {:?}", - lease.address, lease.prefix, lease.server, lease.ms, lease.gateway, lease.dns - ); - if let Err(why) = profile.judge(&format!("lan.{}.lease_ms", back.label), lease.ms) { - bad.push(why.to_string()); - } - if lease.address != cable.addr { - bad.push(format!( - "this boot leased {} and the host pinged {}, which the router hands this \ - MAC under the operating system before it — so either something else \ - answered or that server does not repeat a lease across the two", - lease.address, cable.addr - )); - } + // **A lease with no interrupt is a contradiction.** DHCP is a round trip on + // the wire and this kernel delivers a user-driven NIC's vector to the + // process that claimed it, so a boot that leased and counted none did not + // lease — it read somebody else's answer, or this census is not of this + // card. + if leased { + match userdev_raised(text) { + Ok(0) => bad.push(format!( + "netd answered with a lease and every `irq:` line of this boot reads \ + {USERDEV}=0: the card raised no interrupt, so nothing it received reached the \ + driver" + )), + Ok(raised) => eprintln!(" [lan] the card raised {raised} interrupt(s) into netd"), + Err(why) => bad.push(why), } - Err(why) => bad.push(why), } match cable.reply { @@ -125,10 +123,18 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { if let Err(why) = profile.judge(&format!("boot.{}.ping_secs", back.label), reply.secs) { bad.push(why.to_string()); } - if let Err(why) = - bootlog::host_second_inside_this_boot(text, cable.skew, LEASE, reply.at) - { - bad.push(why); + // The lower edge is the hand-over: nothing on this machine could + // answer on that function before the kernel gave it to a driver, + // and it is the earliest record of this boot that is true of. One + // fact, one finding — a boot with no hand-over record has already + // said so above, and a bracket it cannot compute is that absence + // again rather than something else about the reply. + if handover.is_some() { + if let Err(why) = + bootlog::host_second_inside_this_boot(text, cable.skew, &handed, reply.at) + { + bad.push(why); + } } } None => bad.push(format!( @@ -148,6 +154,31 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { Err(format!("{} finding(s):\n {}", bad.len(), bad.join("\n "))) } +/// Interrupts this boot delivered to a process that drives a device, summed +/// over the machine. +/// +/// The counters are cumulative, so a CPU's last census is its whole boot; a +/// boot that printed none is refused rather than summed to zero, which would +/// read as the card being silent when it is the kernel that said nothing. +fn userdev_raised(text: &str) -> Result { + let mut last: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for line in text.lines() { + match Census::parse(line) { + None => continue, + Some(Ok(census)) => { + last.insert(census.cpu, census.source(USERDEV)); + } + Some(Err(why)) => return Err(format!("{why}\nline: {line}")), + } + } + if last.is_empty() { + return Err("no `irq: cpu` census in this boot's log, so nothing here says whether the \ + card raised an interrupt" + .to_string()); + } + Ok(last.values().sum()) +} + /// The QEMU arm: the client, against a DHCP server this repository did not /// write. /// @@ -155,10 +186,16 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { /// option or read the mask off the wrong one would otherwise pass where the /// answers happen to agree; and the readiness line is checked to come *after* /// the lease, because every other arm waits for it and then connects. +/// +/// **It is also the only arm that runs the T14's own judge end to end.** The +/// job that asks netd, netd's answer and the fold the exit code carries are +/// what the metal arm has instead of a log, and here the same three are read +/// against records — netd's `MAC` line and its lease line — that the T14 does +/// not have. pub fn lan_dhcp_lease( _test_config: &Path, _c_bins: &[(String, Vec)], - _rust_bins: &[(String, Vec)], + rust_bins: &[(String, Vec)], ) -> Result<(), String> { let case = super::compile::repo_root().join(QEMU_CONFIG); let dump = std::env::temp_dir().join(format!("toyos-lan-{}.pcap", std::process::id())); @@ -171,9 +208,17 @@ pub fn lan_dhcp_lease( if !qemu::profile_argv(&options).iter().any(|a| a.contains("e1000e")) { return Err("this test needs an Intel NIC and the profile has none".to_string()); } - let mut guest = QemuInstance::boot_with_options(&case, &[], &[], options); + let asker: Vec<(String, Vec)> = + rust_bins.iter().filter(|(name, _)| name == ASKER).cloned().collect(); + if asker.is_empty() { + return Err(format!("{ASKER} was not built")); + } + let mut guest = QemuInstance::boot_with_options(&case, &[], &asker, options); let mut console = guest.boot_log().to_string(); qemu::await_marker(&mut guest, &mut console, READY, "netd to take an address")?; + let asked = guest.run_test(JOBS[1], std::time::Duration::from_secs(30)); + console.push_str(&asked.before); + console.push_str(&asked.serial); console.push_str(&guest.drain_serial(std::time::Duration::from_millis(500))); // QEMU owns the pcap while it runs, and every refusal below is a return: // the frames are taken once the machine is gone and the file removed here. @@ -205,6 +250,22 @@ pub fn lan_dhcp_lease( started", lease.ms ); + // What the job that asks netd exits with, against the two records netd + // wrote about the same two facts. **Nothing in the guest computes both + // sides**: netd answered the job out of its interface and wrote these lines + // out of the driver, and the fold is taken here. + let announced = log + .text() + .lines() + .find_map(|l| l.split(MAC).nth(1)?.split_whitespace().next()) + .ok_or_else(|| format!("no {MAC:?} record: netd named no card"))?; + let code = asked + .exit_code + .ok_or_else(|| format!("{ASKER} left no exit code: {:?}\n{}", asked.error, asked.stdout))?; + if let Err(why) = job_said(code, lease.address, announced) { + return Err(format!("{why}\n{}", asked.stdout)); + } + eprintln!(" [lan] netd answered {ASKER} with {announced} and {}", lease.address); log.must_be_clean()?; asked_under_its_own_name(&frames)?; eprintln!(" [lan] the client asked under its own name on the wire"); diff --git a/tests/lancase/system.toml b/tests/lancase/system.toml index 5eaf3999ff..8c7f90f653 100644 --- a/tests/lancase/system.toml +++ b/tests/lancase/system.toml @@ -18,4 +18,7 @@ serves = ["netd"] devices = ["pci:8086:15fc"] [programs.test-runner] +# The job that asks netd what network this machine is on connects through the +# namespace it inherits from this program. +receives = ["netd"] syscap = ["logread"] diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index 1e042b850a..cd56ae3113 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -611,6 +611,11 @@ ceiling = 30 ceiling_from = "as boot.testcases.stick_secs" # --- the cable: the boot that runs netd in front of the T14's own I219 --- +# netd's own spans are not among them. It writes `netd: I219: link up at ` and +# `netd: DHCP: lease ` to a console whose backend on this machine is `None`, so +# no such line has ever reached the stick; what that boot reports about its +# network is the exit code of the job that asked netd, which is a fold and not a +# number this file can price. [[number]] name = "boot.lancase.complete_ms" @@ -644,15 +649,3 @@ name = "list.lancase.job_ms" unit = "ms" ceiling = 22000 ceiling_from = "toyos_tco::LEASE_BOUND_MS, which is lan_hold's whole sleep, plus two seconds for the spawn and the exit record around it" - -[[number]] -name = "lan.lancase.link_up_ms" -unit = "ms" -ceiling = 20000 -ceiling_from = "toyos_tco::LEASE_BOUND_MS: a link that comes up later than lan_hold sleeps is a link this boot never had, because the machine has already handed itself back" - -[[number]] -name = "lan.lancase.lease_ms" -unit = "ms" -ceiling = 20000 -ceiling_from = "as lan.lancase.link_up_ms, which is netd's own dhcp::LEASE_BOUND: a boot with no lease by then has already said so in its log" diff --git a/tests/toyos-rust-tests/Cargo.lock b/tests/toyos-rust-tests/Cargo.lock index 1b51427e87..4c6106f60d 100644 --- a/tests/toyos-rust-tests/Cargo.lock +++ b/tests/toyos-rust-tests/Cargo.lock @@ -1471,6 +1471,10 @@ version = "0.1.0" name = "toyos-keymap" version = "0.1.0" +[[package]] +name = "toyos-lanstate" +version = "0.1.0" + [[package]] name = "toyos-rust-tests" version = "0.1.0" @@ -1483,6 +1487,7 @@ dependencies = [ "sha2", "toyos 0.6.0", "toyos-abi 0.5.0", + "toyos-lanstate", "toyos-tco", "toyos-window", "ureq", diff --git a/tests/toyos-rust-tests/Cargo.toml b/tests/toyos-rust-tests/Cargo.toml index 05cb4b2bd4..e991b4243d 100644 --- a/tests/toyos-rust-tests/Cargo.toml +++ b/tests/toyos-rust-tests/Cargo.toml @@ -9,6 +9,7 @@ toyos-abi = { path = "../../toyos-abi" } toyos = { path = "../../toyos" } toyos-window = { path = "../../userland/toyos-window" } toyos-tco = { path = "../../toyos-tco" } +toyos-lanstate = { path = "../../toyos-lanstate" } libloading = { git = "https://github.com/ToyOSOrg/rust_libloading", branch = "toyos" } cpal = { git = "https://github.com/ToyOSOrg/cpal", branch = "toyos-0.18.0-sdk-0.2" } ureq = { version = "3", default-features = false, features = ["rustls-no-provider", "rustls-webpki-roots"] } diff --git a/tests/toyos-rust-tests/src/bin/lan_state.rs b/tests/toyos-rust-tests/src/bin/lan_state.rs new file mode 100644 index 0000000000..90209b080f --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/lan_state.rs @@ -0,0 +1,31 @@ +//! Ask netd what network this machine is on, and exit with the answer. +//! +//! **The exit code is the whole report.** On the ThinkPad T14 there is no +//! serial port and a userland write ends at `Backend::None`, so nothing this +//! program prints reaches the harness; the kernel's `exit: pid=N code=N` +//! record is the one word that crosses, and `toyos_lanstate` is the grammar the +//! host reads it back with. + +use toyos::endow; +use toyos::net::RespType; +use toyos_lanstate::{Refusal, State, ANSWER_LEN, ASK}; + +fn main() { + std::process::exit(match asked() { + Ok(state) => state.code(), + Err(refusal) => refusal.code(), + }); +} + +/// One question and one answer, over the port netd already serves. +fn asked() -> Result { + let netd = endow::service("netd").map_err(|_| Refusal::NoNetd)?; + netd.signal(ASK).map_err(|_| Refusal::NoNetd)?; + let header = netd.recv_header().map_err(|_| Refusal::NoNetd)?; + if header.msg_type != RespType::Result as u32 { + return Err(Refusal::Unanswered); + } + let mut answer = [0u8; ANSWER_LEN]; + let got = netd.recv_bytes(&header, &mut answer).map_err(|_| Refusal::Unanswered)?; + State::decode(&answer[..got]).ok_or(Refusal::Malformed) +} diff --git a/tests/toyos.rs b/tests/toyos.rs index 22f3ee6185..115f716306 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -236,6 +236,10 @@ const RUST_SKIP: &[&str] = &[ // `lan_dhcp_lease`'s metal arm is the only job list that names it. On a // shared boot it would be twenty seconds of nothing. "lan_hold", + // Needs netd with a NIC in front of it and a `netd` connector in its own + // namespace, which only `tests/e1000case` and `tests/lancase` give a job. + // `lan_dhcp_lease` runs it on the first and its metal arm on the second. + "lan_state", // Needs SYS_DEBUG, which the shipping kernel has no arm of at all. // `heap_ceiling_recovery` boots the `test-actuators` kernel on one CPU, // which is also what makes its claim about *the recovered CPU* precise. @@ -1686,11 +1690,14 @@ const USB_RESET_BOOTS: &[metal::Arm] = &[ const METALCASE: &[metal::Arm] = &[metal::once("metalcase", "tests/metalcase", &[], &[])]; -/// The cable's own boot: netd in front of the T14's I219, and one job that -/// holds the machine up long enough for the host to reach it. The one arm in -/// this suite that names a PCI function for the loop to reach the boot over. -const LANCASE: &[metal::Arm] = - &[metal::Arm { nic: Some(lan::NIC), ..metal::once(lan::BOOT, lan::CONFIG, &[], lan::JOBS) }]; +/// The cable's own boot: netd in front of the T14's I219, one job that holds +/// the machine up long enough for the host to reach it and one that asks netd +/// what network it is on. The one arm in this suite that names a PCI function +/// for the loop to reach the boot over. +const LANCASE: &[metal::Arm] = &[metal::Arm { + nic: Some(toyos_build::lan::NIC), + ..metal::once(lan::BOOT, lan::CONFIG, &[], lan::JOBS) +}]; /// One boot for every in-kernel self-test that logs its verdict at init and /// does nothing else. diff --git a/toyos-lanstate/Cargo.toml b/toyos-lanstate/Cargo.toml new file mode 100644 index 0000000000..72b1ce3c3d --- /dev/null +++ b/toyos-lanstate/Cargo.toml @@ -0,0 +1,10 @@ +# A member of the host workspace (root `Cargo.toml`), like toyos-tco: netd +# depends on it by path from the userland workspace, the guest job that asks +# netd does the same from the harness's, and its tests run on the host beside +# the judge that reads what they declare. + +[package] +name = "toyos-lanstate" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" diff --git a/toyos-lanstate/src/lib.rs b/toyos-lanstate/src/lib.rs new file mode 100644 index 0000000000..1fddaee5f4 --- /dev/null +++ b/toyos-lanstate/src/lib.rs @@ -0,0 +1,237 @@ +//! What netd holds of the network it is on, and the one word a boot with no +//! console has to say it with. +//! +//! **The channel is a process's exit code.** On a machine with no serial port a +//! userland write ends at `Backend::None`, so a job's whole verdict crosses as +//! the kernel's `exit: pid=N code=N` record, which carries the full +//! `i32`. An address and a MAC are eighty bits and the record carries +//! thirty-two, so what crosses is a fold of the pair: the judge already holds +//! what the pair must be — it pinged the address and read the MAC off the wire +//! — and recomputes the same fold. A fingerprint is what a channel narrower +//! than its answer leaves. +//! +//! Three crates read this file and none of them shares another's: netd answers +//! [`ASK`], the job that asked turns the answer into an exit code, and the +//! harness reads that code back out of the record. + +#![no_std] +#![forbid(unsafe_code)] + +#[cfg(test)] +extern crate std; + +use core::net::Ipv4Addr; + +/// The request netd answers with [`State::encode`]'s bytes. +/// +/// **Not one of `toyos::net::MsgType`'s words**: nothing that *uses* the +/// network sends it — it is how a machine whose output reaches nobody reports +/// the network it is on — and it is held clear of every word the SDK does send +/// by `toyos_build::lan`'s scan of that file. +pub const ASK: u32 = 0x4c_41_4e; + +/// netd's answer: the MAC of the card it drives, then the address its lease +/// gave this machine. +pub const ANSWER_LEN: usize = 10; + +/// What netd holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct State { + pub mac: [u8; 6], + /// `None` where this machine took no lease. + pub address: Option, +} + +impl State { + /// The answer as netd writes it. An absent address is four zero bytes, + /// which no lease is: RFC 1122 §3.2.1.3 gives 0.0.0.0 to a host that does + /// not yet know its own address, and a server never assigns it. + pub fn encode(&self) -> [u8; ANSWER_LEN] { + let mut out = [0u8; ANSWER_LEN]; + out[..6].copy_from_slice(&self.mac); + if let Some(address) = self.address { + out[6..].copy_from_slice(&address.octets()); + } + out + } + + /// The answer as the job reads it, or `None` where those are not bytes this + /// grammar wrote. + pub fn decode(bytes: &[u8]) -> Option { + let bytes: [u8; ANSWER_LEN] = bytes.try_into().ok()?; + let (mac, octets) = bytes.split_at(6); + let address = Ipv4Addr::from([octets[0], octets[1], octets[2], octets[3]]); + Some(Self { + mac: mac.try_into().ok()?, + address: (!address.is_unspecified()).then_some(address), + }) + } + + /// The code a job that got this answer exits with. + pub fn code(&self) -> i32 { + match self.address { + Some(address) => fingerprint(self.mac, address), + None => Refusal::NoLease.code(), + } + } +} + +/// Why a job has no state to report, as the negative exit codes the host reads. +/// +/// **Negative, and never zero**, as `metalprobe`'s refusals are: a refusal may +/// not share the space with an answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// This program's namespace holds no netd, or netd has gone. + NoNetd = -1, + /// netd took the question and refused it, or hung up on it. + Unanswered = -2, + /// netd answered bytes this grammar does not read. + Malformed = -3, + /// netd drives a card and this machine has no address. + NoLease = -4, +} + +impl Refusal { + pub fn code(self) -> i32 { + self as i32 + } + + /// The whole finding, because the code is the whole of what the host has. + pub fn why(self) -> &'static str { + match self { + Self::NoNetd => "the job's namespace held no netd, or netd had already gone", + Self::Unanswered => "netd refused the question or hung up on it", + Self::Malformed => "netd answered bytes this grammar does not read", + Self::NoLease => "netd drove the card and this machine had no address: no lease", + } + } + + fn from_code(code: i32) -> Option { + [Self::NoNetd, Self::Unanswered, Self::Malformed, Self::NoLease] + .into_iter() + .find(|refusal| refusal.code() == code) + } +} + +/// The lowest fingerprint. +/// +/// **A code below it is no word this grammar wrote.** A job the kernel killed +/// or one that panicked exits with a small positive number, and read as a +/// fingerprint that merely did not match it would be diagnosed as a card swap. +pub const FIRST_FINGERPRINT: i32 = 1 << 30; + +/// The pair folded into the thirty bits left over [`FIRST_FINGERPRINT`], FNV-1a +/// (Fowler–Noll–Vo, 32 bit) over the MAC and then the address. +/// +/// A fold and not the value: two eighty-bit answers cannot both be carried by a +/// thirty-two-bit channel, so what the judge can ask is agreement with the pair +/// it already holds. Two different pairs fold together with probability 2^-30. +pub fn fingerprint(mac: [u8; 6], address: Ipv4Addr) -> i32 { + const OFFSET_BASIS: u32 = 0x811c_9dc5; + const PRIME: u32 = 0x0100_0193; + let mut hash = OFFSET_BASIS; + for byte in mac.iter().chain(address.octets().iter()) { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(PRIME); + } + let width = FIRST_FINGERPRINT as u32 - 1; + FIRST_FINGERPRINT | (hash & width) as i32 +} + +/// What one exit code says. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Said { + /// The fold of the pair netd held. + Fingerprint(i32), + Refused(Refusal), + /// A code no job of this family wrote. + Foreign(i32), +} + +/// Read one `exit: pid=N code=` record's code. +pub fn said(code: i32) -> Said { + if code >= FIRST_FINGERPRINT { + return Said::Fingerprint(code); + } + match Refusal::from_code(code) { + Some(refusal) => Said::Refused(refusal), + None => Said::Foreign(code), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAC: [u8; 6] = [0x54, 0xbf, 0x64, 0x2f, 0x0a, 0x1c]; + const ADDR: Ipv4Addr = Ipv4Addr::new(192, 168, 1, 42); + + #[test] + fn an_answer_survives_the_wire_whether_or_not_it_carries_a_lease() { + for address in [Some(ADDR), None] { + let state = State { mac: MAC, address }; + assert_eq!(State::decode(&state.encode()), Some(state)); + } + // The absence is four zero bytes and not a shorter answer: a reader + // that trusted the length would take a truncated frame for a lease. + assert_eq!(State { mac: MAC, address: None }.encode()[6..], [0, 0, 0, 0]); + } + + #[test] + fn bytes_this_grammar_did_not_write_are_no_answer() { + let whole = State { mac: MAC, address: Some(ADDR) }.encode(); + assert_eq!(State::decode(&whole[..ANSWER_LEN - 1]), None); + let mut longer = std::vec::Vec::from(whole); + longer.push(0); + assert_eq!(State::decode(&longer), None); + assert_eq!(State::decode(&[]), None); + } + + /// **The band is what tells a verdict from an accident.** Every code a + /// process can leave that this grammar did not write — a zero exit, a + /// panic, a kill — reads as foreign rather than as a fingerprint that + /// disagreed, which is a different finding. + #[test] + fn a_code_no_job_of_this_family_wrote_is_refused_as_foreign() { + for code in [0, 1, 101, 139, FIRST_FINGERPRINT - 1, -5, i32::MIN] { + assert_eq!(said(code), Said::Foreign(code), "{code}"); + } + assert_eq!(said(FIRST_FINGERPRINT), Said::Fingerprint(FIRST_FINGERPRINT)); + } + + #[test] + fn every_refusal_reaches_the_judge_by_its_own_name() { + for refusal in [Refusal::NoNetd, Refusal::Unanswered, Refusal::Malformed, Refusal::NoLease] + { + assert!(refusal.code() < 0, "{refusal:?}"); + assert_eq!(said(refusal.code()), Said::Refused(refusal)); + assert!(!refusal.why().is_empty()); + } + assert_eq!(State { mac: MAC, address: None }.code(), Refusal::NoLease.code()); + } + + /// One byte of either half moves the fold, and the fold stays in the band: + /// a fingerprint that could collide with a refusal or with a foreign code + /// would make the grammar's three answers two. + #[test] + fn a_fingerprint_is_of_the_whole_pair_and_stays_in_its_band() { + let whole = State { mac: MAC, address: Some(ADDR) }.code(); + assert_eq!(whole, fingerprint(MAC, ADDR)); + let mut moved = std::vec::Vec::new(); + for i in 0..6 { + let mut mac = MAC; + mac[i] = mac[i].wrapping_add(1); + moved.push(fingerprint(mac, ADDR)); + } + for i in 0..4 { + let mut octets = ADDR.octets(); + octets[i] = octets[i].wrapping_add(1); + moved.push(fingerprint(MAC, Ipv4Addr::from(octets))); + } + for other in &moved { + assert_ne!(*other, whole); + assert_eq!(said(*other), Said::Fingerprint(*other), "{other}"); + } + } +} diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 193c50303d..ff417064da 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -1922,6 +1922,7 @@ dependencies = [ "toyos 0.6.0", "toyos-abi 0.5.0", "toyos-i219", + "toyos-lanstate", "toyos-tco", ] @@ -3779,6 +3780,10 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06db3c1cb438056c9b9a766c8f0dbbfdf3331e723a5bc7f287badc0cfb2f7f78" +[[package]] +name = "toyos-lanstate" +version = "0.1.0" + [[package]] name = "toyos-logstream" version = "0.1.0" diff --git a/userland/netd/Cargo.toml b/userland/netd/Cargo.toml index 8a29c74cdd..796e382e16 100644 --- a/userland/netd/Cargo.toml +++ b/userland/netd/Cargo.toml @@ -8,6 +8,7 @@ toyos-abi = { path = "../../toyos-abi" } toyos = { path = "../../toyos" } toyos-i219 = { path = "../../toyos-i219" } toyos-tco = { path = "../../toyos-tco" } +toyos-lanstate = { path = "../../toyos-lanstate" } [dependencies.smoltcp] version = "0.12" diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index 2ed6db5d15..b5d13d5634 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -516,10 +516,14 @@ struct NetDaemon { pending_piped_connects: Vec, udp_pipes: HashMap, max_piped_connections: usize, + /// The card's own, as the driver read it out of the register file: the six + /// bytes `netd: MAC` announces, kept because a boot with no console has + /// only [`toyos_lanstate::ASK`] to report them through. + mac: [u8; 6], } impl NetDaemon { - fn new(dns_handle: SocketHandle, max_piped_connections: usize) -> Self { + fn new(dns_handle: SocketHandle, max_piped_connections: usize, mac: [u8; 6]) -> Self { Self { sockets: HashMap::new(), next_id: 1, @@ -532,6 +536,7 @@ impl NetDaemon { pending_piped_connects: Vec::new(), udp_pipes: HashMap::new(), max_piped_connections, + mac, } } @@ -588,6 +593,15 @@ impl NetDaemon { Some(MsgType::TcpConnectPiped) => self.handle_tcp_connect_piped(req, socket_set, iface), Some(MsgType::TcpBindPiped) => self.handle_tcp_bind_piped(&req, socket_set), Some(MsgType::TcpAcceptPiped) => self.handle_tcp_accept_piped(&req, socket_set), + // A word of netd's own that the SDK does not send: the one channel + // a machine with no console has for saying what network it is on. + None if req.msg_type == toyos_lanstate::ASK => { + let state = toyos_lanstate::State { + mac: self.mac, + address: iface.ipv4_addr(), + }; + req.client.result_bytes(&state.encode()); + } None => { say!("netd: unknown message type {}", req.msg_type); req.client.error(ERR_INVALID_INPUT); @@ -1325,7 +1339,7 @@ fn main() { let total_mem = total_memory(); let max_piped = max_piped_connections(total_mem); - let mut daemon = NetDaemon::new(dns_handle, max_piped); + let mut daemon = NetDaemon::new(dns_handle, max_piped, mac); // Sized for the slot ceiling rather than for `max_piped`: the batch // between two `wait` calls is the two fixed registrations, one per live piped From cf5f720d1a7520aa6ed2828bb6ab139e80dfd767 Mon Sep 17 00:00:00 2001 From: japabu Date: Sun, 13 Sep 2026 17:56:10 +0200 Subject: [PATCH 23/23] Every decision the cable's judge makes is in one crate, and an arm holds it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 6 refused eleven things about the judge rebuilt on what crosses. The answers, in the order the review put them. The fold's dispersion is now bought. `a_fingerprint_is_of_the_whole_pair_and_ stays_in_its_band` asks two more things of it: that a permutation of the pair folds somewhere else, and that a single-byte move reaches above the low byte. Deleting `hash = hash.wrapping_mul(PRIME)` — the whole of what makes it a fold rather than an XOR — reds that arm (EXIT=101) where it used to leave the crate green. The SDK-word scan reaches every notation a discriminant is written in. It read `= ,` only, which is blind to the hex-with-separators spelling `ASK` itself is written in, so a collision spelled that way would have read as absent. `discriminant` now takes hex and digit separators, and the test asserts the scan finds `ASK` under four spellings of it before it asserts the SDK sends none. Over `toyos/src/net.rs` as written the scan still yields the same fourteen. The judge's two text decisions moved out of the harness glue into `src/lan.rs`, where the module header already claimed every answer both arms read lives, and `tests/toyos.rs` is `harness = false` so a `#[test]` beside them could never run. `handed_over` is the hand-over record or the refusal, and `interrupts_into_the_driver` is what the card raised; `tests/common/lan.rs` keeps only the census adapter and the call. Two fixture tests hold them, and they are the judge's own negative controls: unscoping the refusal match reds `the_hand_over_this_boot_owes_is_read_of_this_function_alone` (EXIT=101). `NOT HANDED OVER` is matched against this function alone. Another function's refusal was quoted as "the kernel refused this function", which is one fact with the wrong diagnosis on it; a boot that refused some other card now reads as the card being absent, which is what it is. The hand-over head is declared once. `src/bootlog.rs` gains `HANDED_OVER` and `NOT_HANDED_OVER` beside `EXIT` and `SPAWN`, and the four sites that spelled the record inline — `tests/toyos.rs`, `tests/common/faults.rs`, `tests/common/iommu.rs` and this branch's own bracket fixture — read them. The bracket's anchor is now the matched line itself, so the needle the judge builds and the needle it brackets on are one string. netd's `mac` field, its `NetDaemon::new` parameter and their doc are gone: `iface.hardware_addr()` is the interface's own, two lines from the `iface.ipv4_addr()` the same arm already reads. `lan_no_lease` runs the asker. It is the one boot in this tree that produces `Refusal::NoLease` from a real netd on a real wire, and it asserted nothing about it; it now runs `lan_state` after netd's own refusal line and checks the exit code is that refusal and no other. `Lease::ms` and `link_up_ms` are deleted with everything that fed them — the field, the parser, its refusal arm, `LINK_UP`, the two `eprintln!`s and `a_link_that_was_already_up_is_told_from_one_that_came_up`. `ms` was compared against itself inside the expected value and `link_up_ms`'s number reached only a print. The dewrap fixture moves to `LEASE`, a head something still reads. `lan_state` exits `Unanswered` where netd takes the question and hangs up, which is what `Unanswered`'s own declaration names; it exited `NoNetd`. `Refusal`'s declaration says which binary's exit codes it reads, so the two grammars on this tree — `metalprobe`'s `Refused` and this one — cannot be applied to each other's records by a reader holding only a number. `State::decode` has no fallible arm over an infallible conversion. The prose findings are answered by deletion: the no-console paragraph is kept once, in the crate whose whole existence is that channel, and deleted from `src/lan.rs`, `tests/common/lan.rs`, `lan_state.rs` and `tests/metal-profile.toml`; with it go "has ever reached the stick", what an earlier implementation carried, the fingerprint aphorism, the fold's restatement of its own module header, the unmeasured 2^-30, RFC 1122's second claim and the narration of `receives` in `tests/lancase/system.toml`. Gates, each with the exit status of the command itself: cargo test --lib EXIT=0 319 passed, 1 ignored cargo test --workspace --exclude toyos-build EXIT=0 140 ok, 0 FAILED cargo run -- --clippy EXIT=0 5 invocations clean cargo test --test toyos-build --no-run EXIT=0 cargo check -p netd EXIT=0 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK --- src/bootlog.rs | 13 +- src/lan.rs | 168 +++++++++++++++----- tests/common/faults.rs | 4 +- tests/common/iommu.rs | 4 +- tests/common/lan.rs | 136 +++++++--------- tests/lancase/system.toml | 2 - tests/metal-profile.toml | 5 - tests/toyos-rust-tests/src/bin/lan_state.rs | 13 +- tests/toyos.rs | 3 +- toyos-lanstate/src/lib.rs | 35 ++-- userland/netd/src/main.rs | 15 +- 11 files changed, 232 insertions(+), 166 deletions(-) diff --git a/src/bootlog.rs b/src/bootlog.rs index 146dc4b1d7..275b40c97b 100644 --- a/src/bootlog.rs +++ b/src/bootlog.rs @@ -143,6 +143,15 @@ pub const SPAWN: &str = "spawn: "; /// trailing ` online` as a separate word: the same head carries the failure. pub const AP_BRINGUP: &str = "SMP: AP cpu"; +/// The kernel's record for a PCI function it gave to a driver above the +/// boundary, in `kernel/src/pcidev/mod.rs`. A reader names the function ahead of +/// it: the head alone finds whichever card this boot handed over. +pub const HANDED_OVER: &str = "handed over on slot"; + +/// The record the same site writes where a function could not be given away, +/// with the reason after an em dash. +pub const NOT_HANDED_OVER: &str = "NOT HANDED OVER"; + /// `kernel/src/process.rs`'s `THREAD_NAME_LEN`, one byte of which is the /// terminator `make_name` leaves. const NAME_LEN: usize = 28; @@ -550,11 +559,11 @@ mod record_time_tests { fn a_reply_a_second_after_the_hand_over_record_is_this_boots() { let handed = BOOT.replace( "Boot: complete (1258ms)", - "pcidev: PCI 00:1f.6 [8086:15fc] handed over on slot 0, vector 0x28", + &format!("pcidev: PCI 00:1f.6 [8086:15fc] {HANDED_OVER} 0, vector 0x28"), ); let handed_at = first() + 1; for at in [handed_at, handed_at + 1, handed_at + 2] { - let verdict = host_second_inside_this_boot(&handed, 0, "handed over on slot", at); + let verdict = host_second_inside_this_boot(&handed, 0, HANDED_OVER, at); assert_eq!(verdict, Ok(()), "{} s after the hand-over", at - handed_at); } } diff --git a/src/lan.rs b/src/lan.rs index 4c26e3a42e..f2a9c090ef 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -5,21 +5,24 @@ //! QEMU arm and the T14 arm in `tests/common/lan.rs` read their answers through //! this, so a guest and a laptop cannot be judged by different grammars. //! -//! **The two arms do not read the same channel.** A `netd:` record exists on a -//! guest, whose console is a serial port the harness holds the other end of; -//! on the T14 there is no serial port, a userland write ends at -//! `Backend::None`, and no `netd:` line has ever reached the stick. So the -//! records below are the QEMU arm's, and the T14 arm's answers are the -//! kernel's own records and `toyos_lanstate`'s exit code. +//! The `netd:` records below are the QEMU arm's; the T14 arm reads the kernel's +//! own records and `toyos_lanstate`'s exit code. #![forbid(unsafe_code)] +use std::collections::BTreeMap; use std::net::Ipv4Addr; +use crate::bootlog; + /// The PCI function the T14's card is, as `/sys/bus/pci/devices` spells it: /// the cable the metal loop reaches a boot over while it runs. pub const NIC: &str = "0000:00:1f.6"; +/// The card on that function, as the kernel's records and `tests/lancase`'s +/// `devices` row spell it. +const ID: &str = "8086:15fc"; + /// The same function as the kernel's records spell it: `/sys` names the PCI /// segment first and the kernel's records name the bus. pub fn kernel_function(sysfs: &str) -> &str { @@ -29,7 +32,6 @@ pub fn kernel_function(sysfs: &str) -> &str { /// The records both arms are written against, spelled once. pub const MAC: &str = "netd: MAC "; pub const LEASE: &str = "netd: DHCP: lease "; -const LINK_UP: &str = "netd: I219: link up at "; pub const READY: &str = "netd: ready, at most "; pub const NO_LEASE: &str = "netd: DHCP: no lease as "; @@ -57,8 +59,6 @@ pub struct Lease { /// `None` where the server sent no router option: netd writes `gateway none`. pub gateway: Option, pub dns: Vec, - /// Milliseconds between netd starting and the lease landing. - pub ms: u64, } /// The lease record, read out of a boot's log. @@ -95,26 +95,57 @@ pub fn lease_in(text: &str) -> Result { server: address("the server's address", after(" from ", ",")?)?, gateway, dns, - ms: after("], ", " ms after netd came up")? - .parse() - .map_err(|_| unreadable("a millisecond count"))?, }) } -/// How long after the driver came up the link did, out of the driver's own -/// record. -pub fn link_up_ms(text: &str) -> Result { - let line = text.lines().find(|l| l.contains(LINK_UP)).ok_or_else(|| { - format!("no {LINK_UP:?} record: this boot's card never reported a link") - })?; - let (_, rest) = line.split_once(", ").ok_or_else(|| { - format!("{line:?} says nothing about when the link came up, so the card was already up") - })?; - rest.split_once(" ms after the driver came up") - .ok_or_else(|| format!("{line:?} carries no link-up time"))? - .0 - .parse() - .map_err(|_| format!("{line:?} carries no readable link-up time")) +/// The record the kernel wrote as it gave this boot's NIC to a driver, or why +/// there is none. +/// +/// **The needle is built out of [`NIC`]**, so the card the host read its MAC off +/// and the card the kernel gave away are one record rather than two checks that +/// could drift apart. A refusal is quoted only where it is this function's: +/// another function's is one fact with the wrong diagnosis attached to it. +pub fn handed_over(text: &str) -> Result<&str, String> { + let function = kernel_function(NIC); + let handed = format!("PCI {function} [{ID}] {}", bootlog::HANDED_OVER); + if let Some(line) = text.lines().find(|l| l.contains(&handed)) { + return Ok(line.trim()); + } + let refused = format!("PCI {function} {}", bootlog::NOT_HANDED_OVER); + match text.lines().find(|l| l.contains(&refused)) { + Some(line) => Err(format!("the kernel refused {NIC}: {}", line.trim())), + None => Err(format!( + "no `{handed}` record and no refusal either: nothing on this machine claimed {ID}, \ + so `tests/lancase` was flashed onto a machine that has no such card" + )), + } +} + +/// What the card raised into the process driving it, out of each CPU's census +/// of the source a user-driven device's vector is counted under. +/// +/// **A lease with no interrupt is a contradiction**: DHCP is a round trip on the +/// wire and this kernel delivers a user-driven NIC's vector to the process that +/// claimed it, so a boot that leased and counted none did not lease — it read +/// somebody else's answer, or the census is not of this card. The counters are +/// cumulative, so a CPU's last census is its whole boot; a boot that printed +/// none at all is a different finding and says so rather than summing to zero. +pub fn interrupts_into_the_driver(census: &[(u32, u64)]) -> Result { + if census.is_empty() { + return Err("no `irq: cpu` census in this boot's log, so nothing here says whether the \ + card raised an interrupt" + .to_string()); + } + let mut last: BTreeMap = BTreeMap::new(); + for (cpu, raised) in census { + last.insert(*cpu, *raised); + } + match last.values().sum::() { + 0 => Err("every `irq:` census of this boot counts none for the card netd drives: it \ + raised no interrupt, so nothing it received reached the driver" + .to_string()), + raised => Ok(raised), + } } /// What the job that asked netd left in its `exit:` record, judged against the @@ -215,8 +246,8 @@ mod tests { /// today, so a scan over the file as written is green either way. #[test] fn a_head_rustfmt_split_across_two_lines_reads_as_one() { - let wrapped = " crate::say!(\"netd: I219: link up \\\n at {} Mb/s\");"; - let head = format!("\"{LINK_UP}"); + let wrapped = " crate::say!(\"netd: DHCP: \\\n lease {}/{} from {}\");"; + let head = format!("\"{LEASE}"); assert!(!wrapped.contains(&head), "this fixture carries no wrap to close up"); assert!(dewrapped(wrapped).contains(&head), "the wrap still swallows {head:?}"); } @@ -232,7 +263,7 @@ mod tests { std::fs::read_to_string(&at).unwrap_or_else(|e| panic!("{}: {e}", at.display())) }; let source = dewrapped(&["main.rs", "i219.rs", "dhcp.rs"].map(read).join("\n")); - for head in [MAC, LEASE, LINK_UP, READY, NO_LEASE] { + for head in [MAC, LEASE, READY, NO_LEASE] { assert!(source.contains(&format!("\"{head}")), "netd opens no record with {head:?}"); } assert!( @@ -261,6 +292,15 @@ mod tests { for (name, word) in [("TcpClose", 4), ("TcpAcceptPiped", 22), ("Error", 129)] { assert!(words.contains(&word), "the scan missed `{name} = {word}`: {words:?}"); } + // And every notation a discriminant may be written in, `ASK`'s own + // among them: a scan blind to one would call a collision spelled that + // way absent. + let ask = toyos_lanstate::ASK; + for spelling in + [format!("{ask}"), format!("{ask:#x}"), "4_997_454".to_string(), "0x4c_41_4e".into()] + { + assert_eq!(sdk_words(&format!(" Ask = {spelling},\n")), [ask], "{spelling}"); + } assert!( !words.contains(&toyos_lanstate::ASK), "netd's state word {} is also one of the SDK's: {words:?}", @@ -274,10 +314,21 @@ mod tests { source .lines() .filter_map(|line| line.trim_end().strip_suffix(',')?.split_once(" = ")) - .filter_map(|(_, value)| value.trim().parse().ok()) + .filter_map(|(_, value)| discriminant(value.trim())) .collect() } + /// One discriminant in any notation Rust spells one in: decimal or hex, + /// with or without the digit separators [`toyos_lanstate::ASK`] itself is + /// written with. + fn discriminant(value: &str) -> Option { + let value = value.replace('_', ""); + match value.strip_prefix("0x") { + Some(hex) => u32::from_str_radix(hex, 16).ok(), + None => value.parse().ok(), + } + } + /// The whole grammar as `on_metal` reads it, against the pair a readback /// carries: the fold agreeing, the fold not agreeing, every refusal, and a /// code the job never wrote. @@ -317,6 +368,51 @@ mod tests { assert_eq!(format!("0000:{}", kernel_function(NIC)), NIC); } + /// The kernel's own record, as `kernel/src/pcidev/mod.rs` writes it. + fn handover_line(function: &str) -> String { + format!( + "[2026-09-13 14:27:17 1.450 cpu0] pcidev: PCI {function} [{ID}] {} 0, vector 0x28 \ + on MSI", + bootlog::HANDED_OVER + ) + } + + #[test] + fn the_hand_over_this_boot_owes_is_read_of_this_function_alone() { + let line = handover_line(kernel_function(NIC)); + assert_eq!(handed_over(&format!("boot\n{line}\nmore\n")), Ok(line.as_str())); + // Another function's hand-over is not this one's. + let elsewhere = handover_line("00:1f.3"); + let why = handed_over(&elsewhere).expect_err("a different function"); + assert!(why.contains("has no such card"), "{why}"); + // This function's refusal is quoted as the diagnosis… + let refused = format!( + "[x] pcidev: PCI {} {} — it would have no address space of its own", + kernel_function(NIC), + bootlog::NOT_HANDED_OVER + ); + let why = handed_over(&refused).expect_err("a function the kernel would not give away"); + assert!(why.contains("no address space of its own"), "{why}"); + // …and another function's is not, because quoting it would put the + // wrong diagnosis on the one fact this boot has. + let others = format!("[x] pcidev: PCI 00:1f.3 {} — x", bootlog::NOT_HANDED_OVER); + let why = handed_over(&others).expect_err("another function's refusal"); + assert!(why.contains("has no such card"), "{why}"); + assert!(handed_over("").is_err()); + } + + /// The counters are cumulative, so the last census a CPU wrote is its whole + /// boot, and the two absences are told apart: a machine that said nothing + /// and a card that raised nothing are different findings. + #[test] + fn a_lease_with_no_interrupt_and_a_boot_with_no_census_are_different_findings() { + assert_eq!(interrupts_into_the_driver(&[(0, 1), (0, 7), (1, 2)]), Ok(9)); + let why = interrupts_into_the_driver(&[]).expect_err("a boot that printed no census"); + assert!(why.contains("nothing here says whether"), "{why}"); + let why = interrupts_into_the_driver(&[(0, 0), (1, 0)]).expect_err("a silent card"); + assert!(why.contains("raised no interrupt"), "{why}"); + } + #[test] fn a_lease_record_is_read_field_by_field() { assert_eq!( @@ -327,7 +423,6 @@ mod tests { server: Ipv4Addr::new(10, 0, 2, 2), gateway: Some(Ipv4Addr::new(10, 0, 2, 2)), dns: vec![Ipv4Addr::new(10, 0, 2, 3), Ipv4Addr::new(10, 0, 2, 4)], - ms: 412, }) ); // A lease with no resolvers at all is a lease, and an empty list is not @@ -357,17 +452,6 @@ mod tests { let why = lease_in(&LEASED.replace("dns [10.0.2.3", "dns [fe80::1")) .expect_err("an IPv6 resolver is not one this record can carry"); assert!(why.contains("a resolver"), "{why}"); - let why = lease_in(&LEASED.replace("412 ms", "later ms")).expect_err("no milliseconds"); - assert!(why.contains("a millisecond count"), "{why}"); - } - - #[test] - fn a_link_that_was_already_up_is_told_from_one_that_came_up() { - let came_up = format!("[x] {LINK_UP}1000 Mb/s, 2400 ms after the driver came up"); - assert_eq!(link_up_ms(&came_up), Ok(2_400)); - assert!(link_up_ms("nothing\n").unwrap_err().contains("never reported a link")); - let why = link_up_ms(&format!("[x] {LINK_UP}1000 Mb/s")).expect_err("no comma"); - assert!(why.contains("already up"), "{why}"); } /// One pcap record per frame, with the timestamps a reader here never looks diff --git a/tests/common/faults.rs b/tests/common/faults.rs index 1189e99aeb..f094d29f03 100644 --- a/tests/common/faults.rs +++ b/tests/common/faults.rs @@ -284,14 +284,14 @@ pub fn virtio_net_no_msix() -> Result<(), String> { // any driver exists — a function whose interrupt cannot be armed is one // whose holder would never be told anything, and handing it over anyway // would be handing out a device that looks alive and never speaks. - log.must_say("pcidev: PCI 00:03.0 NOT HANDED OVER")?; + log.must_say(&format!("pcidev: PCI 00:03.0 {}", toyos_build::bootlog::NOT_HANDED_OVER))?; // **Both mechanisms, named.** `pcidev` arms MSI-X and falls back to MSI, so // this refusal is owed only by a function that has neither — and a virtio // function stripped of its MSI-X table publishes no MSI capability to fall // back to. A predicate naming one of them alone would be satisfied on a // machine that armed the other and handed the function over. log.must_say("neither its MSI-X nor its MSI could be armed")?; - log.must_not_say("[1af4:1041] handed over")?; + log.must_not_say(&format!("[1af4:1041] {}", toyos_build::bootlog::HANDED_OVER))?; // And the refusal is the *whole* of it: no BAR moved for a function nobody // can be given one. log.must_not_say("pcidev: PCI 00:03.0 BAR")?; diff --git a/tests/common/iommu.rs b/tests/common/iommu.rs index 66df45e8d8..315b392686 100644 --- a/tests/common/iommu.rs +++ b/tests/common/iommu.rs @@ -789,9 +789,9 @@ pub fn iommu_virtio_platform( /// and netd exits rather than driving anything — and the machine finishes /// booting, which is the half a refusal that panicked would fail. fn no_unit_is_no_claim(log: &Serial) -> Result<(), String> { - log.must_say("NOT HANDED OVER")?; + log.must_say(toyos_build::bootlog::NOT_HANDED_OVER)?; log.must_say("it would have no address space of its own")?; - log.must_not_say("handed over on slot")?; + log.must_not_say(toyos_build::bootlog::HANDED_OVER)?; // **And the refusal spent nothing on the way out.** No BAR was moved, so // this function's BARs are still where firmware put them, and no vector was // programmed into *either* of the two mechanisms `bring_up` may arm — which diff --git a/tests/common/lan.rs b/tests/common/lan.rs index dfabf4c519..8889f1aea7 100644 --- a/tests/common/lan.rs +++ b/tests/common/lan.rs @@ -1,21 +1,18 @@ //! The cable: netd taking this machine's address from the network, and the T14 //! answering the development host on it. //! -//! **The two arms read different channels.** A guest's console is a serial port -//! this process holds the other end of, so the QEMU arm reads netd's own -//! records. The T14 has no serial port: a userland write ends at -//! `Backend::None`, no `netd:` line has ever reached the stick, and the T14 arm -//! reads what the kernel records — the hand-over, the interrupt census and the -//! `exit:` code the job that asked netd left — beside what the loop measured -//! off the cable itself. +//! **Every verdict here is `toyos_build::lan`'s.** This module boots the +//! machines and hands that one what they left: the QEMU arm netd's own records, +//! the T14 arm the kernel's records and the `exit:` code of the job that asked +//! netd, beside what the loop measured off the cable itself. use std::net::Ipv4Addr; use std::path::Path; use toyos_build::bootlog; use toyos_build::lan::{ - asked_under_its_own_name, job_said, kernel_function, lease_in, link_up_ms, Lease, HOSTNAME, - LEASE, MAC, NIC, NO_LEASE, READY, + asked_under_its_own_name, handed_over, interrupts_into_the_driver, job_said, lease_in, Lease, + HOSTNAME, LEASE, MAC, NO_LEASE, READY, }; use toyos_build::metalprofile::Profile; @@ -39,9 +36,6 @@ pub const JOBS: &[&str] = &["test_rs_lan_hold", "test_rs_lan_state"]; /// this host can put in front of it. const QEMU_CONFIG: &str = "tests/e1000case"; -/// The card the T14 arm claims, as the kernel and the manifest spell it. -const ID: &str = "8086:15fc"; - /// The census source a NIC a *process* drives raises its interrupts under. const USERDEV: &str = "userdev"; @@ -64,26 +58,20 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { ) })?; - // The function the loop reached this boot over is named in the needle, so - // the card the host read its MAC off and the card the kernel gave netd are - // one record rather than two checks. A boot with no hand-over line carries - // the kernel's refusal instead, and quoting that is the whole diagnosis. - let handed = format!("PCI {} [{ID}] handed over on slot", kernel_function(NIC)); - let handover = text.lines().find(|l| l.contains(&handed)); - match handover { - Some(line) => eprintln!(" [lan] {}", line.trim()), - None => bad.push(match text.lines().find(|l| l.contains("NOT HANDED OVER")) { - Some(line) => format!("the kernel refused this function: {}", line.trim()), - None => format!( - "no `{handed}` record and no refusal either: nothing on this machine claimed \ - {ID}, so `tests/lancase` was flashed onto a machine that has no such card" - ), - }), - } + let handover = match handed_over(text) { + Ok(line) => { + eprintln!(" [lan] {line}"); + Some(line) + } + Err(why) => { + bad.push(why); + None + } + }; // What netd held, folded through the one word a machine with no console - // has. `Ok` is the address that answered the ping on the card the host read - // off the wire, so the address check the log used to carry is inside it. + // has: the address that answered the ping, on the card the host read off + // the wire. let leased = match back.exit_code(JOBS[1]).and_then(|code| { job_said(code, cable.addr, &cable.mac).map(|()| code) }) { @@ -97,18 +85,9 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { } }; - // **A lease with no interrupt is a contradiction.** DHCP is a round trip on - // the wire and this kernel delivers a user-driven NIC's vector to the - // process that claimed it, so a boot that leased and counted none did not - // lease — it read somebody else's answer, or this census is not of this - // card. + // Asked only after a lease: a boot with neither is consistent. if leased { - match userdev_raised(text) { - Ok(0) => bad.push(format!( - "netd answered with a lease and every `irq:` line of this boot reads \ - {USERDEV}=0: the card raised no interrupt, so nothing it received reached the \ - driver" - )), + match census(text).and_then(|census| interrupts_into_the_driver(&census)) { Ok(raised) => eprintln!(" [lan] the card raised {raised} interrupt(s) into netd"), Err(why) => bad.push(why), } @@ -129,9 +108,9 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { // fact, one finding — a boot with no hand-over record has already // said so above, and a bracket it cannot compute is that absence // again rather than something else about the reply. - if handover.is_some() { + if let Some(line) = handover { if let Err(why) = - bootlog::host_second_inside_this_boot(text, cable.skew, &handed, reply.at) + bootlog::host_second_inside_this_boot(text, cable.skew, line, reply.at) { bad.push(why); } @@ -154,29 +133,18 @@ pub fn on_metal(back: &metal::Readback) -> Result<(), String> { Err(format!("{} finding(s):\n {}", bad.len(), bad.join("\n "))) } -/// Interrupts this boot delivered to a process that drives a device, summed -/// over the machine. -/// -/// The counters are cumulative, so a CPU's last census is its whole boot; a -/// boot that printed none is refused rather than summed to zero, which would -/// read as the card being silent when it is the kernel that said nothing. -fn userdev_raised(text: &str) -> Result { - let mut last: std::collections::BTreeMap = std::collections::BTreeMap::new(); +/// Every `irq:` line this boot wrote, as the CPU it is of and that CPU's +/// [`USERDEV`] count, in the order the log carries them. +fn census(text: &str) -> Result, String> { + let mut out = Vec::new(); for line in text.lines() { match Census::parse(line) { None => continue, - Some(Ok(census)) => { - last.insert(census.cpu, census.source(USERDEV)); - } + Some(Ok(census)) => out.push((census.cpu, census.source(USERDEV))), Some(Err(why)) => return Err(format!("{why}\nline: {line}")), } } - if last.is_empty() { - return Err("no `irq: cpu` census in this boot's log, so nothing here says whether the \ - card raised an interrupt" - .to_string()); - } - Ok(last.values().sum()) + Ok(out) } /// The QEMU arm: the client, against a DHCP server this repository did not @@ -208,11 +176,7 @@ pub fn lan_dhcp_lease( if !qemu::profile_argv(&options).iter().any(|a| a.contains("e1000e")) { return Err("this test needs an Intel NIC and the profile has none".to_string()); } - let asker: Vec<(String, Vec)> = - rust_bins.iter().filter(|(name, _)| name == ASKER).cloned().collect(); - if asker.is_empty() { - return Err(format!("{ASKER} was not built")); - } + let asker = staged(rust_bins)?; let mut guest = QemuInstance::boot_with_options(&case, &[], &asker, options); let mut console = guest.boot_log().to_string(); qemu::await_marker(&mut guest, &mut console, READY, "netd to take an address")?; @@ -235,7 +199,6 @@ pub fn lan_dhcp_lease( server: Ipv4Addr::new(10, 0, 2, 2), gateway: Some(Ipv4Addr::new(10, 0, 2, 2)), dns: vec![Ipv4Addr::new(10, 0, 2, 3)], - ms: lease.ms, }; if lease != want { return Err(format!( @@ -244,12 +207,6 @@ pub fn lan_dhcp_lease( } // The order, and not merely the presence of both. log.must_say_after(LEASE, READY)?; - let ms = link_up_ms(log.text())?; - eprintln!( - " [lan] the emulated link came up in {ms} ms and the lease landed {} ms after netd \ - started", - lease.ms - ); // What the job that asks netd exits with, against the two records netd // wrote about the same two facts. **Nothing in the guest computes both // sides**: netd answered the job out of its interface and wrote these lines @@ -272,19 +229,32 @@ pub fn lan_dhcp_lease( Ok(()) } +/// The binary behind [`JOBS`]`[1]`, out of what the build staged. +fn staged(rust_bins: &[(String, Vec)]) -> Result)>, String> { + let asker: Vec<(String, Vec)> = + rust_bins.iter().filter(|(name, _)| name == ASKER).cloned().collect(); + if asker.is_empty() { + return Err(format!("{ASKER} was not built")); + } + Ok(asker) +} + /// The client on a wire with nothing at the other end. /// /// **The refusal the lease boot cannot reach.** A machine whose network never /// answers still has to announce itself, or every arm that waits for that line -/// hangs instead of having its connects refused one at a time. +/// hangs instead of having its connects refused one at a time — and it is the +/// one boot in this tree that produces the refusal the metal judge reads off an +/// exit code, so the job that asks netd runs here too. pub fn lan_no_lease( _test_config: &Path, _c_bins: &[(String, Vec)], - _rust_bins: &[(String, Vec)], + rust_bins: &[(String, Vec)], ) -> Result<(), String> { let case = super::compile::repo_root().join(QEMU_CONFIG); let options = BootOptions { profile: qemu::Profile::E1000eNoServer, ..Default::default() }; - let mut guest = QemuInstance::boot_with_options(&case, &[], &[], options); + let asker = staged(rust_bins)?; + let mut guest = QemuInstance::boot_with_options(&case, &[], &asker, options); let mut console = guest.boot_log().to_string(); // Drained rather than waited on: netd owes its line inside its own bound // and the guest says nothing at all until then, which every wait in this @@ -292,11 +262,25 @@ pub fn lan_no_lease( console.push_str( &guest.drain_serial(std::time::Duration::from_millis(toyos_tco::LEASE_BOUND_MS + 10_000)), ); + let asked = guest.run_test(JOBS[1], std::time::Duration::from_secs(30)); + console.push_str(&asked.before); + console.push_str(&asked.serial); let log = serial::Serial::named("the lan boot with no server", console.as_str()); if let Ok(lease) = lease_in(log.text()) { return Err(format!("a wire with no server leased {lease:?}")); } log.must_say_after(&format!("{NO_LEASE}{HOSTNAME} in "), READY)?; - eprintln!(" [lan] no server answered and netd said so, then served anyway"); + let code = asked + .exit_code + .ok_or_else(|| format!("{ASKER} left no exit code: {:?}\n{}", asked.error, asked.stdout))?; + let said = toyos_lanstate::said(code); + if said != toyos_lanstate::Said::Refused(toyos_lanstate::Refusal::NoLease) { + return Err(format!( + "netd drove a card on a wire with no server and {ASKER} exited {code} ({said:?}), \ + which is not the refusal such a machine owes\n{}", + asked.stdout + )); + } + eprintln!(" [lan] no server answered, netd said so and served anyway, and {ASKER} said it"); Ok(()) } diff --git a/tests/lancase/system.toml b/tests/lancase/system.toml index 8c7f90f653..c4b62538fd 100644 --- a/tests/lancase/system.toml +++ b/tests/lancase/system.toml @@ -18,7 +18,5 @@ serves = ["netd"] devices = ["pci:8086:15fc"] [programs.test-runner] -# The job that asks netd what network this machine is on connects through the -# namespace it inherits from this program. receives = ["netd"] syscap = ["logread"] diff --git a/tests/metal-profile.toml b/tests/metal-profile.toml index cd56ae3113..f175ded07f 100644 --- a/tests/metal-profile.toml +++ b/tests/metal-profile.toml @@ -611,11 +611,6 @@ ceiling = 30 ceiling_from = "as boot.testcases.stick_secs" # --- the cable: the boot that runs netd in front of the T14's own I219 --- -# netd's own spans are not among them. It writes `netd: I219: link up at ` and -# `netd: DHCP: lease ` to a console whose backend on this machine is `None`, so -# no such line has ever reached the stick; what that boot reports about its -# network is the exit code of the job that asked netd, which is a fold and not a -# number this file can price. [[number]] name = "boot.lancase.complete_ms" diff --git a/tests/toyos-rust-tests/src/bin/lan_state.rs b/tests/toyos-rust-tests/src/bin/lan_state.rs index 90209b080f..e22119facc 100644 --- a/tests/toyos-rust-tests/src/bin/lan_state.rs +++ b/tests/toyos-rust-tests/src/bin/lan_state.rs @@ -1,10 +1,5 @@ -//! Ask netd what network this machine is on, and exit with the answer. -//! -//! **The exit code is the whole report.** On the ThinkPad T14 there is no -//! serial port and a userland write ends at `Backend::None`, so nothing this -//! program prints reaches the harness; the kernel's `exit: pid=N code=N` -//! record is the one word that crosses, and `toyos_lanstate` is the grammar the -//! host reads it back with. +//! Ask netd what network this machine is on, and exit with the answer, which +//! `toyos_lanstate` is the grammar of. use toyos::endow; use toyos::net::RespType; @@ -21,7 +16,9 @@ fn main() { fn asked() -> Result { let netd = endow::service("netd").map_err(|_| Refusal::NoNetd)?; netd.signal(ASK).map_err(|_| Refusal::NoNetd)?; - let header = netd.recv_header().map_err(|_| Refusal::NoNetd)?; + // netd took the question and did not answer it, which is not the same as + // there being no netd to ask. + let header = netd.recv_header().map_err(|_| Refusal::Unanswered)?; if header.msg_type != RespType::Result as u32 { return Err(Refusal::Unanswered); } diff --git a/tests/toyos.rs b/tests/toyos.rs index 115f716306..05ce2c2397 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -13758,8 +13758,9 @@ fn run_machine_test( // Exactly one hand-over of that function. Two would be the defect // itself, and zero a boot that says nothing about exclusivity. + let handed = format!("[1af4:1041] {}", bootlog::HANDED_OVER); let handovers = - log.text().lines().filter(|l| l.contains("[1af4:1041] handed over on slot")).count(); + log.text().lines().filter(|l| l.contains(&handed)).count(); if handovers != 1 { return Err(format!( "the NIC's function was handed over {handovers} times, and a second holder \ diff --git a/toyos-lanstate/src/lib.rs b/toyos-lanstate/src/lib.rs index 1fddaee5f4..cc97f2486c 100644 --- a/toyos-lanstate/src/lib.rs +++ b/toyos-lanstate/src/lib.rs @@ -7,8 +7,7 @@ //! `i32`. An address and a MAC are eighty bits and the record carries //! thirty-two, so what crosses is a fold of the pair: the judge already holds //! what the pair must be — it pinged the address and read the MAC off the wire -//! — and recomputes the same fold. A fingerprint is what a channel narrower -//! than its answer leaves. +//! — and recomputes the same fold. //! //! Three crates read this file and none of them shares another's: netd answers //! [`ASK`], the job that asked turns the answer into an exit code, and the @@ -45,7 +44,7 @@ pub struct State { impl State { /// The answer as netd writes it. An absent address is four zero bytes, /// which no lease is: RFC 1122 §3.2.1.3 gives 0.0.0.0 to a host that does - /// not yet know its own address, and a server never assigns it. + /// not yet know its own address. pub fn encode(&self) -> [u8; ANSWER_LEN] { let mut out = [0u8; ANSWER_LEN]; out[..6].copy_from_slice(&self.mac); @@ -59,12 +58,10 @@ impl State { /// grammar wrote. pub fn decode(bytes: &[u8]) -> Option { let bytes: [u8; ANSWER_LEN] = bytes.try_into().ok()?; - let (mac, octets) = bytes.split_at(6); - let address = Ipv4Addr::from([octets[0], octets[1], octets[2], octets[3]]); - Some(Self { - mac: mac.try_into().ok()?, - address: (!address.is_unspecified()).then_some(address), - }) + let mut mac = [0u8; 6]; + mac.copy_from_slice(&bytes[..6]); + let address = Ipv4Addr::from([bytes[6], bytes[7], bytes[8], bytes[9]]); + Some(Self { mac, address: (!address.is_unspecified()).then_some(address) }) } /// The code a job that got this answer exits with. @@ -78,8 +75,10 @@ impl State { /// Why a job has no state to report, as the negative exit codes the host reads. /// -/// **Negative, and never zero**, as `metalprobe`'s refusals are: a refusal may -/// not share the space with an answer. +/// **This grammar reads `lan_state`'s exit code and no other**, as +/// `toyos_build::metaldevices::Refused` reads `metalprobe`'s: the same negative +/// numbers name different refusals in the two, and which one an `exit:` record +/// belongs to is the binary that wrote it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Refusal { /// This program's namespace holds no netd, or netd has gone. @@ -123,10 +122,6 @@ pub const FIRST_FINGERPRINT: i32 = 1 << 30; /// The pair folded into the thirty bits left over [`FIRST_FINGERPRINT`], FNV-1a /// (Fowler–Noll–Vo, 32 bit) over the MAC and then the address. -/// -/// A fold and not the value: two eighty-bit answers cannot both be carried by a -/// thirty-two-bit channel, so what the judge can ask is agreement with the pair -/// it already holds. Two different pairs fold together with probability 2^-30. pub fn fingerprint(mac: [u8; 6], address: Ipv4Addr) -> i32 { const OFFSET_BASIS: u32 = 0x811c_9dc5; const PRIME: u32 = 0x0100_0193; @@ -233,5 +228,15 @@ mod tests { assert_ne!(*other, whole); assert_eq!(said(*other), Said::Fingerprint(*other), "{other}"); } + // The order of the pair, and not only its content: a fold that merely + // mixed the bytes together folds every permutation of one pair to one + // code, and two cards that swapped a byte would agree. + let mut swapped = MAC; + swapped.swap(0, 1); + assert_ne!(fingerprint(swapped, ADDR), whole); + // The whole width of the band, and not the low byte of it: a fold that + // never carried out of one byte answers with 256 codes, and the + // collision the judge rests on would be 2^-8. + assert!(moved.iter().any(|got| got >> 8 != whole >> 8), "{moved:?}"); } } diff --git a/userland/netd/src/main.rs b/userland/netd/src/main.rs index b5d13d5634..3293539fe3 100644 --- a/userland/netd/src/main.rs +++ b/userland/netd/src/main.rs @@ -516,14 +516,10 @@ struct NetDaemon { pending_piped_connects: Vec, udp_pipes: HashMap, max_piped_connections: usize, - /// The card's own, as the driver read it out of the register file: the six - /// bytes `netd: MAC` announces, kept because a boot with no console has - /// only [`toyos_lanstate::ASK`] to report them through. - mac: [u8; 6], } impl NetDaemon { - fn new(dns_handle: SocketHandle, max_piped_connections: usize, mac: [u8; 6]) -> Self { + fn new(dns_handle: SocketHandle, max_piped_connections: usize) -> Self { Self { sockets: HashMap::new(), next_id: 1, @@ -536,7 +532,6 @@ impl NetDaemon { pending_piped_connects: Vec::new(), udp_pipes: HashMap::new(), max_piped_connections, - mac, } } @@ -596,10 +591,8 @@ impl NetDaemon { // A word of netd's own that the SDK does not send: the one channel // a machine with no console has for saying what network it is on. None if req.msg_type == toyos_lanstate::ASK => { - let state = toyos_lanstate::State { - mac: self.mac, - address: iface.ipv4_addr(), - }; + let HardwareAddress::Ethernet(mac) = iface.hardware_addr(); + let state = toyos_lanstate::State { mac: mac.0, address: iface.ipv4_addr() }; req.client.result_bytes(&state.encode()); } None => { @@ -1339,7 +1332,7 @@ fn main() { let total_mem = total_memory(); let max_piped = max_piped_connections(total_mem); - let mut daemon = NetDaemon::new(dns_handle, max_piped, mac); + let mut daemon = NetDaemon::new(dns_handle, max_piped); // Sized for the slot ceiling rather than for `max_piped`: the batch // between two `wait` calls is the two fixed registrations, one per live piped