mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12:39 +02:00
Most desktop applications and games speak PulseAudio, and the box served only PipeWire, so they played no sound. pipewire-pulse now runs beside PipeWire, on a socket the workload can reach, and the workload is told its address. A 32-bit launcher helper also has a 32-bit overlay preloaded into it that links libGL.so.1; libglvnd's dispatcher alone satisfies that, with Mesa assumed rather than installed.
2371 lines
95 KiB
Rust
2371 lines
95 KiB
Rust
// The one thing a descriptor turns into: mount what it names, run what it
|
|
// names, report how that ended.
|
|
//
|
|
// It is a trait because the two halves of it arrive at different times and
|
|
// because the interesting behaviour — one start and never a second, an exit
|
|
// reported rather than acted on — is behaviour of the caller, which a double
|
|
// can test without a VM, a share or a workload.
|
|
|
|
use std::ffi::CString;
|
|
use std::future::Future;
|
|
use std::io;
|
|
use std::os::unix::ffi::OsStringExt as _;
|
|
use std::pin::Pin;
|
|
|
|
use nesprotocol::lifecycle::{
|
|
AttachId, Compositor, Daemon, Exec, Exit, Image, LAUNCH_ID_ENV, LaunchId, Mount, Overlay, Winsize,
|
|
};
|
|
|
|
use std::os::unix::process::CommandExt;
|
|
|
|
use crate::reap::{Waiters, Watched};
|
|
|
|
/// Why something could not be done, in the words the operating system used.
|
|
///
|
|
/// The reason is passed through verbatim on purpose: `EACCES` and a path can
|
|
/// be acted on, where "could not start the workload" cannot.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Failure {
|
|
pub reason: String,
|
|
}
|
|
|
|
impl Failure {
|
|
pub fn new(reason: impl Into<String>) -> Self {
|
|
Self {
|
|
reason: reason.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolves when the workload ends.
|
|
pub type Exited = Pin<Box<dyn Future<Output = io::Result<Exit>> + Send>>;
|
|
|
|
/// Resolves when a mount or an unmount made off the session's loop is done.
|
|
pub type Pending = Pin<Box<dyn Future<Output = Result<(), Failure>> + Send>>;
|
|
|
|
pub trait Workload {
|
|
/// Make the shares the descriptor names, where it says to put them.
|
|
fn mount(&mut self, mounts: &[Mount]) -> Result<(), Failure>;
|
|
|
|
/// Mount each read-only image the descriptor names, where it says.
|
|
///
|
|
/// Before the overlays and the shares: an image is the base other mount
|
|
/// points may be inside of, and nothing mounted yet can be under it.
|
|
fn mount_images(&mut self, images: &[Image]) -> Result<(), Failure>;
|
|
|
|
/// Stack each overlay the descriptor names: its build image, its writable
|
|
/// layer, and the two together where it says.
|
|
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure>;
|
|
|
|
/// Mount one launch's content into a box that is already up: its images,
|
|
/// then its overlays. Off the caller's thread, because the caller also
|
|
/// carries every other launch's exits and stops. ref(d-0085)
|
|
fn mount_launch(&mut self, images: Vec<Image>, overlays: Vec<Overlay>) -> Pending;
|
|
|
|
/// Take down what [`Workload::mount_launch`] mounted, overlays first, so
|
|
/// the loop devices under it clear themselves. Lazily when busy.
|
|
fn unmount_launch(&mut self, images: Vec<Image>, overlays: Vec<Overlay>) -> Pending;
|
|
|
|
/// Start a process on a terminal and connect it to the host on a data port
|
|
/// of its own.
|
|
///
|
|
/// Returns once the process is started or has failed to be, and no later:
|
|
/// everything that can fail before there is anything to connect to fails
|
|
/// here, where it can be sent up the control channel. The connection itself
|
|
/// is made in the background.
|
|
fn attach(&mut self, id: AttachId, exec: &Exec, size: Option<Winsize>) -> Result<(), Failure>;
|
|
|
|
/// Start one launch's command, named by its launch.
|
|
///
|
|
/// Returning the exit as a future, rather than a `wait` method, is what
|
|
/// keeps the caller free to read the channel while the workload runs — a
|
|
/// stop has to arrive during the workload's life or it is not a stop.
|
|
///
|
|
/// Several launches may be running at once: a box is a person's machine
|
|
/// with any of their games in it, not one game's. ref(d-0085)
|
|
fn start(&mut self, id: &LaunchId, exec: &Exec) -> Result<Exited, Failure>;
|
|
|
|
/// Start the box's one compositor, and return once it is serving.
|
|
///
|
|
/// Owned by the box and not by any launch: every launch draws on it, so no
|
|
/// launch's stop may reach it. ref(d-0085)
|
|
fn start_compositor(&mut self, compositor: &Compositor) -> Result<(), Failure>;
|
|
|
|
/// The compositor's exit, once, after [`Workload::start_compositor`] has
|
|
/// returned. The session reports it and ends the box: a box that cannot
|
|
/// draw is no use to anyone in it. `None` when there is no compositor or it
|
|
/// was already taken.
|
|
fn compositor_exit(&mut self) -> Option<Exited> {
|
|
None
|
|
}
|
|
|
|
/// Start the box's daemon, and return once it is spawned.
|
|
///
|
|
/// Owned by the box like the compositor, and for the same reason: every
|
|
/// launch shares what it holds (the box's one Steam sign-in), so no launch's
|
|
/// stop may reach it. Whether it is *working* is its own to say; this is
|
|
/// only that it started. ref(d-0085)
|
|
fn start_daemon(&mut self, daemon: &Daemon) -> Result<(), Failure>;
|
|
|
|
/// The daemon's exit, once, after [`Workload::start_daemon`] has returned.
|
|
/// `None` when there is no daemon or it was already taken.
|
|
fn daemon_exit(&mut self) -> Option<Exited> {
|
|
None
|
|
}
|
|
|
|
/// Ask one launch to stop: its whole process group, not its top process.
|
|
/// Idempotent, and never ends the session by itself. A launch that is not
|
|
/// running is not signalled: its pid may already belong to something else.
|
|
///
|
|
/// The launch's exit is delivered when its group is gone, so it reports
|
|
/// the game's window closed rather than only the top process having left.
|
|
fn stop_launch(&mut self, id: &LaunchId);
|
|
|
|
/// Ask every launch to stop. Idempotent, and never ends the session by
|
|
/// itself.
|
|
fn signal_stop(&mut self);
|
|
}
|
|
|
|
/// The workload as a local process.
|
|
pub struct Process {
|
|
waiters: Waiters,
|
|
/// Every launch started, by id. An entry stays after its exit is
|
|
/// delivered and `Watched::running` is what tells the two apart; it is
|
|
/// replaced when the same id is started again (a restart).
|
|
running: std::collections::BTreeMap<LaunchId, Watched>,
|
|
/// The group of each launch in `running`, which outlives its top process.
|
|
groups: std::collections::BTreeMap<LaunchId, std::sync::Arc<Group>>,
|
|
/// Where launch cgroups are made: [`CGROUP_ROOT`], except in a test.
|
|
cgroup_root: String,
|
|
/// The box's compositor, if it has one. Not a launch: nothing in
|
|
/// `running` is it, and a launch's stop cannot reach it.
|
|
compositor: Option<Watched>,
|
|
/// The compositor's exit, until the session takes it. See
|
|
/// [`Workload::compositor_exit`].
|
|
compositor_exit: Option<Exited>,
|
|
/// The box's daemon, if it has one. Like the compositor, not a launch.
|
|
daemon: Option<Watched>,
|
|
/// The daemon's exit, until the session takes it.
|
|
daemon_exit: Option<Exited>,
|
|
/// Where the daemon is announced to the box's launches: [`DAEMON_MARKER`],
|
|
/// except in a test.
|
|
daemon_marker: String,
|
|
}
|
|
|
|
/// The file that says this box has a daemon, holding its name.
|
|
///
|
|
/// Written before the daemon is spawned and so before the box is ready, which
|
|
/// is before any launch can exist: a launch that finds it knows the daemon is
|
|
/// there to be waited for, and one that does not find it knows there is none to
|
|
/// wait for. Anything else a launch could look at (the daemon's own state) is
|
|
/// not there yet in the first moments of a box, and a launch that read its
|
|
/// absence as "no daemon" would do what the daemon is for by itself.
|
|
pub const DAEMON_MARKER: &str = "/run/nestri/daemon";
|
|
|
|
/// How long a launch's group has to leave after being asked before it is
|
|
/// killed.
|
|
pub(crate) const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
|
|
|
|
/// The compositor's socket, in its user's runtime directory. nescope's own
|
|
/// default, and what a launch is told to join.
|
|
pub(crate) const COMPOSITOR_SOCKET: &str = "nescope-0";
|
|
|
|
/// The compositor's binary in the guest image.
|
|
const COMPOSITOR_BIN: &str = "/usr/bin/nescope";
|
|
|
|
/// How long the compositor has to publish its socket.
|
|
const COMPOSITOR_READY: std::time::Duration = std::time::Duration::from_secs(15);
|
|
|
|
/// Every live process in session `sid`.
|
|
///
|
|
/// A launch is started in a session of its own (`setsid`), so its session id is
|
|
/// its top pid and everything it starts inherits it. Read from `/proc` rather
|
|
/// than asked of the kernel with `kill(-pgid)` alone because a Wine or a
|
|
/// launcher may move its own children to groups of their own, and a group that
|
|
/// has been left is still in the session. Zombies are not members: the reaper
|
|
/// clears them, and waiting on one would wait on nobody.
|
|
pub(crate) fn session_members(sid: i32) -> Vec<i32> {
|
|
let Ok(dir) = std::fs::read_dir("/proc") else {
|
|
return Vec::new();
|
|
};
|
|
dir.flatten()
|
|
.filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
|
|
.filter(|pid| in_session(*pid, sid))
|
|
.collect()
|
|
}
|
|
|
|
/// Whether `pid` is a live (not zombie) member of session `sid`.
|
|
fn in_session(pid: i32, sid: i32) -> bool {
|
|
std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
|
.ok()
|
|
.and_then(|stat| live_session_of(&stat))
|
|
== Some(sid)
|
|
}
|
|
|
|
/// The session a `/proc/<pid>/stat` line belongs to, or `None` for a process
|
|
/// that is a zombie or whose line cannot be read.
|
|
///
|
|
/// `pid (comm) state ppid pgrp session ...`, with a comm that may hold spaces
|
|
/// and parentheses, so it is the last `)` that ends it.
|
|
fn live_session_of(stat: &str) -> Option<i32> {
|
|
let (_, after) = stat.rsplit_once(')')?;
|
|
let mut fields = after.split_whitespace();
|
|
let state = fields.next()?;
|
|
let session = fields.nth(2)?.parse::<i32>().ok()?;
|
|
(state != "Z" && state != "X").then_some(session)
|
|
}
|
|
|
|
/// Where launch cgroups live: the cgroup v2 root, mounted by `filesystems`.
|
|
const CGROUP_ROOT: &str = "/sys/fs/cgroup";
|
|
|
|
/// Everything that belongs to one launch.
|
|
///
|
|
/// A session is not enough: Wine `setsid`s its own processes (wineserver,
|
|
/// services.exe, explorer.exe and the game itself each have a session of their
|
|
/// own), so a stop that signalled the launch's session left the game running
|
|
/// with its window open. Measured 2026-10-18 on the test box.
|
|
///
|
|
/// What a child cannot leave is a cgroup, so a launch is put in one before it
|
|
/// execs and the kernel's own list of its members is what is signalled. Where
|
|
/// no cgroup could be made (v2 not mounted) the members are the launch's
|
|
/// session plus every process whose environment names the launch, which Wine
|
|
/// cannot drop either: it is inherited.
|
|
#[derive(Debug)]
|
|
pub(crate) struct Group {
|
|
id: String,
|
|
/// The top process, which is also the session id.
|
|
sid: i32,
|
|
cgroup: Option<std::path::PathBuf>,
|
|
/// Set when the same id is started again: this group is no longer the
|
|
/// launch's, and nothing waiting on it may act for the launch.
|
|
superseded: std::sync::atomic::AtomicBool,
|
|
}
|
|
|
|
/// Numbers each group, so two launches of one id never share a cgroup.
|
|
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
|
|
/// An id as a directory name: only what is safe in a path.
|
|
fn sanitise(id: &str) -> String {
|
|
id.chars()
|
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
|
.collect()
|
|
}
|
|
|
|
/// Make the cgroup for launch `id`, generation `generation`, under `base`, or
|
|
/// `None` when it cannot be (no cgroup v2, no permission, or one of that name
|
|
/// already). The generation is in the name so a restart never shares a
|
|
/// directory with what is left of the launch before it.
|
|
fn make_cgroup(base: &str, id: &str, generation: u64) -> Option<std::path::PathBuf> {
|
|
let path = std::path::Path::new(base).join(format!("nestri-launch-{}-{generation}", sanitise(id)));
|
|
match std::fs::create_dir(&path) {
|
|
Ok(()) => Some(path),
|
|
Err(error) => {
|
|
tracing::warn!(%id, base, "no cgroup for this launch ({error}); its members are found by environment");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether `pid` is listed in the cgroup at `path`: the proof the launch's
|
|
/// child joined it, which its own unchecked write could not give.
|
|
fn joined(path: &std::path::Path, pid: i32) -> bool {
|
|
std::fs::read_to_string(path.join("cgroup.procs"))
|
|
.map(|procs| procs.lines().any(|line| line.trim().parse::<i32>().ok() == Some(pid)))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Whether `pid` is not a zombie.
|
|
fn is_live(pid: i32) -> bool {
|
|
std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
|
.ok()
|
|
.and_then(|stat| live_session_of(&stat))
|
|
.is_some()
|
|
}
|
|
|
|
/// Whether the NUL-separated environment `environ` carries `NAME=value`.
|
|
fn environ_names(environ: &[u8], id: &str) -> bool {
|
|
let want = format!("{LAUNCH_ID_ENV}={id}");
|
|
environ.split(|b| *b == 0).any(|entry| entry == want.as_bytes())
|
|
}
|
|
|
|
impl Group {
|
|
/// The group of a launch whose top process is `sid`.
|
|
fn new(id: &LaunchId, sid: i32, cgroup: Option<std::path::PathBuf>) -> Self {
|
|
Self { id: id.as_str().to_string(), sid, cgroup, superseded: false.into() }
|
|
}
|
|
|
|
/// Whether the launch has been started again since this group was made.
|
|
fn is_superseded(&self) -> bool {
|
|
self.superseded.load(std::sync::atomic::Ordering::SeqCst)
|
|
}
|
|
|
|
/// Every live process of the launch.
|
|
pub(crate) fn members(&self) -> Vec<i32> {
|
|
if let Some(path) = &self.cgroup {
|
|
if let Ok(procs) = std::fs::read_to_string(path.join("cgroup.procs")) {
|
|
let found: Vec<i32> = procs
|
|
.lines()
|
|
.filter_map(|line| line.trim().parse::<i32>().ok())
|
|
.filter(|pid| is_live(*pid))
|
|
.collect();
|
|
// An empty cgroup with the top process alive is a cgroup the
|
|
// launch is not in: the scan below is what finds it.
|
|
if !found.is_empty() || !in_session(self.sid, self.sid) {
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
let mut members = session_members(self.sid);
|
|
if let Ok(dir) = std::fs::read_dir("/proc") {
|
|
for pid in dir.flatten().filter_map(|e| e.file_name().to_str()?.parse::<i32>().ok()) {
|
|
if members.contains(&pid) || !is_live(pid) {
|
|
continue;
|
|
}
|
|
let named = std::fs::read(format!("/proc/{pid}/environ"))
|
|
.map(|environ| environ_names(&environ, &self.id))
|
|
.unwrap_or(false);
|
|
if named {
|
|
members.push(pid);
|
|
}
|
|
}
|
|
}
|
|
members
|
|
}
|
|
|
|
/// Signal everything of the launch.
|
|
pub(crate) fn signal(&self, signal: libc::c_int) {
|
|
let members = self.members();
|
|
if members.is_empty() {
|
|
// Only a number again: a group call now could reach whatever the
|
|
// kernel gave it to.
|
|
return;
|
|
}
|
|
// SAFETY: integers only; a group that has gone fails with ESRCH.
|
|
unsafe { libc::kill(-self.sid, signal) };
|
|
for pid in members {
|
|
// SAFETY: as above.
|
|
unsafe { libc::kill(pid, signal) };
|
|
}
|
|
}
|
|
|
|
/// End everything of the launch now, with no chance to refuse.
|
|
pub(crate) fn kill(&self) {
|
|
if let Some(path) = &self.cgroup {
|
|
if std::fs::write(path.join("cgroup.kill"), "1").is_ok() {
|
|
return;
|
|
}
|
|
// Before Linux 5.14 there is no `cgroup.kill`: kill by pid.
|
|
}
|
|
self.signal(libc::SIGKILL);
|
|
}
|
|
|
|
/// Remove the launch's cgroup, once it is empty. A zombie still counts as
|
|
/// a member to the kernel until it is reaped, so `EBUSY` is retried for a
|
|
/// moment before it is given up on.
|
|
async fn remove(&self) {
|
|
let Some(path) = &self.cgroup else { return };
|
|
let mut error = None;
|
|
for _ in 0..40 {
|
|
match std::fs::remove_dir(path) {
|
|
Ok(()) => return,
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => return,
|
|
Err(e) => error = Some(e),
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
if let Some(error) = error {
|
|
tracing::warn!(path = %path.display(), "a launch's cgroup was not removed: {error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wait for a launch's group to be gone, ending it if it will not go alone.
|
|
///
|
|
/// Reached once the top process has been reaped. When it leaves, whatever it
|
|
/// started is ended with it (TERM, then KILL after the grace): a launch is not
|
|
/// reported ended while something of it is still running, and what it left
|
|
/// behind is not kept alive.
|
|
async fn group_gone(group: std::sync::Arc<Group>) {
|
|
if group.members().is_empty() {
|
|
group.remove().await;
|
|
return;
|
|
}
|
|
tracing::info!(sid = group.sid, "a launch's top process has gone and its group has not: ending the group");
|
|
group.signal(libc::SIGTERM);
|
|
let deadline = std::time::Instant::now() + STOP_GRACE;
|
|
let mut killed = false;
|
|
// Past the grace plus as long again: a process in uninterruptible sleep
|
|
// ignores SIGKILL, and a launch that never ends is worse than one that
|
|
// ended with something stuck in it.
|
|
let give_up = deadline + STOP_GRACE * 2;
|
|
loop {
|
|
if group.members().is_empty() {
|
|
group.remove().await;
|
|
return;
|
|
}
|
|
let now = std::time::Instant::now();
|
|
if now >= give_up {
|
|
tracing::warn!(sid = group.sid, "a launch's group would not die; reporting the launch ended anyway");
|
|
group.remove().await;
|
|
return;
|
|
}
|
|
if now >= deadline && !killed {
|
|
killed = true;
|
|
group.kill();
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
/// Stop a group: TERM now, then `cgroup.kill` (or KILL) after the grace if it
|
|
/// is still there. On a thread of its own, because the caller cannot wait and
|
|
/// the runtime may be busy with the exits it is meant to deliver.
|
|
fn stop_group(group: std::sync::Arc<Group>) {
|
|
group.signal(libc::SIGTERM);
|
|
std::thread::spawn(move || {
|
|
let deadline = std::time::Instant::now() + STOP_GRACE;
|
|
while std::time::Instant::now() < deadline {
|
|
// The launch was started again under this id: what is in the
|
|
// environment-named fallback is now the new launch's, and a kill
|
|
// here would be its death.
|
|
if group.is_superseded() || group.members().is_empty() {
|
|
return;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
}
|
|
if !group.is_superseded() {
|
|
group.kill();
|
|
}
|
|
});
|
|
}
|
|
|
|
impl Process {
|
|
/// Started through the reaper's registry, because the reaper is the only
|
|
/// thing in this component that may call `wait`.
|
|
pub fn new(waiters: Waiters) -> Self {
|
|
Self {
|
|
waiters,
|
|
running: std::collections::BTreeMap::new(),
|
|
groups: std::collections::BTreeMap::new(),
|
|
cgroup_root: CGROUP_ROOT.to_string(),
|
|
compositor: None,
|
|
compositor_exit: None,
|
|
daemon: None,
|
|
daemon_exit: None,
|
|
daemon_marker: DAEMON_MARKER.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Send a signal to the workload and nothing else.
|
|
///
|
|
/// The launches' top processes only, never every process: the order a
|
|
/// shutdown promises is only true if the workload can be stopped alone.
|
|
/// The one exception is SIGKILL, which is the end of the order and takes
|
|
/// each launch's whole group; a launch's own stop does too
|
|
/// ([`Workload::stop_launch`]).
|
|
pub fn signal(&self, signal: libc::c_int) {
|
|
for watched in self.running.values() {
|
|
Self::signal_one(watched, signal);
|
|
}
|
|
if signal == libc::SIGKILL {
|
|
for group in self.groups.values() {
|
|
group.kill();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn signal_one(watched: &Watched, signal: libc::c_int) {
|
|
// Nothing is signalled once the exit has been delivered. The pid was
|
|
// freed by the reap that produced it, and the kernel is entitled to
|
|
// give that number to something else — a stop or a kill aimed at it
|
|
// then lands on a process nobody meant.
|
|
if !watched.running() {
|
|
return;
|
|
}
|
|
// SAFETY: two integers, and it cannot touch this process's memory. A
|
|
// pid that has already gone fails with ESRCH, which is exactly the
|
|
// idempotence the callers of this rely on.
|
|
unsafe { libc::kill(watched.pid, signal) };
|
|
}
|
|
|
|
/// Wait up to `grace` for the workload to leave, without a runtime.
|
|
///
|
|
/// Used on the way down, after the reaper has stopped: it polls for the
|
|
/// one pid rather than for any child, so a slow service cannot be mistaken
|
|
/// for the workload still running.
|
|
pub fn await_exit(&self, grace: std::time::Duration) -> bool {
|
|
let deadline = std::time::Instant::now() + grace;
|
|
self.running
|
|
.values()
|
|
.all(|watched| Self::await_one(watched, deadline))
|
|
}
|
|
|
|
fn await_one(watched: &Watched, deadline: std::time::Instant) -> bool {
|
|
if !watched.running() {
|
|
return true; // already reaped, and its exit already reported
|
|
}
|
|
let pid = watched.pid;
|
|
loop {
|
|
let mut status: libc::c_int = 0;
|
|
// SAFETY: waitpid writes only into `status`.
|
|
let seen = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
|
|
// -1 is ECHILD: already reaped, which is also gone.
|
|
if seen == pid || seen == -1 {
|
|
return true;
|
|
}
|
|
if std::time::Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Workload for Process {
|
|
fn mount(&mut self, mounts: &[Mount]) -> Result<(), Failure> {
|
|
for share in mounts {
|
|
// Stops at the first failure rather than mounting what it can: a
|
|
// workload given some of its shares fails later, somewhere else,
|
|
// for a reason nobody can see from here.
|
|
mount_share(share)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn mount_images(&mut self, images: &[Image]) -> Result<(), Failure> {
|
|
for image in images {
|
|
mount_image(image)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn attach(&mut self, id: AttachId, exec: &Exec, size: Option<Winsize>) -> Result<(), Failure> {
|
|
use nesprotocol::lifecycle::ATTACH_PORT;
|
|
use tokio_vsock::{VMADDR_CID_HOST, VsockAddr, VsockStream};
|
|
|
|
let shell = crate::attach::spawn(&self.waiters, exec, size)?;
|
|
tracing::info!(attach = %id, pid = shell.pid(), uid = exec.uid, "a shell was started");
|
|
tokio::spawn(async move {
|
|
// Dialled once. The host is listening before it asks, so a refused
|
|
// connection means it stopped wanting the shell, and `shell`
|
|
// dropping at the end of this task is what hangs it up.
|
|
let address = VsockAddr::new(VMADDR_CID_HOST, ATTACH_PORT);
|
|
let stream = match VsockStream::connect(address).await {
|
|
Ok(stream) => stream,
|
|
Err(error) => {
|
|
tracing::warn!(attach = %id, %error, "could not reach the host for a shell");
|
|
return;
|
|
}
|
|
};
|
|
match crate::attach::serve(shell, &id, stream).await {
|
|
Ok(()) => tracing::info!(attach = %id, "a shell ended"),
|
|
Err(error) => tracing::warn!(attach = %id, %error, "a shell's connection failed"),
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> {
|
|
for overlay in overlays {
|
|
mount_overlay(overlay)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn mount_launch(&mut self, images: Vec<Image>, overlays: Vec<Overlay>) -> Pending {
|
|
off_loop(move || {
|
|
for image in &images {
|
|
mount_image(image)?;
|
|
}
|
|
for overlay in &overlays {
|
|
mount_overlay(overlay)?;
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn unmount_launch(&mut self, images: Vec<Image>, overlays: Vec<Overlay>) -> Pending {
|
|
off_loop(move || {
|
|
// Everything is attempted, and the first failure is the one said.
|
|
let mut first = None;
|
|
let mut note = |result: Result<(), Failure>| {
|
|
if let Err(failure) = result {
|
|
first.get_or_insert(failure);
|
|
}
|
|
};
|
|
for overlay in overlays.iter().rev() {
|
|
match OverlayPlan::new(overlay) {
|
|
Ok(plan) => {
|
|
for at in [&plan.at, &plan.rw_at, &plan.lower_at] {
|
|
note(unmount_one(at));
|
|
}
|
|
}
|
|
Err(failure) => note(Err(failure)),
|
|
}
|
|
}
|
|
for image in images.iter().rev() {
|
|
match ImagePlan::new(image) {
|
|
Ok(plan) => note(unmount_one(&plan.at)),
|
|
Err(failure) => note(Err(failure)),
|
|
}
|
|
}
|
|
first.map_or(Ok(()), Err)
|
|
})
|
|
}
|
|
|
|
fn start(&mut self, id: &LaunchId, exec: &Exec) -> Result<Exited, Failure> {
|
|
let Some((program, args)) = exec.argv.split_first() else {
|
|
return Err(Failure::new("the command is empty"));
|
|
};
|
|
|
|
// The standard library's process rather than the runtime's: the
|
|
// runtime reaps the children it spawns, and in this component reaping
|
|
// belongs to one place. See `reap::Waiters`.
|
|
let mut command = std::process::Command::new(program);
|
|
command.args(args);
|
|
// Cleared rather than inherited: init's environment is the kernel's
|
|
// and says nothing a workload should read.
|
|
command.env_clear();
|
|
if let Some(cwd) = &exec.cwd {
|
|
command.current_dir(cwd);
|
|
}
|
|
|
|
let (uid, gid) = (exec.uid, exec.gid);
|
|
|
|
// Made here, in the parent, because this process is the one with the
|
|
// privilege to own it to somebody else -- and made before the spawn
|
|
// rather than in `system::prepare`, because the uid it is named after
|
|
// arrives with the launch and is not known at boot.
|
|
//
|
|
// A warning rather than a refusal: a workload that draws nothing needs
|
|
// no runtime directory, and refusing the launch would turn "audio has
|
|
// nowhere to put a socket" into "the box does not start".
|
|
let runtime = match crate::system::runtime_dir(uid, gid) {
|
|
Ok(path) => {
|
|
tracing::info!(%path, uid, "the launch has a runtime directory");
|
|
Some(path)
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
uid,
|
|
"no runtime directory for this launch, so anything reading \
|
|
XDG_RUNTIME_DIR fails on it: {error}"
|
|
);
|
|
None
|
|
}
|
|
};
|
|
command.envs(launch_environment(exec, runtime.as_deref(), id));
|
|
|
|
// The launch's cgroup, made here and joined before exec: what a child
|
|
// that makes its own session still cannot leave. The path is built
|
|
// now because the closure below may not allocate.
|
|
let generation = GENERATION.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
let cgroup = make_cgroup(&self.cgroup_root, id.as_str(), generation);
|
|
let procs_file = cgroup
|
|
.as_ref()
|
|
.and_then(|path| std::ffi::CString::new(path.join("cgroup.procs").into_os_string().into_vec()).ok());
|
|
|
|
// SAFETY: the closure runs between fork and exec in the child, where
|
|
// only async-signal-safe calls are allowed. These are, and it
|
|
// allocates nothing.
|
|
unsafe {
|
|
command.pre_exec(move || {
|
|
// A session of its own: the launch is a group that can be
|
|
// stopped alone, which is what makes one launch's stop leave
|
|
// another's windows open. Before the drops below, which do
|
|
// not care.
|
|
if libc::setsid() == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
// Into the launch's cgroup, as root, before the uid drops.
|
|
// A failure is not one here, where nothing may be said: the
|
|
// parent checks the child is in the cgroup after the spawn and
|
|
// falls back to the environment when it is not.
|
|
if let Some(file) = &procs_file {
|
|
let fd = libc::open(file.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
|
|
if fd >= 0 {
|
|
libc::write(fd, b"0".as_ptr().cast(), 1);
|
|
libc::close(fd);
|
|
}
|
|
}
|
|
// gid first: dropping the uid first would lose the privilege
|
|
// needed to set the gid at all.
|
|
if libc::setgid(gid) != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
if libc::setuid(uid) != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
|
|
let mut watched = self
|
|
.waiters
|
|
.watch(|| Ok(command.spawn()?.id() as i32))
|
|
.map_err(|error| {
|
|
if let Some(path) = &cgroup {
|
|
let _ = std::fs::remove_dir(path);
|
|
}
|
|
// The program, the user, and what the system said.
|
|
//
|
|
// `Permission denied` on its own is the least useful true
|
|
// sentence available here: it is equally consistent with a
|
|
// share the caller exported without letting this user read it,
|
|
// a binary that is not executable, and a mount that forbids
|
|
// execution. The one thing a reader needs is which file, and
|
|
// as whom. Measured 2026-09-12: a launch refused with the bare
|
|
// message cost a search of three machines' permissions.
|
|
Failure::new(format!("{program} as {}:{}: {error}", exec.uid, exec.gid))
|
|
})?;
|
|
|
|
// The caller gets the exit and reports it; this handle keeps the pid
|
|
// and whether that pid is still this child's.
|
|
let exit = watched.take_exit().expect("a new watch has its exit");
|
|
// A launch that ended leaves its entry behind until its id is used
|
|
// again; dropping those here keeps the map the size of what runs.
|
|
self.running.retain(|_, watched| watched.running());
|
|
self.running.insert(id.clone(), watched);
|
|
|
|
// The session id is the top pid (`setsid` above), and stays that
|
|
// number for as long as anything is in it.
|
|
let sid = self.running.get(id).map(|w| w.pid).expect("just inserted");
|
|
// The join was unchecked in the child. A cgroup the child is not in
|
|
// would read as an empty launch, and a stop of it would do nothing, so
|
|
// it is dropped for the session-and-environment fallback.
|
|
let cgroup = match cgroup {
|
|
Some(path) if !joined(&path, sid) => {
|
|
tracing::warn!(
|
|
%id,
|
|
path = %path.display(),
|
|
"the launch did not join its cgroup; its members are found by environment"
|
|
);
|
|
let _ = std::fs::remove_dir(&path);
|
|
None
|
|
}
|
|
other => other,
|
|
};
|
|
// Groups with nothing left in them are dropped here, as `running` is.
|
|
self.groups.retain(|_, group| !group.members().is_empty());
|
|
let group = std::sync::Arc::new(Group::new(id, sid, cgroup));
|
|
if let Some(old) = self.groups.insert(id.clone(), group.clone()) {
|
|
old.superseded.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
}
|
|
Ok(Box::pin(async move {
|
|
let exit = exit
|
|
.await
|
|
.map_err(|_| io::Error::other("the workload's exit was not delivered"))?;
|
|
// The launch ended when its group has, not when its top process
|
|
// did. See `group_gone`.
|
|
group_gone(group).await;
|
|
Ok(exit)
|
|
}))
|
|
}
|
|
|
|
fn start_compositor(&mut self, compositor: &Compositor) -> Result<(), Failure> {
|
|
let (uid, gid) = (compositor.uid, compositor.gid);
|
|
let runtime = crate::system::runtime_dir(uid, gid).map_err(|error| {
|
|
Failure::new(format!("no runtime directory for the compositor (uid {uid}): {error}"))
|
|
})?;
|
|
let exec = Exec {
|
|
argv: vec![
|
|
COMPOSITOR_BIN.to_string(),
|
|
"--width".into(),
|
|
compositor.width.to_string(),
|
|
"--height".into(),
|
|
compositor.height.to_string(),
|
|
"--fps".into(),
|
|
compositor.fps.to_string(),
|
|
],
|
|
env: compositor.env.clone(),
|
|
cwd: None,
|
|
uid,
|
|
gid,
|
|
};
|
|
let mut command = std::process::Command::new(&exec.argv[0]);
|
|
command.args(&exec.argv[1..]);
|
|
command.env_clear();
|
|
command.envs(environment(&exec, Some(&runtime)));
|
|
// SAFETY: async-signal-safe calls only, nothing allocated.
|
|
unsafe {
|
|
command.pre_exec(move || {
|
|
if libc::setsid() == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
if libc::setgid(gid) != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
if libc::setuid(uid) != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
let socket = format!("{runtime}/{COMPOSITOR_SOCKET}");
|
|
let before = crate::services::identity_of(&socket);
|
|
let mut watched = self
|
|
.waiters
|
|
.watch(|| Ok(command.spawn()?.id() as i32))
|
|
.map_err(|error| {
|
|
Failure::new(format!("{} as {uid}:{gid}: {error}", exec.argv[0]))
|
|
})?;
|
|
let exit = watched.take_exit().expect("a new watch has its exit");
|
|
let pid = watched.pid;
|
|
let flag = &watched;
|
|
if let Err(failure) = crate::services::await_path(
|
|
"the compositor",
|
|
&socket,
|
|
before,
|
|
&|| flag.running(),
|
|
COMPOSITOR_READY,
|
|
) {
|
|
// One that never served is not left running behind a box that
|
|
// reports it could not be made.
|
|
if watched.running() {
|
|
// SAFETY: two integers; the pid is still this child's.
|
|
unsafe { libc::kill(-pid, libc::SIGKILL) };
|
|
unsafe { libc::kill(pid, libc::SIGKILL) };
|
|
}
|
|
return Err(failure);
|
|
}
|
|
tracing::info!(pid, %socket, "the compositor is serving");
|
|
// Handed to the session, which reports it and ends the box: with it
|
|
// gone no launch has anywhere to draw.
|
|
self.compositor_exit = Some(Box::pin(async move {
|
|
exit.await.map_err(|_| io::Error::other("the compositor's exit was not delivered"))
|
|
}));
|
|
self.compositor = Some(watched);
|
|
Ok(())
|
|
}
|
|
|
|
fn compositor_exit(&mut self) -> Option<Exited> {
|
|
self.compositor_exit.take()
|
|
}
|
|
|
|
fn start_daemon(&mut self, daemon: &Daemon) -> Result<(), Failure> {
|
|
let exec = &daemon.exec;
|
|
let Some((program, args)) = exec.argv.split_first() else {
|
|
return Err(Failure::new(format!("the {} daemon has no command", daemon.name)));
|
|
};
|
|
let (uid, gid) = (exec.uid, exec.gid);
|
|
let runtime = crate::system::runtime_dir(uid, gid).map_err(|error| {
|
|
Failure::new(format!(
|
|
"no runtime directory for the {} daemon (uid {uid}): {error}",
|
|
daemon.name
|
|
))
|
|
})?;
|
|
// Before the spawn: see [`DAEMON_MARKER`]. A failure here refuses the
|
|
// box, because a launch that could not tell would start its own.
|
|
if let Some(directory) = std::path::Path::new(&self.daemon_marker).parent() {
|
|
std::fs::create_dir_all(directory).map_err(|error| {
|
|
Failure::new(format!("could not make {}: {error}", directory.display()))
|
|
})?;
|
|
}
|
|
std::fs::write(&self.daemon_marker, &daemon.name).map_err(|error| {
|
|
Failure::new(format!("could not write {}: {error}", self.daemon_marker))
|
|
})?;
|
|
|
|
let mut command = std::process::Command::new(program);
|
|
command.args(args);
|
|
command.env_clear();
|
|
if let Some(cwd) = &exec.cwd {
|
|
command.current_dir(cwd);
|
|
}
|
|
command.envs(environment(exec, Some(&runtime)));
|
|
// SAFETY: async-signal-safe calls only, nothing allocated.
|
|
unsafe {
|
|
command.pre_exec(move || {
|
|
if libc::setsid() == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
if libc::setgid(gid) != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
if libc::setuid(uid) != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
let mut watched = self
|
|
.waiters
|
|
.watch(|| Ok(command.spawn()?.id() as i32))
|
|
.map_err(|error| Failure::new(format!("{program} as {uid}:{gid}: {error}")))?;
|
|
let exit = watched.take_exit().expect("a new watch has its exit");
|
|
let name = daemon.name.clone();
|
|
tracing::info!(pid = watched.pid, %name, "the box's daemon is started");
|
|
self.daemon_exit = Some(Box::pin(async move {
|
|
exit.await
|
|
.map_err(|_| io::Error::other(format!("the {name} daemon's exit was not delivered")))
|
|
}));
|
|
self.daemon = Some(watched);
|
|
Ok(())
|
|
}
|
|
|
|
fn daemon_exit(&mut self) -> Option<Exited> {
|
|
self.daemon_exit.take()
|
|
}
|
|
|
|
fn stop_launch(&mut self, id: &LaunchId) {
|
|
if let Some(group) = self.groups.get(id) {
|
|
// Whether or not the top process is still there: its children may
|
|
// be, and they are what holds the window.
|
|
stop_group(group.clone());
|
|
}
|
|
}
|
|
|
|
fn signal_stop(&mut self) {
|
|
self.signal(libc::SIGTERM);
|
|
}
|
|
}
|
|
|
|
/// A launch's environment: everything [`environment`] gives, then its id.
|
|
///
|
|
/// The id goes last so nothing in the caller's environment can name a launch
|
|
/// other than the one this is. ref(d-0085)
|
|
pub(crate) fn launch_environment(
|
|
exec: &Exec,
|
|
runtime: Option<&str>,
|
|
id: &LaunchId,
|
|
) -> Vec<(String, String)> {
|
|
let mut env = environment(exec, runtime);
|
|
env.retain(|(k, _)| k != LAUNCH_ID_ENV);
|
|
env.push((LAUNCH_ID_ENV.to_string(), id.as_str().to_string()));
|
|
env
|
|
}
|
|
|
|
/// Everything a launch is started with, in the order that decides ties.
|
|
///
|
|
/// The image's own graphics settings first and the caller's environment last,
|
|
/// so a host can override anything here. A host that knows better than this
|
|
/// image about this box is unlikely, but it should not have to patch an image
|
|
/// to say so.
|
|
///
|
|
/// A function rather than two calls on the command, because the two calls
|
|
/// could be -- and for one commit were -- reduced to one by an edit that
|
|
/// dropped the first. The only thing that noticed was a dead-code warning.
|
|
///
|
|
/// `runtime` is the directory made for this launch's user, or `None` when it
|
|
/// could not be made. **Making it and not naming it is the same as not making
|
|
/// it**: the environment is cleared, so nothing a workload inherits points at
|
|
/// it, and every toolkit that wants one reads `XDG_RUNTIME_DIR`. A client that
|
|
/// finds the variable unset does not fail loudly -- the compositor here falls
|
|
/// back to `/tmp` -- so the sockets land somewhere world-writable and shared
|
|
/// with every other user, and everything reports success. ref(d-0065)
|
|
pub(crate) fn environment(exec: &Exec, runtime: Option<&str>) -> Vec<(String, String)> {
|
|
GRAPHICS
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.chain(runtime.map(|path| ("XDG_RUNTIME_DIR".to_string(), path.to_string())))
|
|
.chain(exec.env.iter().map(|(k, v)| (k.clone(), v.clone())))
|
|
.collect()
|
|
}
|
|
|
|
/// What the image's own graphics stack needs said out loud.
|
|
///
|
|
/// # Why this is here and not in a profile script
|
|
///
|
|
/// There is one in the image, and it has never run: every process in a box is
|
|
/// exec'd by this component with `env_clear`, and nothing starts a login
|
|
/// shell. A `profile.d` file is for a person who logged in, and nobody does.
|
|
///
|
|
/// # Why it has to be said at all
|
|
///
|
|
/// The image ships a Mesa with exactly one gallium driver, `zink`, on purpose:
|
|
/// OpenGL is translated to Vulkan so that the capture layer -- which is a
|
|
/// Vulkan layer -- sees the frames of a game that draws in GL. A game whose GL
|
|
/// reached a native driver would render correctly and be captured as nothing,
|
|
/// which is the worst shape a failure can have here.
|
|
///
|
|
/// But the loader picks a driver by the *kernel device's* name. It looks for
|
|
/// one called `virtio_gpu`, finds that the only driver built is `zink`, and
|
|
/// gives up with `virtio_gpu: driver missing`. It does not fall back, and
|
|
/// `zink` is never chosen for an arbitrary device on its own. So it is named.
|
|
///
|
|
/// Measured 2026-09-12: without these, every process that touched the GPU
|
|
/// failed to create an EGL screen, in a box whose Vulkan drivers were both
|
|
/// present and loadable.
|
|
const GRAPHICS: &[(&str, &str)] = &[
|
|
("MESA_LOADER_DRIVER_OVERRIDE", "zink"),
|
|
("GALLIUM_DRIVER", "zink"),
|
|
// For anything that goes through libglvnd. Harmless where nothing does.
|
|
("__GLX_VENDOR_LIBRARY_NAME", "mesa"),
|
|
// **Intel's Vulkan Video is off unless asked for.** Its driver gates the
|
|
// video encode and decode extensions behind this, so on an Intel host the
|
|
// capture layer finds no encode support, produces nothing, and says
|
|
// nothing about why -- a box that streams a black screen while every
|
|
// component reports success.
|
|
//
|
|
// Read only by Intel's driver, so it costs nothing on a host with any
|
|
// other GPU. Measured 2026-09-12 on an Arc A310: without it, capture
|
|
// produced no output at all.
|
|
("ANV_DEBUG", "video-encode,video-decode"),
|
|
// **Audio is not under this user's runtime directory.** The services that
|
|
// serve it run as somebody else, so the socket lives somewhere both can
|
|
// reach and both are told where. Without this a game renders and plays
|
|
// silently, having looked under its own uid and found nothing.
|
|
("PIPEWIRE_RUNTIME_DIR", crate::services::AUDIO_DIR),
|
|
// And the same for PulseAudio, which most applications speak.
|
|
("PULSE_SERVER", concat!("unix:", "/run/pipewire/pulse-native")),
|
|
];
|
|
|
|
/// Mount one share where the descriptor says to put it.
|
|
///
|
|
/// The tag names an export; nothing here is a path on the other side of the
|
|
/// channel, so the guest still learns nothing about the filesystem it is being
|
|
/// handed a piece of.
|
|
fn mount_share(share: &Mount) -> Result<(), Failure> {
|
|
// Checked before anything is created: a descriptor this component cannot
|
|
// act on should leave no directory behind to confuse whoever reads the
|
|
// failure.
|
|
let (source, target, flags) = options(share)?;
|
|
|
|
// The mount point may not exist yet: a share can land anywhere the
|
|
// descriptor names, including a directory no image created.
|
|
std::fs::create_dir_all(&share.at).map_err(|error| failed(share, error))?;
|
|
|
|
// SAFETY: mount takes two paths, a filesystem name and a flag word, all
|
|
// of which outlive the call, and no options string.
|
|
let mounted = unsafe {
|
|
libc::mount(
|
|
source.as_ptr(),
|
|
target.as_ptr(),
|
|
FSTYPE.as_ptr(),
|
|
flags,
|
|
std::ptr::null(),
|
|
)
|
|
};
|
|
if mounted != 0 {
|
|
return Err(failed(share, io::Error::last_os_error()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The shares arrive over a virtio transport, which is the only kind of
|
|
/// filesystem this mounts. A descriptor cannot name another.
|
|
const FSTYPE: &std::ffi::CStr = c"virtiofs";
|
|
|
|
/// Stack one overlay: the build image, the box's writable layer, and the two
|
|
/// together at `at`.
|
|
///
|
|
/// Each step's failure names the step, because "the install did not mount"
|
|
/// has three different causes and each one is fixed somewhere else: a build
|
|
/// image the kernel cannot read, an upper layer that was never formatted, and
|
|
/// an overlay the kernel refused.
|
|
fn mount_overlay(overlay: &Overlay) -> Result<(), Failure> {
|
|
// Everything that can be refused without touching the filesystem is
|
|
// refused first, so a descriptor this cannot act on leaves nothing behind.
|
|
let plan = OverlayPlan::new(overlay)?;
|
|
let (lower, _lower_held) = device_for(&plan.lower, true)
|
|
.map_err(|error| plan.failed("the build image", &plan.lower, error))?;
|
|
let (upper, _upper_held) = device_for(&plan.upper, false)
|
|
.map_err(|error| plan.failed("the writable layer", &plan.upper, error))?;
|
|
|
|
mount_one(
|
|
&lower,
|
|
&plan.lower_at,
|
|
c"erofs",
|
|
plan.lower_flags,
|
|
None,
|
|
)
|
|
.map_err(|error| plan.failed("the build image", &plan.lower_at, error))?;
|
|
mount_one(&upper, &plan.rw_at, c"ext4", plan.upper_flags, None)
|
|
.map_err(|error| plan.failed("the writable layer", &plan.rw_at, error))?;
|
|
|
|
for dir in [&plan.upper_dir, &plan.work_dir] {
|
|
std::fs::create_dir_all(as_path(dir))
|
|
.map_err(|error| plan.failed("the writable layer", dir, error))?;
|
|
}
|
|
// **The upper directory takes the build's root ownership.** overlayfs
|
|
// shows a merged directory with the attributes of its upper half when it
|
|
// has one, and this one always does -- so an upper directory this init
|
|
// created, `root:root 0755`, would make the install's top directory
|
|
// unwritable to the workload, whatever the build image says. Copied from
|
|
// the lower root rather than named here: which uid the workload runs as is
|
|
// the host's decision, and the host already made it when it packed the
|
|
// image.
|
|
let (uid, gid, mode) = ownership(&plan.lower_at)
|
|
.map_err(|error| plan.failed("the build image", &plan.lower_at, error))?;
|
|
set_ownership(&plan.upper_dir, uid, gid, mode)
|
|
.map_err(|error| plan.failed("the writable layer", &plan.upper_dir, error))?;
|
|
|
|
mount_one(
|
|
c"overlay",
|
|
&plan.at,
|
|
c"overlay",
|
|
plan.overlay_flags,
|
|
Some(&plan.overlay_data),
|
|
)
|
|
.map_err(|error| plan.failed("the overlay", &plan.at, error))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// What mounting a read-only image is given, worked out before anything is
|
|
/// attempted, for the reason an overlay's plan is: the part worth asserting is
|
|
/// the part that needs no privilege.
|
|
///
|
|
/// `nosuid` and `nodev` like every other layer, and not `noexec`: what is in an
|
|
/// image is run from it.
|
|
#[derive(Debug)]
|
|
struct ImagePlan {
|
|
device: CString,
|
|
at: CString,
|
|
flags: libc::c_ulong,
|
|
}
|
|
|
|
impl ImagePlan {
|
|
fn new(image: &Image) -> Result<Self, Failure> {
|
|
// Nothing at all is mounted over the root, and a relative mount point
|
|
// would land wherever the init's working directory happens to be.
|
|
let at = std::path::Path::new(&image.at);
|
|
if !at.is_absolute() || at.parent().is_none() {
|
|
return Err(Failure::new(format!(
|
|
"an image cannot be mounted at {:?}: the mount point has to be an \
|
|
absolute path below the root",
|
|
image.at
|
|
)));
|
|
}
|
|
Ok(Self {
|
|
device: c_string(&image.device, "the image device")?,
|
|
at: c_string(&image.at, "the image mount point")?,
|
|
flags: libc::MS_NOSUID | libc::MS_NODEV | libc::MS_RDONLY,
|
|
})
|
|
}
|
|
|
|
/// The mount point and the device, in the operating system's words.
|
|
fn failed(&self, error: io::Error) -> Failure {
|
|
Failure::new(format!(
|
|
"{}: the image {}: {error}",
|
|
self.at.to_string_lossy(),
|
|
self.device.to_string_lossy()
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Mount one read-only image where the descriptor says.
|
|
fn mount_image(image: &Image) -> Result<(), Failure> {
|
|
let plan = ImagePlan::new(image)?;
|
|
let (device, _held) = device_for(&plan.device, true).map_err(|error| plan.failed(error))?;
|
|
mount_one(&device, &plan.at, c"erofs", plan.flags, None).map_err(|error| plan.failed(error))
|
|
}
|
|
|
|
/// Run blocking filesystem work on the runtime's blocking pool.
|
|
fn off_loop(work: impl FnOnce() -> Result<(), Failure> + Send + 'static) -> Pending {
|
|
Box::pin(async move {
|
|
tokio::task::spawn_blocking(work)
|
|
.await
|
|
.unwrap_or_else(|error| Err(Failure::new(format!("the mount work did not finish: {error}"))))
|
|
})
|
|
}
|
|
|
|
/// Unmount one filesystem, lazily if it is still in use: a launch that has
|
|
/// exited can leave a child holding a file open, and the mount must still go
|
|
/// so the loop device under it is freed when that child does.
|
|
fn unmount_one(target: &std::ffi::CStr) -> Result<(), Failure> {
|
|
// SAFETY: a nul-terminated path that outlives the call.
|
|
if unsafe { libc::umount2(target.as_ptr(), 0) } == 0 {
|
|
return Ok(());
|
|
}
|
|
let error = io::Error::last_os_error();
|
|
match error.raw_os_error() {
|
|
// Never mounted, or already gone: what was asked for.
|
|
Some(libc::EINVAL) | Some(libc::ENOENT) => Ok(()),
|
|
Some(libc::EBUSY) => {
|
|
// SAFETY: as above.
|
|
if unsafe { libc::umount2(target.as_ptr(), libc::MNT_DETACH) } == 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(Failure::new(format!(
|
|
"{}: {}",
|
|
target.to_string_lossy(),
|
|
io::Error::last_os_error()
|
|
)))
|
|
}
|
|
}
|
|
_ => Err(Failure::new(format!("{}: {error}", target.to_string_lossy()))),
|
|
}
|
|
}
|
|
|
|
/// The block device to mount `source` from.
|
|
///
|
|
/// A block device is used as it is, which is every mount made at boot. A
|
|
/// regular file is attached to a free loop device first: that is how content
|
|
/// reaches a box that is already running, as a file in a share, because a
|
|
/// box cannot be given a new block device after it has booted. ref(d-0085)
|
|
///
|
|
/// The loop device clears itself when the filesystem on it is unmounted, so
|
|
/// nothing here has to remember it.
|
|
///
|
|
/// A symbolic link anywhere in the path is refused rather than followed: a
|
|
/// file in a share is the host's to name, and a link would let whatever wrote
|
|
/// it name another. Anything that is neither a block device nor a regular file
|
|
/// is refused too.
|
|
///
|
|
/// A loop device comes back with its descriptor, which the caller holds until
|
|
/// the mount is made: it is attached with autoclear, so the moment nothing
|
|
/// holds it open the kernel detaches it, and the next attach is handed the
|
|
/// same device. Two attaches in a row (a build image, then its writable layer)
|
|
/// then both land on one device and the first mount reads the second file.
|
|
fn device_for(source: &std::ffi::CStr, read_only: bool) -> io::Result<(CString, Option<std::fs::File>)> {
|
|
use std::os::unix::fs::FileTypeExt;
|
|
let probe = open_no_symlinks(source, libc::O_RDONLY)?;
|
|
let kind = probe.metadata()?.file_type();
|
|
if kind.is_block_device() {
|
|
return Ok((source.to_owned(), None));
|
|
}
|
|
if !kind.is_file() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"a mount source has to be a block device or a regular file",
|
|
));
|
|
}
|
|
drop(probe);
|
|
loop_attach(source, read_only).map(|(path, device)| (path, Some(device)))
|
|
}
|
|
|
|
/// `struct open_how` from `<linux/openat2.h>`.
|
|
#[repr(C)]
|
|
struct OpenHow {
|
|
flags: u64,
|
|
mode: u64,
|
|
resolve: u64,
|
|
}
|
|
|
|
const RESOLVE_NO_SYMLINKS: u64 = 0x04;
|
|
|
|
/// Open a path, refusing a symbolic link at any component (`openat2`), not
|
|
/// only the last one.
|
|
fn open_no_symlinks(path: &std::ffi::CStr, flags: libc::c_int) -> io::Result<std::fs::File> {
|
|
use std::os::fd::FromRawFd;
|
|
let how = OpenHow {
|
|
flags: (flags | libc::O_CLOEXEC | libc::O_NONBLOCK) as u64,
|
|
mode: 0,
|
|
resolve: RESOLVE_NO_SYMLINKS,
|
|
};
|
|
// SAFETY: a nul-terminated path and an `open_how` that outlive the call.
|
|
let fd = unsafe {
|
|
libc::syscall(
|
|
libc::SYS_openat2,
|
|
libc::AT_FDCWD,
|
|
path.as_ptr(),
|
|
&how as *const OpenHow,
|
|
std::mem::size_of::<OpenHow>(),
|
|
)
|
|
};
|
|
if fd < 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
// SAFETY: a descriptor the kernel just returned, owned by nothing else.
|
|
Ok(unsafe { std::fs::File::from_raw_fd(fd as i32) })
|
|
}
|
|
|
|
/// `struct loop_info64` from `<linux/loop.h>`.
|
|
#[repr(C)]
|
|
struct LoopInfo64 {
|
|
device: u64,
|
|
inode: u64,
|
|
rdevice: u64,
|
|
offset: u64,
|
|
sizelimit: u64,
|
|
number: u32,
|
|
encrypt_type: u32,
|
|
encrypt_key_size: u32,
|
|
flags: u32,
|
|
file_name: [u8; 64],
|
|
crypt_name: [u8; 64],
|
|
encrypt_key: [u8; 32],
|
|
init: [u64; 2],
|
|
}
|
|
|
|
/// `struct loop_config` from `<linux/loop.h>`.
|
|
#[repr(C)]
|
|
struct LoopConfig {
|
|
fd: u32,
|
|
block_size: u32,
|
|
info: LoopInfo64,
|
|
reserved: [u64; 8],
|
|
}
|
|
|
|
const LOOP_CTL_GET_FREE: libc::c_ulong = 0x4C82;
|
|
const LOOP_CONFIGURE: libc::c_ulong = 0x4C0A;
|
|
const LO_FLAGS_READ_ONLY: u32 = 1;
|
|
const LO_FLAGS_AUTOCLEAR: u32 = 4;
|
|
|
|
/// Attach a file to a free loop device and name the device.
|
|
///
|
|
/// Retried a few times on `EBUSY`: a device handed out as free can be taken
|
|
/// by somebody else before it is configured.
|
|
fn loop_attach(file: &std::ffi::CStr, read_only: bool) -> io::Result<(CString, std::fs::File)> {
|
|
use std::os::fd::AsRawFd;
|
|
|
|
// Opened again, with the same refusal, because the file may have been
|
|
// swapped since it was looked at.
|
|
let backing = open_no_symlinks(file, if read_only { libc::O_RDONLY } else { libc::O_RDWR })?;
|
|
if !backing.metadata()?.is_file() {
|
|
return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file"));
|
|
}
|
|
let control = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.open("/dev/loop-control")?;
|
|
|
|
let mut last = io::Error::from_raw_os_error(libc::EBUSY);
|
|
for _ in 0..8 {
|
|
// SAFETY: an ioctl with no argument on a descriptor this owns.
|
|
let number = unsafe { libc::ioctl(control.as_raw_fd(), LOOP_CTL_GET_FREE as _) };
|
|
if number < 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
let path = format!("/dev/loop{number}");
|
|
let device = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(!read_only)
|
|
.open(&path)?;
|
|
let mut flags = LO_FLAGS_AUTOCLEAR;
|
|
if read_only {
|
|
flags |= LO_FLAGS_READ_ONLY;
|
|
}
|
|
// SAFETY: zero is a valid value for every field of this plain C struct.
|
|
let mut config: LoopConfig = unsafe { std::mem::zeroed() };
|
|
config.fd = backing.as_raw_fd() as u32;
|
|
config.info.flags = flags;
|
|
// SAFETY: the kernel reads `config` and nothing else, and it outlives
|
|
// the call.
|
|
let done = unsafe {
|
|
libc::ioctl(
|
|
device.as_raw_fd(),
|
|
LOOP_CONFIGURE as _,
|
|
&config as *const LoopConfig,
|
|
)
|
|
};
|
|
if done == 0 {
|
|
return Ok((CString::new(path).map_err(io::Error::other)?, device));
|
|
}
|
|
last = io::Error::last_os_error();
|
|
if last.raw_os_error() != Some(libc::EBUSY) {
|
|
return Err(last);
|
|
}
|
|
}
|
|
Err(last)
|
|
}
|
|
|
|
/// Mount one filesystem, creating its mount point first.
|
|
fn mount_one(
|
|
source: &std::ffi::CStr,
|
|
target: &std::ffi::CStr,
|
|
fstype: &std::ffi::CStr,
|
|
flags: libc::c_ulong,
|
|
data: Option<&std::ffi::CStr>,
|
|
) -> io::Result<()> {
|
|
// The mount point may not exist yet: the descriptor can name anywhere,
|
|
// including a directory no image created.
|
|
std::fs::create_dir_all(as_path(target))?;
|
|
// SAFETY: every pointer is to a nul-terminated string that outlives the
|
|
// call, and a null data pointer is what mount(2) takes for "no options".
|
|
let mounted = unsafe {
|
|
libc::mount(
|
|
source.as_ptr(),
|
|
target.as_ptr(),
|
|
fstype.as_ptr(),
|
|
flags,
|
|
data.map_or(std::ptr::null(), |d| d.as_ptr().cast()),
|
|
)
|
|
};
|
|
if mounted != 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The same bytes, as a path: lossless, where a round trip through `str` is
|
|
/// not.
|
|
fn as_path(path: &std::ffi::CStr) -> &std::path::Path {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
std::path::Path::new(std::ffi::OsStr::from_bytes(path.to_bytes()))
|
|
}
|
|
|
|
fn ownership(path: &std::ffi::CStr) -> io::Result<(u32, u32, u32)> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
let meta = std::fs::metadata(as_path(path))?;
|
|
Ok((meta.uid(), meta.gid(), meta.mode() & 0o7777))
|
|
}
|
|
|
|
fn set_ownership(path: &std::ffi::CStr, uid: u32, gid: u32, mode: u32) -> io::Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let path = as_path(path);
|
|
std::os::unix::fs::chown(path, Some(uid), Some(gid))?;
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
|
}
|
|
|
|
/// What the mount call is given, split out because this is the part worth
|
|
/// asserting: mounting itself needs privileges a test does not have.
|
|
fn options(share: &Mount) -> Result<(CString, CString, libc::c_ulong), Failure> {
|
|
// nosuid and nodev on every share, whether or not it is writable. A share
|
|
// is data handed to the guest; a setuid binary or a device node appearing
|
|
// in one is not something a workload should be able to use, and no
|
|
// descriptor has a way to ask for it.
|
|
let mut flags = libc::MS_NOSUID | libc::MS_NODEV;
|
|
if share.ro {
|
|
flags |= libc::MS_RDONLY;
|
|
}
|
|
// A nul byte inside a tag or a path is a descriptor that cannot be carried
|
|
// out under any flags. Refused by name rather than silently emptied: an
|
|
// empty source turns up later as a mount failure about something else
|
|
// entirely, which is the wrong thing to go and look at.
|
|
let source = CString::new(share.tag.as_str()).map_err(|_| {
|
|
Failure::new(format!(
|
|
"the share tag contains a nul byte: {:?}",
|
|
share.tag
|
|
))
|
|
})?;
|
|
let target = CString::new(share.at.as_str()).map_err(|_| {
|
|
Failure::new(format!(
|
|
"the mount point contains a nul byte: {:?}",
|
|
share.at
|
|
))
|
|
})?;
|
|
Ok((source, target, flags))
|
|
}
|
|
|
|
/// Everything an overlay's three mounts are given, worked out before any of
|
|
/// them is attempted.
|
|
///
|
|
/// Split out because this is the part worth asserting: mounting needs
|
|
/// privileges a test does not have.
|
|
///
|
|
/// # Where the layers go
|
|
///
|
|
/// Beside the overlay, in a hidden directory named after it:
|
|
/// `/nestri/install` stacks `/nestri/.install/lower` under
|
|
/// `/nestri/.install/rw/upper`. Beside rather than under, because anything
|
|
/// mounted under `at` is covered the moment the overlay is mounted over it.
|
|
///
|
|
/// # Flags
|
|
///
|
|
/// `nosuid` and `nodev` on every layer and on the result, for the reason every
|
|
/// share has them: what is stacked here is files a CDN handed us, checked for
|
|
/// the bytes the manifest named and for nothing about what those bytes are.
|
|
/// **Not `noexec`** anywhere: the game's executable is in the build.
|
|
///
|
|
/// `noatime` on the writable layer and the overlay, so a game reading its own
|
|
/// install does not turn every read into a write. The build image is mounted
|
|
/// read-only and has no access times to write.
|
|
///
|
|
/// No filesystem-specific options on the upper layer: `commit=` and
|
|
/// `barrier=` were once passed to a journal-less ext4 and the mount failed
|
|
/// outright with `EINVAL`.
|
|
#[derive(Debug)]
|
|
struct OverlayPlan {
|
|
lower: CString,
|
|
upper: CString,
|
|
at: CString,
|
|
lower_at: CString,
|
|
rw_at: CString,
|
|
upper_dir: CString,
|
|
work_dir: CString,
|
|
lower_flags: libc::c_ulong,
|
|
upper_flags: libc::c_ulong,
|
|
overlay_flags: libc::c_ulong,
|
|
overlay_data: CString,
|
|
}
|
|
|
|
impl OverlayPlan {
|
|
fn new(overlay: &Overlay) -> Result<Self, Failure> {
|
|
let at = std::path::Path::new(&overlay.at);
|
|
let (Some(parent), Some(name)) = (at.parent(), at.file_name()) else {
|
|
return Err(Failure::new(format!(
|
|
"the overlay mount point has no parent to put its layers beside: {:?}",
|
|
overlay.at
|
|
)));
|
|
};
|
|
let layers = parent.join(format!(".{}", name.to_string_lossy()));
|
|
let lower_at = layers.join("lower");
|
|
let rw_at = layers.join("rw");
|
|
let upper_dir = rw_at.join("upper");
|
|
let work_dir = rw_at.join("work");
|
|
|
|
// overlayfs splits its options on commas and its layer lists on
|
|
// colons, and has no escape for either that this should rely on. A
|
|
// path carrying one would mount a different directory than the one
|
|
// named, so it is refused.
|
|
for path in [&lower_at, &upper_dir, &work_dir] {
|
|
let text = path.to_string_lossy();
|
|
if text.contains([',', ':']) {
|
|
return Err(Failure::new(format!(
|
|
"the overlay mount point cannot carry a comma or a colon: {:?}",
|
|
overlay.at
|
|
)));
|
|
}
|
|
}
|
|
let data = format!(
|
|
"lowerdir={},upperdir={},workdir={}",
|
|
lower_at.display(),
|
|
upper_dir.display(),
|
|
work_dir.display()
|
|
);
|
|
|
|
let common = libc::MS_NOSUID | libc::MS_NODEV;
|
|
Ok(Self {
|
|
lower: c_string(&overlay.lower, "the build image device")?,
|
|
upper: c_string(&overlay.upper, "the writable layer device")?,
|
|
at: c_string(&overlay.at, "the overlay mount point")?,
|
|
lower_at: c_string(&lower_at.to_string_lossy(), "the overlay mount point")?,
|
|
rw_at: c_string(&rw_at.to_string_lossy(), "the overlay mount point")?,
|
|
upper_dir: c_string(&upper_dir.to_string_lossy(), "the overlay mount point")?,
|
|
work_dir: c_string(&work_dir.to_string_lossy(), "the overlay mount point")?,
|
|
lower_flags: common | libc::MS_RDONLY,
|
|
upper_flags: common | libc::MS_NOATIME,
|
|
overlay_flags: common | libc::MS_NOATIME,
|
|
overlay_data: c_string(&data, "the overlay mount point")?,
|
|
})
|
|
}
|
|
|
|
/// Which step failed, on which path, in the operating system's words.
|
|
fn failed(&self, step: &str, path: &std::ffi::CStr, error: io::Error) -> Failure {
|
|
Failure::new(format!(
|
|
"{}: {step}: {}: {error}",
|
|
self.at.to_string_lossy(),
|
|
path.to_string_lossy()
|
|
))
|
|
}
|
|
}
|
|
|
|
/// A nul byte inside a path is a descriptor that cannot be carried out under
|
|
/// any flags. Refused by name rather than silently emptied: an empty path turns
|
|
/// up later as a mount failure about something else entirely.
|
|
fn c_string(text: &str, what: &str) -> Result<CString, Failure> {
|
|
CString::new(text).map_err(|_| Failure::new(format!("{what} contains a nul byte: {text:?}")))
|
|
}
|
|
|
|
/// A failure names the path, which is what makes it actionable: a permission
|
|
/// error and the directory it happened on can be acted on, where "the share
|
|
/// did not mount" cannot.
|
|
fn failed(share: &Mount, error: io::Error) -> Failure {
|
|
Failure::new(format!("{}: {error}", share.at))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod loop_tests {
|
|
use super::*;
|
|
|
|
/// The layouts the kernel reads, checked against `<linux/loop.h>`.
|
|
#[test]
|
|
fn the_loop_structs_have_the_kernels_layout() {
|
|
assert_eq!(std::mem::size_of::<LoopInfo64>(), 232);
|
|
assert_eq!(std::mem::size_of::<LoopConfig>(), 304);
|
|
assert_eq!(std::mem::offset_of!(LoopConfig, info), 8);
|
|
}
|
|
|
|
/// Only a block device (every mount made at boot) or a regular file is a
|
|
/// source. A character device, a directory and the like are refused.
|
|
#[test]
|
|
fn a_source_that_is_neither_a_block_device_nor_a_file_is_refused() {
|
|
for path in [c"/dev/null", c"/tmp"] {
|
|
let error = device_for(path, true).unwrap_err();
|
|
assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{path:?}: {error}");
|
|
}
|
|
}
|
|
|
|
/// A link is refused at the last component and at any earlier one.
|
|
#[test]
|
|
fn a_source_reached_through_a_symbolic_link_is_refused() {
|
|
let dir = std::env::temp_dir().join(format!("nesinit-link-{}", std::process::id()));
|
|
let real = dir.join("real");
|
|
std::fs::create_dir_all(&real).unwrap();
|
|
std::fs::write(real.join("build.erofs"), b"x").unwrap();
|
|
std::os::unix::fs::symlink(real.join("build.erofs"), dir.join("file-link")).unwrap();
|
|
std::os::unix::fs::symlink(&real, dir.join("dir-link")).unwrap();
|
|
for path in [dir.join("file-link"), dir.join("dir-link").join("build.erofs")] {
|
|
let source = CString::new(path.to_string_lossy().as_bytes()).unwrap();
|
|
let error = device_for(&source, true).unwrap_err();
|
|
assert_eq!(error.raw_os_error(), Some(libc::ELOOP), "{path:?}: {error}");
|
|
}
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn unmounting_what_is_not_mounted_is_not_a_failure() {
|
|
assert_eq!(unmount_one(c"/nonexistent/nesinit-test"), Ok(()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_source_that_is_not_there_names_the_error() {
|
|
let error = device_for(c"/nonexistent/build.erofs", true).unwrap_err();
|
|
assert_eq!(error.kind(), io::ErrorKind::NotFound);
|
|
}
|
|
|
|
/// The launch id is in the environment, and the caller cannot name
|
|
/// another launch in its place.
|
|
#[test]
|
|
fn a_launch_is_told_its_own_id() {
|
|
let mut exec = Exec {
|
|
argv: vec!["/bin/true".into()],
|
|
env: Default::default(),
|
|
cwd: None,
|
|
uid: 1001,
|
|
gid: 1001,
|
|
};
|
|
exec.env.insert(LAUNCH_ID_ENV.into(), "launch_other".into());
|
|
let env = launch_environment(&exec, None, &LaunchId::new("launch_01"));
|
|
let ids: Vec<_> = env.iter().filter(|(k, _)| k == LAUNCH_ID_ENV).collect();
|
|
assert_eq!(ids.len(), 1, "{env:?}");
|
|
assert_eq!(ids[0].1, "launch_01");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_stat_line_names_its_session_whatever_the_command_is_called() {
|
|
assert_eq!(live_session_of("42 (launcher) S 1 42 42 0 -1"), Some(42));
|
|
assert_eq!(live_session_of("43 (a) b (c) R 42 42 42 0"), Some(42));
|
|
assert_eq!(live_session_of("44 (x) Z 42 42 42 0"), None);
|
|
assert_eq!(live_session_of("garbage"), None);
|
|
}
|
|
|
|
/// Reaps one pid, as the guest's reaper does for every child.
|
|
fn reap_for(waiters: crate::reap::Waiters, pid: i32) {
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let mut status = 0;
|
|
// SAFETY: writes only into `status`.
|
|
let seen = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
|
|
if seen == pid {
|
|
waiters.deliver(pid, crate::reap::exit_of(status));
|
|
return;
|
|
}
|
|
if seen == -1 {
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// A process whose cgroups are made in a directory of the test's own, so
|
|
/// no test touches the real `/sys/fs/cgroup`. It is a plain directory with
|
|
/// no `cgroup.procs`, so a join into it fails, which is the fallback path.
|
|
fn process_in_tmp(waiters: crate::reap::Waiters) -> (Process, std::path::PathBuf) {
|
|
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
|
let root = std::env::temp_dir().join(format!(
|
|
"nesinit-cg-{}-{}",
|
|
std::process::id(),
|
|
NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
|
));
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let mut process = Process::new(waiters);
|
|
process.cgroup_root = root.to_string_lossy().into_owned();
|
|
(process, root)
|
|
}
|
|
|
|
fn me(script: &str) -> Exec {
|
|
// SAFETY: no arguments.
|
|
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
|
|
Exec {
|
|
argv: vec!["/bin/sh".into(), "-c".into(), script.into()],
|
|
env: Default::default(),
|
|
cwd: None,
|
|
uid,
|
|
gid,
|
|
}
|
|
}
|
|
|
|
async fn started(script: &str) -> (Process, LaunchId, i32, Exited) {
|
|
let waiters = crate::reap::Waiters::new();
|
|
let (mut process, _root) = process_in_tmp(waiters.clone());
|
|
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
|
// Unique per test: members are found by the id in their environment.
|
|
let id = LaunchId::new(format!(
|
|
"g-{}-{}",
|
|
std::process::id(),
|
|
NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
|
));
|
|
let exited = process.start(&id, &me(script)).expect("it starts");
|
|
let top = process.running.get(&id).unwrap().pid;
|
|
reap_for(waiters, top);
|
|
// Long enough for the shell to have started its children.
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
(process, id, top, exited)
|
|
}
|
|
|
|
/// The bug this exists for: a stop that reached the top process only left
|
|
/// everything it had started running, and with it the game's window.
|
|
#[tokio::test]
|
|
async fn stopping_a_launch_ends_its_children_and_it_ends_when_they_have() {
|
|
let (mut process, id, _top, exited) = started("sleep 300 & sleep 300 & wait").await;
|
|
let group = process.groups[&id].clone();
|
|
assert!(group.members().len() >= 3, "{:?}", group.members());
|
|
process.stop_launch(&id);
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), exited)
|
|
.await
|
|
.expect("the launch did not end")
|
|
.unwrap();
|
|
assert!(group.members().is_empty(), "something of the launch is left");
|
|
}
|
|
|
|
/// A child that ignores SIGTERM is killed after the grace, and the launch
|
|
/// is not reported ended before it has gone.
|
|
#[tokio::test]
|
|
async fn a_child_that_ignores_the_stop_is_killed_after_the_grace() {
|
|
let (mut process, id, _top, exited) =
|
|
started("trap '' TERM; (trap '' TERM; sleep 300) & wait").await;
|
|
let group = process.groups[&id].clone();
|
|
process.stop_launch(&id);
|
|
let began = std::time::Instant::now();
|
|
tokio::time::timeout(std::time::Duration::from_secs(15), exited)
|
|
.await
|
|
.expect("the launch did not end")
|
|
.unwrap();
|
|
assert!(began.elapsed() >= STOP_GRACE.saturating_sub(std::time::Duration::from_millis(500)));
|
|
assert!(group.members().is_empty());
|
|
}
|
|
|
|
/// The top process leaving by itself does not end the launch while
|
|
/// something of it runs; the leftovers are ended and then it has ended.
|
|
#[tokio::test]
|
|
async fn a_launch_whose_top_process_left_is_not_ended_until_its_group_is() {
|
|
let (process, id, _top, exited) = started("sleep 300 & exit 3").await;
|
|
let group = process.groups[&id].clone();
|
|
let exit = tokio::time::timeout(std::time::Duration::from_secs(10), exited)
|
|
.await
|
|
.expect("the launch did not end")
|
|
.unwrap();
|
|
assert_eq!(exit, Exit::code(3));
|
|
assert!(group.members().is_empty());
|
|
}
|
|
|
|
/// A cgroup the child could not join is dropped, not trusted: it would
|
|
/// read as an empty launch and a stop would do nothing.
|
|
#[tokio::test]
|
|
async fn a_launch_that_cannot_join_its_cgroup_falls_back_and_is_still_stopped() {
|
|
let waiters = crate::reap::Waiters::new();
|
|
let (mut process, root) = process_in_tmp(waiters.clone());
|
|
let id = LaunchId::new(format!("join-{}", std::process::id()));
|
|
let exited = process.start(&id, &me("sleep 300 & wait")).unwrap();
|
|
reap_for(waiters, process.running[&id].pid);
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
let group = process.groups[&id].clone();
|
|
assert!(group.cgroup.is_none(), "the cgroup was kept");
|
|
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0, "its directory was left");
|
|
assert!(group.members().len() >= 2, "{:?}", group.members());
|
|
process.stop_launch(&id);
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), exited)
|
|
.await
|
|
.expect("the launch did not end")
|
|
.unwrap();
|
|
assert!(group.members().is_empty());
|
|
}
|
|
|
|
/// A stop that is still waiting out its grace must not kill a launch that
|
|
/// has taken the same id since.
|
|
#[tokio::test]
|
|
async fn a_pending_stop_does_not_kill_the_launch_that_replaced_it() {
|
|
let (mut process, id, _top, _exited) = started("sleep 300 & wait").await;
|
|
let old = process.groups[&id].clone();
|
|
process.stop_launch(&id);
|
|
let waiters = process.waiters.clone();
|
|
let _again = process.start(&id, &me("sleep 300 & wait")).unwrap();
|
|
reap_for(waiters, process.running[&id].pid);
|
|
assert!(old.is_superseded());
|
|
tokio::time::sleep(STOP_GRACE + std::time::Duration::from_secs(1)).await;
|
|
let new = process.groups[&id].clone();
|
|
let alive = new.members();
|
|
new.kill();
|
|
assert!(alive.len() >= 2, "the new launch was killed by the old stop: {alive:?}");
|
|
}
|
|
|
|
/// Two launches are two groups: stopping one leaves the other running.
|
|
#[tokio::test]
|
|
async fn stopping_one_launch_leaves_another_running() {
|
|
let waiters = crate::reap::Waiters::new();
|
|
let (mut process, _root) = process_in_tmp(waiters.clone());
|
|
let (a, b) = (LaunchId::new("iso-a"), LaunchId::new("iso-b"));
|
|
let exited_a = process.start(&a, &me("sleep 300 & wait")).unwrap();
|
|
let _exited_b = process.start(&b, &me("sleep 300 & wait")).unwrap();
|
|
let (top_a, top_b) = (
|
|
process.running[&a].pid,
|
|
process.running[&b].pid,
|
|
);
|
|
reap_for(waiters.clone(), top_a);
|
|
reap_for(waiters, top_b);
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
process.stop_launch(&a);
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), exited_a)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(process.groups[&a].members().is_empty());
|
|
assert!(process.groups[&b].members().len() >= 2, "the other launch was touched");
|
|
process.groups[&b].kill();
|
|
}
|
|
|
|
/// The case that broke #433: Wine `setsid`s its own processes, so they are
|
|
/// in no session of the launch's. The launch still owns them, by its
|
|
/// environment (no cgroup here: the test is not root), and a stop ends
|
|
/// them.
|
|
#[tokio::test]
|
|
async fn a_child_that_makes_its_own_session_is_still_stopped() {
|
|
let (mut process, id, top, exited) =
|
|
started("setsid sleep 300 & setsid sh -c 'sleep 300 & wait' & wait").await;
|
|
let group = process.groups[&id].clone();
|
|
assert!(
|
|
session_members(top).len() < group.members().len(),
|
|
"the setsid children should be outside the launch's session: {:?}",
|
|
group.members()
|
|
);
|
|
process.stop_launch(&id);
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), exited)
|
|
.await
|
|
.expect("the launch did not end")
|
|
.unwrap();
|
|
assert!(group.members().is_empty(), "something of the launch is left");
|
|
}
|
|
|
|
/// The same through a real cgroup. Needs a cgroup v2 directory the test
|
|
/// may create in, named by `NESTRI_TEST_CGROUP_BASE` (a systemd user
|
|
/// delegation, or `/sys/fs/cgroup` as root).
|
|
#[tokio::test]
|
|
#[ignore = "needs a writable cgroup v2 directory in NESTRI_TEST_CGROUP_BASE"]
|
|
async fn a_cgroup_holds_a_child_that_makes_its_own_session() {
|
|
let base = std::env::var("NESTRI_TEST_CGROUP_BASE").expect("NESTRI_TEST_CGROUP_BASE");
|
|
let waiters = crate::reap::Waiters::new();
|
|
let mut process = Process::new(waiters.clone());
|
|
process.cgroup_root = base;
|
|
let id = LaunchId::new(format!("cg-{}", std::process::id()));
|
|
let exited = process
|
|
.start(&id, &me("setsid sleep 300 & setsid sh -c 'sleep 300 & wait' & wait"))
|
|
.unwrap();
|
|
let top = process.running[&id].pid;
|
|
reap_for(waiters, top);
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
let group = process.groups[&id].clone();
|
|
let path = group.cgroup.clone().expect("the cgroup was made");
|
|
assert!(group.members().len() >= 4, "{:?}", group.members());
|
|
process.stop_launch(&id);
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), exited)
|
|
.await
|
|
.expect("the launch did not end")
|
|
.unwrap();
|
|
assert!(group.members().is_empty());
|
|
assert!(!path.exists(), "the cgroup was not removed");
|
|
}
|
|
|
|
#[test]
|
|
fn the_launch_id_is_found_whole_in_an_environment() {
|
|
let env = b"A=1\0NESTRI_LAUNCH_ID=g-10\0B=2\0";
|
|
assert!(environ_names(env, "g-10"));
|
|
assert!(!environ_names(env, "g-1"));
|
|
assert!(!environ_names(b"X_NESTRI_LAUNCH_ID=g-10\0", "g-10"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_id_becomes_a_safe_directory_name() {
|
|
assert_eq!(sanitise("a/../b c"), "a_.._b_c");
|
|
}
|
|
|
|
|
|
/// The driver override has to reach the workload, because nothing else
|
|
/// carries it: the image's profile script never runs for an exec'd
|
|
/// process. Without it a game's OpenGL finds no driver at all.
|
|
fn exec_with(env: &[(&str, &str)]) -> Exec {
|
|
Exec {
|
|
argv: vec!["/bin/true".into()],
|
|
env: env
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect(),
|
|
cwd: None,
|
|
uid: 1001,
|
|
gid: 1001,
|
|
}
|
|
}
|
|
|
|
/// The override has to reach the launch, and asserting that it is in a
|
|
/// table is not asserting that. A commit once defined the table and never
|
|
/// applied it; the tests passed and a dead-code warning was the only sign.
|
|
#[test]
|
|
fn the_launch_is_told_which_gallium_driver_to_use() {
|
|
let env = environment(&exec_with(&[]), None);
|
|
let driver = env
|
|
.iter()
|
|
.find(|(k, _)| k == "MESA_LOADER_DRIVER_OVERRIDE")
|
|
.map(|(_, v)| v.as_str());
|
|
assert_eq!(
|
|
driver,
|
|
Some("zink"),
|
|
"without this a game's GL reaches a native driver, renders \
|
|
correctly, and is captured as nothing"
|
|
);
|
|
}
|
|
|
|
/// Intel's driver hides Vulkan Video behind a debug variable, and the
|
|
/// capture layer needs video encode.
|
|
///
|
|
/// Without it the layer loads, finds no encode support, produces nothing,
|
|
/// and reports nothing -- so the box streams a black screen while every
|
|
/// component says it is working. It cost an evening to find once.
|
|
#[test]
|
|
fn intels_vulkan_video_is_asked_for() {
|
|
let env = environment(&exec_with(&[]), None);
|
|
let debug = env
|
|
.iter()
|
|
.find(|(k, _)| k == "ANV_DEBUG")
|
|
.map(|(_, v)| v.as_str())
|
|
.unwrap_or_default();
|
|
assert!(
|
|
debug.contains("video-encode"),
|
|
"on an Intel host this is the difference between a stream and a \
|
|
black screen, and neither says which: {debug:?}"
|
|
);
|
|
}
|
|
|
|
/// The directory made for the launch has to be named to the launch.
|
|
///
|
|
/// Making it and saying nothing is indistinguishable from not making it:
|
|
/// the environment is cleared, so a workload inherits no path to it. The
|
|
/// compositor in this image falls back to `/tmp` rather than failing, which
|
|
/// means the whole session comes up, works, and puts one user's sockets in
|
|
/// a directory every other user can write. ref(d-0065)
|
|
#[test]
|
|
fn the_launch_is_told_where_its_runtime_directory_is() {
|
|
let env = environment(&exec_with(&[]), Some("/run/user/1001"));
|
|
let runtime = env
|
|
.iter()
|
|
.find(|(k, _)| k == "XDG_RUNTIME_DIR")
|
|
.map(|(_, v)| v.as_str());
|
|
assert_eq!(runtime, Some("/run/user/1001"));
|
|
}
|
|
|
|
/// A directory that could not be made is not claimed to exist.
|
|
///
|
|
/// Pointing a workload at a path that is not there is worse than leaving it
|
|
/// unset: unset is a case every toolkit handles, and a bad path is one they
|
|
/// report as something else.
|
|
#[test]
|
|
fn a_launch_without_a_runtime_directory_is_told_nothing() {
|
|
let env = environment(&exec_with(&[]), None);
|
|
assert!(!env.iter().any(|(k, _)| k == "XDG_RUNTIME_DIR"));
|
|
}
|
|
|
|
/// Last wins, so a host can override what the image assumes.
|
|
#[test]
|
|
fn the_callers_own_environment_beats_the_images() {
|
|
let env = environment(&exec_with(&[("GALLIUM_DRIVER", "something-else")]), None);
|
|
let chosen: Vec<&str> = env
|
|
.iter()
|
|
.filter(|(k, _)| k == "GALLIUM_DRIVER")
|
|
.map(|(_, v)| v.as_str())
|
|
.collect();
|
|
// Both are present; `envs` applies in order, so the last is the one
|
|
// the process gets.
|
|
assert_eq!(chosen.last(), Some(&"something-else"));
|
|
}
|
|
|
|
fn share(ro: bool) -> Mount {
|
|
Mount {
|
|
tag: "user".into(),
|
|
at: "/mnt/user".into(),
|
|
ro,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_writable_share_is_still_mounted_without_devices_or_setuid() {
|
|
let (source, target, flags) = options(&share(false)).unwrap();
|
|
assert_eq!(
|
|
source.to_str().unwrap(),
|
|
"user",
|
|
"the tag is the source, never a path"
|
|
);
|
|
assert_eq!(target.to_str().unwrap(), "/mnt/user");
|
|
assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID);
|
|
assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV);
|
|
assert_eq!(flags & libc::MS_RDONLY, 0);
|
|
}
|
|
|
|
fn image(at: &str) -> Image {
|
|
Image {
|
|
device: "/dev/vdd".into(),
|
|
at: at.into(),
|
|
}
|
|
}
|
|
|
|
/// Read-only, guarded like every other layer, and runnable: what is in it
|
|
/// is a program.
|
|
#[test]
|
|
fn an_image_is_mounted_read_only_without_devices_or_setuid_but_can_execute() {
|
|
let plan = ImagePlan::new(&image("/nestri/compat")).unwrap();
|
|
assert_eq!(plan.device.to_str().unwrap(), "/dev/vdd");
|
|
assert_eq!(plan.at.to_str().unwrap(), "/nestri/compat");
|
|
assert_eq!(plan.flags & libc::MS_RDONLY, libc::MS_RDONLY);
|
|
assert_eq!(plan.flags & libc::MS_NOSUID, libc::MS_NOSUID);
|
|
assert_eq!(plan.flags & libc::MS_NODEV, libc::MS_NODEV);
|
|
assert_eq!(plan.flags & libc::MS_NOEXEC, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn an_image_that_cannot_be_placed_is_refused_before_anything_mounts() {
|
|
for at in ["/", "nestri/compat", "", "/nestri/co\0mpat"] {
|
|
assert!(ImagePlan::new(&image(at)).is_err(), "{at:?} was accepted");
|
|
}
|
|
let nul_device = Image {
|
|
device: "/dev/v\0dd".into(),
|
|
at: "/nestri/compat".into(),
|
|
};
|
|
assert!(ImagePlan::new(&nul_device).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_image_names_the_mount_point_and_the_device() {
|
|
let plan = ImagePlan::new(&image("/nestri/compat")).unwrap();
|
|
let failure = plan.failed(io::Error::from_raw_os_error(libc::EINVAL));
|
|
assert!(
|
|
failure
|
|
.reason
|
|
.starts_with("/nestri/compat: the image /dev/vdd: "),
|
|
"{}",
|
|
failure.reason
|
|
);
|
|
}
|
|
|
|
fn overlay(at: &str) -> Overlay {
|
|
Overlay {
|
|
lower: "/dev/vdb".into(),
|
|
upper: "/dev/vdc".into(),
|
|
at: at.into(),
|
|
}
|
|
}
|
|
|
|
/// The layers sit beside the overlay, never under it: anything mounted
|
|
/// under `at` is hidden the moment the overlay covers it.
|
|
#[test]
|
|
fn an_overlays_layers_sit_beside_it_and_the_options_name_them() {
|
|
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
|
assert_eq!(plan.lower.to_str().unwrap(), "/dev/vdb");
|
|
assert_eq!(plan.upper.to_str().unwrap(), "/dev/vdc");
|
|
assert_eq!(plan.lower_at.to_str().unwrap(), "/nestri/.install/lower");
|
|
assert_eq!(plan.rw_at.to_str().unwrap(), "/nestri/.install/rw");
|
|
assert_eq!(
|
|
plan.overlay_data.to_str().unwrap(),
|
|
"lowerdir=/nestri/.install/lower,\
|
|
upperdir=/nestri/.install/rw/upper,\
|
|
workdir=/nestri/.install/rw/work"
|
|
);
|
|
}
|
|
|
|
/// Every layer carries the guard every share carries, and none of them
|
|
/// stops the game's own executable from running.
|
|
///
|
|
/// These are the mounts that most need it: a share is a directory this
|
|
/// host prepared, and a build is a filesystem made out of whatever a CDN
|
|
/// sent.
|
|
#[test]
|
|
fn every_layer_is_mounted_without_devices_or_setuid_but_can_still_execute() {
|
|
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
|
for flags in [plan.lower_flags, plan.upper_flags, plan.overlay_flags] {
|
|
assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID);
|
|
assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV);
|
|
assert_eq!(flags & libc::MS_NOEXEC, 0);
|
|
}
|
|
assert_eq!(plan.lower_flags & libc::MS_RDONLY, libc::MS_RDONLY);
|
|
assert_eq!(plan.upper_flags & libc::MS_RDONLY, 0);
|
|
assert_eq!(plan.overlay_flags & libc::MS_RDONLY, 0);
|
|
assert_eq!(plan.overlay_flags & libc::MS_NOATIME, libc::MS_NOATIME);
|
|
}
|
|
|
|
/// overlayfs splits its options on commas and colons, so a path carrying
|
|
/// one would stack a different directory than the one named.
|
|
#[test]
|
|
fn an_overlay_the_kernel_would_misread_is_refused_before_anything_mounts() {
|
|
for at in [
|
|
"/nestri/in,stall",
|
|
"/nestri/in:stall",
|
|
"/",
|
|
"/nestri/ins\0tall",
|
|
] {
|
|
assert!(
|
|
OverlayPlan::new(&overlay(at)).is_err(),
|
|
"{at:?} was accepted"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_overlay_step_names_the_overlay_the_step_and_the_path() {
|
|
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
|
let failure = plan.failed(
|
|
"the build image",
|
|
&plan.lower_at,
|
|
io::Error::from_raw_os_error(libc::ENODEV),
|
|
);
|
|
assert!(
|
|
failure
|
|
.reason
|
|
.starts_with("/nestri/install: the build image: /nestri/.install/lower: "),
|
|
"{}",
|
|
failure.reason
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_share_is_mounted_read_only() {
|
|
let (_, _, flags) = options(&share(true)).unwrap();
|
|
assert_eq!(flags & libc::MS_RDONLY, libc::MS_RDONLY);
|
|
}
|
|
|
|
#[test]
|
|
fn a_descriptor_with_a_nul_byte_in_it_is_refused_by_name() {
|
|
let tagged = Mount {
|
|
tag: "us\0er".into(),
|
|
at: "/mnt/user".into(),
|
|
ro: false,
|
|
};
|
|
let failure = options(&tagged).expect_err("an empty source would have been mounted");
|
|
assert!(failure.reason.contains("tag"), "{}", failure.reason);
|
|
|
|
let placed = Mount {
|
|
tag: "user".into(),
|
|
at: "/mnt/us\0er".into(),
|
|
ro: false,
|
|
};
|
|
let failure = options(&placed).expect_err("an empty target would have been mounted");
|
|
assert!(failure.reason.contains("mount point"), "{}", failure.reason);
|
|
}
|
|
|
|
#[test]
|
|
fn a_failure_names_the_path_it_happened_on() {
|
|
let failure = failed(&share(false), io::Error::from_raw_os_error(libc::EACCES));
|
|
assert!(
|
|
failure.reason.starts_with("/mnt/user: "),
|
|
"{}",
|
|
failure.reason
|
|
);
|
|
assert!(
|
|
failure.reason.contains("ermission denied"),
|
|
"{}",
|
|
failure.reason
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub mod double {
|
|
use super::*;
|
|
use tokio::sync::oneshot;
|
|
|
|
/// A workload that starts nothing, so what the caller does with it is the
|
|
/// only thing under test.
|
|
pub struct Double {
|
|
pub mounted: Vec<Vec<Mount>>,
|
|
pub overlays: Vec<Vec<Overlay>>,
|
|
pub images: Vec<Vec<Image>>,
|
|
/// Which kind of mount was asked for, in the order it was asked.
|
|
pub order: Vec<&'static str>,
|
|
pub attached: Vec<(AttachId, Exec, Option<Winsize>)>,
|
|
pub attach_failure: Option<Failure>,
|
|
pub started: Vec<Exec>,
|
|
/// An exit of its own for a launch, over `exit`.
|
|
pub exit_for: std::collections::BTreeMap<LaunchId, Exit>,
|
|
/// Launches whose start fails after the first, for a restart test.
|
|
pub fail_restart_of: Option<LaunchId>,
|
|
/// What `unmount_launch` was asked to take down, in order.
|
|
pub unmounted: Vec<(Vec<Image>, Vec<Overlay>)>,
|
|
/// Which launch each start was for, in the same order as `started`.
|
|
pub started_ids: Vec<LaunchId>,
|
|
/// Every stop asked for, one launch or all (`None`).
|
|
pub stopped: Vec<Option<LaunchId>>,
|
|
pub stops: usize,
|
|
/// Every compositor asked for.
|
|
pub compositors: Vec<Compositor>,
|
|
pub compositor_failure: Option<Failure>,
|
|
/// When the compositor "exits": the session takes this after it starts.
|
|
pub compositor_exit: Option<oneshot::Receiver<Exit>>,
|
|
/// Every daemon asked for.
|
|
pub daemons: Vec<Daemon>,
|
|
pub daemon_failure: Option<Failure>,
|
|
/// When the daemon "exits": the session takes this after it starts.
|
|
pub daemon_exit: Option<oneshot::Receiver<Exit>>,
|
|
pub mount_failure: Option<Failure>,
|
|
pub image_failure: Option<Failure>,
|
|
/// Like `image_failure`, but only for a call that names an image, so a
|
|
/// box can boot with none and fail a later mount.
|
|
pub named_image_failure: Option<Failure>,
|
|
pub start_failure: Option<Failure>,
|
|
exit: Exit,
|
|
on_stop: std::collections::BTreeMap<LaunchId, oneshot::Sender<Exit>>,
|
|
holds_until_stopped: bool,
|
|
}
|
|
|
|
impl Double {
|
|
/// Its workload has already ended by the time it is started.
|
|
pub fn exits_at_once(exit: Exit) -> Self {
|
|
Self::new(exit, false)
|
|
}
|
|
|
|
/// Its workload runs until it is asked to stop.
|
|
pub fn exits_when_stopped(exit: Exit) -> Self {
|
|
Self::new(exit, true)
|
|
}
|
|
|
|
fn new(exit: Exit, holds_until_stopped: bool) -> Self {
|
|
Self {
|
|
mounted: Vec::new(),
|
|
overlays: Vec::new(),
|
|
images: Vec::new(),
|
|
order: Vec::new(),
|
|
attached: Vec::new(),
|
|
attach_failure: None,
|
|
started: Vec::new(),
|
|
unmounted: Vec::new(),
|
|
exit_for: Default::default(),
|
|
fail_restart_of: None,
|
|
started_ids: Vec::new(),
|
|
stopped: Vec::new(),
|
|
stops: 0,
|
|
compositors: Vec::new(),
|
|
compositor_failure: None,
|
|
compositor_exit: None,
|
|
daemons: Vec::new(),
|
|
daemon_failure: None,
|
|
daemon_exit: None,
|
|
mount_failure: None,
|
|
image_failure: None,
|
|
named_image_failure: None,
|
|
start_failure: None,
|
|
exit,
|
|
on_stop: Default::default(),
|
|
holds_until_stopped,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Workload for Double {
|
|
fn mount(&mut self, mounts: &[Mount]) -> Result<(), Failure> {
|
|
self.order.push("shares");
|
|
self.mounted.push(mounts.to_vec());
|
|
match &self.mount_failure {
|
|
Some(failure) => Err(failure.clone()),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn attach(
|
|
&mut self,
|
|
id: AttachId,
|
|
exec: &Exec,
|
|
size: Option<Winsize>,
|
|
) -> Result<(), Failure> {
|
|
self.attached.push((id, exec.clone(), size));
|
|
match &self.attach_failure {
|
|
Some(failure) => Err(failure.clone()),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn mount_images(&mut self, images: &[Image]) -> Result<(), Failure> {
|
|
self.order.push("images");
|
|
self.images.push(images.to_vec());
|
|
if let Some(failure) = self.named_image_failure.as_ref().filter(|_| !images.is_empty()) {
|
|
return Err(failure.clone());
|
|
}
|
|
match &self.image_failure {
|
|
Some(failure) => Err(failure.clone()),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn mount_launch(&mut self, images: Vec<Image>, overlays: Vec<Overlay>) -> Pending {
|
|
let result = self
|
|
.mount_images(&images)
|
|
.and_then(|()| self.mount_overlays(&overlays));
|
|
Box::pin(async move { result })
|
|
}
|
|
|
|
fn unmount_launch(&mut self, images: Vec<Image>, overlays: Vec<Overlay>) -> Pending {
|
|
self.unmounted.push((images, overlays));
|
|
Box::pin(async { Ok(()) })
|
|
}
|
|
|
|
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> {
|
|
self.order.push("overlays");
|
|
self.overlays.push(overlays.to_vec());
|
|
match &self.mount_failure {
|
|
Some(failure) => Err(failure.clone()),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn start(&mut self, id: &LaunchId, exec: &Exec) -> Result<Exited, Failure> {
|
|
self.started.push(exec.clone());
|
|
let again = self.started_ids.contains(id);
|
|
self.started_ids.push(id.clone());
|
|
if let Some(failure) = &self.start_failure {
|
|
return Err(failure.clone());
|
|
}
|
|
if again && self.fail_restart_of.as_ref() == Some(id) {
|
|
return Err(Failure::new("it would not come back"));
|
|
}
|
|
let exit = self.exit_for.get(id).copied().unwrap_or(self.exit);
|
|
if !self.holds_until_stopped {
|
|
return Ok(Box::pin(async move { Ok(exit) }));
|
|
}
|
|
let (tx, rx) = oneshot::channel();
|
|
self.on_stop.insert(id.clone(), tx);
|
|
Ok(Box::pin(async move {
|
|
rx.await
|
|
.map_err(|_| io::Error::other("the workload was dropped"))
|
|
}))
|
|
}
|
|
|
|
fn start_compositor(&mut self, compositor: &Compositor) -> Result<(), Failure> {
|
|
self.order.push("compositor");
|
|
self.compositors.push(compositor.clone());
|
|
match &self.compositor_failure {
|
|
Some(failure) => Err(failure.clone()),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn compositor_exit(&mut self) -> Option<Exited> {
|
|
let rx = self.compositor_exit.take()?;
|
|
Some(Box::pin(async move {
|
|
rx.await.map_err(|_| io::Error::other("the compositor was dropped"))
|
|
}))
|
|
}
|
|
|
|
fn start_daemon(&mut self, daemon: &Daemon) -> Result<(), Failure> {
|
|
self.order.push("daemon");
|
|
self.daemons.push(daemon.clone());
|
|
match &self.daemon_failure {
|
|
Some(failure) => Err(failure.clone()),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn daemon_exit(&mut self) -> Option<Exited> {
|
|
let rx = self.daemon_exit.take()?;
|
|
Some(Box::pin(async move {
|
|
rx.await.map_err(|_| io::Error::other("the daemon was dropped"))
|
|
}))
|
|
}
|
|
|
|
fn stop_launch(&mut self, id: &LaunchId) {
|
|
self.stops += 1;
|
|
self.stopped.push(Some(id.clone()));
|
|
if let Some(tx) = self.on_stop.remove(id) {
|
|
let _ = tx.send(self.exit_for.get(id).copied().unwrap_or(self.exit));
|
|
}
|
|
}
|
|
|
|
fn signal_stop(&mut self) {
|
|
self.stops += 1;
|
|
self.stopped.push(None);
|
|
for (id, tx) in std::mem::take(&mut self.on_stop) {
|
|
let _ = tx.send(self.exit_for.get(&id).copied().unwrap_or(self.exit));
|
|
}
|
|
}
|
|
}
|
|
}
|