From d9de1a1f0ac2e86911b4d6bc8a8d3ffb20950770 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 28 Oct 2026 00:09:02 +0100 Subject: [PATCH] feat(vm): the machine runs as a child; the console streams through a file The exit-code contract needed a rethink once the real libkrun was in the loop: on a normal payload exit the VMM ends its own process with the payload's code, so run() forks and the child enters the VM loop. The parent reaps it and the code flows to the consumer; the control file stays as the boot-failure diagnostic, and the guest script no longer powers off - the init blob does that when the script exits, since the libkrunfw kernel has no sysrq. The console cannot ride stdio: with a tty on fd 0 libkrun stops a background caller with SIGTTOU, and without one it routes guest bytes through its logger. So the console goes to a file in the scratch that the parent streams to real stdout, with a final drain after the reap; the child unwires fd 0 to /dev/null so the terminal is never touched and a Ctrl-C to the process group still reaches the guest. PDEATHSIG keeps T4: a killed consumer is a killed machine. The hostname and workdir now ride the set_exec config natively (the init blob applies them); log level follows quiet/verbose. Also in the CLI: the command after '--' was parsed but never copied into RunOptions, so every run executed the image's default shell. --- crates/ecrou-cli/src/main.rs | 6 +- crates/ecrou/src/guest.rs | 31 ++++-- crates/ecrou/src/vm.rs | 202 +++++++++++++++++++++++++++++++---- 3 files changed, 206 insertions(+), 33 deletions(-) diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs index ec9b838..fd98edb 100644 --- a/crates/ecrou-cli/src/main.rs +++ b/crates/ecrou-cli/src/main.rs @@ -17,17 +17,19 @@ fn main() -> std::process::ExitCode { let cli = Cli::parse(); logger::init(cli.verbose); match cli.command { - Command::Run(args) => cmd_run(&args), + Command::Run(args) => cmd_run(&args, cli.verbose), Command::Probe => cmd_probe(), } } -fn cmd_run(args: &RunArgs) -> std::process::ExitCode { +fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { let mut options = RunOptions { image: args.image.clone(), + command: args.command.clone(), memory: args.memory.clone(), workdir: args.workdir.clone(), quiet: args.quiet, + verbose: cli_verbose, no_cache: args.no_cache, ..RunOptions::default() }; diff --git a/crates/ecrou/src/guest.rs b/crates/ecrou/src/guest.rs index 962bcb9..9c08d9a 100644 --- a/crates/ecrou/src/guest.rs +++ b/crates/ecrou/src/guest.rs @@ -1,8 +1,9 @@ //! The guest init: a script provisioned into the scratch rootfs before -//! boot. It mounts the virtiofs shares, runs the payload, writes the -//! payload's exit code to the control file the host reads back, and -//! powers the machine off. As PID 1 it is deliberately the only thing -//! that runs; there is no guest agent. +//! boot. It mounts the virtiofs shares, runs the payload, and writes +//! the payload's exit code to the control file. The exit itself ends +//! the machine: libkrun's init blob reaps the script, reports the code, +//! and the VMM terminates the host process with it. This script is +//! deliberately the only thing that runs; there is no guest agent. /// A share as the init sees it: a virtiofs tag to mount at a guest path. #[derive(Debug, Clone, PartialEq)] @@ -80,10 +81,12 @@ pub fn init_script( } script.push_str("\n\ncode=$?\n"); script.push_str(&format!("echo \"$code\" > {}\n", sh_quote(EXIT_FILE))); - // sysrq poweroff: works without a poweroff binary in the rootfs. If - // sysrq is disabled the machine lingers and the consumer's kill - // (T4) is the backstop. - script.push_str("echo o > /proc/sysrq-trigger\nexit \"$code\"\n"); + // When this script exits, the guest init blob reaps it, reports the + // exit code to the VMM, and the VMM ends the host process with it: + // no poweroff helper is needed in the rootfs. The control file write + // above must precede this line; it is the diagnostic when the boot + // dies before the init ran. + script.push_str("exit \"$code\"\n"); script } @@ -136,13 +139,19 @@ mod tests { } #[test] - fn writes_exit_code_and_powers_off() { + fn writes_exit_code_then_exits() { let script = init_script(&[], &["true".to_string()], "/work", "ecrou-vm-x"); assert!(script.contains(&format!("echo \"$code\" > '{}'", EXIT_FILE))); - assert!(script.contains("echo o > /proc/sysrq-trigger")); - assert!(script.contains("exit \"$code\"")); + // The file write must precede the exit: it is the only + // diagnostic when a boot dies without the init running. + let write = script.find("echo \"$code\"").unwrap(); + let exit = script.find("exit \"$code\"").unwrap(); + assert!(write < exit); // No exec of the payload: the init must survive it to report. assert!(!script.contains("exec ")); + // The libkrunfw kernel has no sysrq; ending the machine is the + // init blob's job when this script exits. + assert!(!script.contains("sysrq")); } #[test] diff --git a/crates/ecrou/src/vm.rs b/crates/ecrou/src/vm.rs index 7e4979c..8b49c2f 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -43,6 +43,8 @@ pub struct RunOptions { pub env: Vec<(String, String)>, pub hostname: String, pub quiet: bool, + /// More library diagnostics on stderr + pub verbose: bool, pub no_cache: bool, } @@ -59,14 +61,16 @@ impl Default for RunOptions { env: Vec::new(), hostname: String::new(), quiet: false, + verbose: false, no_cache: false, } } } /// libkrun log levels (0 off, 1 error, 2 warn, 3 info, 4 debug). +const LOG_LEVEL_WARN: u32 = 2; const LOG_LEVEL_INFO: u32 = 3; -const LOG_LEVEL_QUIET: u32 = 1; +const LOG_LEVEL_QUIET: u32 = 0; /// Run one machine to completion and return the payload's exit code. pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { @@ -100,7 +104,7 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { .map(|(_, gs)| gs.clone()) .collect::>(); let payload = payload_command(options, rootfs.dir()); - let env = compose_env(rootfs.dir(), options); + let env = compose_env(rootfs.dir(), options, &hostname); // The guest init is generated for every run: it is the exit-code // contract (it writes the control file), the share mounter, and the @@ -120,8 +124,10 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { krun.set_log_level(if options.quiet { LOG_LEVEL_QUIET - } else { + } else if options.verbose { LOG_LEVEL_INFO + } else { + LOG_LEVEL_WARN })?; let ctx = krun.create_ctx()?; krun.set_vm_config(ctx, vcpus, ram_mib)?; @@ -129,8 +135,11 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { for (share, gs) in &share_pairs { krun.add_virtiofs(ctx, &gs.tag, &share.host)?; } - // The init script owns the workdir (it cds before the payload), so - // the native krun_set_workdir stays unused. + // The blob chdirs from KRUN_WORKDIR before exec; the script's cd + // stays as the fallback path. + if !options.workdir.is_empty() && options.workdir != "/" { + krun.set_workdir(ctx, &options.workdir)?; + } krun.set_exec( ctx, "/ecrou-init", @@ -140,20 +149,172 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { .collect::>(), )?; - // Blocks until the guest powers off; the machine dies with this - // process if the consumer kills us first. - krun.start_enter(ctx)?; + // The console goes through a file we stream to real stdout: without + // a tty, libkrun would route guest output through its logger, and + // with one, its terminal setup stops a background process with + // SIGTTOU. A killed parent kills the machine through PDEATHSIG. + let console_path = rootfs.dir().join(".ecrou-console"); + std::fs::File::create(&console_path) + .with_context(|| format!("creating {}", console_path.display()))?; - let code = std::fs::read_to_string(&exit_file_host) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .ok_or_else(|| { - Error::Other(anyhow!( - "the guest did not report an exit code through {}; the machine \ - failed to boot or the init did not run to completion", - guest::EXIT_FILE - )) - })?; + // Fork with no other threads alive: the child only touches libkrun + // configuration before entering the VM loop, which never returns (a + // normal payload exit ends the child through the VMM's own exit). + let child = unsafe { libc::fork() }; + if child == 0 { + // Safety: after fork the child runs no threads of its own; the + // prctl/exit pair has no unwinding preconditions. + let bail = |msg: String, code: i32| -> ! { + eprintln!("Error: {msg}"); + unsafe { libc::_exit(code) } + }; + unsafe { + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); + // Unwire the console input: with a tty on fd 0 libkrun puts + // that terminal into raw mode, which fights whoever owns it + // (and stops a background caller with SIGTTOU). /dev/null + // keeps the console on its plain path and still forwards a + // Ctrl-C sent to the process group into the guest. + let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR); + if null >= 0 { + libc::dup2(null, libc::STDIN_FILENO); + if null != libc::STDIN_FILENO { + libc::close(null); + } + } + libc::signal(libc::SIGTTOU, libc::SIG_IGN); + } + if let Err(err) = krun.set_console_output(ctx, &console_path) { + bail(format!("{err:#}"), 125); + } + if let Err(err) = krun.start_enter(ctx) { + bail(format!("{err:#}"), 125); + } + bail("start_enter returned".to_string(), 125); + } + if child < 0 { + return Err(Error::Other( + anyhow::Error::from(std::io::Error::last_os_error()) + .context("forking the machine process"), + )); + } + + let reaped = std::sync::atomic::AtomicI32::new(-1); + stream_console(&console_path, child, &reaped)?; + wait_machine(child, &reaped, &exit_file_host) +} + +/// Relay the console file to real stdout while the machine runs, so +/// output appears as the guest produces it. +fn stream_console( + console_path: &Path, + child: libc::pid_t, + reaped: &std::sync::atomic::AtomicI32, +) -> Result<()> { + use std::io::{Read, Seek, Write}; + let mut pos: u64 = 0; + let mut done = false; + while !done { + let len = std::fs::metadata(console_path) + .with_context(|| format!("stat {}", console_path.display()))? + .len(); + if len > pos { + let mut file = std::fs::File::open(console_path) + .with_context(|| format!("opening {}", console_path.display()))?; + file.seek(std::io::SeekFrom::Start(pos)) + .with_context(|| format!("seeking {}", console_path.display()))?; + let mut buf = Vec::with_capacity((len - pos) as usize); + file.read_to_end(&mut buf) + .with_context(|| format!("reading {}", console_path.display()))?; + pos += buf.len() as u64; + let mut out = std::io::stdout().lock(); + out.write_all(&buf).context("writing console output")?; + out.flush().ok(); + } + // Reap-if-exited: the raw status lands in `reaped` for the + // collector below. WNOWAIT is waitid-only; plain WNOHANG here + // both reports and reaps. + let mut status: libc::c_int = 0; + // Safety: waitpid with WNOHANG only; the pid is our own child. + let r = unsafe { libc::waitpid(child, &mut status, libc::WNOHANG) }; + if r == child { + reaped.store(status, std::sync::atomic::Ordering::SeqCst); + done = true; + } else if r < 0 { + return Err(Error::Other( + anyhow::Error::from(std::io::Error::last_os_error()) + .context("waiting for the machine process"), + )); + } else { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + // The machine's last console bytes land in the file right before it + // dies; drain once more past the final metadata check above. + let len = std::fs::metadata(console_path) + .with_context(|| format!("stat {}", console_path.display()))? + .len(); + if len > pos { + let mut file = std::fs::File::open(console_path) + .with_context(|| format!("opening {}", console_path.display()))?; + file.seek(std::io::SeekFrom::Start(pos)) + .with_context(|| format!("seeking {}", console_path.display()))?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf) + .with_context(|| format!("reading {}", console_path.display()))?; + let mut out = std::io::stdout().lock(); + out.write_all(&buf).context("writing console output")?; + out.flush().ok(); + } + Ok(()) +} + +/// Collect the machine: its exit status is the payload's exit code (the +/// guest init blob reports it and the VMM exits the child with it). +fn wait_machine( + child: libc::pid_t, + reaped: &std::sync::atomic::AtomicI32, + exit_file_host: &Path, +) -> Result { + let reaped_status = reaped.load(std::sync::atomic::Ordering::SeqCst); + if reaped_status >= 0 { + return decode_status(reaped_status, exit_file_host); + } + let mut status: libc::c_int = 0; + // Safety: waitpid on our own child; the pointer points at valid + // storage. + let ret = unsafe { libc::waitpid(child, &mut status, 0) }; + if ret < 0 { + return Err(Error::Other( + anyhow::Error::from(std::io::Error::last_os_error()) + .context("waiting for the machine process"), + )); + } + decode_status(status, exit_file_host) +} + +fn decode_status(status: libc::c_int, exit_file_host: &Path) -> Result { + use std::os::unix::process::ExitStatusExt; + let code = if let Some(sig) = std::process::ExitStatus::from_raw(status).signal() { + 128 + sig + } else { + std::process::ExitStatus::from_raw(status) + .code() + .unwrap_or(125) + }; + if code == 125 { + // Boot failure: the init blob never reported a code. The + // control file is the diagnostic when it ran partway. + if let Ok(s) = std::fs::read_to_string(exit_file_host) { + if let Ok(c) = s.trim().parse::() { + return Ok(c); + } + } + return Err(Error::Other(anyhow!( + "the machine failed to boot; run with --quiet off and check the \ + console output above" + ))); + } Ok(code) } @@ -210,7 +371,7 @@ fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec { /// The guest environment, composed from scratch: a usable base, the /// workspace mount point, and the caller's pairs. Never the host /// environment. -fn compose_env(rootfs_dir: &Path, options: &RunOptions) -> Vec<(String, String)> { +fn compose_env(rootfs_dir: &Path, options: &RunOptions, hostname: &str) -> Vec<(String, String)> { let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()); let mut env = vec![ ("HOME".to_string(), "/root".to_string()), @@ -220,6 +381,7 @@ fn compose_env(rootfs_dir: &Path, options: &RunOptions) -> Vec<(String, String)> utils::detect_shell(rootfs_dir).to_string(), ), ("TERM".to_string(), host_term), + ("HOSTNAME".to_string(), hostname.to_string()), ( "PATH".to_string(), "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(), @@ -323,7 +485,7 @@ mod tests { options .env .push(("ECROU_TEST_MARK".to_string(), "present".to_string())); - let env = compose_env(&dir, &options); + let env = compose_env(&dir, &options, "ecrou-vm-x"); assert!(env .iter() .any(|(k, v)| k == "ECROU_TEST_MARK" && v == "present"));