Package workstation tools, reuse emulator sessions, and derive build versions

This commit is contained in:
2026-09-23 03:45:48 +08:00
parent 6bfcce8070
commit f5d9f96409
79 changed files with 878 additions and 132 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "fds-workstation"
version = "0.1.0"
version.workspace = true
build = "../../tools/version-build.rs"
edition = "2024"
license = "MIT"
description = "Linux workstation software and cartridge tools"
+3 -2
View File
@@ -4,12 +4,13 @@ use fds_workstation::emulator::Session;
use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
name = "fds-emulator",
version = env!("FDS_BUILD_VERSION"),
about = "Boot FDS/OS and hotplug virtual USB cartridges on a Linux workstation",
after_help = "QEMU virt tests FDS software, not Raspberry Pi firmware or physical hardware. DATA uses retained temporary overlays; source images remain unchanged."
)]
struct Cli {
/// Private session directory. Choose a new directory for each boot.
/// Private session directory. A stopped session can be started again.
#[arg(long, global = true, default_value = "out/emulator")]
session: PathBuf,
#[command(subcommand)]
+120 -17
View File
@@ -12,8 +12,11 @@ use std::{
fs::{self, File, OpenOptions},
io::Write,
os::{
fd::AsRawFd,
unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
fd::{AsRawFd, FromRawFd, OwnedFd},
unix::{
fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
net::UnixStream,
},
},
path::{Path, PathBuf},
process::Stdio,
@@ -90,6 +93,43 @@ fn lock(root: &Path) -> Result<File> {
}
Ok(file)
}
// A pidfd remains attached to the original process even if its PID is reused.
// QMP EOF alone does not establish that QEMU finished releasing its files.
fn process(root: &Path) -> Result<Option<OwnedFd>> {
let text = match fs::read_to_string(root.join("qemu.pid")) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let pid: i32 = text
.trim()
.parse()
.map_err(|_| Error("Invalid QEMU pid file".into()))?;
if pid <= 0 {
return Err(Error("Invalid QEMU pid file".into()));
}
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) } as i32;
if fd < 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) {
return Ok(None);
}
return Err(error.into());
}
Ok(Some(unsafe { OwnedFd::from_raw_fd(fd) }))
}
fn exited(process: &OwnedFd, timeout_ms: i32) -> Result<bool> {
let mut event = libc::pollfd {
fd: process.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let result = unsafe { libc::poll(&mut event, 1, timeout_ms) };
if result < 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(result > 0 && event.revents & libc::POLLIN != 0)
}
impl Session {
pub fn load(root: &Path) -> Result<Self> {
ordinary()?;
@@ -159,17 +199,70 @@ impl Session {
"Use a shorter session path (under 90 bytes), without commas or line breaks".into(),
));
}
fs::DirBuilder::new()
.mode(0o700)
.create(&root)
.map_err(|e| {
Error(format!(
"Create a new session directory {}: {e}",
root.display()
))
})?;
let runner = runner.map(regular).transpose()?;
let guard = match fs::DirBuilder::new().mode(0o700).create(&root) {
Ok(()) => lock(&root)?,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let previous = Self::load(&root)?;
if process(&root)?
.as_ref()
.map(|pid| exited(pid, 0))
.transpose()?
== Some(false)
{
return Err(Error(
"Session still has a live process; stop it before starting again".into(),
));
}
for name in ["qmp.sock", "console.sock"] {
match UnixStream::connect(root.join(name)) {
Ok(_) => {
return Err(Error(
"Session still has an active socket; stop it before starting again"
.into(),
));
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound
| std::io::ErrorKind::ConnectionRefused
) =>
{
()
}
Err(error) => return Err(error.into()),
}
}
// Keep the previous cartridge-to-overlay mapping as well as the
// overlay files. A fresh boot starts with empty cartridge bays.
let archive = root.join(format!(
"previous-{}.json",
image::hex(&image::random_id()?)
));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(archive)?;
file.write_all(
&serde_json::to_vec_pretty(&previous.state)
.map_err(|e| Error(e.to_string()))?,
)?;
file.sync_all()?;
for name in ["qmp.sock", "console.sock", "qemu.pid"] {
match fs::remove_file(root.join(name)) {
Ok(()) => (),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
Err(error) => return Err(error.into()),
}
}
previous._lock
}
Err(error) => return Err(error.into()),
};
let session = Self {
_lock: lock(&root)?,
_lock: guard,
root,
state: State {
format: 1,
@@ -177,7 +270,7 @@ impl Session {
kernel,
initramfs,
system,
qemu_runner: runner.map(regular).transpose()?,
qemu_runner: runner,
cartridges: BTreeMap::new(),
},
};
@@ -193,7 +286,7 @@ impl Session {
.arg("-initrd").arg(&session.state.initramfs)
.args(["-append","console=ttyAMA0 rdinit=/init ro quiet loglevel=3 fds.emulator=1","-blockdev"]).arg(block.to_string())
.args(["-device","virtio-blk-pci,drive=system","-device","qemu-xhci,id=xhci,addr=05.0,p2=12,p3=12"])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(File::create(session.root.join("qemu.log"))?);
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(OpenOptions::new().create(true).append(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(session.root.join("qemu.log"))?);
crate::success(&mut command)
.map_err(|e| Error(format!("{e}; inspect {}/qemu.log", session.root.display())))?;
let boot = (|| -> Result<()> {
@@ -344,16 +437,26 @@ impl Session {
}
pub fn stop(&self, force: bool) -> Result<Value> {
let mut qmp = self.qmp()?;
if force {
qmp.execute("quit", json!({}))?;
let process = process(&self.root)?.ok_or_else(|| {
Error("QEMU process record is unavailable; cannot confirm shutdown".into())
})?;
let response = if force {
// QEMU may reset QMP before acknowledging quit. The pidfd,
// rather than transport EOF or a reply, establishes actual exit.
qmp.execute("quit", json!({})).map(|_| ())
} else {
let output =
Serial::connect(&self.root, 120)?.command(&["fds".into(), "poweroff".into()])?;
if output.status != 0 {
return Err(Error(format!("Guest refused shutdown: {}", output.output)));
}
qmp.closed()
};
if !exited(&process, 30_000)? {
return Err(response
.err()
.unwrap_or_else(|| Error("QEMU has not exited; session remains in use".into())));
}
qmp.closed()?;
Ok(json!({"stopped":self.root,"forced":force,"logs_and_data_overlays_retained":true}))
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
name = "fds-cartridge",
version = env!("FDS_BUILD_VERSION"),
about = "Build Void source packages and ready-to-run cartridge images on Linux"
)]
struct Cli {