From db86c513d3302d293513d9b3ba06a9192dfb16c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Fri, 29 May 2026 14:05:51 +0200 Subject: [PATCH 1/2] iris-ci: add cdrom-load to swap in an arbitrary CD image at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cdrom-eject only cycles through the configured changer list. Add `iris-ci cdrom-load ` to load an arbitrary host ISO into a SCSI CD-ROM and make it the active disc immediately (medium change / unit attention so mediad remounts), as if hand-swapping the disc. ScsiDevice::load_disc opens the path as a Direct (raw ISO) backend — matching eject_next — inserts it at the front of the changer list, and signals unit_attention. Wired through Wd33c93a::load_disc, the ci.rs "cdrom-load" dispatch/handler, and the iris-ci CdromLoad subcommand. Lets you install extra media (e.g. the dev/compiler discs) onto a running, booted IRIX without halting and rewriting the changer config. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ci.rs | 19 +++++++++++++++++++ src/iris_ci_main.rs | 4 ++++ src/scsi.rs | 24 ++++++++++++++++++++++++ src/wd33c93a.rs | 10 ++++++++++ 4 files changed, 57 insertions(+) diff --git a/src/ci.rs b/src/ci.rs index c8dd6f9..635a2d7 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -203,6 +203,7 @@ fn dispatch(server: &CiServer, req: &Request) -> Response { "push" => cmd_push(&req.args), "rtc-save" => cmd_rtc_save(server, &req.args), "cdrom-eject" => cmd_cdrom_eject(server, &req.args), + "cdrom-load" => cmd_cdrom_load(server, &req.args), other => Response::err(format!("unknown command: {}", other)), } } @@ -234,6 +235,24 @@ fn cmd_cdrom_eject(server: &CiServer, args: &Value) -> Response { } } +fn cmd_cdrom_load(server: &CiServer, args: &Value) -> Response { + let id = match args.get("id").and_then(|v| v.as_u64()) { + Some(n) => n as usize, + None => return Response::err("cdrom-load: missing 'id' arg"), + }; + let path = match args.get("path").and_then(|v| v.as_str()) { + Some(p) => p.to_string(), + None => return Response::err("cdrom-load: missing 'path' arg"), + }; + let result = server.with_machine(|m| { + m.hpc3().scsi().load_disc(id, path.clone()) + }); + match result { + Ok(loaded) => Response::data(serde_json::json!({ "id": id, "disc": loaded })), + Err(e) => Response::err(format!("cdrom-load: {}", e)), + } +} + fn cmd_quit() -> Response { // Schedule process exit after a brief delay so the response flushes. thread::spawn(|| { diff --git a/src/iris_ci_main.rs b/src/iris_ci_main.rs index 7187255..2626319 100644 --- a/src/iris_ci_main.rs +++ b/src/iris_ci_main.rs @@ -177,6 +177,9 @@ enum Cmd { /// Cycle the CD changer on a SCSI ID to the next disc. CdromEject { id: u64 }, + + /// Load an arbitrary CD image into a SCSI ID and make it the active disc. + CdromLoad { id: u64, path: String }, } #[derive(Subcommand, Debug)] @@ -285,6 +288,7 @@ fn dispatch(opts: &Opts, cmd: Cmd) -> Result<()> { simple(opts, "rtc-save", args, "nvram saved") } Cmd::CdromEject { id } => simple(opts, "cdrom-eject", json!({"id": id}), "ejected"), + Cmd::CdromLoad { id, path } => simple(opts, "cdrom-load", json!({"id": id, "path": path}), "loaded"), } } diff --git a/src/scsi.rs b/src/scsi.rs index fa41bb3..40953cf 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -274,6 +274,30 @@ impl ScsiDevice { &self.discs } + /// Load an explicit image path and make it the active disc immediately, + /// as if a hand swapped the disc in the drive. The path is inserted at the + /// front of the changer list (becomes index 0) and a medium-change is + /// signalled (unit attention) so the guest re-reads the TOC on its next + /// command. The image is opened as a raw ISO (`Direct` backend), matching + /// the changer's eject path. Err if this is not a CD-ROM or the file can't + /// be opened. + pub fn load_disc(&mut self, path: String) -> Result { + if !self.is_cdrom { + return Err("Not a CD-ROM device".to_string()); + } + let f = OpenOptions::new().read(true).open(&path) + .map_err(|e| format!("could not open {}: {}", path, e))?; + let size = f.metadata().map(|m| m.len()).unwrap_or(0); + self.backend = DiskBackend::Direct(f); + self.size = size; + // phys/logical block sizes persist across disc changes (controller + // settings), exactly as in eject_next. + self.filename = path.clone(); + self.unit_attention = true; // signal medium change on next command + self.discs.insert(0, path.clone()); + Ok(path) + } + /// Insert a new disc path at position 1 (next after current). /// Returns Err if this is not a CD-ROM or the path doesn't exist. pub fn add_disc(&mut self, path: String) -> Result<(), String> { diff --git a/src/wd33c93a.rs b/src/wd33c93a.rs index 3a1dacd..90ffdf5 100644 --- a/src/wd33c93a.rs +++ b/src/wd33c93a.rs @@ -365,6 +365,16 @@ impl Wd33c93a { } } + /// Load an arbitrary image path into a CD-ROM device and make it the active + /// disc immediately (medium change). Returns the loaded path. + pub fn load_disc(&self, id: usize, path: String) -> Result { + let mut state = self.state.lock(); + match state.devices.get_mut(id).and_then(|d| d.as_mut()) { + None => Err(format!("No device at SCSI ID {}", id)), + Some(dev) => dev.load_disc(path), + } + } + /// Add a disc path at position 1 (next-after-eject) for a CD-ROM device. pub fn add_disc(&self, id: usize, path: String) -> Result<(), String> { let mut state = self.state.lock(); From 3768c0bcb86129b81fd812064348458339b4fae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Sat, 30 May 2026 14:52:04 +0200 Subject: [PATCH 2/2] iris-ci: make get/put work under a /bin/sh guest shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_get/cmd_put hardcoded csh redirect syntax (>& /dev/null) and the $status rc-marker. When the guest's root login shell is /bin/sh (the klindert 6.5 disk), the csh redirect errors with "bad file unit number" and the rc-marker comes back empty — which broke `iris-ci get`/`put` entirely and is also why every `iris-ci run` reported "guest exit -1". Add detect_guest_shell() (probes $0, which both shells expand, with a sentinel so it doesn't itself depend on the rc-marker) and devnull_redirect(); get/put now select the matching redirect (2>&1 vs >&) and rc-marker ($? vs $status). Works for both sh-root (6.5 klindert) and csh-root (classic 5.3) guests, so the fast scratch-volume file pull works again (~1.8s for a 294 KB frame). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/iris_ci_main.rs | 66 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/src/iris_ci_main.rs b/src/iris_ci_main.rs index 2626319..5fbb897 100644 --- a/src/iris_ci_main.rs +++ b/src/iris_ci_main.rs @@ -567,6 +567,38 @@ fn run_capture(opts: &Opts, command: &str, shell: &str, timeout_ms: u64) -> Resu Ok((stdout, rc)) } +/// Detect the guest's interactive login shell so `get`/`put` use redirect + +/// rc-marker syntax that actually parses there. Historically IRIX root ran csh +/// (`>&`, `$status`); some installs (e.g. the klindert 6.5 disk) switch root to +/// `/bin/sh` (`2>&1`, `$?`), where the csh forms fail ("bad file unit number") +/// and the rc marker comes back empty. `$0` expands to the shell in both, so +/// probe it with a self-chosen sentinel (no rc marker, so it can't itself depend +/// on the shell). Defaults to csh on any ambiguity (the historical assumption). +fn detect_guest_shell(opts: &Opts) -> &'static str { + const SENTINEL: &str = "ZZSHELLZZ="; + let _ = send(opts, "serial-read", json!({})); + if send(opts, "serial-send", json!({"data": format!("echo {}$0\r", SENTINEL)})).is_err() { + return "csh"; + } + // Wait for the value line (the typed echo also contains the sentinel; read a + // bit more so the rsplit lands on the command's actual output). + let _ = send(opts, "wait-serial", json!({"pattern": SENTINEL, "timeout_ms": 8000})); + std::thread::sleep(Duration::from_millis(250)); + let more = send(opts, "serial-read", json!({})).ok(); + let buf = more.as_ref().and_then(|v| v.as_str()).unwrap_or(""); + let val = buf.rsplit(SENTINEL).next().unwrap_or(""); + // `$0` is e.g. "-sh", "/bin/sh", "/bin/csh", "-csh", "tcsh". + if val.contains("csh") { "csh" } else if val.contains("sh") { "sh" } else { "csh" } +} + +/// Shell-specific "discard stdout+stderr" suffix. +fn devnull_redirect(shell: &str) -> &'static str { + match shell { + "sh" | "bash" | "ksh" => "> /dev/null 2>&1", + _ => ">& /dev/null", // csh / tcsh + } +} + /// Send a command, wait for a sentinel, print stdout, fail on non-zero exit. /// csh: appends `; echo IRIS-CI-RC=$status`. sh: appends `; echo IRIS-CI-RC=$?`. fn cmd_run(opts: &Opts, command: &str, shell: &str, timeout_ms: u64) -> Result<()> { @@ -661,24 +693,28 @@ fn cmd_put(opts: &Opts, host_path: &std::path::Path, to: Option<&str>, timeout_m } // 2. Drive the guest to read exactly the right number of 512-byte sectors. - // Use `>&` for combined stderr+stdout (csh syntax — `2>&1` is sh-only). - // cmd_run wraps with `; echo IRIS-CI-RC=$status` itself. + // Redirect + rc-marker syntax must match the guest's actual login shell + // (csh `>&`/`$status` vs sh `2>&1`/`$?`), or the command errors ("bad file + // unit number") / the rc comes back empty. cmd_run appends the marker. + let shell = detect_guest_shell(opts); + let nul = devnull_redirect(shell); let sectors = (bytes.len() as u64).div_ceil(512); let dd_cmd = format!( - "dd if=/dev/rdsk/dks0d2s0 of={} bs=512 count={} >& /dev/null", - guest_path, sectors + "dd if=/dev/rdsk/dks0d2s0 of={} bs=512 count={} {}", + guest_path, sectors, nul ); - cmd_run(opts, &dd_cmd, "csh", timeout_ms)?; + cmd_run(opts, &dd_cmd, shell, timeout_ms)?; // 3. Truncate the guest file to the original byte length (dd reads in // sector multiples, so a 28-byte input becomes 512 bytes on the guest). // `dd of=FILE bs=1 seek=N count=0` is POSIX and IRIX-clean. let dd_trunc = format!( - "dd if=/dev/null of={} bs=1 seek={} count=0 >& /dev/null", + "dd if=/dev/null of={} bs=1 seek={} count=0 {}", guest_path, - bytes.len() + bytes.len(), + nul ); - cmd_run(opts, &dd_trunc, "csh", 10_000)?; + cmd_run(opts, &dd_trunc, shell, 10_000)?; if !opts.quiet { eprintln!("put: {} → {} ({} bytes)", host_path.display(), guest_path, bytes.len()); @@ -703,20 +739,22 @@ fn cmd_get(opts: &Opts, guest_path: &str, to: Option<&std::path::Path>, timeout_ send(opts, "scratch-clear", json!({}))?; // 2. Drive the guest to write the file to scratch with conv=sync padding. - // csh redirect syntax: `>&` for stdout+stderr. cmd_run adds the - // rc-marker echo itself. + // Redirect + rc-marker syntax must match the guest's login shell (csh + // `>&`/`$status` vs sh `2>&1`/`$?`). cmd_run adds the rc-marker echo. + let shell = detect_guest_shell(opts); + let nul = devnull_redirect(shell); let dd_cmd = format!( - "dd if={} of=/dev/rdsk/dks0d2s0 bs=512 conv=sync,notrunc >& /dev/null", - guest_path + "dd if={} of=/dev/rdsk/dks0d2s0 bs=512 conv=sync,notrunc {}", + guest_path, nul ); - cmd_run(opts, &dd_cmd, "csh", timeout_ms)?; + cmd_run(opts, &dd_cmd, shell, timeout_ms)?; // 3. Look up the guest file size so we know how much to slice off the // scratch payload (which is now padded to a 512-byte boundary). Use // a pure-shell approach: `wc -c` outputs just the byte count. // `awk` is also available but `wc -c` is simpler to parse. let stat_cmd = format!("wc -c < {}", guest_path); - let (stat_stdout, stat_rc) = run_capture(opts, &stat_cmd, "csh", 10_000)?; + let (stat_stdout, stat_rc) = run_capture(opts, &stat_cmd, shell, 10_000)?; if stat_rc != 0 { return Err(Error::Iris(format!( "guest stat of {} failed (exit {})", guest_path, stat_rc