From 867006978d2fbca98275a3f07c99f15b11cb94dc Mon Sep 17 00:00:00 2001 From: James Braid Date: Wed, 11 Nov 2026 23:06:50 -0700 Subject: [PATCH 1/2] ip32: save the PROM's flash on iris-ci rtc-save `iris-ci rtc-save` answered "not supported here" on an O2, so a scripted run that seeds the PROM's environment with setenv could not ask for the flash to be written, as it does on the Indy and the Octane. The O2 keeps that environment in flash, and saves it only when the guest writes it and when the machine stops. Implement the call on the O2: it writes the flash to the file `nvram` names, and the RTC's RAM beside it. A path argument is refused, since the flash has one home. The test builds an O2, saves, and checks the file is the size of the part. Found while teaching `iris-ci boot-test` the O2: its seeding pass sent rtc-save after the setenv lines and stopped on the error. --- src/ip32.rs | 5 +++++ src/ip32_machine.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/ip32.rs b/src/ip32.rs index 6dd5c19d8..1a303d75f 100644 --- a/src/ip32.rs +++ b/src/ip32.rs @@ -663,6 +663,11 @@ impl Ip32Bus { self.crime.regs.interrupting() } + /// The file the flash is kept in, if one was named. + pub fn flash_path(&self) -> Option<&std::path::Path> { + self.flash_file.as_deref() + } + /// Write the flash out, if a file was named for it. pub fn save_flash(&self) { let Some(path) = &self.flash_file else { return }; diff --git a/src/ip32_machine.rs b/src/ip32_machine.rs index 795a46255..d498832e2 100644 --- a/src/ip32_machine.rs +++ b/src/ip32_machine.rs @@ -834,6 +834,17 @@ impl Workstation for O2Machine { self.controller_up.store(true, Ordering::SeqCst); } fn monitor_addr(&self) -> Option { self.monitor.bound_addr() } + /// The PROM's flash, where `setenv` keeps its settings, and the RTC's RAM, + /// written out now. The flash goes only to the file `nvram` names. + fn save_nvram(&self, path: Option<&str>) -> Result { + if path.is_some() { + return Err("the O2 saves its flash only to the file `nvram` names".into()); + } + let file = self.bus.flash_path().ok_or("no nvram file configured")?; + self.bus.save_flash(); + self.bus.save_rtc(); + Ok(file.display().to_string()) + } fn lifecycle(&self) -> crate::machine_state::GuestState { self.lifecycle } fn set_lifecycle(&mut self, state: crate::machine_state::GuestState) { self.lifecycle = state; } @@ -1292,6 +1303,22 @@ mod drop_tests { let _ = std::fs::remove_dir_all(&dir); } + /// `iris-ci rtc-save` on an O2 writes the PROM's flash to the `nvram` + /// file now, rather than failing as a machine with no NVRAM does. + #[test] + fn rtc_save_writes_the_flash_to_the_nvram_file() { + let dir = scratch("o2-save-nvram"); + let cfg = config(&dir, MachineProfile::O2Ip32, GraphicsBoard::Newport); + let nvram = std::path::PathBuf::from(&cfg.nvram); + let machine = big_stack(move || O2Machine::new(&cfg).expect("O2")); + assert!(!nvram.exists()); + assert_eq!(Workstation::save_nvram(&machine, None), Ok(nvram.display().to_string())); + assert_eq!(std::fs::metadata(&nvram).unwrap().len(), PROM_SIZE as u64); + assert!(Workstation::save_nvram(&machine, Some("elsewhere.bin")).is_err()); + drop(machine); + let _ = std::fs::remove_dir_all(&dir); + } + /// `[nvme]` puts a card in the "PCI option" slot and records its disk; /// the bus's disk flush reaches its namespace. #[test] From 53ed8b6c3ae44843bd4cca253f33ea7f76408993 Mon Sep 17 00:00:00 2001 From: James Braid Date: Wed, 11 Nov 2026 23:06:52 -0700 Subject: [PATCH 2/2] ci: boot IRIX 6.5.30 on an O2 in the nightly boot test The nightly boot test covered IRIX 5.3 and 6.5.22, both on an Indy, and nothing booted 6.5.30. No 6.5.30 Indy disk is published, but the dist host has a stock 6.5.30 O2 install, so teach boot-test to boot another machine. `iris-ci boot-test` takes `--machine`, a profile name, and `--prom`. The Indy stays the default. The O2 has no PROM built in, so it needs one, and the config the test writes now names the machine and the PROM. The O2's Command Monitor prompts with `> ` where the Indy's prints `>> `, so the prompt is chosen by machine; waiting for `> ` everywhere would stop early on any `> ` inside a listing. The disk, NVRAM variables and `console d` are the same as the Indy's, so tests/irix/boot-irix6530-o2.toml repeats boot-irix65.toml, serial only for want of a golden screenshot. The `irix` list gets a third image, the O2's, with its machine, disk and PROM, each pinned by URL and sha256. Like the other images it boots on every host platform. The Indy images leave the machine and PROM fields empty. The job downloads the PROM and checks its hash when the image names one. The PROM is rev 4.18, the IRIX installer's ip32prom.image (sha256 11230c35...). The O2 has no PROM on the dist host yet: the entry points at prom/ip32prom-4.18.image there, which has to be uploaded before the nightly can pass. Run locally with iris and iris-ci built from this tree on the pinned disk (sha256 checked after download) and that PROM: all five checks passed and `login:` arrived 170 s after start, 181 s for the whole test, on a loaded 16-thread host. The runner-side steps did not run here: actionlint is clean, but the job itself needs GitHub. --- .github/workflows/build.yml | 19 +++++- TESTING.md | 7 +- clanker/testing/ci-unattended-irix-boot.md | 7 ++ src/iris_ci_boot.rs | 76 ++++++++++++++++++++-- tests/irix/boot-irix6530-o2.toml | 28 ++++++++ 5 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 tests/irix/boot-irix6530-o2.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfc14d8c5..abcef5080 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,8 @@ # The boot-test job then boots every IRIX image on the Linux, macOS and Windows # CLI builds: a platform x irix matrix, each image listed once and pinned by URL # and sha256. IRIX 5.3 boots once on the serial console and once on the -# graphics console; IRIX 6.5.22 on the serial console only. +# graphics console; IRIX 6.5.22 and 6.5.30 on the serial console only. The +# 6.5.30 image is an O2, which has no PROM built in, so its entry pins one too. # # Runner images are pinned: a glibc-linked Linux binary needs at least the # build host's glibc, so ubuntu-24.04 decides which distros our packages run on, @@ -405,6 +406,10 @@ jobs: # Stock IRIX 6.5.22 Indy install, 865 MB. Serial console only: there is # no golden screenshot of this disk. - { version: "6.5.22", url: "https://origami-dist.irix.fans/irix/6.5.22/indy-a22c2ba9.chd", sha256: a22c2ba9abc3bd6c61056304c965c39074c0ef00e06f7c9aabd1ec25a1865482, steps: tests/irix/boot-irix65.toml } + # Stock IRIX 6.5.30 O2 install, 879 MB, on the O2's rev 4.18 PROM (the + # IRIX installer's ip32prom.image). Serial console only, as above. The + # others leave machine and the PROM empty and boot an Indy. + - { version: "6.5.30", url: "https://origami-dist.irix.fans/irix/6.5.30/o2-34c49a43.chd", sha256: 34c49a43a794e1391f36eab2cb6e10115dbbbe39e5b4a2d0cc6512d47ff235fc, steps: tests/irix/boot-irix6530-o2.toml, machine: o2_ip32, prom_url: "https://origami-dist.irix.fans/prom/ip32prom-4.18.image", prom_sha256: 11230c353cb14fcc50ede286df228e169e32fc9e5a2206276ae29c259fafb23c } defaults: run: shell: bash @@ -449,8 +454,18 @@ jobs: fi ls -la boot-image + - name: Download the PROM image + if: ${{ matrix.irix.prom_url }} + run: | + curl -fL --retry 3 --retry-delay 10 -o boot-image/prom.image "${{ matrix.irix.prom_url }}" + got=$({ shasum -a 256 2>/dev/null || sha256sum; } < boot-image/prom.image | cut -d' ' -f1) + if [ "$got" != "${{ matrix.irix.prom_sha256 }}" ]; then + echo "::error::boot-image/prom.image sha256 $got, expected ${{ matrix.irix.prom_sha256 }}" + exit 1 + fi + - name: Boot IRIX ${{ matrix.irix.version }} - run: core/iris-ci boot-test boot-image/disk.chd ${{ matrix.irix.steps && format('--steps {0}', matrix.irix.steps) || '' }} --out boot-test --timeout "$BOOT_TIMEOUT" + run: core/iris-ci boot-test boot-image/disk.chd ${{ matrix.irix.steps && format('--steps {0}', matrix.irix.steps) || '' }} ${{ matrix.irix.machine && format('--machine {0} --prom boot-image/prom.image', matrix.irix.machine) || '' }} --out boot-test --timeout "$BOOT_TIMEOUT" # boot-test writes each check as a JUnit case, so a failed run is # annotated with the check that failed. diff --git a/TESTING.md b/TESTING.md index 7680abdf5..f9f8060c9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -326,10 +326,11 @@ space; a skipped test (`# SKIP`) reports `ok`. `build.yml` boots every IRIX image with each CLI build, on Linux, macOS and Windows: a platform by image matrix in which each image is listed once, from a -pinned, checksummed stock Indy disk. IRIX 5.3 boots on the serial and graphics +pinned, checksummed stock disk. IRIX 5.3 boots on the serial and graphics consoles ([clanker/testing/ci-irix53-disk.md](clanker/testing/ci-irix53-disk.md)); -IRIX 6.5.22 boots on the serial console only, for want of a golden screenshot. -6.5.30 has no Indy disk published, so it is not booted. +IRIX 6.5.22 and 6.5.30 boot on the serial console only, for want of a golden +screenshot. No 6.5.30 Indy disk is published, so 6.5.30 boots an O2 (`--machine +o2_ip32`) on its own PROM image, pinned in the matrix. `iris-ci boot-test` seeds NVRAM on a first headless run, boots with the console on serial and waits up to 15 minutes for `login:`, then does the same with the diff --git a/clanker/testing/ci-unattended-irix-boot.md b/clanker/testing/ci-unattended-irix-boot.md index a51ce620f..c2991994b 100644 --- a/clanker/testing/ci-unattended-irix-boot.md +++ b/clanker/testing/ci-unattended-irix-boot.md @@ -12,6 +12,13 @@ directory; `--steps` and `--golden` replace them. It starts its own iris, so run it as `iris-ci boot-test --out ` with `iris` beside `iris-ci` or named with `--iris`. +`--machine` names the profile the disk was installed on (`indy_ip24` unless set) +and `--prom` the PROM image for one with none built in. The O2 has none, and its +Command Monitor prompts with `> ` where the Indy's prints `>> `. Its settings +live in the PROM's flash, which `nvram` names and `rtc-save` writes. The stock +6.5.30 O2 disk (`tests/irix/boot-irix6530-o2.toml`) reached `login:` on serial +170 s after start, on a loaded 16-thread host. + ## A fresh NVRAM can't boot anything With no `nvram.bin` (or a bad checksum) the PROM prints `NVRAM checksum is diff --git a/src/iris_ci_boot.rs b/src/iris_ci_boot.rs index 5a04fe13b..ac6dedb9b 100644 --- a/src/iris_ci_boot.rs +++ b/src/iris_ci_boot.rs @@ -34,6 +34,13 @@ pub struct Args { /// A screenshot to match in place of the one each graphics boot names. #[arg(long)] golden: Option, + /// The machine the disk was installed on, by its `[machine] profile` name. + /// The disk is a stock install for one machine and boots only there. + #[arg(long, default_value = "indy_ip24")] + machine: String, + /// The PROM image, for a machine with no PROM built in (the O2). + #[arg(long)] + prom: Option, /// Seconds each boot may take to reach its login. #[arg(long, default_value_t = 900)] timeout: u64, @@ -96,6 +103,15 @@ pub fn run(args: Args) -> Result<()> { }); } let abs = |p: &Path| std::path::absolute(p).map_err(|e| local(p, e)); + let machine = iris::config::MachineProfile::from_name(&args.machine).map_err(Error::Local)?; + let prom = match (&args.prom, machine.has_embedded_prom()) { + (Some(p), _) => Some(abs(p)?), + (None, true) => None, + (None, false) => return Err(Error::Local(format!("{} has no PROM built in: name one with --prom", machine.name()))), + }; + if let Some(p) = prom.as_ref().filter(|p| !p.is_file()) { + return Err(Error::Local(format!("{}: no such PROM image", p.display()))); + } let disk = abs(&args.disk)?; if !disk.is_file() { return Err(Error::Local(format!("{}: no such disk image", disk.display()))); @@ -115,13 +131,14 @@ pub fn run(args: Args) -> Result<()> { } let socket = ci_socket(); - std::fs::write(out.join("boot-test.toml"), config(&disk, &socket)).map_err(|e| local(&out, e))?; + std::fs::write(out.join("boot-test.toml"), config(&disk, &socket, machine.name(), prom.as_deref())).map_err(|e| local(&out, e))?; let mut t = Test { out: out.clone(), iris, opts: Opts { socket: PathBuf::from(socket), json: false, quiet: true }, child: None, + prompt: monitor_prompt(machine), check: String::new(), cases: Vec::new(), failed: false, @@ -153,6 +170,13 @@ fn verdict(aborted: Step, failed: bool) -> Result<()> { } } +/// The Command Monitor's prompt. The O2's PROM prints a single `>`; the +/// others print `>>`. Waiting for `> ` on all of them would also stop at any +/// `> ` inside a listing that precedes the prompt. +fn monitor_prompt(machine: iris::config::MachineProfile) -> &'static str { + if machine == iris::config::MachineProfile::O2Ip32 { "> " } else { ">> " } +} + /// The CI socket: TCP on Windows, and elsewhere a Unix socket in the host's /// temporary directory, as iris's own default is. A path under the output /// directory could pass macOS's 104-byte limit on a Unix socket path. @@ -164,9 +188,10 @@ fn ci_socket() -> String { } } -/// The iris config of every run: the disk on SCSI 1 with an overlay, the -/// NVRAM and the serial mirror in the working directory. -fn config(disk: &Path, socket: &str) -> String { +/// The iris config of every run: the machine and its PROM, the disk on SCSI 1 +/// with an overlay, the NVRAM (the PROM's flash on an O2) and the serial mirror +/// in the working directory. +fn config(disk: &Path, socket: &str, machine: &str, prom: Option<&Path>) -> String { let mut scsi1 = toml::Table::new(); // Forward slashes in TOML work on every host, Windows included. scsi1.insert("path".into(), disk.to_string_lossy().replace('\\', "/").into()); @@ -174,7 +199,13 @@ fn config(disk: &Path, socket: &str) -> String { scsi1.insert("overlay".into(), true.into()); let mut scsi = toml::Table::new(); scsi.insert("1".into(), scsi1.into()); + let mut m = toml::Table::new(); + m.insert("profile".into(), machine.into()); let mut t = toml::Table::new(); + t.insert("machine".into(), m.into()); + if let Some(p) = prom { + t.insert("prom".into(), p.to_string_lossy().replace('\\', "/").into()); + } t.insert("no_audio".into(), true.into()); t.insert("nvram".into(), "nvram.bin".into()); t.insert("serial_log".into(), "serial.log".into()); @@ -223,6 +254,8 @@ struct Test { iris: PathBuf, opts: Opts, child: Option, + /// What the PROM's Command Monitor prints when it wants a line. + prompt: &'static str, /// The case a failure that ends the test is filed under. check: String, cases: Vec, @@ -345,7 +378,7 @@ impl Test { /// printed comes back. fn monitor(&mut self, line: &str) -> Step { let sent = send(&self.opts, "serial-send", json!({"data": format!("{line}\r")})); - match sent.is_ok().then(|| self.prom_wait(">> ", PROMPT_WAIT)).flatten() { + match sent.is_ok().then(|| self.prom_wait(self.prompt, PROMPT_WAIT)).flatten() { Some(text) => Ok(text), None => { self.dump(&self.out.join("phase-a.log")); @@ -372,7 +405,7 @@ impl Test { } // Enter Command Monitor. let _ = send(&self.opts, "serial-send", json!({"data": "5\r"})); - if self.prom_wait(">> ", PROMPT_WAIT).is_none() { + if self.prom_wait(self.prompt, PROMPT_WAIT).is_none() { self.dump(&self.out.join("phase-a.log")); return Err(self.abort("phase A: no Command Monitor prompt".into())); } @@ -555,6 +588,7 @@ mod tests { iris: PathBuf::new(), opts: Opts { socket: PathBuf::new(), json: false, quiet: true }, child: None, + prompt: ">> ", check: String::new(), cases: Vec::new(), failed: false, @@ -613,11 +647,26 @@ mod tests { #[test] fn the_config_names_the_disk_whatever_its_path_holds() { let disk = Path::new("C:\\a \"b\"\\irix.chd"); - let t: toml::Table = toml::from_str(&config(disk, "127.0.0.1:19851")).unwrap(); + let t: toml::Table = toml::from_str(&config(disk, "127.0.0.1:19851", "indy_ip24", None)).unwrap(); let scsi1 = &t["scsi"]["1"]; assert_eq!(scsi1["path"].as_str(), Some("C:/a \"b\"/irix.chd")); assert_eq!((scsi1["overlay"].as_bool(), t["ci"].as_bool()), (Some(true), Some(true))); assert_eq!(t["ci_socket"].as_str(), Some("127.0.0.1:19851")); + assert_eq!(t["machine"]["profile"].as_str(), Some("indy_ip24")); + assert!(!t.contains_key("prom")); + } + + #[test] + fn the_o2_prom_prompts_with_one_angle_bracket() { + use iris::config::MachineProfile::*; + assert_eq!((monitor_prompt(O2Ip32), monitor_prompt(IndyIp24)), ("> ", ">> ")); + } + + #[test] + fn an_o2_config_names_the_machine_and_its_prom() { + let t: toml::Table = toml::from_str(&config(Path::new("d.chd"), "s", "o2_ip32", Some(Path::new(r"C:\p\ip32prom.bin")))).unwrap(); + assert_eq!(t["machine"]["profile"].as_str(), Some("o2_ip32")); + assert_eq!(t["prom"].as_str(), Some("C:/p/ip32prom.bin")); } /// The built-in file: the same seeded variables as before, a serial and a @@ -642,6 +691,19 @@ mod tests { assert_eq!((label.as_str(), img.width > 0), ("indy-irix53.png", true)); } + /// The O2's file seeds the same variables as the Indy's and checks only + /// the serial login: no golden screenshot of that disk exists. + #[test] + fn the_o2_boot_is_the_serial_login_with_the_indys_seeding() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/irix"); + let o2 = guest_steps::load(&dir.join("boot-irix6530-o2.toml")).unwrap(); + let indy = guest_steps::parse(BOOT_TOML, "boot.toml").unwrap(); + let vars = |f: &guest_steps::StepFile| f.nvram.iter().map(|v| (v.name.clone(), v.value.clone(), v.guest)).collect::>(); + assert_eq!(vars(&o2), vars(&indy)); + let boots: Vec<_> = o2.boot.iter().map(|b| (b.console.as_str(), b.serial.as_deref(), b.screen.is_some())).collect(); + assert_eq!(boots, [("d", Some("login:"), false)]); + } + #[test] fn a_screenshot_is_found_beside_the_steps_file_or_built_in_and_not_otherwise() { let dir = std::env::temp_dir().join(format!("iris-ci-golden-{}", std::process::id())); diff --git a/tests/irix/boot-irix6530-o2.toml b/tests/irix/boot-irix6530-o2.toml new file mode 100644 index 000000000..31f279eb0 --- /dev/null +++ b/tests/irix/boot-irix6530-o2.toml @@ -0,0 +1,28 @@ +# Read by `iris-ci boot-test --steps` for the stock IRIX 6.5.30 O2 disk. The +# same seeding as boot.toml, on the O2's PROM; serial console only, because no +# golden screenshot of this disk exists. + +[[nvram]] +name = "SystemPartition" +value = "scsi(0)disk(1)rdisk(0)partition(8)" +guest = true + +[[nvram]] +name = "OSLoadPartition" +value = "scsi(0)disk(1)rdisk(0)partition(0)" +guest = true + +[[nvram]] +name = "OSLoadFilename" +value = "/unix" +guest = true + +[[nvram]] +name = "AutoLoad" +value = "Yes" + +[[boot]] +name = "serial console: login prompt" +console = "d" +quit = "iris quits when asked, serial boot" +serial = "login:"