From 7ccd44caf94618a5a79e0daefb88b953458b9054 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 15:24:15 +0100 Subject: [PATCH 1/3] toyos-metal boots every bench image off the bench's internal NVMe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bench's stick was in every boot's path: when the AMD bench's stick stopped answering, its firmware tried the BootNext entry naming it and sat at a prompt waiting for a key. The loop now writes the image to the install area `internal` already knew, partitions 5–11 after the bench's four, and boots it once through BootNext on the ToyOS-internal entry, which is never in BootOrder, so a failed or reset ToyOS boot hands the next boot to Ubuntu. - `--image` gives partitions 7–11 the image's GUIDs, dd's each piece and makes or reuses the entry; the area is made once with sgdisk and partx where it is absent. Ubuntu's partitions and entries are never written. - The readback mounts partition 7 read-only, and `--fat32-check` reads the image's log volume length off it (`iflag=count_bytes`). - The stick's machinery goes: the whole-disk flash, the locator and `--device`, the wait for the stick to enumerate and `stick_secs`, the record's `[stick]` table and `--record`'s disk. Scratch adoption stays. - `--fall-through missing|garbage` boots once an entry on the internal ESP naming a loader that is not there, or the image's 16-byte log.guid, and passes once Ubuntu answers with BootNext consumed: how a bench's firmware is shown to fall through rather than prompt (UEFI 2.10 §3.1.2). - A loader panic resets the machine where BootCurrent is not BootOrder's first entry, so a BootNext boot falls back with no hand, and powers it off as before where the loader is the default, where a reset would only panic again. Each pass says which. - Rows: `usbd_serves_the_boot_stick` goes, a bench's @boot being its NVMe controller; `stick_survives_a_reset_under_load` is `the_disk_survives_a_reset_under_load`; the AMD-Vi rows judge the boot controller; `nvme_leaves_the_benchs_disk_alone` admits the loader's log and boot volumes and nothing else but DATA; the spare jobs take the first free xHCI, the one they took before on both benches. - `issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md` records what no bench row runs now; the partitions track records the bench's table edits as its edit stage's first plan. Co-Authored-By: Claude Opus 5.5 --- ...command-does-not-brick-the-benchs-stick.md | 13 +- ...rtition-is-wiped-by-the-next-runs-flash.md | 14 +- ...n-to-the-stick-starves-a-tone-beside-it.md | 5 +- issues/machines-anyone-can-lend-to-toyos.md | 5 +- ...storage-since-the-benches-boot-off-nvme.md | 44 + ...ed-the-nvme-driver-on-a-benchs-own-disk.md | 4 +- ...-runs-only-what-no-cheaper-tier-reaches.md | 7 +- ...-small-interrupts-post-and-threads-wait.md | 9 +- ...he-t14-answers-only-through-a-usb-stick.md | 12 +- issues/toyos-manages-its-own-partitions.md | 6 +- loader/src/bootnext.rs | 16 + loader/src/efi/mod.rs | 22 +- loader/src/main.rs | 16 + tests/checks.rs | 9 +- tests/checks/metal.rs | 62 +- tests/common/audio.rs | 10 +- tests/common/hostdisk.rs | 52 +- tests/common/metal.rs | 46 +- tests/common/power.rs | 27 +- .../rust-tests/src/bin/end_returns_claims.rs | 2 +- tests/guest/rust-tests/src/bin/stick_load.rs | 11 +- tests/guest/rust-tests/src/bin/stick_write.rs | 6 +- .../src/bin/tone_beside_stick_write.rs | 6 +- tests/guest/rust-tests/src/spare.rs | 24 +- tests/guest/rust-tests/src/stick_lines.rs | 8 +- tests/guest/rust-tests/src/usbd_spare.rs | 10 +- tests/images/acpicase.toml | 4 +- tests/images/benchcase.toml | 4 +- tests/images/floorcase.toml | 4 +- tests/images/jobcase.toml | 4 +- tests/images/latencycase.toml | 4 +- tests/images/metalcase.toml | 4 +- tests/images/metaldevicecase.toml | 4 +- tests/images/netcase.toml | 4 +- tests/images/proctreecase.toml | 4 +- tests/images/testcases.toml | 6 +- tests/metal/Cargo.toml | 2 +- tests/metal/src/image.rs | 2 +- tests/metal/src/lib.rs | 1276 +++++------------ tests/metal/src/testargs.rs | 4 +- tests/metal/src/timings.rs | 31 +- tests/toyos.rs | 218 ++- 42 files changed, 801 insertions(+), 1220 deletions(-) create mode 100644 issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md diff --git a/issues/a-cut-bulk-only-command-does-not-brick-the-benchs-stick.md b/issues/a-cut-bulk-only-command-does-not-brick-the-benchs-stick.md index 3f10f2e99ab..ca526da884b 100644 --- a/issues/a-cut-bulk-only-command-does-not-brick-the-benchs-stick.md +++ b/issues/a-cut-bulk-only-command-does-not-brick-the-benchs-stick.md @@ -106,14 +106,15 @@ resets every connected port and its controller before the kernel's stop (`toyos_xhci::hc::Hc::stop`). The boot deadline, the hard-lockup detector and a panic reset with no USB register touched, as #901 §6 accepted, and so does a stop whose usbd died, its function reset by its claim's release. The -guard for that path is `stick_survives_a_reset_under_load`: `stick_load` -writes the stick through usbd until the boot deadline resets the machine, -judged on `boot.usbload.stick_secs` against the bench's record. It has not -run yet. +guard for that path was `stick_survives_a_reset_under_load`'s +`boot.usbload.stick_secs`, `stick_load` writing the stick through usbd until +the boot deadline reset the machine. Since the benches boot off their NVMe it +writes the NVMe, and no row writes a stick on a bench +(`issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md`). ## Exit condition A reproduction of the hang itself — not of a reset — that leaves evidence off the stick, and an account of what the controller and the device were in when it -happened. The regression guard is `stick_survives_a_reset_under_load`'s -`boot.usbload.stick_secs`. +happened. The regression guard is a reset under a write to a stick a bench +row drives, which that issue's exit owes. diff --git a/issues/a-hung-boots-log-partition-is-wiped-by-the-next-runs-flash.md b/issues/a-hung-boots-log-partition-is-wiped-by-the-next-runs-flash.md index c5d77c1cc7a..0a161bc80a3 100644 --- a/issues/a-hung-boots-log-partition-is-wiped-by-the-next-runs-flash.md +++ b/issues/a-hung-boots-log-partition-is-wiped-by-the-next-runs-flash.md @@ -19,15 +19,11 @@ before `:1489`. The next invocation opens with `flash` again. The previous boot's partition therefore survives to be read exactly when something outside the loop reads it between the two invocations — and nothing in the loop does. -**And the loop cannot read a wedged stick at all.** `read_log` `?`s on the -`mkdir` and the mount (`:1131-1132`) and `read_mounted` on the listing and -every `cat` (`:1152-1162`); `raw_log` (`:1173`) refuses a short read by name -(`Refusal::Landed`, `:1184-1190`). Both are reached only after -`wait_for_the_stick` has seen the block node for up to `STICK_SECS` (`:68`, -30 s) — which is not tolerance of an unreachable stick but the reason neither -is asked to tolerate one. A pre-flash call to either, as written, would refuse -every run whose predecessor left the stick unenumerable (run 49's mode, below) -instead of recording that it did. +**Since the benches boot off their NVMe nothing stops the loop reading it +first.** The log volume is the install area's partition 7 +(`ToyOS-internal-log`), on the disk Ubuntu boots from, so it is there whenever +the loop is: the stick that could come back unenumerable (run 49's mode, +below) is in no boot's path. The line numbers above are of the stick loop. ## What was and was not lost diff --git a/issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md b/issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md index 3c0250a8f74..4672fd48ca0 100644 --- a/issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md +++ b/issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md @@ -25,7 +25,10 @@ much to the stick while a tone plays is the same stimulus. Since the small-kernel track's step 10 the stick is usbd's: the interrupts are its claim's and the waits its thread's, and the kernel's two-second transport -break is gone. Whether the starvation remains is unmeasured. +break is gone. Whether the starvation remains is unmeasured, and on a bench +`tone_beside_a_stick_write` now writes the NVMe the bench boots off, so no +bench row writes a stick beside the tone +(`issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md`). ## Exit condition diff --git a/issues/machines-anyone-can-lend-to-toyos.md b/issues/machines-anyone-can-lend-to-toyos.md index a31fb550e46..662ccbbe40e 100644 --- a/issues/machines-anyone-can-lend-to-toyos.md +++ b/issues/machines-anyone-can-lend-to-toyos.md @@ -12,9 +12,10 @@ could boot ToyOS for the suite with no one standing next to it. **What exists.** - `--metal-host` targets any bench. -- `toyos-metal --record` captures a machine and its stick into the host store +- `toyos-metal --record` captures a machine into the host store (`timings::dir()`), never into the tree. -- The loop wipes and writes the stick over `ssh` and sets a one-shot `BootNext`. +- The loop writes the image to the machine's internal install area over + `ssh` and sets a one-shot `BootNext`. - The machine comes back to its own OS, and the loop reads the log partition back. diff --git a/issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md b/issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md new file mode 100644 index 00000000000..97312cbe7f9 --- /dev/null +++ b/issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md @@ -0,0 +1,44 @@ +--- +status: open +kind: tooling +opened: 2026-10-28 +--- + +# No bench row drives USB storage since the benches boot off their NVMe + +`toyos-metal` writes each image to the bench's internal install area +(partitions 5–11 after the bench's four, `tests/metal/src/internal.rs`) and +boots it once through `BootNext`, so no stick is in a bench boot's path. The +supervisor's `@boot` is then the NVMe controller, a test image's usbd row +drives no controller on a bench, and every metal judgement whose subject was +the boot stick lost it: + +- `usbd_serves_the_boot_stick` is deleted: a bench's boot controller is no + xHCI. +- `stick_survives_a_reset_under_load` is `the_disk_survives_a_reset_under_load`: + the deadline reset under a write to the bench's NVMe. Whether a reset + leaves a USB stick its next host can enumerate, `boot.usbload.stick_secs` + and the guard `a-cut-bulk-only-command-does-not-brick-the-benchs-stick.md` + names, is judged by no row. +- `tone_beside_a_stick_write` and `mask_windows` under `stick_write` write + `/log`, which is the NVMe now: the USB load + `a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md` asks about is + not reproduced on a bench. Their jobs and the records' + `boot.windowscase.stick_write.*` still say "stick"; those two rows of each + bench's record were taken under the USB load and are re-recorded once + deleted. +- `bench_data_designated` no longer reads the T14 stick's short last sector. +- `amdvi_translates_the_boot_controller` and `amdvi_remaps_the_boot_controller` + judge the NVMe controller where they judged the stick's xHCI. + +QEMU's machines still boot off a stick (`the_log_lands_on_the_boot_stick`). +The test images' `pci:0c:03:30@boot` entry names QEMU's NEC controller, which +the entry before it already names, and nothing on a bench: whether `@boot` +keeps a user is the owner's to say. + +**Exit**: a metal row drives a USB stick that is not the boot disk — the +scratch disk (`issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md`) +— and judges usbd serving it, a reset under a write to it, and a tone beside +that write. + +**Owner**: the orchestrator, which runs metal. diff --git a/issues/no-readback-has-judged-the-nvme-driver-on-a-benchs-own-disk.md b/issues/no-readback-has-judged-the-nvme-driver-on-a-benchs-own-disk.md index 0b4100b817c..ba95de43ef8 100644 --- a/issues/no-readback-has-judged-the-nvme-driver-on-a-benchs-own-disk.md +++ b/issues/no-readback-has-judged-the-nvme-driver-on-a-benchs-own-disk.md @@ -16,7 +16,9 @@ no volume of ours and no designation stamp is never written (`a_host_disk_beside_the_stick_is_left_alone`, on QEMU's controller under Intel's id). -No bench has run it. The controller's bring-up, its geometry (`MDTS` of at +Every bench boot now runs on it: the benches boot their images off their +internal NVMe, so the log and boot volumes are served through it. No +readback has judged it yet. The controller's bring-up, its geometry (`MDTS` of at least 32 blocks, 512- or 4096-byte sectors), its shutdown at the stop, and the host booting again off a controller ToyOS drove are each unmeasured on both benches. diff --git a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md index c3531cf3443..df56f2437ac 100644 --- a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md +++ b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md @@ -135,9 +135,10 @@ The kernel drives no USB; usbd does (the small-kernel track's step 10). Host: and `toyos-usbhid`'s and `toyos-scsi`'s decoders. Metal: the usbd rows. Exit: as stage A, with what is still owed: -- metal: the rewritten `usbload` row, a stick written continuously through - usbd and the machine reset under it by the boot deadline, judged on - `boot.usbload.stick_secs` (#901 section 9). +- metal: a stick written continuously through usbd and the machine reset + under it by the boot deadline (#901 section 9), which no bench row writes + since the benches boot off their NVMe + (`issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md`). - metal: `tone_beside_a_stick_write` (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`). - metal `late_storage_connect`: a disk that connects after bring-up is diff --git a/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md b/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md index d02d34e6ab2..431976ce185 100644 --- a/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md +++ b/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md @@ -203,8 +203,7 @@ Beside that order, each waiting on something outside it: port and its controller (`Hc::stop`) before the kernel's, as the kernel's xHCI stop did. **The exit's evidence**: the kernel's part by `usbd_drives_the_spare` on the default `qemu-xhci` and - `usbd_drives_the_type_c_controller`, and `usbd_serves_the_boot_stick` - for the boot controller; the kernel's input streams gone, with no + `usbd_drives_the_type_c_controller`; the kernel's input streams gone, with no `Keyboard` or `Mouse` in `sdk/abi`'s `DeviceType` or `FileType`; the owner of input's by `toyos-input`'s `a_connections_end_releases_every_device_it_carried_and_a_gone_only_its_own_source`; @@ -223,9 +222,9 @@ Beside that order, each waiting on something outside it: metal rows that asked for that machine (`iommu-no-remap`) are deleted, and the refusal is QEMU's to hold, by `usbd_refused_without_amdvi_remapping` and `amdvi_firmware_left`. - **Left**: the metal rows, which have not run: `usbd_serves_the_boot_stick` - and #901 section 9's three that judge with a userland load, - `stick_survives_a_reset_under_load` (a deadline reset under + **Left**: the metal rows, which have not run: #901 section 9's three + that judge with a userland load, + `the_disk_survives_a_reset_under_load` (a deadline reset under `stick_load`), `tone_beside_a_stick_write` (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`'s exit) and `mask_windows` under `stick_write`. #971's first metal boots diff --git a/issues/the-t14-answers-only-through-a-usb-stick.md b/issues/the-t14-answers-only-through-a-usb-stick.md index 88ed6adc095..fb05e315d7d 100644 --- a/issues/the-t14-answers-only-through-a-usb-stick.md +++ b/issues/the-t14-answers-only-through-a-usb-stick.md @@ -4,10 +4,10 @@ kind: track opened: 2026-09-07 --- -# The T14 answers only through a USB stick +# The T14 answers only through what Ubuntu reads off its disk -Every boot of the bench is flashed to a stick under Ubuntu and judged by what -is read off it after a reboot into Ubuntu. The laptop is on a cable on the same +Every boot of the bench is flashed to its internal NVMe under Ubuntu and +judged by what is read off it after a reboot into Ubuntu. The laptop 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`. The track is to make that cable the answer path. The boot on which the Mac read the log, ran a command and handed the machine back over it @@ -16,8 +16,8 @@ is deleted: the host reaches no T14 that runs ToyOS What is left is the harness running userland tests over ssh through a russh client, which `issues/the-t14-reboots-through-ubuntu-for-every-test.md` -stages, and a netboot spike in which the firmware fetches the loader over HTTP -so the stick leaves the boot path. +stages, and a netboot spike in which the firmware fetches the loader over +HTTP. Constraints a reader would otherwise pay to re-derive: @@ -50,6 +50,6 @@ Constraints a reader would otherwise pay to re-derive: the image named the Mac and nothing on the Mac listened. The serving half and the Mac's reader are deleted (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`); a boot - that dies before netd leases needed the stick either way. + that dies before netd leases needed the disk Ubuntu reads either way. - The metal loop is `toyos-metal` (`tests/metal/src/lib.rs`), and the T14 is run by the orchestrator alone. diff --git a/issues/toyos-manages-its-own-partitions.md b/issues/toyos-manages-its-own-partitions.md index 66679d7de49..48886d95e02 100644 --- a/issues/toyos-manages-its-own-partitions.md +++ b/issues/toyos-manages-its-own-partitions.md @@ -38,7 +38,11 @@ a signed test image's job holds it. (`--active`), writes sector-granular at the disk's true last LBA, flushes between the two copies, and reads both back. Unrelated partitions are untouched by construction; another system's are reclaimed only when the - plan names them. + plan names them. Until then a bench's table is edited under its Ubuntu: + `toyos-metal` makes the install area (partitions 5–11 after the bench's + four) once with `sgdisk` and `partx`, and every flash gives partitions 7–11 + the GUIDs the image names with `sgdisk --partition-guid`. A bench's area + is this stage's first plan. 3. **Scratch disks.** `Init` with one aligned partition of `Guid::TOYOS_SCRATCH` replaces `toyos-metal --adopt-scratch` (`issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md`). diff --git a/loader/src/bootnext.rs b/loader/src/bootnext.rs index 5965b10f2be..d802b014e26 100644 --- a/loader/src/bootnext.rs +++ b/loader/src/bootnext.rs @@ -67,6 +67,22 @@ pub fn point_at_us(handle: Handle, system_table: &SystemTable) { } } +/// Whether the machine's default boot, `BootOrder`'s first entry, is another +/// entry than `BootCurrent`, the one that started this loader (UEFI 2.10 +/// §3.3): a `BootNext` bought this boot, so a reset hands the machine to that +/// default and not back here. `false` where either variable does not read. +pub fn default_is_another(system_table: &SystemTable) -> bool { + let rt = system_table.runtime_services(); + let read = |name: &CStr16| rt.get_variable(name, &GLOBAL_VARIABLE).ok().map(|(bytes, _)| bytes); + let (Some(current), Some(order)) = (read(cstr16!("BootCurrent")), read(cstr16!("BootOrder"))) else { + return false; + }; + match (current.get(..2), order.get(..2)) { + (Some(current), Some(first)) => current != first, + _ => false, + } +} + /// The GPT partition GUID of the volume firmware loaded this image from. fn our_partition(handle: Handle, system_table: &SystemTable) -> Option<[u8; 16]> { let bs = system_table.boot_services(); diff --git a/loader/src/efi/mod.rs b/loader/src/efi/mod.rs index 728343ca20d..521f584307c 100644 --- a/loader/src/efi/mod.rs +++ b/loader/src/efi/mod.rs @@ -539,20 +539,32 @@ unsafe impl core::alloc::GlobalAlloc for Pool { } /// Set by the first panic, so a panic while saying one goes straight to the -/// power-off. +/// reset. static PANICKED: AtomicBool = AtomicBool::new(false); -/// Say the panic on firmware's console and power the machine off; from the -/// first `ExitBootServices` call the console is not written, and once boot -/// services are gone neither is there, and the CPU spins. +/// Whether a panic resets the machine rather than powering it off +/// ([`reset_on_panic`]). +static PANIC_RESETS: AtomicBool = AtomicBool::new(false); + +/// Have a panic from here on reset the machine instead of powering it off: +/// for a machine whose next boot after a reset is not this loader's. +pub fn reset_on_panic() { + PANIC_RESETS.store(true, Ordering::Relaxed); +} + +/// Say the panic on firmware's console, then reset the machine or power it +/// off as [`reset_on_panic`] chose; from the first `ExitBootServices` call the +/// console is not written, and once boot services are gone neither is there, +/// and the CPU spins. #[panic_handler] fn panic(info: &core::panic::PanicInfo) -> ! { if let Some(raw) = live() { if console().is_some() && !PANICKED.swap(true, Ordering::Relaxed) { print(format_args!("[PANIC]: {info}\n")); } + let kind = if PANIC_RESETS.load(Ordering::Relaxed) { ResetType::WARM } else { ResetType::SHUTDOWN }; // SAFETY: runtime services live as long as the table. - unsafe { &*raw.runtime_services }.reset(ResetType::SHUTDOWN, Status::ABORTED) + unsafe { &*raw.runtime_services }.reset(kind, Status::ABORTED) } loop { core::hint::spin_loop(); diff --git a/loader/src/main.rs b/loader/src/main.rs index a0b02621ace..66080fa5cbd 100644 --- a/loader/src/main.rs +++ b/loader/src/main.rs @@ -808,6 +808,15 @@ fn main(handle: Handle, system_table: SystemTable) -> Status { // so the answer is on the stick and not only on the screen. let firmware_watchdog = system_table.boot_services().set_watchdog_timer(FIRMWARE_WATCHDOG_SECS, WATCHDOG_CODE); + // Before anything that can panic: a panic resets the machine where the + // reset boots its default entry and not this loader, so a bench whose + // `BootNext` bought this boot falls back to its own system with no hand, + // and powers it off where this loader is the default, which a reset would + // only start again. + let panic_resets = bootnext::default_is_another(&system_table); + if panic_resets { + efi::reset_on_panic(); + } // Before the first line, so the screen holds this loader's lines and none // of the firmware's: its logo and its boot manager's text. ClearScreen // (UEFI 2.11 §12.4.8) is the console's own clear, which also homes the @@ -875,6 +884,13 @@ fn main(handle: Handle, system_table: SystemTable) -> Status { if let Err(e) = cleared { println!("Screen: firmware's console would not clear ({e}), so its own lines stay above these"); } + println!( + "Panic: a panic in this loader {}", + match panic_resets { + true => "resets the machine, whose default boot is another entry than this one", + false => "powers the machine off, since its default boot is this loader or the firmware does not say", + } + ); if let Some(line) = claim_refused { println!("{line}"); } diff --git a/tests/checks.rs b/tests/checks.rs index 2812a6cb1a7..817fcce946d 100644 --- a/tests/checks.rs +++ b/tests/checks.rs @@ -994,6 +994,11 @@ mod checks { metal_checks::readbacks_of_a_machine_with_no_record_are_red_and_record_nothing(); } + #[test] + fn metal_bench_disk_judge_admits_data_and_the_loaders_volumes_alone() { + metal_checks::the_bench_disk_judge_admits_data_and_the_loaders_volumes_alone(); + } + #[test] fn metal_bench_reaches_the_loop_as_its_host() { metal_checks::the_bench_reaches_the_loop_as_its_host(); @@ -1155,10 +1160,10 @@ mod checks { let unnamed = format!("{staged}{}{}| cpu0 pc=0xffff80006051b0b2 {spin}\n", awake(1), awake(0)); assert!(judge(&[&readback("deadlinewedge", &wedged(&unnamed), kernel)]).is_err()); - // The stick load's boot: its deadline ended it while the job's lines + // The disk load's boot: its deadline ended it while the job's lines // were reaching /log; one whose job never wrote, or that handed the // machine back, is no reset under load. - let judge = metal_judge("stick_survives_a_reset_under_load"); + let judge = metal_judge("the_disk_survives_a_reset_under_load"); let line = |n: u32| format!("[2026-09-29 10:33:40 20.100 test-runner pid=12] stick_load: line {n} {}\n", "x".repeat(840)); let wrote = format!("{kernel}{}", (0..1200).map(line).collect::()); let short = format!("{kernel}{}", (0..10).map(line).collect::()); diff --git a/tests/checks/metal.rs b/tests/checks/metal.rs index 2c170d538b4..877cbc4b80e 100644 --- a/tests/checks/metal.rs +++ b/tests/checks/metal.rs @@ -7,7 +7,7 @@ use toyos_metal::{ verdict_file, BootFile, Phases, Refusal, READBACK_BOOT, READBACK_KERNEL, READBACK_LOADER, READBACK_VERDICT, }; -use toyos_metal::timings::{Machine, Record, Stick}; +use toyos_metal::timings::{Machine, Record}; const PANEL: &str = "| panel: paints=10 px=8741248 us=21751 max_us=3851\n"; const BOOTED: &str = "[2026-09-29 18:22:39 1.165 cpu0 kernel] Boot: complete (1165ms)\n"; @@ -20,15 +20,11 @@ fn t14() -> Machine { } } -fn stick() -> Stick { - Stick { sectors: 60_062_500, vendor: "SanDisk".to_string(), model: "Ultra".to_string() } -} - /// A root holding the T14's record as `toyos-metal --record` writes -/// one, its stick and no number: what every run the loop made had. +/// one, with no number: what every run the loop made had. fn recorded() -> toyos_tmpdir::TempDir { let root = toyos_tmpdir::TempDir::new("metal-records"); - Record::new(&t14(), stick()).save(&root).expect("a recorded machine"); + Record::new(&t14()).save(&root).expect("a recorded machine"); root } @@ -104,7 +100,6 @@ const STARTED: &str = "[2026-09-29 18:22:38 0.050 cpu0 kernel] spawn: /system/b pub(super) fn boot_file() -> String { let phases = Phases { preamble: 3.1, - wipe: 0.4, flash: 121.5, dd_secs: 117.2, entry: 2.3, @@ -112,7 +107,7 @@ pub(super) fn boot_file() -> String { read_log: 1.4, raw_log: Some(9.9), }; - BootFile { back: 40, stick: 0, machine: t14(), phases }.text() + BootFile { back: 40, machine: t14(), phases }.text() } /// One boot's readback as the loop writes it. @@ -369,7 +364,6 @@ fn kept(bios: &str, complete_ms: u64) -> Record { vendor: machine.vendor, product: machine.product, bios: bios.to_string(), - stick: stick(), measured: [ ("boot.passing.complete_ms", complete_ms), ("boot.passing.panel_max_us", 3851), @@ -722,3 +716,51 @@ pub fn an_exit_record_without_a_code_answers_no_code() { assert!(answer.as_ref().is_err_and(|why| why.contains(end)), "{end}: {answer:?}"); } } + +/// **On a bench booted off its own disk, the disk judge admits the volumes the +/// loader named and DATA, and nothing else of that disk**: a session on the +/// host's ESP, of the same type as ToyOS's, is refused, and so is every +/// session but DATA's where the loader's lines are not read. +pub fn the_bench_disk_judge_admits_data_and_the_loaders_volumes_alone() { + let guid = |text: &str| toyos_gpt::Guid::parse(text).expect("a GUID"); + let (host_esp, data, esp, log) = ( + guid("BACB3F81-3674-441E-80A9-643C279215E3"), + guid("771E4813-696F-4DEA-828A-522A0A822331"), + guid("855A1AD8-B730-4CD0-B949-19D93B0DEF53"), + guid("CC672C8C-965F-4C00-8E4C-C039D06C3CDB"), + ); + let bytes = |g: toyos_gpt::Guid| g.0.iter().map(|b| format!("{b:02x}")).collect::>().join(", "); + let loader = serial::Serial::named( + "loader.log", + format!( + "[10.405 cpu0 loader] Boot partition: LBA 2048+69632 signature [{}]\n\ + [10.416 cpu0 loader] Log partition: signature [{}]\n", + bytes(esp), + bytes(log) + ), + ); + let ours = common::hostdisk::loader_named(&loader).expect("the loader's two lines"); + assert_eq!(ours, [esp, log]); + let part = |g: toyos_gpt::Guid, kind: toyos_gpt::Guid| format!("[16.267 nvme] nvme 04:00.0: partition {g} of type {kind} at block 256, 8704 blocks\n"); + let session = |n: u32, g: toyos_gpt::Guid| format!("[16.578 nvme] nvme 04:00.0: session {n} opened {g} (8704 blocks)\n"); + let table = [ + BOOTED.to_string(), + "[16.219 nvme] nvme 04:00.0: up: 4 I/O queues of 63 commands\n".to_string(), + part(host_esp, toyos_gpt::Guid::EFI_SYSTEM), + part(data, toyos_gpt::Guid::TOYOS_DATA), + part(esp, toyos_gpt::Guid::EFI_SYSTEM), + part(log, toyos_gpt::Guid::MICROSOFT_BASIC), + ] + .concat(); + let tail = "[16.600 diskserver] diskserver: every disk driver has settled\n\ + [16.652 fileserver] fileserver: mounted the DATA volume\n\ + [16.677 fileserver] fileserver: Data serving /apps — bcachefs, 14 names\n"; + let booted = |sessions: &[String]| serial::Serial::named("the boot", format!("{table}{}{tail}", sessions.concat())); + let ours_and_data = booted(&[session(0, data), session(1, log), session(2, esp)]); + assert_eq!(common::hostdisk::left_alone(&ours_and_data, &ours), Ok((4, common::hostdisk::Data::Mounted))); + assert!(common::hostdisk::left_alone(&ours_and_data, &[]).is_err(), "the loader's volumes admitted unnamed"); + let host = booted(&[session(0, data), session(1, host_esp)]); + let why = common::hostdisk::left_alone(&host, &ours).unwrap_err(); + assert!(why.contains(&host_esp.to_string()), "{why}"); + assert!(common::hostdisk::loader_named(&serial::Serial::named("loader.log", "Loading kernel elf...\n")).is_err()); +} diff --git a/tests/common/audio.rs b/tests/common/audio.rs index b677233e7d4..33c6287f3c5 100644 --- a/tests/common/audio.rs +++ b/tests/common/audio.rs @@ -1,4 +1,4 @@ -//! soundserver judged off the log the T14's stick came back with. Audio is judged on +//! soundserver judged off the log a bench's boot left on its disk. Audio is judged on //! metal and nowhere else: no QEMU guest test plays it. use super::serial::Serial; @@ -83,20 +83,20 @@ pub fn tone_on_metal(log: &Serial) -> Result<(), String> { Ok(()) } -/// The job that plays `hda_tone`'s tone while it writes the stick. +/// The job that plays `hda_tone`'s tone while it writes the log disk. pub const TONE_BESIDE_STICK_WRITE: &str = "test_rs_tone_beside_stick_write"; /// That job's tone played to its end through the HDA controller with no /// period left to soundserver, while at least a mebibyte of its lines reached -/// the stick: counted in the file read back off it, not in what the job says. +/// the log disk: counted in the file read back off it, not in what the job says. pub fn tone_beside_a_stick_write(log: &Serial) -> Result<(), String> { on_an_output(log.text(), TONE_BESIDE_STICK_WRITE)?; let window = job_window(log.text(), TONE_BESIDE_STICK_WRITE)?; let reached = super::metal::bytes_of_lines(window, "tone_beside_stick_write: line "); if reached < 1 << 20 { - return Err(format!("{reached} bytes of the job's lines reached the stick beside the tone, under a mebibyte")); + return Err(format!("{reached} bytes of the job's lines reached the disk beside the tone, under a mebibyte")); } - let wrote = format!("{} KiB of lines reached the stick beside the tone", reached >> 10); + let wrote = format!("{} KiB of lines reached the disk beside the tone", reached >> 10); let underruns = sum_field(window, "underruns"); if underruns != 0 { return Err(format!( diff --git a/tests/common/hostdisk.rs b/tests/common/hostdisk.rs index e82da3eb56a..3ee276184c2 100644 --- a/tests/common/hostdisk.rs +++ b/tests/common/hostdisk.rs @@ -1,5 +1,6 @@ //! Another system's disk, as a bench's internal disk is before ToyOS is -//! installed on it, and the judge of what ToyOS did to it. +//! installed on it, and the judge of what ToyOS did to it, there or on a bench +//! that boots ToyOS off the same disk. //! //! The layout is the benches': the host's ESP, its `/boot` and its root, and //! the owner's ToyOS DATA, unformatted. Every block of every partition carries @@ -77,14 +78,15 @@ pub enum Data { Mounted, } -/// A boot off a stick drove an NVMe disk that is another system's and -/// touched nothing on it but its ToyOS DATA partition: a driver brought a -/// controller up and offered its partitions, it opened at least one session -/// and each on a ToyOS DATA partition, and the file server either left that -/// DATA alone, finding it neither ours nor designated, or — once it is -/// designated — formatted or mounted it; never DATA from memory. Answers how -/// many partitions the disks' tables named, and which of those DATA was. -pub fn left_alone(log: &Serial) -> Result<(usize, Data), String> { +/// A boot drove an NVMe disk that holds another system's partitions and +/// touched nothing on it but its ToyOS DATA partition and `ours`, the volumes +/// the loader named for this boot: a driver brought a controller up and +/// offered its partitions, it opened at least one session and each on DATA +/// or one of `ours`, and the file server either left that DATA alone, finding +/// it neither ours nor designated, or — once it is designated — formatted or +/// mounted it; never DATA from memory. Answers how many partitions the disks' +/// tables named, and which of those DATA was. +pub fn left_alone(log: &Serial, ours: &[Guid]) -> Result<(usize, Data), String> { log.must_say("diskserver: every disk driver has settled")?; let said: Vec<&str> = log.text().lines().filter_map(said_by_nvme).collect(); if !said.iter().any(|s| s.starts_with("up: ")) { @@ -109,10 +111,17 @@ pub fn left_alone(log: &Serial) -> Result<(usize, Data), String> { if opened.is_empty() { return Err(format!("no nvme driver opened a session, so none was seen on DATA\n{}", log.text())); } + let ours: Vec = ours.iter().map(Guid::to_string).collect(); for guid in opened { match kinds.iter().find(|(g, _)| *g == guid) { Some((_, kind)) if *kind == data => {} - other => return Err(format!("an nvme driver opened a session on {guid}, of type {other:?}, which is no ToyOS DATA")), + Some(_) if ours.iter().any(|o| o == guid) => {} + other => { + return Err(format!( + "an nvme driver opened a session on {guid}, of type {other:?}, which is no ToyOS DATA \ + and none of the volumes the loader named ({ours:?})" + )) + } } } log.must_not_say("are in memory and will not survive a reboot")?; @@ -136,3 +145,26 @@ pub fn left_alone(log: &Serial) -> Result<(usize, Data), String> { } Ok((kinds.len(), fate)) } + +/// The boot and log volumes the loader named for its boot, out of its own +/// `Boot partition:` and `Log partition:` lines; a boot whose loader named no +/// boot partition has only its log. +pub fn loader_named(loader: &Serial) -> Result, String> { + let signature = |head: &str| -> Result, String> { + let Some(line) = loader.text().lines().find(|l| l.contains(head)) else { return Ok(None) }; + let list = line + .split_once("signature [") + .and_then(|(_, rest)| rest.split_once(']')) + .map(|(list, _)| list) + .ok_or_else(|| format!("{line:?} names no signature"))?; + let bytes: Vec = list + .split(", ") + .map(|b| u8::from_str_radix(b, 16)) + .collect::>() + .map_err(|e| format!("{line:?}: {e}"))?; + let bytes: [u8; 16] = bytes.try_into().map_err(|_| format!("{line:?} names no sixteen bytes"))?; + Ok(Some(Guid(bytes))) + }; + let log = signature("Log partition: signature [")?.ok_or_else(|| format!("the loader named no log partition:\n{}", loader.text()))?; + Ok(signature("Boot partition: LBA ")?.into_iter().chain([log]).collect()) +} diff --git a/tests/common/metal.rs b/tests/common/metal.rs index a975d929519..399962e6cf8 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -1,5 +1,6 @@ -//! The metal profile: which registrations run on the T14, how they batch into -//! images, and what each one's verdict is over the log the stick came back with. +//! The metal profile: which registrations run on a bench, how they batch into +//! images, and what each one's verdict is over the log each boot left on the +//! bench's internal disk. //! //! **A registration's metal declaration is a separate table**, so a test that //! runs only under QEMU simply has no row. What a row says is: the boots this @@ -141,8 +142,8 @@ impl SharedBoot { /// This boot with the members `named` and none else, or `None` where that /// is none of them. **It carries only the files and links its own members - /// name**: the C corpus stages a binary and an expectation per case, and a - /// stick is written over `ssh`. + /// name**: the C corpus stages a binary and an expectation per case, and an + /// image is written over `ssh`. fn keeping(&self, named: impl Fn(&str) -> bool) -> Option { let members: Vec = self.members.iter().filter(|member| named(&member.job)).cloned().collect(); let mine = |path: &str| { @@ -214,7 +215,7 @@ pub struct Metal { pub judge: fn(&[&Readback]) -> Result<(), String>, } -/// What one boot left on the stick, and what the host clock saw of it. +/// What one boot left on the bench's disk, and what the host clock saw of it. pub struct Readback { pub label: String, loader: String, @@ -226,8 +227,6 @@ pub struct Readback { pub boot_ms: Option, /// What the machine spent getting back to Ubuntu's ssh server. pub back_secs: u64, - /// How long after that the boot stick's own partition was there again. - pub stick_secs: u64, /// The machine the loop read before the flash. pub machine: Result, /// Where the loop's wall time went. @@ -244,8 +243,6 @@ impl Readback { let kernel = bootlog::kernel_records(&log); let back_secs = toyos_metal::back_secs(boot) .ok_or_else(|| format!("{label}'s boot file names no `back_secs`: {boot:?}"))?; - let stick_secs = toyos_metal::stick_secs(boot) - .ok_or_else(|| format!("{label}'s boot file names no `stick_secs`: {boot:?}"))?; let phases = toyos_metal::phases(boot) .map_err(|why| format!("{label}'s boot file {why}: {boot:?}"))?; Ok(Readback { @@ -255,7 +252,6 @@ impl Readback { kernel, log, back_secs, - stick_secs, machine: toyos_metal::machine(boot) .map_err(|why| format!("{label}'s boot file {why}")), phases, @@ -306,7 +302,7 @@ impl Readback { /// goes on the black-box page under the boot's `DONE` seal and comes back /// in the next loader pass, and this reads it there — for every boot, /// because the suite's whole verdict is read out of those two files. - pub fn log_reached_the_stick(&self) -> Result<(), String> { + pub fn log_is_whole_to_its_stop(&self) -> Result<(), String> { let after = self.after_the_reset()?; let text = after.text(); // A boot its own deadline or the lockup detector ended never finished @@ -556,13 +552,13 @@ fn within_one_period(late_ms: i64, period_ns: u64) -> Result<(), String> { /// One image, and every test that rides it. /// The bytes of every line of `text` that carries `marker`, newline included: -/// what of a load's lines a file read back off the stick holds. +/// what of a load's lines a file read back off the log volume holds. pub fn bytes_of_lines(text: &str, marker: &str) -> usize { text.lines().filter(|l| l.contains(marker)).map(|l| l.len() + 1).sum() } -/// The job that writes the boot stick for ever (`stick_load.rs`), so its boot -/// is reset by its own deadline with a write in flight. +/// The job that writes the boot's log disk for ever (`stick_load.rs`), so its +/// boot is reset by its own deadline with a write in flight. pub const STICK_LOAD_JOB: &str = "test_rs_stick_load"; /// That boot's deadline: past both benches' `Boot: complete` (12 s on the @@ -601,9 +597,9 @@ impl Batch { /// The bound the kernel gives this whole boot, in milliseconds. /// /// **A boot carrying [`STICK_LOAD_JOB`] is ended by it on purpose**, with - /// the job still writing the stick: [`STICK_LOAD_DEADLINE_MS`], below the - /// list's own bound, so the kernel's reset path, which touches no USB - /// register, lands before the runner would hand the machine back. + /// the job still writing the disk: [`STICK_LOAD_DEADLINE_MS`], below the + /// list's own bound, so the kernel's reset lands before the runner would + /// hand the machine back. pub fn deadline_ms(&self) -> u64 { if self.jobs.iter().any(|job| job == STICK_LOAD_JOB) { assert!(STICK_LOAD_DEADLINE_MS < self.bound_ms(), "the load's deadline is past its runner's bound"); @@ -775,8 +771,8 @@ fn build( let config = home.join(format!("system-{:016x}.toml", fingerprint(&derived))); std::fs::write(&config, &derived).map_err(|e| format!("{}: {e}", config.display()))?; - // Only what this batch's jobs name: a stick is written over `ssh`, so an - // image carrying two hundred binaries it never runs is a minute of flash. + // Only what this batch's jobs name: an image is written over `ssh`, so one + // carrying two hundred binaries it never runs is a minute of flash. let mut extra: Vec<(String, Vec)> = Vec::new(); for job in &batch.jobs { let Some(name) = job.strip_prefix("test_rs_") else { continue }; @@ -795,7 +791,7 @@ fn build( // looking for a child nothing had staged. // // **Which binary, though, is read rather than assumed.** Staging all of them - // on every image cost 150 MB a boot, and a stick is written over `ssh`. A + // on every image cost 150 MB a boot, and an image is written over `ssh`. A // driver reaches a binary as the literal `test_rs_` — the same // spelling `suite_split` reads the harness for — so the text a boot could // possibly name it in is its job list, its symlink targets, and the source @@ -1173,11 +1169,9 @@ pub fn judge_readbacks( }; let ms = back.boot_ms.map_or_else(|| "-".to_string(), |ms| ms.to_string()); eprintln!( - " {label}: Boot: complete {ms} ms, a log of {} bytes, back in {} s, the stick enumerated {} s \ - after that", + " {label}: Boot: complete {ms} ms, a log of {} bytes, back in {} s", back.log.len(), - back.back_secs, - back.stick_secs + back.back_secs ); eprintln!(" phases: {}", back.phases); let panel = back.panel(); @@ -1204,10 +1198,10 @@ pub fn judge_readbacks( } } // **Every boot, and before any verdict is read out of its log.** A - // test's judge reads the file the stick came back with, so a file that + // test's judge reads the file the boot left on the disk, so a file that // stops before the boot does turns a machine fact into a missing line — // and the missing line is what a reader would have to guess about. - findings.extend(back.log_reached_the_stick().err()); + findings.extend(back.log_is_whole_to_its_stop().err()); findings.extend(back.stop_completed().err()); findings.extend(back.deadline_on_time().err()); findings.extend(back.lockup_on_time().err()); diff --git a/tests/common/power.rs b/tests/common/power.rs index 27c7e24e7d7..7f1cd586043 100644 --- a/tests/common/power.rs +++ b/tests/common/power.rs @@ -302,7 +302,7 @@ pub fn watchdog_quiet( ) -> Result<(), String> { // The loader's channel said *something*, which is what makes the absence // below mean anything. Asked of the loader's own first line rather than - // through `must_not_say`: on the stick `loader.log` is a file of its own and + // through `must_not_say`: on the log volume `loader.log` is a file of its own and // carries no kernel record for `Serial::alive` to find. loader.must_say(bootlog::LOADER_FIRST_LINE)?; says_nothing_of(loader, &loader_armed())?; @@ -313,7 +313,7 @@ pub fn watchdog_quiet( /// `must_not_say` on a channel whose liveness the caller has already /// established, because `Serial::alive` asks for a kernel record and the -/// loader's own file on the stick has none. +/// loader's own file on the log volume has none. pub fn says_nothing_of(channel: &serial::Serial, needle: &str) -> Result<(), String> { match channel.text().lines().find(|l| l.contains(needle)) { Some(line) => Err(format!( @@ -415,7 +415,7 @@ fn record_cpu<'a>(line: &'a str, needle: &str) -> Option<&'a str> { /// purpose ended itself, and the pass after the reset read why off the page. /// /// **The only evidence a wedge can leave on this machine.** `logkeeper` writes the -/// kernel log to the stick, and a wedged boot's `logkeeper` never runs again — so +/// kernel log to the log volume, and a wedged boot's `logkeeper` never runs again — so /// everything after the wedge exists only in the record ring, and the black box /// is the one channel that carries a copy of it across the reset. pub fn deadline_wedge_chain(after: &serial::Serial) -> Result<(), String> { @@ -462,14 +462,14 @@ pub fn deadline_wedge_chain(after: &serial::Serial) -> Result<(), String> { /// /// The state under it is the one measured on this machine and on no other: run /// 22's boot hung after its job list with a 120 s deadline armed, sat past -/// 420 s, and left the stick with no kernel log at all — the deadline never +/// 420 s, and left the log volume with no kernel log at all — the deadline never /// fired, because nothing was taking the interrupt that polls it. pub fn hard_lockup_chain( kernel: &serial::Serial, after: &serial::Serial, ) -> Result<(), String> { // **Nothing this judge reads was written after the wedge.** `logkeeper` stops - // where the scheduler does, so the stick's kernel log ends at the last + // where the scheduler does, so the log volume's kernel log ends at the last // spawn, and the sealed page can fill with a boot's *older* records before // it reaches the lines the control writes about itself. What crosses is the // arm line and the record itself. @@ -566,21 +566,20 @@ pub fn blackbox_unclaimed( const ACPI_ARMED: &str = "acpiserver: armed: power button served"; /// [`STICK_LOAD_JOB`](super::metal::STICK_LOAD_JOB)'s boot: the job wrote the -/// stick through usbd, the boot deadline ended the machine while it did, and -/// the stick came back on the next host. How long it took to enumerate is -/// judged against the bench's record (`boot.usbload.stick_secs`), and a stick -/// that never came back is a readback that failed before this was asked. -pub fn stick_survives_a_reset_under_load(back: &super::metal::Readback) -> Result<(), String> { +/// log disk, the boot deadline ended the machine while it did, and the pass +/// after the reset read the deadline's record. The host booting again off that +/// disk is the loop's own half, and a readback is only there once it did. +pub fn the_disk_survives_a_reset_under_load(back: &super::metal::Readback) -> Result<(), String> { let after = back.after_the_reset()?; after.must_say(bootlog::PREVIOUS_PANIC)?; after.must_say_after(bootlog::PREVIOUS_PANIC, bootlog::DEADLINE_EXPIRED)?; says_nothing_of(&after, REBOOTING)?; // What reached the medium: the load's lines in the file read back off the - // stick, which logkeeper wrote through usbd. + // log volume. let reached = super::metal::bytes_of_lines(back.log().text(), "stick_load: line "); if reached < 1 << 20 { - return Err(format!("{reached} bytes of the load's lines reached the stick, under a mebibyte")); + return Err(format!("{reached} bytes of the load's lines reached the disk, under a mebibyte")); } - eprintln!(" [power] {} KiB of the load's lines reached the stick before the reset", reached >> 10); - back.measured("boot.usbload.stick_secs", back.stick_secs) + eprintln!(" [power] {} KiB of the load's lines reached the disk before the reset", reached >> 10); + Ok(()) } diff --git a/tests/guest/rust-tests/src/bin/end_returns_claims.rs b/tests/guest/rust-tests/src/bin/end_returns_claims.rs index 7c60a1a727a..3ed91d1fdbc 100644 --- a/tests/guest/rust-tests/src/bin/end_returns_claims.rs +++ b/tests/guest/rust-tests/src/bin/end_returns_claims.rs @@ -54,7 +54,7 @@ fn spelled(id: PciId) -> String { /// The spare this machine has, as `usbd_spare` chooses it, named by its /// arrival, so every round claims that function and no other of its card. fn spare(cap: &SysCap) -> (PciId, Arrival) { - let pci = spare::of(cap, &SPARES).expect("end_returns_claims: no one spare controller on this machine"); + let pci = spare::of(cap, &SPARES).expect("end_returns_claims: no spare controller on this machine"); (PciId { vendor: pci.vendor, device: pci.device }, pci.arrival) } diff --git a/tests/guest/rust-tests/src/bin/stick_load.rs b/tests/guest/rust-tests/src/bin/stick_load.rs index 0d413dcb3e0..d0ee48583ec 100644 --- a/tests/guest/rust-tests/src/bin/stick_load.rs +++ b/tests/guest/rust-tests/src/bin/stick_load.rs @@ -1,12 +1,13 @@ -//! Writes the boot stick through usbd for ever, so the boot deadline resets -//! the machine with a write in flight: `stick_survives_a_reset_under_load`'s -//! load. Lines to `/log` ([`stick_lines`]), which logkeeper writes to the -//! stick and syncs every round. +//! Writes the boot's log disk for ever — QEMU's stick, a bench's NVMe — so the +//! boot deadline resets the machine with a write in flight: +//! `the_disk_survives_a_reset_under_load`'s load. Lines to `/log` +//! ([`stick_lines`]), which logkeeper writes to that disk and syncs every +//! round. #[path = "../stick_lines.rs"] mod stick_lines; -/// About 180 KB a second: a write and a sync on the stick every logkeeper +/// About 180 KB a second: a write and a sync on the disk every logkeeper /// round, and a boot's log that stays inside logkeeper's retention (16 files /// of a mebibyte) until the deadline ends it, so the file read back is /// whole. At ten times the rate the boot rotated 55 of its own parts away. diff --git a/tests/guest/rust-tests/src/bin/stick_write.rs b/tests/guest/rust-tests/src/bin/stick_write.rs index 31e0bd0e103..47b3cd91a0d 100644 --- a/tests/guest/rust-tests/src/bin/stick_write.rs +++ b/tests/guest/rust-tests/src/bin/stick_write.rs @@ -1,6 +1,6 @@ -//! Eight mebibytes of lines to `/log` on the boot stick through usbd -//! ([`stick_lines`]), then an end: the stick-write load `mask_windows` reads -//! the windows under. +//! Eight mebibytes of lines to `/log` on the boot's log disk +//! ([`stick_lines`]), then an end: the log-write load `mask_windows` reads the +//! windows under. #[path = "../stick_lines.rs"] mod stick_lines; diff --git a/tests/guest/rust-tests/src/bin/tone_beside_stick_write.rs b/tests/guest/rust-tests/src/bin/tone_beside_stick_write.rs index f9cb7cc4dc8..04f5c3eb661 100644 --- a/tests/guest/rust-tests/src/bin/tone_beside_stick_write.rs +++ b/tests/guest/rust-tests/src/bin/tone_beside_stick_write.rs @@ -1,7 +1,7 @@ //! `hda_tone`'s tone, played while a thread writes lines to `/log` on the -//! boot stick through usbd ([`stick_lines`]): `tone_beside_a_stick_write`'s -//! stimulus (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`). -//! The judge counts the lines read back off the stick, which have to be the +//! boot's log disk ([`stick_lines`]): `tone_beside_a_stick_write`'s stimulus +//! (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`). +//! The judge counts the lines read back off the log, which have to be the //! mebibyte the issue names; soundserver's counters over the job's window are //! the verdict. diff --git a/tests/guest/rust-tests/src/spare.rs b/tests/guest/rust-tests/src/spare.rs index a6a0a58bee9..520fe625f2c 100644 --- a/tests/guest/rust-tests/src/spare.rs +++ b/tests/guest/rust-tests/src/spare.rs @@ -1,29 +1,27 @@ -//! The spare controller a job claims: the one function of any of a set of -//! cards no driver holds. Named by its arrival, because a machine may have -//! two of one card. +//! The spare controller a job claims: the first function, in the inventory's +//! order, of any of a set of cards no driver holds. A bench booted off its +//! NVMe leaves both its xHCI controllers free, and the first is the one every +//! boot of it takes. Named by its arrival, because a machine may have two of +//! one card. use toyos::syscap::SysCap; use toyos_abi::inventory::{Driven, Pci, RawRecord, Record}; use toyos_abi::syscall::PciId; -/// That function, or `None` where it is not one. +/// That function, or `None` where every one of `ids` is held. pub fn of(cap: &SysCap, ids: &[PciId]) -> Option { - one(cap, ids, Driven::Free) + all(cap, ids).into_iter().find(|pci| pci.driven == Driven::Free) } -/// The one function of `ids` that `driven` says of. -pub fn one(cap: &SysCap, ids: &[PciId], driven: Driven) -> Option { +/// Every function of `ids`, in the inventory's order. +pub fn all(cap: &SysCap, ids: &[PciId]) -> Vec { let records: Vec = cap.records(|n| vec![RawRecord::EMPTY; n]).unwrap_or_else(|why| panic!("the job's capability reads no inventory: {why:?}")); - let spares: Vec = records + records .into_iter() .filter_map(|record| match record { Record::Pci(pci) if ids.contains(&PciId { vendor: pci.vendor, device: pci.device }) => Some(pci), _ => None, }) - .collect(); - match spares.iter().copied().filter(|pci| pci.driven == driven).collect::>()[..] { - [one] => Some(one), - _ => None, - } + .collect() } diff --git a/tests/guest/rust-tests/src/stick_lines.rs b/tests/guest/rust-tests/src/stick_lines.rs index ac26db43d2f..791bcc9b058 100644 --- a/tests/guest/rust-tests/src/stick_lines.rs +++ b/tests/guest/rust-tests/src/stick_lines.rs @@ -1,13 +1,11 @@ -//! Lines written to `/log`, which a metal boot's logkeeper keeps on the boot -//! stick and makes durable every round: the one volume on that stick a job -//! reaches. (`/home` is DATA's, and a metal test image carries no DATA -//! partition, so DATA lives in memory there.) +//! Lines written to `/log`, which a boot's logkeeper keeps on its log volume +//! and makes durable every round. //! //! Each line is one record of [`LINE_BYTES`], paced at the caller's rate, //! under logkeeper's allowance for a program's children (4096 records a //! second), so none is suppressed and every one reaches the file. What //! reached the medium is counted by the judge in the file read back off the -//! stick, never here. +//! log volume, never here. use std::time::{Duration, Instant}; diff --git a/tests/guest/rust-tests/src/usbd_spare.rs b/tests/guest/rust-tests/src/usbd_spare.rs index 63c16c5e84d..f9d959c29e1 100644 --- a/tests/guest/rust-tests/src/usbd_spare.rs +++ b/tests/guest/rust-tests/src/usbd_spare.rs @@ -48,8 +48,8 @@ pub fn cap() -> SysCap { /// The controllers a boot of this job can leave to a claim: QEMU's /// `qemu-xhci` on the guest profiles, either of the T14's two and the AMD -/// bench's, whose two controllers share an id. The image's usbd row drives the -/// one holding the stick, so `claim` takes the one of them nobody holds. +/// bench's, whose two controllers share an id. `claim` takes the first of them +/// nobody holds. const SPARES: [PciId; 4] = [ PciId { vendor: 0x8086, device: 0xa0ed }, PciId { vendor: 0x1b36, device: 0x000d }, @@ -73,7 +73,7 @@ const PAST_THE_GRANT: u64 = 1 << 30; /// card the kernel does not drive, or, on a kernel that drives every one, /// the only one there is. fn claim(cap: &SysCap) -> (PciId, Result) { - let pci = spare::of(cap, &SPARES).expect("usbd_spare: no one spare controller on this machine"); + let pci = spare::of(cap, &SPARES).expect("usbd_spare: no spare controller on this machine"); (PciId { vendor: pci.vendor, device: pci.device }, cap.claim_function(pci.arrival)) } @@ -240,8 +240,8 @@ pub fn fault(cap: &SysCap) { const BOOT: [PciId; 1] = [PciId { vendor: 0x1033, device: 0x0194 }]; pub fn refused(cap: &SysCap) { - let boot = spare::one(cap, &BOOT, toyos_abi::inventory::Driven::Claimed) - .expect("usbd_spare: no boot controller a process holds"); + let held: Vec<_> = spare::all(cap, &BOOT).into_iter().filter(|pci| pci.driven == toyos_abi::inventory::Driven::Claimed).collect(); + let [boot] = held[..] else { panic!("usbd_spare: not one boot controller a process holds: {} are", held.len()) }; let id = PciId { vendor: boot.vendor, device: boot.device }; match cap.claim_function::(boot.arrival) { Err(SyscallError::AlreadyExists) => println!("usbd_spare: the claim on {} was refused: AlreadyExists", spelled(id)), diff --git a/tests/images/acpicase.toml b/tests/images/acpicase.toml index ff0955ee095..8b8d4a9c3f5 100644 --- a/tests/images/acpicase.toml +++ b/tests/images/acpicase.toml @@ -45,8 +45,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free for the tests that claim one. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free for the tests that claim one. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/images/benchcase.toml b/tests/images/benchcase.toml index 580062e5459..5eea324c793 100644 --- a/tests/images/benchcase.toml +++ b/tests/images/benchcase.toml @@ -109,8 +109,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free for the tests that claim one. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free for the tests that claim one. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/images/floorcase.toml b/tests/images/floorcase.toml index a58a1fe20cb..f28e0ae9513 100644 --- a/tests/images/floorcase.toml +++ b/tests/images/floorcase.toml @@ -71,8 +71,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. No floor: it drives -# disks too, so it serves ordinary work. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# No floor: it drives disks too, so it serves ordinary work. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/images/jobcase.toml b/tests/images/jobcase.toml index 4ac95388b1c..b4dcda2afcc 100644 --- a/tests/images/jobcase.toml +++ b/tests/images/jobcase.toml @@ -36,8 +36,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free for the tests that claim one. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free for the tests that claim one. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/images/latencycase.toml b/tests/images/latencycase.toml index 687d9d189c7..9225b3423e1 100644 --- a/tests/images/latencycase.toml +++ b/tests/images/latencycase.toml @@ -53,8 +53,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free for the tests that claim one. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free for the tests that claim one. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/images/metalcase.toml b/tests/images/metalcase.toml index d9d1f610f4d..1b1dbe6e61c 100644 --- a/tests/images/metalcase.toml +++ b/tests/images/metalcase.toml @@ -89,8 +89,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free for the tests that claim one. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free for the tests that claim one. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk", "input"] diff --git a/tests/images/metaldevicecase.toml b/tests/images/metaldevicecase.toml index d61a7fc8b68..050ea8dcc2f 100644 --- a/tests/images/metaldevicecase.toml +++ b/tests/images/metaldevicecase.toml @@ -77,8 +77,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free for the tests that claim one. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free for the tests that claim one. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/images/netcase.toml b/tests/images/netcase.toml index a50e38f2fbe..1d3ec03f932 100644 --- a/tests/images/netcase.toml +++ b/tests/images/netcase.toml @@ -54,8 +54,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free for the tests that claim one. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free for the tests that claim one. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/images/proctreecase.toml b/tests/images/proctreecase.toml index 0ec846d590c..c32d8ec1911 100644 --- a/tests/images/proctreecase.toml +++ b/tests/images/proctreecase.toml @@ -68,8 +68,8 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free for the tests that claim one. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free for the tests that claim one. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/images/testcases.toml b/tests/images/testcases.toml index 41961cfb284..881d330ff86 100644 --- a/tests/images/testcases.toml +++ b/tests/images/testcases.toml @@ -109,9 +109,9 @@ drives = ["pci:01:08:02"] receives = ["disk"] # The USB driver: QEMU's `nec-usb-xhci`, and the controller firmware booted -# through, which on a bench is the one holding the stick. Every other xHCI -# controller stays free: `usbd_spare` claims the one a machine has and starts -# its own usbd on it, holding the claim, its `usb` port and its process. +# through where that is an xHCI, which on a bench booted off its NVMe is none. +# Every other xHCI controller stays free: `usbd_spare` claims the first and +# starts its own usbd on it, holding the claim, its `usb` port and its process. [programs.usbd] drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] receives = ["disk"] diff --git a/tests/metal/Cargo.toml b/tests/metal/Cargo.toml index f76030bffef..fd61b6c3406 100644 --- a/tests/metal/Cargo.toml +++ b/tests/metal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos-metal" -description = "The metal loop that flashes an image to a bench's stick, boots it once and reads back what the boot left; the suite's command line, and what the metal profile derives, times and reads off a boot." +description = "The metal loop that writes an image to a bench's internal disk, boots it once and reads back what the boot left; the suite's command line, and what the metal profile derives, times and reads off a boot." version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/tests/metal/src/image.rs b/tests/metal/src/image.rs index ef4c451e195..5be91304db3 100644 --- a/tests/metal/src/image.rs +++ b/tests/metal/src/image.rs @@ -119,7 +119,7 @@ pub fn derive( symlinks.insert(REBOOT_LINK.to_string(), Value::String(TOYBOX_PATH.to_string())); // A boot whose jobs are reached through names of their own — the C corpus's // one comparator, staged once and symlinked once per case, so the kernel - // records each run under the case's name and a stick says which one failed. + // records each run under the case's name and the log says which one failed. for (from, to) in extra_links { symlinks.insert(from.clone(), Value::String(to.clone())); } diff --git a/tests/metal/src/lib.rs b/tests/metal/src/lib.rs index 2926fca80c8..500d18600f4 100644 --- a/tests/metal/src/lib.rs +++ b/tests/metal/src/lib.rs @@ -1,28 +1,23 @@ -//! `toyos-metal` — the loop that flashes ToyOS to a metal bench's stick, boots -//! it once, and answers with [`toyos_build::bootlog`]'s verdict on what that boot -//! wrote to the log partition; and beside it, the internal install -//! ([`internal`]) that writes an image to the bench's own NVMe for a demo boot. +//! `toyos-metal` — the loop that writes a ToyOS image to a metal bench's +//! internal NVMe ([`internal`]), boots it once through `BootNext`, and answers +//! with [`toyos_build::bootlog`]'s verdict on what that boot wrote to its log +//! partition. //! //! It runs on the development host and reaches the machine over `ssh`: the //! T14 bench unless `--host` names another, whose account's home holds the //! log's mount point and which has passwordless sudo. [`Target::argv`] is the //! one place a root command line ([`Job`]) is written. //! -//! Two admission checks stand before any write: the image is a whole number of -//! [`LBA`]-byte sectors carrying `EFI PART` in its final one, and the stick is -//! the one disk that answers with the identity the machine's record names and -//! carries a ToyOS image ([`located`]) — the machine read by its SMBIOS first, -//! and a machine with no record refused. A stick that carries no ToyOS image -//! yet is adopted only by naming its node by hand. A node is a whole SCSI or -//! NVMe disk, `/dev/sd` or `/dev/nvmen`, and nothing else. A -//! recorded bench's internal NVMe is a playground ToyOS may be installed on and -//! a broken driver may wipe; what an internal install may write on it is -//! [`internal::recognise`]'s to say. +//! **The bench's own partitions are never written.** The machine must answer +//! as a recorded bench by its SMBIOS, the disk must be one +//! [`internal::recognise`] knows, and every write names one of the partitions +//! after the bench's four. The firmware's order keeps Ubuntu first: ToyOS's +//! entry is never in it, so a ToyOS boot that fails or is reset hands the next +//! boot to Ubuntu. //! //! Beside the loop: [`testargs`], the suite's command line, which carries the -//! metal profile's flags; [`image`], the config a metal boot derives; -//! [`timings`], each bench's record; and [`devices`], what a boot's reset says -//! about itself. +//! metal profile's flags; [`image`], the config a metal boot derives; and +//! [`timings`], each bench's record. use std::fmt; use std::io::{Read, Seek}; @@ -30,13 +25,12 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use toyos_build::bootlog; -use toyos_build::disk::{whole_disk, Node, Unfit}; +use toyos_build::disk::Node; use toyos_build::flags::{declare_flags, Flag}; -use toyos_build::image::LBA; use toyos_gpt::Guid; use crate::internal::Owned; -use crate::timings::{Machine, Record, Stick}; +use crate::timings::{Machine, Record}; pub mod image; pub mod internal; @@ -80,15 +74,6 @@ pub fn return_secs(deadline_ms: u64) -> u64 { /// How often the loop asks whether the machine is there. const POLL: std::time::Duration = std::time::Duration::from_secs(1); -/// 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 -/// USB device its next host can enumerate.** Ten times the three seconds -/// Ubuntu's whole enumeration — four addressing attempts and a port power cycle -/// — spends before it gives up, so a stick this wait does not find is one the -/// host has already finished refusing. -pub const STICK_SECS: u64 = 30; - /// Each tool at the path the bench's Ubuntu installs it, so `sudo` runs that /// one whatever the account's `PATH`. const BLOCKDEV: &str = "/usr/sbin/blockdev"; @@ -107,12 +92,15 @@ const WIPEFS: &str = "/usr/sbin/wipefs"; /// event at 180 s (systemd-udevd(8), `--event-timeout`). const SETTLE_SECS: u64 = 180; -/// What the firmware loads off the stick's ESP, and off the internal one. +/// What the firmware loads off the internal ESP. const LOADER: &str = r"\EFI\BOOT\BOOTX64.EFI"; /// The internal install's boot entry in the firmware's list. const INTERNAL_LABEL: &str = "ToyOS-internal"; +/// The entry [`fall_through`] boots once, on the internal ESP, and deletes. +const UNBOOTABLE_LABEL: &str = "ToyOS-unbootable"; + /// The three logind keys that keep a closed-lid machine awake. const LID_KEYS: &[&str] = &["HandleLidSwitch", "HandleLidSwitchExternalPower", "HandleLidSwitchDocked"]; @@ -138,26 +126,13 @@ pub fn said_refusal(stderr: &str) -> Option { /// Every way this loop refuses, by name. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Refusal { - /// The node is no whole disk's, or the image no whole disk's image. - Disk(Unfit), /// No `HOME`, so the default key path would be rooted at `/`. NoHome, - /// The disk did not answer with the identity this loop was given. + /// The disk did not answer what [`Identity::parse`] reads. Identity { field: &'static str, want: String, got: String }, - /// No disk is a recorded one carrying what this step needs; each disk - /// considered is named with why it is not. - Unlocated { carrying: String, considered: Vec }, - /// More than one disk is a recorded one carrying it, so none is. - Ambiguous { carrying: String, nodes: Vec }, /// The disk named to be adopted as scratch is not one this loop erases. Adopt(String), File { path: String, why: String }, - Table(String), - /// The table does not hold exactly one partition of a type the loop needs. - Partitions { what: &'static str, matched: u32 }, - /// The table holds neither one nor two of a slot's partitions, BOOT or - /// ROOT: the slots the loader boots. - Slots { what: &'static str, matched: u32 }, /// The image is armed with a parameter [`FLASHABLE`] does not clear. Armed { name: String }, /// **The image carries no bound on its own boot.** Every metal image is @@ -168,7 +143,7 @@ pub enum Refusal { NoBound { staged_a_wedge: bool }, /// **The outside judge's verdict on the volume the boot left.** What /// `toyos-fat32-check` said about the log partition read straight off the - /// stick — the bytes, not a driver's opinion of them, and not the FAT32 + /// disk — the bytes, not a driver's opinion of them, and not the FAT32 /// implementation that wrote them. Fat32(String), /// What landed on the disk is not what the image says. @@ -183,9 +158,9 @@ pub enum Refusal { Remote { what: String, status: String, stderr: 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 - /// left a USB device its next host cannot enumerate. - Stick { secs: u64 }, + /// The firmware booted the entry [`fall_through`] made unbootable, or + /// kept the `BootNext` naming it. + FellThrough(String), /// The log the boot left is not a passing boot's. Log(bootlog::Unfit), /// **The loader reported a record and booted no kernel.** Not the same thing @@ -196,7 +171,7 @@ pub enum Refusal { /// record to report at all. ReportedAndBootedNothing { said: String }, /// **The last boot of this image was handed the machine and hung.** The - /// loader counts attempts on the stick, and this pass is the second of an + /// loader counts attempts on the log partition, and this pass is the second of an /// image whose first never reported — so it booted no kernel and handed the /// machine back rather than starting the same hang again. What is owed is a /// look at why that kernel stopped, and the boot before this one is where. @@ -211,7 +186,7 @@ pub enum Refusal { Machine(String), /// The account's home on the machine is no normal absolute path. Home(String), - /// The machine has no record, so no stick this loop may write. + /// The machine has no record, so it is no bench this loop writes. Unrecorded { path: String }, /// The machine's record is there and does not read. Record(String), @@ -227,12 +202,12 @@ pub enum Refusal { impl Refusal { /// Whether the machine failed rather than the loop: the boot is the subject - /// only once the stick is written and the reboot asked for. + /// only once the disk is written and the reboot asked for. pub fn about_the_boot(&self) -> bool { matches!( self, Self::Silent { .. } - | Self::Stick { .. } + | Self::FellThrough(_) | Self::Log(_) | Self::Fat32(_) | Self::ReportedAndBootedNothing { .. } @@ -245,36 +220,12 @@ impl Refusal { impl fmt::Display for Refusal { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Disk(unfit) => write!(f, "{unfit}"), Self::NoHome => write!(f, "no HOME, so there is no default key path; pass --key"), Self::Identity { field, want, got } => { write!(f, "the disk's {field} reads {got:?} where this loop was given {want:?}") } - Self::Unlocated { carrying, considered } => write!( - f, - "no disk is a recorded one carrying {carrying}, so nothing was written ({}). A \ - boot stick that carries no ToyOS image yet is adopted by naming it by hand, \ - --device /dev/sd, and a scratch disk by --adopt-scratch /dev/sd", - match considered.is_empty() { - true => "no /dev/sd disk is there".to_string(), - false => considered.join("; "), - } - ), Self::Adopt(why) => write!(f, "nothing was erased: {why}"), - Self::Ambiguous { carrying, nodes } => write!( - f, - "{} are each a recorded disk carrying {carrying}, so nothing was written: unplug \ - every one but the disk meant", - nodes.join(" and ") - ), Self::File { path, why } => write!(f, "{path}: {why}"), - Self::Table(why) => write!(f, "the image carries no readable partition table: {why}"), - Self::Partitions { what, matched } => { - write!(f, "the image carries {matched} {what} partitions and this loop needs one") - } - Self::Slots { what, matched } => { - write!(f, "the image carries {matched} {what} partitions and this loop needs one or two") - } Self::NoBound { staged_a_wedge } => write!( f, "the image carries no `{}` on its parameter line{}. Every image the \ @@ -292,7 +243,7 @@ impl fmt::Display for Refusal { Self::Fat32(said) => write!( f, "the log partition the boot left does not check out: {said}. This is \ - `toyos-fat32-check` reading the volume off the stick, so what it names is \ + `toyos-fat32-check` reading the volume off the disk, so what it names is \ what the kernel wrote and not what a driver read back" ), Self::Armed { name } => write!( @@ -329,13 +280,7 @@ impl fmt::Display for Refusal { a boot runs under plus the time coming back costs; why it did not is what the \ panel and the log partition say, and neither is readable from here" ), - Self::Stick { secs } => write!( - f, - "the machine came back and the stick carrying this image did not, within {secs} s: \ - the boot that just ran left this USB device in a state its next host cannot enumerate, and a \ - physical replug is what clears one — see `usb 3-1: device descriptor read/64, \ - error -71` on the machine's own journal" - ), + Self::FellThrough(why) => write!(f, "the firmware did not fall through past an unbootable entry: {why}"), Self::Log(unfit) => write!(f, "the log partition came back and {unfit}"), Self::Wedge { why } => write!( f, @@ -364,10 +309,9 @@ impl fmt::Display for Refusal { ), Self::Unrecorded { path } => write!( f, - "this machine has no record at {path}, so nothing names the stick this loop may \ - write. Record it once, naming the boot stick by hand: `cargo run -p toyos-metal -- \ - --record /dev/sd --host @` writes the record from what the \ - machine and that disk answer" + "this machine has no record at {path}, so it is no bench this loop writes. Record \ + it once: `cargo run -p toyos-metal -- --record --host @` writes the \ + record from what the machine's SMBIOS answers" ), Self::Record(why) => write!(f, "the machine's record does not read: {why}"), Self::Layout(why) => write!(f, "the internal disk is not installed on: {why}"), @@ -379,7 +323,7 @@ impl fmt::Display for Refusal { } /// A whole SCSI disk, `/dev/sd`: what a USB stick enumerates as, and -/// the one kind of disk the loop writes whole. +/// the one kind of disk [`adopt_scratch`] erases. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Sd(u8); @@ -387,7 +331,7 @@ impl Sd { fn parse(path: &str) -> Result { match Node::parse(path) { Ok(Node::Scsi(letter)) => Ok(Self(letter)), - _ => Err(Refusal::Usage(format!("{path:?} is no /dev/sd disk, which is what a stick is"))), + _ => Err(Refusal::Usage(format!("{path:?} is no /dev/sd disk, which is what a scratch disk is"))), } } @@ -435,27 +379,10 @@ impl Identity { removable: removable == "1", }) } - - fn check(&self, want: &Stick) -> Result<(), Refusal> { - let no = |field, want: String, got: String| Refusal::Identity { field, want, got }; - if self.sectors != want.sectors { - return Err(no("size", want.sectors.to_string(), self.sectors.to_string())); - } - if self.vendor != want.vendor { - return Err(no("vendor", want.vendor.clone(), self.vendor.clone())); - } - if self.model != want.model { - return Err(no("model", want.model.clone(), self.model.clone())); - } - if !self.removable { - return Err(no("removable", "1".to_string(), "0".to_string())); - } - Ok(()) - } } /// Every block device with its partitions' numbers, types, GUIDs and -/// filesystems, as udev records them, for [`located`]. +/// filesystems, as udev records them, for [`adoptable`]. const DISKS: &str = "lsblk --noheadings --paths --pairs --output NAME,TYPE,PKNAME,PARTN,PARTTYPE,PARTUUID,FSTYPE"; /// One partition [`DISKS`] listed. @@ -505,26 +432,13 @@ fn sd_disks(listing: &str) -> Result)>, Refusal> { Ok(disks) } -/// What a disk's partitions are, by type, so a refusal tells apart two disks -/// of one identity for whoever then names one by hand. -fn table(parts: &[Listed]) -> String { - let kinds: Vec = - parts.iter().map(|p| p.kind.map_or_else(|| "no GPT type".to_string(), |k| k.to_string())).collect(); - match kinds.len() { - 0 => "no partitions".to_string(), - n => format!("{n} partitions: {}", kinds.join(", ")), - } -} - -/// What a disk must hold to be the stick a step writes or reads: the recorded -/// identity alone is shared by every stick of the model. +/// What a disk holds, which is what tells a scratch disk from one carrying an +/// image. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Carrying { - /// A ToyOS image's table, as [`admit`] holds an image to one: one ESP, one + /// A ToyOS image's table, as `build/image.rs` writes one: one ESP, one /// TOYOS-LOG, one slot table, and one or two slots. Layout, - /// This image's TOYOS-LOG, by the GUID its build drew. - Log { index: u32, guid: Guid }, /// A scratch disk's table, as [`adopt_scratch`] writes it: one partition, /// of [`Guid::TOYOS_SCRATCH`], so no disk holding a ToyOS image is one. /// The mark is the whole of a scratch disk's identity: only adoption @@ -534,7 +448,6 @@ enum Carrying { impl Carrying { fn holds(self, parts: &[Listed]) -> bool { - let at = |index: u32| parts.iter().find(|p| p.index == index); let count = |kind: Guid| parts.iter().filter(|p| p.kind == Some(kind)).count(); match self { Self::Layout => { @@ -544,7 +457,6 @@ impl Carrying { && (1..=2).contains(&count(Guid::TOYOS_BOOT)) && (1..=2).contains(&count(Guid::TOYOS_ROOT)) } - Self::Log { index, guid } => at(index).is_some_and(|p| p.unique == Some(guid)), Self::Scratch => matches!(parts, [only] if only.kind == Some(Guid::TOYOS_SCRATCH)), } } @@ -554,42 +466,11 @@ impl fmt::Display for Carrying { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Layout => write!(f, "a ToyOS image's partition table"), - Self::Log { guid, .. } => write!(f, "this image's TOYOS-LOG {guid}"), Self::Scratch => write!(f, "a scratch disk's one partition"), } } } -/// The one disk of `listing` that carries `carrying` and answers as the -/// recorded disk `want`, `identity` answering [`Sd::identity`] for each disk -/// that carries it; refused where none is, or more than one. -fn located( - listing: &str, - carrying: Carrying, - want: &Stick, - mut identity: impl FnMut(Sd) -> Result, -) -> Result { - let (mut found, mut considered) = (Vec::new(), Vec::new()); - for (sd, parts) in sd_disks(listing)? { - if !carrying.holds(&parts) { - considered.push(format!("{} does not carry {carrying} ({})", sd.node().whole(), table(&parts))); - continue; - } - match Identity::parse(&identity(sd)?)?.check(want) { - Ok(()) => found.push(sd), - Err(no) => considered.push(format!("{}: {no}", sd.node().whole())), - } - } - match found[..] { - [one] => Ok(one), - [] => Err(Refusal::Unlocated { carrying: carrying.to_string(), considered }), - _ => Err(Refusal::Ambiguous { - carrying: carrying.to_string(), - nodes: found.iter().map(|sd| sd.node().whole()).collect(), - }), - } -} - /// `machine`'s record in `dir`, refused where it has none. fn recorded(dir: &Path, machine: &Machine) -> Result { Record::load(dir, machine) @@ -597,55 +478,18 @@ fn recorded(dir: &Path, machine: &Machine) -> Result { .ok_or_else(|| Refusal::Unrecorded { path: dir.join(machine.file()).display().to_string() }) } -/// Whether `disk` is the stick `machine`'s record in `dir` names. -fn recorded_stick(dir: &Path, machine: &Machine, disk: &Identity) -> Result { - let stick = recorded(dir, machine)?.stick; - disk.check(&stick)?; - Ok(stick) -} - -/// What a capture left: the machine's record, the stick it names, and whether -/// this capture wrote it. -#[derive(Debug, PartialEq, Eq)] -struct Captured { - at: PathBuf, - stick: Stick, - wrote: bool, -} - -impl Captured { - /// The capture's account of itself: the record, the disk it was taken - /// from, and the disk every later run writes. - fn said(&self, sd: Sd) -> String { - let Stick { sectors, vendor, model } = &self.stick; - format!( - "{} {}: the stick is {}, {sectors} sectors, {vendor} {model}, removable; every run on \ - this machine writes the one disk of that identity carrying a ToyOS image, or the one \ - --device names", - self.at.display(), - if self.wrote { "records this machine now" } else { "already recorded this machine" }, - sd.node().whole(), - ) - } -} - -/// Record `machine` with `disk` as its stick where it has no record, and check -/// `disk` against the record where it has one. **It never moves a record**: a -/// machine is re-recorded by deleting its file. -fn record_machine(dir: &Path, machine: &Machine, disk: &Identity) -> Result { +/// Record `machine` in `dir` where it has no record, answering the record's +/// path and whether this wrote it. **It never moves a record**: a machine is +/// re-recorded by deleting its file. +fn record_machine(dir: &Path, machine: &Machine) -> Result<(PathBuf, bool), Refusal> { let wrote = match Record::load(dir, machine).map_err(Refusal::Record)? { Some(_) => false, None => { - let stick = - Stick { sectors: disk.sectors, vendor: disk.vendor.clone(), model: disk.model.clone() }; - // Before the write, so a fixed disk is never recorded as a stick. - disk.check(&stick)?; - Record::new(machine, stick).save(dir).map_err(Refusal::Record)?; + Record::new(machine).save(dir).map_err(Refusal::Record)?; true } }; - let stick = recorded_stick(dir, machine, disk)?; - Ok(Captured { at: dir.join(machine.file()), stick, wrote }) + Ok((dir.join(machine.file()), wrote)) } /// Every command this loop runs as root, with what it acts on. @@ -655,25 +499,25 @@ enum Job { /// The signatures inside one partition, which a wipe of the whole disk /// leaves. WipePartition { sd: Sd, part: u32 }, - Flash(Sd), Settle, /// The kernel's table of the disk, read again off the disk. Reread(Sd), - /// One partition read whole, as bytes rather than as files: what - /// `toyos-fat32-check` judges is the volume `logkeeper` wrote, and a + /// The log volume's first `bytes` read as bytes rather than as files: + /// what `toyos-fat32-check` judges is the volume `logkeeper` wrote, and a /// `mount` has already had a driver's opinion about it. - ReadLog { sd: Sd, part: u32 }, - Create { sd: Sd, esp: u32 }, + ReadLog { bytes: u64 }, + /// A boot entry naming `loader` on the internal ESP. + Create { label: &'static str, loader: &'static str }, Delete(String), BootNext(String), - Mount { sd: Sd, part: u32 }, + /// One of the install's FAT volumes, read-only, at [`Home::mount`]. + Mount(Owned), Umount, Reboot, Probe, Partition, AddPartitions, Retag(Vec<(Owned, Guid)>), - CreateInternal, Write(Owned), Scratch(Sd), } @@ -719,13 +563,8 @@ struct Target { user: String, host: String, key: PathBuf, - /// The stick named by hand: one a boot adopts without the ToyOS image - /// [`located`] otherwise asks it to carry. - device: Option, - /// The boot entry's label in the firmware's list. - label: String, - /// The bench's own NVMe, which an internal install writes only the - /// [`Owned`] partitions of. + /// The bench's own NVMe, which an install writes only the [`Owned`] + /// partitions of. internal: Node, } @@ -737,8 +576,6 @@ impl Target { user: "toyos".to_string(), host: "bench-t14".to_string(), key: PathBuf::from(home).join(".ssh/id_ed25519_toyos_runner"), - device: None, - label: "ToyOS".to_string(), internal: Node::Nvme { controller: b'0', namespace: b'1' }, }) } @@ -747,30 +584,42 @@ impl Target { fn argv(&self, job: &Job, home: &Home) -> Vec { let internal = self.internal.whole(); let words = |words: &[&str]| words.iter().map(|w| (*w).to_string()).collect::>(); - // `--create-only`, never `--create`: the latter "add[s] to bootorder" - // (efibootmgr(8)) at the top, so the boot after the one `--bootnext` - // bought picks ToyOS again, and a job list whose one job is a reboot - // then loops — and an internal install would replace Ubuntu as the - // machine's default. - let create = |disk: &str, part: u32, label: &str| { - words(&[EFIBOOTMGR, "--create-only", "--disk", disk, "--part", &part.to_string(), "--label", label, "--loader", LOADER]) - }; match job { Job::Wipe(sd) => words(&[WIPEFS, "--all", &sd.node().whole()]), Job::WipePartition { sd, part } => words(&[WIPEFS, "--all", &sd.node().partition(*part)]), - // `nocreat`: a node that is not there is refused, rather than - // created as a file in `/dev` that every later write lands in. - Job::Flash(sd) => words(&[DD, &format!("of={}", sd.node().whole()), "bs=4M", "conv=fsync,nocreat"]), // Root's, because only root's settle first waits for udevd to // have handled what is already pending, such as a writer's close // it has yet to turn into an event. Job::Settle => words(&[UDEVADM, "settle", &format!("--timeout={SETTLE_SECS}")]), Job::Reread(sd) => words(&[BLOCKDEV, "--rereadpt", &sd.node().whole()]), - Job::ReadLog { sd, part } => words(&[DD, &format!("if={}", sd.node().partition(*part)), "bs=4M"]), - Job::Create { sd, esp } => create(&sd.node().whole(), *esp, &self.label), + // The volume and not the partition, which is larger than any + // image's (coreutils' `count_bytes`). + Job::ReadLog { bytes } => words(&[ + DD, + &format!("if={}", Owned::Log.node(&self.internal)), + "bs=4M", + "iflag=count_bytes", + &format!("count={bytes}"), + ]), + // `--create-only`, never `--create`: the latter "add[s] to + // bootorder" (efibootmgr(8)) at the top, which would replace + // Ubuntu as the machine's default and leave a failed ToyOS boot + // nothing to fall back to. + Job::Create { label, loader } => words(&[ + EFIBOOTMGR, + "--create-only", + "--disk", + &internal, + "--part", + &Owned::Esp.index().to_string(), + "--label", + label, + "--loader", + loader, + ]), Job::Delete(id) => words(&[EFIBOOTMGR, "--delete-bootnum", "--bootnum", id]), Job::BootNext(id) => words(&[EFIBOOTMGR, "--bootnext", id]), - Job::Mount { sd, part } => words(&[MOUNT, "-o", "ro", &sd.node().partition(*part), &home.mount()]), + Job::Mount(o) => words(&[MOUNT, "-o", "ro", &o.node(&self.internal), &home.mount()]), Job::Umount => words(&[UMOUNT, &home.mount()]), Job::Reboot => words(&[REBOOT]), Job::Probe => words(&[TRUE]), @@ -812,7 +661,8 @@ impl Target { "--change-name=1:ToyOS-scratch", &sd.node().whole(), ]), - Job::CreateInternal => create(&internal, Owned::Esp.index(), INTERNAL_LABEL), + // `nocreat`: a node that is not there is refused, rather than + // created as a file in `/dev` that every later write lands in. Job::Write(o) => words(&[DD, &format!("of={}", o.node(&self.internal)), "bs=4M", "conv=fsync,nocreat"]), } } @@ -840,37 +690,15 @@ impl Target { } } -/// One partition of the image, in [`LBA`]-byte sectors — the unit `/sys` -/// reports `start` and `size` for a disk in. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Part { - index: u32, - start: u64, - sectors: u64, - guid: toyos_gpt::Guid, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct Flashable { - path: PathBuf, - bytes: u64, - esp: Part, - log: Part, - /// Every partition's unique GUID, which no other disk of the machine may - /// carry. - guids: Vec, -} - -/// Every parameter the T14 survives — the boot ends and the machine is what it -/// was — and nothing else reaches the stick. +/// Every parameter a bench survives — the boot ends and the machine is what it +/// was — and nothing else reaches its disk. /// /// **The metal profile flashes test images**, so an actuator is admissible here /// where `build::flashable_params` refuses it for the owner's own flash path. /// What is not admissible is an arm that would leave the machine changed where -/// a flash cannot put it back: firmware state a reboot does not undo is a -/// machine somebody has to open a lid to repair. The internal NVMe is not such -/// state — the T14 is a playground, and a disk a broken driver wipes is a disk -/// the next install writes again. +/// a flash cannot put it back: firmware state a reboot does not undo, or the +/// bench's own partitions, which hold the Ubuntu every failed boot falls back +/// to. The install area is not such state: the next flash writes it again. /// /// **A deny-list fails open on the next actuator**, so this is the whole /// judgement: an image armed with a name that has no row here is refused by @@ -923,7 +751,7 @@ pub const FLASHABLE: &[&str] = &[ // level resets at warm and cold reset (55922 §1.4.4.11). "microcode-amd-flip", // It seals this boot's own record under an identity one bit from this - // stick's, so the pass that finds it clears it and boots a kernel. The page + // image's, so the pass that finds it clears it and boots a kernel. The page // is memory the loader allocated and the machine is what it was after. FOREIGN_RECORD_ARM, // **The one arm that deliberately stops this machine.** At the shutdown @@ -962,7 +790,7 @@ pub const LOCKUP_ARM: &str = "hard-lockup-probe"; pub const WEDGE_ARMS: &[&str] = &[WEDGE_ARM, LOCKUP_ARM]; /// The arm whose boot the pass after it reads as a hang on purpose: its record -/// is sealed under another stick's identity, so that pass clears it and finds +/// is sealed under another image's identity, so that pass clears it and finds /// an attempt nothing reported, and hands the machine back. pub const FOREIGN_RECORD_ARM: &str = "blackbox-foreign-identity"; @@ -975,7 +803,7 @@ pub fn stages_a_wedge(armed: &[impl AsRef]) -> bool { /// Whether this image is ended by its own boot deadline on purpose: armed to /// wedge, or carrying a deadline below the least bound a runner gives its list /// ([`toyos_tco::JOB_BOUND_MS`]). The harness gives such a deadline only to a -/// boot whose load runs until the reset, such as `stick_survives_a_reset_under_load`'s, +/// boot whose load runs until the reset, such as `the_disk_survives_a_reset_under_load`'s, /// so the boot is judged by the record that bound sealed, as a wedge is. pub fn ended_by_its_deadline(armed: &[impl AsRef]) -> bool { stages_a_wedge(armed) @@ -1046,54 +874,6 @@ pub fn judge_arms(armed: &[String]) -> Result { } } -/// [`whole_disk`]: exactly one ESP, one TOYOS-LOG and one slot table, and one -/// or two BOOTs and ROOTs, the slots the loader boots — [`Carrying::Layout`], -/// so a stick this flashes is found again. -fn admit(path: &Path) -> Result { - let (mut file, bytes) = whole_disk(path).map_err(Refusal::Disk)?; - let esp = one_partition(&mut file, toyos_gpt::Guid::EFI_SYSTEM, "ESP")?; - let log = one_partition(&mut file, toyos_gpt::Guid::MICROSOFT_BASIC, "TOYOS-LOG")?; - one_partition(&mut file, toyos_gpt::Guid::TOYOS_SLOTS, "TOYOS-SLOTS")?; - for (what, kind) in [("TOYOS-BOOT", toyos_gpt::Guid::TOYOS_BOOT), ("TOYOS-ROOT", toyos_gpt::Guid::TOYOS_ROOT)] { - let matched = toyos_gpt::locate_type(&mut toyos_build::image::FileSectors(&mut file), kind, &mut []) - .map_err(|e| Refusal::Table(format!("{e:?}")))? - .matched; - if !(1..=2).contains(&matched) { - return Err(Refusal::Slots { what, matched }); - } - } - let mut listed = [None; 16]; - toyos_gpt::list(&mut toyos_build::image::FileSectors(&mut file), &mut listed) - .map_err(|e| Refusal::Table(format!("{e:?}")))?; - let guids = listed.iter().flatten().map(|entry| entry.map_or_else(|stated| stated.unique_guid, |p| p.unique_guid())).collect(); - Ok(Flashable { path: path.to_path_buf(), bytes, esp, log, guids }) -} - -/// The one partition of type `guid`, numbered as the flashing host's -/// partition nodes are (`toyos_build::disk`'s `part_name`): the entry index -/// plus one. -fn one_partition( - file: &mut std::fs::File, - guid: toyos_gpt::Guid, - what: &'static str, -) -> Result { - let mut out = [None; 2]; - let scan = toyos_gpt::locate_type(&mut toyos_build::image::FileSectors(file), guid, &mut out) - .map_err(|e| Refusal::Table(format!("{e:?}")))?; - let part = toyos_build::image::one_partition_of(scan, out[0]).map_err(|e| match e { - toyos_build::image::OnePartitionError::Unplaced(unplaced) => { - Refusal::Table(format!("the {what} entry is no partition: {unplaced:?}")) - } - toyos_build::image::OnePartitionError::Matched(matched) => Refusal::Partitions { what, matched }, - })?; - Ok(Part { - index: part.index() + 1, - start: part.first_lba(), - sectors: part.lba_count().get(), - guid: part.unique_guid(), - }) -} - /// What `dd` said of a copy on its own last line, /// ` bytes (…) copied, s, `. #[derive(Debug, Clone, PartialEq)] @@ -1289,39 +1069,6 @@ impl Driver { Ok(()) } - /// **The write is verified, not assumed**: `dd`'s own byte count and then - /// the table the kernel re-read are both compared. `wipefs` - /// first, because the stick outlives the image and its old backup GPT would - /// otherwise leave the firmware two tables. - /// - /// `None` is a dry run, which wrote nothing to time. - fn flash(&self, image: &Flashable, sd: Sd) -> Result, Refusal> { - let began = std::time::Instant::now(); - self.as_root("wiping the old signatures", Job::Wipe(sd), None)?; - let wipe = began.elapsed().as_secs_f64(); - let began = std::time::Instant::now(); - let feed = Feed { path: &image.path, at: 0, len: image.bytes }; - let out = self.as_root("flashing the stick", Job::Flash(sd), Some(feed))?; - let Some(out) = out else { return Ok(None) }; - let dd = self.fed("flashing the stick", &out, image.bytes)?; - self.reread(sd)?; - for (name, part) in [("the ESP", image.esp), ("TOYOS-LOG", image.log)] { - let at = sd.node().part_sysfs(part.index); - let read = self.ssh("reading the new table back", &format!("cat {at}/start {at}/size"))?; - let mut numbers = read.lines().map(str::trim).map(str::parse::); - let start = numbers.next().and_then(Result::ok).unwrap_or_default(); - let sectors = numbers.next().and_then(Result::ok).unwrap_or_default(); - for (what, want, got) in - [("first sector", part.start, start), ("sector count", part.sectors, sectors)] - { - if want != got { - return Err(Refusal::Landed { what: format!("{name}'s {what}"), want, got }); - } - } - } - Ok(Some(Flashed { wipe, flash: began.elapsed().as_secs_f64(), dd: dd.secs })) - } - /// The writes that make `sd` a scratch disk, and its read-back; `false` is a /// dry run, which wrote nothing. /// @@ -1344,8 +1091,8 @@ impl Driver { /// The boot entry `create` makes under `label` for the ESP whose partition /// GUID is `esp`. An entry carrying the label but another partition's GUID - /// names a partition that is gone — every `--build-only` draws a fresh one — - /// so it is deleted rather than reused. + /// names a partition that is gone, an install area made again, so it is + /// deleted rather than reused. fn boot_entry(&self, label: &str, create: Job, esp: toyos_gpt::Guid) -> Result { let want = esp.to_string().to_ascii_lowercase(); let ours = |listing: &str| { @@ -1394,7 +1141,7 @@ 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. Answers + /// caught dying Ubuntu would read a log ToyOS had never written. Answers /// the wall seconds going down took, and the whole seconds coming back did. fn ride_the_reboot(&self, secs: u64) -> Result<(f64, u64), Refusal> { let began = std::time::Instant::now(); @@ -1403,32 +1150,6 @@ impl Driver { Ok((down, self.wait(secs, "come back", true)?)) } - /// The one disk that is `want` and carries `carrying`, by [`located`]. - fn locate(&self, carrying: Carrying, want: &Stick) -> Result { - let listing = self.ssh("listing the disks", DISKS)?; - located(&listing, carrying, want, |sd| self.ssh("reading a disk", &sd.identity())) - } - - /// Wait for the stick carrying this image's log, wherever it enumerated - /// this time, and say how long it took. - /// - /// **A number and not an implication.** Whether the stick came back is what - /// the T14 alone can say about the ruling, and before this it was only ever - /// visible as `mount: special device /dev/sda3 does not exist` — a mount - /// that fails for a dozen other reasons too. - fn wait_for_the_stick(&self, carrying: Carrying, want: &Stick) -> Result<(Sd, u64), Refusal> { - let began = std::time::Instant::now(); - loop { - match self.locate(carrying, want) { - Ok(sd) => return Ok((sd, began.elapsed().as_secs())), - Err(Refusal::Unlocated { .. }) if began.elapsed().as_secs() < STICK_SECS => {} - Err(Refusal::Unlocated { .. }) => return Err(Refusal::Stick { secs: STICK_SECS }), - Err(refusal) => return Err(refusal), - } - std::thread::sleep(POLL); - } - } - /// [`wait_on`] the machine's `ssh`. fn wait(&self, secs: u64, what: &'static str, answering: bool) -> Result { let host = self.target.host.as_str(); @@ -1447,20 +1168,25 @@ impl Driver { /// /// Two strings and not one, because only the second is the boot's log and /// [`bootlog::verdict`] is about that. - fn read_log(&self, sd: Sd, part: u32) -> Result<(String, String), Refusal> { + fn read_log(&self) -> Result<(String, String), Refusal> { + self.mounted(Owned::Log, Self::read_mounted) + } + + /// `read` over volume `o`, mounted read-only for it alone. + fn mounted(&self, o: Owned, read: impl FnOnce(&Self) -> Result) -> Result { self.ssh("making the mount point", &format!("mkdir -p {}", shell_word(&self.home.mount())))?; - self.as_root("mounting the log partition", Job::Mount { sd, part }, None)?; - let read = self.read_mounted(); + self.as_root(&format!("mounting the {o:?} volume"), Job::Mount(o), None)?; + let read = read(self); // Unconditional, because a failure in the read would otherwise leave // the partition mounted and wedge the next run — and subordinate, // because the read's failure is the one worth answering with. - let unmounted = self.as_root("unmounting the log partition", Job::Umount, None); + let unmounted = self.as_root(&format!("unmounting the {o:?} volume"), Job::Umount, None); match (read, unmounted) { - (Ok(both), Ok(_)) => Ok(both), + (Ok(read), Ok(_)) => Ok(read), (Ok(_), Err(umount)) => Err(umount), (Err(read), Ok(_)) => Err(read), (Err(read), Err(umount)) => Err(Refusal::Remote { - what: format!("reading the log ({read}), and then unmounting it"), + what: format!("reading the {o:?} volume ({read}), and then unmounting it"), status: "both failed".to_string(), stderr: umount.to_string(), }), @@ -1484,30 +1210,24 @@ impl Driver { Ok((loader, text)) } - /// The log partition read whole, as bytes. + /// The log volume the image wrote, `bytes` long, read whole as bytes. /// /// **The outside judge needs the volume and not a listing of it.** `read_log` /// mounts read-only and `cat`s, so everything it can say has already been /// filtered through a FAT driver — the same family of code that wrote the /// volume. This reads the sectors. - fn raw_log(&self, sd: Sd, log: Part) -> Result, Refusal> { - let sectors = log.sectors; - let line = self.target.remote(&Job::ReadLog { sd, part: log.index }, &self.home); - println!(" run: {line} ({sectors} sectors)"); + fn raw_log(&self, bytes: u64) -> Result, Refusal> { + let line = self.target.remote(&Job::ReadLog { bytes }, &self.home); + println!(" run: {line}"); let out = Command::new("ssh") .args(self.target.ssh_argv(&line)) .stdin(Stdio::null()) .output() .map_err(|e| unstarted("reading the log partition", &e))?; let out = answer("reading the log partition", out)?; - let want = sectors * u64::from(LBA); let got = out.stdout.len() as u64; - if got != want { - return Err(Refusal::Landed { - what: "the log partition read back off the stick".to_string(), - want, - got, - }); + if got != bytes { + return Err(Refusal::Landed { what: "the log volume read back".to_string(), want: bytes, got }); } Ok(out.stdout) } @@ -1542,7 +1262,7 @@ impl Driver { /// [`internal::recognise`] has said what the disk holds: the area's /// partitions are given the GUIDs the image's contents name before their /// bytes are written, and the boot entry is made or refreshed last. - fn install_internal(&self, image: &internal::Install) -> Result, Refusal> { + fn install_internal(&self, image: &internal::Install) -> Result, Refusal> { let node = self.target.internal; if internal::recognise(&self.internal_disk()?).map_err(Refusal::Layout)? == internal::Area::Absent { self.make_area()?; @@ -1578,6 +1298,7 @@ impl Driver { } } } + let mut dd = 0.0; for o in Owned::ALL { let Some(piece) = image.piece(o) else { continue }; let part = own(o); @@ -1590,12 +1311,13 @@ impl Driver { let what = format!("writing the image's {o:?} to {}", part.node); let feed = Feed { path: &image.path, at: piece.at, len: piece.len }; if let Some(out) = self.as_root(&what, Job::Write(o), Some(feed))? { - self.fed(&what, &out, piece.len)?; + dd += self.fed(&what, &out, piece.len)?.secs; } } - let entry = self.boot_entry(INTERNAL_LABEL, Job::CreateInternal, kept(Owned::Esp))?; + let create = Job::Create { label: INTERNAL_LABEL, loader: LOADER }; + let entry = self.boot_entry(INTERNAL_LABEL, create, kept(Owned::Esp))?; println!("installed {} on {}; Boot{entry} ({INTERNAL_LABEL}) boots it", image.path.display(), node.whole()); - Ok(Some(entry)) + Ok(Some(Installed { entry, dd })) } /// Make the install area, which the kernel's view says is absent. @@ -1645,7 +1367,7 @@ impl Driver { /// `answers`, since a listener can come up before the service behind it. /// **Going down is a dial refused or unanswered for [`CONNECT_SECS`]**, what /// `ssh`'s own connect is given: a SYN lost while Ubuntu is still up would -/// otherwise read as down, and the stick be read before ToyOS booted it. +/// otherwise read as down, and the log be read before ToyOS booted it. fn wait_on( secs: u64, what: &'static str, @@ -1700,9 +1422,9 @@ declare_flags!(METAL = { IMAGE = "--image", Next; HOST = "--host", Next; KEY = "--key", Next; - DEVICE = "--device", Next; ADOPT_SCRATCH = "--adopt-scratch", Next; - RECORD = "--record", Next; + RECORD = "--record", None; + FALL_THROUGH = "--fall-through", Next; READBACK = "--readback", Next; FAT32_CHECK = "--fat32-check", None; DRY_RUN = "--dry-run", None; @@ -1710,10 +1432,9 @@ declare_flags!(METAL = { BOOT_INTERNAL = "--boot-internal", None; }); - -/// The flags that describe a stick boot, which [`Args::parse`] refuses beside -/// an internal install or boot. -const ABOUT_THE_STICK: &[&Flag] = &[&IMAGE, &READBACK, &FAT32_CHECK, &DEVICE]; +/// The flags that describe a judged boot, which [`Args::parse`] refuses beside +/// an install or boot that writes no readback. +const ABOUT_THE_JUDGED_BOOT: &[&Flag] = &[&IMAGE, &READBACK, &FAT32_CHECK]; /// What the binary was asked to do. #[derive(Debug, Clone, PartialEq, Eq)] @@ -1727,19 +1448,20 @@ pub struct Args { image: Option, target: Target, dry_run: bool, - /// The boot stick [`record`] records the machine with, or checks against - /// its record. **An action of its own, and it writes no disk.** - record: Option, - /// Where the stick's two files and this boot's own facts are written, for a + /// Record the machine ([`record`]). **An action of its own, and it writes + /// no disk.** + record: bool, + /// Where the log's two files and this boot's own facts are written, for a /// judge that is not this process. Absent leaves the run's only account its /// standard output, which no per-test predicate can be held to. readback: Option, - /// Read the log partition whole off the stick and hand it to + /// Read the log volume whole off the disk and hand it to /// `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 image an internal install writes to the bench's own NVMe. An action - /// of its own: it boots nothing unless [`Args::boot_internal`] asks. + /// The image an install writes to the bench's own NVMe with no readback, + /// such as a shipped image for a demo. An action of its own: it boots + /// nothing unless [`Args::boot_internal`] asks. install_internal: Option, /// Boot the internal install once, through `BootNext`, and leave the /// firmware's order — Ubuntu first — as it is. @@ -1747,6 +1469,8 @@ pub struct Args { /// The disk [`adopt_scratch`] erases into a scratch disk. An action of its /// own, beside nothing but the flags that say which machine and `--dry-run`. adopt_scratch: Option, + /// The unbootable entry [`fall_through`] boots once. An action of its own. + fall_through: Option, } impl Args { @@ -1766,12 +1490,13 @@ impl Args { image: value(&IMAGE).map(PathBuf::from), target: Target::bench_t14()?, dry_run: METAL.present(args, &DRY_RUN), - record: value(&RECORD).map(Sd::parse).transpose()?, + record: METAL.present(args, &RECORD), readback: value(&READBACK).map(PathBuf::from), fat32_check: METAL.present(args, &FAT32_CHECK), install_internal: value(&INSTALL_INTERNAL).map(PathBuf::from), boot_internal: METAL.present(args, &BOOT_INTERNAL), adopt_scratch: value(&ADOPT_SCRATCH).map(Sd::parse).transpose()?, + fall_through: value(&FALL_THROUGH).map(Unbootable::parse).transpose()?, }; if let Some(host) = value(&HOST) { let (user, machine) = host_of(host)?; @@ -1781,15 +1506,15 @@ impl Args { if let Some(key) = value(&KEY) { out.target.key = PathBuf::from(key); } - if let Some(node) = value(&DEVICE) { - out.target.device = Some(Sd::parse(node)?); - } // **An action is not a mode**: it does its one thing and exits, so a // flag beside it that describes anything else is refused rather than // left for the action to fall through into — which is how a capture // once flashed whatever `--image` defaulted to. - let actions: [(&Flag, &[&Flag]); 2] = - [(&RECORD, &[&HOST, &KEY]), (&ADOPT_SCRATCH, &[&HOST, &KEY, &DRY_RUN])]; + let actions: [(&Flag, &[&Flag]); 3] = [ + (&RECORD, &[&HOST, &KEY]), + (&ADOPT_SCRATCH, &[&HOST, &KEY, &DRY_RUN]), + (&FALL_THROUGH, &[&HOST, &KEY, &DRY_RUN]), + ]; for (action, alone) in actions { if !METAL.present(args, action) { continue; @@ -1808,17 +1533,17 @@ impl Args { ))); } } - let stick: Vec<&str> = line + let judged: Vec<&str> = line .seen .iter() .map(|seen| seen.flag.name) - .filter(|name| ABOUT_THE_STICK.iter().any(|flag| flag.name == *name)) + .filter(|name| ABOUT_THE_JUDGED_BOOT.iter().any(|flag| flag.name == *name)) .collect(); - if (out.install_internal.is_some() || out.boot_internal) && !stick.is_empty() { + if (out.install_internal.is_some() || out.boot_internal) && !judged.is_empty() { return Err(Refusal::Usage(format!( - "--install-internal and --boot-internal write and boot the bench's own disk and \ - nothing on the stick, so {} describes a boot they will not make", - stick.join(" and ") + "--install-internal and --boot-internal write and boot an image nobody judges, \ + so {} describes a boot they will not make", + judged.join(" and ") ))); } Ok(out) @@ -1837,16 +1562,16 @@ pub fn host_of(host: &str) -> Result<(&str, &str), Refusal> { Ok((user, machine)) } -/// Record a machine with `sd`, named by hand, as its boot stick where it has -/// no record, and check `sd` against the record where it has one. It says -/// which disk that is, and asks nothing: a bench holds nothing a wrong capture -/// could lose, and the loop reaches no other machine. -fn record(target: &Target, sd: Sd) -> Result<(), Refusal> { +/// Record the machine `target` reaches where it has no record, by what its +/// SMBIOS answers. It asks nothing: a bench holds nothing a wrong capture could +/// lose, and the loop reaches no other machine. +fn record(target: &Target) -> Result<(), Refusal> { let driver = Driver::reach(target.clone(), false)?; let machine = Machine::parse(&driver.ssh("reading the machine's SMBIOS", Machine::QUERY)?) .map_err(Refusal::Machine)?; - let disk = Identity::parse(&driver.ssh("reading the disk", &sd.identity())?)?; - println!("{}", record_machine(&timings::dir(), &machine, &disk)?.said(sd)); + let (at, wrote) = record_machine(&timings::dir(), &machine)?; + let how = if wrote { "records this machine now" } else { "already recorded this machine" }; + println!("{} {how}: {} {}, BIOS {}", at.display(), machine.vendor, machine.product, machine.bios); Ok(()) } @@ -1860,11 +1585,11 @@ fn parts_of(listing: &str, sd: Sd) -> Result, Refusal> { } /// Whether `sd` of `listing` may be erased to make it a scratch disk: listed, -/// and holding no ToyOS image, which is what tells the boot stick apart. +/// and holding no ToyOS image, which a disk somebody flashed may still be. fn adoptable(listing: &str, sd: Sd, image: Carrying) -> Result<(), Refusal> { if image.holds(&parts_of(listing, sd)?) { return Err(Refusal::Adopt(format!( - "{} carries {image}, as the boot stick does, and a scratch disk is never one", + "{} carries {image}, and a scratch disk is never one", sd.node().whole() ))); } @@ -1946,7 +1671,7 @@ fn internal_action(args: &Args) -> Result<(), Refusal> { println!("machine {} {}, BIOS {}", machine.vendor, machine.product, machine.bios); let entry = match &image { Some(image) => match driver.install_internal(image)? { - Some(entry) => entry, + Some(installed) => installed.entry, None => return Ok(()), }, None => driver.internal_entry()?, @@ -1961,10 +1686,10 @@ fn internal_action(args: &Args) -> Result<(), Refusal> { /// The whole loop, answering this boot's own millisecond count. `None` is a /// dry run, which reaches no boot record because it wrote nothing, and every -/// action that is not a stick boot. +/// action that is not a judged boot. pub fn run(args: &Args) -> Result, Refusal> { - if let Some(sd) = args.record { - record(&args.target, sd)?; + if args.record { + record(&args.target)?; return Ok(None); } if args.install_internal.is_some() || args.boot_internal { @@ -1975,6 +1700,10 @@ pub fn run(args: &Args) -> Result, Refusal> { adopt_scratch(&args.target, args.dry_run, sd)?; return Ok(None); } + if let Some(how) = args.fall_through { + fall_through(&args.target, args.dry_run, how)?; + return Ok(None); + } let began = std::time::Instant::now(); let Some(asked) = &args.image else { @@ -1984,7 +1713,8 @@ pub fn run(args: &Args) -> Result, Refusal> { boots nothing could fall through to", ))); }; - let image = admit(asked)?; + let image = internal::admit(asked).map_err(Refusal::Install)?; + let log = image.piece(Owned::Log).expect("an admitted image carries a log"); // **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 @@ -1992,17 +1722,11 @@ pub fn run(args: &Args) -> Result, Refusal> { if let Some(dir) = &args.readback { clear_readback(dir)?; } - println!( - "image {}: {} bytes, ESP p{} at {}+{}, log p{} at {}+{}", - image.path.display(), - image.bytes, - image.esp.index, - image.esp.start, - image.esp.sectors, - image.log.index, - image.log.start, - image.log.sectors - ); + let pieces: Vec = Owned::ALL + .iter() + .filter_map(|o| image.piece(*o).map(|p| format!("{o:?} {} bytes", p.len))) + .collect(); + println!("image {}: {}", image.path.display(), pieces.join(", ")); // The pre-flash gate, before the disk is even asked what it is: what this // image will arm, judged against the only table that has ruled on any of it. let (armed, deadline_ms) = arms_are_admissible(asked)?; @@ -2019,65 +1743,37 @@ pub fn run(args: &Args) -> Result, Refusal> { let policy = driver.ssh("reading the lid policy", "systemd-analyze cat-config systemd/logind.conf")?; lid_policy(&policy)?; - let machine = Machine::parse(&driver.ssh("reading the machine's SMBIOS", Machine::QUERY)?) - .map_err(Refusal::Machine)?; + let machine = driver.recorded_machine()?; println!("machine {} {}, BIOS {}", machine.vendor, machine.product, machine.bios); - let want = recorded(&timings::dir(), &machine)?.stick; - let sd = match args.target.device { - Some(sd) => { - Identity::parse(&driver.ssh("reading the disk", &sd.identity())?)?.check(&want)?; - sd - } - None => driver.locate(Carrying::Layout, &want)?, - }; - let holders = driver.ssh("listing every partition's GUID", internal::HOLDERS)?; - let node = sd.node(); - internal::collision(&holders, &image.guids, |name| (1..=128).any(|i| node.partition(i) == name)) - .map_err(Refusal::Collision)?; - println!( - "disk {}: {} sectors, {} {}, removable — the stick {} names{}", - node.whole(), - want.sectors, - want.vendor, - want.model, - timings::dir().join(machine.file()).display(), - if args.target.device.is_some() { ", adopted by --device" } else { ", carrying a ToyOS image" }, - ); let preamble = began.elapsed().as_secs_f64(); - let flashed = driver.flash(&image, sd)?; let began = std::time::Instant::now(); - let entry = driver.boot_entry(&args.target.label, Job::Create { sd, esp: image.esp.index }, image.esp.guid)?; - - driver.as_root("setting bootnext", Job::BootNext(entry), None)?; + let Some(installed) = driver.install_internal(&image)? else { return Ok(None) }; + let flash = began.elapsed().as_secs_f64(); + let began = std::time::Instant::now(); + driver.as_root("setting bootnext", Job::BootNext(installed.entry), None)?; driver.as_root("rebooting", Job::Reboot, None)?; let entry = began.elapsed().as_secs_f64(); if driver.dry_run { - driver.as_root("mounting the log partition", Job::Mount { sd, part: image.log.index }, None)?; + driver.as_root("mounting the log partition", Job::Mount(Owned::Log), None)?; driver.as_root("unmounting the log partition", Job::Umount, None)?; println!("dry run: nothing was written and the machine was not rebooted"); return Ok(None); } - let flashed = flashed.expect("only a dry run flashes nothing, and it has returned"); let (down, back) = driver.ride_the_reboot(wait_secs)?; println!("the machine answered ssh again after {back} s"); - // Before the mount, so the stick's own answer is a number rather than - // the reason a mount failed. - let carrying = Carrying::Log { index: image.log.index, guid: image.log.guid }; - let (sd, stick) = driver.wait_for_the_stick(carrying, &want)?; - println!("the boot stick enumerated as {} {stick} s after the machine answered", sd.node().whole()); let began = std::time::Instant::now(); - let (loader, log) = driver.read_log(sd, image.log.index)?; + let (loader, log_text) = driver.read_log()?; let read_log = began.elapsed().as_secs_f64(); - print!("{loader}{log}"); + print!("{loader}{log_text}"); // The outside judge, and it runs before the verdict: a volume this // cannot read is a finding about what the boot wrote, and the reason to // read the sectors rather than the mount is that a mount has already // had a FAT driver's opinion about them. let began = std::time::Instant::now(); if args.fat32_check { - let bytes = driver.raw_log(sd, image.log)?; + let bytes = driver.raw_log(log.len)?; if let Some(dir) = &args.readback { let at = dir.join(READBACK_VOLUME); std::fs::write(&at, &bytes).map_err(|e| Refusal::File { @@ -2090,19 +1786,103 @@ pub fn run(args: &Args) -> Result, Refusal> { println!("toyos-fat32-check: the log partition's {} bytes check out", bytes.len()); } let raw_log = args.fat32_check.then(|| began.elapsed().as_secs_f64()); - let phases = Phases { - preamble, - wipe: flashed.wipe, - flash: flashed.flash, - dd_secs: flashed.dd, - entry, - down, - read_log, - raw_log, - }; + let phases = Phases { preamble, flash, dd_secs: installed.dd, entry, down, read_log, raw_log }; println!("phases: {phases}"); - let boot = BootFile { back, stick, machine, phases }.text(); - judge_and_write_readback(&armed, &loader, &log, args.readback.as_deref(), &boot).map(Some) + let boot = BootFile { back, machine, phases }.text(); + judge_and_write_readback(&armed, &loader, &log_text, args.readback.as_deref(), &boot).map(Some) +} + +/// An entry on the internal ESP that no firmware can boot, as +/// [`fall_through`] names it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Unbootable { + /// A loader path no file is at. + Missing, + /// A file that is no PE image: the image's own `\toyos\log.guid`. + Garbage, +} + +impl Unbootable { + fn parse(word: &str) -> Result { + match word { + "missing" => Ok(Self::Missing), + "garbage" => Ok(Self::Garbage), + _ => Err(Refusal::Usage(format!("--fall-through takes missing or garbage, not {word:?}"))), + } + } + + /// The entry's loader path, as firmware reads it. + fn loader(self) -> &'static str { + match self { + Self::Missing => r"\EFI\TOYOS\ABSENT.EFI", + Self::Garbage => r"\toyos\log.guid", + } + } + + /// The same file under the ESP's mount, and the size it has to have: none + /// for the missing one. + fn file(self) -> (&'static str, Option) { + match self { + Self::Missing => ("EFI/TOYOS/ABSENT.EFI", None), + Self::Garbage => ("toyos/log.guid", Some(16)), + } + } +} + +/// **Boot an entry the firmware cannot load, once, and see Ubuntu come back.** +/// UEFI 2.10 §3.1.2 has the boot manager try `BootNext` and then the +/// `BootOrder` list; a firmware that prompts instead is a bench a failed +/// install leaves waiting for a hand. The entry names a loader on the internal +/// ESP that `how` says no firmware can boot, the ESP is first read to hold +/// exactly that, and the entry is deleted once the machine is back. +fn fall_through(target: &Target, dry_run: bool, how: Unbootable) -> Result<(), Refusal> { + let driver = Driver::reach(target.clone(), dry_run)?; + driver.require_sudo()?; + lid_policy(&driver.ssh("reading the lid policy", "systemd-analyze cat-config systemd/logind.conf")?)?; + let machine = driver.recorded_machine()?; + println!("machine {} {}, BIOS {}", machine.vendor, machine.product, machine.bios); + let esp = driver.install_area()?[0].unique.expect("recognise refuses a partition with no GUID"); + let (file, size) = how.file(); + let at = shell_word(&format!("{}/{file}", driver.home.mount())); + let said = driver.mounted(Owned::Esp, |d| d.ssh("reading the loader's file", &format!("stat -c %s {at} 2>/dev/null || echo none")))?; + let found = said.trim().parse::().ok(); + if !dry_run && found != size { + return Err(Refusal::Layout(format!( + "the internal ESP's {file} is {} where this entry needs it {}", + found.map_or_else(|| "absent".to_string(), |n| format!("{n} bytes")), + size.map_or_else(|| "absent".to_string(), |n| format!("{n} bytes")) + ))); + } + // Every one, since a run that refused after its create left one whose + // loader may be the other variant's. + for id in ids(&driver.ssh("listing boot entries", EFIBOOTMGR)?, UNBOOTABLE_LABEL) { + driver.as_root("deleting an earlier unbootable entry", Job::Delete(id), None)?; + } + let create = Job::Create { label: UNBOOTABLE_LABEL, loader: how.loader() }; + let entry = driver.boot_entry(UNBOOTABLE_LABEL, create, esp)?; + driver.as_root("setting bootnext", Job::BootNext(entry.clone()), None)?; + driver.as_root("rebooting", Job::Reboot, None)?; + if dry_run { + println!("dry run: nothing was created and the machine was not rebooted"); + return Ok(()); + } + let (_, back) = driver.ride_the_reboot(RETURN_ALLOWANCE_SECS)?; + let listing = driver.ssh("listing boot entries", EFIBOOTMGR)?; + let deleted = driver.as_root("deleting the unbootable entry", Job::Delete(entry.clone()), None); + let current = listing.lines().find_map(|l| l.strip_prefix("BootCurrent:")).map(str::trim); + if current == Some(entry.as_str()) { + return Err(Refusal::FellThrough(format!("BootCurrent is Boot{entry}, the unbootable entry"))); + } + if let Some(next) = listing.lines().find_map(|l| l.strip_prefix("BootNext:")) { + return Err(Refusal::FellThrough(format!("BootNext still reads {}", next.trim()))); + } + deleted?; + println!( + "the firmware fell through past Boot{entry} ({}) to Boot{} and answered ssh after {back} s", + how.loader(), + current.unwrap_or("????") + ); + Ok(()) } /// The outside judge's verdict on the log partition. A boot that hands the @@ -2127,12 +1907,11 @@ fn fat32_verdict(armed: &[String], complaints: &[toyos_fat32_check::Complaint]) } } -/// What [`Driver::flash`] took, on a run that was no rehearsal. -struct Flashed { - wipe: f64, - /// The write and the table read back after it. - flash: f64, - /// `dd`'s own seconds for the write. +/// What [`Driver::install_internal`] made. +struct Installed { + /// The boot entry that boots it. + entry: String, + /// `dd`'s own seconds for every piece it wrote. dd: f64, } @@ -2154,7 +1933,7 @@ fn judge_and_write_readback( verdict } -/// The stick's own verdict on one boot, off what its image is armed with, the +/// The log's own verdict on one boot, off what its image is armed with, the /// loader's file and the log. fn boot_verdict(armed: &[String], loader: &str, log: &str) -> Result { let staged_hang = clears_its_own_page(armed); @@ -2338,7 +2117,6 @@ pub const READBACK_VOLUME: &str = "log-partition.img"; /// The keys [`READBACK_BOOT`] carries, one ` ` per line. pub const BACK_SECS: &str = "back_secs"; -pub const STICK_SECS_KEY: &str = "stick_secs"; /// The machine the boot ran on, as [`Machine::QUERY`] answered before the flash. pub const VENDOR_KEY: &str = "machine_vendor"; @@ -2348,7 +2126,6 @@ pub const BIOS_KEY: &str = "machine_bios"; /// [`Phases`]' keys, each in wall seconds; [`RAW_LOG_KEY`] reads [`NOT_READ`] /// on a run that read no volume. const PREAMBLE_KEY: &str = "preamble_secs"; -const WIPE_KEY: &str = "wipe_secs"; const FLASH_KEY: &str = "flash_secs"; const DD_SECS_KEY: &str = "dd_secs"; const ENTRY_KEY: &str = "entry_secs"; @@ -2358,22 +2135,20 @@ const RAW_LOG_KEY: &str = "raw_log_secs"; const NOT_READ: &str = "-"; /// Where one run of the loop spent its wall time, by the host's clock, phase -/// by phase; coming back and the stick are [`BACK_SECS`] and -/// [`STICK_SECS_KEY`]. Any save of the log partition before the flash is no -/// phase of this loop's. +/// by phase; coming back is [`BACK_SECS`]. Any save of the log partition +/// before the flash is no phase of this loop's. #[derive(Debug, Clone, PartialEq)] pub struct Phases { - /// Admitting the image, then the account's home, sudo, the lid policy, - /// the machine and the disk read off the machine. + /// Admitting the image, then the account's home, sudo, the lid policy and + /// the machine read off the machine. pub preamble: f64, - pub wipe: f64, - /// `dd` over `ssh`, settling udev, the kernel's re-read of the table, and - /// the table read back. + /// The install: the disk read and checked, the GUIDs given and read back, + /// each piece's `dd` over `ssh`, and the boot entry. pub flash: f64, - /// What `dd` itself said of the write. It reads `ssh`'s stdin, so this is - /// whichever of the wire and the stick is slower, and splits neither out. + /// What `dd` itself said of the writes. It reads `ssh`'s stdin, so this is + /// whichever of the wire and the disk is slower, and splits neither out. pub dd_secs: f64, - /// The boot entry, `--bootnext` and `reboot`. + /// `--bootnext` and `reboot`. pub entry: f64, pub down: f64, pub read_log: f64, @@ -2386,10 +2161,9 @@ impl fmt::Display for Phases { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "preamble {:.1} s, wipe {:.1} s, flash {:.1} s (dd {:.1} s), entry {:.1} s, down {:.1} \ - s, read_log {:.1} s, raw_log ", + "preamble {:.1} s, flash {:.1} s (dd {:.1} s), entry {:.1} s, down {:.1} s, read_log \ + {:.1} s, raw_log ", self.preamble, - self.wipe, self.flash, self.dd_secs, self.entry, @@ -2407,23 +2181,20 @@ impl fmt::Display for Phases { /// machine it ran on. pub struct BootFile { pub back: u64, - pub stick: u64, pub machine: Machine, pub phases: Phases, } impl BootFile { pub fn text(&self) -> String { - let Self { back, stick, machine, phases } = self; + let Self { back, machine, phases } = self; let secs = |s: f64| format!("{s:.3}"); let rows = [ (BACK_SECS, back.to_string()), - (STICK_SECS_KEY, stick.to_string()), (VENDOR_KEY, machine.vendor.clone()), (PRODUCT_KEY, machine.product.clone()), (BIOS_KEY, machine.bios.clone()), (PREAMBLE_KEY, secs(phases.preamble)), - (WIPE_KEY, secs(phases.wipe)), (FLASH_KEY, secs(phases.flash)), (DD_SECS_KEY, secs(phases.dd_secs)), (ENTRY_KEY, secs(phases.entry)), @@ -2444,7 +2215,6 @@ pub fn phases(text: &str) -> Result { }; Ok(Phases { preamble: secs(PREAMBLE_KEY)?, - wipe: secs(WIPE_KEY)?, flash: secs(FLASH_KEY)?, dd_secs: secs(DD_SECS_KEY)?, entry: secs(ENTRY_KEY)?, @@ -2470,9 +2240,9 @@ pub const READBACK_FILES: &[&str] = &[ /// Empty a readback directory, before this run can leave any of it standing. /// /// **A file that is still there after this ran is one this boot wrote.** -/// [`write_readback`] is reached only after the machine came back and the stick +/// [`write_readback`] is reached only after the machine came back and the log /// was read, so every refusal before it — a machine that never returned, a -/// stick that never enumerated, a volume the outside judge complained about — +/// volume that would not mount, a volume the outside judge complained about — /// used to leave the *previous* run's `kernel.log` in place for a judge that /// cannot tell one boot's file from another's. pub fn clear_readback(dir: &Path) -> Result<(), Refusal> { @@ -2548,13 +2318,6 @@ pub fn back_secs(text: &str) -> Option { key(text, BACK_SECS) } -/// How long after the machine answered the boot stick was there again — the -/// one thing only this machine can say about the ruling that no reset this -/// kernel performs may leave a USB device its next host cannot enumerate. -pub fn stick_secs(text: &str) -> Option { - key(text, STICK_SECS_KEY) -} - /// The machine a readback names, or why it names none. pub fn machine(text: &str) -> Result { match (word(text, VENDOR_KEY), word(text, PRODUCT_KEY), word(text, BIOS_KEY)) { @@ -2638,7 +2401,7 @@ mod tests { assert_eq!(said_refusal(&stderr[..stderr.find(REFUSAL_HEAD).unwrap()]), None, "cargo's lines are no refusal"); } - /// **An image with no bound on its own boot never reaches the stick.** + /// **An image with no bound on its own boot never reaches the bench.** /// /// The case, measured twice: a kernel that hung after its job list, and a /// capture that fell through into flashing an ordinary @@ -2713,24 +2476,24 @@ mod tests { format!("{}{}", toyos_tco::DEADLINE_PARAM, toyos_tco::wedge_bound_ms(toyos_tco::JOB_BOUND_MS)) } - /// **An internal install or boot is an action of its own**: it takes the + /// **An unjudged install or boot is an action of its own**: it takes the /// flags that say which machine and a rehearsal, and refuses every flag - /// that describes a stick boot or a capture beside it. + /// that describes a judged boot or a capture beside it. #[test] - fn an_internal_install_is_not_also_a_stick_boot() { + fn an_unjudged_install_is_not_also_a_judged_boot() { let words = |w: &[&str]| w.iter().map(|w| (*w).to_string()).collect::>(); let both = Args::parse(&words(&["--install-internal", "demo.img", "--boot-internal", "--dry-run", "--host", "toyos@bench-amd"])).unwrap(); assert_eq!(both.install_internal.as_deref(), Some(Path::new("demo.img"))); assert!(both.boot_internal && both.dry_run); assert!(Args::parse(&words(&["--boot-internal"])).unwrap().install_internal.is_none()); - for stick in [&["--image", "x.img"][..], &["--readback", "r"], &["--fat32-check"]] { + for judged in [&["--image", "x.img"][..], &["--readback", "r"], &["--fat32-check"]] { for internal in [&["--install-internal", "demo.img"][..], &["--boot-internal"]] { - let line = [internal, stick].concat(); + let line = [internal, judged].concat(); let said = Args::parse(&words(&line)).unwrap_err().to_string(); - assert!(said.contains(stick[0]) && said.contains("describes a boot they will not make"), "{line:?}: {said}"); + assert!(said.contains(judged[0]) && said.contains("describes a boot they will not make"), "{line:?}: {said}"); } } - let said = Args::parse(&words(&["--record", "/dev/sda", "--boot-internal"])).unwrap_err().to_string(); + let said = Args::parse(&words(&["--record", "--boot-internal"])).unwrap_err().to_string(); assert!(said.contains("--boot-internal"), "{said}"); } @@ -2752,7 +2515,7 @@ mod tests { /// The gate fails closed: a name nobody has ruled on is refused, and it is /// refused *by that name* rather than by a count. #[test] - fn an_arm_with_no_ruling_never_reaches_the_stick() { + fn an_arm_with_no_ruling_never_reaches_the_bench() { assert!(flashable("watchdog")); assert!(flashable("blackbox=0x8000000")); assert!(!flashable("nvme-write-selftest")); @@ -2773,7 +2536,7 @@ mod tests { fn every_arm_that_stops_this_machine_is_cleared_and_judged_as_one() { let bound = alloc_deadline(); for arm in WEDGE_ARMS { - assert!(flashable(arm), "{arm} reaches no stick"); + assert!(flashable(arm), "{arm} reaches no bench"); assert_eq!(judge_arms(&[arm.to_string(), bound.clone()]), Ok(toyos_tco::wedge_bound_ms(toyos_tco::JOB_BOUND_MS)), "{arm}"); assert!(stages_a_wedge(&[arm.to_string()]), "{arm}"); assert_eq!(bound_for(&[arm], 135_680), toyos_tco::STAGED_BOUND_MS, "{arm}"); @@ -2860,7 +2623,6 @@ mod tests { fn phased(raw_log: Option) -> Phases { Phases { preamble: 3.104, - wipe: 0.412, flash: 121.5, dd_secs: 117.234, entry: 2.25, @@ -2872,7 +2634,7 @@ mod tests { #[test] fn the_machine_crosses_in_the_boot_file() { - let boot = BootFile { back: 47, stick: 0, machine: t14(), phases: phased(None) }.text(); + let boot = BootFile { back: 47, machine: t14(), phases: phased(None) }.text(); assert_eq!(machine(&boot), Ok(t14())); assert_eq!(back_secs(&boot), Some(47)); assert!(machine("back_secs 47\nmachine_vendor LENOVO\n").is_err()); @@ -2883,20 +2645,19 @@ mod tests { #[test] fn the_phases_cross_in_the_boot_file() { for raw_log in [Some(9.875), None] { - let boot = - BootFile { back: 141, stick: 2, machine: t14(), phases: phased(raw_log) }.text(); + let boot = BootFile { back: 141, machine: t14(), phases: phased(raw_log) }.text(); assert_eq!(phases(&boot), Ok(phased(raw_log)), "{boot}"); - assert_eq!((back_secs(&boot), stick_secs(&boot)), (Some(141), Some(2))); + assert_eq!(back_secs(&boot), Some(141)); for line in boot.lines().filter(|line| line.contains("_secs ")) { - if line.starts_with(BACK_SECS) || line.starts_with(STICK_SECS_KEY) { + if line.starts_with(BACK_SECS) { continue; } let short = boot.replace(&format!("{line}\n"), ""); assert!(phases(&short).is_err(), "{line:?} gone still names phases"); } } - assert!(phases("back_secs 47\nstick_secs 0\n").is_err()); - let garbled = BootFile { back: 1, stick: 0, machine: t14(), phases: phased(None) } + assert!(phases("back_secs 47\n").is_err()); + let garbled = BootFile { back: 1, machine: t14(), phases: phased(None) } .text() .replace("flash_secs 121.500", "flash_secs later"); assert!(phases(&garbled).is_err()); @@ -2922,7 +2683,7 @@ mod tests { fn the_readback_carries_the_verdict_the_loop_returns() { let dir = toyos_tmpdir::TempDir::new("verdict"); let armed = [alloc_deadline()]; - let boot = "back_secs 40\nstick_secs 0\n"; + let boot = "back_secs 40\n"; let written = || { let at = dir.join(READBACK_VERDICT); loop_verdict(&std::fs::read_to_string(&at).expect("the verdict is written")) @@ -2959,118 +2720,28 @@ mod tests { assert_eq!(written(), Ok(())); } - /// **The stick a machine's loop writes is the one its record names.** - #[test] - fn the_identity_is_the_recorded_stick_or_it_is_refused() { - let dir = toyos_tmpdir::TempDir::new("metal-record"); - let root: &Path = &dir; - let t14 = t14(); - let recorded = Stick { sectors: 60_062_500, vendor: "SanDisk".to_string(), model: "Ultra".to_string() }; - Record::new(&t14, recorded.clone()).save(root).expect("a record"); - let stick = Identity::parse("60062500\nSanDisk \nUltra \n1\n").unwrap(); - assert_eq!(stick.sectors, 60_062_500); - assert_eq!(recorded_stick(root, &t14, &stick), Ok(recorded)); - - let bigger = Identity::parse("500118192\nSanDisk \nUltra \n1\n").unwrap(); - assert_eq!( - recorded_stick(root, &t14, &bigger), - Err(Refusal::Identity { - field: "size", - want: "60062500".to_string(), - got: "500118192".to_string(), - }) - ); - for (answer, field) in [ - ("60062500\nKingston\nUltra \n1\n", "vendor"), - ("60062500\nSanDisk \nCruzer \n1\n", "model"), - ("60062500\nSanDisk \nUltra \n0\n", "removable"), - ] { - let disk = Identity::parse(answer).unwrap(); - let refused = recorded_stick(root, &t14, &disk); - assert!(matches!(refused, Err(Refusal::Identity { field: f, .. }) if f == field), "{field}"); - } - assert!(Identity::parse("60062500\nSanDisk \n").is_err()); - assert!(Identity::parse("many\nSanDisk \nUltra\n1\n").is_err()); - } - - /// **A second bench is checked against its own record, and a machine with - /// none is refused with how to make one.** Recording writes the disk that - /// answered, refuses one that is not removable, and never moves a record - /// that is there: a second capture with another disk is refused by it. + /// **A machine with no record is refused with how to make one, and a + /// record is written once**: a second capture finds it and moves nothing. #[test] - fn a_machine_is_recorded_once_and_then_checked_against_its_record() { + fn a_machine_is_recorded_once_and_then_found_recorded() { let root = toyos_tmpdir::TempDir::new("metal-record"); let bench = Machine::parse("ACME\nBench 2\nX1.0\n").expect("three lines"); - let stick = Identity::parse("120176640\nKingston\nDataTraveler \n1\n").unwrap(); - - let refused = recorded_stick(&root, &bench, &stick); let at = root.join("acme-bench-2.toml"); let path = at.display().to_string(); + let refused = recorded(&root, &bench); assert_eq!(refused, Err(Refusal::Unrecorded { path: path.clone() })); let said = refused.unwrap_err().to_string(); - assert!(said.contains("--record /dev/sd") && said.contains(&path), "{said}"); - - let fixed = Identity::parse("120176640\nKingston\nDataTraveler \n0\n").unwrap(); - assert!(matches!( - record_machine(&root, &bench, &fixed), - Err(Refusal::Identity { field: "removable", .. }) - )); - assert_eq!(Record::load(&root, &bench), Ok(None), "a refused capture wrote a record"); - - let want = Stick { sectors: 120_176_640, vendor: "Kingston".to_string(), model: "DataTraveler".to_string() }; - assert_eq!( - record_machine(&root, &bench, &stick), - Ok(Captured { at: at.clone(), stick: want.clone(), wrote: true }) - ); - assert_eq!(recorded_stick(&root, &bench, &stick), Ok(want.clone())); - let record = Record::load(&root, &bench).expect("a readable record").expect("a record"); - assert_eq!(record.stick, want); - assert!(record.measured.is_empty()); - assert_eq!(record_machine(&root, &bench, &stick), Ok(Captured { at, stick: want, wrote: false })); - - // The T14's stick is not this bench's. - let t14_stick = Identity::parse("60062500\nSanDisk \nUltra \n1\n").unwrap(); - assert!(matches!( - recorded_stick(&root, &bench, &t14_stick), - Err(Refusal::Identity { field: "size", .. }) - )); - assert!(matches!( - record_machine(&root, &bench, &t14_stick), - Err(Refusal::Identity { field: "size", .. }) - )); - assert_eq!(Record::load(&root, &bench), Ok(Some(record)), "a refused capture moved the record"); - // Neither refusal is about a boot: the loop exits 2. + assert!(said.contains("--record --host") && said.contains(&path), "{said}"); assert!(!Refusal::Unrecorded { path }.about_the_boot()); - } - - /// **The capture says which disk every later run writes**: the record's - /// path, the node, and the identity it recorded or found recorded. - #[test] - fn a_capture_says_the_disk_it_recorded_and_every_run_will_write() { - let root = toyos_tmpdir::TempDir::new("metal-record"); - let bench = Machine::parse("ACME\nBench 2\nX1.0\n").expect("three lines"); - let disk = Identity::parse("120176640\nKingston\nDataTraveler \n1\n").unwrap(); - let sd = Sd::parse("/dev/sdb").unwrap(); - let at = root.join("acme-bench-2.toml").display().to_string(); - let first = record_machine(&root, &bench, &disk).expect("recorded").said(sd); - let again = record_machine(&root, &bench, &disk).expect("checked").said(sd); - for (said, how) in [(first, "records this machine now"), (again, "already recorded this machine")] { - assert_eq!( - said, - format!( - "{at} {how}: the stick is /dev/sdb, 120176640 sectors, Kingston DataTraveler, \ - removable; every run on this machine writes the one disk of that identity \ - carrying a ToyOS image, or the one --device names" - ) - ); - } - } - /// The T14's SanDisk Ultra as `/sys/class/block` answers [`Sd::identity`]. - const ULTRA: &str = "60062500\nSanDisk \nUltra \n1\n"; - - fn ultra() -> Stick { - Stick { sectors: 60_062_500, vendor: "SanDisk".to_string(), model: "Ultra".to_string() } + assert_eq!(record_machine(&root, &bench), Ok((at.clone(), true))); + let record = recorded(&root, &bench).expect("a record"); + assert!(record.measured.is_empty()); + let mut measured = record; + measured.measured.insert("boot.a.complete_ms".to_string(), 1166); + measured.save(&root).expect("a record with a number"); + assert_eq!(record_machine(&root, &bench), Ok((at, false))); + assert_eq!(recorded(&root, &bench), Ok(measured), "a second capture moved the record"); } /// `node` and its partitions as [`DISKS`] lists them, each `(type, GUID)`. @@ -3086,7 +2757,8 @@ mod tests { out } - /// A flashed ToyOS image's table, its log carrying `log`. + /// A ToyOS image's table, as a flashed stick carries one, its log carrying + /// `log`. fn toyos(node: &str, log: &str) -> String { listed( node, @@ -3109,124 +2781,17 @@ mod tests { const LAYOUT: Carrying = Carrying::Layout; - /// [`located`] over `listing`, every disk answering `answers`' identity - /// and each one asked about recorded. - fn locate(listing: &str, carrying: Carrying, answers: &[(&str, &str)]) -> (Result, Vec) { - let mut asked = Vec::new(); - let found = located(listing, carrying, &ultra(), |sd| { - asked.push(sd.node().whole()); - Ok(answers.iter().find(|(node, _)| *node == sd.node().whole()).expect("a disk of the listing").1.to_string()) - }); - (found, asked) - } - - /// **A second stick of the boot stick's model is told apart by what it - /// holds, whichever node it enumerated as.** The bench's own NVMe carries - /// ToyOS types in its install area and is no `/dev/sd`, and an - /// empty card reader carries nothing; neither is asked its identity. - #[test] - fn the_stick_is_the_one_disk_of_its_identity_carrying_a_toyos_image() { - let nvme = listed( - "/dev/nvme0n1", - &[ - ("c12a7328-f81f-11d2-ba4b-00a0c93ec93b", "a0000000-0000-4000-8000-000000000000"), - ("94464329-e06e-4288-a9da-7fc7154f5e92", "a1000000-0000-4000-8000-000000000000"), - ("ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", "a2000000-0000-4000-8000-000000000000"), - ("037719d7-dea5-481a-aa07-6af8be6d51e2", "a3000000-0000-4000-8000-000000000000"), - ("b350bc93-bb6a-4c5e-9589-a5c3cfd555fd", "a4000000-0000-4000-8000-000000000000"), - ], - ); - let reader = listed("/dev/sdc", &[]); - let answers = [("/dev/sda", ULTRA), ("/dev/sdb", ULTRA), ("/dev/sdc", "0\nGeneric\nSD Reader\n1\n")]; - for (listing, stick) in [ - (format!("{}{}{reader}{nvme}", iso("/dev/sda"), toyos("/dev/sdb", LOG)), b'b'), - (format!("{}{}{reader}{nvme}", toyos("/dev/sda", LOG), iso("/dev/sdb")), b'a'), - ] { - let (found, asked) = locate(&listing, LAYOUT, &answers); - assert_eq!(found, Ok(Sd(stick)), "{listing}"); - assert_eq!(asked, [Sd(stick).node().whole()], "only a disk carrying the image is asked what it is"); - } - } - - /// **Two identical sticks both carrying a ToyOS image are refused, both - /// named**: neither is the boot stick by any fact this loop reads. - #[test] - fn an_identical_pair_both_carrying_toyos_is_refused() { - let listing = format!("{}{}", toyos("/dev/sda", LOG), toyos("/dev/sdb", LOG)); - let (found, _) = locate(&listing, LAYOUT, &[("/dev/sda", ULTRA), ("/dev/sdb", ULTRA)]); - let refused = found.unwrap_err(); - assert_eq!( - refused, - Refusal::Ambiguous { - carrying: "a ToyOS image's partition table".to_string(), - nodes: vec!["/dev/sda".to_string(), "/dev/sdb".to_string()], - } - ); - assert!(!refused.about_the_boot()); - } - - /// **No stick is refused, never defaulted**: an identical stick holding - /// another system, a ToyOS image on a disk that is not the recorded stick, - /// and no disk at all each refuse, naming every disk considered and the - /// way to adopt a fresh stick. - #[test] - fn a_missing_stick_is_refused_and_never_defaulted() { - let card = toyos("/dev/sdb", LOG); - let (found, _) = locate( - &format!("{}{card}", iso("/dev/sda")), - LAYOUT, - &[("/dev/sda", ULTRA), ("/dev/sdb", "62521344\nGeneric \nSD Card \n1\n")], - ); - let Err(Refusal::Unlocated { considered, .. }) = &found else { panic!("{found:?}") }; - assert_eq!(considered.len(), 2, "{considered:?}"); - assert_eq!( - considered[0], - "/dev/sda does not carry a ToyOS image's partition table (2 partitions: no GPT type, no GPT type)" - ); - assert!(considered[1].starts_with("/dev/sdb: the disk's size"), "{considered:?}"); - let said = found.unwrap_err().to_string(); - assert!(said.contains("--device /dev/sd"), "{said}"); - - let (found, _) = locate(&listed("/dev/sda", &[]), LAYOUT, &[]); - assert!( - matches!(found, Err(Refusal::Unlocated { ref considered, .. }) if considered[0].ends_with("(no partitions)")), - "{found:?}" - ); - let (found, asked) = locate("", LAYOUT, &[]); - assert!(matches!(found, Err(Refusal::Unlocated { ref considered, .. }) if considered.is_empty()), "{found:?}"); - assert!(asked.is_empty()); - let (found, _) = locate("NAME=\"/dev/sda\" TYPE=\"disk", LAYOUT, &[]); - assert!(matches!(found, Err(Refusal::Remote { .. })), "{found:?}"); - } - - /// **After the boot the stick is the one carrying this image's log**, by - /// the GUID its build drew: a stick still holding an earlier image is not - /// it, and one not back yet is a `NoStick` the wait polls on. - #[test] - fn after_the_boot_the_stick_is_the_one_carrying_this_images_log() { - let guid = Guid::parse(&LOG.to_ascii_uppercase()).unwrap(); - let carrying = Carrying::Log { index: 3, guid }; - let earlier = "31000000-0000-4000-8000-000000000000"; - let listing = format!("{}{}", toyos("/dev/sda", earlier), toyos("/dev/sdb", LOG)); - let answers = [("/dev/sda", ULTRA), ("/dev/sdb", ULTRA)]; - assert_eq!(locate(&listing, carrying, &answers).0, Ok(Sd(b'b'))); - let (gone, _) = locate(&toyos("/dev/sda", earlier), carrying, &answers); - assert!(matches!(gone, Err(Refusal::Unlocated { .. })), "{gone:?}"); - } - /// A disk adopted as scratch: one partition, of the scratch type. fn scratch(node: &str) -> String { listed(node, &[("51cc09f9-7442-435b-bc03-0fd8718b0fb5", "60000000-0000-4000-8000-000000000000")]) } - /// **The boot stick and a scratch disk of the same model are never taken - /// for each other**, in either direction and whichever node each has. + /// **A disk carrying an image and a scratch disk are never taken for each + /// other**, in either direction and whichever node each has. #[test] - fn the_boot_stick_and_a_scratch_disk_are_never_confused() { - let answers = [("/dev/sda", ULTRA), ("/dev/sdb", ULTRA)]; + fn an_image_and_a_scratch_disk_are_never_confused() { for (boot, spare) in [("/dev/sda", "/dev/sdb"), ("/dev/sdb", "/dev/sda")] { let listing = format!("{}{}", toyos(boot, LOG), scratch(spare)); - assert_eq!(locate(&listing, LAYOUT, &answers).0, Ok(Sd::parse(boot).unwrap())); let parts = |sd: &str| parts_of(&listing, Sd::parse(sd).unwrap()).unwrap(); assert!(Carrying::Scratch.holds(&parts(spare)) && !LAYOUT.holds(&parts(spare))); assert!(!Carrying::Scratch.holds(&parts(boot))); @@ -3253,8 +2818,8 @@ mod tests { assert!(matches!(refused, Refusal::Adopt(ref why) if why.ends_with("does not read back as a scratch disk")), "{refused:?}"); } - /// **Adoption never erases a disk holding a ToyOS image**, which is the - /// one thing the boot stick is known by, nor a node the machine lacks. + /// **Adoption never erases a disk holding a ToyOS image**, nor a node the + /// machine lacks. #[test] fn a_disk_holding_a_toyos_image_is_never_adopted_as_scratch() { let listing = format!("{}{}", toyos("/dev/sda", LOG), iso("/dev/sdb")); @@ -3265,29 +2830,34 @@ mod tests { assert_eq!(adoptable(&scratch("/dev/sdb"), Sd(b'b'), LAYOUT), Ok(()), "adopting again rewrites the table"); } - /// **A capture and an adoption are actions, not modes**: each names a - /// `/dev/sd` disk and stands beside nothing but the flags that say - /// which machine, and adoption beside `--dry-run`. A capture once fell - /// through into the loop with whatever `--image` defaulted to, and booted - /// the T14 into a boot with no job list and no bound. + /// **A capture, an adoption and a fall-through are actions, not modes**: + /// each stands beside nothing but the flags that say which machine, and + /// the last two beside `--dry-run`. A capture once fell through into the + /// loop with whatever `--image` defaulted to, and booted the T14 into a + /// boot with no job list and no bound. #[test] - fn a_capture_and_an_adoption_are_actions_of_their_own() { + fn a_capture_an_adoption_and_a_fall_through_are_actions_of_their_own() { let words = |w: &[&str]| w.iter().map(|w| (*w).to_string()).collect::>(); let adopt = Args::parse(&words(&["--adopt-scratch", "/dev/sdb", "--dry-run", "--host", "toyos@bench-amd"])).unwrap(); assert_eq!(adopt.adopt_scratch, Some(Sd(b'b'))); - let record = Args::parse(&words(&["--record", "/dev/sda", "--host", "dev@t14", "--key", "/tmp/k"])).unwrap(); - assert_eq!(record.record, Some(Sd(b'a'))); - let boot = [&["--image", "x.img"][..], &["--readback", "r"], &["--fat32-check"], &["--device", "/dev/sda"], &["--boot-internal"]]; - for (action, node) in [("--adopt-scratch", "/dev/sdb"), ("--record", "/dev/sda")] { - for beside in boot.iter().copied().chain([&["--dry-run"][..]].into_iter().filter(|_| action == "--record")) { - let line = [&[action, node][..], beside].concat(); + assert!(Args::parse(&words(&["--record", "--host", "dev@t14", "--key", "/tmp/k"])).unwrap().record); + for (word, how) in [("missing", Unbootable::Missing), ("garbage", Unbootable::Garbage)] { + let fall = Args::parse(&words(&["--fall-through", word, "--dry-run", "--host", "toyos@bench-amd"])).unwrap(); + assert_eq!(fall.fall_through, Some(how)); + } + assert!(matches!(Args::parse(&words(&["--fall-through", "pxe"])), Err(Refusal::Usage(_)))); + let boot = [&["--image", "x.img"][..], &["--readback", "r"], &["--fat32-check"], &["--boot-internal"]]; + for action in [&["--adopt-scratch", "/dev/sdb"][..], &["--record"], &["--fall-through", "missing"]] { + let dry: &[&str] = &["--dry-run"]; + for beside in boot.iter().copied().chain([dry].into_iter().filter(|_| action[0] == "--record")) { + let line = [action, beside].concat(); let refused = Args::parse(&words(&line)).unwrap_err(); assert!(matches!(refused, Refusal::Usage(ref why) if why.contains(beside[0])), "{line:?}"); assert!(!refused.about_the_boot()); } - assert!(matches!(Args::parse(&words(&[action, "/dev/mmcblk0"])), Err(Refusal::Usage(_)))); } - let both = Args::parse(&words(&["--record", "/dev/sda", "--adopt-scratch", "/dev/sdb"])).unwrap_err(); + assert!(matches!(Args::parse(&words(&["--adopt-scratch", "/dev/mmcblk0"])), Err(Refusal::Usage(_)))); + let both = Args::parse(&words(&["--record", "--adopt-scratch", "/dev/sdb"])).unwrap_err(); assert!(matches!(both, Refusal::Usage(_))); } @@ -3300,8 +2870,8 @@ mod tests { let home = Home::parse("/var/home/dev").expect("a home"); assert_eq!(home.mount(), "/var/home/dev/toyos-log"); assert_eq!( - target.argv(&Job::Mount { sd: Sd(b'b'), part: 3 }, &home), - ["/usr/bin/mount", "-o", "ro", "/dev/sdb3", "/var/home/dev/toyos-log"] + target.argv(&Job::Mount(Owned::Log), &home), + ["/usr/bin/mount", "-o", "ro", "/dev/nvme0n1p7", "/var/home/dev/toyos-log"] ); assert_eq!(target.argv(&Job::Umount, &home), ["/usr/bin/umount", "/var/home/dev/toyos-log"]); for answer in ["", "var/home/dev", "/", "/var/home/dev/", "/var//dev", "/var/./dev", "/var/../root", "/a\n"] @@ -3333,8 +2903,17 @@ mod tests { let t = target(); let guid = Guid::parse("69DDC8F6-FAB2-423F-9818-93BB0BA7349C").unwrap(); let named = Owned::ALL.iter().filter(|o| o.named_by_image()).map(|o| (*o, guid)).collect(); - let mut internal_jobs = vec![Job::Partition, Job::AddPartitions, Job::Retag(named), Job::CreateInternal]; + let mut internal_jobs = vec![ + Job::Partition, + Job::AddPartitions, + Job::Retag(named), + Job::Create { label: INTERNAL_LABEL, loader: LOADER }, + Job::Create { label: UNBOOTABLE_LABEL, loader: Unbootable::Missing.loader() }, + Job::Create { label: UNBOOTABLE_LABEL, loader: Unbootable::Garbage.loader() }, + Job::ReadLog { bytes: 35_651_584 }, + ]; internal_jobs.extend(Owned::ALL.map(Job::Write)); + internal_jobs.extend(Owned::ALL.map(Job::Mount)); for job in internal_jobs { let argv = t.argv(&job, &home()); let internal = argv.iter().any(|w| w.contains("/dev/nvme0n1")); @@ -3356,15 +2935,13 @@ mod tests { } /// The read the outside judge needs is a read: it carries no `of=` and no - /// `conv=`, so it cannot write to the disk it names. + /// `conv=`, so it cannot write to the disk it names, and it reads the + /// volume's bytes from the log partition and no more. #[test] fn the_outside_judges_read_can_write_nothing() { - let argv = target().argv(&Job::ReadLog { sd: Sd(b'a'), part: 3 }, &home()); - assert!(argv.iter().any(|w| w.starts_with("if=")), "{argv:?}"); + let argv = target().argv(&Job::ReadLog { bytes: 35_651_584 }, &home()); assert!(!argv.iter().any(|w| w.starts_with("of=") || w.starts_with("conv=")), "{argv:?}"); - // And it names a partition rather than the whole disk, so it cannot be - // the flash line with one word changed. - assert_eq!(argv[1], "if=/dev/sda3"); + assert_eq!(argv[1..], ["if=/dev/nvme0n1p7", "bs=4M", "iflag=count_bytes", "count=35651584"]); } #[test] @@ -3391,8 +2968,8 @@ mod tests { /sys/class/block/sda/device/model /sys/class/block/sda/removable" ); assert_eq!( - t.remote(&Job::Flash(Sd(b'a')), &home()), - "sudo -n '/usr/bin/dd' 'of=/dev/sda' 'bs=4M' 'conv=fsync,nocreat'" + t.remote(&Job::Write(Owned::RootA), &home()), + "sudo -n '/usr/bin/dd' 'of=/dev/nvme0n1p9' 'bs=4M' 'conv=fsync,nocreat'" ); assert_eq!(t.remote(&Job::Settle, &home()), "sudo -n '/usr/bin/udevadm' 'settle' '--timeout=180'"); assert_eq!(t.remote(&Job::Reread(Sd(b'a')), &home()), "sudo -n '/usr/sbin/blockdev' '--rereadpt' '/dev/sda'"); @@ -3401,9 +2978,14 @@ mod tests { "sudo -n '/usr/sbin/wipefs' '--all' '/dev/sda1'" ); assert_eq!( - t.remote(&Job::Create { sd: Sd(b'a'), esp: 1 }, &home()), - "sudo -n '/usr/bin/efibootmgr' '--create-only' '--disk' '/dev/sda' '--part' '1' \ - '--label' 'ToyOS' '--loader' '\\EFI\\BOOT\\BOOTX64.EFI'" + t.remote(&Job::Create { label: INTERNAL_LABEL, loader: LOADER }, &home()), + "sudo -n '/usr/bin/efibootmgr' '--create-only' '--disk' '/dev/nvme0n1' '--part' '5' \ + '--label' 'ToyOS-internal' '--loader' '\\EFI\\BOOT\\BOOTX64.EFI'" + ); + assert_eq!( + t.remote(&Job::Create { label: UNBOOTABLE_LABEL, loader: Unbootable::Garbage.loader() }, &home()), + "sudo -n '/usr/bin/efibootmgr' '--create-only' '--disk' '/dev/nvme0n1' '--part' '5' \ + '--label' 'ToyOS-unbootable' '--loader' '\\toyos\\log.guid'" ); } @@ -3514,7 +3096,8 @@ mod tests { assert!(Refusal::Log(bootlog::Unfit::NoBootRecord).about_the_boot()); assert!(Refusal::Log(bootlog::Unfit::Unfinished("x".to_string())).about_the_boot()); assert!(Refusal::Silent { what: "come back", secs: 420 }.about_the_boot()); - assert!(!Refusal::Disk(Unfit::Node("/dev/nvme0n1p1".to_string())).about_the_boot()); + assert!(Refusal::FellThrough("BootNext still reads 0002".to_string()).about_the_boot()); + assert!(!Refusal::Install("it carries no log".to_string()).about_the_boot()); 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()); @@ -3560,118 +3143,19 @@ mod tests { let args = Args::parse(&[]).unwrap(); assert_eq!((args.target.user.as_str(), args.target.host.as_str()), ("toyos", "bench-t14")); assert_eq!(args.target.internal.whole(), "/dev/nvme0n1"); - assert_eq!(args.target.device, None, "no node is the stick by default"); assert!(!args.dry_run); - let words: Vec = ["--dry-run", "--device", "/dev/sdb", "--host", "runner@box"] - .iter() - .map(|w| (*w).to_string()) - .collect(); + let words: Vec = ["--dry-run", "--host", "runner@box"].iter().map(|w| (*w).to_string()).collect(); let args = Args::parse(&words).unwrap(); assert!(args.dry_run); - assert_eq!(args.target.device, Some(Sd(b'b'))); assert_eq!(args.target.user, "runner"); assert_eq!(args.target.host, "box"); - for node in ["/dev/nvme0n1", "/dev/nvme0n1p2", "/dev/sda1"] { - let words = ["--device".to_string(), node.to_string()]; - assert!(matches!(Args::parse(&words), Err(Refusal::Usage(_))), "{node}"); - } + assert!(matches!(Args::parse(&["--device".to_string(), "/dev/sdb".to_string()]), Err(Refusal::Usage(_)))); assert!(matches!(Args::parse(&["--image".to_string()]), Err(Refusal::Usage(_)))); assert!(matches!(Args::parse(&["--flash".to_string()]), Err(Refusal::Usage(_)))); } - /// A disk carrying `parts` in order, so `admit`'s refusals are exercised on - /// tables rather than on prose about tables. - fn disk(parts: &[(&str, toyos_gpt::Guid)]) -> Vec { - const TOTAL: u64 = 16 * 1024 * 1024; - const ALIGN: u64 = 2048; - let entries: Vec = (1..) - .zip(parts) - .map(|(i, (name, kind))| { - toyos_gpt::edit::Slot::new(*kind, toyos_build::image::random_guid(), i * ALIGN, i * ALIGN + 511, name).unwrap() - }) - .collect(); - let mut out = vec![0u8; TOTAL as usize]; - toyos_build::image::write_gpt(&mut std::io::Cursor::new(&mut out), TOTAL, toyos_build::image::random_guid(), &entries); - out - } - - #[test] - fn an_image_admits_only_a_toyos_table() { - let dir = toyos_tmpdir::TempDir::new("metal"); - let write = |name: &str, bytes: &[u8]| { - let at = dir.join(name); - std::fs::write(&at, bytes).expect("write"); - at - }; - let (esp, basic, slots, boot, root) = ( - toyos_gpt::Guid::EFI_SYSTEM, - toyos_gpt::Guid::MICROSOFT_BASIC, - toyos_gpt::Guid::TOYOS_SLOTS, - toyos_gpt::Guid::TOYOS_BOOT, - toyos_gpt::Guid::TOYOS_ROOT, - ); - - assert_eq!( - admit(&write("ragged.img", &vec![0u8; 1000])), - Err(Refusal::Disk(Unfit::Sectors { bytes: 1000 })) - ); - assert_eq!(admit(&write("empty.img", &[])), Err(Refusal::Disk(Unfit::Sectors { bytes: 0 }))); - assert_eq!( - admit(&write("headless.img", &vec![0u8; 1024])), - Err(Refusal::Disk(Unfit::BackupHeader { at: 512, saw: "\0\0\0\0\0\0\0\0".to_string() })) - ); - // The signature in the *primary* header alone: the false pass a healthy - // front hides, which is why the check reads the final sector. - let mut front = vec![0u8; 1536]; - front[512..520].copy_from_slice(b"EFI PART"); - assert!(matches!( - admit(&write("front-only.img", &front)), - Err(Refusal::Disk(Unfit::BackupHeader { .. })) - )); - - let two = disk(&[("a", esp), ("b", esp), ("log", basic), ("slots", slots)]); - assert_eq!( - admit(&write("two-esps.img", &two)), - Err(Refusal::Partitions { what: "ESP", matched: 2 }) - ); - let no_log = disk(&[("esp", esp), ("slots", slots)]); - assert_eq!( - admit(&write("no-log.img", &no_log)), - Err(Refusal::Partitions { what: "TOYOS-LOG", matched: 0 }) - ); - let bootless = disk(&[("esp", esp), ("slots", slots), ("log", basic), ("root", root)]); - assert_eq!( - admit(&write("bootless.img", &bootless)), - Err(Refusal::Slots { what: "TOYOS-BOOT", matched: 0 }) - ); - let rootless = disk(&[("esp", esp), ("slots", slots), ("log", basic), ("boot", boot)]); - assert_eq!( - admit(&write("rootless.img", &rootless)), - Err(Refusal::Slots { what: "TOYOS-ROOT", matched: 0 }) - ); - let three = disk(&[("esp", esp), ("slots", slots), ("log", basic), ("boot", boot), ("a", root), ("b", root), ("c", root)]); - assert_eq!( - admit(&write("three-roots.img", &three)), - Err(Refusal::Slots { what: "TOYOS-ROOT", matched: 3 }) - ); - let no_table = disk(&[("esp", esp), ("root", root), ("log", basic)]); - assert_eq!( - admit(&write("no-table.img", &no_table)), - Err(Refusal::Partitions { what: "TOYOS-SLOTS", matched: 0 }) - ); - // And the shapes `build/image.rs` writes, one slot or two, which are - // the ones admitted. - let two_slots = - disk(&[("esp", esp), ("slots", slots), ("log", basic), ("a", boot), ("a", root), ("b", boot), ("b", root)]); - assert!(admit(&write("two-slots.img", &two_slots)).is_ok(), "a two-slot image is refused"); - let built = disk(&[("esp", esp), ("slots", slots), ("log", basic), ("boot", boot), ("root", root)]); - let ok = admit(&write("built.img", &built)).expect("the built shape is admitted"); - assert_eq!((ok.esp.index, ok.log.index), (1, 3)); - assert_eq!(ok.bytes % u64::from(LBA), 0); - assert_ne!(ok.esp.guid, toyos_gpt::Guid::ZERO); - } /// The lateness comes off the line the kernel writes, counted from the /// record of arming the bound, and a boot that wrote none measures nothing /// rather than zero. @@ -3774,7 +3258,7 @@ mod tests { } /// A boot given a deadline below its runner's least bound is ended by it on - /// purpose, as `stick_survives_a_reset_under_load`'s is: judged by the + /// purpose, as `the_disk_survives_a_reset_under_load`'s is: judged by the /// record the deadline sealed, never by a supervisor's stop it was never /// going to reach. The same boot under the ordinary deadline is the /// unfinished boot it would be, and one that handed back was not ended diff --git a/tests/metal/src/testargs.rs b/tests/metal/src/testargs.rs index 0fb5cd4f3e8..f94e7704793 100644 --- a/tests/metal/src/testargs.rs +++ b/tests/metal/src/testargs.rs @@ -15,8 +15,8 @@ declare_flags!(pub SUITE = { pub NOCAPTURE = "--nocapture", None; pub JOBS = "--jobs", Next; pub JOBS_SHORT = "-j", Next; - /// The metal profile: the registrations that run on the T14, batched into - /// images and judged off the log the stick came back with. + /// The metal profile: the registrations that run on a bench, batched into + /// images and judged off the log each boot left on the bench's disk. pub METAL = "--metal", None; /// Where those images and their readbacks live. **Naming it means the /// machine is not touched**: the run builds the images and writes down what diff --git a/tests/metal/src/timings.rs b/tests/metal/src/timings.rs index ac0965b16d8..8154f80ab16 100644 --- a/tests/metal/src/timings.rs +++ b/tests/metal/src/timings.rs @@ -1,18 +1,18 @@ -//! Each metal bench's record: the boot stick its loop may write, and every -//! number the metal suite measures there, judged only against the record of -//! the machine that measured it. +//! Each metal bench's record: that the machine is a bench the loop may write, +//! and every number the metal suite measures there, judged only against the +//! record of the machine that measured it. //! //! One file per machine under [`dir`], named for its SMBIOS vendor and product: //! `metal/` in the host's store, outside every checkout, so every worktree on //! the host reads one record and none commits it. -//! `toyos-metal --record` writes it once, with the stick and no -//! number, and the loop refuses a machine that has none. A name the record +//! `toyos-metal --record` writes it once, with no number, and the loop +//! refuses a machine that has none. A name the record //! lacks is added by a run and not judged, and only where its owner passed, the //! boot or the row that measured it; a recorded value is never moved by a run, //! so a slow run cannot become the baseline the next is judged by. A run under //! a BIOS other than the record's is judged against it, fails naming both, and //! records nothing. Deleting a row is how a number is re-recorded, and deleting -//! the file is how a machine is, firmware, stick and all. +//! the file is how a machine is, firmware and all. //! //! [`ceiling`] is the one rule. @@ -87,34 +87,22 @@ fn file(vendor: &str, product: &str) -> String { format!("{}.toml", slug.trim_end_matches('-')) } -/// The one disk the loop may write on a machine, as its -/// `/sys/class/block/` answered when the machine was recorded. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Stick { - pub sectors: u64, - pub vendor: String, - pub model: String, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Record { pub vendor: String, pub product: String, pub bios: String, - pub stick: Stick, pub measured: BTreeMap, } impl Record { - /// `machine` with `stick`, before any run has measured it. - pub fn new(machine: &Machine, stick: Stick) -> Self { + /// `machine`, before any run has measured it. + pub fn new(machine: &Machine) -> Self { Self { vendor: machine.vendor.clone(), product: machine.product.clone(), bios: machine.bios.clone(), - stick, measured: BTreeMap::new(), } } @@ -282,8 +270,7 @@ mod tests { } fn fresh(machine: &Machine) -> Record { - let stick = Stick { sectors: 60_062_500, vendor: "SanDisk".to_string(), model: "Ultra".to_string() }; - Record::new(machine, stick) + Record::new(machine) } fn recorded(machine: &Machine, pairs: &[(&str, u64)]) -> Record { diff --git a/tests/toyos.rs b/tests/toyos.rs index 5b8cc55d072..0c48ea46c6d 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -67,10 +67,10 @@ const ACTUATOR_KERNEL: &[&str] = toyos_build::build::TEST_KERNEL; // Rust helper binaries that are spawned by tests, not tests themselves. const RUST_SKIP: &[&str] = &[ - // It writes the stick for ever, so that its boot's deadline resets the - // machine under it: `stick_survives_a_reset_under_load` runs it alone. + // It writes the log disk for ever, so that its boot's deadline resets the + // machine under it: `the_disk_survives_a_reset_under_load` runs it alone. "stick_load", - // The stick-write load `mask_windows` reads windows under, on the boot + // The log-write load `mask_windows` reads windows under, on the boot // whose kernel reports them. "stick_write", // It opens a window, which needs a compositor the shared boot does not @@ -478,7 +478,7 @@ const MACHINE_TESTS: &[&str] = &[ // runs over it. q35 only. "the_kernel_tells_a_bypassing_console_physical_addresses", // An AMD-Vi unit left blocking every device, switched off and programmed - // before the boot stick is driven behind it: the register sequence and the + // before the boot disk is driven behind it: the register sequence and the // DMA it lets through are no host test's, and the AMD bench is staged by // hand rather than by CI. "amdvi_firmware_left", @@ -579,8 +579,8 @@ const MACHINE_TESTS: &[&str] = &[ // A machine whose one NVMe disk is all it has boots the image off it and // serves every role off it: the firmware that reads it, the loader and // kernel it hands the image to, and the claim the nvme driver serves the - // block service's sessions through have no host build, and the T14 boots - // from a stick beside an NVMe disk that is another system's. + // block service's sessions through have no host build. A bench boots the + // same way from its own disk, beside another system's partitions. "nvme_disk_boots", // A stick boot beside an NVMe disk that is another system's, on a // controller under Intel's id: the row drives it by its class through the @@ -653,8 +653,8 @@ const MACHINE_TESTS: &[&str] = &[ // filter. "usbd_refused_without_smi_filter", // The same machine with its AMD unit remapping nothing: the claim is - // refused by that reason. No metal row asks it: a bench without remapping - // drives no USB, so no `/log` reaches its stick to judge. + // refused by that reason. No metal row asks it: no bench is without + // remapping. "usbd_refused_without_amdvi_remapping", // Every vector register a thread holds, across switches to threads holding // others, against a scheduler, which no host build has. The shared boot's @@ -711,9 +711,9 @@ const MACHINE_TESTS: &[&str] = &[ "a_window_is_drawn_at_the_scale_and_relaid_at_the_next", ]; -/// **The metal profile**: which registrations run on the ThinkPad T14, what -/// boots each one needs, and how each is judged off the log the stick came back -/// with. +/// **The metal profile**: which registrations run on a bench, what boots each +/// one needs, and how each is judged off the log the boot left on the bench's +/// disk. /// /// Two rows naming the same boot share one image and one boot, and their job /// lists are unioned. A boot is about a minute of the machine's time, so that @@ -802,8 +802,8 @@ const METAL: &[(&str, metal::Metal)] = &[ }, ), ( - // `hda_tone`'s tone while a thread of its process writes the stick - // through usbd: no period soundserver had to fill + // `hda_tone`'s tone while a thread of its process writes the log + // disk: no period soundserver had to fill // (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`'s // exit). It is the kernel's: one claim's interrupts and its holder's // threads never starve another holder's deadline. The T14 and the AMD @@ -917,7 +917,7 @@ const METAL: &[(&str, metal::Metal)] = &[ ), ( // The two numbers `syscall_cost` measures, printed by the job and read - // off the stick: that it ran, and what it said, are the verdict. + // off the log: that it ran, and what it said, are the verdict. "syscall_cost", metal::Metal { arms: TESTCASES, @@ -931,7 +931,7 @@ const METAL: &[(&str, metal::Metal)] = &[ ), ( // How long a spawn of `toybox` takes to its exit, printed by the job and - // read off the stick for an A/B against another build: that it ran, and + // read off the log for an A/B against another build: that it ran, and // what it said, are the verdict. "spawn_cost", metal::Metal { @@ -1013,14 +1013,14 @@ const METAL: &[(&str, metal::Metal)] = &[ "watchdog_fed", metal::Metal { arms: &[metal::once("testcases-watchdog", "tests/images/testcases.toml", &["watchdog"], &[])], - judge: |b| b[0].log_reached_the_stick(), + judge: |b| b[0].log_is_whole_to_its_stop(), }, ), // ---- the boot facts, riding the same tests/images/testcases.toml image ---- // // **Seven names and no extra minute of the machine.** Every needle below is a // record the *shipping* kernel writes on any boot it takes, so each rides - // whatever image is already going on the stick; the two rows after them are + // whatever image is already going on the bench; the two rows after them are // the two things this suite measures rather than reads, and those cost a // boot. ( @@ -1167,7 +1167,7 @@ const METAL: &[(&str, metal::Metal)] = &[ ), ( // Its own boot, and it must not share one: it deliberately leaves the - // page holding a record no stick owns, and a boot that then read it as + // page holding a record no image owns, and a boot that then read it as // a predecessor's is exactly what the arm above judges. "blackbox_foreign_record", metal::Metal { @@ -1297,7 +1297,7 @@ const METAL: &[(&str, metal::Metal)] = &[ metal::Metal { arms: SELFTESTS, judge: |b| lapic_vectors(b[0].kernel().text()) }, ), ( - // No drain on this side: the whole boot's records are on the stick, so + // No drain on this side: the whole boot's records are on the log, so // the probe's line is either in them or it never ran. "sysret_ss_reload", metal::Metal { arms: SELFTESTS, judge: |b| sysret_ss(b[0].kernel().text()) }, @@ -1346,22 +1346,22 @@ const METAL: &[(&str, metal::Metal)] = &[ metal::Metal { arms: TESTCASES, judge: |b| claims::clear_of_host_bridges(&b[0].kernel()) }, ), ( - // An AMD-Vi unit programmed, and the boot stick's controller read + // An AMD-Vi unit programmed, and the boot disk's controller read // through a domain of its own: the register sequence, the device // table and the host tables on real silicon. The AMD bench's row. - "amdvi_translates_the_stick_controller", + "amdvi_translates_the_boot_controller", metal::Metal { arms: TESTCASES, judge: |b| amdvi_translates(&b[0].kernel(), &b[0].log()) }, ), ( - // An AMD-Vi unit remapping every message: the boot stick's + // An AMD-Vi unit remapping every message: the boot disk's // controller's interrupt delivered through an entry of its own // table, on real silicon, and every I/O APIC named. - "amdvi_remaps_the_stick_controller", + "amdvi_remaps_the_boot_controller", metal::Metal { arms: TESTCASES, judge: |b| amdvi_remaps(&b[0].kernel(), &b[0].log()) }, ), ( - // The AMD bench's xHCI that does not hold the stick, which the - // image's usbd row leaves, claimed and driven by usbd through an entry of its own table, its + // The AMD bench's first xHCI, which the image's usbd row leaves, + // claimed and driven by usbd through an entry of its own table, its // MSI-X table in the register BAR it hands over; then aimed past its // grant, recorded against the claim; then its holder rewriting its // own table entry to a vector not its own, refused and recorded. @@ -1405,23 +1405,18 @@ const METAL: &[(&str, metal::Metal)] = &[ }, ), ( - // The boot controller handed over to the image's usbd row (the one - // holding the stick), its message through its remapping - // entry, its No-Op answered by that interrupt, and the boot stick - // served through it: what every metal boot's `/log` now rides. - // `Boot: complete` and the log volume's mount are printed, not judged. - "usbd_serves_the_boot_stick", - metal::Metal { arms: TESTCASES, judge: |b| usbd_serves_the_boot_stick(&b[0].kernel(), &b[0].log()) }, - ), - ( - // The bench's own NVMe disk, driven beside the stick by the row's - // class: nothing on it is touched but its ToyOS DATA partition, which - // is left alone while it is foreign and formatted or mounted once it - // is designated (`hostdisk::left_alone`), and never stood in for by - // memory. The T14 and the AMD bench; the metal loop's next boot of the - // host off that disk is the other half. + // The bench's own NVMe disk, which the boot came off, driven by the + // row's class: nothing on it is touched but the log and boot volumes + // the loader named and its ToyOS DATA partition, which is left alone + // while it is foreign and formatted or mounted once it is designated + // (`hostdisk::left_alone`), and never stood in for by memory. The T14 + // and the AMD bench; the metal loop's next boot of the host off that + // disk is the other half. "nvme_leaves_the_benchs_disk_alone", - metal::Metal { arms: TESTCASES, judge: |b| common::hostdisk::left_alone(&b[0].log()).map(drop) }, + metal::Metal { + arms: TESTCASES, + judge: |b| common::hostdisk::left_alone(&b[0].log(), &common::hostdisk::loader_named(&b[0].loader())?).map(drop), + }, ), ( // The bench's DATA partition designated from inside ToyOS, and the @@ -1430,25 +1425,24 @@ const METAL: &[(&str, metal::Metal)] = &[ // flushes and reads it back, under the one authorization the // designation image grants its runner. Destructive and once per // bench, so named only: the T14 and the AMD bench, whose p4s are the - // owner's empty ToyOS DATA. On the T14 the same boot reads its stick's - // last sector, past its last whole block. + // owner's empty ToyOS DATA. "bench_data_designated", metal::Metal { arms: DESIGNATE, judge: |b| bench_data_designated(&b[0].log(), &b[1].log()) }, ), ( - // The stick written through usbd while the boot deadline resets the - // machine, which touches no USB register (#901 §6): the stick must - // enumerate on the next host within the bench's record. It is the - // kernel's reset path that is judged; usbd and the job are the load. - "stick_survives_a_reset_under_load", + // The log disk written while the boot deadline resets the machine: the + // pass after the reset reads the deadline's record and the host boots + // again off the disk the write was in flight on. It is the kernel's + // reset path that is judged; the job is the load. + "the_disk_survives_a_reset_under_load", metal::Metal { arms: &[metal::once("usbload", "tests/images/testcases.toml", &[], &[metal::STICK_LOAD_JOB])], - judge: |b| power::stick_survives_a_reset_under_load(b[0]), + judge: |b| power::the_disk_survives_a_reset_under_load(b[0]), }, ), ( - // The T14's xHCI that does not hold the stick, which the image's - // usbd row leaves, claimed, and usbd driving it: handed + // The T14's first xHCI, the Type-C one, which the image's usbd row + // leaves, claimed, and usbd driving it: handed // over, MSI armed through remapping, and the No-Op answered by an // interrupt; usbd is killed, so the release says which reset the // function advertises. Then a holder of it is killed and the claim @@ -1791,8 +1785,9 @@ const WINDOWSCASE: &[metal::Arm] = &[metal::Arm { /// The load `mask_windows` reads the windows under. const WINDOWS_LOAD: &str = "test_rs_ring_park_herd"; -/// The stick-write load it reads them under too (#901 §9): 32 MiB through -/// usbd, whose interrupts and threads are a claim's and a process's. +/// The log-write load it reads them under too (#901 §9): 8 MiB through the +/// log disk's driver, whose interrupts and threads are a claim's and a +/// process's. const STICK_WRITE: &str = "test_rs_stick_write"; /// The head of the kernel's record of `job`'s exit. @@ -4729,14 +4724,6 @@ fn bench_data_designated(first: &serial::Serial, second: &serial::Serial) -> Res first.must_say("supervisor: test-runner is authorized for `designate:data`")?; nvme_wrote_the_designation(first.text())?; first.must_say("the DATA partition is designated: the stamp is on the disk and read back")?; - // The T14's stick carries 7507812 sectors, four past its last whole 4 KiB - // block, and a smaller image than it, so its last sector holds no backup: - // usbd reads it with a short READ(10) and says the copies disagree. A - // read that failed would name the sector. - let usbd = |l: &&str| l.contains("usbd ") && l.contains("two copies do not agree"); - if let Some(failed) = first.text().lines().filter(usbd).find(|l| l.contains("ReadFailed(")) { - return Err(format!("a stick's last sector would not read: {failed:?}")); - } first.must_not_say("are in memory and will not survive a reboot")?; second.must_say("fileserver: block 0 designates this partition for ToyOS; formatting it")?; let serving = second.must_say("fileserver: Data serving ")?; @@ -4803,7 +4790,7 @@ fn a_host_disk_beside_the_stick_is_left_alone(test_config: &Path) -> Result<(), log.must_say("vendor=8086 device=5845 prog_if=02")?; // This disk's DATA is never designated: what it is judged on is that // nothing writes to a disk that is not ours. - match common::hostdisk::left_alone(&log)? { + match common::hostdisk::left_alone(&log, &[])? { (4, common::hostdisk::Data::Foreign) => {} (named, data) => return Err(format!("the driver named {named} partitions of a disk that has 4, and DATA was {data:?}")), } @@ -4835,13 +4822,12 @@ fn job_window(qemu: &mut QemuInstance, command: &str) -> Result Ok(result.serial) } -/// The xHCI controller a job's claim took on a metal boot, by address and -/// card: handed over, and not the one the supervisor says `@boot` is, which -/// the image's usbd row drives — so whichever port holds the stick, the spare -/// is the other controller. +/// The xHCI controller a job's claim took, by address and card: handed over, +/// and not the one the supervisor says `@boot` is, which the image's usbd row +/// drives where it is an xHCI. fn spare_xhci(kernel: &serial::Serial, log: &serial::Serial) -> Result<(String, String), String> { let text = kernel.text(); - let boot = boot_xhci(log)?; + let boot = boot_controller(log)?; let mut spares: Vec<(String, String)> = Vec::new(); for line in text.lines() { let Some((at, rest)) = line.split_once("pcidev: PCI ").and_then(|(_, l)| l.split_once(" [")) else { continue }; @@ -4858,7 +4844,7 @@ fn spare_xhci(kernel: &serial::Serial, log: &serial::Serial) -> Result<(String, } /// The controller the supervisor says `@boot` is, as `bb:dd.f`. -fn boot_xhci(log: &serial::Serial) -> Result<&str, String> { +fn boot_controller(log: &serial::Serial) -> Result<&str, String> { log.text() .lines() .find_map(|l| l.split_once(toyos_manifest::device::BOOT_SAID)?.1.split_whitespace().next()) @@ -5145,11 +5131,15 @@ fn no_fault_but_the_spares(kernel: &serial::Serial, log: &serial::Serial) -> Res } } -/// usbd's word that it serves the boot stick: its first disk offered. -const STICK_SERVED: &str = ": serving it as disk 0"; +/// The nvme driver's word that a session opened on the disk it drives at +/// `at`: block I/O through that controller. +fn nvme_session(at: &str) -> String { + format!("nvme {at}: session ") +} -/// Every AMD-Vi unit translating, every xHCI controller usbd drives on a -/// domain of its own, the boot stick served through one, and no DMA refused. +/// Every AMD-Vi unit translating, the boot disk's controller and every xHCI +/// controller usbd drives on a domain of its own, the boot disk read and +/// written through its one, and no DMA refused. fn amdvi_translates(kernel: &serial::Serial, log: &serial::Serial) -> Result<(), String> { let text = kernel.text(); let units: Vec<&str> = @@ -5159,27 +5149,26 @@ fn amdvi_translates(kernel: &serial::Serial, log: &serial::Serial) -> Result<(), } kernel.must_not_say("isolates no device")?; no_fault_but_the_spares(kernel, log)?; - let controllers = usb_controllers(kernel); - if controllers.is_empty() { - return Err(format!("no xHCI controller was handed over:\n{text}")); - } + let boot = boot_controller(log)?; + let mut controllers = usb_controllers(kernel); + controllers.push(boot.to_string()); for at in &controllers { kernel.must_say(&format!("iommu: {at} moves to domain"))?; } - log.must_say(STICK_SERVED)?; + log.must_say(&nvme_session(boot))?; for unit in units { eprintln!(" [amdvi] {}", unit.trim()); } - eprintln!(" [amdvi] {} controller(s) on domains of their own, the stick served", controllers.len()); + eprintln!(" [amdvi] {} controller(s) on domains of their own, the boot disk served", controllers.len()); Ok(()) } /// Every AMD-Vi unit remapping (`GAEn`, MMIO 0018h bit 17) and, once the /// kernel's drivers are settled, filtering SMIs and logging each it refuses /// (`SmiFEn` bit 22, `SmiFLogEn` bit 24); every I/O APIC the MADT has named -/// by the IVRS; and each xHCI controller usbd drives armed through a present -/// entry of its own table at the vector its message carries, the stick served -/// by those interrupts. +/// by the IVRS; and the boot disk's controller and each xHCI controller usbd +/// drives armed through a present entry of its own table at the vector its +/// message carries, the boot disk served by those interrupts. fn amdvi_remaps(kernel: &serial::Serial, log: &serial::Serial) -> Result<(), String> { let text = kernel.text(); let remaps = kernel.must_say("iommu: amdvi remaps every message through its requester's table")?; @@ -5212,7 +5201,9 @@ fn amdvi_remaps(kernel: &serial::Serial, log: &serial::Serial) -> Result<(), Str return Err(format!("{remaps:?} does not name the {apics} I/O APIC(s) the MADT has")); } no_fault_but_the_spares(kernel, log)?; - let controllers = usb_controllers(kernel); + let boot = boot_controller(log)?; + let mut controllers = usb_controllers(kernel); + controllers.push(boot.to_string()); for at in &controllers { let data = text .lines() @@ -5221,7 +5212,7 @@ fn amdvi_remaps(kernel: &serial::Serial, log: &serial::Serial) -> Result<(), Str let vector = u32::from_str_radix(data.trim_start_matches("0x"), 16).map_err(|e| format!("{data}: {e}"))? & 0xFF; kernel.must_say(&format!("iommu: amdvi irte source={at} vector={vector:#04x} p=1 "))?; } - log.must_say(STICK_SERVED)?; + log.must_say(&nvme_session(boot))?; eprintln!(" [amdvi] {}", remaps.trim()); for filter in filters { eprintln!(" [amdvi] {}", filter.trim()); @@ -5229,57 +5220,12 @@ fn amdvi_remaps(kernel: &serial::Serial, log: &serial::Serial) -> Result<(), Str for refused in text.lines().filter(|l| l.contains("iommu: amdvi unit") && l.contains(" said so far")) { eprintln!(" [amdvi] {}", refused.trim()); } - eprintln!(" [amdvi] {} controller(s) armed through their own entries, the stick served", controllers.len()); - Ok(()) -} - -/// The controller the boot stick is on, the one the supervisor says `@boot` -/// is, handed over once to the image's usbd, its message armed in remappable -/// format through an entry of its own (VT-d's Interrupt Format, bit 4 of an MSI -/// address; AMD-Vi's entry for its source), its No-Op answered, and the stick -/// served by it. The supervisor's lines for the boot sources are printed. -fn usbd_serves_the_boot_stick(kernel: &serial::Serial, log: &serial::Serial) -> Result<(), String> { - let served: Vec = usb_controllers(kernel) - .into_iter() - .filter(|at| log.text().lines().any(|l| l.contains(&format!("usbd {at}: ")) && l.ends_with(STICK_SERVED))) - .collect(); - let [at] = &served[..] else { - return Err(format!("not one controller served the stick: {served:?}\n{}", log.text())); - }; - let boot = boot_xhci(log)?; - if at != boot { - return Err(format!("the stick was served on {at}, and the supervisor says `@boot` is {boot}")); - } - let handed = format!("pcidev: PCI {at} ["); - let handovers: Vec<&str> = - kernel.text().lines().filter(|l| l.contains(&handed) && l.contains("] handed over")).collect(); - let [line] = handovers[..] else { - return Err(format!("{at} was not handed over once: {handovers:?}")); - }; - let remapped = kernel.text().lines().any(|l| { - l.split_once(&format!("PCI {at}: msi address=")) - .and_then(|(_, a)| u32::from_str_radix(a.split_whitespace().next()?.trim_start_matches("0x"), 16).ok()) - .is_some_and(|address| address & (1 << 4) != 0) - }) || kernel.text().contains(&format!("iommu: amdvi irte source={at} ")); - if !remapped { - return Err(format!("{at}'s message names no remapping entry of its own:\n{}", kernel.text())); - } - log.must_say(&usbd_noop(at))?; - eprintln!(" [usbd] {}", line.trim()); - for source in log.text().lines().filter(|l| l.contains("supervisor: the loader came off") || l.contains("supervisor: the slot volume came off")) { - eprintln!(" [usbd] {}", source.trim()); - } - for complete in kernel.text().lines().filter(|l| l.contains("Boot: complete")) { - eprintln!(" [usbd] {}", complete.trim()); - } - if let Some(mounted) = log.text().lines().find(|l| l.contains("fileserver: Log serving /log")) { - eprintln!(" [usbd] {}", mounted.trim()); - } + eprintln!(" [amdvi] {} controller(s) armed through their own entries, the boot disk served", controllers.len()); Ok(()) } -/// The T14's spare controller — the Type-C one, or 00:14.0 where the stick is -/// in a Type-C port — left to a claim and driven by usbd: the claim was +/// The T14's spare controller, the first free one, left to a claim and driven +/// by usbd: the claim was /// handed over with its MSI in remappable format and its remapping entry /// written, the message reached its claim, and the No-Op completed. What /// reset the function advertises is read off the release and printed, not @@ -5925,15 +5871,15 @@ fn windows_on_metal(boot: &metal::Readback) -> Result<(), String> { " [windows] held irqs_off_ns={} preempt_off_ns={}; herd irqs_off_ns={} preempt_off_ns={}", read.held.0, read.held.1, read.load.0, read.load.1 ); - // Under the stick write, each CPU's longest windows are judged against + // Under the log write, each CPU's longest windows are judged against // the bench's record (`timings::ceiling`), the base they were first read at. boot.job_passed(STICK_WRITE)?; let reached = metal::bytes_of_lines(boot.log().text(), "stick_write: line "); if reached < 1 << 20 { - return Err(format!("{reached} bytes of the stick write's lines reached the stick, under a mebibyte")); + return Err(format!("{reached} bytes of the log write's lines reached the disk, under a mebibyte")); } let under = common::irqcensus::windows_under(kernel.text(), cpus, &exited(STICK_WRITE))?; - eprintln!(" [windows] stick write irqs_off_ns={} preempt_off_ns={}", under.load.0, under.load.1); + eprintln!(" [windows] log write irqs_off_ns={} preempt_off_ns={}", under.load.0, under.load.1); boot.measured("boot.windowscase.stick_write.irqs_off_ns", under.load.0)?; boot.measured("boot.windowscase.stick_write.preempt_off_ns", under.load.1) } @@ -6093,8 +6039,8 @@ fn sysret_ss(log: &str) -> Result<(), String> { /// Every unit this kernel went on to program, and every AMD-Vi unit it /// switched off, said it was handed over with what the actuator left on. /// -/// An AMD-Vi unit's lines are on the boot stick only because the switch-off -/// let the stick's DMA through the unit the actuator left blocking it. +/// An AMD-Vi unit's lines are on the log disk only because the switch-off +/// let that disk's DMA through the unit the actuator left blocking it. fn iommu_firmware_left(log: &str) -> Result<(), String> { let mut units = 0; for line in log.lines().filter(|l| l.contains(" translating gsts=")) { From 7bfbea26d47a1b528f972f0e7b45d3fddccfe3fd Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 15:34:22 +0100 Subject: [PATCH 2/3] toyos-metal: Refusal::Identity says both of its uses Co-Authored-By: Claude Opus 5.5 --- tests/metal/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/metal/src/lib.rs b/tests/metal/src/lib.rs index 500d18600f4..c0076803a8a 100644 --- a/tests/metal/src/lib.rs +++ b/tests/metal/src/lib.rs @@ -128,7 +128,7 @@ pub fn said_refusal(stderr: &str) -> Option { pub enum Refusal { /// No `HOME`, so the default key path would be rooted at `/`. NoHome, - /// The disk did not answer what [`Identity::parse`] reads. + /// The disk's identity did not read, or is not a removable disk's. Identity { field: &'static str, want: String, got: String }, /// The disk named to be adopted as scratch is not one this loop erases. Adopt(String), From 78ade1ec03a3bad024718a865c6eb540747c693b Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 16:21:41 +0100 Subject: [PATCH 3/3] review r1: what the deleted tests held, the table rewrite said, and the order guarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `whole_disk`'s refusals are tested at their owner, `build/disk.rs`, and `Identity::parse` beside its reader; the dead `Node::part_sysfs` goes. - The module header and `install_internal` say the partition table is rewritten whole on every flash, entries 1–4 as they were. - A boot or fall-through is refused where its entry is in `BootOrder`, or where the boot moved it, since the next failed ToyOS boot would come back to ToyOS; the fall-through also owes `BootCurrent` to be the order's first. - The loads that write `/log`, now the bench's NVMe, are named for it: `log_write`, `tone_beside_log_write` and their row, with `boot.windowscase.log_write.*` recording afresh; the tone issue's exit asks for a write to a stick, which that row no longer makes. - The partitions track says the loop's `sgdisk` and `partx` run under Ubuntu and leave with it; the T14 answer-path issue is renamed for its heading; the `@boot` question leaves the tooling issue for the PR. Co-Authored-By: Claude Opus 5.5 --- build/disk.rs | 34 ++++-- ...n-to-the-stick-starves-a-tone-beside-it.md | 10 +- ...storage-since-the-benches-boot-off-nvme.md | 15 +-- ...-runs-only-what-no-cheaper-tier-reaches.md | 3 +- ...-small-interrupts-post-and-threads-wait.md | 18 +-- ...oes-only-what-must-precede-the-handover.md | 2 +- ...through-what-ubuntu-reads-off-its-disk.md} | 0 issues/toyos-manages-its-own-partitions.md | 13 +- loader/src/efi/mod.rs | 4 +- tests/common/audio.rs | 10 +- tests/guest/rust-tests/src/bin/log_write.rs | 14 +++ tests/guest/rust-tests/src/bin/stick_load.rs | 8 +- tests/guest/rust-tests/src/bin/stick_write.rs | 14 --- ...tick_write.rs => tone_beside_log_write.rs} | 18 ++- .../src/{stick_lines.rs => log_lines.rs} | 0 tests/metal/Cargo.toml | 2 +- tests/metal/src/lib.rs | 114 +++++++++++++++--- tests/toyos.rs | 34 +++--- 18 files changed, 206 insertions(+), 107 deletions(-) rename issues/{the-t14-answers-only-through-a-usb-stick.md => the-t14-answers-only-through-what-ubuntu-reads-off-its-disk.md} (100%) create mode 100644 tests/guest/rust-tests/src/bin/log_write.rs delete mode 100644 tests/guest/rust-tests/src/bin/stick_write.rs rename tests/guest/rust-tests/src/bin/{tone_beside_stick_write.rs => tone_beside_log_write.rs} (52%) rename tests/guest/rust-tests/src/{stick_lines.rs => log_lines.rs} (100%) diff --git a/build/disk.rs b/build/disk.rs index f53882a8992..065742afcf2 100644 --- a/build/disk.rs +++ b/build/disk.rs @@ -91,14 +91,10 @@ impl Node { format!("/dev/{}", self.part_name(index)) } - /// Where `/sys` answers for the disk, and for one of its partitions. + /// Where `/sys` answers for the disk. pub fn sysfs(&self) -> String { format!("/sys/class/block/{}", self.name()) } - - pub fn part_sysfs(&self, index: u32) -> String { - format!("{}/{}", self.sysfs(), self.part_name(index)) - } } /// A whole disk's image, opened: whole sectors, `EFI PART` in the *final* @@ -143,10 +139,34 @@ mod tests { } assert_eq!(Node::parse("/dev/sda").unwrap().whole(), "/dev/sda"); assert_eq!(Node::parse("/dev/sdb").unwrap().partition(3), "/dev/sdb3"); - assert_eq!(Node::parse("/dev/sda").unwrap().part_sysfs(1), "/sys/class/block/sda/sda1"); let nvme = Node::parse("/dev/nvme0n1").unwrap(); assert_eq!(nvme.whole(), "/dev/nvme0n1"); assert_eq!(nvme.partition(3), "/dev/nvme0n1p3"); - assert_eq!(nvme.part_sysfs(1), "/sys/class/block/nvme0n1/nvme0n1p1"); + } + + /// **A whole disk's image is whole sectors with `EFI PART` in its final + /// one**: a ragged or empty file, and one whose signature is only in the + /// primary header, the false pass a healthy front hides, are refused. + #[test] + fn a_whole_disk_is_whole_sectors_ending_in_the_backup_header() { + let dir = toyos_tmpdir::TempDir::new("whole-disk"); + let write = |name: &str, bytes: &[u8]| { + let at = dir.join(name); + std::fs::write(&at, bytes).expect("write"); + at + }; + let refused = |name: &str, bytes: &[u8]| whole_disk(&write(name, bytes)).map(|(_, n)| n).unwrap_err(); + assert_eq!(refused("ragged.img", &[0u8; 1000]), Unfit::Sectors { bytes: 1000 }); + assert_eq!(refused("empty.img", &[]), Unfit::Sectors { bytes: 0 }); + assert_eq!( + refused("headless.img", &[0u8; 1024]), + Unfit::BackupHeader { at: 512, saw: "\0\0\0\0\0\0\0\0".to_string() } + ); + let mut front = vec![0u8; 1536]; + front[512..520].copy_from_slice(b"EFI PART"); + assert!(matches!(refused("front-only.img", &front), Unfit::BackupHeader { at: 1024, .. })); + let mut whole = front.clone(); + whole[1024..1032].copy_from_slice(b"EFI PART"); + assert_eq!(whole_disk(&write("whole.img", &whole)).map(|(_, n)| n), Ok(1536)); } } diff --git a/issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md b/issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md index 4672fd48ca0..38189bb35df 100644 --- a/issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md +++ b/issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md @@ -25,13 +25,13 @@ much to the stick while a tone plays is the same stimulus. Since the small-kernel track's step 10 the stick is usbd's: the interrupts are its claim's and the waits its thread's, and the kernel's two-second transport -break is gone. Whether the starvation remains is unmeasured, and on a bench -`tone_beside_a_stick_write` now writes the NVMe the bench boots off, so no -bench row writes a stick beside the tone +break is gone. Whether the starvation remains is unmeasured. A bench's `/log` +is on its NVMe now, so `tone_beside_a_log_write` writes no stick and is no +evidence for this issue (`issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md`). ## Exit condition On the T14, a `METAL` row plays `hda_tone`'s tone while a program writes a -mebibyte to `/log`, and soundserver reports `underruns=0` over the tone's window -across repeated boots. Owner: the metal suite (`tests/toyos.rs`'s `METAL`). +mebibyte to a USB stick through usbd, and soundserver reports `underruns=0` +over the tone's window across repeated boots. Owner: the metal suite (`tests/toyos.rs`'s `METAL`). diff --git a/issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md b/issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md index 97312cbe7f9..9cd17acbcc9 100644 --- a/issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md +++ b/issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md @@ -20,21 +20,18 @@ the boot stick lost it: leaves a USB stick its next host can enumerate, `boot.usbload.stick_secs` and the guard `a-cut-bulk-only-command-does-not-brick-the-benchs-stick.md` names, is judged by no row. -- `tone_beside_a_stick_write` and `mask_windows` under `stick_write` write - `/log`, which is the NVMe now: the USB load +- `tone_beside_a_stick_write` and `mask_windows` under `stick_write` are + `tone_beside_a_log_write` and `mask_windows` under `log_write`: they write + `/log`, the NVMe now, so the USB load `a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md` asks about is - not reproduced on a bench. Their jobs and the records' - `boot.windowscase.stick_write.*` still say "stick"; those two rows of each - bench's record were taken under the USB load and are re-recorded once - deleted. + not reproduced on a bench. The windows' records are + `boot.windowscase.log_write.*`, recorded afresh under the NVMe load; the + `stick_write` rows of each bench's record were taken under the USB load. - `bench_data_designated` no longer reads the T14 stick's short last sector. - `amdvi_translates_the_boot_controller` and `amdvi_remaps_the_boot_controller` judge the NVMe controller where they judged the stick's xHCI. QEMU's machines still boot off a stick (`the_log_lands_on_the_boot_stick`). -The test images' `pci:0c:03:30@boot` entry names QEMU's NEC controller, which -the entry before it already names, and nothing on a bench: whether `@boot` -keeps a user is the owner's to say. **Exit**: a metal row drives a USB stick that is not the boot disk — the scratch disk (`issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md`) diff --git a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md index df56f2437ac..06b0e24b399 100644 --- a/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md +++ b/issues/the-guest-suite-runs-only-what-no-cheaper-tier-reaches.md @@ -139,7 +139,8 @@ Exit: as stage A, with what is still owed: under it by the boot deadline (#901 section 9), which no bench row writes since the benches boot off their NVMe (`issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md`). -- metal: `tone_beside_a_stick_write` +- metal: a tone beside a stick write, which no bench row makes since the + benches boot off their NVMe (`tone_beside_a_log_write` writes the NVMe) (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`). - metal `late_storage_connect`: a disk that connects after bring-up is served, and `/boot` and `/log` mount off it — on the stub, a device plugged diff --git a/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md b/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md index 431976ce185..12dad33ef7f 100644 --- a/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md +++ b/issues/the-kernel-is-small-interrupts-post-and-threads-wait.md @@ -25,9 +25,10 @@ kernel's part of a restart, and named module tests hold each program's where firmware put it, handed over in 4 KiB pages with its MSI-X table's page, under the owner's Q2 ruling. The orchestrator ruled on #900 that this route serves usbd's claim (step 10). -4. **Step 10's metal rows still owed** (step 10, below): the rewritten - `usbload` row, `tone_beside_a_stick_write` and `mask_windows` under a stick - write. +4. **Step 10's metal rows still owed** (step 10, below): a reset, a tone and + `mask_windows` under a write to a stick through usbd, which no bench row + makes since the benches boot off their NVMe + (`issues/no-bench-row-drives-usb-storage-since-the-benches-boot-off-nvme.md`). 5. **Stage 6 step 5, then stage 5's windows.** Beside that order, each waiting on something outside it: @@ -222,12 +223,13 @@ Beside that order, each waiting on something outside it: metal rows that asked for that machine (`iommu-no-remap`) are deleted, and the refusal is QEMU's to hold, by `usbd_refused_without_amdvi_remapping` and `amdvi_firmware_left`. - **Left**: the metal rows, which have not run: #901 section 9's three - that judge with a userland load, - `the_disk_survives_a_reset_under_load` (a deadline reset under - `stick_load`), `tone_beside_a_stick_write` + **Left**: #901 section 9's three metal judgements under a userland + load on a stick — a deadline reset, a tone (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`'s - exit) and `mask_windows` under `stick_write`. #971's first metal boots + exit) and `mask_windows` — which no bench row makes since the benches + boot off their NVMe; `the_disk_survives_a_reset_under_load`, + `tone_beside_a_log_write` and `mask_windows` under `log_write` judge the + same under a write to the NVMe. #971's first metal boots came back with no `logkeeper` file on the stick, which every metal row reads; QEMU's stick boot (`the_log_lands_on_the_boot_stick`) has it. And the client test diff --git a/issues/the-loader-does-only-what-must-precede-the-handover.md b/issues/the-loader-does-only-what-must-precede-the-handover.md index a3a3e382133..bcbc3824d5f 100644 --- a/issues/the-loader-does-only-what-must-precede-the-handover.md +++ b/issues/the-loader-does-only-what-must-precede-the-handover.md @@ -335,7 +335,7 @@ Each stage lands on its own, in this order. - The tested pass's `loader.log` is kept as `loader-previous.log` by a rename (`SetInfo`), not by #539's copy. - It runs over the cable that - `issues/the-t14-answers-only-through-a-usb-stick.md` owns, and + `issues/the-t14-answers-only-through-what-ubuntu-reads-off-its-disk.md` owns, and waits on `issues/the-t14-hung-after-rebooting-with-its-i219-faulted.md`, `issues/a-fatal-event-stands-down-both-bounds-and-may-leave-nothing-to-end-the-machine.md`, diff --git a/issues/the-t14-answers-only-through-a-usb-stick.md b/issues/the-t14-answers-only-through-what-ubuntu-reads-off-its-disk.md similarity index 100% rename from issues/the-t14-answers-only-through-a-usb-stick.md rename to issues/the-t14-answers-only-through-what-ubuntu-reads-off-its-disk.md diff --git a/issues/toyos-manages-its-own-partitions.md b/issues/toyos-manages-its-own-partitions.md index 48886d95e02..4a2f65f0619 100644 --- a/issues/toyos-manages-its-own-partitions.md +++ b/issues/toyos-manages-its-own-partitions.md @@ -38,14 +38,13 @@ a signed test image's job holds it. (`--active`), writes sector-granular at the disk's true last LBA, flushes between the two copies, and reads both back. Unrelated partitions are untouched by construction; another system's are reclaimed only when the - plan names them. Until then a bench's table is edited under its Ubuntu: - `toyos-metal` makes the install area (partitions 5–11 after the bench's - four) once with `sgdisk` and `partx`, and every flash gives partitions 7–11 - the GUIDs the image names with `sgdisk --partition-guid`. A bench's area - is this stage's first plan. + plan names them. 3. **Scratch disks.** `Init` with one aligned partition of `Guid::TOYOS_SCRATCH` replaces `toyos-metal --adopt-scratch` (`issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md`). 4. **The installer** (`issues/storage-is-layers-and-a-role-is-a-filesystem.md`, - stage 3) lays its layout through stage 2's library and authorization, and - `toyos-metal --install-internal`'s `sgdisk` and `partx` go with it. + stage 3) lays its layout through stage 2's library and authorization. + `toyos-metal`'s `sgdisk` and `partx` are not this track's to replace: they + run under a bench's Ubuntu before ToyOS boots, making the install area once + and giving partitions 7–11 the image's GUIDs on every flash, and they leave + with Ubuntu (`issues/the-build-runs-host-tools-outside-rust-and-qemu.md`). diff --git a/loader/src/efi/mod.rs b/loader/src/efi/mod.rs index 521f584307c..31df0ec8664 100644 --- a/loader/src/efi/mod.rs +++ b/loader/src/efi/mod.rs @@ -538,8 +538,8 @@ unsafe impl core::alloc::GlobalAlloc for Pool { } } -/// Set by the first panic, so a panic while saying one goes straight to the -/// reset. +/// Set by the first panic, so a panic while saying one goes straight to +/// `ResetSystem`. static PANICKED: AtomicBool = AtomicBool::new(false); /// Whether a panic resets the machine rather than powering it off diff --git a/tests/common/audio.rs b/tests/common/audio.rs index 33c6287f3c5..bd766d80e73 100644 --- a/tests/common/audio.rs +++ b/tests/common/audio.rs @@ -84,15 +84,15 @@ pub fn tone_on_metal(log: &Serial) -> Result<(), String> { } /// The job that plays `hda_tone`'s tone while it writes the log disk. -pub const TONE_BESIDE_STICK_WRITE: &str = "test_rs_tone_beside_stick_write"; +pub const TONE_BESIDE_LOG_WRITE: &str = "test_rs_tone_beside_log_write"; /// That job's tone played to its end through the HDA controller with no /// period left to soundserver, while at least a mebibyte of its lines reached /// the log disk: counted in the file read back off it, not in what the job says. -pub fn tone_beside_a_stick_write(log: &Serial) -> Result<(), String> { - on_an_output(log.text(), TONE_BESIDE_STICK_WRITE)?; - let window = job_window(log.text(), TONE_BESIDE_STICK_WRITE)?; - let reached = super::metal::bytes_of_lines(window, "tone_beside_stick_write: line "); +pub fn tone_beside_a_log_write(log: &Serial) -> Result<(), String> { + on_an_output(log.text(), TONE_BESIDE_LOG_WRITE)?; + let window = job_window(log.text(), TONE_BESIDE_LOG_WRITE)?; + let reached = super::metal::bytes_of_lines(window, "tone_beside_log_write: line "); if reached < 1 << 20 { return Err(format!("{reached} bytes of the job's lines reached the disk beside the tone, under a mebibyte")); } diff --git a/tests/guest/rust-tests/src/bin/log_write.rs b/tests/guest/rust-tests/src/bin/log_write.rs new file mode 100644 index 00000000000..cfc9381886a --- /dev/null +++ b/tests/guest/rust-tests/src/bin/log_write.rs @@ -0,0 +1,14 @@ +//! Eight mebibytes of lines to `/log` on the boot's log disk +//! ([`log_lines`]), then an end: the log-write load `mask_windows` reads the +//! windows under. + +#[path = "../log_lines.rs"] +mod log_lines; + +/// About eight mebibytes. +const LINES: u64 = (8 << 20) / log_lines::LINE_BYTES as u64; + +fn main() { + let said = log_lines::say("log_write", LINES, 2000, || true); + println!("log_write: {said} lines of {} bytes said", log_lines::LINE_BYTES); +} diff --git a/tests/guest/rust-tests/src/bin/stick_load.rs b/tests/guest/rust-tests/src/bin/stick_load.rs index d0ee48583ec..84dcdb7dffc 100644 --- a/tests/guest/rust-tests/src/bin/stick_load.rs +++ b/tests/guest/rust-tests/src/bin/stick_load.rs @@ -1,11 +1,11 @@ //! Writes the boot's log disk for ever — QEMU's stick, a bench's NVMe — so the //! boot deadline resets the machine with a write in flight: //! `the_disk_survives_a_reset_under_load`'s load. Lines to `/log` -//! ([`stick_lines`]), which logkeeper writes to that disk and syncs every +//! ([`log_lines`]), which logkeeper writes to that disk and syncs every //! round. -#[path = "../stick_lines.rs"] -mod stick_lines; +#[path = "../log_lines.rs"] +mod log_lines; /// About 180 KB a second: a write and a sync on the disk every logkeeper /// round, and a boot's log that stays inside logkeeper's retention (16 files @@ -14,5 +14,5 @@ mod stick_lines; const PER_SECOND: u64 = 200; fn main() { - stick_lines::say("stick_load", u64::MAX, PER_SECOND, || true); + log_lines::say("stick_load", u64::MAX, PER_SECOND, || true); } diff --git a/tests/guest/rust-tests/src/bin/stick_write.rs b/tests/guest/rust-tests/src/bin/stick_write.rs deleted file mode 100644 index 47b3cd91a0d..00000000000 --- a/tests/guest/rust-tests/src/bin/stick_write.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Eight mebibytes of lines to `/log` on the boot's log disk -//! ([`stick_lines`]), then an end: the log-write load `mask_windows` reads the -//! windows under. - -#[path = "../stick_lines.rs"] -mod stick_lines; - -/// About eight mebibytes. -const LINES: u64 = (8 << 20) / stick_lines::LINE_BYTES as u64; - -fn main() { - let said = stick_lines::say("stick_write", LINES, 2000, || true); - println!("stick_write: {said} lines of {} bytes said", stick_lines::LINE_BYTES); -} diff --git a/tests/guest/rust-tests/src/bin/tone_beside_stick_write.rs b/tests/guest/rust-tests/src/bin/tone_beside_log_write.rs similarity index 52% rename from tests/guest/rust-tests/src/bin/tone_beside_stick_write.rs rename to tests/guest/rust-tests/src/bin/tone_beside_log_write.rs index 04f5c3eb661..180bdba5716 100644 --- a/tests/guest/rust-tests/src/bin/tone_beside_stick_write.rs +++ b/tests/guest/rust-tests/src/bin/tone_beside_log_write.rs @@ -1,9 +1,7 @@ //! `hda_tone`'s tone, played while a thread writes lines to `/log` on the -//! boot's log disk ([`stick_lines`]): `tone_beside_a_stick_write`'s stimulus -//! (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`). -//! The judge counts the lines read back off the log, which have to be the -//! mebibyte the issue names; soundserver's counters over the job's window are -//! the verdict. +//! boot's log disk ([`log_lines`]): `tone_beside_a_log_write`'s stimulus. +//! The judge counts the lines read back off the log, which have to be a +//! mebibyte; soundserver's counters over the job's window are the verdict. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -13,8 +11,8 @@ use cpal::traits::{DeviceTrait, HostTrait}; #[path = "../tone.rs"] mod tone; -#[path = "../stick_lines.rs"] -mod stick_lines; +#[path = "../log_lines.rs"] +mod log_lines; fn main() { let device = cpal::default_host().default_output_device().expect("no audio output device"); @@ -22,11 +20,11 @@ fn main() { let playing = Arc::new(AtomicBool::new(true)); let writer = { let playing = playing.clone(); - std::thread::spawn(move || stick_lines::say("tone_beside_stick_write", u64::MAX, 2000, || playing.load(Ordering::Relaxed))) + std::thread::spawn(move || log_lines::say("tone_beside_log_write", u64::MAX, 2000, || playing.load(Ordering::Relaxed))) }; tone::play(&device, config.into()); playing.store(false, Ordering::Relaxed); - let said = writer.join().expect("tone_beside_stick_write: the writer panicked"); + let said = writer.join().expect("tone_beside_log_write: the writer panicked"); println!("tone done"); - println!("tone_beside_stick_write: {said} lines of {} bytes said beside the tone", stick_lines::LINE_BYTES); + println!("tone_beside_log_write: {said} lines of {} bytes said beside the tone", log_lines::LINE_BYTES); } diff --git a/tests/guest/rust-tests/src/stick_lines.rs b/tests/guest/rust-tests/src/log_lines.rs similarity index 100% rename from tests/guest/rust-tests/src/stick_lines.rs rename to tests/guest/rust-tests/src/log_lines.rs diff --git a/tests/metal/Cargo.toml b/tests/metal/Cargo.toml index fd61b6c3406..ddeec840836 100644 --- a/tests/metal/Cargo.toml +++ b/tests/metal/Cargo.toml @@ -11,7 +11,7 @@ serde = { version = "1", features = ["derive"] } toml = "0.8" # The build system's reading of an image, a boot log and a command line. toyos-build = { path = "../.." } -# The outside judge of the log partition the boot left on the stick. +# The outside judge of the log volume the boot left on the bench's disk. toyos-fat32-check = { path = "../../lib/fat32/check" } toyos-gpt = { path = "../../lib/gpt" } toyos-quiesce = { path = "../../lib/quiesce" } diff --git a/tests/metal/src/lib.rs b/tests/metal/src/lib.rs index c0076803a8a..6f343eb2165 100644 --- a/tests/metal/src/lib.rs +++ b/tests/metal/src/lib.rs @@ -11,9 +11,11 @@ //! **The bench's own partitions are never written.** The machine must answer //! as a recorded bench by its SMBIOS, the disk must be one //! [`internal::recognise`] knows, and every write names one of the partitions -//! after the bench's four. The firmware's order keeps Ubuntu first: ToyOS's -//! entry is never in it, so a ToyOS boot that fails or is reset hands the next -//! boot to Ubuntu. +//! after the bench's four, but for the partition table itself, which `sgdisk` +//! rewrites whole on every flash with entries 1–4 as they were. The +//! firmware's order keeps Ubuntu first: ToyOS's entry is never in it, which +//! every boot checks before and after, so a ToyOS boot that fails or is reset +//! hands the next boot to Ubuntu. //! //! Beside the loop: [`testargs`], the suite's command line, which carries the //! metal profile's flags; [`image`], the config a metal boot derives; and @@ -150,8 +152,11 @@ pub enum Refusal { Landed { what: String, want: u64, got: u64 }, /// `efibootmgr` named no four-hex-digit boot entry. BootEntry(String), - /// Creating the entry moved the firmware's boot order. - BootOrder { before: String, now: String }, + /// The firmware's boot order moved `during` something this loop did. + BootOrder { during: &'static str, before: String, now: String }, + /// The entry about to be booted once is in the firmware's boot order, so + /// a failed ToyOS boot would come back to ToyOS rather than Ubuntu. + Ordered { entry: String, order: String }, Sudo(String), /// A lid key that no longer reads `ignore`, which is what keeps the machine up. Lid { key: &'static str, got: String }, @@ -258,10 +263,15 @@ impl fmt::Display for Refusal { Self::BootEntry(saw) => { write!(f, "efibootmgr named no four-hex-digit boot entry: {saw:?}") } - Self::BootOrder { before, now } => write!( + Self::BootOrder { during, before, now } => write!( f, - "creating the boot entry moved BootOrder from {before:?} to {now:?}: this loop \ - buys one boot with --bootnext and leaves the order alone" + "{during} moved BootOrder from {before:?} to {now:?}: this loop buys one boot with \ + --bootnext and leaves the order alone" + ), + Self::Ordered { entry, order } => write!( + f, + "Boot{entry} is in BootOrder {order:?}, so a ToyOS boot that fails would come back to \ + ToyOS: the firmware put it there, and it is taken out by hand" ), Self::Sudo(saw) => { write!(f, "`sudo -n` on the machine did not answer: {saw}. The bench account needs passwordless sudo") @@ -929,7 +939,7 @@ fn entry_after_create( ) -> Result { let (was, now) = (boot_order(before), boot_order(after)); if was != now { - return Err(Refusal::BootOrder { before: was, now }); + return Err(Refusal::BootOrder { during: "creating the boot entry", before: was, now }); } entries_labelled(after, label) .into_iter() @@ -950,6 +960,15 @@ fn boot_order(listing: &str) -> String { .to_string() } +/// Refused where `entry` is one of `order`'s, out of a listing's `BootOrder:`. +fn unordered(listing: &str, entry: &str) -> Result<(), Refusal> { + let order = boot_order(listing); + match order.split(',').any(|id| id.trim().eq_ignore_ascii_case(entry)) { + true => Err(Refusal::Ordered { entry: entry.to_string(), order }), + false => Ok(()), + } +} + /// systemd's *effective* logind policy, out of `systemd-analyze cat-config`: /// the last assignment of a key wins, and `Key = value` is legal spacing. fn lid_policy(text: &str) -> Result<(), Refusal> { @@ -1258,10 +1277,11 @@ impl Driver { /// first where it is absent, and answer the boot entry for it — `None` on /// a dry run that stopped at the area it did not create. /// - /// **Every write names an [`Owned`] partition**, and runs only once - /// [`internal::recognise`] has said what the disk holds: the area's - /// partitions are given the GUIDs the image's contents name before their - /// bytes are written, and the boot entry is made or refreshed last. + /// **Every write names an [`Owned`] partition or is the partition table**, + /// and runs only once [`internal::recognise`] has said what the disk holds: + /// the table is rewritten whole to give the area's partitions the GUIDs the + /// image's contents name before their bytes are written, and the boot entry + /// is made or refreshed last. fn install_internal(&self, image: &internal::Install) -> Result, Refusal> { let node = self.target.internal; if internal::recognise(&self.internal_disk()?).map_err(Refusal::Layout)? == internal::Area::Absent { @@ -1751,6 +1771,8 @@ pub fn run(args: &Args) -> Result, Refusal> { let Some(installed) = driver.install_internal(&image)? else { return Ok(None) }; let flash = began.elapsed().as_secs_f64(); let began = std::time::Instant::now(); + let before = driver.ssh("listing boot entries", EFIBOOTMGR)?; + unordered(&before, &installed.entry)?; driver.as_root("setting bootnext", Job::BootNext(installed.entry), None)?; driver.as_root("rebooting", Job::Reboot, None)?; let entry = began.elapsed().as_secs_f64(); @@ -1789,7 +1811,20 @@ pub fn run(args: &Args) -> Result, Refusal> { let phases = Phases { preamble, flash, dd_secs: installed.dd, entry, down, read_log, raw_log }; println!("phases: {phases}"); let boot = BootFile { back, machine, phases }.text(); - judge_and_write_readback(&armed, &loader, &log_text, args.readback.as_deref(), &boot).map(Some) + let verdict = judge_and_write_readback(&armed, &loader, &log_text, args.readback.as_deref(), &boot); + // After the readback, so a boot that moved the order is still judged; the + // order's refusal is the louder answer, since the next failure loops. + order_kept(&before, &driver.ssh("listing boot entries", EFIBOOTMGR)?)?; + verdict.map(Some) +} + +/// Refused where the boot between `before` and `after` moved `BootOrder`. +fn order_kept(before: &str, after: &str) -> Result<(), Refusal> { + let (was, now) = (boot_order(before), boot_order(after)); + match was == now { + true => Ok(()), + false => Err(Refusal::BootOrder { during: "the boot", before: was, now }), + } } /// An entry on the internal ESP that no firmware can boot, as @@ -1860,6 +1895,8 @@ fn fall_through(target: &Target, dry_run: bool, how: Unbootable) -> Result<(), R } let create = Job::Create { label: UNBOOTABLE_LABEL, loader: how.loader() }; let entry = driver.boot_entry(UNBOOTABLE_LABEL, create, esp)?; + let before = driver.ssh("listing boot entries", EFIBOOTMGR)?; + unordered(&before, &entry)?; driver.as_root("setting bootnext", Job::BootNext(entry.clone()), None)?; driver.as_root("rebooting", Job::Reboot, None)?; if dry_run { @@ -1876,6 +1913,11 @@ fn fall_through(target: &Target, dry_run: bool, how: Unbootable) -> Result<(), R if let Some(next) = listing.lines().find_map(|l| l.strip_prefix("BootNext:")) { return Err(Refusal::FellThrough(format!("BootNext still reads {}", next.trim()))); } + let first = boot_order(&listing).split(',').next().unwrap_or_default().trim().to_string(); + if current != Some(first.as_str()) { + return Err(Refusal::FellThrough(format!("BootCurrent is {current:?} and BootOrder's first entry {first:?}"))); + } + order_kept(&before, &listing)?; deleted?; println!( "the firmware fell through past Boot{entry} ({}) to Boot{} and answered ssh after {back} s", @@ -3006,6 +3048,7 @@ mod tests { assert_eq!( entry_after_create(before, &ordered, "ToyOS", guid), Err(Refusal::BootOrder { + during: "creating the boot entry", before: "0001,001D".to_string(), now: "0002,0001,001D".to_string(), }) @@ -3023,10 +3066,51 @@ mod tests { assert_eq!(boot_order("BootCurrent: 0001\n"), ""); assert_eq!(boot_order(""), ""); - assert!(!Refusal::BootOrder { before: String::new(), now: "0002".to_string() } + assert!(!Refusal::BootOrder { during: "the boot", before: String::new(), now: "0002".to_string() } .about_the_boot()); } + /// **An entry in the boot order is never bought with `BootNext`, and a + /// boot that moved the order is refused**: either is a bench whose next + /// failed ToyOS boot comes back to ToyOS and not to Ubuntu. + #[test] + fn the_boot_order_holds_no_toyos_entry_and_no_boot_moves_it() { + let listing = "BootCurrent: 0003\nBootOrder: 0003,2001,2002\n"; + assert_eq!(unordered(listing, "0004"), Ok(())); + assert_eq!( + unordered(listing, "2001"), + Err(Refusal::Ordered { entry: "2001".to_string(), order: "0003,2001,2002".to_string() }) + ); + assert!(unordered("BootOrder: 0003,000a\n", "000A").is_err(), "the case efibootmgr prints is not the guard's"); + assert_eq!(unordered("BootCurrent: 0003\n", "0003"), Ok(())); + assert_eq!(order_kept(listing, listing), Ok(())); + let moved = listing.replace("BootOrder: 0003,", "BootOrder: 0004,0003,"); + assert_eq!( + order_kept(listing, &moved), + Err(Refusal::BootOrder { + during: "the boot", + before: "0003,2001,2002".to_string(), + now: "0004,0003,2001,2002".to_string(), + }) + ); + } + + /// **A disk's identity is the four `sysfs` lines [`Sd::identity`] asks + /// for**, read as they come, and refused otherwise: adoption's refusal to + /// erase a fixed disk rests on `removable`. + #[test] + fn a_disks_identity_reads_four_lines_or_is_refused() { + let stick = Identity::parse("60062500\nSanDisk \nUltra \n1\n").unwrap(); + assert_eq!( + stick, + Identity { sectors: 60_062_500, vendor: "SanDisk".to_string(), model: "Ultra".to_string(), removable: true } + ); + assert!(!Identity::parse("500118192\nATA \nSSD \n0\n").unwrap().removable); + for (answer, field) in [("60062500\nSanDisk \n", "the whole query"), ("many\nSanDisk \nUltra\n1\n", "size")] { + assert!(matches!(Identity::parse(answer), Err(Refusal::Identity { field: f, .. }) if f == field), "{answer:?}"); + } + } + #[test] fn a_boot_entry_is_matched_on_the_partition_its_path_names() { let listing = "BootCurrent: 0001\nBootOrder: 0001,001D\n\ diff --git a/tests/toyos.rs b/tests/toyos.rs index 0c48ea46c6d..cbffe2ca0b8 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -72,7 +72,7 @@ const RUST_SKIP: &[&str] = &[ "stick_load", // The log-write load `mask_windows` reads windows under, on the boot // whose kernel reports them. - "stick_write", + "log_write", // It opens a window, which needs a compositor the shared boot does not // run: `a_window_is_drawn_at_the_scale_and_relaid_at_the_next` runs it on // tests/images/scalecase.toml, and the `gop_edid_scale` metal row on @@ -185,7 +185,7 @@ const RUST_SKIP: &[&str] = &[ // on QEMU's. "audio_tone", "audio_tone_resampled", - "tone_beside_stick_write", + "tone_beside_log_write", "volume_step", // It hands the ACPI claim to a server of its own and kills it, which needs // a boot that starts none: the `acpi_server_death` metal row runs it on @@ -803,17 +803,15 @@ const METAL: &[(&str, metal::Metal)] = &[ ), ( // `hda_tone`'s tone while a thread of its process writes the log - // disk: no period soundserver had to fill - // (`issues/a-megabyte-written-to-the-stick-starves-a-tone-beside-it.md`'s - // exit). It is the kernel's: one claim's interrupts and its holder's - // threads never starve another holder's deadline. The T14 and the AMD - // bench. - "tone_beside_a_stick_write", + // disk, the bench's NVMe: no period soundserver had to fill. It is the + // kernel's: one claim's interrupts and its holder's threads never + // starve another holder's deadline. The T14 and the AMD bench. + "tone_beside_a_log_write", metal::Metal { arms: TESTCASES, judge: |b| { - b[0].job_passed(audio::TONE_BESIDE_STICK_WRITE)?; - audio::tone_beside_a_stick_write(&b[0].log()) + b[0].job_passed(audio::TONE_BESIDE_LOG_WRITE)?; + audio::tone_beside_a_log_write(&b[0].log()) }, }, ), @@ -1629,7 +1627,7 @@ const TESTCASES: &[metal::Arm] = &[metal::once( "test_rs_wake_onto_busy", "test_rs_audio_tone", "test_rs_audio_tone_resampled", - audio::TONE_BESIDE_STICK_WRITE, + audio::TONE_BESIDE_LOG_WRITE, "test_rs_volume_step", "test_rs_syscall_cost", "test_rs_spawn_cost", @@ -1778,7 +1776,7 @@ const WINDOWSCASE: &[metal::Arm] = &[metal::Arm { "windowscase", "tests/images/testcases.toml", &[], - &["test_rs_idle_span", "pwd", WINDOWS_LOAD, STICK_WRITE], + &["test_rs_idle_span", "pwd", WINDOWS_LOAD, LOG_WRITE], ) }]; @@ -1788,7 +1786,7 @@ const WINDOWS_LOAD: &str = "test_rs_ring_park_herd"; /// The log-write load it reads them under too (#901 §9): 8 MiB through the /// log disk's driver, whose interrupts and threads are a claim's and a /// process's. -const STICK_WRITE: &str = "test_rs_stick_write"; +const LOG_WRITE: &str = "test_rs_log_write"; /// The head of the kernel's record of `job`'s exit. fn exited(job: &str) -> String { @@ -5873,15 +5871,15 @@ fn windows_on_metal(boot: &metal::Readback) -> Result<(), String> { ); // Under the log write, each CPU's longest windows are judged against // the bench's record (`timings::ceiling`), the base they were first read at. - boot.job_passed(STICK_WRITE)?; - let reached = metal::bytes_of_lines(boot.log().text(), "stick_write: line "); + boot.job_passed(LOG_WRITE)?; + let reached = metal::bytes_of_lines(boot.log().text(), "log_write: line "); if reached < 1 << 20 { return Err(format!("{reached} bytes of the log write's lines reached the disk, under a mebibyte")); } - let under = common::irqcensus::windows_under(kernel.text(), cpus, &exited(STICK_WRITE))?; + let under = common::irqcensus::windows_under(kernel.text(), cpus, &exited(LOG_WRITE))?; eprintln!(" [windows] log write irqs_off_ns={} preempt_off_ns={}", under.load.0, under.load.1); - boot.measured("boot.windowscase.stick_write.irqs_off_ns", under.load.0)?; - boot.measured("boot.windowscase.stick_write.preempt_off_ns", under.load.1) + boot.measured("boot.windowscase.log_write.irqs_off_ns", under.load.0)?; + boot.measured("boot.windowscase.log_write.preempt_off_ns", under.load.1) } fn pci_cap_selftest(log: &str) -> Result<(), String> {