use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use crate::sidecar::backend::{Access, BackendError, CpuBackend, Phase, Reply, Stop};
use crate::sidecar::command::{Alias, CONTROL_BLOCK_PROPERTY, Property, SidecarCommand};
use crate::sidecar::layout::{ControlBlock, InterruptLevels, PROTOCOL_VERSION};
use crate::sidecar::link::Link;
use crate::sidecar::mapping::Mapping;
use crate::sidecar::turn::WaitError;
use crate::soc::{Region, RegionKind};
use crate::time::VirtualNanos;
const SHARED_MEMORY_DIRECTORY: &str = "/dev/shm";
const FILE_PREFIX: &str = "rigless";
const CONTROL_BLOCK_FILE: &str = "control-block";
const CONTROL_BLOCK_ID: &str = "cb";
const CONTROL_BLOCK_SIZE: u64 = size_of::<ControlBlock>() as u64;
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
const SHUTDOWN_POLL_QUANTUM: Duration = Duration::from_millis(10);
static SPAWN_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug)]
pub struct SidecarOptions {
pub binary: Option<PathBuf>,
pub cancel: &'static AtomicBool,
}
#[derive(Clone, Copy, Debug)]
pub struct TargetStart<'a> {
pub firmware: &'a Path,
pub debugger_port: Option<u16>,
pub wait_for_debugger: bool,
}
#[derive(Debug)]
pub struct Sidecar {
child: Child,
mapping: Mapping,
shutdown_grace: Duration,
cancel: &'static AtomicBool,
}
impl Sidecar {
pub fn spawn(
command: &SidecarCommand,
memory: &[Region],
start: &TargetStart<'_>,
options: &SidecarOptions,
) -> Result<Self, BackendError> {
let explicit_binary = options.binary.as_deref();
let no_sidecar = || BackendError::NoSidecar {
explicit: explicit_binary.map(Path::to_path_buf),
on_path: command.binary,
};
let binary = match explicit_binary {
Some(path) if path.is_file() => path.to_path_buf(),
Some(_not_a_file) => return Err(no_sidecar()),
None => PathBuf::from(command.binary),
};
let files = SharedFiles::create(memory)?;
let mapping = Mapping::open(&files.control_block.file)
.map_err(|source| files_error(&files.control_block.path, source))?;
ControlBlock::initialise(mapping.control_block());
let child = Command::new(&binary)
.args(command_line(command, memory, &files, start))
.stdin(Stdio::null())
.spawn()
.map_err(|source| {
if source.kind() == ErrorKind::NotFound {
no_sidecar()
} else {
BackendError::Spawn { binary, source }
}
})?;
let mut sidecar = Self {
child,
mapping,
shutdown_grace: SHUTDOWN_GRACE,
cancel: options.cancel,
};
sidecar.handshake()?;
drop(files);
Ok(sidecar)
}
fn handshake(&mut self) -> Result<(), BackendError> {
let result = self.link().handshake(PROTOCOL_VERSION);
if let Err(BackendError::Version { .. }) = result {
let _exited = self.wait_after_shutdown();
return result;
}
self.settle(result, Phase::Handshake)
}
fn link(&mut self) -> Link<'_, impl FnMut() -> Result<(), WaitError>> {
let Self {
child,
mapping,
cancel,
..
} = self;
Link::new(mapping.control_block(), || {
if cancel.load(Ordering::Acquire) {
Err(WaitError::Cancelled)
} else if alive(child) {
Ok(())
} else {
Err(WaitError::PeerGone)
}
})
}
fn settle<T>(
&mut self,
result: Result<T, BackendError>,
during: Phase,
) -> Result<T, BackendError> {
match result {
Err(BackendError::Peer(WaitError::PeerGone)) => Err(match self.child.wait() {
Ok(status) => BackendError::Died { status, during },
Err(source) => BackendError::Process(source),
}),
Err(BackendError::Peer(WaitError::Cancelled)) => Err(BackendError::Cancelled),
other => other,
}
}
#[expect(
clippy::disallowed_methods,
reason = "pacing the wait for the sidecar's exit; virtual time is untouched"
)]
fn wait_after_shutdown(&mut self) -> Result<ExitStatus, BackendError> {
let mut waited = Duration::ZERO;
while waited < self.shutdown_grace {
if let Some(status) = self.child.try_wait().map_err(BackendError::Process)? {
return Ok(status);
}
std::thread::sleep(SHUTDOWN_POLL_QUANTUM);
waited += SHUTDOWN_POLL_QUANTUM;
}
self.child.kill().map_err(BackendError::Process)?;
self.child.wait().map_err(BackendError::Process)?;
Err(BackendError::ShutdownTimedOut)
}
}
impl CpuBackend for Sidecar {
fn run_until(
&mut self,
deadline: VirtualNanos,
on_access: &mut dyn FnMut(Access, &InterruptLevels) -> Reply,
) -> Result<Stop, BackendError> {
let result = self.link().run_until(deadline, on_access);
self.settle(result, Phase::Run)
}
fn interrupts(&self) -> &InterruptLevels {
&self.mapping.control_block().interrupt_levels
}
fn shutdown(mut self) -> Result<(), BackendError> {
self.link().shutdown();
let status = self.wait_after_shutdown()?;
if status.success() {
Ok(())
} else {
Err(BackendError::Died {
status,
during: Phase::Shutdown,
})
}
}
}
impl Drop for Sidecar {
fn drop(&mut self) {
if alive(&mut self.child) {
let _killed = self.child.kill();
let _reaped = self.child.wait();
}
}
}
fn alive(child: &mut Child) -> bool {
matches!(child.try_wait(), Ok(None))
}
fn files_error(path: &Path, source: std::io::Error) -> BackendError {
BackendError::Files {
path: path.to_path_buf(),
source,
}
}
struct SharedFile {
path: PathBuf,
file: File,
}
impl SharedFile {
fn create(prefix: &str, name: &str, size: u64) -> Result<Self, BackendError> {
let path = Path::new(SHARED_MEMORY_DIRECTORY).join(format!("{prefix}-{name}"));
let file = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&path)
.map_err(|source| files_error(&path, source))?;
file.set_len(size)
.map_err(|source| files_error(&path, source))?;
Ok(Self { path, file })
}
}
impl Drop for SharedFile {
fn drop(&mut self) {
let _unlinked = fs::remove_file(&self.path);
}
}
struct SharedFiles {
control_block: SharedFile,
regions: Vec<SharedFile>,
}
impl SharedFiles {
fn create(memory: &[Region]) -> Result<Self, BackendError> {
let prefix = format!(
"{FILE_PREFIX}-{}-{}",
std::process::id(),
SPAWN_SEQUENCE.fetch_add(1, Ordering::Relaxed)
);
Ok(Self {
control_block: SharedFile::create(&prefix, CONTROL_BLOCK_FILE, CONTROL_BLOCK_SIZE)?,
regions: memory
.iter()
.map(|region| SharedFile::create(&prefix, region.name, region.size))
.collect::<Result<_, _>>()?,
})
}
}
fn command_line(
command: &SidecarCommand,
memory: &[Region],
files: &SharedFiles,
start: &TargetStart<'_>,
) -> Vec<OsString> {
let control_block = Property::new(CONTROL_BLOCK_PROPERTY, CONTROL_BLOCK_ID);
let machine = std::iter::once(command.machine.to_owned())
.chain(
command
.properties
.iter()
.chain(std::iter::once(&control_block))
.map(Property::to_string),
)
.collect::<Vec<_>>()
.join(",");
let mut arguments: Vec<OsString> = vec![
"-M".into(),
machine.into(),
"-cpu".into(),
command.cpu.into(),
"-object".into(),
memory_backend(
CONTROL_BLOCK_ID,
&files.control_block.path,
CONTROL_BLOCK_SIZE,
),
];
for (region, file) in memory.iter().zip(&files.regions) {
arguments.push("-object".into());
arguments.push(memory_backend(region.name, &file.path, region.size));
arguments.push("-device".into());
arguments.push(memory_device(region, &command.aliases).into());
}
arguments.extend([
"-kernel".into(),
start.firmware.into(),
"-icount".into(),
format!("shift={},sleep=off,align=off", command.icount_shift).into(),
"-run-with".into(),
"exit-with-parent=on".into(),
"-nographic".into(),
"-display".into(),
"none".into(),
"-serial".into(),
"none".into(),
"-monitor".into(),
"none".into(),
]);
if let Some(port) = start.debugger_port {
arguments.extend(["-gdb".into(), format!("tcp::{port}").into()]);
if start.wait_for_debugger {
arguments.push("-S".into());
}
}
arguments
}
fn memory_backend(id: &str, path: &Path, size: u64) -> OsString {
let mut argument = OsString::from(format!("memory-backend-file,id={id},mem-path="));
argument.push(path);
argument.push(format!(",size={size},share=on"));
argument
}
fn memory_device(region: &Region, aliases: &[Alias]) -> String {
let alias = aliases
.iter()
.find(|alias| alias.region == region.name)
.map(|alias| format!(",alias-base={:#x}", alias.at))
.unwrap_or_default();
let read_only = if region.kind == RegionKind::Ram {
""
} else {
",readonly=on"
};
format!(
"rigless-memory,memdev={},base={:#x}{alias}{read_only}",
region.name, region.base
)
}
#[cfg(test)]
mod tests {
use super::*;
const FLASH_BASE: u64 = 0x0800_0000;
const FLASH_SIZE: u64 = 2 << 20;
const SRAM_BASE: u64 = 0x2000_0000;
const SRAM_SIZE: u64 = 192 << 10;
const MEMORY: [Region; 2] = [
Region::flash("flash", FLASH_BASE, FLASH_SIZE),
Region::ram("sram", SRAM_BASE, SRAM_SIZE),
];
fn a_board() -> SidecarCommand {
SidecarCommand {
binary: "rigless-sidecar",
machine: "rigless-test",
cpu: "test-cpu",
properties: vec![
Property::new("first", 168_000_000u64),
Property::new("second", 4u8),
],
icount_shift: 2,
aliases: vec![Alias {
region: "flash",
at: 0,
}],
}
}
fn a_start(firmware: &Path) -> TargetStart<'_> {
TargetStart {
firmware,
debugger_port: None,
wait_for_debugger: false,
}
}
const A_BINARY_THAT_EXISTS: &str = "/bin/true";
const A_BINARY_THAT_SLEEPS: &str = "/bin/sleep";
const SLEEP_LONGER_THAN_THE_TEST: &str = "60";
const SHORT_GRACE: Duration = Duration::from_millis(50);
const DEBUGGER_PORT: u16 = 3333;
static NEVER_CANCELLED: AtomicBool = AtomicBool::new(false);
static CANCELLED: AtomicBool = AtomicBool::new(true);
fn options(binary: Option<&str>) -> SidecarOptions {
SidecarOptions {
binary: binary.map(PathBuf::from),
cancel: &NEVER_CANCELLED,
}
}
fn shm_path(files: &SharedFiles, name: &str) -> String {
let control_block = files.control_block.path.to_str().unwrap();
let prefix = control_block.strip_suffix(CONTROL_BLOCK_FILE).unwrap();
format!("{prefix}{name}")
}
#[test]
fn a_region_is_placed_read_only_by_its_kind_and_aliased_only_when_the_command_names_it() {
let aliases = [Alias {
region: "flash",
at: 0,
}];
assert_eq!(
memory_device(&MEMORY[0], &aliases),
"rigless-memory,memdev=flash,base=0x8000000,alias-base=0x0,readonly=on"
);
assert_eq!(
memory_device(&MEMORY[1], &aliases),
"rigless-memory,memdev=sram,base=0x20000000",
"read-write memory takes a guest's write and answers at one address"
);
assert_eq!(
memory_device(&Region::system("system", 0x1FFF_0000, 1 << 15), &aliases),
"rigless-memory,memdev=system,base=0x1fff0000,readonly=on"
);
assert_eq!(
memory_device(&Region::flash("other", 0x0900_0000, 1 << 20), &aliases),
"rigless-memory,memdev=other,base=0x9000000,readonly=on",
"a second flash region is not a second alias; the command names the one"
);
}
#[test]
#[cfg_attr(miri, ignore = "Miri has no /dev/shm")]
fn command_line_for_a_flash_and_sram_map() {
let files = SharedFiles::create(&MEMORY).unwrap();
let firmware = Path::new("firmware.elf");
let arguments = command_line(&a_board(), &MEMORY, &files, &a_start(firmware));
let backend = |name: &str, size: u64| {
format!(
"memory-backend-file,id={name},mem-path={},size={size},share=on",
shm_path(&files, name)
)
};
let expected: Vec<String> = vec![
"-M".into(),
"rigless-test,first=168000000,second=4,control-block=cb".into(),
"-cpu".into(),
"test-cpu".into(),
"-object".into(),
format!(
"memory-backend-file,id=cb,mem-path={},size=4096,share=on",
shm_path(&files, CONTROL_BLOCK_FILE)
),
"-object".into(),
backend("flash", FLASH_SIZE),
"-device".into(),
"rigless-memory,memdev=flash,base=0x8000000,alias-base=0x0,readonly=on".into(),
"-object".into(),
backend("sram", SRAM_SIZE),
"-device".into(),
"rigless-memory,memdev=sram,base=0x20000000".into(),
"-kernel".into(),
"firmware.elf".into(),
"-icount".into(),
"shift=2,sleep=off,align=off".into(),
"-run-with".into(),
"exit-with-parent=on".into(),
"-nographic".into(),
"-display".into(),
"none".into(),
"-serial".into(),
"none".into(),
"-monitor".into(),
"none".into(),
];
let expected: Vec<OsString> = expected.into_iter().map(OsString::from).collect();
assert_eq!(arguments, expected);
let with_debugger = command_line(
&a_board(),
&MEMORY,
&files,
&TargetStart {
debugger_port: Some(DEBUGGER_PORT),
wait_for_debugger: true,
..a_start(firmware)
},
);
let debugger_flags: Vec<OsString> = ["-gdb", "tcp::3333", "-S"]
.into_iter()
.map(OsString::from)
.collect();
assert_eq!(
with_debugger,
[arguments.clone(), debugger_flags].concat(),
"the debugger flags follow the ordinary command line"
);
let free_running = command_line(
&a_board(),
&MEMORY,
&files,
&TargetStart {
debugger_port: Some(DEBUGGER_PORT),
wait_for_debugger: false,
..a_start(firmware)
},
);
let serving: Vec<OsString> = ["-gdb", "tcp::3333"]
.into_iter()
.map(OsString::from)
.collect();
assert_eq!(
free_running,
[arguments, serving].concat(),
"serving the stub does not hold the target; only wait_for_debugger adds -S"
);
let flash = shm_path(&files, "flash");
assert_eq!(
fs::metadata(&flash).unwrap().len(),
FLASH_SIZE,
"the region file is sized from the map"
);
drop(files);
assert!(
!Path::new(&flash).exists(),
"the files are unlinked when dropped"
);
}
#[test]
#[cfg_attr(miri, ignore = "Miri has no /dev/shm")]
fn an_explicit_path_that_is_not_a_file_is_no_sidecar() {
let error = Sidecar::spawn(
&a_board(),
&MEMORY,
&a_start(Path::new("firmware.elf")),
&options(Some("/nonexistent/explicit")),
)
.unwrap_err();
assert!(matches!(error, BackendError::NoSidecar { .. }), "{error}");
assert_eq!(
error.to_string(),
"no sidecar binary: the explicit path /nonexistent/explicit is not a file"
);
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot spawn a process")]
fn a_name_that_is_not_on_the_path_is_no_sidecar() {
let unknown = SidecarCommand {
binary: "rigless-sidecar-that-does-not-exist",
..a_board()
};
let error = Sidecar::spawn(
&unknown,
&MEMORY,
&a_start(Path::new("firmware.elf")),
&options(None),
)
.unwrap_err();
assert_eq!(
error.to_string(),
"no sidecar binary: no rigless-sidecar-that-does-not-exist on PATH; it is the package published at https://gitlab.com/rigless-labs/qemu/-/releases"
);
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot spawn a process")]
fn a_binary_that_exits_without_hello_is_died() {
let error = Sidecar::spawn(
&a_board(),
&MEMORY,
&a_start(Path::new("firmware.elf")),
&options(Some(A_BINARY_THAT_EXISTS)),
)
.unwrap_err();
assert!(
matches!(error, BackendError::Died { status, during: Phase::Handshake } if status.success()),
"{error}"
);
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot spawn a process")]
fn a_cancelled_handshake_is_cancelled_whatever_the_process_does() {
let error = Sidecar::spawn(
&a_board(),
&MEMORY,
&a_start(Path::new("firmware.elf")),
&SidecarOptions {
cancel: &CANCELLED,
..options(Some(A_BINARY_THAT_EXISTS))
},
)
.unwrap_err();
assert!(matches!(error, BackendError::Cancelled), "{error}");
}
fn sidecar_over(
child: Child,
shutdown_grace: Duration,
cancel: &'static AtomicBool,
) -> Sidecar {
let files = SharedFiles::create(&[]).unwrap();
let mapping = Mapping::open(&files.control_block.file).unwrap();
ControlBlock::initialise(mapping.control_block());
Sidecar {
child,
mapping,
shutdown_grace,
cancel,
}
}
fn sleeper() -> Child {
Command::new(A_BINARY_THAT_SLEEPS)
.arg(SLEEP_LONGER_THAN_THE_TEST)
.spawn()
.unwrap()
}
fn assert_killed(sleeper: u32) {
assert!(
!Path::new(&format!("/proc/{sleeper}")).exists(),
"the sleeper was killed and reaped"
);
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot spawn a process")]
fn a_cancelled_run_is_cancelled_and_the_drop_kills_the_sidecar() {
let child = sleeper();
let sleeper = child.id();
let mut sidecar = sidecar_over(child, SHUTDOWN_GRACE, &CANCELLED);
let result = sidecar.run_until(VirtualNanos(1), &mut |_access, _levels| {
panic!("nothing hands over the turn")
});
assert!(matches!(result, Err(BackendError::Cancelled)), "{result:?}");
drop(sidecar);
assert_killed(sleeper);
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot spawn a process")]
fn shutdown_returns_once_the_process_has_exited() {
let child = Command::new(A_BINARY_THAT_EXISTS).spawn().unwrap();
assert!(
sidecar_over(child, SHUTDOWN_GRACE, &NEVER_CANCELLED)
.shutdown()
.is_ok()
);
}
#[test]
#[cfg_attr(miri, ignore = "Miri cannot spawn a process")]
fn shutdown_kills_a_process_that_ignores_it() {
let child = sleeper();
let sleeper = child.id();
assert!(matches!(
sidecar_over(child, SHORT_GRACE, &NEVER_CANCELLED).shutdown(),
Err(BackendError::ShutdownTimedOut)
));
assert_killed(sleeper);
}
}