mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
feat(nesinit): the box starts one daemon for its whole life, beside the compositor
This commit is contained in:
@@ -160,6 +160,9 @@ where
|
||||
let mut services_open = true;
|
||||
// The box's compositor exit, once there is a compositor.
|
||||
let mut compositor_exit: Option<crate::workload::Exited> = None;
|
||||
// The box's daemon exit and name, once there is a daemon.
|
||||
let mut daemon_exit: Option<crate::workload::Exited> = None;
|
||||
let mut daemon_name = String::new();
|
||||
|
||||
loop {
|
||||
// The exits are guarded like the channels below: an empty set resolves
|
||||
@@ -182,6 +185,9 @@ where
|
||||
gone = async { compositor_exit.as_mut().expect("guarded").await }, if compositor_exit.is_some() => {
|
||||
Event::CompositorDied(gone)
|
||||
},
|
||||
gone = async { daemon_exit.as_mut().expect("guarded").await }, if daemon_exit.is_some() => {
|
||||
Event::DaemonDied(gone)
|
||||
},
|
||||
};
|
||||
|
||||
let line = match event {
|
||||
@@ -298,6 +304,20 @@ where
|
||||
"the compositor exited ({exit:?}), so the box can no longer draw"
|
||||
))));
|
||||
}
|
||||
Event::DaemonDied(gone) => {
|
||||
// What every launch shares is gone with it (the box's one
|
||||
// Steam sign-in), and a launch that carried on would have
|
||||
// nothing to say why its game cannot reach it. Said as a
|
||||
// service death, then the box ends.
|
||||
let exit = gone.unwrap_or_else(|_| Exit::code(1));
|
||||
let name = daemon_name.clone();
|
||||
tracing::error!(?exit, %name, "the box's daemon has exited");
|
||||
send(&mut writer, &GuestToHost::ServiceDied { name: name.clone(), exit }).await?;
|
||||
workload.signal_stop();
|
||||
return Ok(Outcome::Refused(Failure::new(format!(
|
||||
"the {name} daemon exited ({exit:?}), so the box can no longer do what it is for"
|
||||
))));
|
||||
}
|
||||
Event::ServiceDied(None) => {
|
||||
// The supervisor is gone, which on the way down is ordinary.
|
||||
// Nothing to report and nothing to end: a box with no service
|
||||
@@ -425,10 +445,21 @@ where
|
||||
workload.start_compositor(compositor)?;
|
||||
up.push("nescope".to_string());
|
||||
}
|
||||
// After the compositor: what it starts may draw. And the
|
||||
// daemon is started, not waited on: it says itself when it
|
||||
// works, to whoever needs it to.
|
||||
if let Some(daemon) = &descriptor.daemon {
|
||||
workload.start_daemon(daemon)?;
|
||||
up.push(daemon.name.clone());
|
||||
}
|
||||
Ok(up)
|
||||
}) {
|
||||
Ok(up) => {
|
||||
compositor_exit = workload.compositor_exit();
|
||||
daemon_exit = workload.daemon_exit();
|
||||
if let Some(daemon) = &descriptor.daemon {
|
||||
daemon_name = daemon.name.clone();
|
||||
}
|
||||
tracing::info!(services = up.len(), "the box is ready to be launched into");
|
||||
send(&mut writer, &GuestToHost::Initialized { services: up }).await?
|
||||
}
|
||||
@@ -670,6 +701,7 @@ enum Event {
|
||||
Address(Option<crate::ticket::Found>),
|
||||
ServiceDied(Option<Died>),
|
||||
CompositorDied(std::io::Result<Exit>),
|
||||
DaemonDied(std::io::Result<Exit>),
|
||||
}
|
||||
|
||||
/// Hand an envelope to the relay, and treat a relay that is not there as the
|
||||
@@ -725,6 +757,7 @@ mod tests {
|
||||
images: Vec::new(),
|
||||
video: Default::default(),
|
||||
compositor: None,
|
||||
daemon: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,6 +1055,86 @@ mod tests {
|
||||
assert!(matches!(outcome, Outcome::Refused(_)), "{outcome:?}");
|
||||
}
|
||||
|
||||
fn daemon_given() -> nesprotocol::lifecycle::Daemon {
|
||||
nesprotocol::lifecycle::Daemon {
|
||||
name: "steam".into(),
|
||||
exec: Exec {
|
||||
argv: vec!["/payload/client".into()],
|
||||
env: Default::default(),
|
||||
cwd: None,
|
||||
uid: 1001,
|
||||
gid: 1001,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The box's daemon is started by the box, after the compositor, and is
|
||||
/// listed among its services. ref(d-0085)
|
||||
#[tokio::test]
|
||||
async fn the_daemon_is_started_by_the_box_after_the_compositor() {
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
let mut caller = Caller::new(host);
|
||||
let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0))));
|
||||
let mut given = descriptor();
|
||||
given.compositor = Some(nesprotocol::lifecycle::Compositor {
|
||||
width: 1280, height: 720, fps: 30, uid: 1001, gid: 1001, env: Default::default(),
|
||||
});
|
||||
given.daemon = Some(daemon_given());
|
||||
caller.expect_ready().await;
|
||||
caller.say(&HostToGuest::Boot { descriptor: Box::new(given.clone()) }).await;
|
||||
assert_eq!(caller.expect().await, GuestToHost::Mounted);
|
||||
let GuestToHost::Initialized { services } = caller.expect().await else {
|
||||
panic!("the box did not come up")
|
||||
};
|
||||
assert!(services.contains(&"steam".to_string()), "{services:?}");
|
||||
caller.say(&HostToGuest::Shutdown).await;
|
||||
let (_, workload, _) = session.await.unwrap();
|
||||
assert_eq!(workload.daemons, vec![given.daemon.unwrap()]);
|
||||
let order: Vec<_> = workload.order.iter().filter(|o| ["compositor", "daemon"].contains(o)).collect();
|
||||
assert_eq!(order, vec![&"compositor", &"daemon"]);
|
||||
}
|
||||
|
||||
/// A daemon that will not start refuses the box.
|
||||
#[tokio::test]
|
||||
async fn a_daemon_that_will_not_start_refuses_the_box() {
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
let mut caller = Caller::new(host);
|
||||
let mut double = Double::exits_when_stopped(Exit::code(0));
|
||||
double.daemon_failure = Some(Failure::new("no client"));
|
||||
let session = spawn(guest, Given::new(double));
|
||||
let mut given = descriptor();
|
||||
given.daemon = Some(daemon_given());
|
||||
caller.expect_ready().await;
|
||||
caller.say(&HostToGuest::Boot { descriptor: Box::new(given) }).await;
|
||||
assert_eq!(caller.expect().await, GuestToHost::Mounted);
|
||||
assert_eq!(caller.expect().await, GuestToHost::InitFailed { reason: "no client".into() });
|
||||
let (outcome, _, _) = session.await.unwrap();
|
||||
assert!(matches!(outcome, Outcome::Refused(_)), "{outcome:?}");
|
||||
}
|
||||
|
||||
/// A daemon that exits is reported by name and ends the box.
|
||||
#[tokio::test]
|
||||
async fn a_daemon_that_exits_is_reported_and_ends_the_box() {
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
let mut caller = Caller::new(host);
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let mut double = Double::exits_when_stopped(Exit::code(0));
|
||||
double.daemon_exit = Some(rx);
|
||||
let session = spawn(guest, Given::new(double));
|
||||
let mut given = descriptor();
|
||||
given.daemon = Some(daemon_given());
|
||||
caller.expect_ready().await;
|
||||
caller.say(&HostToGuest::Boot { descriptor: Box::new(given) }).await;
|
||||
caller.expect_booted().await;
|
||||
tx.send(Exit::code(3)).unwrap();
|
||||
assert_eq!(
|
||||
caller.expect().await,
|
||||
GuestToHost::ServiceDied { name: "steam".into(), exit: Exit::code(3) }
|
||||
);
|
||||
let (outcome, _, _) = session.await.unwrap();
|
||||
assert!(matches!(outcome, Outcome::Refused(_)), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_descriptors_video_limits_reach_the_services() {
|
||||
// The ceiling is useless if it stops at the descriptor. `neshub` is the
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::os::unix::ffi::OsStringExt as _;
|
||||
use std::pin::Pin;
|
||||
|
||||
use nesprotocol::lifecycle::{
|
||||
AttachId, Compositor, Exec, Exit, Image, LAUNCH_ID_ENV, LaunchId, Mount, Overlay, Winsize,
|
||||
AttachId, Compositor, Daemon, Exec, Exit, Image, LAUNCH_ID_ENV, LaunchId, Mount, Overlay, Winsize,
|
||||
};
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -99,6 +99,20 @@ pub trait Workload {
|
||||
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.
|
||||
@@ -129,8 +143,25 @@ pub struct Process {
|
||||
/// 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);
|
||||
@@ -419,6 +450,9 @@ impl Process {
|
||||
cgroup_root: CGROUP_ROOT.to_string(),
|
||||
compositor: None,
|
||||
compositor_exit: None,
|
||||
daemon: None,
|
||||
daemon_exit: None,
|
||||
daemon_marker: DAEMON_MARKER.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,6 +842,70 @@ impl Workload for Process {
|
||||
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
|
||||
@@ -2082,6 +2180,11 @@ pub mod double {
|
||||
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
|
||||
@@ -2227,6 +2330,22 @@ pub mod double {
|
||||
}))
|
||||
}
|
||||
|
||||
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()));
|
||||
|
||||
Reference in New Issue
Block a user