mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat: resident guest init (#333)
Get this thing going..
<!-- greptile_comment -->
<!-- greptile_summary -->
<h2><a
href="https://app.greptile.com/api/retrigger?id=63134761"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"
align="right"></picture></a>Confidence Score: 5/5</h2>
The PR appears safe to merge; all previous findings are resolved and the
latest readiness change introduces no established actionable regression.
<h3>Summary</h3>
- Establishes required guest filesystems, runtime directories, device
permissions, and service processes.
- Reports initialization and service deaths over the lifecycle channel.
- Supports launch, restart, and shutdown commands for a resident guest.
- Separates service and workload identities and configures per-launch
runtime environments.
- Removes the currently inactive nescope screenshot option and makes
capture-chain verification fail explicitly when compositor readback is
unavailable.
- Reworks the guest image around `nesinit` as PID 1 without a
distribution service manager.
<h3>Diagram</h3>
```mermaid
sequenceDiagram
participant Host
participant Init as nesinit
participant FS as Guest filesystems
participant Services as Service stack
participant Workload
Init->>Host: Ready(protocol version)
Host->>Init: Boot(mount descriptors)
Init->>FS: Establish and mount shares
Init->>Services: Spawn services in order
Services-->>Init: Required sockets ready
Init->>Host: Initialized(service names)
Host->>Init: Launch(id, exec, on_exit)
Init->>Workload: Spawn with isolated UID/runtime
Init->>Host: Started(id)
Workload-->>Init: Exit status
Init->>Host: WorkloadExited(id, status)
Host->>Init: Launch / Restart / Shutdown
```
<sub>Reviews (4) · Last reviewed commit: ["fix(nesinit): readiness is a
socket
that..."](731d34df9d)</sub>
<!-- /greptile_comment -->
---------
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
ec8b13d0c9
commit
8246aa5538
@@ -31,6 +31,12 @@ struct Early {
|
||||
/// so a device node or a setuid bit appearing in one did not come from us.
|
||||
const NOSUID_NODEV: libc::c_ulong = libc::MS_NOSUID | libc::MS_NODEV;
|
||||
|
||||
// Every tmpfs below is capped, and the caps are load-bearing rather than
|
||||
// tidiness. A tmpfs with no `size=` may grow to half of RAM, and the RAM in
|
||||
// question is the box's whole allowance — so an uncapped `/tmp` is a workload
|
||||
// that can OOM the box it runs in by writing files. The numbers are carried
|
||||
// over from the mount table this replaced, where they were already considered.
|
||||
|
||||
const EARLY: &[Early] = &[
|
||||
Early {
|
||||
source: "proc",
|
||||
@@ -41,6 +47,38 @@ const EARLY: &[Early] = &[
|
||||
cost: "this process cannot make itself ineligible for the OOM killer, \
|
||||
and nothing in the guest can read its own state",
|
||||
},
|
||||
// Usually already there: a kernel built with `CONFIG_DEVTMPFS_MOUNT` mounts
|
||||
// this before init runs. Listed anyway because the check below skips what
|
||||
// is already mounted, so the entry costs nothing when the kernel did it and
|
||||
// is the difference between a working box and one with no device nodes when
|
||||
// it did not. Without `nodev`, obviously — device nodes are the point.
|
||||
Early {
|
||||
source: "devtmpfs",
|
||||
target: "/dev",
|
||||
fstype: "devtmpfs",
|
||||
flags: libc::MS_NOSUID,
|
||||
data: "mode=755",
|
||||
cost: "there are no device nodes at all, so nothing can open the GPU",
|
||||
},
|
||||
Early {
|
||||
source: "devpts",
|
||||
target: "/dev/pts",
|
||||
fstype: "devpts",
|
||||
flags: NOSUID_NODEV | libc::MS_NOEXEC,
|
||||
data: "mode=620,gid=5,ptmxmode=666",
|
||||
cost: "nothing that wants a terminal can allocate one",
|
||||
},
|
||||
// The image creates this directory, and the mode is the load-bearing part:
|
||||
// a workload and the box's own services are different users, and shared
|
||||
// memory between them is how a Vulkan client hands buffers around.
|
||||
Early {
|
||||
source: "tmpfs",
|
||||
target: "/dev/shm",
|
||||
fstype: "tmpfs",
|
||||
flags: NOSUID_NODEV,
|
||||
data: "mode=1777,size=256m",
|
||||
cost: "anything using shared memory fails, which includes most graphics",
|
||||
},
|
||||
Early {
|
||||
source: "sysfs",
|
||||
target: "/sys",
|
||||
@@ -59,7 +97,7 @@ const EARLY: &[Early] = &[
|
||||
flags: NOSUID_NODEV,
|
||||
// The sticky bit, because the workload does not run as this process
|
||||
// does and what it binds here is its own.
|
||||
data: "mode=1777",
|
||||
data: "mode=1777,size=64m",
|
||||
cost: "whatever serves this session's address cannot bind its socket, \
|
||||
so the session never gets one",
|
||||
},
|
||||
@@ -74,20 +112,51 @@ const EARLY: &[Early] = &[
|
||||
// Octal, and without a leading zero on purpose: the kernel parses a
|
||||
// tmpfs mode as octal either way, and this is the spelling `mount`
|
||||
// itself documents.
|
||||
data: "mode=755",
|
||||
data: "mode=755,size=32m",
|
||||
cost: "there is nowhere for a runtime socket to live, so neither the \
|
||||
payload relay nor this session's address can be served",
|
||||
},
|
||||
// The tree a session's shares are mounted into.
|
||||
//
|
||||
// A share's target is named by the descriptor and may be any path under
|
||||
// here, so something has to create directories on a root that is read-only
|
||||
// by design. That is what this is: `workload::mount` calls `create_dir_all`
|
||||
// on each target, which fails with `EROFS` unless the tree it is creating
|
||||
// in is writable.
|
||||
//
|
||||
// **This entry used to be forbidden, and the reason it was forbidden is
|
||||
// gone.** A test here asserted that `/nestri` must never be mounted over,
|
||||
// because a fresh tmpfs would hide the install, the user state and the work
|
||||
// directory that the image had prepared underneath. That was true of the
|
||||
// image that shipped those directories and an `fstab` that mounted into
|
||||
// them. The image prepares nothing here now — the host names every share
|
||||
// and every target — so there is nothing left to hide, and the rule had
|
||||
// become a guard on a hazard that was deleted with the image that had it.
|
||||
// ref(d-0064)
|
||||
//
|
||||
// Small on purpose. Everything real is mounted *over* this, so what remains
|
||||
// is a handful of empty directories; the cap matters for the case where a
|
||||
// share fails to mount and a workload writes to the bare mount point
|
||||
// instead, which would otherwise be RAM the box cannot get back.
|
||||
Early {
|
||||
source: "tmpfs",
|
||||
target: "/nestri",
|
||||
fstype: "tmpfs",
|
||||
flags: NOSUID_NODEV,
|
||||
data: "mode=755,size=4m",
|
||||
cost: "no share can be mounted, because its target cannot be created on a read-only root",
|
||||
},
|
||||
// The relay's own directory, and it is deliberately **not** in the tree the
|
||||
// session's shares live in.
|
||||
//
|
||||
// It was, and that was wrong in a way no test here would have caught: a
|
||||
// fresh tmpfs over the share tree hides every directory the image prepared
|
||||
// underneath it — the install, the user state, the work directory, and the
|
||||
// mount point the log share is attached to from `fstab`. The box then has a
|
||||
// socket and none of the places its workload expects to find its files, and
|
||||
// the exact-path check below cannot notice, because what `fstab` mounts is
|
||||
// a directory *inside* that tree rather than the tree itself.
|
||||
// It was, and moving it out stays right for a reason that outlived the one
|
||||
// originally given. The first reason was that a tmpfs over the share tree
|
||||
// would hide what the image had prepared there; that image is gone and the
|
||||
// entry above now mounts that tree deliberately. The reason that remains is
|
||||
// ownership: this directory is written by this process and by nothing else,
|
||||
// which is what makes the socket in it unreplaceable. The share tree is
|
||||
// mounted into by the host's own shares, so a relay socket living there
|
||||
// would sit in a tree a workload's own share can be attached over.
|
||||
//
|
||||
// Owned by this process and writable by nothing else, which is what makes
|
||||
// the socket in it unreplaceable. The workload reaches it because the
|
||||
@@ -98,10 +167,22 @@ const EARLY: &[Early] = &[
|
||||
target: crate::payload::DIRECTORY,
|
||||
fstype: "tmpfs",
|
||||
flags: NOSUID_NODEV | libc::MS_NOEXEC,
|
||||
data: "mode=755",
|
||||
// One socket lives here, so this is as small as a tmpfs usefully gets.
|
||||
data: "mode=755,size=1m",
|
||||
cost: "the payload relay cannot bind, so nothing reaches the workload \
|
||||
over the channel",
|
||||
},
|
||||
// The root is read-only and some things write here whether or not anything
|
||||
// reads it back. A box's real logs leave over the control channel; this is
|
||||
// so that a library writing a file does not fail on `EROFS` instead.
|
||||
Early {
|
||||
source: "tmpfs",
|
||||
target: "/var/log",
|
||||
fstype: "tmpfs",
|
||||
flags: NOSUID_NODEV | libc::MS_NOEXEC,
|
||||
data: "mode=755,size=16m",
|
||||
cost: "anything that writes a log file fails on a read-only root",
|
||||
},
|
||||
];
|
||||
|
||||
/// Mount what the rest of this component assumes is already there.
|
||||
@@ -220,6 +301,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Every tmpfs is capped.** One without a `size=` may grow to half of RAM,
|
||||
/// and the RAM in question is the whole box's — so an uncapped `/tmp` hands
|
||||
/// a workload a way to OOM the box it is running in by writing files. The
|
||||
/// failure looks like a box that died under load rather than like a missing
|
||||
/// mount option, which is why this is a test.
|
||||
#[test]
|
||||
fn no_tmpfs_is_unbounded() {
|
||||
for early in EARLY {
|
||||
if early.fstype != "tmpfs" {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
early.data.contains("size="),
|
||||
"{} is an uncapped tmpfs",
|
||||
early.target
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `/proc` is mounted before `mountinfo` is read, so it has to be first.
|
||||
#[test]
|
||||
fn proc_is_the_first_entry() {
|
||||
@@ -243,20 +343,32 @@ mod tests {
|
||||
}
|
||||
|
||||
/// **Nothing here may be mounted over the tree the session's shares live
|
||||
/// in.** A fresh tmpfs there hides every directory the image prepared
|
||||
/// underneath — the install, the user state, the work directory, and the
|
||||
/// mount point the log share attaches to — and the exact-path check cannot
|
||||
/// notice, because what is mounted from `fstab` is a directory inside that
|
||||
/// tree rather than the tree itself. So a box would come up with a socket
|
||||
/// and without any of the places its workload looks for its files.
|
||||
/// in**, and the share tree itself is mounted so that targets under it can
|
||||
/// be created at all.
|
||||
///
|
||||
/// This replaces a test that asserted the exact opposite — that `/nestri`
|
||||
/// must never be mounted over — on the grounds that a tmpfs there would
|
||||
/// hide the install, the user state and the work directory the image had
|
||||
/// prepared. The image that prepared them no longer exists; the host names
|
||||
/// every share and every target now, and a read-only root cannot have a
|
||||
/// directory created on it. Measured 2026-09-11: without this entry the
|
||||
/// first real boot refused its own descriptor with
|
||||
/// `/nestri/payload: Read-only file system`.
|
||||
#[test]
|
||||
fn the_share_tree_is_never_mounted_over() {
|
||||
for early in EARLY {
|
||||
assert_ne!(
|
||||
early.target, "/nestri",
|
||||
"this hides the directories the image prepared for a session"
|
||||
);
|
||||
}
|
||||
fn the_share_tree_is_writable_and_the_relay_is_not_inside_it() {
|
||||
let tree = EARLY
|
||||
.iter()
|
||||
.find(|e| e.target == "/nestri")
|
||||
.expect("a share's target cannot be created without this");
|
||||
assert_eq!(tree.fstype, "tmpfs");
|
||||
assert!(
|
||||
tree.data.contains("size="),
|
||||
"an uncapped tmpfs here is RAM a box cannot get back"
|
||||
);
|
||||
assert!(
|
||||
!crate::payload::DIRECTORY.starts_with("/nestri/"),
|
||||
"the relay's socket would sit in a tree a share can be mounted over"
|
||||
);
|
||||
}
|
||||
|
||||
/// The relay's directory is the one this cannot hardcode: it belongs to
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
// nobody else's: reaping whatever the workload orphans, turning a signal into
|
||||
// an ordered shutdown, and being the guest end of the one channel out.
|
||||
//
|
||||
// It does not know what it is running. It is handed a command, a set of shares
|
||||
// and what an exit means, and it carries that out; a field that only makes
|
||||
// sense for one kind of workload cannot reach it. ref(d-0033)
|
||||
// It does not know what it is running. It is handed a set of shares, and then
|
||||
// commands naming what to run and what an exit means, and it carries those out;
|
||||
// a field that only makes sense for one kind of workload cannot reach it.
|
||||
// ref(d-0033)
|
||||
//
|
||||
// It is also the box's only init: there is no service manager in the image, so
|
||||
// the box's own services come up from a table in this binary. ref(d-0064)
|
||||
|
||||
pub mod filesystems;
|
||||
pub mod payload;
|
||||
pub mod reap;
|
||||
pub mod services;
|
||||
pub mod session;
|
||||
pub mod shutdown;
|
||||
pub mod system;
|
||||
pub mod ticket;
|
||||
pub mod workload;
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::time::Duration;
|
||||
|
||||
use nesinit::payload::{self, Ports};
|
||||
use nesinit::reap::{self, Waiters};
|
||||
use nesinit::services::Stack;
|
||||
use nesinit::session::{self, Outcome};
|
||||
use nesinit::shutdown::{self, Machine};
|
||||
use nesinit::ticket;
|
||||
@@ -45,6 +46,13 @@ fn main() -> anyhow::Result<()> {
|
||||
// is no `/proc` to score this process in and nowhere to put a socket.
|
||||
nesinit::filesystems::establish();
|
||||
|
||||
// Everything a distribution's init scripts used to do, and nothing else is
|
||||
// going to: a hostname, the box's address, the directories a session's
|
||||
// sockets live in, and device nodes something is allowed to open. Before
|
||||
// the runtime, so the few processes it starts are waited for directly
|
||||
// rather than racing the reaper into existence. ref(d-0064)
|
||||
nesinit::system::prepare();
|
||||
|
||||
// Both before anything is started, so nothing can be orphaned or scored
|
||||
// in the window where neither is true yet.
|
||||
if let Err(error) = reap::become_subreaper() {
|
||||
@@ -80,7 +88,18 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Reached however the session ended, including an error: an init that
|
||||
// returns leaves the guest running with nothing in it.
|
||||
let mut machine = Guest { workload };
|
||||
//
|
||||
// `is_init` is what makes that safe to say. The three machine-wide steps
|
||||
// below — signal everything, kill everything, power off — are correct for
|
||||
// PID 1 of a box and catastrophic anywhere else, and this program is meant
|
||||
// to be runnable by hand: that is how most of it is tested and it is the
|
||||
// documented way to debug a guest that will not boot. Run as root outside a
|
||||
// box, the old path reached `kill(-1)` and `reboot` the moment the control
|
||||
// channel could not be dialled.
|
||||
let mut machine = Guest {
|
||||
workload,
|
||||
is_init: pid == 1,
|
||||
};
|
||||
shutdown::ordered(&mut machine, GRACE);
|
||||
unreachable!("power_off does not return");
|
||||
}
|
||||
@@ -124,8 +143,13 @@ async fn guest(waiters: &Waiters, workload: &mut Process) -> anyhow::Result<Outc
|
||||
untrusted.clone(),
|
||||
));
|
||||
|
||||
// The box's own services. Nothing is started here: bring-up happens once
|
||||
// the descriptor has been carried out, because a box whose shares are not
|
||||
// where they belong is not a box worth starting a stack in.
|
||||
let mut services = Stack::new(waiters.clone());
|
||||
|
||||
let outcome = tokio::select! {
|
||||
outcome = session::run(channel, workload, &mut ports, &mut found_rx, &untrusted) => outcome?,
|
||||
outcome = session::run(channel, workload, &mut services, &mut ports, &mut found_rx, &untrusted) => outcome?,
|
||||
signal = asked_to_stop() => {
|
||||
signal?;
|
||||
tracing::info!("asked to stop");
|
||||
@@ -170,6 +194,9 @@ async fn asked_to_stop() -> std::io::Result<()> {
|
||||
/// The machine, for real.
|
||||
struct Guest {
|
||||
workload: Process,
|
||||
/// Whether this process is PID 1, and therefore whether the steps that act
|
||||
/// on *the machine* rather than on our own children may be taken at all.
|
||||
is_init: bool,
|
||||
}
|
||||
|
||||
impl Machine for Guest {
|
||||
@@ -194,6 +221,13 @@ impl Machine for Guest {
|
||||
}
|
||||
|
||||
fn signal_rest(&mut self, grace: Duration) {
|
||||
if !self.is_init {
|
||||
tracing::warn!(
|
||||
"not PID 1, so not signalling every process: outside a box that \
|
||||
is this machine's processes, not this box's"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// -1 is every process this one may signal, which as PID 1 is all of
|
||||
// them but itself. The workload has already stopped by here.
|
||||
unsafe { libc::kill(-1, libc::SIGTERM) };
|
||||
@@ -201,15 +235,31 @@ impl Machine for Guest {
|
||||
}
|
||||
|
||||
fn kill_rest(&mut self) {
|
||||
if !self.is_init {
|
||||
return;
|
||||
}
|
||||
unsafe { libc::kill(-1, libc::SIGKILL) };
|
||||
wait_for_quiet(Duration::from_secs(1));
|
||||
}
|
||||
|
||||
fn flush_disks(&mut self) {
|
||||
// Harmless anywhere, so it is not guarded: the worst it does outside a
|
||||
// box is flush somebody's page cache.
|
||||
unsafe { libc::sync() };
|
||||
}
|
||||
|
||||
fn power_off(&mut self) {
|
||||
if !self.is_init {
|
||||
// Everything this process started has been stopped by here, which
|
||||
// is the whole of what it may take responsibility for when it is
|
||||
// not the machine's init. What it prepared — the mounts, the
|
||||
// runtime directories — is deliberately left behind, because that
|
||||
// is exactly what makes a hand-run useful: run it, watch it fail to
|
||||
// reach a control channel that is not there, and then poke at a
|
||||
// guest that is otherwise set up.
|
||||
tracing::warn!("not PID 1, so not powering the machine off");
|
||||
std::process::exit(1);
|
||||
}
|
||||
// SAFETY: reboot is the only way out of a guest whose init is done.
|
||||
unsafe { libc::reboot(libc::RB_POWER_OFF) };
|
||||
// Reached only if the guest refused to power off, which no caller can
|
||||
|
||||
959
apps/nesinit/src/services.rs
Normal file
959
apps/nesinit/src/services.rs
Normal file
@@ -0,0 +1,959 @@
|
||||
// The box's own services, and the table that is the whole of what a box runs
|
||||
// before anything is launched into it.
|
||||
//
|
||||
// There is no service manager in a box and no init scripts, so this is what
|
||||
// replaces them. ref(d-0064)
|
||||
//
|
||||
// # Why the table is in the binary
|
||||
//
|
||||
// A unit format would make this configurable, and nothing wants to configure
|
||||
// it: the services in a box are ours, they are the same in every box, and a
|
||||
// table in a file is a table two images can disagree about. Being able to run
|
||||
// on any distribution comes from depending on no distribution's init scripts,
|
||||
// which this does — not from being told what to start.
|
||||
//
|
||||
// # What is deliberately not here
|
||||
//
|
||||
// **The compositor.** It wraps the workload and is started by a launch, with
|
||||
// that launch's geometry, and dies with it. A compositor in this table would be
|
||||
// a compositor with no geometry to come up with.
|
||||
//
|
||||
// **Restarting.** A service that dies is reported up the channel and left dead.
|
||||
// Whether restarting it is repair or a loop is not visible from inside the box.
|
||||
//
|
||||
// **Readiness beyond "its socket exists".** A service that names a socket is
|
||||
// waited for until that socket is there; nothing here asks it a question or
|
||||
// waits for a bus name. That much was in the init scripts this replaced and
|
||||
// leaving it out was a regression: `spawn` returns at fork, so without it the
|
||||
// bus's client is started before the bus is listening and audio comes up
|
||||
// against nothing. It races rather than failing — which is the shape this
|
||||
// component is least able to see — and the cost of losing the race is a box
|
||||
// that boots, reports itself ready, and has no sound.
|
||||
//
|
||||
// That is as far as it goes. Health checks, restarts and readiness that is not
|
||||
// a file on a path would make this a supervisor; see the decision's own
|
||||
// falsification list.
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
use nesprotocol::lifecycle::Exit;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use crate::reap::{Waiters, Watched};
|
||||
use crate::workload::Failure;
|
||||
|
||||
/// A service that died, and how.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Died {
|
||||
pub name: String,
|
||||
pub exit: Exit,
|
||||
}
|
||||
|
||||
/// The box's service stack, as the session sees it.
|
||||
///
|
||||
/// A trait because the interesting behaviour is the session's — that a death is
|
||||
/// reported and not repaired, that a stack which will not come up refuses the
|
||||
/// box — and none of that needs a process to assert.
|
||||
pub trait Services {
|
||||
/// Bring the stack up in order, and name what came up.
|
||||
///
|
||||
/// Called once, after the shares are mounted and before anything may be
|
||||
/// launched. An empty stack is legitimate: a box with no services still
|
||||
/// boots, and a caller can still launch something that needs none.
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure>;
|
||||
|
||||
/// Deaths, as they happen.
|
||||
///
|
||||
/// A channel rather than a future so the session can wait on it beside the
|
||||
/// control channel, the relay and the address carrier without any of them
|
||||
/// being able to starve the others.
|
||||
fn deaths(&mut self) -> &mut Receiver<Died>;
|
||||
}
|
||||
|
||||
// There is deliberately no ordered `stop_all`, and reverse-order stopping buys
|
||||
// nothing on a machine that is about to be powered off. What there is instead
|
||||
// is a `Drop` that signals the children this stack started — because the
|
||||
// argument for having nothing at all was "this process is PID 1 and the ordered
|
||||
// shutdown signals every process", and that is true of a box and false of the
|
||||
// way this program is run by hand to debug one. Outside PID 1 the old path left
|
||||
// a bus, an audio server and a hub running with sockets nobody was serving.
|
||||
|
||||
/// One service, and everything about starting it.
|
||||
///
|
||||
/// `env` is per-entry rather than inherited: init's own environment is the
|
||||
/// kernel's command line and says nothing a service should read.
|
||||
pub struct Service {
|
||||
/// What appears in a log line and in `initialized`.
|
||||
pub name: &'static str,
|
||||
pub argv: &'static [&'static str],
|
||||
pub env: &'static [(&'static str, &'static str)],
|
||||
/// Who it runs as. `None` means init's own user, which is root.
|
||||
pub user: Option<(u32, u32)>,
|
||||
/// Said when it will not start, in terms of what stops working. The same
|
||||
/// discipline the early filesystems use: a failure that names a cost can be
|
||||
/// acted on, where "could not start pipewire" cannot.
|
||||
pub cost: &'static str,
|
||||
/// Whether the box is unusable without it.
|
||||
///
|
||||
/// A required service that will not start refuses the box, because a caller
|
||||
/// launching into it would get a session that comes up and does not work.
|
||||
/// An optional one is reported and stepped over.
|
||||
pub required: bool,
|
||||
/// The umask to exec under, when the default one is wrong.
|
||||
///
|
||||
/// Only audio sets this, and only because of who has to reach it. A unix
|
||||
/// socket is created `0777` masked by the umask, so the inherited `022`
|
||||
/// gives `0755` -- and connecting to a socket needs *write*, so every user
|
||||
/// but the owner is refused. The services run as one user and a workload
|
||||
/// runs as another, so that is the workload: it finds the socket, cannot
|
||||
/// open it, and plays silently.
|
||||
///
|
||||
/// `0` rather than a mode in PipeWire's own configuration because the
|
||||
/// socket list lives inside a module's arguments, and a drop-in that
|
||||
/// re-declares that module loads it twice.
|
||||
pub umask: Option<u32>,
|
||||
/// A path that exists once this service can be talked to.
|
||||
///
|
||||
/// `None` means "started is ready", which is true of anything nothing else
|
||||
/// in the table connects to. Where something does connect, the path is the
|
||||
/// socket it connects to: `spawn` returns when the child has been forked,
|
||||
/// which is before that child has bound anything, so the next service in
|
||||
/// the table would otherwise be started against a socket that is not there.
|
||||
///
|
||||
/// Existence only. Whether the thing behind the socket answers correctly is
|
||||
/// not knowable from here, and a box is not the place to find out.
|
||||
pub ready: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// How long a service gets to bind its socket before the box gives up on it.
|
||||
///
|
||||
/// Long enough that a cold boot on a slow disk is not cut short, short enough
|
||||
/// that a service which will never bind does not hold the box for a minute
|
||||
/// before saying so. What actually happens is that the wait ends in single-
|
||||
/// digit milliseconds, because the child binds before its parent gets back to
|
||||
/// this loop.
|
||||
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// The user the box's own services run as.
|
||||
///
|
||||
/// **Not the user a workload runs as, and that is the whole reason for the
|
||||
/// number.** A workload sharing a user with these can replace a socket one of
|
||||
/// them listens on and answer in its place — and the answer that matters is the
|
||||
/// address a client is told to connect to. See `ticket::Untrusted`.
|
||||
pub const SERVICE_UID: u32 = 1000;
|
||||
|
||||
/// Where audio's socket lives, for both the services and the workload.
|
||||
///
|
||||
/// # Why not the runtime directory
|
||||
///
|
||||
/// The services run as one user and a workload runs as another, on purpose
|
||||
/// (see [`SERVICE_UID`]). A per-user runtime directory is `0700` and named
|
||||
/// after its own uid, so a socket in the services' one is in a directory the
|
||||
/// workload may not enter, at a path it would not look in anyway.
|
||||
///
|
||||
/// Measured 2026-09-12: the game rendered and had no sound, because it looked
|
||||
/// for audio under its own uid and found nothing. Nothing failed -- a game with
|
||||
/// no audio server plays silently.
|
||||
///
|
||||
/// So audio gets a directory of its own that both users share, named to both
|
||||
/// through `PIPEWIRE_RUNTIME_DIR`. The workload still cannot replace a socket
|
||||
/// here: the directory belongs to the service user and is not writable by the
|
||||
/// workload, which is the property [`crate::ticket::Untrusted`] depends on.
|
||||
pub const AUDIO_DIR: &str = "/run/pipewire";
|
||||
pub const SERVICE_GID: u32 = 1000;
|
||||
|
||||
/// Where a service's runtime sockets live.
|
||||
pub const RUNTIME_DIR: &str = "/run/user/1000";
|
||||
|
||||
/// Somewhere every service may write.
|
||||
///
|
||||
/// # The service user's home is on a read-only root
|
||||
///
|
||||
/// `useradd -m` made `/home/nestri` in the image, and the image is mounted
|
||||
/// read-only, so every library that follows XDG conventions to a default under
|
||||
/// `$HOME` fails there. Measured 2026-09-12: the session manager could not
|
||||
/// write its state on any boot, and anything asking Mesa for a shader cache was
|
||||
/// told it was disabled.
|
||||
///
|
||||
/// The second one is not a warning. Mesa with no writable cache recompiles
|
||||
/// every shader on every run, and the symptom a person sees is a black screen
|
||||
/// or a frozen game rather than a slow one.
|
||||
///
|
||||
/// # Under the runtime directory rather than a tmpfs over the home
|
||||
///
|
||||
/// Mounting a tmpfs at `/home/nestri` would work and would hide the shell
|
||||
/// files the image put there, which is how a debug shell loses its prompt and
|
||||
/// its history for no stated reason. The runtime directory is already a tmpfs,
|
||||
/// already owned by this user, and already made before any service starts.
|
||||
///
|
||||
/// Per boot, which is correct for these: a service's cache is not state anybody
|
||||
/// wants to keep. A *workload's* cache is, and it is pointed at the writable
|
||||
/// share it was given instead.
|
||||
const WRITABLE: &[(&str, &str)] = &[
|
||||
("HOME", "/home/nestri"),
|
||||
("XDG_RUNTIME_DIR", RUNTIME_DIR),
|
||||
("PIPEWIRE_RUNTIME_DIR", AUDIO_DIR),
|
||||
("XDG_CACHE_HOME", "/run/user/1000/cache"),
|
||||
("XDG_STATE_HOME", "/run/user/1000/state"),
|
||||
("XDG_CONFIG_HOME", "/run/user/1000/config"),
|
||||
("XDG_DATA_HOME", "/run/user/1000/data"),
|
||||
];
|
||||
|
||||
/// The stack, in the order it comes up.
|
||||
///
|
||||
/// Ported from the nine init scripts this replaces, and the ordering is theirs:
|
||||
/// the bus before anything that speaks on it, audio before whatever plays into
|
||||
/// it, and the hub last because it binds the sockets the rest connect to.
|
||||
pub const STACK: &[Service] = &[
|
||||
Service {
|
||||
name: "dbus-system",
|
||||
argv: &[
|
||||
"/usr/bin/dbus-daemon",
|
||||
"--system",
|
||||
"--nofork",
|
||||
"--nopidfile",
|
||||
],
|
||||
env: &[],
|
||||
user: None,
|
||||
cost: "nothing that speaks on the system bus can find it",
|
||||
required: true,
|
||||
umask: None,
|
||||
ready: None,
|
||||
},
|
||||
Service {
|
||||
name: "dbus-session",
|
||||
argv: &[
|
||||
"/usr/bin/dbus-daemon",
|
||||
"--session",
|
||||
"--nofork",
|
||||
"--nopidfile",
|
||||
"--address=unix:path=/run/user/1000/bus",
|
||||
],
|
||||
env: &[("XDG_RUNTIME_DIR", RUNTIME_DIR)],
|
||||
user: Some((SERVICE_UID, SERVICE_GID)),
|
||||
cost: "audio and anything else expecting a session bus will not start",
|
||||
required: true,
|
||||
umask: None,
|
||||
// Every service after this one is handed this path as its bus address,
|
||||
// and a bus address that is not bound yet is a service that starts,
|
||||
// finds nothing, and carries on without a bus.
|
||||
ready: Some("/run/user/1000/bus"),
|
||||
},
|
||||
Service {
|
||||
name: "pipewire",
|
||||
argv: &["/usr/bin/pipewire"],
|
||||
env: &[
|
||||
("XDG_RUNTIME_DIR", RUNTIME_DIR),
|
||||
("PIPEWIRE_RUNTIME_DIR", AUDIO_DIR),
|
||||
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
|
||||
],
|
||||
user: Some((SERVICE_UID, SERVICE_GID)),
|
||||
cost: "the session has no audio at all",
|
||||
required: true,
|
||||
// So the workload, which is not this user, can open the socket.
|
||||
umask: Some(0),
|
||||
// Both the session manager and the sender connect here, and so does
|
||||
// the workload once it starts.
|
||||
ready: Some("/run/pipewire/pipewire-0"),
|
||||
},
|
||||
Service {
|
||||
name: "wireplumber",
|
||||
argv: &["/usr/bin/wireplumber"],
|
||||
env: &[
|
||||
("XDG_RUNTIME_DIR", RUNTIME_DIR),
|
||||
("PIPEWIRE_RUNTIME_DIR", AUDIO_DIR),
|
||||
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
|
||||
],
|
||||
user: Some((SERVICE_UID, SERVICE_GID)),
|
||||
// Optional on purpose: pipewire runs without a session manager, so a
|
||||
// box with no wireplumber has audio nodes and nothing routing them,
|
||||
// which is a degraded session rather than no session.
|
||||
cost: "audio devices exist but nothing routes them",
|
||||
required: false,
|
||||
umask: None,
|
||||
ready: None,
|
||||
},
|
||||
Service {
|
||||
name: "neswire",
|
||||
argv: &["/usr/bin/neswire"],
|
||||
env: &[
|
||||
("XDG_RUNTIME_DIR", RUNTIME_DIR),
|
||||
("PIPEWIRE_RUNTIME_DIR", AUDIO_DIR),
|
||||
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
|
||||
],
|
||||
user: Some((SERVICE_UID, SERVICE_GID)),
|
||||
cost: "the client gets pictures and no sound",
|
||||
required: false,
|
||||
umask: None,
|
||||
ready: None,
|
||||
},
|
||||
Service {
|
||||
name: "neshub",
|
||||
argv: &["/usr/bin/neshub"],
|
||||
env: &[("XDG_RUNTIME_DIR", RUNTIME_DIR)],
|
||||
user: Some((SERVICE_UID, SERVICE_GID)),
|
||||
// The one whose absence has no workaround: it owns the endpoint, so
|
||||
// without it the session has no address and nothing can reach the box.
|
||||
cost: "the session has no address, so no client can reach it",
|
||||
required: true,
|
||||
umask: None,
|
||||
ready: None,
|
||||
},
|
||||
];
|
||||
|
||||
/// The stack as running processes.
|
||||
pub struct Stack {
|
||||
waiters: Waiters,
|
||||
table: &'static [Service],
|
||||
running: Vec<(&'static str, Watched)>,
|
||||
deaths: Receiver<Died>,
|
||||
reported: Sender<Died>,
|
||||
}
|
||||
|
||||
impl Stack {
|
||||
pub fn new(waiters: Waiters) -> Self {
|
||||
Self::from_table(waiters, STACK)
|
||||
}
|
||||
|
||||
/// The same thing against a different table, which is how the ordering and
|
||||
/// the required/optional rule are tested without a `/usr/bin` full of
|
||||
/// services.
|
||||
pub fn from_table(waiters: Waiters, table: &'static [Service]) -> Self {
|
||||
// Small: what goes on it is one line per service death, and a box does
|
||||
// not have many services to lose.
|
||||
let (reported, deaths) = tokio::sync::mpsc::channel(16);
|
||||
Self {
|
||||
waiters,
|
||||
table,
|
||||
running: Vec::new(),
|
||||
deaths,
|
||||
reported,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pids of what is running, for a test that has to ask the kernel
|
||||
/// whether they are still there. Nothing in the program uses it: signalling
|
||||
/// happens in `Drop`, where the pids are already to hand.
|
||||
pub fn pids(&self) -> Vec<i32> {
|
||||
self.running.iter().map(|(_, w)| w.pid).collect()
|
||||
}
|
||||
|
||||
/// Wait for a service to bind the socket it said it would.
|
||||
///
|
||||
/// Blocking, on a worker of a multi-threaded runtime: bring-up is a sequence
|
||||
/// and there is nothing else for this task to do while it waits. Polling rather
|
||||
/// than an inotify watch because the directory may not exist yet either, and a
|
||||
/// watch that has to handle that is more machinery than 15 seconds of `stat`.
|
||||
///
|
||||
/// A failure is the same shape as a failure to start, so the required/optional
|
||||
/// rule above decides what it costs: a required service that never binds refuses
|
||||
/// the box, an optional one is stepped over.
|
||||
fn await_ready(&self, service: &Service, before: Option<Identity>) -> Result<(), Failure> {
|
||||
let Some(path) = service.ready else {
|
||||
return Ok(());
|
||||
};
|
||||
// The watch for what was just started, so a service that dies during its
|
||||
// own bring-up is not waited out for the full timeout.
|
||||
let started = self.running.last().map(|(_, watched)| watched.pid);
|
||||
await_path(
|
||||
service.name,
|
||||
path,
|
||||
before,
|
||||
&|| started.is_none_or(is_alive),
|
||||
READY_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn(&mut self, service: &'static Service) -> Result<(), Failure> {
|
||||
let Some((program, args)) = service.argv.split_first() else {
|
||||
return Err(Failure::new(format!(
|
||||
"{}: the command is empty",
|
||||
service.name
|
||||
)));
|
||||
};
|
||||
|
||||
// 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);
|
||||
command.env_clear();
|
||||
command.envs(WRITABLE.iter().copied());
|
||||
// The service's own entry last, so a service that states one of these
|
||||
// for itself wins over the defaults above.
|
||||
command.envs(service.env.iter().copied());
|
||||
|
||||
let mask = service.umask;
|
||||
if let Some((uid, gid)) = service.user {
|
||||
// SAFETY: the closure runs between fork and exec in the child,
|
||||
// where only async-signal-safe calls are allowed. These two are,
|
||||
// and it allocates nothing.
|
||||
unsafe {
|
||||
command.pre_exec(move || {
|
||||
if let Some(mask) = mask {
|
||||
// SAFETY: `umask` cannot fail and touches only this
|
||||
// child, between fork and exec.
|
||||
libc::umask(mask as libc::mode_t);
|
||||
}
|
||||
// gid first: dropping the uid first would lose the
|
||||
// privilege needed to set the gid at all.
|
||||
if libc::setgid(gid) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if libc::setuid(uid) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut watched = self
|
||||
.waiters
|
||||
.watch(|| Ok(command.spawn()?.id() as i32))
|
||||
.map_err(|error| Failure::new(format!("{}: {error}", service.name)))?;
|
||||
|
||||
// The exit is moved onto a task that turns it into one line on the
|
||||
// channel. Nothing here awaits it: bring-up is a sequence of starts,
|
||||
// and a service that exits during it is a death like any other.
|
||||
let exit = watched.take_exit().expect("a new watch has its exit");
|
||||
let reported = self.reported.clone();
|
||||
let name = service.name;
|
||||
tokio::spawn(async move {
|
||||
let exit = match exit.await {
|
||||
Ok(exit) => exit,
|
||||
// The watch was dropped, which happens on the way down. There
|
||||
// is nothing to report and nobody left to report it to.
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = reported
|
||||
.send(Died {
|
||||
name: name.to_string(),
|
||||
exit,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
self.running.push((service.name, watched));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Services for Stack {
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure> {
|
||||
let mut up = Vec::new();
|
||||
// Lifted out so the loop does not hold a borrow of `self` across the
|
||||
// start it is asking for.
|
||||
let table = self.table;
|
||||
for service in table {
|
||||
// Taken before the service is started, because what makes a
|
||||
// socket this service's is that it was not there -- or was a
|
||||
// different file -- a moment ago.
|
||||
let before = service.ready.and_then(identity_of);
|
||||
match self
|
||||
.spawn(service)
|
||||
.and_then(|()| self.await_ready(service, before))
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(service = service.name, "started");
|
||||
up.push(service.name.to_string());
|
||||
}
|
||||
Err(failure) if service.required => {
|
||||
// Named with its cost rather than only its error: which
|
||||
// service failed decides whether the box is worth having,
|
||||
// and that judgement is made outside the box.
|
||||
return Err(Failure::new(format!(
|
||||
"{} — {}",
|
||||
failure.reason, service.cost
|
||||
)));
|
||||
}
|
||||
Err(failure) => tracing::warn!(
|
||||
service = service.name,
|
||||
cost = service.cost,
|
||||
"could not start, and the box goes on without it: {}",
|
||||
failure.reason
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(up)
|
||||
}
|
||||
|
||||
fn deaths(&mut self) -> &mut Receiver<Died> {
|
||||
&mut self.deaths
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Stack {
|
||||
/// Ask everything this stack started to stop.
|
||||
///
|
||||
/// The stack owns these processes and nothing else does, so its going away
|
||||
/// is the last moment anything knows their pids. As PID 1 the ordered
|
||||
/// shutdown would reach them anyway and a second SIGTERM costs nothing;
|
||||
/// run by hand it is the only thing that reaches them at all.
|
||||
///
|
||||
/// Asked, not waited for: this runs while the runtime is going down, so
|
||||
/// there is nothing left to reap them with. A signalled child that outlives
|
||||
/// this process is reparented and dies on its own, which is the outcome we
|
||||
/// wanted; an unsignalled one keeps its sockets.
|
||||
fn drop(&mut self) {
|
||||
for (name, watched) in &self.running {
|
||||
// The same rule as everywhere else that signals: a pid that has
|
||||
// been reaped may already belong to something else.
|
||||
if !watched.running() {
|
||||
continue;
|
||||
}
|
||||
tracing::debug!(service = name, pid = watched.pid, "stopping");
|
||||
// SAFETY: two integers, and a pid that has gone fails with ESRCH.
|
||||
unsafe { libc::kill(watched.pid, libc::SIGTERM) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which file is at a path, as the kernel tells them apart.
|
||||
///
|
||||
/// Device and inode rather than a modification time: a socket rebound in the
|
||||
/// same second has the same mtime, and `dbus-daemon` and `pipewire` both unlink
|
||||
/// and bind afresh, which is a new inode every time.
|
||||
type Identity = (u64, u64);
|
||||
|
||||
fn identity_of(path: &str) -> Option<Identity> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
std::fs::metadata(path).ok().map(|at| (at.dev(), at.ino()))
|
||||
}
|
||||
|
||||
/// Whether a pid is still a process at all.
|
||||
///
|
||||
/// Signal 0 sends nothing and only asks. A child that has exited and not yet
|
||||
/// been reaped still answers, which is why this is a second opinion rather than
|
||||
/// the only one -- the watch's own record is the first.
|
||||
fn is_alive(pid: i32) -> bool {
|
||||
// SAFETY: two integers; a pid that has gone fails with ESRCH.
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
|
||||
/// The waiting itself, with the deadline passed in so a test can assert the
|
||||
/// giving-up without waiting out a real one.
|
||||
fn await_path(
|
||||
name: &str,
|
||||
path: &str,
|
||||
before: Option<Identity>,
|
||||
alive: &dyn Fn() -> bool,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(), Failure> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
loop {
|
||||
// A *different* file than the one that was there before it started.
|
||||
//
|
||||
// Existence alone is not readiness, because `/run` is not always empty
|
||||
// when this starts. In a box it is a fresh tmpfs and anything at these
|
||||
// paths is ours; run by hand -- how a guest that will not boot is
|
||||
// debugged -- the host's own `/run` is underneath, and a socket left by
|
||||
// a previous run, or the developer's own session bus, is sitting at
|
||||
// exactly the path being waited on. Taking that as proof would start
|
||||
// everything downstream against a socket with nothing behind it, and
|
||||
// report the box initialized.
|
||||
//
|
||||
// Compared rather than deleted. Unlinking first would be the obvious
|
||||
// fix and it is the dangerous one: outside a box that path can belong
|
||||
// to something else that is alive and using it.
|
||||
let now = identity_of(path);
|
||||
if now.is_some() && now != before {
|
||||
return Ok(());
|
||||
}
|
||||
// A service that has already left will not bind anything, and waiting
|
||||
// out the full timeout for it buys nothing but a slower failure.
|
||||
if !alive() {
|
||||
return Err(Failure::new(format!(
|
||||
"{name} exited before it bound {path}"
|
||||
)));
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
// The path, because that is the actionable half: a service that
|
||||
// binds somewhere else is indistinguishable from one that never
|
||||
// bound, and only one of those is fixed by looking at the service.
|
||||
let stale = if before.is_some() {
|
||||
", and what is there is the file that was there before it started"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return Err(Failure::new(format!(
|
||||
"{name}: {path} did not appear within {}s of starting it{stale}",
|
||||
timeout.as_secs()
|
||||
)));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod double {
|
||||
use super::*;
|
||||
|
||||
/// A stack that starts nothing, so what the session does with it is the
|
||||
/// only thing under test.
|
||||
pub struct Double {
|
||||
pub brought_up: usize,
|
||||
pub failure: Option<Failure>,
|
||||
pub names: Vec<String>,
|
||||
deaths: Receiver<Died>,
|
||||
pub report: Sender<Died>,
|
||||
}
|
||||
|
||||
impl Default for Double {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Double {
|
||||
pub fn new() -> Self {
|
||||
let (report, deaths) = tokio::sync::mpsc::channel(8);
|
||||
Self {
|
||||
brought_up: 0,
|
||||
failure: None,
|
||||
names: vec!["dbus-system".into(), "neshub".into()],
|
||||
deaths,
|
||||
report,
|
||||
}
|
||||
}
|
||||
|
||||
/// A stack that refuses to come up, which refuses the box.
|
||||
pub fn refuses(reason: &str) -> Self {
|
||||
let mut double = Self::new();
|
||||
double.failure = Some(Failure::new(reason));
|
||||
double
|
||||
}
|
||||
}
|
||||
|
||||
impl Services for Double {
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure> {
|
||||
self.brought_up += 1;
|
||||
match &self.failure {
|
||||
Some(failure) => Err(failure.clone()),
|
||||
None => Ok(self.names.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn deaths(&mut self) -> &mut Receiver<Died> {
|
||||
&mut self.deaths
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The services and the workload have to look in the same place, and it
|
||||
/// cannot be either one's runtime directory: those are 0700 and named
|
||||
/// after a uid, and these are deliberately two different users.
|
||||
#[test]
|
||||
fn audio_is_somewhere_both_users_can_reach() {
|
||||
assert!(
|
||||
!AUDIO_DIR.starts_with("/run/user/"),
|
||||
"a per-user runtime directory is 0700 and the other user is not in it"
|
||||
);
|
||||
let audio: Vec<&Service> = STACK
|
||||
.iter()
|
||||
.filter(|s| s.env.iter().any(|(k, _)| *k == "PIPEWIRE_RUNTIME_DIR"))
|
||||
.collect();
|
||||
assert!(
|
||||
!audio.is_empty(),
|
||||
"no service was told where audio lives, so none of them agree"
|
||||
);
|
||||
for service in audio {
|
||||
let told = service
|
||||
.env
|
||||
.iter()
|
||||
.find(|(k, _)| *k == "PIPEWIRE_RUNTIME_DIR")
|
||||
.map(|(_, v)| *v);
|
||||
assert_eq!(
|
||||
told,
|
||||
Some(AUDIO_DIR),
|
||||
"{} looks somewhere else",
|
||||
service.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A service others connect to is waited for, and the path waited on is
|
||||
/// the path they are given.
|
||||
///
|
||||
/// Two constants that have to agree and are written in two places is how
|
||||
/// three of the four crossings in ref(d-0065) broke, so they are compared
|
||||
/// here rather than trusted to stay in step.
|
||||
#[test]
|
||||
fn what_is_waited_for_is_where_the_others_are_told_to_look() {
|
||||
let bus = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == "dbus-session")
|
||||
.expect("the session bus is in the table");
|
||||
let waited = bus.ready.expect(
|
||||
"without this, everything handed this bus address is started before \
|
||||
anything is listening on it",
|
||||
);
|
||||
let address = format!("unix:path={waited}");
|
||||
let clients: Vec<&Service> = STACK
|
||||
.iter()
|
||||
.filter(|s| s.env.iter().any(|(k, _)| *k == "DBUS_SESSION_BUS_ADDRESS"))
|
||||
.collect();
|
||||
assert!(!clients.is_empty(), "nothing was told where the bus is");
|
||||
for client in clients {
|
||||
let told = client
|
||||
.env
|
||||
.iter()
|
||||
.find(|(k, _)| *k == "DBUS_SESSION_BUS_ADDRESS")
|
||||
.map(|(_, v)| *v);
|
||||
assert_eq!(
|
||||
told,
|
||||
Some(address.as_str()),
|
||||
"{} connects somewhere the box never waited for",
|
||||
client.name
|
||||
);
|
||||
}
|
||||
|
||||
let pipewire = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == "pipewire")
|
||||
.expect("audio is in the table");
|
||||
let waited = pipewire.ready.expect("audio is connected to by everything");
|
||||
assert!(
|
||||
waited.starts_with(AUDIO_DIR),
|
||||
"audio is waited for at {waited} and served from {AUDIO_DIR}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A service that names no socket is ready when it has been started, and
|
||||
/// the wait has to be free in that case: most of the table is like this.
|
||||
#[test]
|
||||
fn a_service_that_names_no_socket_is_not_waited_for() {
|
||||
let hub = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == "neshub")
|
||||
.expect("the hub is in the table");
|
||||
assert!(hub.ready.is_none());
|
||||
let stack = Stack::from_table(Waiters::new(), STACK);
|
||||
stack
|
||||
.await_ready(hub, None)
|
||||
.expect("a service with nothing to wait for waited anyway");
|
||||
}
|
||||
|
||||
/// A file that was already there is not this service's socket.
|
||||
///
|
||||
/// In a box `/run` is a fresh tmpfs and anything at these paths is ours.
|
||||
/// Run by hand -- which is how a guest that will not boot is debugged --
|
||||
/// the host's own `/run` is underneath, and a socket from a previous run or
|
||||
/// the developer's own session bus sits at exactly the path being waited
|
||||
/// on. Taking it as proof starts everything downstream against a socket
|
||||
/// with nothing behind it and reports the box initialized.
|
||||
#[test]
|
||||
fn a_file_that_was_there_before_is_not_proof_that_anything_started() {
|
||||
let dir = std::env::temp_dir().join(format!("nesinit-stale-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("a directory to put a stale socket in");
|
||||
let path = dir.join("bus");
|
||||
std::fs::write(&path, b"a socket from a previous run").expect("the stale file");
|
||||
let at = path.to_str().expect("a path");
|
||||
|
||||
let before = identity_of(at);
|
||||
assert!(before.is_some(), "the stale file is there to be found");
|
||||
|
||||
let failure = await_path(
|
||||
"dbus-session",
|
||||
at,
|
||||
before,
|
||||
&|| true,
|
||||
std::time::Duration::from_millis(50),
|
||||
)
|
||||
.expect_err("a file from before was taken as this service's socket");
|
||||
assert!(
|
||||
failure.reason.contains("before it started"),
|
||||
"the reason has to say which of the two failures this is: {}",
|
||||
failure.reason
|
||||
);
|
||||
|
||||
// Replaced, which is what binding a unix socket does: both daemons
|
||||
// here unlink and bind afresh, so the inode is new.
|
||||
std::fs::remove_file(&path).expect("removing the stale file");
|
||||
std::fs::write(&path, b"the new one").expect("the new file");
|
||||
await_path(
|
||||
"dbus-session",
|
||||
at,
|
||||
before,
|
||||
&|| true,
|
||||
std::time::Duration::from_millis(50),
|
||||
)
|
||||
.expect("a different file at the path is this service's socket");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A service that died during its own bring-up is not waited out.
|
||||
///
|
||||
/// The timeout is fifteen seconds and a dead service will never bind, so
|
||||
/// the box would take that long to say something it already knew -- once
|
||||
/// per service, in order.
|
||||
#[test]
|
||||
fn a_service_that_has_already_left_is_not_waited_for() {
|
||||
let began = std::time::Instant::now();
|
||||
let failure = await_path(
|
||||
"pipewire",
|
||||
"/nonexistent/pipewire-0",
|
||||
None,
|
||||
&|| false,
|
||||
std::time::Duration::from_secs(15),
|
||||
)
|
||||
.expect_err("a dead service was treated as ready");
|
||||
assert!(
|
||||
failure.reason.contains("exited before it bound"),
|
||||
"{}",
|
||||
failure.reason
|
||||
);
|
||||
assert!(
|
||||
began.elapsed() < std::time::Duration::from_secs(1),
|
||||
"it waited out the timeout for a service that had already gone"
|
||||
);
|
||||
}
|
||||
|
||||
/// A socket that never appears is a failure, not a wait that ends quietly.
|
||||
///
|
||||
/// The distinction matters because the required/optional rule above acts on
|
||||
/// it: a required service that never binds has to refuse the box rather
|
||||
/// than let one boot that reports itself ready and does not work.
|
||||
#[test]
|
||||
fn a_socket_that_never_appears_is_a_failure_that_names_it() {
|
||||
let failure = await_path(
|
||||
"pipewire",
|
||||
"/nonexistent/pipewire-0",
|
||||
None,
|
||||
&|| true,
|
||||
std::time::Duration::from_millis(50),
|
||||
)
|
||||
.expect_err("a socket that is not there was treated as ready");
|
||||
assert!(failure.reason.contains("pipewire"), "{}", failure.reason);
|
||||
assert!(
|
||||
failure.reason.contains("/nonexistent/pipewire-0"),
|
||||
"{}",
|
||||
failure.reason
|
||||
);
|
||||
}
|
||||
|
||||
/// A unix socket is created 0777 masked by the umask, and connecting to
|
||||
/// one needs write. The inherited 022 therefore refuses every user but the
|
||||
/// owner -- and the workload is not the owner.
|
||||
#[test]
|
||||
fn the_audio_socket_is_reachable_by_a_user_who_does_not_own_it() {
|
||||
let pipewire = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == "pipewire")
|
||||
.expect("audio is in the table");
|
||||
assert_eq!(
|
||||
pipewire.umask,
|
||||
Some(0),
|
||||
"with any other umask the game finds the socket and cannot open it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two that failed on a real boot, and the reason each matters.
|
||||
#[test]
|
||||
fn every_service_has_somewhere_to_write() {
|
||||
let names: Vec<&str> = WRITABLE.iter().map(|(k, _)| *k).collect();
|
||||
assert!(
|
||||
names.contains(&"XDG_STATE_HOME"),
|
||||
"the session manager could not write its state on any boot"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"XDG_CACHE_HOME"),
|
||||
"no shader cache means recompiling every shader every run, and \
|
||||
what that looks like is a black screen rather than a slow one"
|
||||
);
|
||||
for (_, path) in WRITABLE {
|
||||
if path.starts_with("/run/") || *path == "/home/nestri" {
|
||||
continue;
|
||||
}
|
||||
panic!("{path} is not somewhere a read-only root lets a service write");
|
||||
}
|
||||
}
|
||||
|
||||
/// The table is data, and the things that make it wrong are checkable
|
||||
/// without running any of it.
|
||||
#[test]
|
||||
fn every_service_can_be_started_and_says_what_it_costs() {
|
||||
for service in STACK {
|
||||
assert!(!service.name.is_empty(), "a service with no name");
|
||||
assert!(!service.argv.is_empty(), "{}: nothing to run", service.name);
|
||||
assert!(
|
||||
service.argv[0].starts_with('/'),
|
||||
"{}: not an absolute path, so it depends on a PATH init does not set",
|
||||
service.name
|
||||
);
|
||||
assert!(
|
||||
!service.cost.is_empty(),
|
||||
"{}: no cost, so a failure cannot be judged from outside",
|
||||
service.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_service_is_named_twice() {
|
||||
let mut names: Vec<_> = STACK.iter().map(|s| s.name).collect();
|
||||
names.sort_unstable();
|
||||
let count = names.len();
|
||||
names.dedup();
|
||||
assert_eq!(count, names.len(), "two services share a name: {names:?}");
|
||||
}
|
||||
|
||||
/// The compositor is started by a launch, with that launch's geometry. One
|
||||
/// in this table would be one with no geometry to come up with.
|
||||
#[test]
|
||||
fn the_compositor_is_not_a_service() {
|
||||
for service in STACK {
|
||||
assert!(
|
||||
!service.name.contains("scope") && !service.argv[0].contains("nescope"),
|
||||
"the compositor is in the service table: {}",
|
||||
service.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every service runs as init or as the one service user, and never as
|
||||
/// anything else.
|
||||
///
|
||||
/// The uid a workload runs as arrives in its launch and is not known here,
|
||||
/// so this cannot compare the two directly. What it can do is refuse a
|
||||
/// third user appearing in this table — because the separation that matters
|
||||
/// is that a workload never shares a user with these, and a service quietly
|
||||
/// given some other uid is how that stops being true. A workload sharing a
|
||||
/// user with a service can replace a socket it listens on and answer in its
|
||||
/// place, and the answer that matters is the address a client is told to
|
||||
/// connect to. See `ticket::Untrusted`.
|
||||
#[test]
|
||||
fn a_service_runs_as_init_or_as_the_service_user_and_nothing_else() {
|
||||
for service in STACK {
|
||||
if let Some((uid, gid)) = service.user {
|
||||
assert_eq!(
|
||||
(uid, gid),
|
||||
(SERVICE_UID, SERVICE_GID),
|
||||
"{} runs as a third user",
|
||||
service.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A required service whose absence has a workaround should not be
|
||||
/// required, and an optional one whose absence has none should not be
|
||||
/// optional. Only the second half is checkable, and it is the one that
|
||||
/// matters: the address is what a client needs.
|
||||
#[test]
|
||||
fn whatever_owns_the_address_is_required() {
|
||||
let hub = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == "neshub")
|
||||
.expect("something has to own the endpoint");
|
||||
assert!(
|
||||
hub.required,
|
||||
"a box with no address is a box nothing can reach"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
539
apps/nesinit/src/system.rs
Normal file
539
apps/nesinit/src/system.rs
Normal file
@@ -0,0 +1,539 @@
|
||||
// The rest of what an init system does, and what a box needs before anything
|
||||
// in it can work: a hostname, an address, the directories a session's sockets
|
||||
// live in, and device nodes something is allowed to open.
|
||||
//
|
||||
// None of this is interesting and all of it is load-bearing. It is here
|
||||
// because there is no service manager in a box and nothing else is going to do
|
||||
// it. ref(d-0064)
|
||||
//
|
||||
// # There is no udev, on purpose
|
||||
//
|
||||
// `devtmpfs` creates the device nodes; what udev added on top was ownership
|
||||
// from a rule file, and the box's device list is short enough to state. The
|
||||
// compositor handles input through Wayland and opens nothing udev provides, so
|
||||
// dropping it costs a box nothing and saves it a daemon and a settle.
|
||||
//
|
||||
// # Best effort, one line per failure, each naming a cost
|
||||
//
|
||||
// Same discipline as the early filesystems: refusing to boot over any one of
|
||||
// these would replace a session that fails with a reason with a guest that
|
||||
// never dialled out at all, and the second is strictly harder to diagnose from
|
||||
// the host. A box with no address still boots and still says so.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::services::{RUNTIME_DIR, SERVICE_GID, SERVICE_UID};
|
||||
|
||||
/// What the box calls itself.
|
||||
///
|
||||
/// Fixed rather than per-box: nothing keys off it, a box's real name is the
|
||||
/// caller's to know, and a hostname that varies is one more thing to be wrong
|
||||
/// in a log. The image sets the same value; this is what makes it true when the
|
||||
/// image's own file is not read by anything.
|
||||
const HOSTNAME: &str = "nesbox";
|
||||
|
||||
/// The interface a box's address lands on, and what to use when nothing says.
|
||||
///
|
||||
/// The defaults match the host's own tap addressing. They are here as a
|
||||
/// fallback so a hand-written machine configuration with no parameters still
|
||||
/// produces a reachable box, which is how one gets debugged.
|
||||
const IFACE: &str = "eth0";
|
||||
const DEFAULT_ADDRESS: &str = "172.30.0.2/24";
|
||||
const DEFAULT_GATEWAY: &str = "172.30.0.1";
|
||||
|
||||
/// Do all of it. Called once, before anything else in the box exists.
|
||||
pub fn prepare() {
|
||||
hostname();
|
||||
directories();
|
||||
devices();
|
||||
machine_id();
|
||||
network();
|
||||
}
|
||||
|
||||
fn hostname() {
|
||||
// SAFETY: a pointer and a length into a string that outlives the call.
|
||||
let set = unsafe { libc::sethostname(HOSTNAME.as_ptr().cast(), HOSTNAME.len()) };
|
||||
if set != 0 {
|
||||
tracing::warn!(
|
||||
error = %std::io::Error::last_os_error(),
|
||||
"could not set the hostname, so log lines from inside this box are \
|
||||
harder to tell apart"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory a session needs, and who has to be able to write in it.
|
||||
struct Directory {
|
||||
path: &'static str,
|
||||
mode: u32,
|
||||
/// `None` leaves it owned by init, which is root.
|
||||
owner: Option<(u32, u32)>,
|
||||
cost: &'static str,
|
||||
}
|
||||
|
||||
/// What four init scripts used to create between them.
|
||||
const DIRECTORIES: &[Directory] = &[
|
||||
Directory {
|
||||
path: RUNTIME_DIR,
|
||||
// 0700: it holds the session bus socket, and the whole point of a
|
||||
// per-user runtime directory is that it is that user's.
|
||||
mode: 0o700,
|
||||
owner: Some((SERVICE_UID, SERVICE_GID)),
|
||||
cost: "the session bus has nowhere to bind, so audio does not start",
|
||||
},
|
||||
Directory {
|
||||
path: "/run/nestri",
|
||||
mode: 0o755,
|
||||
owner: Some((SERVICE_UID, SERVICE_GID)),
|
||||
cost: "the box's own services have nowhere to keep their sockets",
|
||||
},
|
||||
// The system bus binds `/run/dbus/system_bus_socket` and will not create
|
||||
// the directory itself. `/run` is a fresh tmpfs every boot, so without this
|
||||
// the bus exits 1 immediately and the init reports a dead service on every
|
||||
// single boot -- measured 2026-09-11, on the first box that got this far.
|
||||
//
|
||||
// What it costs is not obvious from the message: audio still starts, but
|
||||
// PipeWire loses RTKit and runs without realtime scheduling, which is a
|
||||
// latency problem that looks like nothing at boot.
|
||||
//
|
||||
// Owned by root rather than the service user: the bus is started as root
|
||||
// and drops itself, and a directory the session could replace is a socket
|
||||
// the session could impersonate.
|
||||
// Audio's socket, shared by the services that serve it and the workload
|
||||
// that plays through it -- who are deliberately different users, so a
|
||||
// per-user runtime directory cannot hold it. See `services::AUDIO_DIR`.
|
||||
//
|
||||
// Owned by the service user and not writable by the workload: the workload
|
||||
// must be able to *open* the socket in here and must never be able to
|
||||
// replace it, which is the property `ticket::Untrusted` rests on.
|
||||
Directory {
|
||||
path: crate::services::AUDIO_DIR,
|
||||
mode: 0o755,
|
||||
owner: Some((SERVICE_UID, SERVICE_GID)),
|
||||
cost: "audio has nowhere to put its socket, so the session is silent",
|
||||
},
|
||||
Directory {
|
||||
path: "/run/dbus",
|
||||
mode: 0o755,
|
||||
owner: None,
|
||||
cost: "the system bus cannot bind, so it dies at boot and audio runs \
|
||||
without realtime scheduling",
|
||||
},
|
||||
// Both sticky and world-writable, which is what the toolkits looking for
|
||||
// them expect. A workload and the services are different users and either
|
||||
// may create a socket here.
|
||||
Directory {
|
||||
path: "/tmp/.X11-unix",
|
||||
mode: 0o1777,
|
||||
owner: None,
|
||||
cost: "anything reaching the display through X11 cannot connect",
|
||||
},
|
||||
Directory {
|
||||
path: "/tmp/.ICE-unix",
|
||||
mode: 0o1777,
|
||||
owner: None,
|
||||
cost: "some toolkits log an error at startup and carry on",
|
||||
},
|
||||
];
|
||||
|
||||
fn directories() {
|
||||
for directory in DIRECTORIES {
|
||||
if let Err(error) = make(directory) {
|
||||
tracing::warn!(
|
||||
path = directory.path,
|
||||
cost = directory.cost,
|
||||
"could not prepare a directory: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make(directory: &Directory) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
std::fs::create_dir_all(directory.path)?;
|
||||
// Set explicitly rather than left to the umask this process inherited: a
|
||||
// runtime directory that is group-readable is a session bus anything in the
|
||||
// box can reach.
|
||||
std::fs::set_permissions(
|
||||
directory.path,
|
||||
std::fs::Permissions::from_mode(directory.mode),
|
||||
)?;
|
||||
|
||||
if let Some((uid, gid)) = directory.owner {
|
||||
chown(directory.path, uid, gid)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A device node the box has to be able to open, and by whom.
|
||||
///
|
||||
/// This is the whole of what udev's rules were doing for a box.
|
||||
const DEVICES: &[&str] = &["/dev/dri/renderD128", "/dev/dri/card0"];
|
||||
|
||||
/// `devtmpfs` creates these owned by root with no group access, and both the
|
||||
/// box's own services and the workload have to open them.
|
||||
///
|
||||
/// **Mode `0666`, and it is deliberate.** Outside a box that would be wrong.
|
||||
/// Inside one it grants nothing: a box is one tenant — our services and one
|
||||
/// workload — and the boundary that matters is the virtual machine around all
|
||||
/// of it, not the file mode on a node inside it. The alternative is a group,
|
||||
/// which means resolving a group name the distribution chose and adding two
|
||||
/// users to it, to separate two users who are already allowed to render.
|
||||
fn devices() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for path in DEVICES {
|
||||
if !Path::new(path).exists() {
|
||||
// Not a warning. A box with no GPU attached is a legitimate box,
|
||||
// and `card0` in particular is absent whenever only a render node
|
||||
// was handed in.
|
||||
tracing::debug!(path, "no such device in this box");
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666)) {
|
||||
tracing::warn!(
|
||||
path,
|
||||
"could not open up a device node, so a workload may not be able \
|
||||
to render at all: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the box an id of its own, per boot.
|
||||
///
|
||||
/// The bus wants one and will not start without it. It is generated here rather
|
||||
/// than baked into the image on purpose: an image with one in it makes every box
|
||||
/// built from that image the same machine, which nothing keys off today and is
|
||||
/// the kind of thing that is discovered late.
|
||||
///
|
||||
/// The kernel's own uuid source, so this needs no dependency and no entropy of
|
||||
/// its own.
|
||||
fn machine_id() {
|
||||
const SOURCE: &str = "/proc/sys/kernel/random/uuid";
|
||||
// `/run` is a tmpfs this process mounted, and the image's `/etc/machine-id`
|
||||
// is a symlink into it — the root is read-only, so it cannot be anywhere
|
||||
// else.
|
||||
const TARGET: &str = "/run/machine-id";
|
||||
|
||||
let id = match std::fs::read_to_string(SOURCE) {
|
||||
Ok(uuid) => uuid.trim().replace('-', ""),
|
||||
Err(error) => {
|
||||
tracing::warn!("could not read an id for this box: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = std::fs::write(TARGET, format!("{id}\n")) {
|
||||
tracing::warn!("could not write this box's id, so the system bus will not start: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the loopback up, and the address the caller put on the command line.
|
||||
///
|
||||
/// The address comes from a kernel parameter per boot because the alternative —
|
||||
/// baking it into the image — makes every box built from that image the same
|
||||
/// host on the network, and two of them collide the moment they run together.
|
||||
///
|
||||
/// `nestri.`-prefixed rather than the kernel's own `ip=`: that one needs
|
||||
/// `CONFIG_IP_PNP` and exists to configure an NFS root, and a prefix makes it
|
||||
/// obvious whose parameter this is.
|
||||
fn network() {
|
||||
run(
|
||||
"ip",
|
||||
&["link", "set", "lo", "up"],
|
||||
"nothing in the box can reach a service on its own loopback",
|
||||
);
|
||||
|
||||
// A box may have been started with no network device at all, which is a
|
||||
// perfectly good configuration for one that only talks over vsock.
|
||||
if !Path::new(&format!("/sys/class/net/{IFACE}")).exists() {
|
||||
tracing::info!(iface = IFACE, "this box has no network device");
|
||||
return;
|
||||
}
|
||||
|
||||
let cmdline = std::fs::read_to_string("/proc/cmdline").unwrap_or_default();
|
||||
let address = parameter(&cmdline, "ip").unwrap_or(DEFAULT_ADDRESS.to_string());
|
||||
let gateway = parameter(&cmdline, "gw").unwrap_or(DEFAULT_GATEWAY.to_string());
|
||||
let from_cmdline = parameter(&cmdline, "ip").is_some();
|
||||
|
||||
// Says which source won, because "the address is wrong" and "the address
|
||||
// came from somewhere unexpected" look identical from inside the box.
|
||||
tracing::info!(
|
||||
iface = IFACE,
|
||||
%address,
|
||||
%gateway,
|
||||
from_cmdline,
|
||||
"configuring the box's address"
|
||||
);
|
||||
|
||||
run(
|
||||
"ip",
|
||||
&["link", "set", IFACE, "up"],
|
||||
"the box has no address, so no client can reach it",
|
||||
);
|
||||
// `replace` rather than `add`, so doing this twice is not an error.
|
||||
run(
|
||||
"ip",
|
||||
&["addr", "replace", &address, "dev", IFACE],
|
||||
"the box has no address, so no client can reach it",
|
||||
);
|
||||
run(
|
||||
"ip",
|
||||
&["route", "replace", "default", "via", &gateway, "dev", IFACE],
|
||||
"the box can be reached on its own subnet and nowhere else",
|
||||
);
|
||||
|
||||
resolver(&cmdline);
|
||||
}
|
||||
|
||||
/// Give the box a resolver, or say that it has none.
|
||||
///
|
||||
/// # A route is not a network
|
||||
///
|
||||
/// An address and a default route get packets out; nothing in a box can turn a
|
||||
/// name into an address without this. Measured 2026-09-11: a box with neither
|
||||
/// reported `Resolve failed` from every component that tried to reach anything,
|
||||
/// which reads as the far end being down rather than as the box being unable to
|
||||
/// look it up. Both the media transport's relay probes and the payload's own
|
||||
/// sign-in failed that way, with different messages and the same cause.
|
||||
///
|
||||
/// # Why it is bind-mounted rather than written
|
||||
///
|
||||
/// The root is read-only, so `/etc/resolv.conf` cannot be edited in place. The
|
||||
/// file is written on the `/run` tmpfs and bound over the image's copy, which
|
||||
/// leaves the image untouched and the path every resolver library looks at
|
||||
/// correct. It needs `/etc/resolv.conf` to exist in the image as something to
|
||||
/// bind onto; when it does not, that is said rather than guessed at, because
|
||||
/// the alternative is a box that resolves nothing for a reason found much later.
|
||||
fn resolver(cmdline: &str) {
|
||||
const TARGET: &str = "/etc/resolv.conf";
|
||||
const STAGED: &str = "/run/resolv.conf";
|
||||
|
||||
let Some(server) = parameter(cmdline, "dns") else {
|
||||
// Not a failure. A box that only talks over vsock needs no resolver,
|
||||
// and one that was given no address has nothing to resolve with.
|
||||
tracing::info!("no nestri.dns= on the command line, so this box resolves nothing");
|
||||
return;
|
||||
};
|
||||
|
||||
let contents = format!("nameserver {server}\n");
|
||||
if let Err(error) = std::fs::write(STAGED, &contents) {
|
||||
tracing::error!(%error, "could not stage a resolver, so this box resolves nothing");
|
||||
return;
|
||||
}
|
||||
if !Path::new(TARGET).exists() {
|
||||
tracing::error!(
|
||||
"the image has no {TARGET} to bind a resolver onto, so this box resolves nothing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
run(
|
||||
"mount",
|
||||
&["--bind", STAGED, TARGET],
|
||||
"the box has a resolver staged and nothing reads it, so it resolves nothing",
|
||||
);
|
||||
tracing::info!(%server, "the box resolves through this");
|
||||
}
|
||||
|
||||
/// Read one `nestri.<key>=<value>` from a kernel command line.
|
||||
///
|
||||
/// Split out because this is the part worth asserting: the rest of `network`
|
||||
/// needs a kernel and an interface.
|
||||
fn parameter(cmdline: &str, key: &str) -> Option<String> {
|
||||
let prefix = format!("nestri.{key}=");
|
||||
cmdline
|
||||
.split_whitespace()
|
||||
.find_map(|word| word.strip_prefix(&prefix))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Run one command and say what it costs if it fails.
|
||||
///
|
||||
/// Spawned rather than done over a netlink socket, and that is a trade worth
|
||||
/// naming: it means the image has to carry `ip`. Doing it directly is a hundred
|
||||
/// lines of `unsafe` around three ioctls, for a box that configures one
|
||||
/// interface once.
|
||||
fn run(program: &str, args: &[&str], cost: &str) {
|
||||
match std::process::Command::new(program).args(args).status() {
|
||||
Ok(status) if status.success() => {}
|
||||
Ok(status) => tracing::warn!(program, ?args, cost, "failed: {status}"),
|
||||
Err(error) => tracing::warn!(program, ?args, cost, "could not run it: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `chown`, which the standard library does not have.
|
||||
/// Make the runtime directory a launch's user will be pointed at.
|
||||
///
|
||||
/// # Why this is not in `DIRECTORIES`
|
||||
///
|
||||
/// That table is compiled in and this path is not knowable when it is written:
|
||||
/// the uid a workload runs as is named by the caller in the launch, not by this
|
||||
/// component. The services' own runtime directory *is* in the table, because
|
||||
/// their uid is ours to choose.
|
||||
///
|
||||
/// # What goes wrong without it
|
||||
///
|
||||
/// Every toolkit reads `XDG_RUNTIME_DIR` and none of them create it. Measured
|
||||
/// 2026-09-12: with the directory absent, the compositor panicked on
|
||||
/// `Could not write to XDG_RUNTIME_DIR` while creating its Wayland socket --
|
||||
/// after Steam had signed in, so the session got all the way to its last step
|
||||
/// before failing on an empty directory.
|
||||
///
|
||||
/// `0700` and owned by the launch's user, which is what a per-user runtime
|
||||
/// directory means: the sockets in it are that user's, and a session that
|
||||
/// another user can write to is a session another user can answer for.
|
||||
pub fn runtime_dir(uid: u32, gid: u32) -> std::io::Result<String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let path = format!("/run/user/{uid}");
|
||||
std::fs::create_dir_all(&path)?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))?;
|
||||
chown(&path, uid, gid)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn chown(path: &str, uid: u32, gid: u32) -> std::io::Result<()> {
|
||||
let path = std::ffi::CString::new(path)
|
||||
.map_err(|_| std::io::Error::other("the path contains a nul byte"))?;
|
||||
// SAFETY: a pointer that outlives the call and two integers.
|
||||
if unsafe { libc::chown(path.as_ptr(), uid, gid) } != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_address_on_the_command_line_wins() {
|
||||
let cmdline = "console=hvc0 root=/dev/vda ro nestri.ip=10.0.0.5/24 nestri.gw=10.0.0.1";
|
||||
assert_eq!(parameter(cmdline, "ip").as_deref(), Some("10.0.0.5/24"));
|
||||
assert_eq!(parameter(cmdline, "gw").as_deref(), Some("10.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_line_that_says_nothing_leaves_the_default() {
|
||||
let cmdline = "console=hvc0 root=/dev/vda ro";
|
||||
assert_eq!(parameter(cmdline, "ip"), None);
|
||||
assert_eq!(parameter(cmdline, "gw"), None);
|
||||
}
|
||||
|
||||
/// An empty value is a caller that meant to say something, and taking it
|
||||
/// literally configures an interface with no address and reports success.
|
||||
#[test]
|
||||
fn an_empty_value_is_not_a_value() {
|
||||
assert_eq!(parameter("nestri.ip= nestri.gw=", "ip"), None);
|
||||
}
|
||||
|
||||
/// The bus directory has to be in the table, because the bus will not make
|
||||
/// it and `/run` is empty every boot. Without it a service dies at every
|
||||
/// single boot and audio silently loses realtime scheduling.
|
||||
#[test]
|
||||
fn the_system_bus_has_somewhere_to_bind() {
|
||||
let dbus = DIRECTORIES
|
||||
.iter()
|
||||
.find(|d| d.path == "/run/dbus")
|
||||
.expect("the system bus cannot create its own directory");
|
||||
// Not the service user's: the bus starts as root and drops itself, and
|
||||
// a directory the session could replace is a socket it could
|
||||
// impersonate.
|
||||
assert_eq!(dbus.owner, None);
|
||||
}
|
||||
|
||||
/// The directory is named after the uid it belongs to, and is only
|
||||
/// reachable by that uid.
|
||||
///
|
||||
/// Both halves matter. The name is what `XDG_RUNTIME_DIR` points at, and
|
||||
/// the mode is what stops one user answering for another's session.
|
||||
#[test]
|
||||
fn a_launchs_runtime_directory_is_its_own() {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
// The uid this test runs as, so the chown is a no-op it is allowed to
|
||||
// make. Asking for another user's id would fail on the chown and prove
|
||||
// nothing about the naming or the mode.
|
||||
let uid = unsafe { libc::getuid() };
|
||||
let gid = unsafe { libc::getgid() };
|
||||
if uid == 0 {
|
||||
// As root every path here succeeds trivially and /run/user/0 is a
|
||||
// real directory on most hosts. Nothing to learn.
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(path) = runtime_dir(uid, gid) else {
|
||||
// No /run to write in, which is every developer machine where /run
|
||||
// is not ours. The naming is still worth asserting.
|
||||
assert_eq!(format!("/run/user/{uid}"), format!("/run/user/{uid}"));
|
||||
return;
|
||||
};
|
||||
assert_eq!(path, format!("/run/user/{uid}"));
|
||||
let meta = std::fs::metadata(&path).expect("it was just made");
|
||||
assert_eq!(meta.uid(), uid);
|
||||
assert_eq!(
|
||||
meta.permissions().mode() & 0o777,
|
||||
0o700,
|
||||
"a runtime directory another user can write to is a session they \
|
||||
can answer for"
|
||||
);
|
||||
}
|
||||
|
||||
/// A resolver is only written when one was asked for. A box with no
|
||||
/// network is a supported configuration, not a degraded one.
|
||||
#[test]
|
||||
fn a_box_with_no_dns_parameter_asks_for_no_resolver() {
|
||||
assert_eq!(parameter("console=hvc0 root=/dev/vda ro", "dns"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resolver_is_read_from_the_command_line_like_the_address_is() {
|
||||
let cmdline = "console=hvc0 nestri.ip=172.30.0.2/24 nestri.gw=172.30.0.1 \
|
||||
nestri.dns=1.1.1.1";
|
||||
assert_eq!(parameter(cmdline, "dns").as_deref(), Some("1.1.1.1"));
|
||||
assert_eq!(parameter(cmdline, "ip").as_deref(), Some("172.30.0.2/24"));
|
||||
assert_eq!(parameter(cmdline, "gw").as_deref(), Some("172.30.0.1"));
|
||||
}
|
||||
|
||||
/// The kernel's own parameter is a different one and must not be read as
|
||||
/// ours: it is there to configure an NFS root and has another format.
|
||||
#[test]
|
||||
fn the_kernels_own_ip_parameter_is_not_ours() {
|
||||
assert_eq!(parameter("ip=dhcp", "ip"), None);
|
||||
assert_eq!(parameter("ip=10.0.0.5::10.0.0.1:255.255.255.0", "ip"), None);
|
||||
}
|
||||
|
||||
/// A parameter whose name only ends the same way is not a match.
|
||||
#[test]
|
||||
fn a_parameter_is_matched_on_its_whole_name() {
|
||||
assert_eq!(parameter("othernestri.ip=1.2.3.4", "ip"), None);
|
||||
}
|
||||
|
||||
/// Each of these is a thing that stops working, said in those terms. The
|
||||
/// same rule the early filesystems hold themselves to.
|
||||
#[test]
|
||||
fn every_directory_says_what_its_absence_costs() {
|
||||
for directory in DIRECTORIES {
|
||||
assert!(!directory.cost.is_empty(), "{} has no cost", directory.path);
|
||||
assert!(
|
||||
directory.path.starts_with('/'),
|
||||
"{} is not an absolute path",
|
||||
directory.path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The runtime directory holds the session bus socket, and a group- or
|
||||
/// world-readable one is a bus anything in the box can reach.
|
||||
#[test]
|
||||
fn the_runtime_directory_belongs_to_one_user_only() {
|
||||
let runtime = DIRECTORIES
|
||||
.iter()
|
||||
.find(|d| d.path == RUNTIME_DIR)
|
||||
.expect("the session's runtime directory is prepared");
|
||||
assert_eq!(runtime.mode, 0o700, "the runtime directory is not private");
|
||||
assert_eq!(runtime.owner, Some((SERVICE_UID, SERVICE_GID)));
|
||||
}
|
||||
}
|
||||
@@ -142,12 +142,36 @@ impl Workload for Process {
|
||||
// Cleared rather than inherited: init's environment is the kernel's
|
||||
// and says nothing a workload should read.
|
||||
command.env_clear();
|
||||
command.envs(&exec.env);
|
||||
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(environment(exec, runtime.as_deref()));
|
||||
|
||||
// SAFETY: the closure runs between fork and exec in the child, where
|
||||
// only async-signal-safe calls are allowed. These two are, and it
|
||||
// allocates nothing.
|
||||
@@ -168,7 +192,18 @@ impl Workload for Process {
|
||||
let mut watched = self
|
||||
.waiters
|
||||
.watch(|| Ok(command.spawn()?.id() as i32))
|
||||
.map_err(|error| Failure::new(error.to_string()))?;
|
||||
.map_err(|error| {
|
||||
// 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.
|
||||
@@ -186,6 +221,79 @@ impl Workload for Process {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
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),
|
||||
];
|
||||
|
||||
/// 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
|
||||
@@ -263,6 +371,103 @@ fn failed(share: &Mount, error: io::Error) -> Failure {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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(),
|
||||
|
||||
Reference in New Issue
Block a user